@mengruo/dsh-vision-toolkit 0.0.1 → 0.1.0

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 (51) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +17 -42
  3. package/README.zh.md +16 -41
  4. package/assets/logo_eapi_dark.png +0 -0
  5. package/docs/aihubmix-gemini-vision.i18n.yaml +2 -2
  6. package/docs/aihubmix-gemini-vision.md +2 -2
  7. package/docs/aihubmix-gemini-vision.zh.md +2 -2
  8. package/lib/client.js +246 -68
  9. package/lib/client.js.map +1 -1
  10. package/lib/config.js +143 -24
  11. package/lib/config.js.map +1 -1
  12. package/lib/evidence-cache.js +14 -0
  13. package/lib/evidence-cache.js.map +1 -1
  14. package/lib/image-input-variants.js +39 -27
  15. package/lib/image-input-variants.js.map +1 -1
  16. package/lib/runtime-install.js +204 -19
  17. package/lib/runtime-install.js.map +1 -1
  18. package/lib/runtime.js +310 -154
  19. package/lib/runtime.js.map +1 -1
  20. package/lib/tools.js +1 -0
  21. package/lib/tools.js.map +1 -1
  22. package/lib/types/client/index.d.ts +45 -3
  23. package/lib/types/client/index.d.ts.map +1 -1
  24. package/lib/types/config.d.ts +53 -0
  25. package/lib/types/config.d.ts.map +1 -1
  26. package/lib/types/evidence-cache.d.ts.map +1 -1
  27. package/lib/types/image-input-variants.d.ts +1 -6
  28. package/lib/types/image-input-variants.d.ts.map +1 -1
  29. package/lib/types/runtime-install.d.ts +21 -0
  30. package/lib/types/runtime-install.d.ts.map +1 -1
  31. package/lib/types/runtime.d.ts +45 -10
  32. package/lib/types/runtime.d.ts.map +1 -1
  33. package/lib/types/tools.d.ts.map +1 -1
  34. package/lib/types/upstream.d.ts +3 -0
  35. package/lib/types/upstream.d.ts.map +1 -1
  36. package/lib/types/web.d.ts +8 -0
  37. package/lib/types/web.d.ts.map +1 -1
  38. package/lib/upstream.js +11 -6
  39. package/lib/upstream.js.map +1 -1
  40. package/lib/web.js +50 -7
  41. package/lib/web.js.map +1 -1
  42. package/package.json +1 -1
  43. package/src/client/index.tsx +331 -88
  44. package/src/config.ts +210 -28
  45. package/src/evidence-cache.ts +14 -0
  46. package/src/image-input-variants.ts +39 -27
  47. package/src/runtime-install.ts +231 -20
  48. package/src/runtime.ts +333 -180
  49. package/src/tools.ts +1 -0
  50. package/src/upstream.ts +15 -8
  51. package/src/web.ts +67 -8
package/src/runtime.ts CHANGED
@@ -14,7 +14,7 @@ import type { Context } from '@deepseek-ai/cordis'
14
14
  import type { ResolvedCredential } from '@deepseek-ai/dsh-credentials'
15
15
  import { SaxesParser } from 'saxes'
16
16
  import { describeArtifact, type ArtifactDescriptor } from './artifacts.ts'
17
- import { isBuiltInFreeVisionProvider, type ResolvedVisionToolkitConfig } from './config.ts'
17
+ import { isBuiltInFreeVisionProvider, type ResolvedProvider, type ResolvedVisionToolkitConfig } from './config.ts'
18
18
  import { BUILT_IN_FREE_VISION_KEY } from './defaults.ts'
19
19
  import { evidenceRuntimeFingerprint } from './evidence-cache.ts'
20
20
  import { VisionToolkitError } from './errors.ts'
@@ -194,6 +194,16 @@ export class Semaphore {
194
194
  next.resolve()
195
195
  }
196
196
  }
197
+
198
+ /** Non-blocking acquisition: claim a free slot immediately, else return false. */
199
+ tryAcquire(permits = 1): boolean {
200
+ if (!Number.isInteger(permits) || permits < 1 || permits > this.limit) return false
201
+ if (this.waiters.length === 0 && this.active + permits <= this.limit) {
202
+ this.active += permits
203
+ return true
204
+ }
205
+ return false
206
+ }
197
207
  }
198
208
 
199
209
  /** Validated image metadata retained in structured results and diagnostics. */
@@ -203,6 +213,8 @@ export interface ImageInfo {
203
213
  width: number
204
214
  height: number
205
215
  format: string
216
+ /** True when the analyzed image carries an alpha (transparency) channel. */
217
+ hasAlpha: boolean
206
218
  /** Original user-facing image path before any automatic compression. */
207
219
  originalPath: string
208
220
  }
@@ -451,7 +463,7 @@ export interface HealthCheck {
451
463
  detail: string
452
464
  }
453
465
 
454
- /** Runtime, dependency, storage, credential, and optional service health. */
466
+ /** Runtime, dependency, browser, and optional per-provider service health. */
455
467
  export interface VisionToolkitHealthResult {
456
468
  pluginVersion: string
457
469
  upstream: UpstreamVersionInfo
@@ -459,15 +471,14 @@ export interface VisionToolkitHealthResult {
459
471
  python: HealthCheck
460
472
  dependencies: HealthCheck
461
473
  chrome: HealthCheck
462
- credential: HealthCheck
463
- artifactDirectory: HealthCheck
464
- tempDirectory: HealthCheck
465
474
  service: HealthCheck
466
475
  model: HealthCheck
467
476
  }
468
477
  healthy: boolean
469
478
  connectionTested: boolean
470
479
  modelTested: boolean
480
+ /** Provider label when the service/model checks targeted one specific provider. */
481
+ providerName?: string
471
482
  }
472
483
 
473
484
  /** Shared per-call execution options. */
@@ -686,10 +697,17 @@ export function parseRegion(region: string): { x1: number; y1: number; x2: numbe
686
697
  return box
687
698
  }
688
699
 
700
+ /** One enabled provider paired with its resolved upstream environment. */
701
+ interface ResolvedProviderEnv {
702
+ provider: ResolvedProvider
703
+ env: UpstreamEnvironment
704
+ }
705
+
689
706
  /** Runtime facade used by every native tool. */
690
707
  export class VisionToolkitRuntime {
691
708
  private readonly semaphores = new Map<string, Semaphore>()
692
709
  private readonly glanceCache = new WeakMap<object, GlanceCacheEntry>()
710
+ private readonly providerGates = new Map<string, Semaphore>()
693
711
  private readonly adapter: UpstreamAdapter
694
712
 
695
713
  constructor(
@@ -712,26 +730,39 @@ export class VisionToolkitRuntime {
712
730
 
713
731
  /** Capture the credential and provider identity used by one evidence conversion. */
714
732
  async captureEvidenceRuntime(): Promise<CapturedEvidenceRuntime> {
715
- const env = await this.resolveVisionEnv()
716
- const evidenceFingerprint = evidenceRuntimeFingerprint(
717
- this.config,
718
- createHash('sha256').update(env.VISION_API_KEY).digest('hex'),
719
- env.VISION_SSL_VERIFY,
720
- )
733
+ // The primary provider's key hash sharpens cache invalidation, but a
734
+ // missing primary credential must not block evidence conversion when a
735
+ // later provider in the failover pool is available.
736
+ let credentialSha256: string | undefined
737
+ let sslVerify: string | undefined
738
+ try {
739
+ const env = await this.resolveVisionEnv()
740
+ credentialSha256 = createHash('sha256').update(env.VISION_API_KEY).digest('hex')
741
+ sslVerify = env.VISION_SSL_VERIFY
742
+ } catch {
743
+ credentialSha256 = undefined
744
+ }
745
+ const evidenceFingerprint = evidenceRuntimeFingerprint(this.config, credentialSha256, sslVerify)
721
746
  return Object.freeze({
722
747
  evidenceFingerprint,
723
- glance: (request: GlanceRequest, options: ToolCallOptions) => this.glanceWithEnv(request, options, env),
748
+ glance: (request: GlanceRequest, options: ToolCallOptions) => this.glanceWithEnv(request, options),
724
749
  })
725
750
  }
726
751
 
727
752
  private timeout(options: ToolCallOptions): number {
728
- const value = options.timeoutMs ?? this.config.timeoutMs
753
+ const value = options.timeoutMs ?? this.operationTimeoutMs()
729
754
  if (!Number.isInteger(value) || value < 1000 || value > MAX_TIMEOUT_MS) {
730
755
  throw new VisionToolkitError('input', `timeoutMs must be an integer between 1000 and ${MAX_TIMEOUT_MS}`)
731
756
  }
732
757
  return value
733
758
  }
734
759
 
760
+ /** Overall operation budget: the slowest enabled provider's timeout, else the global default. */
761
+ private operationTimeoutMs(): number {
762
+ const providers = this.config.providers.filter(provider => provider.enabled)
763
+ return providers.length === 0 ? this.config.timeoutMs : Math.max(...providers.map(provider => provider.timeoutMs))
764
+ }
765
+
735
766
  private operationError(
736
767
  tool: string,
737
768
  error: unknown,
@@ -851,34 +882,78 @@ export class VisionToolkitRuntime {
851
882
  }
852
883
  }
853
884
 
854
- /** Resolve the configured credential at the remote-operation boundary. */
855
- async resolveVisionEnv(): Promise<UpstreamEnvironment> {
856
- const resolved: ResolvedCredential | undefined = isBuiltInFreeVisionProvider(this.config.provider)
857
- ? { value: BUILT_IN_FREE_VISION_KEY, source: 'built-in' }
858
- : await this.ctx.credentials.resolve(this.config.provider.credential)
859
- if (resolved === undefined) {
860
- throw new VisionToolkitError(
861
- 'config',
862
- `credential ${this.config.provider.credential} is not configured; set it through DSH credentials`,
863
- )
864
- }
865
- return this.visionEnv(resolved)
885
+ /** Highest-priority enabled provider, falling back to the first entry. */
886
+ private get primaryProvider(): ResolvedProvider {
887
+ return this.config.providers.find(provider => provider.enabled) ?? this.config.providers[0]!
866
888
  }
867
889
 
868
- private visionEnv(resolved: ResolvedCredential): UpstreamEnvironment {
890
+ /** Build the upstream environment for one resolved provider. */
891
+ private providerEnv(provider: ResolvedProvider, resolved: ResolvedCredential): UpstreamEnvironment {
869
892
  const sslVerify = process.env.VISION_SSL_VERIFY?.trim()
870
893
  return {
871
894
  VISION_API_KEY: resolved.value,
872
- VISION_BASE_URL: this.config.provider.baseUrl,
873
- VISION_MODEL: this.config.provider.model,
874
- VISION_API_PROTOCOL: this.config.provider.protocol === 'anthropic' ? 'anthropic' : 'chat_completions',
875
- VISION_ANTHROPIC_THINKING: this.config.provider.anthropicThinking,
895
+ VISION_BASE_URL: provider.baseUrl,
896
+ VISION_MODEL: provider.model,
897
+ VISION_API_PROTOCOL: provider.protocol === 'anthropic' ? 'anthropic' : 'chat_completions',
898
+ VISION_ANTHROPIC_THINKING: provider.anthropicThinking,
876
899
  ...(sslVerify === undefined ? {} : { VISION_SSL_VERIFY: sslVerify }),
877
- VISION_USER_AGENT: this.config.provider.userAgent,
900
+ VISION_USER_AGENT: provider.userAgent,
878
901
  LANG: this.config.language,
879
902
  }
880
903
  }
881
904
 
905
+ /** Resolve one provider's credential into its environment, or undefined when unavailable. */
906
+ private async resolveProviderEnv(provider: ResolvedProvider): Promise<ResolvedProviderEnv | undefined> {
907
+ let resolved: ResolvedCredential | undefined
908
+ try {
909
+ resolved = isBuiltInFreeVisionProvider({
910
+ baseUrl: provider.baseUrl,
911
+ credential: provider.credential,
912
+ model: provider.model,
913
+ protocol: provider.protocol,
914
+ anthropicThinking: provider.anthropicThinking,
915
+ userAgent: provider.userAgent,
916
+ })
917
+ ? { value: BUILT_IN_FREE_VISION_KEY, source: 'built-in' }
918
+ : await this.ctx.credentials.resolve(provider.credential)
919
+ } catch {
920
+ resolved = undefined
921
+ }
922
+ if (resolved === undefined) return undefined
923
+ return { provider, env: this.providerEnv(provider, resolved) }
924
+ }
925
+
926
+ /** Resolve every enabled provider in priority order, skipping unreadable credentials. */
927
+ async resolveProviderPool(): Promise<ResolvedProviderEnv[]> {
928
+ const pool: ResolvedProviderEnv[] = []
929
+ for (const provider of this.config.providers) {
930
+ if (!provider.enabled) continue
931
+ const entry = await this.resolveProviderEnv(provider)
932
+ if (entry === undefined) {
933
+ this.ctx.logger.warn(
934
+ 'dsh-vision-toolkit provider=%s credential=%s unavailable; skipped from the failover pool',
935
+ provider.name,
936
+ String(provider.credential),
937
+ )
938
+ continue
939
+ }
940
+ pool.push(entry)
941
+ }
942
+ return pool
943
+ }
944
+
945
+ /** Resolve the primary provider's credential at the remote-operation boundary. */
946
+ async resolveVisionEnv(): Promise<UpstreamEnvironment> {
947
+ const entry = await this.resolveProviderEnv(this.primaryProvider)
948
+ if (entry === undefined) {
949
+ throw new VisionToolkitError(
950
+ 'config',
951
+ `credential ${String(this.primaryProvider.credential)} is not configured; set it through DSH credentials`,
952
+ )
953
+ }
954
+ return entry.env
955
+ }
956
+
882
957
  private pathPolicy(workspace: string): Promise<PathPolicy> {
883
958
  return createPathPolicy(workspace, this.config.allowedDirs)
884
959
  }
@@ -915,7 +990,7 @@ export class VisionToolkitRuntime {
915
990
  maxBytes: number,
916
991
  maxPixels: number,
917
992
  operation: OperationContext,
918
- ): Promise<{ path: string; bytes: number; width: number; height: number; format: string } | undefined> {
993
+ ): Promise<{ path: string; bytes: number; width: number; height: number; format: string; hasAlpha: boolean } | undefined> {
919
994
  const candidate = join(root, name)
920
995
  let info
921
996
  try {
@@ -939,7 +1014,7 @@ export class VisionToolkitRuntime {
939
1014
  }
940
1015
  const digest = createHash('sha256').update(bytes).digest('hex')
941
1016
  if (bytes.length !== info.size || !digest.startsWith(expectedOutDigestPrefix)) return undefined
942
- let probed: { width: number; height: number; format: string } | undefined
1017
+ let probed: { width: number; height: number; format: string; mode: string; hasAlpha: boolean } | undefined
943
1018
  try {
944
1019
  probed = await this.adapter.probeImageSize(real, { signal: operation.signal })
945
1020
  } catch {
@@ -953,7 +1028,7 @@ export class VisionToolkitRuntime {
953
1028
  ) {
954
1029
  return undefined
955
1030
  }
956
- return { path: real, bytes: bytes.length, width: probed.width, height: probed.height, format: probed.format }
1031
+ return { path: real, bytes: bytes.length, width: probed.width, height: probed.height, format: probed.format, hasAlpha: probed.hasAlpha }
957
1032
  }
958
1033
 
959
1034
  private cacheEntryOutDigest(entry: string, prefix: string): string | undefined {
@@ -1016,6 +1091,8 @@ export class VisionToolkitRuntime {
1016
1091
  image: { path: string; bytes: number },
1017
1092
  policy: PathPolicy,
1018
1093
  operation: OperationContext,
1094
+ maxBytes: number,
1095
+ maxPixels: number,
1019
1096
  ): Promise<ImageInfo> {
1020
1097
  let bytes: Buffer
1021
1098
  try {
@@ -1029,7 +1106,7 @@ export class VisionToolkitRuntime {
1029
1106
  const digest = createHash('sha256').update(bytes).digest('hex').slice(0, COMPRESSED_IMAGE_CACHE_KEY_DIGEST_LENGTH)
1030
1107
  const root = await this.compressedImageRoot(policy)
1031
1108
  await this.pruneCompressedCache(root)
1032
- const prefix = `${COMPRESSED_IMAGE_CACHE_VERSION}-${digest}-b${this.config.maxImageBytes}-p${this.config.maxImagePixels}`
1109
+ const prefix = `${COMPRESSED_IMAGE_CACHE_VERSION}-${digest}-b${maxBytes}-p${maxPixels}`
1033
1110
  for (const entry of await readdir(root)) {
1034
1111
  if (!entry.startsWith(`${prefix}-`) || entry.startsWith('.')) continue
1035
1112
  const outDigestPrefix = this.cacheEntryOutDigest(entry, prefix)
@@ -1037,14 +1114,7 @@ export class VisionToolkitRuntime {
1037
1114
  await rm(join(root, entry), { force: true }).catch(() => {})
1038
1115
  continue
1039
1116
  }
1040
- const cached = await this.readCacheCandidate(
1041
- root,
1042
- entry,
1043
- outDigestPrefix,
1044
- this.config.maxImageBytes,
1045
- this.config.maxImagePixels,
1046
- operation,
1047
- )
1117
+ const cached = await this.readCacheCandidate(root, entry, outDigestPrefix, maxBytes, maxPixels, operation)
1048
1118
  if (cached !== undefined) {
1049
1119
  return { ...cached, originalPath: image.path }
1050
1120
  }
@@ -1053,13 +1123,7 @@ export class VisionToolkitRuntime {
1053
1123
  const staged = join(root, `.${prefix}-${randomUUID()}.partial`)
1054
1124
  let compressed: CompressedImageInfo
1055
1125
  try {
1056
- compressed = await this.adapter.compressImage(
1057
- image.path,
1058
- staged,
1059
- this.config.maxImageBytes,
1060
- this.config.maxImagePixels,
1061
- { signal: operation.signal },
1062
- )
1126
+ compressed = await this.adapter.compressImage(image.path, staged, maxBytes, maxPixels, { signal: operation.signal })
1063
1127
  } catch (error) {
1064
1128
  await rm(staged, { force: true }).catch(() => {})
1065
1129
  throw error
@@ -1069,14 +1133,7 @@ export class VisionToolkitRuntime {
1069
1133
  const outDigest = createHash('sha256').update(stagedBytes).digest('hex').slice(0, COMPRESSED_IMAGE_CACHE_KEY_DIGEST_LENGTH)
1070
1134
  const finalName = `${prefix}-${outDigest}-${compressed.width}x${compressed.height}.${extension}`
1071
1135
  const finalPath = join(root, finalName)
1072
- const existing = await this.readCacheCandidate(
1073
- root,
1074
- finalName,
1075
- outDigest,
1076
- this.config.maxImageBytes,
1077
- this.config.maxImagePixels,
1078
- operation,
1079
- )
1136
+ const existing = await this.readCacheCandidate(root, finalName, outDigest, maxBytes, maxPixels, operation)
1080
1137
  if (existing !== undefined) {
1081
1138
  await rm(staged, { force: true }).catch(() => {})
1082
1139
  return { ...existing, originalPath: image.path }
@@ -1095,10 +1152,12 @@ export class VisionToolkitRuntime {
1095
1152
  width: compressed.width,
1096
1153
  height: compressed.height,
1097
1154
  format: compressed.format,
1155
+ hasAlpha: compressed.hasAlpha,
1098
1156
  originalPath: image.path,
1099
1157
  }
1100
1158
  }
1101
1159
 
1160
+ /** Validate one image against the configured global limits (used by local tools). */
1102
1161
  private async validateImage(raw: string, policy: PathPolicy, operation: OperationContext): Promise<ImageInfo> {
1103
1162
  const image = await resolveInputFile(raw, policy)
1104
1163
  const decoded = await this.adapter.probeImageSize(image.path, { signal: operation.signal })
@@ -1112,9 +1171,9 @@ export class VisionToolkitRuntime {
1112
1171
  throw new VisionToolkitError('input', `image content is ${decoded.format}, but the filename uses ${extension}`)
1113
1172
  }
1114
1173
  if (image.bytes <= this.config.maxImageBytes && pixels <= this.config.maxImagePixels) {
1115
- return { ...image, width: decoded.width, height: decoded.height, format: decoded.format, originalPath: image.path }
1174
+ return { ...image, width: decoded.width, height: decoded.height, format: decoded.format, hasAlpha: decoded.hasAlpha, originalPath: image.path }
1116
1175
  }
1117
- return this.autoCompressImage(image, policy, operation)
1176
+ return this.autoCompressImage(image, policy, operation, this.config.maxImageBytes, this.config.maxImagePixels)
1118
1177
  }
1119
1178
 
1120
1179
  private accountImage(image: ImageInfo, operation: OperationContext): void {
@@ -1123,10 +1182,107 @@ export class VisionToolkitRuntime {
1123
1182
  operation.metrics.imagePixels += image.width * image.height
1124
1183
  }
1125
1184
 
1185
+ /** Stable gate key for one provider's in-flight request cap. */
1186
+ private providerGate(provider: ResolvedProvider): Semaphore {
1187
+ const key = `${provider.baseUrl}\u0000${provider.model}\u0000${String(provider.credential)}`
1188
+ let gate = this.providerGates.get(key)
1189
+ if (gate === undefined) {
1190
+ gate = new Semaphore(provider.concurrency)
1191
+ this.providerGates.set(key, gate)
1192
+ }
1193
+ return gate
1194
+ }
1195
+
1196
+ /**
1197
+ * Prepare one image for an online vision request against the enabled
1198
+ * provider pool. The raw image is kept when at least one enabled provider
1199
+ * accepts it; otherwise it is compressed once to the first (highest
1200
+ * priority) provider's limits so the priority route can proceed.
1201
+ */
1202
+ private async prepareVisionImage(
1203
+ raw: string,
1204
+ providers: readonly ResolvedProvider[],
1205
+ policy: PathPolicy,
1206
+ operation: OperationContext,
1207
+ ): Promise<ImageInfo> {
1208
+ const image = await resolveInputFile(raw, policy)
1209
+ const decoded = await this.adapter.probeImageSize(image.path, { signal: operation.signal })
1210
+ const pixels = decoded.width * decoded.height
1211
+ if (!Number.isSafeInteger(pixels) || pixels < 1) {
1212
+ throw new VisionToolkitError('input', `image dimensions are invalid: ${decoded.width}x${decoded.height}`)
1213
+ }
1214
+ const extension = extname(image.path).toLowerCase()
1215
+ const expected = FORMAT_BY_EXTENSION.get(extension)
1216
+ if (expected !== decoded.format) {
1217
+ throw new VisionToolkitError('input', `image content is ${decoded.format}, but the filename uses ${extension}`)
1218
+ }
1219
+ const fits = providers.some(provider => image.bytes <= provider.maxImageBytes && pixels <= provider.maxImagePixels)
1220
+ if (fits) {
1221
+ return { ...image, width: decoded.width, height: decoded.height, format: decoded.format, hasAlpha: decoded.hasAlpha, originalPath: image.path }
1222
+ }
1223
+ const first = providers[0]
1224
+ if (first === undefined) {
1225
+ throw new VisionToolkitError('config', 'no enabled vision provider is available for this image')
1226
+ }
1227
+ return this.autoCompressImage(image, policy, operation, first.maxImageBytes, first.maxImagePixels)
1228
+ }
1229
+
1230
+ /**
1231
+ * Run one online-vision upstream command across the enabled provider pool in
1232
+ * priority order. A provider is skipped when its size limits or concurrency
1233
+ * are exhausted, retried up to its attempt count, and the next provider
1234
+ * takes over on failure. Throws once every provider has failed.
1235
+ */
1236
+ private async runVisionWithFailover(
1237
+ tool: 'glance' | 'ground' | 'detect' | 'long_screenshot_ocr',
1238
+ args: readonly string[],
1239
+ images: readonly ImageInfo[],
1240
+ operation: OperationContext,
1241
+ pool: readonly ResolvedProviderEnv[],
1242
+ ): Promise<UpstreamRunResult> {
1243
+ if (pool.length === 0) {
1244
+ throw new VisionToolkitError('config', 'no enabled vision provider has a resolvable credential')
1245
+ }
1246
+ let lastError: unknown
1247
+ let attempted = false
1248
+ for (const { provider, env } of pool) {
1249
+ const fits = images.every(image => image.bytes <= provider.maxImageBytes && image.width * image.height <= provider.maxImagePixels)
1250
+ if (!fits) continue
1251
+ const gate = this.providerGate(provider)
1252
+ if (!gate.tryAcquire()) continue
1253
+ attempted = true
1254
+ try {
1255
+ for (let attempt = 1; attempt <= provider.attempts; attempt++) {
1256
+ const attemptDeadline = createDeadline(operation.signal, provider.timeoutMs)
1257
+ try {
1258
+ return await this.runUpstream(tool, args, { signal: attemptDeadline.signal, metrics: operation.metrics }, env)
1259
+ } catch (error) {
1260
+ if (operation.signal.aborted) throw error
1261
+ const classified = error instanceof VisionToolkitError
1262
+ ? error
1263
+ : new VisionToolkitError('service', `${tool}: request failed`, { cause: error })
1264
+ lastError = attemptDeadline.timedOut && classified.code === 'cancelled'
1265
+ ? new VisionToolkitError('timeout', `${tool}: ${provider.name} request timed out after ${provider.timeoutMs}ms`, { cause: classified })
1266
+ : classified
1267
+ } finally {
1268
+ attemptDeadline.cleanup()
1269
+ }
1270
+ }
1271
+ } finally {
1272
+ gate.release()
1273
+ }
1274
+ }
1275
+ if (!attempted) {
1276
+ throw new VisionToolkitError('capacity', `${tool}: no enabled vision provider accepts the image size or has a free concurrency slot`)
1277
+ }
1278
+ if (lastError instanceof VisionToolkitError) throw lastError
1279
+ throw new VisionToolkitError('service', `${tool}: all vision providers failed`, { cause: lastError })
1280
+ }
1281
+
1126
1282
  private async glanceCacheKey(
1127
1283
  request: GlanceRequest,
1128
1284
  images: readonly ImageInfo[],
1129
- env: UpstreamEnvironment,
1285
+ pool: readonly ResolvedProviderEnv[],
1130
1286
  signal: AbortSignal,
1131
1287
  ): Promise<string> {
1132
1288
  const imageFingerprints = await Promise.all(images.map(async (image) => {
@@ -1149,16 +1305,20 @@ export class VisionToolkitRuntime {
1149
1305
  query: request.query ?? null,
1150
1306
  ocr: request.ocr === true,
1151
1307
  region: request.region ?? null,
1152
- provider: {
1308
+ language: this.config.language,
1309
+ providers: pool.map(({ provider, env }) => ({
1310
+ name: provider.name,
1153
1311
  baseUrl: env.VISION_BASE_URL,
1154
1312
  model: env.VISION_MODEL,
1155
1313
  protocol: env.VISION_API_PROTOCOL,
1156
1314
  anthropicThinking: env.VISION_ANTHROPIC_THINKING,
1157
1315
  sslVerify: env.VISION_SSL_VERIFY ?? null,
1158
1316
  userAgent: env.VISION_USER_AGENT,
1159
- language: env.LANG,
1160
1317
  credentialSha256: createHash('sha256').update(env.VISION_API_KEY).digest('hex'),
1161
- },
1318
+ maxImageBytes: provider.maxImageBytes,
1319
+ maxImagePixels: provider.maxImagePixels,
1320
+ attempts: provider.attempts,
1321
+ })),
1162
1322
  })
1163
1323
  }
1164
1324
 
@@ -1245,7 +1405,6 @@ export class VisionToolkitRuntime {
1245
1405
  private async glanceWithEnv(
1246
1406
  request: GlanceRequest,
1247
1407
  options: ToolCallOptions,
1248
- capturedEnv?: UpstreamEnvironment,
1249
1408
  ): Promise<GlanceResult> {
1250
1409
  return this.runOperation('vision_glance', options, async (operation) => {
1251
1410
  if (request.images.length === 0) throw new VisionToolkitError('input', 'glance requires at least one image')
@@ -1257,10 +1416,15 @@ export class VisionToolkitRuntime {
1257
1416
  }
1258
1417
  if (request.region !== undefined) parseRegion(request.region)
1259
1418
  const policy = await this.pathPolicy(options.workspace)
1419
+ const pool = await this.resolveProviderPool()
1420
+ if (pool.length === 0) {
1421
+ throw new VisionToolkitError('config', 'no enabled vision provider has a resolvable credential')
1422
+ }
1423
+ const providers = pool.map(entry => entry.provider)
1260
1424
  const images: ImageInfo[] = []
1261
1425
  const seen = new Set<string>()
1262
1426
  for (const raw of request.images) {
1263
- const image = await this.validateImage(raw, policy, operation)
1427
+ const image = await this.prepareVisionImage(raw, providers, policy, operation)
1264
1428
  if (seen.has(image.path)) {
1265
1429
  operation.metrics.cacheHits += 1
1266
1430
  continue
@@ -1269,10 +1433,9 @@ export class VisionToolkitRuntime {
1269
1433
  this.accountImage(image, operation)
1270
1434
  images.push(image)
1271
1435
  }
1272
- const env = capturedEnv ?? await this.resolveVisionEnv()
1273
1436
  const cacheKey = options.sessionScope === undefined
1274
1437
  ? undefined
1275
- : await this.glanceCacheKey(request, images, env, operation.signal)
1438
+ : await this.glanceCacheKey(request, images, pool, operation.signal)
1276
1439
  if (options.sessionScope !== undefined && cacheKey !== undefined) {
1277
1440
  const cached = this.glanceCache.get(options.sessionScope)
1278
1441
  if (cached?.key === cacheKey) {
@@ -1280,12 +1443,12 @@ export class VisionToolkitRuntime {
1280
1443
  return cached.result
1281
1444
  }
1282
1445
  }
1283
- const result = await this.runUpstream('glance', [
1446
+ const result = await this.runVisionWithFailover('glance', [
1284
1447
  ...images.map(image => image.path),
1285
1448
  ...(request.region !== undefined ? ['--region', request.region] : []),
1286
1449
  ...(request.ocr === true ? ['--ocr'] : []),
1287
1450
  ...(request.query !== undefined ? ['-q', request.query] : []),
1288
- ], operation, env)
1451
+ ], images, operation, pool)
1289
1452
  const answer = result.stdout.trim()
1290
1453
  if (answer.length === 0) throw new VisionToolkitError('output', 'glance: vision API returned an empty description')
1291
1454
  const value: GlanceResult = {
@@ -1327,14 +1490,17 @@ export class VisionToolkitRuntime {
1327
1490
  if (request.target.trim().length === 0) throw new VisionToolkitError('input', 'target must not be empty')
1328
1491
  if (request.region !== undefined) parseRegion(request.region)
1329
1492
  const policy = await this.pathPolicy(options.workspace)
1330
- const image = await this.validateImage(request.image, policy, operation)
1493
+ const pool = await this.resolveProviderPool()
1494
+ if (pool.length === 0) {
1495
+ throw new VisionToolkitError('config', 'no enabled vision provider has a resolvable credential')
1496
+ }
1497
+ const image = await this.prepareVisionImage(request.image, pool.map(entry => entry.provider), policy, operation)
1331
1498
  this.accountImage(image, operation)
1332
- const env = await this.resolveVisionEnv()
1333
- const result = await this.runUpstream(tool, [
1499
+ const result = await this.runVisionWithFailover(tool, [
1334
1500
  image.path,
1335
1501
  request.target,
1336
1502
  ...(request.region !== undefined ? ['--region', request.region] : []),
1337
- ], operation, env)
1503
+ ], [image], operation, pool)
1338
1504
  const elements = parseLocationOutput(result.stdout)
1339
1505
  this.validateLocations(elements, image.width, image.height)
1340
1506
  return { image, elements }
@@ -1665,7 +1831,13 @@ export class VisionToolkitRuntime {
1665
1831
  throw new VisionToolkitError('input', 'long_screenshot_ocr.prompt must not be empty when provided')
1666
1832
  }
1667
1833
  const policy = await this.pathPolicy(options.workspace)
1668
- const image = await this.validateImage(request.image, policy, operation)
1834
+ const pool = splitOnly ? [] : await this.resolveProviderPool()
1835
+ if (!splitOnly && pool.length === 0) {
1836
+ throw new VisionToolkitError('config', 'no enabled vision provider has a resolvable credential')
1837
+ }
1838
+ const image = splitOnly
1839
+ ? await this.validateImage(request.image, policy, operation)
1840
+ : await this.prepareVisionImage(request.image, pool.map(entry => entry.provider), policy, operation)
1669
1841
  this.accountImage(image, operation)
1670
1842
  const stem = basename(image.originalPath, extname(image.originalPath))
1671
1843
  const finalDirectory = resolveOutputDirectory(request.runName, policy, `${stem}.long-ocr`)
@@ -1680,7 +1852,7 @@ export class VisionToolkitRuntime {
1680
1852
  const finalOutput = join(finalDirectory, basename(stagedOutput))
1681
1853
  const stagedChunks = join(stagedDirectory, 'chunks')
1682
1854
  const stagedManifest = join(stagedChunks, 'manifest.json')
1683
- const result = await this.runUpstream('long_screenshot_ocr', [
1855
+ const ocrArgs = [
1684
1856
  image.path,
1685
1857
  '--mode',
1686
1858
  mode,
@@ -1699,7 +1871,10 @@ export class VisionToolkitRuntime {
1699
1871
  String(chunkTimeoutSeconds),
1700
1872
  ...(splitOnly ? ['--split-only'] : []),
1701
1873
  ...(request.resume === true ? ['--resume'] : []),
1702
- ], operation, splitOnly ? undefined : await this.resolveVisionEnv())
1874
+ ]
1875
+ const result = splitOnly
1876
+ ? await this.runUpstream('long_screenshot_ocr', ocrArgs, operation)
1877
+ : await this.runVisionWithFailover('long_screenshot_ocr', ocrArgs, [image], operation, pool)
1703
1878
  const reported = result.stdout.trim()
1704
1879
  const expectedReported = splitOnly ? stagedManifest : stagedOutput
1705
1880
  if (reported !== expectedReported) {
@@ -2030,20 +2205,8 @@ export class VisionToolkitRuntime {
2030
2205
  })
2031
2206
  }
2032
2207
 
2033
- private async writableDirectoryCheck(path: string, label: string): Promise<HealthCheck> {
2034
- const probe = join(path, `.vision-toolkit-health-${randomUUID()}`)
2035
- try {
2036
- await writeFile(probe, 'ok\n', { encoding: 'utf8', flag: 'wx' })
2037
- await rm(probe, { force: true })
2038
- return { status: 'ok', detail: `${label} is writable: ${path}` }
2039
- } catch {
2040
- await rm(probe, { force: true }).catch(() => {})
2041
- return { status: 'error', detail: `${label} is not writable: ${path}` }
2042
- }
2043
- }
2044
-
2045
- /** Health: inspect local readiness, optionally probe `/models`, and explicitly test one real multimodal request. */
2046
- async health(testConnection: boolean, options: ToolCallOptions, testModel = false): Promise<VisionToolkitHealthResult> {
2208
+ /** Health: inspect local readiness, and optionally probe one provider's `/models` plus one real multimodal request. */
2209
+ async health(testConnection: boolean, options: ToolCallOptions, testModel = false, provider?: ResolvedProvider): Promise<VisionToolkitHealthResult> {
2047
2210
  return this.runOperation('vision_toolkit_health', options, async (operation) => {
2048
2211
  const info = this.upstreamVersion
2049
2212
  const python: HealthCheck = { status: 'ok', detail: `${info.pythonVersion} via ${info.python}` }
@@ -2063,108 +2226,97 @@ export class VisionToolkitRuntime {
2063
2226
  if (operation.signal.aborted) throw new VisionToolkitError('cancelled', 'vision_toolkit_health: cancelled')
2064
2227
  chrome = { status: 'error', detail: 'Chrome availability probe failed' }
2065
2228
  }
2066
- let resolvedCredential: ResolvedCredential | undefined
2067
- let credential: HealthCheck
2068
- try {
2069
- resolvedCredential = isBuiltInFreeVisionProvider(this.config.provider)
2070
- ? { value: BUILT_IN_FREE_VISION_KEY, source: 'built-in' }
2071
- : await this.ctx.credentials.resolve(this.config.provider.credential)
2072
- credential = resolvedCredential === undefined
2073
- ? { status: 'error', detail: `credential ${this.config.provider.credential} is not configured` }
2074
- : { status: 'ok', detail: `credential ${this.config.provider.credential} is resolvable` }
2075
- } catch {
2076
- credential = { status: 'error', detail: `credential ${this.config.provider.credential} could not be resolved` }
2077
- }
2078
- let artifactDirectory: HealthCheck
2079
- try {
2080
- // allowedDirs are session input roots; they do not affect output readiness.
2081
- const policy = await createPathPolicy(options.workspace, [])
2082
- artifactDirectory = await this.writableDirectoryCheck(policy.outputDir, 'Artifact directory')
2083
- } catch {
2084
- artifactDirectory = { status: 'error', detail: 'Artifact directory could not be prepared' }
2085
- }
2086
- const tempDirectory = await this.writableDirectoryCheck(info.runtimeHome, 'Runtime temp directory')
2087
2229
  let service: HealthCheck = {
2088
2230
  status: 'not_tested',
2089
- detail: 'Connection was not tested; pass testConnection=true to query the configured /models endpoint',
2231
+ detail: 'Connection was not tested; use the per-provider API test',
2090
2232
  }
2091
2233
  let model: HealthCheck = {
2092
2234
  status: 'not_tested',
2093
- detail: 'Vision model was not tested; run an explicit model test to send the bundled diagnostic image',
2235
+ detail: 'Vision model was not tested; use the per-provider model test',
2094
2236
  }
2095
- if (testConnection) {
2096
- if (resolvedCredential === undefined) {
2097
- service = { status: 'error', detail: 'Connection test skipped because the configured credential is unavailable' }
2098
- } else {
2099
- operation.metrics.usedVisionService = true
2100
- const endpoint = `${this.config.provider.baseUrl}/models`
2101
- try {
2102
- const started = Date.now()
2103
- const headers: Record<string, string> = {
2104
- Accept: 'application/json',
2105
- 'User-Agent': this.config.provider.userAgent,
2106
- }
2107
- if (this.config.provider.protocol === 'anthropic') {
2108
- headers['x-api-key'] = resolvedCredential.value
2109
- headers['anthropic-version'] = '2023-06-01'
2110
- } else {
2111
- headers.Authorization = `Bearer ${resolvedCredential.value}`
2112
- }
2113
- const response = await fetch(endpoint, {
2114
- method: 'GET',
2115
- headers,
2116
- signal: operation.signal,
2117
- })
2118
- operation.metrics.upstreamMs += Date.now() - started
2119
- await response.body?.cancel().catch(() => {})
2120
- if (response.ok) {
2121
- service = { status: 'ok', detail: `Service responded at ${endpoint} (HTTP ${response.status})` }
2122
- } else if (response.status === 401) {
2123
- service = { status: 'error', detail: `Service rejected the configured credential (HTTP ${response.status})` }
2124
- } else if (response.status === 403) {
2125
- // Some providers (e.g. Groq preview/account restrictions) block GET /models
2126
- // while real multimodal requests still work. Treat 403 as a warning so the
2127
- // explicit vision-model test, not the model list endpoint, decides access.
2128
- service = { status: 'warning', detail: `Service is reachable but restricted GET /models (HTTP 403); the credential may still be valid for real vision requests` }
2129
- } else if (response.status === 404 || response.status === 405) {
2130
- service = { status: 'warning', detail: `Service is reachable but does not expose GET /models (HTTP ${response.status})` }
2131
- } else if (response.status === 429) {
2132
- service = { status: 'warning', detail: 'Service is reachable but rate-limited the connection test (HTTP 429)' }
2133
- } else {
2134
- service = { status: 'error', detail: `Service connection test failed with HTTP ${response.status}` }
2135
- }
2136
- } catch {
2137
- if (operation.signal.aborted) throw new VisionToolkitError('cancelled', 'vision_toolkit_health: connection test cancelled')
2138
- service = { status: 'error', detail: `Service could not be reached at ${endpoint}` }
2237
+ const target = provider ?? (testConnection || testModel ? this.primaryProvider : undefined)
2238
+ if (target !== undefined && (testConnection || testModel)) {
2239
+ const entry = await this.resolveProviderEnv(target)
2240
+ if (entry === undefined) {
2241
+ if (testConnection) {
2242
+ service = { status: 'error', detail: `Connection test skipped because credential ${String(target.credential)} is unavailable` }
2243
+ }
2244
+ if (testModel) {
2245
+ model = { status: 'error', detail: `Vision model test skipped because credential ${String(target.credential)} is unavailable` }
2139
2246
  }
2140
- }
2141
- }
2142
- if (testModel) {
2143
- if (resolvedCredential === undefined) {
2144
- model = { status: 'error', detail: 'Vision model test skipped because the configured credential is unavailable' }
2145
2247
  } else {
2146
- try {
2147
- const result = await this.runUpstream(
2148
- 'glance',
2149
- [VISION_MODEL_TEST_IMAGE, '-q', VISION_MODEL_TEST_PROMPT],
2150
- operation,
2151
- this.visionEnv(resolvedCredential),
2152
- )
2153
- if (result.stdout.trim().length === 0) {
2154
- throw new VisionToolkitError('output', 'glance: vision API returned an empty description')
2248
+ if (testConnection) {
2249
+ operation.metrics.usedVisionService = true
2250
+ const endpoint = `${target.baseUrl}/models`
2251
+ try {
2252
+ const started = Date.now()
2253
+ const headers: Record<string, string> = {
2254
+ Accept: 'application/json',
2255
+ 'User-Agent': target.userAgent,
2256
+ }
2257
+ if (target.protocol === 'anthropic') {
2258
+ headers['x-api-key'] = entry.env.VISION_API_KEY
2259
+ headers['anthropic-version'] = '2023-06-01'
2260
+ } else {
2261
+ headers.Authorization = `Bearer ${entry.env.VISION_API_KEY}`
2262
+ }
2263
+ const response = await fetch(endpoint, {
2264
+ method: 'GET',
2265
+ headers,
2266
+ signal: operation.signal,
2267
+ })
2268
+ operation.metrics.upstreamMs += Date.now() - started
2269
+ await response.body?.cancel().catch(() => {})
2270
+ if (response.ok) {
2271
+ service = { status: 'ok', detail: `Service responded at ${endpoint} (HTTP ${response.status})` }
2272
+ } else if (response.status === 401) {
2273
+ service = { status: 'error', detail: `Service rejected the configured credential (HTTP ${response.status})` }
2274
+ } else if (response.status === 403) {
2275
+ // Some providers (e.g. Groq preview/account restrictions) block GET /models
2276
+ // while real multimodal requests still work. Treat 403 as a warning so the
2277
+ // explicit vision-model test, not the model list endpoint, decides access.
2278
+ service = { status: 'warning', detail: `Service is reachable but restricted GET /models (HTTP 403); the credential may still be valid for real vision requests` }
2279
+ } else if (response.status === 404 || response.status === 405) {
2280
+ service = { status: 'warning', detail: `Service is reachable but does not expose GET /models (HTTP ${response.status})` }
2281
+ } else if (response.status === 429) {
2282
+ service = { status: 'warning', detail: 'Service is reachable but rate-limited the connection test (HTTP 429)' }
2283
+ } else {
2284
+ service = { status: 'error', detail: `Service connection test failed with HTTP ${response.status}` }
2285
+ }
2286
+ } catch {
2287
+ if (operation.signal.aborted) throw new VisionToolkitError('cancelled', 'vision_toolkit_health: connection test cancelled')
2288
+ service = { status: 'error', detail: `Service could not be reached at ${endpoint}` }
2155
2289
  }
2156
- model = {
2157
- status: 'ok',
2158
- detail: `Vision model ${this.config.provider.model} completed a multimodal request`,
2290
+ }
2291
+ if (testModel) {
2292
+ try {
2293
+ const attemptDeadline = createDeadline(operation.signal, target.timeoutMs)
2294
+ try {
2295
+ const result = await this.runUpstream(
2296
+ 'glance',
2297
+ [VISION_MODEL_TEST_IMAGE, '-q', VISION_MODEL_TEST_PROMPT],
2298
+ { signal: attemptDeadline.signal, metrics: operation.metrics },
2299
+ entry.env,
2300
+ )
2301
+ if (result.stdout.trim().length === 0) {
2302
+ throw new VisionToolkitError('output', 'glance: vision API returned an empty description')
2303
+ }
2304
+ model = {
2305
+ status: 'ok',
2306
+ detail: `Vision model ${target.model} completed a multimodal request`,
2307
+ }
2308
+ } finally {
2309
+ attemptDeadline.cleanup()
2310
+ }
2311
+ } catch (error) {
2312
+ if (operation.signal.aborted) throw error
2313
+ const detail = error instanceof Error ? error.message : String(error)
2314
+ model = { status: 'error', detail: `Vision model test failed: ${detail.slice(0, 600)}` }
2159
2315
  }
2160
- } catch (error) {
2161
- if (operation.signal.aborted) throw error
2162
- const detail = error instanceof Error ? error.message : String(error)
2163
- model = { status: 'error', detail: `Vision model test failed: ${detail.slice(0, 600)}` }
2164
2316
  }
2165
2317
  }
2166
2318
  }
2167
- const checks = { python, dependencies, chrome, credential, artifactDirectory, tempDirectory, service, model }
2319
+ const checks = { python, dependencies, chrome, service, model }
2168
2320
  const healthy = Object.values(checks).every(check => check.status !== 'error')
2169
2321
  return {
2170
2322
  pluginVersion: PLUGIN_VERSION,
@@ -2173,6 +2325,7 @@ export class VisionToolkitRuntime {
2173
2325
  healthy,
2174
2326
  connectionTested: testConnection,
2175
2327
  modelTested: testModel,
2328
+ ...(provider === undefined ? {} : { providerName: provider.name }),
2176
2329
  }
2177
2330
  })
2178
2331
  }