@mengruo/dsh-vision-toolkit 0.1.3 → 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 (101) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +15 -70
  3. package/README.zh.md +15 -69
  4. package/docs/requirements-traceability/README.i18n.yaml +2 -2
  5. package/docs/requirements-traceability/README.md +1 -1
  6. package/docs/requirements-traceability/README.zh.md +1 -1
  7. package/lib/artifact-access.js +20 -2
  8. package/lib/artifact-access.js.map +1 -1
  9. package/lib/client.js +128 -12
  10. package/lib/client.js.map +1 -1
  11. package/lib/config.js +69 -0
  12. package/lib/config.js.map +1 -1
  13. package/lib/errors.js +25 -2
  14. package/lib/errors.js.map +1 -1
  15. package/lib/evidence-cache.js +2 -1
  16. package/lib/evidence-cache.js.map +1 -1
  17. package/lib/exposure.js +14 -1
  18. package/lib/exposure.js.map +1 -1
  19. package/lib/image-input-variants.js +22 -12
  20. package/lib/image-input-variants.js.map +1 -1
  21. package/lib/index.js +53 -6
  22. package/lib/index.js.map +1 -1
  23. package/lib/object-storage.js +141 -0
  24. package/lib/object-storage.js.map +1 -0
  25. package/lib/paste-images.js +67 -19
  26. package/lib/paste-images.js.map +1 -1
  27. package/lib/paths.js +214 -28
  28. package/lib/paths.js.map +1 -1
  29. package/lib/runtime-manager.js +76 -10
  30. package/lib/runtime-manager.js.map +1 -1
  31. package/lib/runtime.js +219 -38
  32. package/lib/runtime.js.map +1 -1
  33. package/lib/storage-history.js +154 -0
  34. package/lib/storage-history.js.map +1 -0
  35. package/lib/types/artifact-access.d.ts.map +1 -1
  36. package/lib/types/client/index.d.ts +45 -2
  37. package/lib/types/client/index.d.ts.map +1 -1
  38. package/lib/types/client/paste-images.d.ts +2 -0
  39. package/lib/types/client/paste-images.d.ts.map +1 -1
  40. package/lib/types/config.d.ts +55 -0
  41. package/lib/types/config.d.ts.map +1 -1
  42. package/lib/types/errors.d.ts +18 -2
  43. package/lib/types/errors.d.ts.map +1 -1
  44. package/lib/types/evidence-cache.d.ts +1 -1
  45. package/lib/types/evidence-cache.d.ts.map +1 -1
  46. package/lib/types/exposure.d.ts.map +1 -1
  47. package/lib/types/image-input-variants.d.ts +5 -3
  48. package/lib/types/image-input-variants.d.ts.map +1 -1
  49. package/lib/types/index.d.ts.map +1 -1
  50. package/lib/types/object-storage.d.ts +54 -0
  51. package/lib/types/object-storage.d.ts.map +1 -0
  52. package/lib/types/paste-images.d.ts +12 -4
  53. package/lib/types/paste-images.d.ts.map +1 -1
  54. package/lib/types/paths.d.ts +31 -5
  55. package/lib/types/paths.d.ts.map +1 -1
  56. package/lib/types/runtime-manager.d.ts +28 -4
  57. package/lib/types/runtime-manager.d.ts.map +1 -1
  58. package/lib/types/runtime.d.ts +31 -1
  59. package/lib/types/runtime.d.ts.map +1 -1
  60. package/lib/types/storage-history.d.ts +63 -0
  61. package/lib/types/storage-history.d.ts.map +1 -0
  62. package/lib/types/upstream.d.ts +1 -0
  63. package/lib/types/upstream.d.ts.map +1 -1
  64. package/lib/types/web.d.ts +7 -0
  65. package/lib/types/web.d.ts.map +1 -1
  66. package/lib/upstream.js +34 -8
  67. package/lib/upstream.js.map +1 -1
  68. package/lib/web.js +44 -9
  69. package/lib/web.js.map +1 -1
  70. package/package.json +3 -1
  71. package/src/artifact-access.ts +22 -2
  72. package/src/client/index.tsx +169 -9
  73. package/src/client/paste-images.tsx +14 -4
  74. package/src/config.ts +128 -0
  75. package/src/errors.ts +25 -2
  76. package/src/evidence-cache.ts +2 -1
  77. package/src/exposure.ts +16 -2
  78. package/src/image-input-variants.ts +21 -6
  79. package/src/index.ts +65 -6
  80. package/src/object-storage.ts +174 -0
  81. package/src/paste-images.ts +81 -19
  82. package/src/paths.ts +249 -28
  83. package/src/runtime-manager.ts +93 -10
  84. package/src/runtime.ts +215 -36
  85. package/src/storage-history.ts +172 -0
  86. package/src/upstream.ts +36 -7
  87. package/src/web.ts +54 -9
  88. package/vendor/agent-vision-toolkit/UPSTREAM_MANIFEST.json +11 -11
  89. package/vendor/agent-vision-toolkit/__pycache__/detect.cpython-314.pyc +0 -0
  90. package/vendor/agent-vision-toolkit/__pycache__/ground.cpython-314.pyc +0 -0
  91. package/vendor/agent-vision-toolkit/__pycache__/vision_client.cpython-314.pyc +0 -0
  92. package/vendor/agent-vision-toolkit/bin/__pycache__/glancecpython-314.pyc +0 -0
  93. package/vendor/agent-vision-toolkit/bin/glance +8 -1
  94. package/vendor/agent-vision-toolkit/detect.py +13 -7
  95. package/vendor/agent-vision-toolkit/ground.py +43 -18
  96. package/vendor/agent-vision-toolkit/tests/test_vision_client.py +88 -0
  97. package/vendor/agent-vision-toolkit/vision_client.py +84 -6
  98. package/assets/community-group-qr.png +0 -0
  99. package/assets/logo_aihubmix.png +0 -0
  100. package/assets/logo_eapi_dark.png +0 -0
  101. package/assets/wechat-reward.png +0 -0
package/lib/runtime.js CHANGED
@@ -15,6 +15,7 @@ import { isBuiltInFreeVisionProvider } from "./config.js";
15
15
  import { BUILT_IN_FREE_VISION_KEY } from "./defaults.js";
16
16
  import { evidenceRuntimeFingerprint } from "./evidence-cache.js";
17
17
  import { VisionToolkitError } from "./errors.js";
18
+ import { ObjectStorageClient, splitObjectStorageCredential } from "./object-storage.js";
18
19
  import { assertDistinctOutput, commitStagedDirectory, commitStagedOutput, createPathPolicy, createStagedDirectory, createStagedOutput, isWithin, resolveHtmlFile, resolveInputFile, resolveOutputDirectory, resolveOutputFile, seedStagedDirectory, } from "./paths.js";
19
20
  import { parseCropOutput, parseDominantColorsOutput, parseExtractForegroundOutput, parseHtmlScreenshotOutput, parseLocationOutput, parsePixelDiffOutput, parseTraceOutput, UpstreamAdapter, } from "./upstream.js";
20
21
  import { PLUGIN_VERSION } from "./version.js";
@@ -174,8 +175,25 @@ const FORMAT_BY_EXTENSION = new Map([
174
175
  ['.webp', 'webp'],
175
176
  ]);
176
177
  const HEX_COLOR_PATTERN = /^#[0-9A-F]{6}$/;
177
- /** Error codes a provider retries within its attempt budget (429 is handled separately). */
178
- const RETRYABLE_CODES = new Set(['service', 'timeout']);
178
+ /** MIME type for one analyzed image format, used when uploading to object storage. */
179
+ function imageMimeType(format) {
180
+ switch (format) {
181
+ case 'png': return 'image/png';
182
+ case 'jpeg': return 'image/jpeg';
183
+ case 'gif': return 'image/gif';
184
+ case 'webp': return 'image/webp';
185
+ default: return 'application/octet-stream';
186
+ }
187
+ }
188
+ /**
189
+ * Error codes a provider retries against the SAME provider within its
190
+ * `attempts` budget. Only transient failures are worth re-requesting: a
191
+ * timeout may clear on the next attempt and a 5xx / network drop is usually
192
+ * ephemeral. Deterministic failures (auth, quota, rate_limit, invalid_request,
193
+ * region, tos) must fail over to the next provider immediately instead of
194
+ * re-requesting a backend that cannot succeed with the same input.
195
+ */
196
+ const RETRYABLE_CODES = new Set(['timeout', 'server', 'network']);
179
197
  /** Resolve as soon as `signal` aborts (or immediately when already aborted). */
180
198
  function untilAbort(signal) {
181
199
  if (signal.aborted)
@@ -331,13 +349,15 @@ export function parseRegion(region) {
331
349
  export class VisionToolkitRuntime {
332
350
  ctx;
333
351
  config;
352
+ readableStorageDirs;
334
353
  semaphores = new Map();
335
354
  glanceCache = new WeakMap();
336
355
  providerGates = new Map();
337
356
  adapter;
338
- constructor(ctx, config, adapter) {
357
+ constructor(ctx, config, adapter, readableStorageDirs = []) {
339
358
  this.ctx = ctx;
340
359
  this.config = config;
360
+ this.readableStorageDirs = readableStorageDirs;
341
361
  this.adapter = adapter ?? new UpstreamAdapter(ctx, config);
342
362
  }
343
363
  /** Pinned and prepared upstream identity. */
@@ -348,6 +368,10 @@ export class VisionToolkitRuntime {
348
368
  get sessionMaxConcurrency() {
349
369
  return this.config.sessionMaxConcurrency;
350
370
  }
371
+ /** Shared storage root belonging to this immutable runtime generation. */
372
+ get storageDirectory() {
373
+ return this.config.storageDir;
374
+ }
351
375
  /** Stable identity for persisted image descriptions produced by this runtime. */
352
376
  get evidenceFingerprint() {
353
377
  return evidenceRuntimeFingerprint(this.config, undefined, process.env.VISION_SSL_VERIFY?.trim());
@@ -478,6 +502,7 @@ export class VisionToolkitRuntime {
478
502
  VISION_API_PROTOCOL: provider.protocol === 'anthropic' ? 'anthropic' : 'chat_completions',
479
503
  VISION_ANTHROPIC_THINKING: provider.anthropicThinking,
480
504
  ...(sslVerify === undefined ? {} : { VISION_SSL_VERIFY: sslVerify }),
505
+ ...(provider.stream ? { VISION_STREAM: '1' } : {}),
481
506
  VISION_USER_AGENT: provider.userAgent,
482
507
  LANG: this.config.language,
483
508
  };
@@ -493,6 +518,8 @@ export class VisionToolkitRuntime {
493
518
  protocol: provider.protocol,
494
519
  anthropicThinking: provider.anthropicThinking,
495
520
  userAgent: provider.userAgent,
521
+ stream: provider.stream,
522
+ uploadViaUrl: provider.uploadViaUrl,
496
523
  })
497
524
  ? { value: BUILT_IN_FREE_VISION_KEY, source: 'built-in' }
498
525
  : await this.ctx.credentials.resolve(provider.credential);
@@ -528,12 +555,12 @@ export class VisionToolkitRuntime {
528
555
  return entry.env;
529
556
  }
530
557
  pathPolicy(workspace) {
531
- return createPathPolicy(workspace, this.config.allowedDirs);
558
+ return createPathPolicy(workspace, this.config.allowedDirs, this.config.storageDir, this.readableStorageDirs);
532
559
  }
533
560
  async compressedImageRoot(policy) {
534
- const root = join(policy.workspace, '.dsh-vision-toolkit', 'tmp', 'compressed-images');
535
- let current = policy.workspace;
536
- for (const segment of ['.dsh-vision-toolkit', 'tmp', 'compressed-images']) {
561
+ const root = join(policy.storageRoot, 'tmp', 'compressed-images');
562
+ let current = policy.storageRoot;
563
+ for (const segment of ['tmp', 'compressed-images']) {
537
564
  current = join(current, segment);
538
565
  try {
539
566
  await mkdir(current, { mode: 0o700 });
@@ -546,13 +573,13 @@ export class VisionToolkitRuntime {
546
573
  if (info.isSymbolicLink() || !info.isDirectory()) {
547
574
  throw new VisionToolkitError('path', `compressed-image cache path is not a real directory: ${current}`);
548
575
  }
549
- if (!isWithin(policy.workspace, current)) {
550
- throw new VisionToolkitError('path', `compressed-image cache path escaped the workspace: ${current}`);
576
+ if (!isWithin(policy.storageRoot, current)) {
577
+ throw new VisionToolkitError('path', `compressed-image cache path escaped plugin storage: ${current}`);
551
578
  }
552
579
  }
553
580
  const canonical = await realpath(root);
554
- if (!isWithin(policy.workspace, canonical)) {
555
- throw new VisionToolkitError('path', 'compressed-image cache resolved outside the workspace');
581
+ if (!isWithin(policy.storageRoot, canonical)) {
582
+ throw new VisionToolkitError('path', 'compressed-image cache resolved outside plugin storage');
556
583
  }
557
584
  return canonical;
558
585
  }
@@ -746,6 +773,83 @@ export class VisionToolkitRuntime {
746
773
  operation.metrics.imageBytes += image.bytes;
747
774
  operation.metrics.imagePixels += image.width * image.height;
748
775
  }
776
+ /** Resolve the configured object storage into a usable client, or undefined. */
777
+ async resolveObjectStorageClient() {
778
+ const objectStorage = this.config.objectStorage;
779
+ if (objectStorage.credential === undefined || objectStorage.endpoint.length === 0 || objectStorage.bucket.length === 0) {
780
+ return undefined;
781
+ }
782
+ let resolved;
783
+ try {
784
+ resolved = await this.ctx.credentials.resolve(objectStorage.credential);
785
+ }
786
+ catch {
787
+ resolved = undefined;
788
+ }
789
+ if (resolved === undefined)
790
+ return undefined;
791
+ let accessKeyId;
792
+ let secretAccessKey;
793
+ try {
794
+ const split = splitObjectStorageCredential(resolved.value);
795
+ accessKeyId = split.accessKeyId;
796
+ secretAccessKey = split.secretAccessKey;
797
+ }
798
+ catch (error) {
799
+ throw new VisionToolkitError('config', 'object storage credential is malformed', { cause: error });
800
+ }
801
+ const settings = {
802
+ endpoint: objectStorage.endpoint,
803
+ bucket: objectStorage.bucket,
804
+ accessKeyId,
805
+ secretAccessKey,
806
+ ...(objectStorage.publicBase === undefined ? {} : { publicBase: objectStorage.publicBase }),
807
+ };
808
+ return new ObjectStorageClient(settings);
809
+ }
810
+ /**
811
+ * Upload the prepared images to object storage and return their URLs plus a
812
+ * cleanup callback, when the primary provider opts into URL transfer and
813
+ * object storage is configured. Returns undefined otherwise (base64 path).
814
+ */
815
+ async maybeTransferImages(pool, images, operation) {
816
+ const primary = pool[0];
817
+ if (primary === undefined || primary.provider.uploadViaUrl !== true)
818
+ return undefined;
819
+ const client = await this.resolveObjectStorageClient();
820
+ if (client === undefined) {
821
+ throw new VisionToolkitError('config', 'uploadViaUrl is enabled but object storage is not configured');
822
+ }
823
+ const keys = [];
824
+ const urls = [];
825
+ try {
826
+ for (const image of images) {
827
+ if (operation.signal.aborted)
828
+ throw new VisionToolkitError('cancelled', 'vision image upload cancelled');
829
+ const uploaded = await client.uploadImage(image.path, imageMimeType(image.format));
830
+ keys.push(uploaded.key);
831
+ urls.push(uploaded.url);
832
+ }
833
+ }
834
+ catch (error) {
835
+ await Promise.allSettled(keys.map(key => client.deleteObject(key)));
836
+ throw error;
837
+ }
838
+ return {
839
+ urls,
840
+ cleanup: async () => {
841
+ await Promise.allSettled(keys.map(key => client.deleteObject(key)));
842
+ },
843
+ };
844
+ }
845
+ /** Settings "test storage" probe: upload → head → delete a tiny marker object. */
846
+ async testObjectStorage() {
847
+ const client = await this.resolveObjectStorageClient();
848
+ if (client === undefined) {
849
+ throw new VisionToolkitError('config', 'object storage is not configured (endpoint, bucket, and credential are required)');
850
+ }
851
+ return client.test();
852
+ }
749
853
  /** Stable gate key for one provider's in-flight request cap. */
750
854
  providerGate(provider) {
751
855
  const key = `${provider.baseUrl}\u0000${provider.model}\u0000${String(provider.credential)}`;
@@ -876,6 +980,23 @@ export class VisionToolkitRuntime {
876
980
  const globalRemaining = operation.deadlineAt - Date.now();
877
981
  return Math.max(0, Math.min(t2Remaining, globalRemaining));
878
982
  }
983
+ /**
984
+ * Advance to the next provider after one provider reached a terminal
985
+ * failure. This is what makes failover work for FAST failures too: the
986
+ * hedge timer only launches the next provider when the current one is SLOW
987
+ * (crosses t1), so a quick auth/5xx/network failure must explicitly launch
988
+ * the successor. Never advances when a higher-priority provider superseded
989
+ * this task, when the whole operation was cancelled, or when the global
990
+ * deadline has too little room left for another request. `launch` is
991
+ * idempotent, so an earlier hedge timer cannot cause a double launch.
992
+ */
993
+ advanceAfterFailure(task, operation, launchNext) {
994
+ if (task.abort.signal.aborted || operation.signal.aborted)
995
+ return;
996
+ if (operation.deadlineAt - Date.now() < this.config.minAvailableSeconds * 1000)
997
+ return;
998
+ launchNext();
999
+ }
879
1000
  /**
880
1001
  * Run one provider to a terminal state: retryable errors retry within
881
1002
  * `attempts`, a single request crossing t1 hedges the next provider, and the
@@ -902,6 +1023,7 @@ export class VisionToolkitRuntime {
902
1023
  if (budget < minAvailableMs) {
903
1024
  task.status = 'failed';
904
1025
  task.error = new VisionToolkitError('timeout', `${tool}: ${provider.name} has insufficient remaining time`);
1026
+ this.advanceAfterFailure(task, operation, launchNext);
905
1027
  return;
906
1028
  }
907
1029
  const reqDeadline = createDeadline(AbortSignal.any([operation.signal, task.abort.signal]), budget);
@@ -943,6 +1065,7 @@ export class VisionToolkitRuntime {
943
1065
  if (reqDeadline.timedOut) {
944
1066
  task.status = 'failed';
945
1067
  task.error = new VisionToolkitError('timeout', `${tool}: ${provider.name} exhausted its t2 budget`);
1068
+ this.advanceAfterFailure(task, operation, launchNext);
946
1069
  return;
947
1070
  }
948
1071
  if (classified.code === 'rate_limit') {
@@ -957,6 +1080,7 @@ export class VisionToolkitRuntime {
957
1080
  }
958
1081
  task.status = 'failed';
959
1082
  task.error = classified;
1083
+ this.advanceAfterFailure(task, operation, launchNext);
960
1084
  return;
961
1085
  }
962
1086
  finally {
@@ -1058,6 +1182,8 @@ export class VisionToolkitRuntime {
1058
1182
  protocol: env.VISION_API_PROTOCOL,
1059
1183
  anthropicThinking: env.VISION_ANTHROPIC_THINKING,
1060
1184
  sslVerify: env.VISION_SSL_VERIFY ?? null,
1185
+ stream: env.VISION_STREAM === '1',
1186
+ uploadViaUrl: provider.uploadViaUrl,
1061
1187
  userAgent: env.VISION_USER_AGENT,
1062
1188
  credentialSha256: createHash('sha256').update(env.VISION_API_KEY).digest('hex'),
1063
1189
  maxImageBytes: provider.maxImageBytes,
@@ -1170,25 +1296,34 @@ export class VisionToolkitRuntime {
1170
1296
  return cached.result;
1171
1297
  }
1172
1298
  }
1173
- const result = await this.runVisionHedge('glance', [
1174
- ...images.map(image => image.path),
1175
- ...(request.region !== undefined ? ['--region', request.region] : []),
1176
- ...(request.ocr === true ? ['--ocr'] : []),
1177
- ...(request.query !== undefined ? ['-q', request.query] : []),
1178
- ], images, operation, pool);
1179
- const answer = result.stdout.trim();
1180
- if (answer.length === 0)
1181
- throw new VisionToolkitError('output', 'glance: vision API returned an empty description');
1182
- const value = {
1183
- images,
1184
- mode: request.ocr === true ? 'ocr' : request.query !== undefined ? 'qa' : 'describe',
1185
- answer,
1186
- truncated: false,
1187
- };
1188
- if (options.sessionScope !== undefined && cacheKey !== undefined && !operation.signal.aborted) {
1189
- this.glanceCache.set(options.sessionScope, { key: cacheKey, result: value });
1299
+ const transfer = request.region === undefined
1300
+ ? await this.maybeTransferImages(pool, images, operation)
1301
+ : undefined;
1302
+ try {
1303
+ const result = await this.runVisionHedge('glance', [
1304
+ ...(transfer !== undefined ? transfer.urls : images.map(image => image.path)),
1305
+ ...(transfer === undefined && request.region !== undefined ? ['--region', request.region] : []),
1306
+ ...(request.ocr === true ? ['--ocr'] : []),
1307
+ ...(request.query !== undefined ? ['-q', request.query] : []),
1308
+ ], images, operation, pool);
1309
+ const answer = result.stdout.trim();
1310
+ if (answer.length === 0)
1311
+ throw new VisionToolkitError('output', 'glance: vision API returned an empty description');
1312
+ const value = {
1313
+ images,
1314
+ mode: request.ocr === true ? 'ocr' : request.query !== undefined ? 'qa' : 'describe',
1315
+ answer,
1316
+ truncated: false,
1317
+ };
1318
+ if (options.sessionScope !== undefined && cacheKey !== undefined && !operation.signal.aborted) {
1319
+ this.glanceCache.set(options.sessionScope, { key: cacheKey, result: value });
1320
+ }
1321
+ return value;
1322
+ }
1323
+ finally {
1324
+ if (transfer !== undefined)
1325
+ await transfer.cleanup();
1190
1326
  }
1191
- return value;
1192
1327
  });
1193
1328
  }
1194
1329
  validateLocations(elements, width, height) {
@@ -1217,14 +1352,25 @@ export class VisionToolkitRuntime {
1217
1352
  }
1218
1353
  const image = await this.prepareVisionImage(request.image, pool.map(entry => entry.provider), policy, operation);
1219
1354
  this.accountImage(image, operation);
1220
- const result = await this.runVisionHedge(tool, [
1221
- image.path,
1222
- request.target,
1223
- ...(request.region !== undefined ? ['--region', request.region] : []),
1224
- ], [image], operation, pool);
1225
- const elements = parseLocationOutput(result.stdout);
1226
- this.validateLocations(elements, image.width, image.height);
1227
- return { image, elements };
1355
+ const transfer = request.region === undefined
1356
+ ? await this.maybeTransferImages(pool, [image], operation)
1357
+ : undefined;
1358
+ try {
1359
+ const result = await this.runVisionHedge(tool, transfer !== undefined
1360
+ ? [transfer.urls[0], request.target, '--size', `${image.width}x${image.height}`]
1361
+ : [
1362
+ image.path,
1363
+ request.target,
1364
+ ...(request.region !== undefined ? ['--region', request.region] : []),
1365
+ ], [image], operation, pool);
1366
+ const elements = parseLocationOutput(result.stdout);
1367
+ this.validateLocations(elements, image.width, image.height);
1368
+ return { image, elements };
1369
+ }
1370
+ finally {
1371
+ if (transfer !== undefined)
1372
+ await transfer.cleanup();
1373
+ }
1228
1374
  }
1229
1375
  /** ground: locate one named target and return pixel boxes. */
1230
1376
  async ground(request, options) {
@@ -1906,6 +2052,18 @@ export class VisionToolkitRuntime {
1906
2052
  }
1907
2053
  });
1908
2054
  }
2055
+ async writableDirectoryCheck(path, label) {
2056
+ const probe = join(path, `.vision-toolkit-health-${randomUUID()}`);
2057
+ try {
2058
+ await writeFile(probe, 'ok\n', { encoding: 'utf8', flag: 'wx' });
2059
+ await rm(probe, { force: true });
2060
+ return { status: 'ok', detail: `${label} is writable: ${path}` };
2061
+ }
2062
+ catch {
2063
+ await rm(probe, { force: true }).catch(() => { });
2064
+ return { status: 'error', detail: `${label} is not writable: ${path}` };
2065
+ }
2066
+ }
1909
2067
  /** Health: inspect local readiness, and optionally probe one provider's `/models` plus one real multimodal request. */
1910
2068
  async health(testConnection, options, testModel = false, provider) {
1911
2069
  return this.runOperation('vision_toolkit_health', options, async (operation) => {
@@ -1929,6 +2087,29 @@ export class VisionToolkitRuntime {
1929
2087
  throw new VisionToolkitError('cancelled', 'vision_toolkit_health: cancelled');
1930
2088
  chrome = { status: 'error', detail: 'Chrome availability probe failed' };
1931
2089
  }
2090
+ let resolvedCredential;
2091
+ let credential;
2092
+ try {
2093
+ resolvedCredential = isBuiltInFreeVisionProvider(this.config.provider)
2094
+ ? { value: BUILT_IN_FREE_VISION_KEY, source: 'built-in' }
2095
+ : await this.ctx.credentials.resolve(this.config.provider.credential);
2096
+ credential = resolvedCredential === undefined
2097
+ ? { status: 'error', detail: `credential ${this.config.provider.credential} is not configured` }
2098
+ : { status: 'ok', detail: `credential ${this.config.provider.credential} is resolvable` };
2099
+ }
2100
+ catch {
2101
+ credential = { status: 'error', detail: `credential ${this.config.provider.credential} could not be resolved` };
2102
+ }
2103
+ let artifactDirectory;
2104
+ try {
2105
+ // allowedDirs are session input roots; they do not affect output readiness.
2106
+ const policy = await createPathPolicy(options.workspace, [], this.config.storageDir);
2107
+ artifactDirectory = await this.writableDirectoryCheck(policy.outputDir, 'Artifact directory');
2108
+ }
2109
+ catch {
2110
+ artifactDirectory = { status: 'error', detail: 'Artifact directory could not be prepared' };
2111
+ }
2112
+ const tempDirectory = await this.writableDirectoryCheck(info.runtimeHome, 'Runtime temp directory');
1932
2113
  let service = {
1933
2114
  status: 'not_tested',
1934
2115
  detail: 'Connection was not tested; use the per-provider API test',
@@ -2026,7 +2207,7 @@ export class VisionToolkitRuntime {
2026
2207
  }
2027
2208
  }
2028
2209
  }
2029
- const checks = { python, dependencies, chrome, service, model };
2210
+ const checks = { python, dependencies, chrome, credential, artifactDirectory, tempDirectory, service, model };
2030
2211
  const healthy = Object.values(checks).every(check => check.status !== 'error');
2031
2212
  return {
2032
2213
  pluginVersion: PLUGIN_VERSION,