@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.
- package/README.i18n.yaml +3 -3
- package/README.md +23 -8
- package/README.zh.md +23 -8
- package/docs/assets/vision-settings.png +0 -0
- package/docs/requirements-traceability/README.md +1 -1
- package/docs/requirements-traceability/README.zh.md +1 -1
- package/lib/client.js +338 -76
- package/lib/client.js.map +1 -1
- package/lib/config.js +18 -1
- package/lib/config.js.map +1 -1
- package/lib/runtime.js +18 -1
- package/lib/runtime.js.map +1 -1
- package/lib/types/client/index.d.ts +73 -0
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/config.d.ts +12 -1
- package/lib/types/config.d.ts.map +1 -1
- package/lib/types/runtime.d.ts.map +1 -1
- package/lib/types/upstream.d.ts +3 -0
- package/lib/types/upstream.d.ts.map +1 -1
- package/lib/types/web.d.ts +1 -1
- package/lib/upstream.js +3 -0
- package/lib/upstream.js.map +1 -1
- package/lib/web.js +1 -1
- package/package.json +4 -4
- package/src/client/index.tsx +375 -102
- package/src/config.ts +29 -2
- package/src/runtime.ts +17 -1
- package/src/upstream.ts +6 -0
- package/src/web.ts +1 -1
- package/vendor/agent-vision-toolkit/CHANGELOG.md +13 -0
- package/vendor/agent-vision-toolkit/README.md +11 -6
- package/vendor/agent-vision-toolkit/UPSTREAM_MANIFEST.json +14 -9
- package/vendor/agent-vision-toolkit/tests/test_vision_client.py +396 -0
- package/vendor/agent-vision-toolkit/vision_client.py +134 -18
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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 =
|
|
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]
|
|
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(
|
|
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
|