@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.
Files changed (47) hide show
  1. package/README.md +11 -70
  2. package/README.zh.md +11 -69
  3. package/lib/client.js +107 -6
  4. package/lib/client.js.map +1 -1
  5. package/lib/config.js +35 -0
  6. package/lib/config.js.map +1 -1
  7. package/lib/object-storage.js +141 -0
  8. package/lib/object-storage.js.map +1 -0
  9. package/lib/runtime.js +139 -26
  10. package/lib/runtime.js.map +1 -1
  11. package/lib/types/client/index.d.ts +39 -1
  12. package/lib/types/client/index.d.ts.map +1 -1
  13. package/lib/types/config.d.ts +33 -0
  14. package/lib/types/config.d.ts.map +1 -1
  15. package/lib/types/object-storage.d.ts +54 -0
  16. package/lib/types/object-storage.d.ts.map +1 -0
  17. package/lib/types/runtime.d.ts +12 -0
  18. package/lib/types/runtime.d.ts.map +1 -1
  19. package/lib/types/upstream.d.ts +1 -0
  20. package/lib/types/upstream.d.ts.map +1 -1
  21. package/lib/types/web.d.ts +7 -0
  22. package/lib/types/web.d.ts.map +1 -1
  23. package/lib/upstream.js +3 -0
  24. package/lib/upstream.js.map +1 -1
  25. package/lib/web.js +35 -6
  26. package/lib/web.js.map +1 -1
  27. package/package.json +3 -1
  28. package/src/client/index.tsx +151 -6
  29. package/src/config.ts +67 -0
  30. package/src/object-storage.ts +174 -0
  31. package/src/runtime.ts +136 -25
  32. package/src/upstream.ts +4 -0
  33. package/src/web.ts +45 -7
  34. package/vendor/agent-vision-toolkit/UPSTREAM_MANIFEST.json +11 -11
  35. package/vendor/agent-vision-toolkit/__pycache__/detect.cpython-314.pyc +0 -0
  36. package/vendor/agent-vision-toolkit/__pycache__/ground.cpython-314.pyc +0 -0
  37. package/vendor/agent-vision-toolkit/__pycache__/vision_client.cpython-314.pyc +0 -0
  38. package/vendor/agent-vision-toolkit/bin/__pycache__/glancecpython-314.pyc +0 -0
  39. package/vendor/agent-vision-toolkit/bin/glance +8 -1
  40. package/vendor/agent-vision-toolkit/detect.py +13 -7
  41. package/vendor/agent-vision-toolkit/ground.py +43 -18
  42. package/vendor/agent-vision-toolkit/tests/test_vision_client.py +88 -0
  43. package/vendor/agent-vision-toolkit/vision_client.py +84 -6
  44. package/assets/community-group-qr.png +0 -0
  45. package/assets/logo_aihubmix.png +0 -0
  46. package/assets/logo_eapi_dark.png +0 -0
  47. 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
- type SettingsRequest = SaveRequest | HealthRequest | CredentialRequest | DeleteCredentialRequest | CheckUpdateRequest | ApplyUpdateRequest
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 provider = resolved.providers.find(entry => String(entry.credential) === String(request.ref))
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 "${String(request.ref)}" does not match any configured vision provider; reload Settings and try again`,
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 === 'credential'
490
- ? 'credential-rejected'
491
- : 'settings-rejected'
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": "0eaa22a0d1d0dd6d6523a0ddd3fa8197246005042bc164a703fb05102b287a38",
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": 3742,
36
- "sha256": "fa4ba52e8e180475b948daec817151d893d957f973f7d9df1b8bbf201051cefc"
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": 2218,
51
- "sha256": "48a7070084f5b23b1477fa9a690ef1e679da8a03228e64a1ac583de633040bfc"
50
+ "bytes": 2532,
51
+ "sha256": "cac2135f9d835215f3ba90bfd70fd84dd02b0e88299f2b60e109e006b4f3a4d9"
52
52
  },
53
53
  {
54
54
  "path": "ground.py",
55
- "bytes": 10117,
56
- "sha256": "845e56dbdf92f2c79495170f5d215de49985b3099012b4d398231d3c78bd0090"
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": 20481,
86
- "sha256": "b019f0da28b7567226292044f791059ee34d7f0088bd9ceb328cfa1bab250a48"
85
+ "bytes": 24882,
86
+ "sha256": "87e39429766b38caed6a4ccb1ac8b193094c69858a0f7541940a005f66421e45"
87
87
  },
88
88
  {
89
89
  "path": "vision_client.py",
90
- "bytes": 11838,
91
- "sha256": "c6a48048c864e99513faf90ba17c4ac0d9462bb718683682ed8eda12c0e2cfe0"
90
+ "bytes": 15053,
91
+ "sha256": "6800843b99df500a476b1dd6ff5a4c05aa8122ed990f6f91b16a11ee07bdebe6"
92
92
  }
93
93
  ]
94
94
  }
@@ -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 [image_path_to_data_url(path) for path in args.images])
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", type=Path, help="path to the 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
- matches = locate(args.image.expanduser(), build_target(args.category), region=args.region)
49
- if Image is None:
50
- raise GroundError("detect requires Pillow; install the optional dependency pillow first")
51
- with Image.open(args.image.expanduser()) as image:
52
- width, height = image.size
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: Path, target: str, region: str | None = None) -> list[Match]:
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
- try:
193
- with Image.open(image_path) as image:
194
- width, height = image.size
195
- if region:
196
- box = _parse_region(region, width, height)
197
- buffer = io.BytesIO()
198
- image.crop(box).save(buffer, format="PNG")
199
- url = "data:image/png;base64," + base64.b64encode(buffer.getvalue()).decode()
200
- except (OSError, ValueError) as exc:
201
- raise GroundError(f"Cannot read image: {image_path}") from exc
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", type=Path, help="path to the 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
- matches = locate(args.image.expanduser(), args.target, region=args.region)
256
- if Image is None:
257
- raise GroundError("ground requires Pillow; install the optional dependency pillow first")
258
- with Image.open(args.image.expanduser()) as image:
259
- width, height = image.size
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
- with urllib.request.urlopen(request, timeout=timeout, context=context) as response:
284
- data = json.load(response)
285
- try:
286
- text = extract_text(data)
287
- except (KeyError, IndexError, TypeError) as exc:
288
- raise VisionError("Vision API returned an incompatible response structure") from exc
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
Binary file
Binary file
Binary file