@saifymatteo/pi-vision 0.0.0-stage → 0.1.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.
- package/LICENSE +661 -0
- package/NOTICE.md +5 -0
- package/README.md +42 -2
- package/examples/vision-toolkit.example.json +12 -0
- package/package.json +36 -4
- package/skills/vision-tools/LICENSE +21 -0
- package/skills/vision-tools/SKILL.md +134 -0
- package/skills/vision-tools/agents/openai.yaml +4 -0
- package/skills/vision-tools/bin/crop +94 -0
- package/skills/vision-tools/bin/detect +18 -0
- package/skills/vision-tools/bin/glance +98 -0
- package/skills/vision-tools/bin/ground +18 -0
- package/skills/vision-tools/bin/trace +134 -0
- package/skills/vision-tools/bin/vision-doctor +168 -0
- package/skills/vision-tools/detect.py +56 -0
- package/skills/vision-tools/ground.py +216 -0
- package/skills/vision-tools/references/gui.md +93 -0
- package/skills/vision-tools/references/long-screenshot-ocr.md +103 -0
- package/skills/vision-tools/references/restore-graphic.md +92 -0
- package/skills/vision-tools/references/restore-structure.md +47 -0
- package/skills/vision-tools/references/restore-ui.md +200 -0
- package/skills/vision-tools/scripts/dominant_colors.py +224 -0
- package/skills/vision-tools/scripts/extract_fg.py +278 -0
- package/skills/vision-tools/scripts/html_shot.py +111 -0
- package/skills/vision-tools/scripts/long_screenshot_ocr.py +1245 -0
- package/skills/vision-tools/scripts/pixel_diff.py +88 -0
- package/skills/vision-tools/vision_client.py +581 -0
- package/src/index.ts +728 -0
|
@@ -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
|