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.
Files changed (40) hide show
  1. {agenthub_python-0.4.8 → agenthub_python-0.4.10}/PKG-INFO +1 -1
  2. {agenthub_python-0.4.8 → agenthub_python-0.4.10}/agenthub/ant_messages/client.py +9 -2
  3. {agenthub_python-0.4.8 → agenthub_python-0.4.10}/agenthub/auto_client.py +19 -7
  4. {agenthub_python-0.4.8 → agenthub_python-0.4.10}/agenthub/claude5/client.py +8 -2
  5. {agenthub_python-0.4.8 → agenthub_python-0.4.10}/agenthub/deepseek_v4/client.py +8 -1
  6. {agenthub_python-0.4.8/agenthub/gemini3_7 → agenthub_python-0.4.10/agenthub/gemini3_8}/__init__.py +2 -2
  7. {agenthub_python-0.4.8/agenthub/gemini3_7 → agenthub_python-0.4.10/agenthub/gemini3_8}/client.py +18 -20
  8. {agenthub_python-0.4.8 → agenthub_python-0.4.10}/agenthub/glm5_3/client.py +14 -14
  9. {agenthub_python-0.4.8 → agenthub_python-0.4.10}/agenthub/gpt5_6/client.py +17 -5
  10. {agenthub_python-0.4.8 → agenthub_python-0.4.10}/agenthub/openai_chat/client.py +13 -5
  11. agenthub_python-0.4.10/agenthub/openai_chat_vllm_adapter/__init__.py +4 -0
  12. agenthub_python-0.4.10/agenthub/openai_chat_vllm_adapter/client.py +122 -0
  13. {agenthub_python-0.4.8 → agenthub_python-0.4.10}/agenthub/openai_responses/client.py +16 -3
  14. {agenthub_python-0.4.8 → agenthub_python-0.4.10}/agenthub/registry.py +22 -8
  15. agenthub_python-0.4.10/agenthub/utils.py +243 -0
  16. {agenthub_python-0.4.8 → agenthub_python-0.4.10}/pyproject.toml +1 -1
  17. {agenthub_python-0.4.8 → agenthub_python-0.4.10}/pyproject.toml.orig +1 -1
  18. agenthub_python-0.4.8/agenthub/utils.py +0 -51
  19. {agenthub_python-0.4.8 → agenthub_python-0.4.10}/README.md +0 -0
  20. {agenthub_python-0.4.8 → agenthub_python-0.4.10}/agenthub/__init__.py +0 -0
  21. {agenthub_python-0.4.8 → agenthub_python-0.4.10}/agenthub/abort_signal.py +0 -0
  22. {agenthub_python-0.4.8 → agenthub_python-0.4.10}/agenthub/ant_messages/__init__.py +0 -0
  23. {agenthub_python-0.4.8 → agenthub_python-0.4.10}/agenthub/base_client.py +0 -0
  24. {agenthub_python-0.4.8 → agenthub_python-0.4.10}/agenthub/claude5/__init__.py +0 -0
  25. {agenthub_python-0.4.8 → agenthub_python-0.4.10}/agenthub/deepseek_v4/__init__.py +0 -0
  26. {agenthub_python-0.4.8 → agenthub_python-0.4.10}/agenthub/errors.py +0 -0
  27. {agenthub_python-0.4.8 → agenthub_python-0.4.10}/agenthub/glm5_3/__init__.py +0 -0
  28. {agenthub_python-0.4.8 → agenthub_python-0.4.10}/agenthub/gpt5_6/__init__.py +0 -0
  29. {agenthub_python-0.4.8 → agenthub_python-0.4.10}/agenthub/integration/__init__.py +0 -0
  30. {agenthub_python-0.4.8 → agenthub_python-0.4.10}/agenthub/integration/playground.py +0 -0
  31. {agenthub_python-0.4.8 → agenthub_python-0.4.10}/agenthub/integration/tracer.py +0 -0
  32. {agenthub_python-0.4.8 → agenthub_python-0.4.10}/agenthub/kimi_k3/__init__.py +0 -0
  33. {agenthub_python-0.4.8 → agenthub_python-0.4.10}/agenthub/kimi_k3/client.py +0 -0
  34. {agenthub_python-0.4.8 → agenthub_python-0.4.10}/agenthub/minimax_m3/__init__.py +0 -0
  35. {agenthub_python-0.4.8 → agenthub_python-0.4.10}/agenthub/minimax_m3/client.py +0 -0
  36. {agenthub_python-0.4.8 → agenthub_python-0.4.10}/agenthub/openai_chat/__init__.py +0 -0
  37. {agenthub_python-0.4.8 → agenthub_python-0.4.10}/agenthub/openai_embedding/__init__.py +0 -0
  38. {agenthub_python-0.4.8 → agenthub_python-0.4.10}/agenthub/openai_embedding/client.py +0 -0
  39. {agenthub_python-0.4.8 → agenthub_python-0.4.10}/agenthub/openai_responses/__init__.py +0 -0
  40. {agenthub_python-0.4.8 → agenthub_python-0.4.10}/agenthub/types.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agenthub-python
3
- Version: 0.4.8
3
+ Version: 0.4.10
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
@@ -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
- if config.get("thinking_summary") and ant_config.get("thinking", {}).get("type") == "adaptive":
132
- ant_config["thinking"]["display"] = "summarized"
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 = ("openai-chat", "openai-responses", "ant-messages", "openai-embedding")
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.7-flash, gemini-3-flash-preview, gemini-embedding-2
72
- from .gemini3_7 import Gemini3_7Client
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 Gemini3_7Client
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
- if config.get("thinking_summary") and claude_config.get("thinking") is not None:
180
- claude_config["thinking"]["display"] = "summarized"
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
 
@@ -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 Gemini3_7Client
15
+ from .client import Gemini3_8Client
16
16
 
17
17
 
18
- __all__ = ["Gemini3_7Client"]
18
+ __all__ = ["Gemini3_8Client"]
@@ -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 Gemini3_7Client(LLMClient):
65
- """Unified client for the Gemini family, named for the newest generation it serves (3.7).
66
-
67
- It serves every generateContent model generation (3.7 back through 3.x text, image, TTS,
68
- and embedding models, with the 2.5 series reachable via an explicit client_type). The API
69
- deprecated the temperature/top_p/top_k sampling parameters starting with the 3.6
70
- generation (silently ignored today, HTTP 400 in future generations), and this client
71
- applies that contract to the whole family: temperature is rejected everywhere.
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.7 client with model and API key."""
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/gemini3_7/docs/thinking.md).
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
- # The 3.7 generation rejects "minimal" with a 400 (verified live 2026-08-13).
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
- # The model takes no thinking_level at all; drop the parameter and
184
- # let the model use its default instead of forwarding a 400.
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/gemini3_7/docs/thinking.md)
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
- # gemini 3.7 does not support partial tool call, mock a partial tool call event
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. GLM-5.2 accepts the full vocabulary and maps it server-side
79
- (low/medium to high, xhigh to max); NONE disables thinking there instead.
80
- Models before 5.2 take no reasoning_effort parameter at all.
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
- if "glm-5.2" in model:
94
- mapping = {
95
- ThinkingLevel.NONE: None,
96
- ThinkingLevel.LOW: "low",
97
- ThinkingLevel.MEDIUM: "medium",
98
- ThinkingLevel.HIGH: "high",
99
- ThinkingLevel.XHIGH: "xhigh",
100
- ThinkingLevel.MAX: "max",
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
- if config.get("thinking_summary"):
102
- openai_config["reasoning"]["summary"] = "concise"
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({"type": "input_image", "image_url": item["image_url"]})
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({"type": "input_image", "image_url": image_url})
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({"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(
@@ -0,0 +1,4 @@
1
+ from .client import OpenaiChatVllmAdapterClient
2
+
3
+
4
+ __all__ = ["OpenaiChatVllmAdapterClient"]
@@ -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({"type": "input_image", "image_url": item["image_url"]})
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({"type": "input_image", "image_url": image_url})
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.7",
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.7",
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.7",
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.7",
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.7",
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.7",
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.7",
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,6 +1,6 @@
1
1
  [project]
2
2
  name = "agenthub-python"
3
- version = "0.4.8"
3
+ version = "0.4.10"
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.8"
3
+ version = "0.4.10"
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")