@anionex/dsh-vision-toolkit 0.1.5 → 0.1.6

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.
@@ -0,0 +1,396 @@
1
+ #!/usr/bin/env python3
2
+ """Core retry/error test for the shared vision client and glance CLI."""
3
+
4
+ from http.server import BaseHTTPRequestHandler, HTTPServer
5
+ import json
6
+ import os
7
+ from pathlib import Path
8
+ import subprocess
9
+ import sys
10
+ import tempfile
11
+ import threading
12
+ import urllib.error
13
+
14
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
15
+
16
+ import vision_client
17
+
18
+
19
+ class Handler(BaseHTTPRequestHandler):
20
+ statuses = []
21
+ bodies = []
22
+ response_headers = []
23
+ calls = 0
24
+ last_body = b""
25
+ last_headers = {}
26
+ last_path = ""
27
+
28
+ def do_POST(self):
29
+ Handler.calls += 1
30
+ Handler.last_headers = dict(self.headers)
31
+ Handler.last_path = self.path
32
+ length = int(self.headers.get("Content-Length", 0))
33
+ Handler.last_body = self.rfile.read(length)
34
+ status = Handler.statuses.pop(0)
35
+ if Handler.bodies:
36
+ body = Handler.bodies.pop(0)
37
+ elif status == 200:
38
+ body = json.dumps({"choices": [{"message": {"content": "fixture answer"}}]}).encode()
39
+ else:
40
+ body = b'{"error":{"message":"fixture error"}}'
41
+ self.send_response(status)
42
+ if Handler.response_headers:
43
+ for name, value in Handler.response_headers.pop(0).items():
44
+ self.send_header(name, value)
45
+ self.send_header("Content-Length", str(len(body)))
46
+ self.end_headers()
47
+ self.wfile.write(body)
48
+
49
+ def log_message(self, *_args):
50
+ pass
51
+
52
+
53
+ def main():
54
+ with tempfile.TemporaryDirectory() as raw:
55
+ root = Path(raw)
56
+ explicit_env = root / "explicit.env"
57
+ explicit_env.write_text("ENV_PRIORITY_PROBE=explicit\n")
58
+ windows_env = root / "local-app-data" / "agent-vision-toolkit" / "env"
59
+ windows_env.parent.mkdir(parents=True)
60
+ windows_env.write_text("ENV_PRIORITY_PROBE=local-app-data\n")
61
+ cwd = root / "cwd"
62
+ cwd.mkdir()
63
+ (cwd / ".env").write_text("ENV_PRIORITY_PROBE=cwd\n")
64
+ previous_cwd = Path.cwd()
65
+ previous_home = os.environ.get("HOME")
66
+ previous_local_appdata = os.environ.get("LOCALAPPDATA")
67
+ previous_explicit = os.environ.get("VISION_ENV_FILE")
68
+ previous_probe = os.environ.get("ENV_PRIORITY_PROBE")
69
+ os.environ["HOME"] = raw
70
+ os.environ["LOCALAPPDATA"] = str(root / "local-app-data")
71
+ os.environ["VISION_ENV_FILE"] = str(explicit_env)
72
+ os.environ.pop("ENV_PRIORITY_PROBE", None)
73
+ os.chdir(cwd)
74
+ try:
75
+ vision_client.load_default_env()
76
+ assert os.environ.get("ENV_PRIORITY_PROBE") == "explicit"
77
+ finally:
78
+ os.chdir(previous_cwd)
79
+ for name, value in (
80
+ ("HOME", previous_home),
81
+ ("LOCALAPPDATA", previous_local_appdata),
82
+ ("VISION_ENV_FILE", previous_explicit),
83
+ ("ENV_PRIORITY_PROBE", previous_probe),
84
+ ):
85
+ if value is None:
86
+ os.environ.pop(name, None)
87
+ else:
88
+ os.environ[name] = value
89
+
90
+ with tempfile.TemporaryDirectory() as raw:
91
+ windows_env = Path(raw) / "agent-vision-toolkit" / "env"
92
+ windows_env.parent.mkdir()
93
+ windows_env.write_text("WINDOWS_ENV_PROBE=loaded\n")
94
+ previous_local_appdata = os.environ.get("LOCALAPPDATA")
95
+ previous_explicit = os.environ.get("VISION_ENV_FILE")
96
+ os.environ["LOCALAPPDATA"] = raw
97
+ os.environ.pop("VISION_ENV_FILE", None)
98
+ os.environ.pop("WINDOWS_ENV_PROBE", None)
99
+ try:
100
+ vision_client.load_default_env()
101
+ assert os.environ.get("WINDOWS_ENV_PROBE") == "loaded"
102
+ finally:
103
+ os.environ.pop("WINDOWS_ENV_PROBE", None)
104
+ if previous_explicit is None:
105
+ os.environ.pop("VISION_ENV_FILE", None)
106
+ else:
107
+ os.environ["VISION_ENV_FILE"] = previous_explicit
108
+ if previous_local_appdata is None:
109
+ os.environ.pop("LOCALAPPDATA", None)
110
+ else:
111
+ os.environ["LOCALAPPDATA"] = previous_local_appdata
112
+
113
+ server = HTTPServer(("127.0.0.1", 0), Handler)
114
+ threading.Thread(target=server.serve_forever, daemon=True).start()
115
+ environment = dict(os.environ, VISION_API_KEY="test-key",
116
+ VISION_BASE_URL=f"http://127.0.0.1:{server.server_port}/v1",
117
+ VISION_MODEL="fixture-model")
118
+ environment.pop("VISION_API_PROTOCOL", None)
119
+ environment.pop("VISION_REASONING_EFFORT", None)
120
+ environment.pop("VISION_ANTHROPIC_THINKING", None)
121
+ environment.pop("VISION_USER_AGENT", None)
122
+ environment.pop("VISION_ENV_FILE", None)
123
+ saved = dict(os.environ)
124
+ os.environ.pop("VISION_API_PROTOCOL", None)
125
+ os.environ.pop("VISION_REASONING_EFFORT", None)
126
+ os.environ.pop("VISION_ANTHROPIC_THINKING", None)
127
+ os.environ.pop("VISION_USER_AGENT", None)
128
+ os.environ.pop("VISION_ENV_FILE", None)
129
+ os.environ.update(environment)
130
+ try:
131
+ Handler.calls, Handler.statuses, Handler.bodies, Handler.response_headers = (
132
+ 0, [429, 200], [], [{"Retry-After": "17"}, {}]
133
+ )
134
+ original_sleep = vision_client.time.sleep
135
+ delays = []
136
+ vision_client.time.sleep = delays.append
137
+ try:
138
+ assert vision_client.describe_image("data:image/png;base64,AAAA") == "fixture answer"
139
+ finally:
140
+ vision_client.time.sleep = original_sleep
141
+ assert Handler.calls == 2
142
+ assert delays == [17.0]
143
+ assert Handler.last_headers.get("User-Agent") == vision_client.DEFAULT_USER_AGENT
144
+ assert not Handler.last_headers["User-Agent"].startswith("Python-urllib/")
145
+
146
+ Handler.calls, Handler.statuses, Handler.bodies = 0, [200], []
147
+ os.environ["VISION_USER_AGENT"] = "custom-vision-client/2.0"
148
+ try:
149
+ assert vision_client.describe_image("data:image/png;base64,AAAA") == "fixture answer"
150
+ finally:
151
+ os.environ.pop("VISION_USER_AGENT", None)
152
+ assert Handler.last_headers.get("User-Agent") == "custom-vision-client/2.0"
153
+ assert Handler.calls == 1
154
+
155
+ Handler.calls, Handler.statuses, Handler.bodies = 0, [401], []
156
+ try:
157
+ vision_client.describe_image("data:image/png;base64,AAAA")
158
+ except vision_client.VisionError:
159
+ pass
160
+ else:
161
+ raise AssertionError("401 must fail cleanly")
162
+ assert Handler.calls == 1, "401 must not be retried"
163
+
164
+ Handler.calls, Handler.statuses, Handler.bodies = (
165
+ 0, [403], [b'{"error":"Cloudflare 1010 rejected test-key"}']
166
+ )
167
+ try:
168
+ vision_client.describe_image("data:image/png;base64,AAAA")
169
+ except vision_client.VisionError as exc:
170
+ assert "test-key" not in str(exc)
171
+ assert "<redacted>" in str(exc)
172
+ else:
173
+ raise AssertionError("HTTP errors must fail cleanly")
174
+ assert Handler.calls == 1, "403 must not be retried"
175
+
176
+ original_urlopen = vision_client.urllib.request.urlopen
177
+ original_sleep = vision_client.time.sleep
178
+
179
+ def fail_with_secret(*_args, **_kwargs):
180
+ raise urllib.error.URLError("connection failed for test-key")
181
+
182
+ vision_client.urllib.request.urlopen = fail_with_secret
183
+ vision_client.time.sleep = lambda _seconds: None
184
+ try:
185
+ try:
186
+ vision_client.describe_image("data:image/png;base64,AAAA")
187
+ except vision_client.VisionError as exc:
188
+ assert "test-key" not in str(exc)
189
+ assert "<redacted>" in str(exc)
190
+ else:
191
+ raise AssertionError("network errors must fail with redacted details")
192
+ finally:
193
+ vision_client.urllib.request.urlopen = original_urlopen
194
+ vision_client.time.sleep = original_sleep
195
+
196
+ Handler.calls, Handler.statuses, Handler.bodies = 0, [200], []
197
+ os.environ["LANG"] = "en"
198
+ try:
199
+ vision_client.describe_image("data:image/png;base64,AAAA")
200
+ finally:
201
+ os.environ.pop("LANG", None)
202
+ parts = json.loads(Handler.last_body)["messages"][0]["content"]
203
+ text = next(part["text"] for part in parts if part.get("type") == "text")
204
+ assert text.startswith("Please respond in English.")
205
+ assert Handler.calls == 1
206
+
207
+ Handler.calls, Handler.statuses, Handler.bodies = 0, [200], []
208
+ os.environ["LANG"] = "zh"
209
+ try:
210
+ vision_client.describe_image("data:image/png;base64,AAAA")
211
+ finally:
212
+ os.environ.pop("LANG", None)
213
+ parts = json.loads(Handler.last_body)["messages"][0]["content"]
214
+ text = next(part["text"] for part in parts if part.get("type") == "text")
215
+ assert text.startswith("请使用简体中文回答。")
216
+ assert Handler.calls == 1
217
+
218
+ Handler.calls, Handler.statuses, Handler.bodies = 0, [200], []
219
+ os.environ.pop("LANG", None)
220
+ vision_client.describe_image("data:image/png;base64,AAAA")
221
+ parts = json.loads(Handler.last_body)["messages"][0]["content"]
222
+ text = next(part["text"] for part in parts if part.get("type") == "text")
223
+ assert "Please respond in English." not in text
224
+ assert "请使用简体中文回答。" not in text
225
+ assert Handler.calls == 1
226
+
227
+ Handler.calls, Handler.statuses, Handler.bodies = 0, [200], []
228
+ vision_client.describe_image(["data:image/png;base64,AAAA", "data:image/png;base64,BBBB"])
229
+ content = json.loads(Handler.last_body)["messages"][0]["content"]
230
+ assert content[0].get("type") == "image_url", \
231
+ "vision payloads must put image parts before text for OpenCode Go MiMo compatibility"
232
+ assert Handler.last_headers.get("User-Agent") == vision_client.DEFAULT_USER_AGENT
233
+ image_parts = [part for part in content if part.get("type") == "image_url"]
234
+ assert len(image_parts) == 2, "a list of URLs must become one request with all images"
235
+ assert Handler.calls == 1
236
+
237
+ Handler.calls, Handler.statuses, Handler.bodies, Handler.response_headers = 0, [200], [json.dumps({
238
+ "object": "response",
239
+ "output": [{
240
+ "type": "message",
241
+ "content": [{"type": "output_text", "text": "responses fixture answer"}],
242
+ }],
243
+ }).encode()], []
244
+ os.environ["VISION_API_PROTOCOL"] = "responses"
245
+ os.environ["VISION_REASONING_EFFORT"] = "medium"
246
+ try:
247
+ assert vision_client.describe_image(
248
+ ["data:image/png;base64,AAAA", "data:image/png;base64,BBBB"],
249
+ prompt="read both images",
250
+ max_tokens=123,
251
+ ) == "responses fixture answer"
252
+ finally:
253
+ os.environ.pop("VISION_API_PROTOCOL", None)
254
+ os.environ.pop("VISION_REASONING_EFFORT", None)
255
+ assert Handler.last_path == "/v1/responses"
256
+ payload = json.loads(Handler.last_body)
257
+ content = payload["input"][0]["content"]
258
+ assert [part["type"] for part in content] == ["input_image", "input_image", "input_text"]
259
+ assert payload["store"] is False
260
+ assert payload["max_output_tokens"] == 123
261
+ assert payload["reasoning"] == {"effort": "medium"}
262
+ assert Handler.calls == 1
263
+
264
+ Handler.calls, Handler.statuses, Handler.bodies = 0, [200], [json.dumps({
265
+ "content": [
266
+ {"type": "thinking", "thinking": "internal reasoning"},
267
+ {"type": "text", "text": "anthropic fixture answer"},
268
+ {"type": "text", "text": "second text block"},
269
+ ],
270
+ "usage": {"input_tokens": 42, "output_tokens": 7},
271
+ }).encode()]
272
+ os.environ["VISION_API_PROTOCOL"] = "anthropic"
273
+ try:
274
+ assert vision_client.describe_image(
275
+ ["data:image/png;base64,AAAA", "https://example.com/remote.webp"],
276
+ prompt="read both images",
277
+ max_tokens=123,
278
+ ) == "anthropic fixture answer\nsecond text block"
279
+ finally:
280
+ os.environ.pop("VISION_API_PROTOCOL", None)
281
+ assert Handler.last_path == "/v1/messages"
282
+ assert next(v for k, v in Handler.last_headers.items() if k.lower() == "x-api-key") == "test-key"
283
+ assert next(v for k, v in Handler.last_headers.items() if k.lower() == "anthropic-version") == "2023-06-01"
284
+ assert not any(k.lower() == "authorization" for k in Handler.last_headers)
285
+ payload = json.loads(Handler.last_body)
286
+ assert payload["max_tokens"] == 123
287
+ assert "thinking" not in payload
288
+ content = payload["messages"][0]["content"]
289
+ assert [part["type"] for part in content] == ["image", "image", "text"]
290
+ assert content[0]["source"] == {
291
+ "type": "base64", "media_type": "image/png", "data": "AAAA"
292
+ }
293
+ assert content[1]["source"] == {"type": "url", "url": "https://example.com/remote.webp"}
294
+ assert Handler.calls == 1
295
+
296
+ Handler.calls, Handler.statuses, Handler.bodies, Handler.response_headers = (
297
+ 0, [529, 200], [b'{"error":{"type":"overloaded_error"}}', json.dumps({
298
+ "content": [{"type": "text", "text": "recovered"}],
299
+ }).encode()], [{"Retry-After": "3"}, {}]
300
+ )
301
+ delays = []
302
+ original_sleep = vision_client.time.sleep
303
+ vision_client.time.sleep = delays.append
304
+ os.environ["VISION_API_PROTOCOL"] = "anthropic"
305
+ os.environ["VISION_ANTHROPIC_THINKING"] = "disabled"
306
+ try:
307
+ assert vision_client.describe_image("data:image/png;base64,AAAA") == "recovered"
308
+ finally:
309
+ os.environ.pop("VISION_API_PROTOCOL", None)
310
+ os.environ.pop("VISION_ANTHROPIC_THINKING", None)
311
+ vision_client.time.sleep = original_sleep
312
+ assert json.loads(Handler.last_body)["thinking"] == {"type": "disabled"}
313
+ assert delays == [3.0]
314
+ assert Handler.calls == 2
315
+
316
+ Handler.calls, Handler.statuses, Handler.bodies = 0, [200], [json.dumps({
317
+ "content": [{"type": "text", "text": "adaptive answer"}],
318
+ }).encode()]
319
+ os.environ["VISION_API_PROTOCOL"] = "anthropic"
320
+ os.environ["VISION_ANTHROPIC_THINKING"] = "adaptive"
321
+ try:
322
+ assert vision_client.describe_image("data:image/png;base64,AAAA") == "adaptive answer"
323
+ finally:
324
+ os.environ.pop("VISION_API_PROTOCOL", None)
325
+ os.environ.pop("VISION_ANTHROPIC_THINKING", None)
326
+ assert json.loads(Handler.last_body)["thinking"] == {"type": "adaptive"}
327
+ assert Handler.calls == 1
328
+
329
+ Handler.calls, Handler.statuses, Handler.bodies, Handler.response_headers = 0, [], [], []
330
+ os.environ["VISION_API_PROTOCOL"] = "anthropic"
331
+ os.environ["VISION_ANTHROPIC_THINKING"] = "unsupported"
332
+ try:
333
+ try:
334
+ vision_client.describe_image("data:image/png;base64,AAAA")
335
+ except vision_client.VisionError as exc:
336
+ assert "Unsupported VISION_ANTHROPIC_THINKING" in str(exc)
337
+ else:
338
+ raise AssertionError("an unsupported thinking mode must fail before making a request")
339
+ finally:
340
+ os.environ.pop("VISION_API_PROTOCOL", None)
341
+ os.environ.pop("VISION_ANTHROPIC_THINKING", None)
342
+ assert Handler.calls == 0
343
+
344
+ Handler.calls, Handler.statuses, Handler.bodies = 0, [], []
345
+ os.environ["VISION_API_PROTOCOL"] = "unsupported"
346
+ try:
347
+ try:
348
+ vision_client.describe_image("data:image/png;base64,AAAA")
349
+ except vision_client.VisionError as exc:
350
+ assert "Unsupported VISION_API_PROTOCOL" in str(exc)
351
+ else:
352
+ raise AssertionError("an unsupported protocol must fail before making a request")
353
+ finally:
354
+ os.environ.pop("VISION_API_PROTOCOL", None)
355
+ assert Handler.calls == 0
356
+
357
+ Handler.calls, Handler.statuses, Handler.bodies = 0, [200], []
358
+ with tempfile.TemporaryDirectory() as raw:
359
+ image = Path(raw) / "fixture.png"
360
+ image.write_bytes(b"\x89PNG\r\n\x1a\nfixture")
361
+ # Pin the subprocess to one explicit fixture file so caller and
362
+ # checkout env files cannot redirect requests away from the server.
363
+ fixture_env = Path(raw) / "vision.env"
364
+ fixture_env.write_text(
365
+ "VISION_API_KEY=test-key\n"
366
+ f"VISION_BASE_URL=http://127.0.0.1:{server.server_port}/v1\n"
367
+ "VISION_MODEL=fixture-model\n"
368
+ "VISION_API_PROTOCOL=chat_completions\n"
369
+ )
370
+ isolated_env = dict(environment, HOME=raw, VISION_ENV_FILE=str(fixture_env))
371
+ glance = Path(__file__).resolve().parent.parent / "bin/glance"
372
+ glance_cmd = [sys.executable, str(glance)] if os.name == "nt" else [str(glance)]
373
+ result = subprocess.run(
374
+ [*glance_cmd, str(image), "-q", "图里有什么?"],
375
+ env=isolated_env, cwd=raw, text=True, capture_output=True, check=True,
376
+ )
377
+ assert result.stdout.strip() == "fixture answer"
378
+
379
+ Handler.calls, Handler.statuses, Handler.bodies = 0, [200], []
380
+ result = subprocess.run(
381
+ [*glance_cmd, str(image), str(image), "-q", "differences?"],
382
+ env=isolated_env, cwd=raw, text=True, capture_output=True, check=True,
383
+ )
384
+ assert result.stdout.strip() == "fixture answer"
385
+ content = json.loads(Handler.last_body)["messages"][0]["content"]
386
+ assert sum(part.get("type") == "image_url" for part in content) == 2, \
387
+ "glance with two paths must send both images in one call"
388
+ finally:
389
+ server.shutdown()
390
+ os.environ.clear()
391
+ os.environ.update(saved)
392
+ print("VISION CLIENT TEST PASS")
393
+
394
+
395
+ if __name__ == "__main__":
396
+ main()
@@ -1,9 +1,10 @@
1
1
  #!/usr/bin/env python3
2
- """Shared OpenAI-compatible vision client used by the proxy and glance CLI."""
2
+ """Shared multi-provider vision client used by the proxy and glance CLI."""
3
3
 
4
4
  from __future__ import annotations
5
5
 
6
6
  import base64
7
+ from email.utils import parsedate_to_datetime
7
8
  import http.client
8
9
  import json
9
10
  import mimetypes
@@ -15,6 +16,11 @@ import urllib.error
15
16
  import urllib.request
16
17
 
17
18
  DEFAULT_PROMPT = "Please describe the contents of this image in detail."
19
+ DEFAULT_USER_AGENT = (
20
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
21
+ "AppleWebKit/537.36 (KHTML, like Gecko) "
22
+ "Chrome/126.0.0.0 Safari/537.36"
23
+ )
18
24
 
19
25
  LANG_INSTRUCTIONS = {
20
26
  "zh": "请使用简体中文回答。",
@@ -47,7 +53,10 @@ def load_env_file(path: str | os.PathLike[str] | None) -> None:
47
53
 
48
54
  def load_default_env() -> None:
49
55
  explicit = os.environ.get("VISION_ENV_FILE")
50
- candidates = [Path(explicit).expanduser()] if explicit else []
56
+ if explicit:
57
+ load_env_file(Path(explicit).expanduser())
58
+ return
59
+ candidates = []
51
60
  local_appdata = os.environ.get("LOCALAPPDATA")
52
61
  if local_appdata:
53
62
  candidates.append(Path(local_appdata) / "agent-vision-toolkit" / "env")
@@ -93,6 +102,64 @@ def _message_text(message: object) -> str:
93
102
  return ""
94
103
 
95
104
 
105
+ def _responses_text(response: object) -> str:
106
+ if not isinstance(response, dict) or not isinstance(response.get("output"), list):
107
+ return ""
108
+ return "\n".join(
109
+ part["text"]
110
+ for item in response["output"]
111
+ if isinstance(item, dict) and item.get("type") == "message"
112
+ and isinstance(item.get("content"), list)
113
+ for part in item["content"]
114
+ if isinstance(part, dict) and part.get("type") == "output_text"
115
+ and isinstance(part.get("text"), str)
116
+ ).strip()
117
+
118
+
119
+ def _anthropic_image_source(url: str) -> dict[str, str]:
120
+ if not url.startswith("data:"):
121
+ return {"type": "url", "url": url}
122
+ header, separator, data = url.partition(",")
123
+ if separator == "" or ";base64" not in header:
124
+ raise VisionError("Anthropic image data URLs must use base64 encoding")
125
+ media_type = header[5:].split(";", 1)[0]
126
+ if not media_type:
127
+ raise VisionError("Anthropic image data URLs must include a media type")
128
+ return {"type": "base64", "media_type": media_type, "data": data}
129
+
130
+
131
+ def _anthropic_text(response: object) -> str:
132
+ if not isinstance(response, dict) or not isinstance(response.get("content"), list):
133
+ return ""
134
+ return "\n".join(
135
+ block["text"]
136
+ for block in response["content"]
137
+ if isinstance(block, dict) and block.get("type") == "text"
138
+ and isinstance(block.get("text"), str)
139
+ ).strip()
140
+
141
+
142
+ def _redact(text: str, *secrets: str) -> str:
143
+ for secret in secrets:
144
+ if secret:
145
+ text = text.replace(secret, "<redacted>")
146
+ return text
147
+
148
+
149
+ def _retry_delay(error: urllib.error.HTTPError, attempt: int) -> float:
150
+ value = error.headers.get("Retry-After")
151
+ if value:
152
+ try:
153
+ return max(0.0, min(float(value), 60.0))
154
+ except ValueError:
155
+ try:
156
+ retry_at = parsedate_to_datetime(value)
157
+ return max(0.0, min(retry_at.timestamp() - time.time(), 60.0))
158
+ except (TypeError, ValueError, OverflowError):
159
+ pass
160
+ return min(2 ** attempt, 4)
161
+
162
+
96
163
  def describe_image(image_url: str | list[str], prompt: str | None = None, max_tokens: int = 4096,
97
164
  apply_lang: bool = True) -> str:
98
165
  """Describe one data/http image URL (str) or several (list) in a single call."""
@@ -105,24 +172,73 @@ def describe_image(image_url: str | list[str], prompt: str | None = None, max_to
105
172
  raise VisionError("Only data URLs or http(s) image URLs are supported")
106
173
  base_url = _required("VISION_BASE_URL").rstrip("/")
107
174
  api_key = _required("VISION_API_KEY")
175
+ user_agent = os.environ.get("VISION_USER_AGENT", "").strip() or DEFAULT_USER_AGENT
108
176
  text = prompt or DEFAULT_PROMPT
109
177
  if apply_lang:
110
178
  instruction = LANG_INSTRUCTIONS.get(os.environ.get("LANG", "").strip().lower())
111
179
  if instruction:
112
180
  text = f"{instruction}\n\n{text}"
113
- payload = {
114
- "model": _required("VISION_MODEL"),
115
- "messages": [{"role": "user", "content": [{"type": "text", "text": text}] + [
181
+ model = _required("VISION_MODEL")
182
+ protocol = os.environ.get("VISION_API_PROTOCOL", "").strip().lower() or "chat_completions"
183
+ if protocol == "responses":
184
+ payload = {
185
+ "model": model,
186
+ "store": False,
187
+ "input": [{"role": "user", "content": [
188
+ {"type": "input_image", "image_url": url} for url in urls
189
+ ] + [{"type": "input_text", "text": text}]}],
190
+ }
191
+ if max_tokens is not None:
192
+ payload["max_output_tokens"] = max_tokens
193
+ reasoning_effort = os.environ.get("VISION_REASONING_EFFORT", "").strip()
194
+ if reasoning_effort:
195
+ payload["reasoning"] = {"effort": reasoning_effort}
196
+ endpoint = "/responses"
197
+ extract_text = _responses_text
198
+ elif protocol == "chat_completions":
199
+ payload = {
200
+ "model": model,
201
+ "messages": [{"role": "user", "content": [
116
202
  {"type": "image_url", "image_url": {"url": url}} for url in urls
117
- ]}],
203
+ ] + [{"type": "text", "text": text}]}],
204
+ }
205
+ if max_tokens is not None:
206
+ payload["max_tokens"] = max_tokens
207
+ endpoint = "/chat/completions"
208
+ extract_text = lambda data: _message_text(data["choices"][0]["message"]["content"])
209
+ elif protocol == "anthropic":
210
+ payload = {
211
+ "model": model,
212
+ "max_tokens": max_tokens if max_tokens is not None else 4096,
213
+ "messages": [{"role": "user", "content": [
214
+ {"type": "image", "source": _anthropic_image_source(url)} for url in urls
215
+ ] + [{"type": "text", "text": text}]}],
216
+ }
217
+ thinking = os.environ.get("VISION_ANTHROPIC_THINKING", "").strip().lower() or "omit"
218
+ if thinking != "omit":
219
+ if thinking not in {"disabled", "adaptive"}:
220
+ raise VisionError(
221
+ "Unsupported VISION_ANTHROPIC_THINKING; use omit, disabled, or adaptive"
222
+ )
223
+ payload["thinking"] = {"type": thinking}
224
+ endpoint = "/messages"
225
+ extract_text = _anthropic_text
226
+ else:
227
+ raise VisionError(
228
+ "Unsupported VISION_API_PROTOCOL; use chat_completions, responses, or anthropic"
229
+ )
230
+ headers = {
231
+ "Content-Type": "application/json",
232
+ "User-Agent": user_agent,
118
233
  }
119
- if max_tokens is not None:
120
- payload["max_tokens"] = max_tokens
121
- request = urllib.request.Request(
122
- base_url + "/chat/completions",
123
- data=json.dumps(payload).encode(),
124
- headers={"Content-Type": "application/json", "Authorization": "Bearer " + api_key},
125
- )
234
+ if protocol == "anthropic":
235
+ headers.update({
236
+ "x-api-key": api_key,
237
+ "anthropic-version": "2023-06-01",
238
+ })
239
+ else:
240
+ headers["Authorization"] = "Bearer " + api_key
241
+ request = urllib.request.Request(base_url + endpoint, data=json.dumps(payload).encode(), headers=headers)
126
242
  retries = 2
127
243
  timeout = 180
128
244
  for attempt in range(retries + 1):
@@ -130,18 +246,18 @@ def describe_image(image_url: str | list[str], prompt: str | None = None, max_to
130
246
  with urllib.request.urlopen(request, timeout=timeout) as response:
131
247
  data = json.load(response)
132
248
  try:
133
- text = _message_text(data["choices"][0]["message"]["content"])
249
+ text = extract_text(data)
134
250
  except (KeyError, IndexError, TypeError) as exc:
135
251
  raise VisionError("Vision API returned an incompatible response structure") from exc
136
252
  if not text:
137
253
  raise VisionError("Vision API returned an empty description")
138
254
  return text
139
255
  except urllib.error.HTTPError as exc:
140
- body = exc.read().decode(errors="replace")[:400].replace(api_key, "<redacted>")
256
+ body = _redact(exc.read().decode(errors="replace")[:400], api_key)
141
257
  body = body.replace("\r", " ").replace("\n", " ")
142
- if exc.code in {429, 500, 502, 503, 504} and attempt < retries:
258
+ if exc.code in {429, 500, 502, 503, 504, 529} and attempt < retries:
143
259
  print(f"vision: HTTP {exc.code}, retrying ({attempt + 1}/{retries})", file=sys.stderr)
144
- time.sleep(min(2 ** attempt, 4))
260
+ time.sleep(_retry_delay(exc, attempt))
145
261
  continue
146
262
  raise VisionError(f"Vision API HTTP {exc.code}: {body}") from exc
147
263
  except (urllib.error.URLError, TimeoutError, ConnectionError, http.client.IncompleteRead) as exc:
@@ -149,7 +265,7 @@ def describe_image(image_url: str | list[str], prompt: str | None = None, max_to
149
265
  print(f"vision: {type(exc).__name__}, retrying ({attempt + 1}/{retries})", file=sys.stderr)
150
266
  time.sleep(min(2 ** attempt, 4))
151
267
  continue
152
- reason = getattr(exc, "reason", str(exc))
268
+ reason = _redact(str(getattr(exc, "reason", str(exc))), api_key)
153
269
  raise VisionError(f"Vision API network error: {reason}") from exc
154
270
  except json.JSONDecodeError as exc:
155
271
  raise VisionError("Vision API returned invalid JSON") from exc