@mengruo/dsh-vision-toolkit 0.0.1

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 (216) hide show
  1. package/LICENSE +21 -0
  2. package/README.i18n.yaml +6 -0
  3. package/README.md +356 -0
  4. package/README.zh.md +354 -0
  5. package/assets/community-group-qr.png +0 -0
  6. package/assets/dsh-conversation-artifact.png +0 -0
  7. package/assets/dsh-conversation-image-qa-top.png +0 -0
  8. package/assets/dsh-conversation-image-qa.png +0 -0
  9. package/assets/dsh-conversation-pixel-diff.png +0 -0
  10. package/assets/dsh-conversation-screenshot-debugging-top.png +0 -0
  11. package/assets/dsh-conversation-screenshot-debugging.png +0 -0
  12. package/assets/dsh-conversation-tool-call.png +0 -0
  13. package/assets/dsh-conversation-vision-trace.png +0 -0
  14. package/assets/dsh-view-example.png +0 -0
  15. package/assets/hero-v2.png +0 -0
  16. package/assets/logo_aihubmix.png +0 -0
  17. package/assets/python-bootstrap.json +48 -0
  18. package/assets/skill/SKILL.md +329 -0
  19. package/assets/skill/UPSTREAM.json +71 -0
  20. package/assets/skill/references/gui.md +88 -0
  21. package/assets/skill/references/long-screenshot-ocr.md +77 -0
  22. package/assets/skill/references/restore-graphic.md +84 -0
  23. package/assets/skill/references/restore-structure.md +45 -0
  24. package/assets/skill/references/restore-ui.md +202 -0
  25. package/assets/social-preview.png +0 -0
  26. package/assets/upstream/README.md +18 -0
  27. package/assets/upstream/focus-hint-comparison-1.webp +0 -0
  28. package/assets/upstream/focus-hint-comparison-2.webp +0 -0
  29. package/assets/upstream/infographic-reference.webp +0 -0
  30. package/assets/upstream/infographic-result.webp +0 -0
  31. package/assets/upstream/ui-fast-restore-reference.webp +0 -0
  32. package/assets/upstream/ui-fast-restore-result.webp +0 -0
  33. package/assets/upstream/ui-result.webp +0 -0
  34. package/assets/upstream/ui-sketch.webp +0 -0
  35. package/assets/vision-model-test.png +0 -0
  36. package/assets/vision-settings.png +0 -0
  37. package/assets/wechat-reward.png +0 -0
  38. package/cordis.patch.yml +6 -0
  39. package/docs/aihubmix-gemini-vision.i18n.yaml +6 -0
  40. package/docs/aihubmix-gemini-vision.md +138 -0
  41. package/docs/aihubmix-gemini-vision.zh.md +138 -0
  42. package/docs/assets/aihubmix-api-keys.png +0 -0
  43. package/docs/assets/aihubmix-create-key.png +0 -0
  44. package/docs/assets/aihubmix-free-vision-model.png +0 -0
  45. package/docs/assets/aihubmix-home.png +0 -0
  46. package/docs/assets/aihubmix-sign-up.png +0 -0
  47. package/docs/assets/groq-console-home.png +0 -0
  48. package/docs/assets/groq-console-keys-login.png +0 -0
  49. package/docs/assets/groq-docs-quickstart.png +0 -0
  50. package/docs/assets/groq-docs-qwen3.6.png +0 -0
  51. package/docs/assets/groq-docs-vision.png +0 -0
  52. package/docs/assets/vision-settings.png +0 -0
  53. package/docs/dsh-desktop-install.md +67 -0
  54. package/docs/dsh-desktop-install.zh.md +67 -0
  55. package/docs/groq-qwen3.6-vision.i18n.yaml +6 -0
  56. package/docs/groq-qwen3.6-vision.md +236 -0
  57. package/docs/groq-qwen3.6-vision.zh.md +236 -0
  58. package/docs/python-runtime.i18n.yaml +6 -0
  59. package/docs/python-runtime.md +89 -0
  60. package/docs/python-runtime.zh.md +89 -0
  61. package/docs/requirements-traceability/README.i18n.yaml +6 -0
  62. package/docs/requirements-traceability/README.md +75 -0
  63. package/docs/requirements-traceability/README.zh.md +75 -0
  64. package/examples/ui-restoration/README.i18n.yaml +6 -0
  65. package/examples/ui-restoration/README.md +70 -0
  66. package/examples/ui-restoration/README.zh.md +70 -0
  67. package/examples/ui-restoration/assets/final-heatmap.png +0 -0
  68. package/examples/ui-restoration/assets/final-report.json +83 -0
  69. package/examples/ui-restoration/assets/implementation.png +0 -0
  70. package/examples/ui-restoration/assets/initial-heatmap.png +0 -0
  71. package/examples/ui-restoration/assets/initial-report.json +83 -0
  72. package/examples/ui-restoration/assets/initial.png +0 -0
  73. package/examples/ui-restoration/assets/metrics.json +12 -0
  74. package/examples/ui-restoration/assets/reference.png +0 -0
  75. package/examples/ui-restoration/implementation.html +94 -0
  76. package/examples/ui-restoration/initial.html +57 -0
  77. package/lib/artifact-access.js +369 -0
  78. package/lib/artifact-access.js.map +1 -0
  79. package/lib/artifacts.js +56 -0
  80. package/lib/artifacts.js.map +1 -0
  81. package/lib/client.js +2099 -0
  82. package/lib/client.js.map +1 -0
  83. package/lib/config.js +172 -0
  84. package/lib/config.js.map +1 -0
  85. package/lib/defaults.js +6 -0
  86. package/lib/defaults.js.map +1 -0
  87. package/lib/errors.js +56 -0
  88. package/lib/errors.js.map +1 -0
  89. package/lib/evidence-cache.js +350 -0
  90. package/lib/evidence-cache.js.map +1 -0
  91. package/lib/exposure.js +247 -0
  92. package/lib/exposure.js.map +1 -0
  93. package/lib/image-input-variants.js +882 -0
  94. package/lib/image-input-variants.js.map +1 -0
  95. package/lib/index.js +105 -0
  96. package/lib/index.js.map +1 -0
  97. package/lib/paste-images.js +217 -0
  98. package/lib/paste-images.js.map +1 -0
  99. package/lib/paths.js +354 -0
  100. package/lib/paths.js.map +1 -0
  101. package/lib/plugin-update.js +1003 -0
  102. package/lib/plugin-update.js.map +1 -0
  103. package/lib/runtime-install.js +964 -0
  104. package/lib/runtime-install.js.map +1 -0
  105. package/lib/runtime-manager.js +133 -0
  106. package/lib/runtime-manager.js.map +1 -0
  107. package/lib/runtime.js +1678 -0
  108. package/lib/runtime.js.map +1 -0
  109. package/lib/skill.js +25 -0
  110. package/lib/skill.js.map +1 -0
  111. package/lib/tools.js +549 -0
  112. package/lib/tools.js.map +1 -0
  113. package/lib/types/artifact-access.d.ts +61 -0
  114. package/lib/types/artifact-access.d.ts.map +1 -0
  115. package/lib/types/artifacts.d.ts +42 -0
  116. package/lib/types/artifacts.d.ts.map +1 -0
  117. package/lib/types/client/display-config.d.ts +24 -0
  118. package/lib/types/client/display-config.d.ts.map +1 -0
  119. package/lib/types/client/index.d.ts +350 -0
  120. package/lib/types/client/index.d.ts.map +1 -0
  121. package/lib/types/client/model-variants-hider.d.ts +40 -0
  122. package/lib/types/client/model-variants-hider.d.ts.map +1 -0
  123. package/lib/types/client/paste-images.d.ts +126 -0
  124. package/lib/types/client/paste-images.d.ts.map +1 -0
  125. package/lib/types/config.d.ts +123 -0
  126. package/lib/types/config.d.ts.map +1 -0
  127. package/lib/types/defaults.d.ts +6 -0
  128. package/lib/types/defaults.d.ts.map +1 -0
  129. package/lib/types/errors.d.ts +35 -0
  130. package/lib/types/errors.d.ts.map +1 -0
  131. package/lib/types/evidence-cache.d.ts +91 -0
  132. package/lib/types/evidence-cache.d.ts.map +1 -0
  133. package/lib/types/exposure.d.ts +50 -0
  134. package/lib/types/exposure.d.ts.map +1 -0
  135. package/lib/types/image-input-variants.d.ts +144 -0
  136. package/lib/types/image-input-variants.d.ts.map +1 -0
  137. package/lib/types/index.d.ts +19 -0
  138. package/lib/types/index.d.ts.map +1 -0
  139. package/lib/types/paste-images.d.ts +73 -0
  140. package/lib/types/paste-images.d.ts.map +1 -0
  141. package/lib/types/paths.d.ts +117 -0
  142. package/lib/types/paths.d.ts.map +1 -0
  143. package/lib/types/plugin-update.d.ts +119 -0
  144. package/lib/types/plugin-update.d.ts.map +1 -0
  145. package/lib/types/runtime-install.d.ts +92 -0
  146. package/lib/types/runtime-install.d.ts.map +1 -0
  147. package/lib/types/runtime-manager.d.ts +60 -0
  148. package/lib/types/runtime-manager.d.ts.map +1 -0
  149. package/lib/types/runtime.d.ts +412 -0
  150. package/lib/types/runtime.d.ts.map +1 -0
  151. package/lib/types/skill.d.ts +14 -0
  152. package/lib/types/skill.d.ts.map +1 -0
  153. package/lib/types/tools.d.ts +35 -0
  154. package/lib/types/tools.d.ts.map +1 -0
  155. package/lib/types/upstream.d.ts +233 -0
  156. package/lib/types/upstream.d.ts.map +1 -0
  157. package/lib/types/version.d.ts +15 -0
  158. package/lib/types/version.d.ts.map +1 -0
  159. package/lib/types/web-request.d.ts +11 -0
  160. package/lib/types/web-request.d.ts.map +1 -0
  161. package/lib/types/web.d.ts +118 -0
  162. package/lib/types/web.d.ts.map +1 -0
  163. package/lib/upstream.js +860 -0
  164. package/lib/upstream.js.map +1 -0
  165. package/lib/version.js +18 -0
  166. package/lib/version.js.map +1 -0
  167. package/lib/web-request.js +29 -0
  168. package/lib/web-request.js.map +1 -0
  169. package/lib/web.js +462 -0
  170. package/lib/web.js.map +1 -0
  171. package/package.json +149 -0
  172. package/patches/vision-tools-dsh.patch +970 -0
  173. package/runtime/requirements.lock +3 -0
  174. package/src/artifact-access.ts +386 -0
  175. package/src/artifacts.ts +85 -0
  176. package/src/client/display-config.ts +62 -0
  177. package/src/client/index.tsx +1580 -0
  178. package/src/client/model-variants-hider.ts +159 -0
  179. package/src/client/paste-images.tsx +747 -0
  180. package/src/config.ts +290 -0
  181. package/src/defaults.ts +5 -0
  182. package/src/errors.ts +62 -0
  183. package/src/evidence-cache.ts +418 -0
  184. package/src/exposure.ts +265 -0
  185. package/src/image-input-variants.ts +996 -0
  186. package/src/index.ts +141 -0
  187. package/src/paste-images.ts +286 -0
  188. package/src/paths.ts +383 -0
  189. package/src/plugin-update.ts +1143 -0
  190. package/src/runtime-install.ts +1161 -0
  191. package/src/runtime-manager.ts +174 -0
  192. package/src/runtime.ts +2189 -0
  193. package/src/skill.ts +35 -0
  194. package/src/tools.ts +691 -0
  195. package/src/upstream.ts +1078 -0
  196. package/src/version.ts +37 -0
  197. package/src/web-request.ts +27 -0
  198. package/src/web.ts +594 -0
  199. package/vendor/agent-vision-toolkit/CHANGELOG.md +30 -0
  200. package/vendor/agent-vision-toolkit/LICENSE +21 -0
  201. package/vendor/agent-vision-toolkit/README.md +405 -0
  202. package/vendor/agent-vision-toolkit/UPSTREAM_MANIFEST.json +94 -0
  203. package/vendor/agent-vision-toolkit/bin/crop +90 -0
  204. package/vendor/agent-vision-toolkit/bin/detect +13 -0
  205. package/vendor/agent-vision-toolkit/bin/glance +93 -0
  206. package/vendor/agent-vision-toolkit/bin/ground +13 -0
  207. package/vendor/agent-vision-toolkit/bin/trace +129 -0
  208. package/vendor/agent-vision-toolkit/detect.py +60 -0
  209. package/vendor/agent-vision-toolkit/ground.py +267 -0
  210. package/vendor/agent-vision-toolkit/skills/vision-tools/scripts/dominant_colors.py +224 -0
  211. package/vendor/agent-vision-toolkit/skills/vision-tools/scripts/extract_fg.py +278 -0
  212. package/vendor/agent-vision-toolkit/skills/vision-tools/scripts/html_shot.py +420 -0
  213. package/vendor/agent-vision-toolkit/skills/vision-tools/scripts/long_screenshot_ocr.py +1245 -0
  214. package/vendor/agent-vision-toolkit/skills/vision-tools/scripts/pixel_diff.py +88 -0
  215. package/vendor/agent-vision-toolkit/tests/test_vision_client.py +450 -0
  216. package/vendor/agent-vision-toolkit/vision_client.py +310 -0
@@ -0,0 +1,310 @@
1
+ #!/usr/bin/env python3
2
+ """Shared multi-provider vision client used by the proxy and glance CLI."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import base64
7
+ from email.utils import parsedate_to_datetime
8
+ import http.client
9
+ import json
10
+ import mimetypes
11
+ import os
12
+ from pathlib import Path
13
+ import ssl
14
+ import sys
15
+ import time
16
+ import urllib.error
17
+ import urllib.request
18
+
19
+ DEFAULT_PROMPT = "Please describe the contents of this image in detail."
20
+ DEFAULT_USER_AGENT = (
21
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
22
+ "AppleWebKit/537.36 (KHTML, like Gecko) "
23
+ "Chrome/126.0.0.0 Safari/537.36"
24
+ )
25
+
26
+ LANG_INSTRUCTIONS = {
27
+ "zh": "请使用简体中文回答。",
28
+ "en": "Please respond in English.",
29
+ }
30
+
31
+
32
+ class VisionError(RuntimeError):
33
+ """A safe, user-facing vision request failure."""
34
+
35
+
36
+ def load_env_file(path: str | os.PathLike[str] | None) -> None:
37
+ if not path:
38
+ return
39
+ env_path = Path(path).expanduser()
40
+ if not env_path.is_file():
41
+ return
42
+ for raw_line in env_path.read_text().splitlines():
43
+ line = raw_line.strip()
44
+ if not line or line.startswith("#") or "=" not in line:
45
+ continue
46
+ key, _, value = line.partition("=")
47
+ key = key.strip()
48
+ value = value.strip().strip('"').strip("'")
49
+ # The env file is the user's explicit configuration: whatever it sets wins,
50
+ # even when the same variable already exists in the system environment.
51
+ if key:
52
+ os.environ[key] = value
53
+
54
+
55
+ def load_default_env() -> None:
56
+ explicit = os.environ.get("VISION_ENV_FILE")
57
+ if explicit:
58
+ load_env_file(Path(explicit).expanduser())
59
+ return
60
+ candidates = []
61
+ local_appdata = os.environ.get("LOCALAPPDATA")
62
+ if local_appdata:
63
+ candidates.append(Path(local_appdata) / "agent-vision-toolkit" / "env")
64
+ candidates.extend([
65
+ Path.home() / ".config" / "agent-vision-toolkit" / "env",
66
+ Path(__file__).resolve().parent / ".env",
67
+ Path.cwd() / ".env",
68
+ ])
69
+ for path in candidates:
70
+ load_env_file(path)
71
+
72
+
73
+ def _required(name: str) -> str:
74
+ value = os.environ.get(name, "").strip()
75
+ if not value:
76
+ raise VisionError(f"Missing config {name}; fill it in the .env file")
77
+ return value
78
+
79
+
80
+ def validate_vision_config() -> None:
81
+ for name in ("VISION_API_KEY", "VISION_BASE_URL", "VISION_MODEL"):
82
+ _required(name)
83
+
84
+
85
+ def image_path_to_data_url(path: str | os.PathLike[str]) -> str:
86
+ image_path = Path(path).expanduser()
87
+ if not image_path.is_file():
88
+ raise VisionError(f"Image not found: {image_path}")
89
+ mime, _ = mimetypes.guess_type(image_path.name)
90
+ if mime not in {"image/png", "image/jpeg", "image/gif", "image/webp"}:
91
+ raise VisionError("Only PNG, JPEG, GIF, and WebP images are supported")
92
+ return f"data:{mime};base64,{base64.b64encode(image_path.read_bytes()).decode()}"
93
+
94
+
95
+ def _message_text(message: object) -> str:
96
+ if isinstance(message, str):
97
+ return message.strip()
98
+ if isinstance(message, list):
99
+ return "\n".join(
100
+ part["text"] for part in message
101
+ if isinstance(part, dict) and isinstance(part.get("text"), str)
102
+ ).strip()
103
+ return ""
104
+
105
+
106
+ def _responses_text(response: object) -> str:
107
+ if not isinstance(response, dict) or not isinstance(response.get("output"), list):
108
+ return ""
109
+ return "\n".join(
110
+ part["text"]
111
+ for item in response["output"]
112
+ if isinstance(item, dict) and item.get("type") == "message"
113
+ and isinstance(item.get("content"), list)
114
+ for part in item["content"]
115
+ if isinstance(part, dict) and part.get("type") == "output_text"
116
+ and isinstance(part.get("text"), str)
117
+ ).strip()
118
+
119
+
120
+ def _anthropic_image_source(url: str) -> dict[str, str]:
121
+ if not url.startswith("data:"):
122
+ return {"type": "url", "url": url}
123
+ header, separator, data = url.partition(",")
124
+ if separator == "" or ";base64" not in header:
125
+ raise VisionError("Anthropic image data URLs must use base64 encoding")
126
+ media_type = header[5:].split(";", 1)[0]
127
+ if not media_type:
128
+ raise VisionError("Anthropic image data URLs must include a media type")
129
+ return {"type": "base64", "media_type": media_type, "data": data}
130
+
131
+
132
+ def _anthropic_text(response: object) -> str:
133
+ if not isinstance(response, dict) or not isinstance(response.get("content"), list):
134
+ return ""
135
+ return "\n".join(
136
+ block["text"]
137
+ for block in response["content"]
138
+ if isinstance(block, dict) and block.get("type") == "text"
139
+ and isinstance(block.get("text"), str)
140
+ ).strip()
141
+
142
+
143
+ def _redact(text: str, *secrets: str) -> str:
144
+ for secret in secrets:
145
+ if secret:
146
+ text = text.replace(secret, "<redacted>")
147
+ return text
148
+
149
+
150
+ def _ssl_context() -> ssl.SSLContext | None:
151
+ """Return an unverified context when VISION_SSL_VERIFY disables certificate checks."""
152
+ verify = os.environ.get("VISION_SSL_VERIFY", "").strip().lower()
153
+ if verify in {"0", "false", "off", "no", "none", "disabled"}:
154
+ context = ssl.create_default_context()
155
+ context.check_hostname = False
156
+ context.verify_mode = ssl.CERT_NONE
157
+ return context
158
+ return None
159
+
160
+
161
+ def _retry_delay(error: urllib.error.HTTPError, attempt: int) -> float:
162
+ value = error.headers.get("Retry-After")
163
+ if value:
164
+ try:
165
+ return max(0.0, min(float(value), 60.0))
166
+ except ValueError:
167
+ try:
168
+ retry_at = parsedate_to_datetime(value)
169
+ return max(0.0, min(retry_at.timestamp() - time.time(), 60.0))
170
+ except (TypeError, ValueError, OverflowError):
171
+ pass
172
+ return min(2 ** attempt, 4)
173
+
174
+
175
+ def _api_error_code(body: bytes) -> str:
176
+ try:
177
+ payload = json.loads(body.decode(errors="replace"))
178
+ except (json.JSONDecodeError, UnicodeDecodeError):
179
+ return ""
180
+ if not isinstance(payload, dict):
181
+ return ""
182
+ error = payload.get("error")
183
+ if not isinstance(error, dict):
184
+ return ""
185
+ code = error.get("code")
186
+ return code if isinstance(code, str) else ""
187
+
188
+
189
+ def _retryable_http_error(status: int, body: bytes) -> bool:
190
+ if status not in {429, 500, 502, 503, 504, 529}:
191
+ return False
192
+ return _api_error_code(body) not in {
193
+ "daily_rate_limit_exceeded",
194
+ "global_daily_limit_exceeded",
195
+ "rate_limit_exceeded",
196
+ }
197
+
198
+
199
+ def describe_image(image_url: str | list[str], prompt: str | None = None, max_tokens: int = 4096,
200
+ apply_lang: bool = True) -> str:
201
+ """Describe one data/http image URL (str) or several (list) in a single call."""
202
+ validate_vision_config()
203
+ urls = [image_url] if isinstance(image_url, str) else list(image_url)
204
+ if not urls:
205
+ raise VisionError("No image was provided")
206
+ for url in urls:
207
+ if not url.startswith(("data:", "http://", "https://")):
208
+ raise VisionError("Only data URLs or http(s) image URLs are supported")
209
+ base_url = _required("VISION_BASE_URL").rstrip("/")
210
+ api_key = _required("VISION_API_KEY")
211
+ user_agent = os.environ.get("VISION_USER_AGENT", "").strip() or DEFAULT_USER_AGENT
212
+ text = prompt or DEFAULT_PROMPT
213
+ if apply_lang:
214
+ instruction = LANG_INSTRUCTIONS.get(os.environ.get("LANG", "").strip().lower())
215
+ if instruction:
216
+ text = f"{instruction}\n\n{text}"
217
+ model = _required("VISION_MODEL")
218
+ protocol = os.environ.get("VISION_API_PROTOCOL", "").strip().lower() or "chat_completions"
219
+ if protocol == "responses":
220
+ payload = {
221
+ "model": model,
222
+ "store": False,
223
+ "input": [{"role": "user", "content": [
224
+ {"type": "input_image", "image_url": url} for url in urls
225
+ ] + [{"type": "input_text", "text": text}]}],
226
+ }
227
+ if max_tokens is not None:
228
+ payload["max_output_tokens"] = max_tokens
229
+ reasoning_effort = os.environ.get("VISION_REASONING_EFFORT", "").strip()
230
+ if reasoning_effort:
231
+ payload["reasoning"] = {"effort": reasoning_effort}
232
+ endpoint = "/responses"
233
+ extract_text = _responses_text
234
+ elif protocol == "chat_completions":
235
+ payload = {
236
+ "model": model,
237
+ "messages": [{"role": "user", "content": [
238
+ {"type": "image_url", "image_url": {"url": url}} for url in urls
239
+ ] + [{"type": "text", "text": text}]}],
240
+ }
241
+ if max_tokens is not None:
242
+ payload["max_tokens"] = max_tokens
243
+ endpoint = "/chat/completions"
244
+ extract_text = lambda data: _message_text(data["choices"][0]["message"]["content"])
245
+ elif protocol == "anthropic":
246
+ payload = {
247
+ "model": model,
248
+ "max_tokens": max_tokens if max_tokens is not None else 4096,
249
+ "messages": [{"role": "user", "content": [
250
+ {"type": "image", "source": _anthropic_image_source(url)} for url in urls
251
+ ] + [{"type": "text", "text": text}]}],
252
+ }
253
+ thinking = os.environ.get("VISION_ANTHROPIC_THINKING", "").strip().lower() or "omit"
254
+ if thinking != "omit":
255
+ if thinking not in {"disabled", "adaptive"}:
256
+ raise VisionError(
257
+ "Unsupported VISION_ANTHROPIC_THINKING; use omit, disabled, or adaptive"
258
+ )
259
+ payload["thinking"] = {"type": thinking}
260
+ endpoint = "/messages"
261
+ extract_text = _anthropic_text
262
+ else:
263
+ raise VisionError(
264
+ "Unsupported VISION_API_PROTOCOL; use chat_completions, responses, or anthropic"
265
+ )
266
+ headers = {
267
+ "Content-Type": "application/json",
268
+ "User-Agent": user_agent,
269
+ }
270
+ if protocol == "anthropic":
271
+ headers.update({
272
+ "x-api-key": api_key,
273
+ "anthropic-version": "2023-06-01",
274
+ })
275
+ else:
276
+ headers["Authorization"] = "Bearer " + api_key
277
+ request = urllib.request.Request(base_url + endpoint, data=json.dumps(payload).encode(), headers=headers)
278
+ context = _ssl_context()
279
+ retries = 2
280
+ timeout = 180
281
+ for attempt in range(retries + 1):
282
+ try:
283
+ with urllib.request.urlopen(request, timeout=timeout, context=context) as response:
284
+ data = json.load(response)
285
+ try:
286
+ text = extract_text(data)
287
+ except (KeyError, IndexError, TypeError) as exc:
288
+ raise VisionError("Vision API returned an incompatible response structure") from exc
289
+ if not text:
290
+ raise VisionError("Vision API returned an empty description")
291
+ return text
292
+ except urllib.error.HTTPError as exc:
293
+ raw_body = exc.read()
294
+ body = _redact(raw_body.decode(errors="replace")[:400], api_key)
295
+ body = body.replace("\r", " ").replace("\n", " ")
296
+ if _retryable_http_error(exc.code, raw_body) and attempt < retries:
297
+ print(f"vision: HTTP {exc.code}, retrying ({attempt + 1}/{retries})", file=sys.stderr)
298
+ time.sleep(_retry_delay(exc, attempt))
299
+ continue
300
+ raise VisionError(f"Vision API HTTP {exc.code}: {body}") from exc
301
+ except (urllib.error.URLError, TimeoutError, ConnectionError, http.client.IncompleteRead) as exc:
302
+ if attempt < retries:
303
+ print(f"vision: {type(exc).__name__}, retrying ({attempt + 1}/{retries})", file=sys.stderr)
304
+ time.sleep(min(2 ** attempt, 4))
305
+ continue
306
+ reason = _redact(str(getattr(exc, "reason", str(exc))), api_key)
307
+ raise VisionError(f"Vision API network error: {reason}") from exc
308
+ except json.JSONDecodeError as exc:
309
+ raise VisionError("Vision API returned invalid JSON") from exc
310
+ raise VisionError("Vision API request failed")