@saifymatteo/pi-vision 0.0.0-stage → 0.1.0

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,88 @@
1
+ #!/usr/bin/env python3
2
+ """pixel_diff: compare a rebuilt image against the original and rank the worst regions.
3
+
4
+ Verification is the step agents skip or fake, because comparing two
5
+ descriptions feels like comparing two images. It is not. This runs the real
6
+ comparison and answers the only question that matters next: which region is
7
+ most wrong, so you know where to look and what to fix first.
8
+
9
+ Boxes are printed in the same `x1: .., y1: ..` form as ground/detect, so a
10
+ bad region can be pasted straight into `glance --region` or `detect --region`.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import argparse
16
+ from pathlib import Path
17
+
18
+ try:
19
+ from PIL import Image, ImageChops, ImageStat
20
+ except ImportError:
21
+ Image = None
22
+
23
+
24
+ def load(path: Path, size: tuple[int, int] | None = None) -> "Image.Image":
25
+ # Traced SVGs and screenshots of transparent UI render with an alpha
26
+ # channel; unflattened transparency reads as black and shows up as a huge
27
+ # phantom diff. Compositing on white is what a viewer would show.
28
+ image = Image.open(path)
29
+ if image.mode in ("RGBA", "LA", "P"):
30
+ image = image.convert("RGBA")
31
+ canvas = Image.new("RGB", image.size, "white")
32
+ canvas.paste(image, mask=image.split()[-1])
33
+ image = canvas
34
+ else:
35
+ image = image.convert("RGB")
36
+ return image.resize(size, Image.LANCZOS) if size and image.size != size else image
37
+
38
+
39
+ def cell_scores(diff: "Image.Image", grid: int) -> list[tuple[float, tuple[int, int, int, int]]]:
40
+ width, height = diff.size
41
+ grey = diff.convert("L")
42
+ scores = []
43
+ for row in range(grid):
44
+ for column in range(grid):
45
+ box = (round(column * width / grid), round(row * height / grid),
46
+ round((column + 1) * width / grid), round((row + 1) * height / grid))
47
+ if box[2] > box[0] and box[3] > box[1]:
48
+ mean = ImageStat.Stat(grey.crop(box)).mean[0]
49
+ scores.append((mean / 255 * 100, box))
50
+ return sorted(scores, reverse=True)
51
+
52
+
53
+ def main() -> None:
54
+ parser = argparse.ArgumentParser(
55
+ prog="pixel_diff",
56
+ description="Pixel-diff a rebuilt image against the original and rank the worst regions",
57
+ )
58
+ parser.add_argument("original", type=Path, help="the reference image")
59
+ parser.add_argument("rebuilt", type=Path, help="your rendered reproduction")
60
+ parser.add_argument("--grid", type=int, default=6, help="split into GRID x GRID cells (default: 6)")
61
+ parser.add_argument("--top", type=int, default=5, help="how many worst regions to print (default: 5)")
62
+ parser.add_argument("-o", "--output", type=Path, help="write a diff heatmap image here")
63
+ args = parser.parse_args()
64
+ if Image is None:
65
+ parser.exit(1, "pixel_diff: requires Pillow; install the optional dependency pillow first\n")
66
+ for path in (args.original, args.rebuilt):
67
+ if not path.expanduser().is_file():
68
+ parser.exit(1, f"pixel_diff: image not found: {path}\n")
69
+ original = load(args.original.expanduser())
70
+ with Image.open(args.rebuilt.expanduser()) as probe:
71
+ raw_size = probe.size
72
+ rebuilt = load(args.rebuilt.expanduser(), size=original.size)
73
+ if raw_size != original.size:
74
+ # Worth saying out loud: a size mismatch is itself a finding, and every
75
+ # box printed below is in the original's coordinates, not the rebuild's.
76
+ print(f"note: rebuilt was {raw_size[0]}x{raw_size[1]}, scaled to {original.size[0]}x{original.size[1]}")
77
+ diff = ImageChops.difference(original, rebuilt)
78
+ overall = ImageStat.Stat(diff.convert("L")).mean[0] / 255 * 100
79
+ print(f"overall difference: {overall:.2f}%")
80
+ if args.output:
81
+ diff.save(args.output.expanduser())
82
+ print(f"heatmap: {args.output}")
83
+ for index, (score, box) in enumerate(cell_scores(diff, args.grid)[:args.top], 1):
84
+ print(f"{index}. {score:.2f}% x1: {box[0]}, y1: {box[1]}, x2: {box[2]}, y2: {box[3]}")
85
+
86
+
87
+ if __name__ == "__main__":
88
+ main()
@@ -0,0 +1,581 @@
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 sys
14
+ import time
15
+ import urllib.error
16
+ import urllib.request
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
+ )
24
+
25
+ LANG_INSTRUCTIONS = {
26
+ "zh": "请使用简体中文回答。",
27
+ "en": "Please respond in English.",
28
+ }
29
+
30
+
31
+ class VisionError(RuntimeError):
32
+ """A safe, user-facing vision request failure."""
33
+
34
+ def __init__(self, message, status=None):
35
+ super().__init__(message)
36
+ self.status = status # HTTP status code, when the failure was one
37
+
38
+
39
+ # Request-time failover state. PERMANENT_HTTP_STATUSES fail over immediately
40
+ # (unknown model, bad key); transient failures retry in place first, then
41
+ # fail over. _EXPLICIT_ENV marks a deliberately loaded env file, which the
42
+ # chain must never override.
43
+ PERMANENT_HTTP_STATUSES = {400, 401, 403, 404, 405, 422}
44
+ _EXPLICIT_ENV = False
45
+ _CHAIN_CACHE = None
46
+
47
+
48
+ def _resolution():
49
+ global _CHAIN_CACHE
50
+ if _CHAIN_CACHE is None:
51
+ _CHAIN_CACHE = resolve_vision_config()
52
+ return _CHAIN_CACHE
53
+
54
+
55
+ def load_env_file(path: str | os.PathLike[str] | None) -> None:
56
+ if not path:
57
+ return
58
+ env_path = Path(path).expanduser()
59
+ if not env_path.is_file():
60
+ return
61
+ for raw_line in env_path.read_text().splitlines():
62
+ line = raw_line.strip()
63
+ if not line or line.startswith("#") or "=" not in line:
64
+ continue
65
+ key, _, value = line.partition("=")
66
+ key = key.strip()
67
+ value = value.strip().strip('"').strip("'")
68
+ # The env file is the user's explicit configuration: whatever it sets wins,
69
+ # even when the same variable already exists in the system environment.
70
+ if key:
71
+ os.environ[key] = value
72
+
73
+
74
+ # ---------------------------------------------------------------------------
75
+ # Configuration resolution
76
+ #
77
+ # extensions/vision-toolkit.json (under the Pi agent dir) drives everything:
78
+ # {
79
+ # "model": "9router/designer", # "provider/model-id", or a bare "model-id"
80
+ # "fallbacks": ["openrouter/google/gemini-2.5-flash-lite"],
81
+ # "imagePolicy": "describe-old", # native | describe | describe-old (proxy only)
82
+ # "lang": "en",
83
+ # "providers": {
84
+ # "9router": {"baseUrl": "...", "apiKey": "...", "protocol": "chat_completions"}
85
+ # }
86
+ # }
87
+ #
88
+ # Per provider, sources are consulted in this order:
89
+ # 1. the user override in vision-toolkit.json providers.<name>
90
+ # 2. the model entry in models.json / models-store.json (baseUrl, api, input)
91
+ # plus modelOverrides entries (capabilities for extension-registered models)
92
+ # 3. package-provider configs: pi packages register providers at runtime with
93
+ # a live model catalog, so no static file lists their models; their
94
+ # connection config lives in a package-owned file under the agent dir
95
+ # 4. auth.json credentials as written by Pi's /login (key or access)
96
+ #
97
+ # A candidate needs a baseUrl and an API key to resolve. A model id missing
98
+ # from every static catalog is fine when a runtime provider supplies the
99
+ # connection: the id is validated at request time by the API itself.
100
+
101
+ PACKAGE_PROVIDER_CONFIGS = {
102
+ "9router": "haoyiyin/9router.json",
103
+ }
104
+
105
+ # models.json provider "api" types -> describe_image protocols
106
+ API_TYPE_TO_PROTOCOL = {
107
+ "openai-completions": "chat_completions",
108
+ "openai-responses": "responses",
109
+ "anthropic-messages": "anthropic",
110
+ }
111
+
112
+
113
+ def _agent_dir() -> Path:
114
+ return Path(os.environ.get("PI_CODING_AGENT_DIR") or Path.home() / ".pi" / "agent")
115
+
116
+
117
+ def _read_json(path: Path) -> dict:
118
+ try:
119
+ data = json.loads(path.read_text())
120
+ except (OSError, ValueError):
121
+ return {}
122
+ return data if isinstance(data, dict) else {}
123
+
124
+
125
+ def _expand_secret(value):
126
+ """Allow "$ENV_NAME" references (as models.json does) in apiKey values."""
127
+ if isinstance(value, str) and value.startswith("$") and len(value) > 1:
128
+ return os.environ.get(value[1:], "")
129
+ return value
130
+
131
+
132
+ def _load_toolkit_cfg(agent_dir: Path) -> dict:
133
+ cfg = _read_json(agent_dir / "extensions" / "vision-toolkit.json")
134
+ return cfg if isinstance(cfg, dict) else {}
135
+
136
+
137
+ def _candidate_specs(cfg: dict) -> list:
138
+ """(provider, model_id) pairs: "model" first, then "fallbacks" in order.
139
+ A bare id (no slash) has an empty provider and is searched for across
140
+ every resolvable provider."""
141
+ specs = []
142
+ seen = set()
143
+ raw = [cfg.get("model")] + list(cfg.get("fallbacks") or [])
144
+ for spec in raw:
145
+ if not isinstance(spec, str) or not spec.strip():
146
+ continue
147
+ spec = spec.strip()
148
+ if "/" in spec:
149
+ provider, _, model_id = spec.partition("/")
150
+ else:
151
+ provider, model_id = "", spec
152
+ key = (provider, model_id)
153
+ if model_id and key not in seen:
154
+ seen.add(key)
155
+ specs.append(key)
156
+ return specs
157
+
158
+
159
+ def _snapshots(agent_dir: Path, cfg: dict) -> dict:
160
+ models_json = _read_json(agent_dir / "models.json")
161
+ models_store = _read_json(agent_dir / "models-store.json")
162
+ auth = _read_json(agent_dir / "auth.json")
163
+ pkgs = {}
164
+ for name, rel in PACKAGE_PROVIDER_CONFIGS.items():
165
+ data = _read_json(agent_dir / rel)
166
+ if data:
167
+ pkgs[name] = data
168
+ providers_cfg = cfg.get("providers")
169
+ user_providers = providers_cfg if isinstance(providers_cfg, dict) else {}
170
+ return {"models": models_json, "store": models_store, "auth": auth,
171
+ "pkgs": pkgs, "user": user_providers}
172
+
173
+
174
+ def _resolve_candidate(provider, model_id, agent_dir, snap):
175
+ """Resolve one candidate to key/baseUrl/protocol/capability, or an error."""
176
+ custom = (snap["models"].get("providers") or {}).get(provider) or {}
177
+ store_models = (snap["store"].get(provider) or {}).get("models") or []
178
+ entry = None
179
+ for source in (custom.get("models") or [], store_models):
180
+ match = next((m for m in source if isinstance(m, dict) and m.get("id") == model_id), None)
181
+ if match:
182
+ entry = match
183
+ break
184
+ overrides = (custom.get("modelOverrides") or {}).get(model_id) or {}
185
+ cred = snap["auth"].get(provider) or {}
186
+ pkg = snap["pkgs"].get(provider) or {}
187
+ user_raw = snap["user"].get(provider)
188
+ user = user_raw if isinstance(user_raw, dict) else {}
189
+
190
+ base_url = (user.get("baseUrl") or (entry.get("baseUrl") if entry else None)
191
+ or pkg.get("baseUrl") or custom.get("baseUrl"))
192
+ key_value = (user.get("apiKey") or pkg.get("apiKey")
193
+ or custom.get("apiKey") or cred.get("key") or cred.get("access"))
194
+ api_key = _expand_secret(key_value)
195
+ key_source = None
196
+ if user.get("apiKey"):
197
+ key_source = "vision-toolkit.json providers.%s" % provider
198
+ elif pkg.get("apiKey"):
199
+ key_source = PACKAGE_PROVIDER_CONFIGS.get(provider, "package config")
200
+ elif custom.get("apiKey"):
201
+ key_source = "models.json providers.%s" % provider
202
+ elif cred.get("key") or cred.get("access"):
203
+ key_source = "auth.json (%s)" % ("key" if cred.get("key") else "access")
204
+ base_source = None
205
+ if user.get("baseUrl"):
206
+ base_source = "vision-toolkit.json providers.%s" % provider
207
+ elif entry is not None and entry.get("baseUrl"):
208
+ base_source = "models.json model entry"
209
+ elif pkg.get("baseUrl"):
210
+ base_source = PACKAGE_PROVIDER_CONFIGS.get(provider, "package config")
211
+ elif custom.get("baseUrl"):
212
+ base_source = "models.json providers.%s" % provider
213
+
214
+ protocol = user.get("protocol") or custom.get("protocol")
215
+ if not protocol:
216
+ protocol = API_TYPE_TO_PROTOCOL.get(custom.get("api"), "chat_completions")
217
+
218
+ inputs = None
219
+ for cap in (entry, overrides):
220
+ if isinstance(cap, dict) and isinstance(cap.get("input"), list):
221
+ inputs = cap["input"]
222
+ break
223
+ # None means unknown (runtime-registered catalog): validated at request time.
224
+ image_capable = ("image" in inputs) if inputs else None
225
+
226
+ if not base_url:
227
+ return {"ok": False,
228
+ "error": ('no baseUrl for provider "%s" (add providers.%s.baseUrl to '
229
+ 'vision-toolkit.json, a models entry, or log the provider in)'
230
+ % (provider, provider))}
231
+ if not api_key:
232
+ return {"ok": False,
233
+ "error": ('no API key for provider "%s" (run /login %s, or set '
234
+ 'providers.%s.apiKey in vision-toolkit.json)'
235
+ % (provider, provider, provider))}
236
+ return {"ok": True, "provider": provider, "model_id": model_id,
237
+ "api_key": api_key, "base_url": str(base_url).rstrip("/"),
238
+ "protocol": protocol, "image_capable": image_capable,
239
+ "key_source": key_source, "base_source": base_source,
240
+ "in_catalog": entry is not None}
241
+
242
+
243
+ def resolve_vision_config(agent_dir=None):
244
+ """Resolve the candidate chain in vision-toolkit.json.
245
+
246
+ Returns {"cfg": raw config dict, "agent_dir": Path, "attempts": [...],
247
+ "config": first fully-resolved candidate dict or None}. attempts records
248
+ every candidate with its outcome (api keys redacted)."""
249
+ agent_dir = Path(agent_dir) if agent_dir else _agent_dir()
250
+ cfg = _load_toolkit_cfg(agent_dir)
251
+ snap = _snapshots(agent_dir, cfg)
252
+ attempts = []
253
+ resolved = []
254
+ for provider, model_id in _candidate_specs(cfg):
255
+ if provider:
256
+ search = [provider]
257
+ else:
258
+ # Bare id: search every resolvable provider. Explicit catalog
259
+ # entries first, then runtime-registered package providers,
260
+ # which accept any model id.
261
+ search = []
262
+ for name, prov in (snap["models"].get("providers") or {}).items():
263
+ if isinstance(prov, dict) and isinstance(prov.get("models"), list):
264
+ search.append(name)
265
+ for name, prov in snap["store"].items():
266
+ if isinstance(prov, dict) and (prov.get("models") or []):
267
+ if name not in search:
268
+ search.append(name)
269
+ search.extend(n for n in PACKAGE_PROVIDER_CONFIGS if n not in search)
270
+ spec = ("%s/%s" % (provider, model_id)) if provider else model_id
271
+ for cand in search:
272
+ result = _resolve_candidate(cand, model_id, agent_dir, snap)
273
+ label = spec if len(search) == 1 else "%s (as %s/%s)" % (spec, cand, model_id)
274
+ record = {k: v for k, v in result.items() if k != "api_key"}
275
+ key = result.get("api_key") or ""
276
+ record["key_fingerprint"] = (key[:6] + "..." + key[-4:]) if len(key) > 12 else ("***" if key else None)
277
+ record["spec"] = label
278
+ attempts.append(record)
279
+ if result.get("ok"):
280
+ # This spec is satisfied; the same bare id on other providers
281
+ # is not a meaningful fallback, so move to the next spec.
282
+ resolved.append(result)
283
+ break
284
+ return {"cfg": cfg, "agent_dir": agent_dir, "attempts": attempts,
285
+ "resolved": resolved,
286
+ "config": resolved[0] if resolved else None}
287
+
288
+
289
+ def apply_resolved_to_env(config):
290
+ """Publish a resolved config into the VISION_* environment variables that
291
+ describe_image and the CLIs read."""
292
+ if not config or not config.get("ok"):
293
+ return
294
+ if config.get("api_key"):
295
+ os.environ["VISION_API_KEY"] = config["api_key"]
296
+ if config.get("base_url"):
297
+ os.environ["VISION_BASE_URL"] = config["base_url"]
298
+ os.environ["VISION_MODEL"] = config["model_id"]
299
+ if config.get("protocol") and config["protocol"] != "chat_completions":
300
+ os.environ["VISION_API_PROTOCOL"] = config["protocol"]
301
+
302
+
303
+ def load_pi_vision_json():
304
+ """Resolve vision-toolkit.json and publish the first resolvable candidate
305
+ into the environment. Wins over the legacy env chain; when nothing
306
+ resolves, existing env values are left untouched."""
307
+ resolution = resolve_vision_config()
308
+ apply_resolved_to_env(resolution["config"])
309
+ lang = resolution["cfg"].get("lang")
310
+ if lang:
311
+ os.environ["LANG"] = lang
312
+ def load_default_env() -> None:
313
+ explicit = os.environ.get("VISION_ENV_FILE")
314
+ if explicit:
315
+ # An explicitly pointed env file is the user's deliberate override:
316
+ # it short-circuits everything else, including vision-toolkit.json
317
+ # and the failover chain.
318
+ global _EXPLICIT_ENV
319
+ _EXPLICIT_ENV = True
320
+ load_env_file(Path(explicit).expanduser())
321
+ return
322
+ candidates = []
323
+ local_appdata = os.environ.get("LOCALAPPDATA")
324
+ if local_appdata:
325
+ candidates.append(Path(local_appdata) / "agent-vision-toolkit" / "env")
326
+ candidates.extend([
327
+ Path.home() / ".config" / "agent-vision-toolkit" / "env",
328
+ Path(__file__).resolve().parent / ".env",
329
+ Path.cwd() / ".env",
330
+ ])
331
+ pi_coding_agent_dir = os.environ.get("PI_CODING_AGENT_DIR")
332
+ if pi_coding_agent_dir:
333
+ candidates.append(Path(pi_coding_agent_dir) / ".env")
334
+ for path in candidates:
335
+ load_env_file(path)
336
+ load_pi_vision_json()
337
+
338
+
339
+ def _required(name: str) -> str:
340
+ value = os.environ.get(name, "").strip()
341
+ if not value:
342
+ raise VisionError(
343
+ f"Missing config {name}; run 'vision-doctor' to inspect the "
344
+ "vision-toolkit.json chain (model, fallbacks, providers), or set the "
345
+ "legacy .env values"
346
+ )
347
+ return value
348
+
349
+
350
+ def validate_vision_config() -> None:
351
+ for name in ("VISION_API_KEY", "VISION_BASE_URL", "VISION_MODEL"):
352
+ _required(name)
353
+
354
+
355
+ def image_path_to_data_url(path: str | os.PathLike[str]) -> str:
356
+ image_path = Path(path).expanduser()
357
+ if not image_path.is_file():
358
+ raise VisionError(f"Image not found: {image_path}")
359
+ mime, _ = mimetypes.guess_type(image_path.name)
360
+ if mime not in {"image/png", "image/jpeg", "image/gif", "image/webp"}:
361
+ raise VisionError("Only PNG, JPEG, GIF, and WebP images are supported")
362
+ return f"data:{mime};base64,{base64.b64encode(image_path.read_bytes()).decode()}"
363
+
364
+
365
+ def _message_text(message: object) -> str:
366
+ if isinstance(message, str):
367
+ return message.strip()
368
+ if isinstance(message, list):
369
+ return "\n".join(
370
+ part["text"] for part in message
371
+ if isinstance(part, dict) and isinstance(part.get("text"), str)
372
+ ).strip()
373
+ return ""
374
+
375
+
376
+ def _responses_text(response: object) -> str:
377
+ if not isinstance(response, dict) or not isinstance(response.get("output"), list):
378
+ return ""
379
+ return "\n".join(
380
+ part["text"]
381
+ for item in response["output"]
382
+ if isinstance(item, dict) and item.get("type") == "message"
383
+ and isinstance(item.get("content"), list)
384
+ for part in item["content"]
385
+ if isinstance(part, dict) and part.get("type") == "output_text"
386
+ and isinstance(part.get("text"), str)
387
+ ).strip()
388
+
389
+
390
+ def _anthropic_image_source(url: str) -> dict[str, str]:
391
+ if not url.startswith("data:"):
392
+ return {"type": "url", "url": url}
393
+ header, separator, data = url.partition(",")
394
+ if separator == "" or ";base64" not in header:
395
+ raise VisionError("Anthropic image data URLs must use base64 encoding")
396
+ media_type = header[5:].split(";", 1)[0]
397
+ if not media_type:
398
+ raise VisionError("Anthropic image data URLs must include a media type")
399
+ return {"type": "base64", "media_type": media_type, "data": data}
400
+
401
+
402
+ def _anthropic_text(response: object) -> str:
403
+ if not isinstance(response, dict) or not isinstance(response.get("content"), list):
404
+ return ""
405
+ return "\n".join(
406
+ block["text"]
407
+ for block in response["content"]
408
+ if isinstance(block, dict) and block.get("type") == "text"
409
+ and isinstance(block.get("text"), str)
410
+ ).strip()
411
+
412
+
413
+ def _redact(text: str, *secrets: str) -> str:
414
+ for secret in secrets:
415
+ if secret:
416
+ text = text.replace(secret, "<redacted>")
417
+ return text
418
+
419
+
420
+ def _retry_delay(error: urllib.error.HTTPError, attempt: int) -> float:
421
+ value = error.headers.get("Retry-After")
422
+ if value:
423
+ try:
424
+ return max(0.0, min(float(value), 60.0))
425
+ except ValueError:
426
+ try:
427
+ retry_at = parsedate_to_datetime(value)
428
+ return max(0.0, min(retry_at.timestamp() - time.time(), 60.0))
429
+ except (TypeError, ValueError, OverflowError):
430
+ pass
431
+ return min(2 ** attempt, 4)
432
+
433
+
434
+ def _describe_attempt(image_url: str | list[str], prompt: str | None = None,
435
+ max_tokens: int = 4096, apply_lang: bool = True) -> str:
436
+ """One candidate attempt: reads the VISION_* env, retries transient
437
+ failures in place, raises VisionError (with .status for HTTP errors)."""
438
+ validate_vision_config()
439
+ urls = [image_url] if isinstance(image_url, str) else list(image_url)
440
+ if not urls:
441
+ raise VisionError("No image was provided")
442
+ for url in urls:
443
+ if not url.startswith(("data:", "http://", "https://")):
444
+ raise VisionError("Only data URLs or http(s) image URLs are supported")
445
+ base_url = _required("VISION_BASE_URL").rstrip("/")
446
+ api_key = _required("VISION_API_KEY")
447
+ user_agent = os.environ.get("VISION_USER_AGENT", "").strip() or DEFAULT_USER_AGENT
448
+ text = prompt or DEFAULT_PROMPT
449
+ if apply_lang:
450
+ instruction = LANG_INSTRUCTIONS.get(os.environ.get("LANG", "").strip().lower())
451
+ if instruction:
452
+ text = f"{instruction}\n\n{text}"
453
+ model = _required("VISION_MODEL")
454
+ protocol = os.environ.get("VISION_API_PROTOCOL", "").strip().lower() or "chat_completions"
455
+ if protocol == "responses":
456
+ payload = {
457
+ "model": model,
458
+ "store": False,
459
+ "input": [{"role": "user", "content": [
460
+ {"type": "input_image", "image_url": url} for url in urls
461
+ ] + [{"type": "input_text", "text": text}]}],
462
+ }
463
+ if max_tokens is not None:
464
+ payload["max_output_tokens"] = max_tokens
465
+ reasoning_effort = os.environ.get("VISION_REASONING_EFFORT", "").strip()
466
+ if reasoning_effort:
467
+ payload["reasoning"] = {"effort": reasoning_effort}
468
+ endpoint = "/responses"
469
+ extract_text = _responses_text
470
+ elif protocol == "chat_completions":
471
+ payload = {
472
+ "model": model,
473
+ "stream": False,
474
+ "messages": [{"role": "user", "content": [
475
+ {"type": "image_url", "image_url": {"url": url}} for url in urls
476
+ ] + [{"type": "text", "text": text}]}],
477
+ }
478
+ if max_tokens is not None:
479
+ payload["max_tokens"] = max_tokens
480
+ endpoint = "/chat/completions"
481
+ extract_text = lambda data: _message_text(data["choices"][0]["message"]["content"])
482
+ elif protocol == "anthropic":
483
+ payload = {
484
+ "model": model,
485
+ "max_tokens": max_tokens if max_tokens is not None else 4096,
486
+ "messages": [{"role": "user", "content": [
487
+ {"type": "image", "source": _anthropic_image_source(url)} for url in urls
488
+ ] + [{"type": "text", "text": text}]}],
489
+ }
490
+ thinking = os.environ.get("VISION_ANTHROPIC_THINKING", "").strip().lower() or "omit"
491
+ if thinking != "omit":
492
+ if thinking not in {"disabled", "adaptive"}:
493
+ raise VisionError(
494
+ "Unsupported VISION_ANTHROPIC_THINKING; use omit, disabled, or adaptive"
495
+ )
496
+ payload["thinking"] = {"type": thinking}
497
+ endpoint = "/messages"
498
+ extract_text = _anthropic_text
499
+ else:
500
+ raise VisionError(
501
+ "Unsupported VISION_API_PROTOCOL; use chat_completions, responses, or anthropic"
502
+ )
503
+ headers = {
504
+ "Content-Type": "application/json",
505
+ "User-Agent": user_agent,
506
+ }
507
+ if protocol == "anthropic":
508
+ headers.update({
509
+ "x-api-key": api_key,
510
+ "anthropic-version": "2023-06-01",
511
+ })
512
+ else:
513
+ headers["Authorization"] = "Bearer " + api_key
514
+ request = urllib.request.Request(base_url + endpoint, data=json.dumps(payload).encode(), headers=headers)
515
+ retries = 2
516
+ timeout = 180
517
+ for attempt in range(retries + 1):
518
+ try:
519
+ with urllib.request.urlopen(request, timeout=timeout) as response:
520
+ data = json.load(response)
521
+ try:
522
+ text = extract_text(data)
523
+ except (KeyError, IndexError, TypeError) as exc:
524
+ raise VisionError("Vision API returned an incompatible response structure") from exc
525
+ if not text:
526
+ raise VisionError("Vision API returned an empty description")
527
+ return text
528
+ except urllib.error.HTTPError as exc:
529
+ body = _redact(exc.read().decode(errors="replace")[:400], api_key)
530
+ body = body.replace("\r", " ").replace("\n", " ")
531
+ if exc.code in {429, 500, 502, 503, 504, 529} and attempt < retries:
532
+ print(f"vision: HTTP {exc.code}, retrying ({attempt + 1}/{retries})", file=sys.stderr)
533
+ time.sleep(_retry_delay(exc, attempt))
534
+ continue
535
+ raise VisionError(f"Vision API HTTP {exc.code}: {body}", status=exc.code) from exc
536
+ except (urllib.error.URLError, TimeoutError, ConnectionError, http.client.IncompleteRead) as exc:
537
+ if attempt < retries:
538
+ print(f"vision: {type(exc).__name__}, retrying ({attempt + 1}/{retries})", file=sys.stderr)
539
+ time.sleep(min(2 ** attempt, 4))
540
+ continue
541
+ reason = _redact(str(getattr(exc, "reason", str(exc))), api_key)
542
+ raise VisionError(f"Vision API network error: {reason}") from exc
543
+ except json.JSONDecodeError as exc:
544
+ raise VisionError("Vision API returned invalid JSON") from exc
545
+ raise VisionError("Vision API request failed")
546
+
547
+
548
+ def describe_image(image_url: str | list[str], prompt: str | None = None,
549
+ max_tokens: int = 4096, apply_lang: bool = True,
550
+ config: dict | None = None) -> str:
551
+ """Describe one data/http image URL (str) or several (list) in a single
552
+ call. Walks the candidate chain from vision-toolkit.json: transient
553
+ failures retry in place, then fail over to the next candidate; permanent
554
+ failures (bad key, unknown model) fail over immediately. Pass
555
+ config=<one resolved candidate> to pin exactly that candidate
556
+ (vision-doctor --smoke does)."""
557
+ if config is not None:
558
+ candidates = [config]
559
+ elif _EXPLICIT_ENV:
560
+ candidates = [None] # the user's env file is the deliberate override
561
+ else:
562
+ candidates = _resolution()["resolved"] or [None]
563
+ last_error = None
564
+ for index, candidate in enumerate(candidates):
565
+ if candidate is not None and candidate.get("ok"):
566
+ apply_resolved_to_env(candidate)
567
+ try:
568
+ return _describe_attempt(image_url, prompt, max_tokens, apply_lang)
569
+ except VisionError as exc:
570
+ last_error = exc
571
+ permanent = exc.status in PERMANENT_HTTP_STATUSES
572
+ transient_exhausted = exc.status is not None
573
+ if index + 1 < len(candidates) and (permanent or transient_exhausted):
574
+ nxt = candidates[index + 1]
575
+ nxt_label = (nxt or {}).get("model_id") or "environment"
576
+ cur_label = (candidate or {}).get("model_id") or "environment"
577
+ print(f"vision: {cur_label} failed ({exc}); failing over to {nxt_label}",
578
+ file=sys.stderr)
579
+ continue
580
+ raise
581
+ raise last_error # pragma: no cover - the loop always returns or raises