@mengruo/dsh-vision-toolkit 0.1.4 → 0.1.5
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.md +11 -70
- package/README.zh.md +11 -69
- package/lib/client.js +107 -6
- package/lib/client.js.map +1 -1
- package/lib/config.js +35 -0
- package/lib/config.js.map +1 -1
- package/lib/object-storage.js +141 -0
- package/lib/object-storage.js.map +1 -0
- package/lib/runtime.js +139 -26
- package/lib/runtime.js.map +1 -1
- package/lib/types/client/index.d.ts +39 -1
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/config.d.ts +33 -0
- package/lib/types/config.d.ts.map +1 -1
- package/lib/types/object-storage.d.ts +54 -0
- package/lib/types/object-storage.d.ts.map +1 -0
- package/lib/types/runtime.d.ts +12 -0
- package/lib/types/runtime.d.ts.map +1 -1
- package/lib/types/upstream.d.ts +1 -0
- package/lib/types/upstream.d.ts.map +1 -1
- package/lib/types/web.d.ts +7 -0
- package/lib/types/web.d.ts.map +1 -1
- package/lib/upstream.js +3 -0
- package/lib/upstream.js.map +1 -1
- package/lib/web.js +35 -6
- package/lib/web.js.map +1 -1
- package/package.json +3 -1
- package/src/client/index.tsx +151 -6
- package/src/config.ts +67 -0
- package/src/object-storage.ts +174 -0
- package/src/runtime.ts +136 -25
- package/src/upstream.ts +4 -0
- package/src/web.ts +45 -7
- package/vendor/agent-vision-toolkit/UPSTREAM_MANIFEST.json +11 -11
- package/vendor/agent-vision-toolkit/__pycache__/detect.cpython-314.pyc +0 -0
- package/vendor/agent-vision-toolkit/__pycache__/ground.cpython-314.pyc +0 -0
- package/vendor/agent-vision-toolkit/__pycache__/vision_client.cpython-314.pyc +0 -0
- package/vendor/agent-vision-toolkit/bin/__pycache__/glancecpython-314.pyc +0 -0
- package/vendor/agent-vision-toolkit/bin/glance +8 -1
- package/vendor/agent-vision-toolkit/detect.py +13 -7
- package/vendor/agent-vision-toolkit/ground.py +43 -18
- package/vendor/agent-vision-toolkit/tests/test_vision_client.py +88 -0
- package/vendor/agent-vision-toolkit/vision_client.py +84 -6
- package/assets/community-group-qr.png +0 -0
- package/assets/logo_aihubmix.png +0 -0
- package/assets/logo_eapi_dark.png +0 -0
- package/assets/wechat-reward.png +0 -0
package/src/web.ts
CHANGED
|
@@ -75,6 +75,12 @@ export interface VisionToolkitSettingsSnapshot {
|
|
|
75
75
|
source?: string
|
|
76
76
|
writable: boolean
|
|
77
77
|
}>
|
|
78
|
+
/** Object-storage credential state; `ref` is empty when object storage is unset. */
|
|
79
|
+
objectStorageCredential: {
|
|
80
|
+
ref: string
|
|
81
|
+
configured: boolean
|
|
82
|
+
writable: boolean
|
|
83
|
+
}
|
|
78
84
|
runtime: RuntimeManagerStatus
|
|
79
85
|
release: {
|
|
80
86
|
pluginVersion: string
|
|
@@ -121,7 +127,11 @@ interface ApplyUpdateRequest {
|
|
|
121
127
|
expectedVersion: string
|
|
122
128
|
}
|
|
123
129
|
|
|
124
|
-
|
|
130
|
+
interface StorageTestRequest {
|
|
131
|
+
action: 'test-storage'
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
type SettingsRequest = SaveRequest | HealthRequest | CredentialRequest | DeleteCredentialRequest | CheckUpdateRequest | ApplyUpdateRequest | StorageTestRequest
|
|
125
135
|
|
|
126
136
|
interface JsonError {
|
|
127
137
|
ok: false
|
|
@@ -261,6 +271,7 @@ function parseRequest(value: unknown): SettingsRequest {
|
|
|
261
271
|
}
|
|
262
272
|
return { action: 'apply-update', expectedVersion: value.expectedVersion.trim() }
|
|
263
273
|
}
|
|
274
|
+
if (value.action === 'test-storage') return { action: 'test-storage' }
|
|
264
275
|
throw new TypeError(`unsupported action: ${value.action}`)
|
|
265
276
|
}
|
|
266
277
|
|
|
@@ -315,6 +326,14 @@ export class VisionToolkitWebBackend {
|
|
|
315
326
|
writable: info.writable,
|
|
316
327
|
}
|
|
317
328
|
}))
|
|
329
|
+
const objectStorageRef = resolved.objectStorage.credential === undefined ? '' : String(resolved.objectStorage.credential)
|
|
330
|
+
const objectStorageCredential = objectStorageRef === ''
|
|
331
|
+
? { ref: '', configured: false, writable: this.ctx.settings.writable }
|
|
332
|
+
: await this.ctx.credentials.describe(credentialRef(objectStorageRef)).then(info => ({
|
|
333
|
+
ref: objectStorageRef,
|
|
334
|
+
configured: info.configured,
|
|
335
|
+
writable: info.writable,
|
|
336
|
+
}))
|
|
318
337
|
const update = await this.updater.capability()
|
|
319
338
|
return {
|
|
320
339
|
schemaVersion: 1,
|
|
@@ -333,6 +352,7 @@ export class VisionToolkitWebBackend {
|
|
|
333
352
|
writable: credential.writable,
|
|
334
353
|
},
|
|
335
354
|
credentials,
|
|
355
|
+
objectStorageCredential,
|
|
336
356
|
runtime: this.manager.status(),
|
|
337
357
|
release: {
|
|
338
358
|
pluginVersion: PLUGIN_VERSION,
|
|
@@ -380,10 +400,18 @@ export class VisionToolkitWebBackend {
|
|
|
380
400
|
)
|
|
381
401
|
}
|
|
382
402
|
const resolved = resolveConfig(descriptor.value as VisionToolkitConfig)
|
|
383
|
-
const
|
|
403
|
+
const ref = String(request.ref)
|
|
404
|
+
if (resolved.objectStorage.credential !== undefined && String(resolved.objectStorage.credential) === ref) {
|
|
405
|
+
if (!request.value.includes(':')) {
|
|
406
|
+
throw new Error('object storage credential must be "accessKeyId:secretAccessKey"')
|
|
407
|
+
}
|
|
408
|
+
await this.ctx.credentials.set(request.ref, request.value)
|
|
409
|
+
return this.snapshot()
|
|
410
|
+
}
|
|
411
|
+
const provider = resolved.providers.find(entry => String(entry.credential) === ref)
|
|
384
412
|
if (provider === undefined) {
|
|
385
413
|
throw new CredentialReferenceConflictError(
|
|
386
|
-
`credential reference "${
|
|
414
|
+
`credential reference "${ref}" does not match any configured vision provider; reload Settings and try again`,
|
|
387
415
|
)
|
|
388
416
|
}
|
|
389
417
|
if (isBuiltInFreeVisionProvider(provider)) {
|
|
@@ -426,6 +454,11 @@ export class VisionToolkitWebBackend {
|
|
|
426
454
|
}
|
|
427
455
|
}
|
|
428
456
|
|
|
457
|
+
private async testStorage(): Promise<{ detail: string }> {
|
|
458
|
+
if (!this.manager.ready) throw new Error('runtime is not ready; fix Settings and save a valid configuration first')
|
|
459
|
+
return this.manager.current().testObjectStorage()
|
|
460
|
+
}
|
|
461
|
+
|
|
429
462
|
/** Handle the exact Settings route. */
|
|
430
463
|
async handle(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
|
431
464
|
if (req.method === 'GET') {
|
|
@@ -458,6 +491,9 @@ export class VisionToolkitWebBackend {
|
|
|
458
491
|
case 'health':
|
|
459
492
|
responseJson(res, 200, { ok: true, value: await this.health(parsed, req) })
|
|
460
493
|
break
|
|
494
|
+
case 'test-storage':
|
|
495
|
+
responseJson(res, 200, { ok: true, value: await this.testStorage() })
|
|
496
|
+
break
|
|
461
497
|
case 'save':
|
|
462
498
|
responseJson(res, 200, { ok: true, value: await this.save(parsed) })
|
|
463
499
|
break
|
|
@@ -486,14 +522,16 @@ export class VisionToolkitWebBackend {
|
|
|
486
522
|
? error.code
|
|
487
523
|
: parsed.action === 'health'
|
|
488
524
|
? 'health-failed'
|
|
489
|
-
: parsed.action === '
|
|
490
|
-
? '
|
|
491
|
-
: '
|
|
525
|
+
: parsed.action === 'test-storage'
|
|
526
|
+
? 'storage-test-failed'
|
|
527
|
+
: parsed.action === 'credential'
|
|
528
|
+
? 'credential-rejected'
|
|
529
|
+
: 'settings-rejected'
|
|
492
530
|
const updateConflict = updateError && ['update-in-progress', 'update-stale', 'update-unavailable', 'already-current'].includes(error.code)
|
|
493
531
|
const updateGateway = updateError && error.code === 'update-check-failed'
|
|
494
532
|
const status = settingsConflict || credentialConflict || updateConflict
|
|
495
533
|
? 409
|
|
496
|
-
: parsed.action === 'health'
|
|
534
|
+
: parsed.action === 'health' || parsed.action === 'test-storage'
|
|
497
535
|
? 503
|
|
498
536
|
: updateGateway
|
|
499
537
|
? 502
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"repository": "https://github.com/Anionex/agent-vision-toolkit",
|
|
4
4
|
"version": "v0.1.0+snapshot.bc9803d",
|
|
5
5
|
"commit": "bc9803d7d6300c864d17460ecbb33540b26638e0",
|
|
6
|
-
"contentSha256": "
|
|
6
|
+
"contentSha256": "09bedf007e1469a28981227def5aa33e03667ea21fe2972937f0359898e2928e",
|
|
7
7
|
"files": [
|
|
8
8
|
{
|
|
9
9
|
"path": "CHANGELOG.md",
|
|
@@ -32,8 +32,8 @@
|
|
|
32
32
|
},
|
|
33
33
|
{
|
|
34
34
|
"path": "bin/glance",
|
|
35
|
-
"bytes":
|
|
36
|
-
"sha256": "
|
|
35
|
+
"bytes": 3941,
|
|
36
|
+
"sha256": "7552bef0ba396162f64e8fc4c4a53404dfe2d3149ca3d1c5c8bd717facf82af6"
|
|
37
37
|
},
|
|
38
38
|
{
|
|
39
39
|
"path": "bin/ground",
|
|
@@ -47,13 +47,13 @@
|
|
|
47
47
|
},
|
|
48
48
|
{
|
|
49
49
|
"path": "detect.py",
|
|
50
|
-
"bytes":
|
|
51
|
-
"sha256": "
|
|
50
|
+
"bytes": 2532,
|
|
51
|
+
"sha256": "cac2135f9d835215f3ba90bfd70fd84dd02b0e88299f2b60e109e006b4f3a4d9"
|
|
52
52
|
},
|
|
53
53
|
{
|
|
54
54
|
"path": "ground.py",
|
|
55
|
-
"bytes":
|
|
56
|
-
"sha256": "
|
|
55
|
+
"bytes": 11289,
|
|
56
|
+
"sha256": "6d3175d3f5a7d561d55b2a967c5ca3d49940e80014de658ed9a3e5d45a233a2f"
|
|
57
57
|
},
|
|
58
58
|
{
|
|
59
59
|
"path": "skills/vision-tools/scripts/dominant_colors.py",
|
|
@@ -82,13 +82,13 @@
|
|
|
82
82
|
},
|
|
83
83
|
{
|
|
84
84
|
"path": "tests/test_vision_client.py",
|
|
85
|
-
"bytes":
|
|
86
|
-
"sha256": "
|
|
85
|
+
"bytes": 24882,
|
|
86
|
+
"sha256": "87e39429766b38caed6a4ccb1ac8b193094c69858a0f7541940a005f66421e45"
|
|
87
87
|
},
|
|
88
88
|
{
|
|
89
89
|
"path": "vision_client.py",
|
|
90
|
-
"bytes":
|
|
91
|
-
"sha256": "
|
|
90
|
+
"bytes": 15053,
|
|
91
|
+
"sha256": "6800843b99df500a476b1dd6ff5a4c05aa8122ed990f6f91b16a11ee07bdebe6"
|
|
92
92
|
}
|
|
93
93
|
]
|
|
94
94
|
}
|
|
Binary file
|
|
Binary file
|
|
@@ -40,6 +40,13 @@ def region_data_url(path, region):
|
|
|
40
40
|
return "data:image/png;base64," + base64.b64encode(buffer.getvalue()).decode()
|
|
41
41
|
|
|
42
42
|
|
|
43
|
+
def image_url(path):
|
|
44
|
+
"""Pass http(s) URLs through unchanged; encode local files as data URLs."""
|
|
45
|
+
if path.startswith(("http://", "https://")):
|
|
46
|
+
return path
|
|
47
|
+
return image_path_to_data_url(path)
|
|
48
|
+
|
|
49
|
+
|
|
43
50
|
def build_prompt(args, count):
|
|
44
51
|
if args.ocr is not None:
|
|
45
52
|
extra = f" Additional requirements: {args.ocr}" if args.ocr else ""
|
|
@@ -77,7 +84,7 @@ def main():
|
|
|
77
84
|
if args.region and len(args.images) > 1:
|
|
78
85
|
raise VisionError("--region works with exactly one image")
|
|
79
86
|
urls = ([region_data_url(args.images[0], args.region)] if args.region
|
|
80
|
-
else [
|
|
87
|
+
else [image_url(path) for path in args.images])
|
|
81
88
|
answer = describe_image(
|
|
82
89
|
urls,
|
|
83
90
|
build_prompt(args, len(urls)),
|
|
@@ -8,7 +8,7 @@ try:
|
|
|
8
8
|
except ImportError:
|
|
9
9
|
Image = None
|
|
10
10
|
|
|
11
|
-
from ground import GroundError, _position, locate
|
|
11
|
+
from ground import GroundError, _parse_size, _position, locate
|
|
12
12
|
from vision_client import VisionError
|
|
13
13
|
|
|
14
14
|
DEFAULT_CATEGORY = ("UI element (buttons, links, inputs, icons, labels, "
|
|
@@ -38,18 +38,24 @@ def main() -> None:
|
|
|
38
38
|
prog="detect",
|
|
39
39
|
description="Inventory the elements in an image (or a region) with pixel bounding boxes",
|
|
40
40
|
)
|
|
41
|
-
parser.add_argument("image",
|
|
41
|
+
parser.add_argument("image", help="path or http(s) URL to the image")
|
|
42
42
|
parser.add_argument("category", nargs="?",
|
|
43
43
|
help='restrict to a category, e.g. "buttons" or "icons" (default: all UI elements)')
|
|
44
44
|
parser.add_argument("--region", metavar="X1,Y1,X2,Y2",
|
|
45
45
|
help="inventory only this pixel box; output stays in original-image coordinates")
|
|
46
|
+
parser.add_argument("--size", metavar="WxH",
|
|
47
|
+
help="analyzed image dimensions when the image is an http(s) URL")
|
|
46
48
|
args = parser.parse_args()
|
|
47
49
|
try:
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
50
|
+
size = _parse_size(args.size) if args.size else None
|
|
51
|
+
matches = locate(args.image, build_target(args.category), region=args.region, size=size)
|
|
52
|
+
if size is not None:
|
|
53
|
+
width, height = size
|
|
54
|
+
else:
|
|
55
|
+
if Image is None:
|
|
56
|
+
raise GroundError("detect requires Pillow; install the optional dependency pillow first")
|
|
57
|
+
with Image.open(Path(args.image).expanduser()) as image:
|
|
58
|
+
width, height = image.size
|
|
53
59
|
except (GroundError, VisionError) as exc:
|
|
54
60
|
parser.exit(1, f"detect: {exc}\n")
|
|
55
61
|
for line in format_inventory(matches, width, height):
|
|
@@ -184,23 +184,32 @@ def _parse_region(region: str, width: int, height: int) -> tuple[int, int, int,
|
|
|
184
184
|
return box
|
|
185
185
|
|
|
186
186
|
|
|
187
|
-
def locate(image_path:
|
|
187
|
+
def locate(image_path: str, target: str, region: str | None = None,
|
|
188
|
+
size: tuple[int, int] | None = None) -> list[Match]:
|
|
188
189
|
if Image is None:
|
|
189
190
|
raise GroundError("ground requires Pillow; install the optional dependency pillow first")
|
|
190
191
|
load_default_env()
|
|
191
192
|
box = None
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
193
|
+
if size is not None:
|
|
194
|
+
# URL transfer: the image was uploaded to object storage and this caller
|
|
195
|
+
# supplies the analyzed dimensions, so no local file access is needed.
|
|
196
|
+
width, height = size
|
|
197
|
+
url = image_path if image_path.startswith(("http://", "https://")) else image_path_to_data_url(image_path)
|
|
198
|
+
else:
|
|
199
|
+
local = Path(image_path).expanduser()
|
|
200
|
+
try:
|
|
201
|
+
with Image.open(local) as image:
|
|
202
|
+
width, height = image.size
|
|
203
|
+
if region:
|
|
204
|
+
box = _parse_region(region, width, height)
|
|
205
|
+
buffer = io.BytesIO()
|
|
206
|
+
image.crop(box).save(buffer, format="PNG")
|
|
207
|
+
url = "data:image/png;base64," + base64.b64encode(buffer.getvalue()).decode()
|
|
208
|
+
except (OSError, ValueError) as exc:
|
|
209
|
+
raise GroundError(f"Cannot read image: {image_path}") from exc
|
|
210
|
+
if box is None:
|
|
211
|
+
url = image_path_to_data_url(local)
|
|
202
212
|
if box is None:
|
|
203
|
-
url = image_path_to_data_url(image_path)
|
|
204
213
|
width_used, height_used = width, height
|
|
205
214
|
else:
|
|
206
215
|
width_used, height_used = box[2] - box[0], box[3] - box[1]
|
|
@@ -216,6 +225,16 @@ def locate(image_path: Path, target: str, region: str | None = None) -> list[Mat
|
|
|
216
225
|
m.bbox[2] + box[0], m.bbox[3] + box[1])) for m in matches]
|
|
217
226
|
|
|
218
227
|
|
|
228
|
+
def _parse_size(value: str) -> tuple[int, int]:
|
|
229
|
+
try:
|
|
230
|
+
width, height = (int(part) for part in value.lower().split("x"))
|
|
231
|
+
except (ValueError, AttributeError):
|
|
232
|
+
raise GroundError("--size expects WIDTHxHEIGHT (e.g. 1024x768)") from None
|
|
233
|
+
if width <= 0 or height <= 0:
|
|
234
|
+
raise GroundError("--size expects positive WIDTHxHEIGHT")
|
|
235
|
+
return width, height
|
|
236
|
+
|
|
237
|
+
|
|
219
238
|
def _position(box: tuple[int, int, int, int], width: int, height: int) -> str:
|
|
220
239
|
x1, y1, x2, y2 = box
|
|
221
240
|
x = (x1 + x2) / 2
|
|
@@ -246,17 +265,23 @@ def main() -> None:
|
|
|
246
265
|
prog="ground",
|
|
247
266
|
description="Locate targets in an image with natural language and output pixel coordinates",
|
|
248
267
|
)
|
|
249
|
-
parser.add_argument("image",
|
|
268
|
+
parser.add_argument("image", help="path or http(s) URL to the image")
|
|
250
269
|
parser.add_argument("target", help="target object or region to locate")
|
|
251
270
|
parser.add_argument("--region", metavar="X1,Y1,X2,Y2",
|
|
252
271
|
help="search only this pixel box; output stays in original-image coordinates")
|
|
272
|
+
parser.add_argument("--size", metavar="WxH",
|
|
273
|
+
help="analyzed image dimensions when the image is an http(s) URL")
|
|
253
274
|
args = parser.parse_args()
|
|
254
275
|
try:
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
276
|
+
size = _parse_size(args.size) if args.size else None
|
|
277
|
+
matches = locate(args.image, args.target, region=args.region, size=size)
|
|
278
|
+
if size is not None:
|
|
279
|
+
width, height = size
|
|
280
|
+
else:
|
|
281
|
+
if Image is None:
|
|
282
|
+
raise GroundError("ground requires Pillow; install the optional dependency pillow first")
|
|
283
|
+
with Image.open(Path(args.image).expanduser()) as image:
|
|
284
|
+
width, height = image.size
|
|
260
285
|
except (GroundError, VisionError) as exc:
|
|
261
286
|
parser.exit(1, f"ground: {exc}\n")
|
|
262
287
|
for line in format_matches(matches, width, height):
|
|
@@ -408,6 +408,94 @@ def main():
|
|
|
408
408
|
os.environ.pop("VISION_API_PROTOCOL", None)
|
|
409
409
|
assert Handler.calls == 0
|
|
410
410
|
|
|
411
|
+
# VISION_STREAM truthiness mirrors VISION_SSL_VERIFY: off by default.
|
|
412
|
+
for enabled_value in ("1", "true", "yes", "on", " TRUE "):
|
|
413
|
+
os.environ["VISION_STREAM"] = enabled_value
|
|
414
|
+
assert vision_client._stream_enabled() is True
|
|
415
|
+
for disabled_value in ("", "0", "false", "no", "off", "disabled"):
|
|
416
|
+
os.environ["VISION_STREAM"] = disabled_value
|
|
417
|
+
assert vision_client._stream_enabled() is False
|
|
418
|
+
os.environ.pop("VISION_STREAM", None)
|
|
419
|
+
|
|
420
|
+
# Streamed chat_completions: `stream: true` is sent and deltas accumulate.
|
|
421
|
+
Handler.calls, Handler.statuses, Handler.bodies, Handler.response_headers = 0, [200], [
|
|
422
|
+
(
|
|
423
|
+
'data: {"choices":[{"delta":{"content":"Hello "}}]}\n\n'
|
|
424
|
+
'data: {"choices":[{"delta":{"content":"streamed"}}]}\n\n'
|
|
425
|
+
'data: {"choices":[{"delta":{},"finish_reason":"stop"}]}\n\n'
|
|
426
|
+
'data: [DONE]\n\n'
|
|
427
|
+
).encode(),
|
|
428
|
+
], []
|
|
429
|
+
os.environ["VISION_STREAM"] = "1"
|
|
430
|
+
try:
|
|
431
|
+
assert vision_client.describe_image("data:image/png;base64,AAAA") == "Hello streamed"
|
|
432
|
+
finally:
|
|
433
|
+
os.environ.pop("VISION_STREAM", None)
|
|
434
|
+
assert json.loads(Handler.last_body)["stream"] is True
|
|
435
|
+
assert Handler.calls == 1
|
|
436
|
+
|
|
437
|
+
# Default is non-streaming: no `stream` key is sent.
|
|
438
|
+
Handler.calls, Handler.statuses, Handler.bodies = 0, [200], []
|
|
439
|
+
vision_client.describe_image("data:image/png;base64,AAAA")
|
|
440
|
+
assert "stream" not in json.loads(Handler.last_body)
|
|
441
|
+
assert Handler.calls == 1
|
|
442
|
+
|
|
443
|
+
# Anthropic streaming: text_delta deltas accumulate across blocks.
|
|
444
|
+
Handler.calls, Handler.statuses, Handler.bodies, Handler.response_headers = 0, [200], [
|
|
445
|
+
(
|
|
446
|
+
'data: {"type":"message_start","message":{"id":"m1"}}\n\n'
|
|
447
|
+
'data: {"type":"content_block_start","index":0,"content_block":{"type":"text"}}\n\n'
|
|
448
|
+
'data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"anthropic "}}\n\n'
|
|
449
|
+
'data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"streamed"}}\n\n'
|
|
450
|
+
'data: {"type":"content_block_stop","index":0}\n\n'
|
|
451
|
+
'data: {"type":"message_stop"}\n\n'
|
|
452
|
+
).encode(),
|
|
453
|
+
], []
|
|
454
|
+
os.environ["VISION_API_PROTOCOL"] = "anthropic"
|
|
455
|
+
os.environ["VISION_STREAM"] = "1"
|
|
456
|
+
try:
|
|
457
|
+
assert vision_client.describe_image("data:image/png;base64,AAAA") == "anthropic streamed"
|
|
458
|
+
finally:
|
|
459
|
+
os.environ.pop("VISION_API_PROTOCOL", None)
|
|
460
|
+
os.environ.pop("VISION_STREAM", None)
|
|
461
|
+
assert json.loads(Handler.last_body)["stream"] is True
|
|
462
|
+
assert Handler.calls == 1
|
|
463
|
+
|
|
464
|
+
# Responses streaming: output_text deltas accumulate.
|
|
465
|
+
Handler.calls, Handler.statuses, Handler.bodies, Handler.response_headers = 0, [200], [
|
|
466
|
+
(
|
|
467
|
+
'data: {"type":"response.output_text.delta","delta":"responses "}\n\n'
|
|
468
|
+
'data: {"type":"response.output_text.delta","delta":"streamed"}\n\n'
|
|
469
|
+
'data: {"type":"response.completed"}\n\n'
|
|
470
|
+
).encode(),
|
|
471
|
+
], []
|
|
472
|
+
os.environ["VISION_API_PROTOCOL"] = "responses"
|
|
473
|
+
os.environ["VISION_STREAM"] = "1"
|
|
474
|
+
try:
|
|
475
|
+
assert vision_client.describe_image("data:image/png;base64,AAAA") == "responses streamed"
|
|
476
|
+
finally:
|
|
477
|
+
os.environ.pop("VISION_API_PROTOCOL", None)
|
|
478
|
+
os.environ.pop("VISION_STREAM", None)
|
|
479
|
+
assert json.loads(Handler.last_body)["stream"] is True
|
|
480
|
+
assert Handler.calls == 1
|
|
481
|
+
|
|
482
|
+
# A streamed error event surfaces as a redacted VisionError.
|
|
483
|
+
Handler.calls, Handler.statuses, Handler.bodies = 0, [200], [
|
|
484
|
+
b'data: {"error":{"message":"overloaded for test-key","code":"overloaded"}}\n\n'
|
|
485
|
+
]
|
|
486
|
+
os.environ["VISION_STREAM"] = "1"
|
|
487
|
+
try:
|
|
488
|
+
try:
|
|
489
|
+
vision_client.describe_image("data:image/png;base64,AAAA")
|
|
490
|
+
except vision_client.VisionError as exc:
|
|
491
|
+
assert "test-key" not in str(exc)
|
|
492
|
+
assert "<redacted>" in str(exc)
|
|
493
|
+
else:
|
|
494
|
+
raise AssertionError("streamed errors must fail cleanly")
|
|
495
|
+
finally:
|
|
496
|
+
os.environ.pop("VISION_STREAM", None)
|
|
497
|
+
assert Handler.calls == 1
|
|
498
|
+
|
|
411
499
|
Handler.calls, Handler.statuses, Handler.bodies = 0, [200], []
|
|
412
500
|
with tempfile.TemporaryDirectory() as raw:
|
|
413
501
|
image = Path(raw) / "fixture.png"
|
|
@@ -196,6 +196,77 @@ def _retryable_http_error(status: int, body: bytes) -> bool:
|
|
|
196
196
|
}
|
|
197
197
|
|
|
198
198
|
|
|
199
|
+
def _stream_enabled() -> bool:
|
|
200
|
+
"""Whether VISION_STREAM asks for a streamed completion (default off)."""
|
|
201
|
+
value = os.environ.get("VISION_STREAM", "").strip().lower()
|
|
202
|
+
return value in {"1", "true", "yes", "on"}
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _stream_error(payload: dict) -> str | None:
|
|
206
|
+
"""Return a human-readable error detail from one SSE event, or None."""
|
|
207
|
+
error = payload.get("error")
|
|
208
|
+
if isinstance(error, dict):
|
|
209
|
+
code = error.get("code")
|
|
210
|
+
message = error.get("message")
|
|
211
|
+
if code is not None or message is not None:
|
|
212
|
+
return str(message) if message is not None else str(code)
|
|
213
|
+
return json.dumps(error)
|
|
214
|
+
if payload.get("type") == "error":
|
|
215
|
+
error = payload.get("error")
|
|
216
|
+
if isinstance(error, dict) and error.get("message") is not None:
|
|
217
|
+
return str(error["message"])
|
|
218
|
+
return None
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _stream_text(stream, protocol: str, api_key: str) -> str:
|
|
222
|
+
"""Accumulate the text deltas of a server-sent-events completion stream."""
|
|
223
|
+
parts: list[str] = []
|
|
224
|
+
error_detail: str | None = None
|
|
225
|
+
for raw_line in stream:
|
|
226
|
+
if isinstance(raw_line, (bytes, bytearray)):
|
|
227
|
+
line = raw_line.decode("utf-8", errors="replace")
|
|
228
|
+
else:
|
|
229
|
+
line = str(raw_line)
|
|
230
|
+
line = line.strip()
|
|
231
|
+
if not line or line.startswith(":"):
|
|
232
|
+
continue
|
|
233
|
+
data = line[5:].strip() if line.startswith("data:") else line
|
|
234
|
+
if data == "[DONE]":
|
|
235
|
+
break
|
|
236
|
+
try:
|
|
237
|
+
payload = json.loads(data)
|
|
238
|
+
except (json.JSONDecodeError, ValueError):
|
|
239
|
+
continue
|
|
240
|
+
if not isinstance(payload, dict):
|
|
241
|
+
continue
|
|
242
|
+
error_detail = _stream_error(payload)
|
|
243
|
+
if error_detail is not None:
|
|
244
|
+
break
|
|
245
|
+
if protocol == "anthropic":
|
|
246
|
+
if payload.get("type") == "content_block_delta":
|
|
247
|
+
delta = payload.get("delta")
|
|
248
|
+
if isinstance(delta, dict) and delta.get("type") == "text_delta":
|
|
249
|
+
text = delta.get("text")
|
|
250
|
+
if isinstance(text, str):
|
|
251
|
+
parts.append(text)
|
|
252
|
+
elif protocol == "responses":
|
|
253
|
+
if payload.get("type") == "response.output_text.delta":
|
|
254
|
+
delta = payload.get("delta")
|
|
255
|
+
if isinstance(delta, str):
|
|
256
|
+
parts.append(delta)
|
|
257
|
+
else: # chat_completions
|
|
258
|
+
choices = payload.get("choices")
|
|
259
|
+
if isinstance(choices, list) and choices:
|
|
260
|
+
choice = choices[0]
|
|
261
|
+
delta = choice.get("delta") if isinstance(choice, dict) else None
|
|
262
|
+
content = delta.get("content") if isinstance(delta, dict) else None
|
|
263
|
+
if isinstance(content, str):
|
|
264
|
+
parts.append(content)
|
|
265
|
+
if error_detail is not None:
|
|
266
|
+
raise VisionError(f"Vision API stream error: {_redact(error_detail, api_key)}")
|
|
267
|
+
return "".join(parts)
|
|
268
|
+
|
|
269
|
+
|
|
199
270
|
def describe_image(image_url: str | list[str], prompt: str | None = None, max_tokens: int = 4096,
|
|
200
271
|
apply_lang: bool = True) -> str:
|
|
201
272
|
"""Describe one data/http image URL (str) or several (list) in a single call."""
|
|
@@ -216,6 +287,7 @@ def describe_image(image_url: str | list[str], prompt: str | None = None, max_to
|
|
|
216
287
|
text = f"{instruction}\n\n{text}"
|
|
217
288
|
model = _required("VISION_MODEL")
|
|
218
289
|
protocol = os.environ.get("VISION_API_PROTOCOL", "").strip().lower() or "chat_completions"
|
|
290
|
+
stream = _stream_enabled()
|
|
219
291
|
if protocol == "responses":
|
|
220
292
|
payload = {
|
|
221
293
|
"model": model,
|
|
@@ -263,6 +335,8 @@ def describe_image(image_url: str | list[str], prompt: str | None = None, max_to
|
|
|
263
335
|
raise VisionError(
|
|
264
336
|
"Unsupported VISION_API_PROTOCOL; use chat_completions, responses, or anthropic"
|
|
265
337
|
)
|
|
338
|
+
if stream:
|
|
339
|
+
payload["stream"] = True
|
|
266
340
|
headers = {
|
|
267
341
|
"Content-Type": "application/json",
|
|
268
342
|
"User-Agent": user_agent,
|
|
@@ -280,12 +354,16 @@ def describe_image(image_url: str | list[str], prompt: str | None = None, max_to
|
|
|
280
354
|
timeout = 180
|
|
281
355
|
for attempt in range(retries + 1):
|
|
282
356
|
try:
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
357
|
+
if stream:
|
|
358
|
+
with urllib.request.urlopen(request, timeout=timeout, context=context) as response:
|
|
359
|
+
text = _stream_text(response, protocol, api_key)
|
|
360
|
+
else:
|
|
361
|
+
with urllib.request.urlopen(request, timeout=timeout, context=context) as response:
|
|
362
|
+
data = json.load(response)
|
|
363
|
+
try:
|
|
364
|
+
text = extract_text(data)
|
|
365
|
+
except (KeyError, IndexError, TypeError) as exc:
|
|
366
|
+
raise VisionError("Vision API returned an incompatible response structure") from exc
|
|
289
367
|
if not text:
|
|
290
368
|
raise VisionError("Vision API returned an empty description")
|
|
291
369
|
return text
|
|
Binary file
|
package/assets/logo_aihubmix.png
DELETED
|
Binary file
|
|
Binary file
|
package/assets/wechat-reward.png
DELETED
|
Binary file
|