@mengruo/dsh-vision-toolkit 0.1.2 → 0.1.4

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 (85) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +4 -0
  3. package/README.zh.md +4 -0
  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 +59 -19
  10. package/lib/client.js.map +1 -1
  11. package/lib/config.js +64 -12
  12. package/lib/config.js.map +1 -1
  13. package/lib/errors.js +25 -1
  14. package/lib/errors.js.map +1 -1
  15. package/lib/evidence-cache.js +5 -2
  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/paste-images.js +67 -19
  24. package/lib/paste-images.js.map +1 -1
  25. package/lib/paths.js +214 -28
  26. package/lib/paths.js.map +1 -1
  27. package/lib/runtime-manager.js +76 -10
  28. package/lib/runtime-manager.js.map +1 -1
  29. package/lib/runtime.js +389 -104
  30. package/lib/runtime.js.map +1 -1
  31. package/lib/storage-history.js +154 -0
  32. package/lib/storage-history.js.map +1 -0
  33. package/lib/tools.js +68 -35
  34. package/lib/tools.js.map +1 -1
  35. package/lib/types/artifact-access.d.ts.map +1 -1
  36. package/lib/types/client/index.d.ts +23 -5
  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 +38 -7
  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/paste-images.d.ts +12 -4
  51. package/lib/types/paste-images.d.ts.map +1 -1
  52. package/lib/types/paths.d.ts +31 -5
  53. package/lib/types/paths.d.ts.map +1 -1
  54. package/lib/types/runtime-manager.d.ts +28 -4
  55. package/lib/types/runtime-manager.d.ts.map +1 -1
  56. package/lib/types/runtime.d.ts +75 -12
  57. package/lib/types/runtime.d.ts.map +1 -1
  58. package/lib/types/storage-history.d.ts +63 -0
  59. package/lib/types/storage-history.d.ts.map +1 -0
  60. package/lib/types/tools.d.ts +1 -0
  61. package/lib/types/tools.d.ts.map +1 -1
  62. package/lib/types/upstream.d.ts.map +1 -1
  63. package/lib/types/web.d.ts.map +1 -1
  64. package/lib/upstream.js +31 -8
  65. package/lib/upstream.js.map +1 -1
  66. package/lib/web.js +9 -3
  67. package/lib/web.js.map +1 -1
  68. package/package.json +1 -1
  69. package/src/artifact-access.ts +22 -2
  70. package/src/client/index.tsx +72 -20
  71. package/src/client/paste-images.tsx +14 -4
  72. package/src/config.ts +107 -20
  73. package/src/errors.ts +25 -1
  74. package/src/evidence-cache.ts +5 -2
  75. package/src/exposure.ts +16 -2
  76. package/src/image-input-variants.ts +21 -6
  77. package/src/index.ts +65 -6
  78. package/src/paste-images.ts +81 -19
  79. package/src/paths.ts +249 -28
  80. package/src/runtime-manager.ts +93 -10
  81. package/src/runtime.ts +431 -115
  82. package/src/storage-history.ts +172 -0
  83. package/src/tools.ts +78 -46
  84. package/src/upstream.ts +33 -8
  85. package/src/web.ts +9 -2
package/src/runtime.ts CHANGED
@@ -17,7 +17,7 @@ import { describeArtifact, type ArtifactDescriptor } from './artifacts.ts'
17
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
- import { VisionToolkitError } from './errors.ts'
20
+ import { VisionToolkitError, type VisionToolkitErrorCode } from './errors.ts'
21
21
  import {
22
22
  assertDistinctOutput,
23
23
  commitStagedDirectory,
@@ -154,6 +154,11 @@ export class Semaphore {
154
154
  return this.active === 0 && this.waiters.length === 0
155
155
  }
156
156
 
157
+ /** Free slots still claimable without queuing. */
158
+ get available(): number {
159
+ return Math.max(0, this.limit - this.active)
160
+ }
161
+
157
162
  /** Acquire one slot, aborting while queued when `signal` fires. */
158
163
  async acquire(signal: AbortSignal, permits = 1): Promise<void> {
159
164
  if (signal.aborted) throw new VisionToolkitError('cancelled', 'vision-toolkit: cancelled before execution')
@@ -349,7 +354,6 @@ export interface LongScreenshotOcrRequest {
349
354
  overlap?: number
350
355
  prompt?: string
351
356
  jobs?: number
352
- chunkTimeoutSeconds?: number
353
357
  splitOnly?: boolean
354
358
  resume?: boolean
355
359
  }
@@ -471,6 +475,9 @@ export interface VisionToolkitHealthResult {
471
475
  python: HealthCheck
472
476
  dependencies: HealthCheck
473
477
  chrome: HealthCheck
478
+ credential: HealthCheck
479
+ artifactDirectory: HealthCheck
480
+ tempDirectory: HealthCheck
474
481
  service: HealthCheck
475
482
  model: HealthCheck
476
483
  }
@@ -484,7 +491,8 @@ export interface VisionToolkitHealthResult {
484
491
  /** Shared per-call execution options. */
485
492
  export interface ToolCallOptions {
486
493
  signal: AbortSignal
487
- timeoutMs?: number
494
+ /** Override the global hard timeout (seconds) for this call. */
495
+ timeoutSeconds?: number
488
496
  workspace: string
489
497
  /** Session identity for the per-session concurrency cap. */
490
498
  sessionId?: string
@@ -517,10 +525,12 @@ interface OperationMetrics {
517
525
  interface OperationContext {
518
526
  signal: AbortSignal
519
527
  metrics: OperationMetrics
528
+ /** Absolute epoch-millisecond timestamp when the global hard timeout fires. */
529
+ deadlineAt: number
520
530
  }
521
531
 
522
532
  const REGION_PATTERN = /^\s*(-?\d+)\s*,\s*(-?\d+)\s*,\s*(-?\d+)\s*,\s*(-?\d+)\s*$/
523
- const MAX_TIMEOUT_MS = 600_000
533
+ const MAX_TIMEOUT_SECONDS = 600
524
534
  const FORMAT_BY_EXTENSION = new Map([
525
535
  ['.png', 'png'],
526
536
  ['.jpg', 'jpeg'],
@@ -530,6 +540,32 @@ const FORMAT_BY_EXTENSION = new Map([
530
540
  ])
531
541
  const HEX_COLOR_PATTERN = /^#[0-9A-F]{6}$/
532
542
 
543
+ /**
544
+ * Error codes a provider retries against the SAME provider within its
545
+ * `attempts` budget. Only transient failures are worth re-requesting: a
546
+ * timeout may clear on the next attempt and a 5xx / network drop is usually
547
+ * ephemeral. Deterministic failures (auth, quota, rate_limit, invalid_request,
548
+ * region, tos) must fail over to the next provider immediately instead of
549
+ * re-requesting a backend that cannot succeed with the same input.
550
+ */
551
+ const RETRYABLE_CODES: ReadonlySet<VisionToolkitErrorCode> = new Set(['timeout', 'server', 'network'])
552
+
553
+ /** Resolve as soon as `signal` aborts (or immediately when already aborted). */
554
+ function untilAbort(signal: AbortSignal): Promise<void> {
555
+ if (signal.aborted) return Promise.resolve()
556
+ return new Promise(resolve => signal.addEventListener('abort', () => resolve(), { once: true }))
557
+ }
558
+
559
+ /** Sleep for `ms`, resolving early when `signal` aborts. */
560
+ function abortableSleep(ms: number, signal: AbortSignal): Promise<void> {
561
+ if (signal.aborted) return Promise.resolve()
562
+ return new Promise(resolve => {
563
+ const onAbort = (): void => { clearTimeout(timer); resolve() }
564
+ const timer = setTimeout(() => { signal.removeEventListener('abort', onAbort); resolve() }, ms)
565
+ signal.addEventListener('abort', onAbort, { once: true })
566
+ })
567
+ }
568
+
533
569
  function integerInRange(value: number | undefined, fallback: number, minimum: number, maximum: number, name: string): number {
534
570
  const resolved = value ?? fallback
535
571
  if (!Number.isInteger(resolved) || resolved < minimum || resolved > maximum) {
@@ -703,6 +739,37 @@ interface ResolvedProviderEnv {
703
739
  env: UpstreamEnvironment
704
740
  }
705
741
 
742
+ /** Mutable in-flight state for one provider during a hedge-based failover run. */
743
+ interface ProviderTask {
744
+ index: number
745
+ entry: ResolvedProviderEnv
746
+ cumulativeMs: number
747
+ status: 'idle' | 'running' | 'succeeded' | 'failed' | 'ratelimited'
748
+ result?: UpstreamRunResult
749
+ error?: VisionToolkitError
750
+ hedged: boolean
751
+ launched: boolean
752
+ settled: Promise<void>
753
+ settle: () => void
754
+ abort: AbortController
755
+ }
756
+
757
+ /** Live concurrency accounting returned by the availability query tool. */
758
+ export interface ConcurrencyStatus {
759
+ /** New tool calls this session may start right now. */
760
+ available: number
761
+ /** Per-session cap on concurrent tool operations. */
762
+ sessionMax: number
763
+ /** Tool operations currently in flight in this session. */
764
+ sessionInUse: number
765
+ /** Free per-session slots. */
766
+ sessionFree: number
767
+ /** Total free model-request slots summed across enabled providers. */
768
+ modelFree: number
769
+ /** Per-provider breakdown. */
770
+ models: Array<{ name: string; concurrency: number; inUse: number; free: number }>
771
+ }
772
+
706
773
  /** Runtime facade used by every native tool. */
707
774
  export class VisionToolkitRuntime {
708
775
  private readonly semaphores = new Map<string, Semaphore>()
@@ -714,6 +781,7 @@ export class VisionToolkitRuntime {
714
781
  private readonly ctx: Context,
715
782
  private readonly config: ResolvedVisionToolkitConfig,
716
783
  adapter?: UpstreamAdapter,
784
+ private readonly readableStorageDirs: readonly string[] = [],
717
785
  ) {
718
786
  this.adapter = adapter ?? new UpstreamAdapter(ctx, config)
719
787
  }
@@ -723,6 +791,16 @@ export class VisionToolkitRuntime {
723
791
  return this.adapter.versionInfo
724
792
  }
725
793
 
794
+ /** Per-session cap on concurrent tool operations. */
795
+ get sessionMaxConcurrency(): number {
796
+ return this.config.sessionMaxConcurrency
797
+ }
798
+
799
+ /** Shared storage root belonging to this immutable runtime generation. */
800
+ get storageDirectory(): string | undefined {
801
+ return this.config.storageDir
802
+ }
803
+
726
804
  /** Stable identity for persisted image descriptions produced by this runtime. */
727
805
  get evidenceFingerprint(): string {
728
806
  return evidenceRuntimeFingerprint(this.config, undefined, process.env.VISION_SSL_VERIFY?.trim())
@@ -749,18 +827,13 @@ export class VisionToolkitRuntime {
749
827
  })
750
828
  }
751
829
 
752
- private timeout(options: ToolCallOptions): number {
753
- const value = options.timeoutMs ?? this.operationTimeoutMs()
754
- if (!Number.isInteger(value) || value < 1000 || value > MAX_TIMEOUT_MS) {
755
- throw new VisionToolkitError('input', `timeoutMs must be an integer between 1000 and ${MAX_TIMEOUT_MS}`)
830
+ /** Global hard timeout (ms) for one tool invocation, honoring the per-call override. */
831
+ private hardTimeoutMs(options: ToolCallOptions): number {
832
+ const seconds = options.timeoutSeconds ?? this.config.hardTimeoutSeconds
833
+ if (!Number.isInteger(seconds) || seconds < 1 || seconds > MAX_TIMEOUT_SECONDS) {
834
+ throw new VisionToolkitError('input', `timeoutSeconds must be an integer between 1 and ${MAX_TIMEOUT_SECONDS}`)
756
835
  }
757
- return value
758
- }
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))
836
+ return seconds * 1000
764
837
  }
765
838
 
766
839
  private operationError(
@@ -785,23 +858,51 @@ export class VisionToolkitRuntime {
785
858
  return new VisionToolkitError('runtime', `${tool}: execution failed`, { cause: error })
786
859
  }
787
860
 
788
- private semaphore(options: ToolCallOptions): { key: string; value: Semaphore } {
861
+ /** Per-session concurrency gate; callers acquire without queuing (excess is rejected). */
862
+ private sessionGate(options: ToolCallOptions): { key: string; value: Semaphore } {
789
863
  const key = options.sessionId ?? `workspace:${options.workspace}`
790
- const value = this.semaphores.get(key) ?? new Semaphore(this.config.concurrency)
864
+ const value = this.semaphores.get(key) ?? new Semaphore(this.config.sessionMaxConcurrency)
791
865
  this.semaphores.set(key, value)
792
866
  return { key, value }
793
867
  }
794
868
 
869
+ /** Live concurrency accounting for the calling session across the enabled provider pool. */
870
+ concurrencyStatus(options: ToolCallOptions): ConcurrencyStatus {
871
+ const gate = this.sessionGate(options)
872
+ const sessionFree = gate.value.available
873
+ const models = this.config.providers
874
+ .filter(provider => provider.enabled)
875
+ .map(provider => {
876
+ const modelGate = this.providerGate(provider)
877
+ return {
878
+ name: provider.name,
879
+ concurrency: provider.concurrency,
880
+ inUse: provider.concurrency - modelGate.available,
881
+ free: modelGate.available,
882
+ }
883
+ })
884
+ const modelFree = models.reduce((sum, model) => sum + model.free, 0)
885
+ return {
886
+ available: Math.min(sessionFree, modelFree),
887
+ sessionMax: this.config.sessionMaxConcurrency,
888
+ sessionInUse: this.config.sessionMaxConcurrency - sessionFree,
889
+ sessionFree,
890
+ modelFree,
891
+ models,
892
+ }
893
+ }
894
+
795
895
  private async runOperation<T>(
796
896
  tool: string,
797
897
  options: ToolCallOptions,
798
898
  action: (operation: OperationContext) => Promise<T>,
799
899
  permits = 1,
800
900
  ): Promise<T> {
801
- const timeoutMs = this.timeout(options)
802
- const semaphore = this.semaphore(options)
901
+ const hardTimeoutMs = this.hardTimeoutMs(options)
902
+ const startedAt = Date.now()
903
+ const deadlineAt = startedAt + hardTimeoutMs
803
904
  const metrics: OperationMetrics = {
804
- startedAt: Date.now(),
905
+ startedAt,
805
906
  queueMs: 0,
806
907
  upstreamMs: 0,
807
908
  imageBytes: 0,
@@ -810,48 +911,19 @@ export class VisionToolkitRuntime {
810
911
  cacheHits: 0,
811
912
  usedVisionService: false,
812
913
  }
813
- let acquired = false
814
- const queueDeadline = createDeadline(options.signal, timeoutMs)
815
- try {
816
- await semaphore.value.acquire(queueDeadline.signal, permits)
817
- acquired = true
818
- if (queueDeadline.signal.aborted) throw this.operationError(tool, undefined, queueDeadline, 'queue')
819
- metrics.queueMs = Date.now() - metrics.startedAt
820
- } catch (error) {
821
- metrics.queueMs = Date.now() - metrics.startedAt
822
- const classified = this.operationError(tool, error, queueDeadline, 'queue')
823
- if (acquired) {
824
- semaphore.value.release(permits)
825
- acquired = false
826
- }
827
- this.ctx.logger.warn(
828
- 'dsh-vision-toolkit tool=%s outcome=error category=%s totalMs=%d queueMs=%d upstreamMs=%d images=%d imageBytes=%d imagePixels=%d cacheHits=%d',
829
- tool,
830
- classified.code,
831
- Date.now() - metrics.startedAt,
832
- metrics.queueMs,
833
- metrics.upstreamMs,
834
- metrics.imageCount,
835
- metrics.imageBytes,
836
- metrics.imagePixels,
837
- metrics.cacheHits,
838
- )
839
- throw classified
840
- } finally {
841
- queueDeadline.cleanup()
842
- if (!acquired && semaphore.value.idle) this.semaphores.delete(semaphore.key)
914
+ const gate = this.sessionGate(options)
915
+ if (!gate.value.tryAcquire(permits)) {
916
+ throw new VisionToolkitError('capacity', `${tool}: exceeded the session concurrency limit`)
843
917
  }
844
-
845
- const executionDeadline = createDeadline(options.signal, timeoutMs)
918
+ const executionDeadline = createDeadline(options.signal, hardTimeoutMs)
846
919
  try {
847
920
  if (executionDeadline.signal.aborted) throw this.operationError(tool, undefined, executionDeadline)
848
- const value = await action({ signal: executionDeadline.signal, metrics })
921
+ const value = await action({ signal: executionDeadline.signal, metrics, deadlineAt })
849
922
  if (executionDeadline.signal.aborted) throw this.operationError(tool, undefined, executionDeadline)
850
923
  this.ctx.logger.info(
851
- 'dsh-vision-toolkit tool=%s outcome=ok totalMs=%d queueMs=%d upstreamMs=%d images=%d imageBytes=%d imagePixels=%d cacheHits=%d model=%s',
924
+ 'dsh-vision-toolkit tool=%s outcome=ok totalMs=%d upstreamMs=%d images=%d imageBytes=%d imagePixels=%d cacheHits=%d model=%s',
852
925
  tool,
853
926
  Date.now() - metrics.startedAt,
854
- metrics.queueMs,
855
927
  metrics.upstreamMs,
856
928
  metrics.imageCount,
857
929
  metrics.imageBytes,
@@ -863,11 +935,10 @@ export class VisionToolkitRuntime {
863
935
  } catch (error) {
864
936
  const classified = this.operationError(tool, error, executionDeadline)
865
937
  this.ctx.logger.warn(
866
- 'dsh-vision-toolkit tool=%s outcome=error category=%s totalMs=%d queueMs=%d upstreamMs=%d images=%d imageBytes=%d imagePixels=%d cacheHits=%d',
938
+ 'dsh-vision-toolkit tool=%s outcome=error category=%s totalMs=%d upstreamMs=%d images=%d imageBytes=%d imagePixels=%d cacheHits=%d',
867
939
  tool,
868
940
  classified.code,
869
941
  Date.now() - metrics.startedAt,
870
- metrics.queueMs,
871
942
  metrics.upstreamMs,
872
943
  metrics.imageCount,
873
944
  metrics.imageBytes,
@@ -876,9 +947,9 @@ export class VisionToolkitRuntime {
876
947
  )
877
948
  throw classified
878
949
  } finally {
879
- if (acquired) semaphore.value.release(permits)
950
+ gate.value.release(permits)
880
951
  executionDeadline.cleanup()
881
- if (semaphore.value.idle) this.semaphores.delete(semaphore.key)
952
+ if (gate.value.idle) this.semaphores.delete(gate.key)
882
953
  }
883
954
  }
884
955
 
@@ -955,13 +1026,13 @@ export class VisionToolkitRuntime {
955
1026
  }
956
1027
 
957
1028
  private pathPolicy(workspace: string): Promise<PathPolicy> {
958
- return createPathPolicy(workspace, this.config.allowedDirs)
1029
+ return createPathPolicy(workspace, this.config.allowedDirs, this.config.storageDir, this.readableStorageDirs)
959
1030
  }
960
1031
 
961
1032
  private async compressedImageRoot(policy: PathPolicy): Promise<string> {
962
- const root = join(policy.workspace, '.dsh-vision-toolkit', 'tmp', 'compressed-images')
963
- let current = policy.workspace
964
- for (const segment of ['.dsh-vision-toolkit', 'tmp', 'compressed-images']) {
1033
+ const root = join(policy.storageRoot, 'tmp', 'compressed-images')
1034
+ let current = policy.storageRoot
1035
+ for (const segment of ['tmp', 'compressed-images']) {
965
1036
  current = join(current, segment)
966
1037
  try {
967
1038
  await mkdir(current, { mode: 0o700 })
@@ -972,13 +1043,13 @@ export class VisionToolkitRuntime {
972
1043
  if (info.isSymbolicLink() || !info.isDirectory()) {
973
1044
  throw new VisionToolkitError('path', `compressed-image cache path is not a real directory: ${current}`)
974
1045
  }
975
- if (!isWithin(policy.workspace, current)) {
976
- throw new VisionToolkitError('path', `compressed-image cache path escaped the workspace: ${current}`)
1046
+ if (!isWithin(policy.storageRoot, current)) {
1047
+ throw new VisionToolkitError('path', `compressed-image cache path escaped plugin storage: ${current}`)
977
1048
  }
978
1049
  }
979
1050
  const canonical = await realpath(root)
980
- if (!isWithin(policy.workspace, canonical)) {
981
- throw new VisionToolkitError('path', 'compressed-image cache resolved outside the workspace')
1051
+ if (!isWithin(policy.storageRoot, canonical)) {
1052
+ throw new VisionToolkitError('path', 'compressed-image cache resolved outside plugin storage')
982
1053
  }
983
1054
  return canonical
984
1055
  }
@@ -1228,12 +1299,15 @@ export class VisionToolkitRuntime {
1228
1299
  }
1229
1300
 
1230
1301
  /**
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.
1302
+ * Hedge-based failover across the enabled provider pool. The highest-priority
1303
+ * provider runs first; when one of its requests crosses t1 it keeps running
1304
+ * while the next provider starts in parallel. A provider whose cumulative
1305
+ * request time reaches t2 is terminated. A 429 provider is parked and moved
1306
+ * past immediately; parked providers are revisited at a 10s cadence once
1307
+ * every other provider is exhausted. The result always prefers the earliest
1308
+ * (highest-priority) provider.
1235
1309
  */
1236
- private async runVisionWithFailover(
1310
+ private async runVisionHedge(
1237
1311
  tool: 'glance' | 'ground' | 'detect' | 'long_screenshot_ocr',
1238
1312
  args: readonly string[],
1239
1313
  images: readonly ImageInfo[],
@@ -1243,40 +1317,256 @@ export class VisionToolkitRuntime {
1243
1317
  if (pool.length === 0) {
1244
1318
  throw new VisionToolkitError('config', 'no enabled vision provider has a resolvable credential')
1245
1319
  }
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()
1320
+ // Providers that cannot accept the image size are skipped entirely.
1321
+ const eligible = pool.filter(({ provider }) =>
1322
+ images.every(image => image.bytes <= provider.maxImageBytes && image.width * image.height <= provider.maxImagePixels))
1323
+ if (eligible.length === 0) {
1324
+ throw new VisionToolkitError('capacity', `${tool}: no enabled vision provider accepts the image size`)
1325
+ }
1326
+
1327
+ const tasks: ProviderTask[] = eligible.map((entry, index) => {
1328
+ let settle!: () => void
1329
+ const settled = new Promise<void>(resolve => { settle = resolve })
1330
+ return {
1331
+ index,
1332
+ entry,
1333
+ cumulativeMs: 0,
1334
+ status: 'idle',
1335
+ hedged: false,
1336
+ launched: false,
1337
+ settled,
1338
+ settle,
1339
+ abort: new AbortController(),
1340
+ }
1341
+ })
1342
+ const n = tasks.length
1343
+
1344
+ const launch = (from: number): void => {
1345
+ for (let i = from; i < n; i++) {
1346
+ const task = tasks[i]
1347
+ if (task === undefined || task.launched || task.status !== 'idle') continue
1348
+ task.launched = true
1349
+ void this.runProviderTask(tool, args, operation, task, () => launch(i + 1))
1350
+ return
1351
+ }
1352
+ }
1353
+
1354
+ launch(0)
1355
+
1356
+ const settleAny = (subset: ProviderTask[]): Promise<void> =>
1357
+ Promise.race([...subset.map(task => task.settled), untilAbort(operation.signal)])
1358
+
1359
+ while (true) {
1360
+ if (operation.signal.aborted) break
1361
+ const running = tasks.filter(task => task.status === 'running')
1362
+ const successIndex = tasks.findIndex(task => task.status === 'succeeded')
1363
+ if (successIndex >= 0) {
1364
+ const blocking = running.filter(task => task.index < successIndex)
1365
+ if (blocking.length === 0) {
1366
+ for (const task of running) task.abort.abort()
1367
+ return tasks[successIndex]!.result!
1368
+ }
1369
+ await settleAny(blocking)
1370
+ } else {
1371
+ if (running.length === 0) break
1372
+ await settleAny(running)
1373
+ }
1374
+ }
1375
+
1376
+ // No success from the main pass. Revisit parked (429) providers at a 10s cadence.
1377
+ if (!operation.signal.aborted) {
1378
+ const parked = tasks.filter(task => task.status === 'ratelimited')
1379
+ if (parked.length > 0) {
1380
+ const revisited = await this.revisitRateLimited(tool, args, operation, parked)
1381
+ if (revisited !== undefined) return revisited
1382
+ }
1383
+ }
1384
+
1385
+ const success = tasks.find(task => task.status === 'succeeded')
1386
+ if (success !== undefined) return success.result!
1387
+ if (operation.signal.aborted) {
1388
+ throw new VisionToolkitError('timeout', `${tool}: timed out`)
1389
+ }
1390
+ const firstError = tasks.find(task => task.status === 'failed' || task.status === 'ratelimited')
1391
+ if (firstError?.error !== undefined) throw firstError.error
1392
+ throw new VisionToolkitError('service', `${tool}: all vision providers failed`)
1393
+ }
1394
+
1395
+ /** Remaining request budget (ms) for one provider: the tighter of its t2 and the global deadline. */
1396
+ private providerRequestBudget(task: ProviderTask, operation: OperationContext): number {
1397
+ const t2Remaining = task.entry.provider.t2Seconds * 1000 - task.cumulativeMs
1398
+ const globalRemaining = operation.deadlineAt - Date.now()
1399
+ return Math.max(0, Math.min(t2Remaining, globalRemaining))
1400
+ }
1401
+
1402
+ /**
1403
+ * Advance to the next provider after one provider reached a terminal
1404
+ * failure. This is what makes failover work for FAST failures too: the
1405
+ * hedge timer only launches the next provider when the current one is SLOW
1406
+ * (crosses t1), so a quick auth/5xx/network failure must explicitly launch
1407
+ * the successor. Never advances when a higher-priority provider superseded
1408
+ * this task, when the whole operation was cancelled, or when the global
1409
+ * deadline has too little room left for another request. `launch` is
1410
+ * idempotent, so an earlier hedge timer cannot cause a double launch.
1411
+ */
1412
+ private advanceAfterFailure(task: ProviderTask, operation: OperationContext, launchNext: () => void): void {
1413
+ if (task.abort.signal.aborted || operation.signal.aborted) return
1414
+ if (operation.deadlineAt - Date.now() < this.config.minAvailableSeconds * 1000) return
1415
+ launchNext()
1416
+ }
1417
+
1418
+ /**
1419
+ * Run one provider to a terminal state: retryable errors retry within
1420
+ * `attempts`, a single request crossing t1 hedges the next provider, and the
1421
+ * provider is terminated once its cumulative time reaches t2. A 429 parks the
1422
+ * provider and moves on. No request is issued once the remaining budget drops
1423
+ * below the configured minimum available time.
1424
+ */
1425
+ private async runProviderTask(
1426
+ tool: 'glance' | 'ground' | 'detect' | 'long_screenshot_ocr',
1427
+ args: readonly string[],
1428
+ operation: OperationContext,
1429
+ task: ProviderTask,
1430
+ launchNext: () => void,
1431
+ ): Promise<void> {
1432
+ const { provider, env } = task.entry
1433
+ const gate = this.providerGate(provider)
1434
+ if (!gate.tryAcquire()) {
1435
+ task.status = 'failed'
1436
+ task.error = new VisionToolkitError('capacity', `${tool}: ${provider.name} has no free concurrency slot`)
1437
+ task.settle()
1438
+ launchNext()
1439
+ return
1440
+ }
1441
+ task.status = 'running'
1442
+ try {
1443
+ let attempt = 0
1444
+ const minAvailableMs = this.config.minAvailableSeconds * 1000
1445
+ while (true) {
1446
+ const budget = this.providerRequestBudget(task, operation)
1447
+ if (budget < minAvailableMs) {
1448
+ task.status = 'failed'
1449
+ task.error = new VisionToolkitError('timeout', `${tool}: ${provider.name} has insufficient remaining time`)
1450
+ this.advanceAfterFailure(task, operation, launchNext)
1451
+ return
1452
+ }
1453
+ const reqDeadline = createDeadline(AbortSignal.any([operation.signal, task.abort.signal]), budget)
1454
+ const hedgeMs = Math.min(provider.t1Seconds * 1000, budget)
1455
+ let hedgeTimer: ReturnType<typeof setTimeout> | undefined
1456
+ if (!task.hedged) {
1457
+ hedgeTimer = setTimeout(() => {
1458
+ task.hedged = true
1459
+ launchNext()
1460
+ }, hedgeMs)
1461
+ }
1462
+ const started = Date.now()
1463
+ try {
1464
+ const result = await this.runUpstream(tool, args, { signal: reqDeadline.signal, metrics: operation.metrics }, env)
1465
+ if (hedgeTimer !== undefined) clearTimeout(hedgeTimer)
1466
+ task.cumulativeMs += Date.now() - started
1467
+ task.status = 'succeeded'
1468
+ task.result = result
1469
+ return
1470
+ } catch (error) {
1471
+ if (hedgeTimer !== undefined) clearTimeout(hedgeTimer)
1472
+ task.cumulativeMs += Date.now() - started
1473
+ if (task.abort.signal.aborted) {
1474
+ task.status = 'failed'
1475
+ task.error = new VisionToolkitError('cancelled', `${tool}: superseded by a higher-priority provider`)
1476
+ return
1477
+ }
1478
+ if (operation.signal.aborted) {
1479
+ task.status = 'failed'
1480
+ task.error = new VisionToolkitError('timeout', `${tool}: timed out`)
1481
+ return
1482
+ }
1483
+ const classified = error instanceof VisionToolkitError
1484
+ ? error
1485
+ : new VisionToolkitError('service', `${tool}: request failed`, { cause: error })
1486
+ if (reqDeadline.timedOut) {
1487
+ task.status = 'failed'
1488
+ task.error = new VisionToolkitError('timeout', `${tool}: ${provider.name} exhausted its t2 budget`)
1489
+ this.advanceAfterFailure(task, operation, launchNext)
1490
+ return
1491
+ }
1492
+ if (classified.code === 'rate_limit') {
1493
+ task.status = 'ratelimited'
1494
+ task.error = classified
1495
+ launchNext()
1496
+ return
1269
1497
  }
1498
+ if (RETRYABLE_CODES.has(classified.code) && attempt + 1 < provider.attempts) {
1499
+ attempt += 1
1500
+ continue
1501
+ }
1502
+ task.status = 'failed'
1503
+ task.error = classified
1504
+ this.advanceAfterFailure(task, operation, launchNext)
1505
+ return
1506
+ } finally {
1507
+ reqDeadline.cleanup()
1270
1508
  }
1271
- } finally {
1272
- gate.release()
1273
1509
  }
1510
+ } finally {
1511
+ gate.release()
1512
+ task.settle()
1274
1513
  }
1275
- if (!attempted) {
1276
- throw new VisionToolkitError('capacity', `${tool}: no enabled vision provider accepts the image size or has a free concurrency slot`)
1514
+ }
1515
+
1516
+ /**
1517
+ * Revisit parked (429) providers in priority order at a 10s cadence until one
1518
+ * succeeds or every provider exhausts its budget. Returns a success result or
1519
+ * `undefined` when the global deadline or minimum available time stops the loop.
1520
+ */
1521
+ private async revisitRateLimited(
1522
+ tool: 'glance' | 'ground' | 'detect' | 'long_screenshot_ocr',
1523
+ args: readonly string[],
1524
+ operation: OperationContext,
1525
+ parked: ProviderTask[],
1526
+ ): Promise<UpstreamRunResult | undefined> {
1527
+ const minAvailableMs = this.config.minAvailableSeconds * 1000
1528
+ while (!operation.signal.aborted) {
1529
+ let anyRevisitable = false
1530
+ for (const task of parked) {
1531
+ if (operation.signal.aborted) break
1532
+ if (task.status !== 'ratelimited') continue
1533
+ const budget = this.providerRequestBudget(task, operation)
1534
+ if (budget < minAvailableMs) continue
1535
+ anyRevisitable = true
1536
+ await abortableSleep(Math.min(10_000, budget), operation.signal)
1537
+ if (operation.signal.aborted) break
1538
+ const { provider, env } = task.entry
1539
+ const gate = this.providerGate(provider)
1540
+ if (!gate.tryAcquire()) continue
1541
+ const reqDeadline = createDeadline(operation.signal, Math.min(provider.t2Seconds * 1000 - task.cumulativeMs, operation.deadlineAt - Date.now()))
1542
+ const started = Date.now()
1543
+ try {
1544
+ const result = await this.runUpstream(tool, args, { signal: reqDeadline.signal, metrics: operation.metrics }, env)
1545
+ task.cumulativeMs += Date.now() - started
1546
+ task.status = 'succeeded'
1547
+ task.result = result
1548
+ return result
1549
+ } catch (error) {
1550
+ task.cumulativeMs += Date.now() - started
1551
+ if (reqDeadline.timedOut) {
1552
+ task.status = 'failed'
1553
+ task.error = new VisionToolkitError('timeout', `${tool}: ${provider.name} exhausted its t2 budget`)
1554
+ continue
1555
+ }
1556
+ const classified = error instanceof VisionToolkitError
1557
+ ? error
1558
+ : new VisionToolkitError('service', `${tool}: request failed`, { cause: error })
1559
+ if (classified.code === 'rate_limit') continue
1560
+ task.status = 'failed'
1561
+ task.error = classified
1562
+ } finally {
1563
+ reqDeadline.cleanup()
1564
+ gate.release()
1565
+ }
1566
+ }
1567
+ if (!anyRevisitable) break
1277
1568
  }
1278
- if (lastError instanceof VisionToolkitError) throw lastError
1279
- throw new VisionToolkitError('service', `${tool}: all vision providers failed`, { cause: lastError })
1569
+ return undefined
1280
1570
  }
1281
1571
 
1282
1572
  private async glanceCacheKey(
@@ -1325,7 +1615,7 @@ export class VisionToolkitRuntime {
1325
1615
  private async runUpstream(
1326
1616
  tool: UpstreamTool,
1327
1617
  args: readonly string[],
1328
- operation: OperationContext,
1618
+ operation: { signal: AbortSignal; metrics: OperationMetrics },
1329
1619
  env?: UpstreamEnvironment,
1330
1620
  ): Promise<UpstreamRunResult> {
1331
1621
  const started = Date.now()
@@ -1443,7 +1733,7 @@ export class VisionToolkitRuntime {
1443
1733
  return cached.result
1444
1734
  }
1445
1735
  }
1446
- const result = await this.runVisionWithFailover('glance', [
1736
+ const result = await this.runVisionHedge('glance', [
1447
1737
  ...images.map(image => image.path),
1448
1738
  ...(request.region !== undefined ? ['--region', request.region] : []),
1449
1739
  ...(request.ocr === true ? ['--ocr'] : []),
@@ -1496,7 +1786,7 @@ export class VisionToolkitRuntime {
1496
1786
  }
1497
1787
  const image = await this.prepareVisionImage(request.image, pool.map(entry => entry.provider), policy, operation)
1498
1788
  this.accountImage(image, operation)
1499
- const result = await this.runVisionWithFailover(tool, [
1789
+ const result = await this.runVisionHedge(tool, [
1500
1790
  image.path,
1501
1791
  request.target,
1502
1792
  ...(request.region !== undefined ? ['--region', request.region] : []),
@@ -1820,13 +2110,6 @@ export class VisionToolkitRuntime {
1820
2110
  const overlap = request.overlap === undefined
1821
2111
  ? undefined
1822
2112
  : integerInRange(request.overlap, request.overlap, 0, 10000, 'long_screenshot_ocr.overlap')
1823
- const chunkTimeoutSeconds = finiteInRange(
1824
- request.chunkTimeoutSeconds ?? Math.min(180, Math.max(1, Math.ceil(this.timeout(options) / 1000))),
1825
- 1,
1826
- 600,
1827
- 'long_screenshot_ocr.chunkTimeoutSeconds',
1828
- )
1829
- if (chunkTimeoutSeconds === undefined) throw new VisionToolkitError('input', 'long_screenshot_ocr chunk timeout is required')
1830
2113
  if (request.prompt !== undefined && request.prompt.trim().length === 0) {
1831
2114
  throw new VisionToolkitError('input', 'long_screenshot_ocr.prompt must not be empty when provided')
1832
2115
  }
@@ -1868,13 +2151,13 @@ export class VisionToolkitRuntime {
1868
2151
  '--jobs',
1869
2152
  String(jobs),
1870
2153
  '--timeout',
1871
- String(chunkTimeoutSeconds),
2154
+ String(this.config.hardTimeoutSeconds),
1872
2155
  ...(splitOnly ? ['--split-only'] : []),
1873
2156
  ...(request.resume === true ? ['--resume'] : []),
1874
2157
  ]
1875
2158
  const result = splitOnly
1876
2159
  ? await this.runUpstream('long_screenshot_ocr', ocrArgs, operation)
1877
- : await this.runVisionWithFailover('long_screenshot_ocr', ocrArgs, [image], operation, pool)
2160
+ : await this.runVisionHedge('long_screenshot_ocr', ocrArgs, [image], operation, pool)
1878
2161
  const reported = result.stdout.trim()
1879
2162
  const expectedReported = splitOnly ? stagedManifest : stagedOutput
1880
2163
  if (reported !== expectedReported) {
@@ -2205,6 +2488,18 @@ export class VisionToolkitRuntime {
2205
2488
  })
2206
2489
  }
2207
2490
 
2491
+ private async writableDirectoryCheck(path: string, label: string): Promise<HealthCheck> {
2492
+ const probe = join(path, `.vision-toolkit-health-${randomUUID()}`)
2493
+ try {
2494
+ await writeFile(probe, 'ok\n', { encoding: 'utf8', flag: 'wx' })
2495
+ await rm(probe, { force: true })
2496
+ return { status: 'ok', detail: `${label} is writable: ${path}` }
2497
+ } catch {
2498
+ await rm(probe, { force: true }).catch(() => {})
2499
+ return { status: 'error', detail: `${label} is not writable: ${path}` }
2500
+ }
2501
+ }
2502
+
2208
2503
  /** Health: inspect local readiness, and optionally probe one provider's `/models` plus one real multimodal request. */
2209
2504
  async health(testConnection: boolean, options: ToolCallOptions, testModel = false, provider?: ResolvedProvider): Promise<VisionToolkitHealthResult> {
2210
2505
  return this.runOperation('vision_toolkit_health', options, async (operation) => {
@@ -2226,6 +2521,27 @@ export class VisionToolkitRuntime {
2226
2521
  if (operation.signal.aborted) throw new VisionToolkitError('cancelled', 'vision_toolkit_health: cancelled')
2227
2522
  chrome = { status: 'error', detail: 'Chrome availability probe failed' }
2228
2523
  }
2524
+ let resolvedCredential: ResolvedCredential | undefined
2525
+ let credential: HealthCheck
2526
+ try {
2527
+ resolvedCredential = isBuiltInFreeVisionProvider(this.config.provider)
2528
+ ? { value: BUILT_IN_FREE_VISION_KEY, source: 'built-in' }
2529
+ : await this.ctx.credentials.resolve(this.config.provider.credential)
2530
+ credential = resolvedCredential === undefined
2531
+ ? { status: 'error', detail: `credential ${this.config.provider.credential} is not configured` }
2532
+ : { status: 'ok', detail: `credential ${this.config.provider.credential} is resolvable` }
2533
+ } catch {
2534
+ credential = { status: 'error', detail: `credential ${this.config.provider.credential} could not be resolved` }
2535
+ }
2536
+ let artifactDirectory: HealthCheck
2537
+ try {
2538
+ // allowedDirs are session input roots; they do not affect output readiness.
2539
+ const policy = await createPathPolicy(options.workspace, [], this.config.storageDir)
2540
+ artifactDirectory = await this.writableDirectoryCheck(policy.outputDir, 'Artifact directory')
2541
+ } catch {
2542
+ artifactDirectory = { status: 'error', detail: 'Artifact directory could not be prepared' }
2543
+ }
2544
+ const tempDirectory = await this.writableDirectoryCheck(info.runtimeHome, 'Runtime temp directory')
2229
2545
  let service: HealthCheck = {
2230
2546
  status: 'not_tested',
2231
2547
  detail: 'Connection was not tested; use the per-provider API test',
@@ -2290,7 +2606,7 @@ export class VisionToolkitRuntime {
2290
2606
  }
2291
2607
  if (testModel) {
2292
2608
  try {
2293
- const attemptDeadline = createDeadline(operation.signal, target.timeoutMs)
2609
+ const attemptDeadline = createDeadline(operation.signal, target.t2Seconds * 1000)
2294
2610
  try {
2295
2611
  const result = await this.runUpstream(
2296
2612
  'glance',
@@ -2316,7 +2632,7 @@ export class VisionToolkitRuntime {
2316
2632
  }
2317
2633
  }
2318
2634
  }
2319
- const checks = { python, dependencies, chrome, service, model }
2635
+ const checks = { python, dependencies, chrome, credential, artifactDirectory, tempDirectory, service, model }
2320
2636
  const healthy = Object.values(checks).every(check => check.status !== 'error')
2321
2637
  return {
2322
2638
  pluginVersion: PLUGIN_VERSION,