@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/lib/runtime.js CHANGED
@@ -107,6 +107,10 @@ export class Semaphore {
107
107
  get idle() {
108
108
  return this.active === 0 && this.waiters.length === 0;
109
109
  }
110
+ /** Free slots still claimable without queuing. */
111
+ get available() {
112
+ return Math.max(0, this.limit - this.active);
113
+ }
110
114
  /** Acquire one slot, aborting while queued when `signal` fires. */
111
115
  async acquire(signal, permits = 1) {
112
116
  if (signal.aborted)
@@ -161,7 +165,7 @@ export class Semaphore {
161
165
  }
162
166
  }
163
167
  const REGION_PATTERN = /^\s*(-?\d+)\s*,\s*(-?\d+)\s*,\s*(-?\d+)\s*,\s*(-?\d+)\s*$/;
164
- const MAX_TIMEOUT_MS = 600_000;
168
+ const MAX_TIMEOUT_SECONDS = 600;
165
169
  const FORMAT_BY_EXTENSION = new Map([
166
170
  ['.png', 'png'],
167
171
  ['.jpg', 'jpeg'],
@@ -170,6 +174,31 @@ const FORMAT_BY_EXTENSION = new Map([
170
174
  ['.webp', 'webp'],
171
175
  ]);
172
176
  const HEX_COLOR_PATTERN = /^#[0-9A-F]{6}$/;
177
+ /**
178
+ * Error codes a provider retries against the SAME provider within its
179
+ * `attempts` budget. Only transient failures are worth re-requesting: a
180
+ * timeout may clear on the next attempt and a 5xx / network drop is usually
181
+ * ephemeral. Deterministic failures (auth, quota, rate_limit, invalid_request,
182
+ * region, tos) must fail over to the next provider immediately instead of
183
+ * re-requesting a backend that cannot succeed with the same input.
184
+ */
185
+ const RETRYABLE_CODES = new Set(['timeout', 'server', 'network']);
186
+ /** Resolve as soon as `signal` aborts (or immediately when already aborted). */
187
+ function untilAbort(signal) {
188
+ if (signal.aborted)
189
+ return Promise.resolve();
190
+ return new Promise(resolve => signal.addEventListener('abort', () => resolve(), { once: true }));
191
+ }
192
+ /** Sleep for `ms`, resolving early when `signal` aborts. */
193
+ function abortableSleep(ms, signal) {
194
+ if (signal.aborted)
195
+ return Promise.resolve();
196
+ return new Promise(resolve => {
197
+ const onAbort = () => { clearTimeout(timer); resolve(); };
198
+ const timer = setTimeout(() => { signal.removeEventListener('abort', onAbort); resolve(); }, ms);
199
+ signal.addEventListener('abort', onAbort, { once: true });
200
+ });
201
+ }
173
202
  function integerInRange(value, fallback, minimum, maximum, name) {
174
203
  const resolved = value ?? fallback;
175
204
  if (!Number.isInteger(resolved) || resolved < minimum || resolved > maximum) {
@@ -309,19 +338,29 @@ export function parseRegion(region) {
309
338
  export class VisionToolkitRuntime {
310
339
  ctx;
311
340
  config;
341
+ readableStorageDirs;
312
342
  semaphores = new Map();
313
343
  glanceCache = new WeakMap();
314
344
  providerGates = new Map();
315
345
  adapter;
316
- constructor(ctx, config, adapter) {
346
+ constructor(ctx, config, adapter, readableStorageDirs = []) {
317
347
  this.ctx = ctx;
318
348
  this.config = config;
349
+ this.readableStorageDirs = readableStorageDirs;
319
350
  this.adapter = adapter ?? new UpstreamAdapter(ctx, config);
320
351
  }
321
352
  /** Pinned and prepared upstream identity. */
322
353
  get upstreamVersion() {
323
354
  return this.adapter.versionInfo;
324
355
  }
356
+ /** Per-session cap on concurrent tool operations. */
357
+ get sessionMaxConcurrency() {
358
+ return this.config.sessionMaxConcurrency;
359
+ }
360
+ /** Shared storage root belonging to this immutable runtime generation. */
361
+ get storageDirectory() {
362
+ return this.config.storageDir;
363
+ }
325
364
  /** Stable identity for persisted image descriptions produced by this runtime. */
326
365
  get evidenceFingerprint() {
327
366
  return evidenceRuntimeFingerprint(this.config, undefined, process.env.VISION_SSL_VERIFY?.trim());
@@ -347,17 +386,13 @@ export class VisionToolkitRuntime {
347
386
  glance: (request, options) => this.glanceWithEnv(request, options),
348
387
  });
349
388
  }
350
- timeout(options) {
351
- const value = options.timeoutMs ?? this.operationTimeoutMs();
352
- if (!Number.isInteger(value) || value < 1000 || value > MAX_TIMEOUT_MS) {
353
- throw new VisionToolkitError('input', `timeoutMs must be an integer between 1000 and ${MAX_TIMEOUT_MS}`);
389
+ /** Global hard timeout (ms) for one tool invocation, honoring the per-call override. */
390
+ hardTimeoutMs(options) {
391
+ const seconds = options.timeoutSeconds ?? this.config.hardTimeoutSeconds;
392
+ if (!Number.isInteger(seconds) || seconds < 1 || seconds > MAX_TIMEOUT_SECONDS) {
393
+ throw new VisionToolkitError('input', `timeoutSeconds must be an integer between 1 and ${MAX_TIMEOUT_SECONDS}`);
354
394
  }
355
- return value;
356
- }
357
- /** Overall operation budget: the slowest enabled provider's timeout, else the global default. */
358
- operationTimeoutMs() {
359
- const providers = this.config.providers.filter(provider => provider.enabled);
360
- return providers.length === 0 ? this.config.timeoutMs : Math.max(...providers.map(provider => provider.timeoutMs));
395
+ return seconds * 1000;
361
396
  }
362
397
  operationError(tool, error, deadline, phase = 'execution') {
363
398
  if (deadline.cancelled) {
@@ -370,17 +405,44 @@ export class VisionToolkitRuntime {
370
405
  return error;
371
406
  return new VisionToolkitError('runtime', `${tool}: execution failed`, { cause: error });
372
407
  }
373
- semaphore(options) {
408
+ /** Per-session concurrency gate; callers acquire without queuing (excess is rejected). */
409
+ sessionGate(options) {
374
410
  const key = options.sessionId ?? `workspace:${options.workspace}`;
375
- const value = this.semaphores.get(key) ?? new Semaphore(this.config.concurrency);
411
+ const value = this.semaphores.get(key) ?? new Semaphore(this.config.sessionMaxConcurrency);
376
412
  this.semaphores.set(key, value);
377
413
  return { key, value };
378
414
  }
415
+ /** Live concurrency accounting for the calling session across the enabled provider pool. */
416
+ concurrencyStatus(options) {
417
+ const gate = this.sessionGate(options);
418
+ const sessionFree = gate.value.available;
419
+ const models = this.config.providers
420
+ .filter(provider => provider.enabled)
421
+ .map(provider => {
422
+ const modelGate = this.providerGate(provider);
423
+ return {
424
+ name: provider.name,
425
+ concurrency: provider.concurrency,
426
+ inUse: provider.concurrency - modelGate.available,
427
+ free: modelGate.available,
428
+ };
429
+ });
430
+ const modelFree = models.reduce((sum, model) => sum + model.free, 0);
431
+ return {
432
+ available: Math.min(sessionFree, modelFree),
433
+ sessionMax: this.config.sessionMaxConcurrency,
434
+ sessionInUse: this.config.sessionMaxConcurrency - sessionFree,
435
+ sessionFree,
436
+ modelFree,
437
+ models,
438
+ };
439
+ }
379
440
  async runOperation(tool, options, action, permits = 1) {
380
- const timeoutMs = this.timeout(options);
381
- const semaphore = this.semaphore(options);
441
+ const hardTimeoutMs = this.hardTimeoutMs(options);
442
+ const startedAt = Date.now();
443
+ const deadlineAt = startedAt + hardTimeoutMs;
382
444
  const metrics = {
383
- startedAt: Date.now(),
445
+ startedAt,
384
446
  queueMs: 0,
385
447
  upstreamMs: 0,
386
448
  imageBytes: 0,
@@ -389,51 +451,30 @@ export class VisionToolkitRuntime {
389
451
  cacheHits: 0,
390
452
  usedVisionService: false,
391
453
  };
392
- let acquired = false;
393
- const queueDeadline = createDeadline(options.signal, timeoutMs);
394
- try {
395
- await semaphore.value.acquire(queueDeadline.signal, permits);
396
- acquired = true;
397
- if (queueDeadline.signal.aborted)
398
- throw this.operationError(tool, undefined, queueDeadline, 'queue');
399
- metrics.queueMs = Date.now() - metrics.startedAt;
400
- }
401
- catch (error) {
402
- metrics.queueMs = Date.now() - metrics.startedAt;
403
- const classified = this.operationError(tool, error, queueDeadline, 'queue');
404
- if (acquired) {
405
- semaphore.value.release(permits);
406
- acquired = false;
407
- }
408
- this.ctx.logger.warn('dsh-vision-toolkit tool=%s outcome=error category=%s totalMs=%d queueMs=%d upstreamMs=%d images=%d imageBytes=%d imagePixels=%d cacheHits=%d', tool, classified.code, Date.now() - metrics.startedAt, metrics.queueMs, metrics.upstreamMs, metrics.imageCount, metrics.imageBytes, metrics.imagePixels, metrics.cacheHits);
409
- throw classified;
454
+ const gate = this.sessionGate(options);
455
+ if (!gate.value.tryAcquire(permits)) {
456
+ throw new VisionToolkitError('capacity', `${tool}: exceeded the session concurrency limit`);
410
457
  }
411
- finally {
412
- queueDeadline.cleanup();
413
- if (!acquired && semaphore.value.idle)
414
- this.semaphores.delete(semaphore.key);
415
- }
416
- const executionDeadline = createDeadline(options.signal, timeoutMs);
458
+ const executionDeadline = createDeadline(options.signal, hardTimeoutMs);
417
459
  try {
418
460
  if (executionDeadline.signal.aborted)
419
461
  throw this.operationError(tool, undefined, executionDeadline);
420
- const value = await action({ signal: executionDeadline.signal, metrics });
462
+ const value = await action({ signal: executionDeadline.signal, metrics, deadlineAt });
421
463
  if (executionDeadline.signal.aborted)
422
464
  throw this.operationError(tool, undefined, executionDeadline);
423
- this.ctx.logger.info('dsh-vision-toolkit tool=%s outcome=ok totalMs=%d queueMs=%d upstreamMs=%d images=%d imageBytes=%d imagePixels=%d cacheHits=%d model=%s', tool, Date.now() - metrics.startedAt, metrics.queueMs, metrics.upstreamMs, metrics.imageCount, metrics.imageBytes, metrics.imagePixels, metrics.cacheHits, metrics.usedVisionService ? this.config.provider.model : 'local');
465
+ this.ctx.logger.info('dsh-vision-toolkit tool=%s outcome=ok totalMs=%d upstreamMs=%d images=%d imageBytes=%d imagePixels=%d cacheHits=%d model=%s', tool, Date.now() - metrics.startedAt, metrics.upstreamMs, metrics.imageCount, metrics.imageBytes, metrics.imagePixels, metrics.cacheHits, metrics.usedVisionService ? this.config.provider.model : 'local');
424
466
  return value;
425
467
  }
426
468
  catch (error) {
427
469
  const classified = this.operationError(tool, error, executionDeadline);
428
- this.ctx.logger.warn('dsh-vision-toolkit tool=%s outcome=error category=%s totalMs=%d queueMs=%d upstreamMs=%d images=%d imageBytes=%d imagePixels=%d cacheHits=%d', tool, classified.code, Date.now() - metrics.startedAt, metrics.queueMs, metrics.upstreamMs, metrics.imageCount, metrics.imageBytes, metrics.imagePixels, metrics.cacheHits);
470
+ this.ctx.logger.warn('dsh-vision-toolkit tool=%s outcome=error category=%s totalMs=%d upstreamMs=%d images=%d imageBytes=%d imagePixels=%d cacheHits=%d', tool, classified.code, Date.now() - metrics.startedAt, metrics.upstreamMs, metrics.imageCount, metrics.imageBytes, metrics.imagePixels, metrics.cacheHits);
429
471
  throw classified;
430
472
  }
431
473
  finally {
432
- if (acquired)
433
- semaphore.value.release(permits);
474
+ gate.value.release(permits);
434
475
  executionDeadline.cleanup();
435
- if (semaphore.value.idle)
436
- this.semaphores.delete(semaphore.key);
476
+ if (gate.value.idle)
477
+ this.semaphores.delete(gate.key);
437
478
  }
438
479
  }
439
480
  /** Highest-priority enabled provider, falling back to the first entry. */
@@ -500,12 +541,12 @@ export class VisionToolkitRuntime {
500
541
  return entry.env;
501
542
  }
502
543
  pathPolicy(workspace) {
503
- return createPathPolicy(workspace, this.config.allowedDirs);
544
+ return createPathPolicy(workspace, this.config.allowedDirs, this.config.storageDir, this.readableStorageDirs);
504
545
  }
505
546
  async compressedImageRoot(policy) {
506
- const root = join(policy.workspace, '.dsh-vision-toolkit', 'tmp', 'compressed-images');
507
- let current = policy.workspace;
508
- for (const segment of ['.dsh-vision-toolkit', 'tmp', 'compressed-images']) {
547
+ const root = join(policy.storageRoot, 'tmp', 'compressed-images');
548
+ let current = policy.storageRoot;
549
+ for (const segment of ['tmp', 'compressed-images']) {
509
550
  current = join(current, segment);
510
551
  try {
511
552
  await mkdir(current, { mode: 0o700 });
@@ -518,13 +559,13 @@ export class VisionToolkitRuntime {
518
559
  if (info.isSymbolicLink() || !info.isDirectory()) {
519
560
  throw new VisionToolkitError('path', `compressed-image cache path is not a real directory: ${current}`);
520
561
  }
521
- if (!isWithin(policy.workspace, current)) {
522
- throw new VisionToolkitError('path', `compressed-image cache path escaped the workspace: ${current}`);
562
+ if (!isWithin(policy.storageRoot, current)) {
563
+ throw new VisionToolkitError('path', `compressed-image cache path escaped plugin storage: ${current}`);
523
564
  }
524
565
  }
525
566
  const canonical = await realpath(root);
526
- if (!isWithin(policy.workspace, canonical)) {
527
- throw new VisionToolkitError('path', 'compressed-image cache resolved outside the workspace');
567
+ if (!isWithin(policy.storageRoot, canonical)) {
568
+ throw new VisionToolkitError('path', 'compressed-image cache resolved outside plugin storage');
528
569
  }
529
570
  return canonical;
530
571
  }
@@ -757,56 +798,268 @@ export class VisionToolkitRuntime {
757
798
  return this.autoCompressImage(image, policy, operation, first.maxImageBytes, first.maxImagePixels);
758
799
  }
759
800
  /**
760
- * Run one online-vision upstream command across the enabled provider pool in
761
- * priority order. A provider is skipped when its size limits or concurrency
762
- * are exhausted, retried up to its attempt count, and the next provider
763
- * takes over on failure. Throws once every provider has failed.
801
+ * Hedge-based failover across the enabled provider pool. The highest-priority
802
+ * provider runs first; when one of its requests crosses t1 it keeps running
803
+ * while the next provider starts in parallel. A provider whose cumulative
804
+ * request time reaches t2 is terminated. A 429 provider is parked and moved
805
+ * past immediately; parked providers are revisited at a 10s cadence once
806
+ * every other provider is exhausted. The result always prefers the earliest
807
+ * (highest-priority) provider.
764
808
  */
765
- async runVisionWithFailover(tool, args, images, operation, pool) {
809
+ async runVisionHedge(tool, args, images, operation, pool) {
766
810
  if (pool.length === 0) {
767
811
  throw new VisionToolkitError('config', 'no enabled vision provider has a resolvable credential');
768
812
  }
769
- let lastError;
770
- let attempted = false;
771
- for (const { provider, env } of pool) {
772
- const fits = images.every(image => image.bytes <= provider.maxImageBytes && image.width * image.height <= provider.maxImagePixels);
773
- if (!fits)
774
- continue;
775
- const gate = this.providerGate(provider);
776
- if (!gate.tryAcquire())
777
- continue;
778
- attempted = true;
779
- try {
780
- for (let attempt = 1; attempt <= provider.attempts; attempt++) {
781
- const attemptDeadline = createDeadline(operation.signal, provider.timeoutMs);
782
- try {
783
- return await this.runUpstream(tool, args, { signal: attemptDeadline.signal, metrics: operation.metrics }, env);
813
+ // Providers that cannot accept the image size are skipped entirely.
814
+ const eligible = pool.filter(({ provider }) => images.every(image => image.bytes <= provider.maxImageBytes && image.width * image.height <= provider.maxImagePixels));
815
+ if (eligible.length === 0) {
816
+ throw new VisionToolkitError('capacity', `${tool}: no enabled vision provider accepts the image size`);
817
+ }
818
+ const tasks = eligible.map((entry, index) => {
819
+ let settle;
820
+ const settled = new Promise(resolve => { settle = resolve; });
821
+ return {
822
+ index,
823
+ entry,
824
+ cumulativeMs: 0,
825
+ status: 'idle',
826
+ hedged: false,
827
+ launched: false,
828
+ settled,
829
+ settle,
830
+ abort: new AbortController(),
831
+ };
832
+ });
833
+ const n = tasks.length;
834
+ const launch = (from) => {
835
+ for (let i = from; i < n; i++) {
836
+ const task = tasks[i];
837
+ if (task === undefined || task.launched || task.status !== 'idle')
838
+ continue;
839
+ task.launched = true;
840
+ void this.runProviderTask(tool, args, operation, task, () => launch(i + 1));
841
+ return;
842
+ }
843
+ };
844
+ launch(0);
845
+ const settleAny = (subset) => Promise.race([...subset.map(task => task.settled), untilAbort(operation.signal)]);
846
+ while (true) {
847
+ if (operation.signal.aborted)
848
+ break;
849
+ const running = tasks.filter(task => task.status === 'running');
850
+ const successIndex = tasks.findIndex(task => task.status === 'succeeded');
851
+ if (successIndex >= 0) {
852
+ const blocking = running.filter(task => task.index < successIndex);
853
+ if (blocking.length === 0) {
854
+ for (const task of running)
855
+ task.abort.abort();
856
+ return tasks[successIndex].result;
857
+ }
858
+ await settleAny(blocking);
859
+ }
860
+ else {
861
+ if (running.length === 0)
862
+ break;
863
+ await settleAny(running);
864
+ }
865
+ }
866
+ // No success from the main pass. Revisit parked (429) providers at a 10s cadence.
867
+ if (!operation.signal.aborted) {
868
+ const parked = tasks.filter(task => task.status === 'ratelimited');
869
+ if (parked.length > 0) {
870
+ const revisited = await this.revisitRateLimited(tool, args, operation, parked);
871
+ if (revisited !== undefined)
872
+ return revisited;
873
+ }
874
+ }
875
+ const success = tasks.find(task => task.status === 'succeeded');
876
+ if (success !== undefined)
877
+ return success.result;
878
+ if (operation.signal.aborted) {
879
+ throw new VisionToolkitError('timeout', `${tool}: timed out`);
880
+ }
881
+ const firstError = tasks.find(task => task.status === 'failed' || task.status === 'ratelimited');
882
+ if (firstError?.error !== undefined)
883
+ throw firstError.error;
884
+ throw new VisionToolkitError('service', `${tool}: all vision providers failed`);
885
+ }
886
+ /** Remaining request budget (ms) for one provider: the tighter of its t2 and the global deadline. */
887
+ providerRequestBudget(task, operation) {
888
+ const t2Remaining = task.entry.provider.t2Seconds * 1000 - task.cumulativeMs;
889
+ const globalRemaining = operation.deadlineAt - Date.now();
890
+ return Math.max(0, Math.min(t2Remaining, globalRemaining));
891
+ }
892
+ /**
893
+ * Advance to the next provider after one provider reached a terminal
894
+ * failure. This is what makes failover work for FAST failures too: the
895
+ * hedge timer only launches the next provider when the current one is SLOW
896
+ * (crosses t1), so a quick auth/5xx/network failure must explicitly launch
897
+ * the successor. Never advances when a higher-priority provider superseded
898
+ * this task, when the whole operation was cancelled, or when the global
899
+ * deadline has too little room left for another request. `launch` is
900
+ * idempotent, so an earlier hedge timer cannot cause a double launch.
901
+ */
902
+ advanceAfterFailure(task, operation, launchNext) {
903
+ if (task.abort.signal.aborted || operation.signal.aborted)
904
+ return;
905
+ if (operation.deadlineAt - Date.now() < this.config.minAvailableSeconds * 1000)
906
+ return;
907
+ launchNext();
908
+ }
909
+ /**
910
+ * Run one provider to a terminal state: retryable errors retry within
911
+ * `attempts`, a single request crossing t1 hedges the next provider, and the
912
+ * provider is terminated once its cumulative time reaches t2. A 429 parks the
913
+ * provider and moves on. No request is issued once the remaining budget drops
914
+ * below the configured minimum available time.
915
+ */
916
+ async runProviderTask(tool, args, operation, task, launchNext) {
917
+ const { provider, env } = task.entry;
918
+ const gate = this.providerGate(provider);
919
+ if (!gate.tryAcquire()) {
920
+ task.status = 'failed';
921
+ task.error = new VisionToolkitError('capacity', `${tool}: ${provider.name} has no free concurrency slot`);
922
+ task.settle();
923
+ launchNext();
924
+ return;
925
+ }
926
+ task.status = 'running';
927
+ try {
928
+ let attempt = 0;
929
+ const minAvailableMs = this.config.minAvailableSeconds * 1000;
930
+ while (true) {
931
+ const budget = this.providerRequestBudget(task, operation);
932
+ if (budget < minAvailableMs) {
933
+ task.status = 'failed';
934
+ task.error = new VisionToolkitError('timeout', `${tool}: ${provider.name} has insufficient remaining time`);
935
+ this.advanceAfterFailure(task, operation, launchNext);
936
+ return;
937
+ }
938
+ const reqDeadline = createDeadline(AbortSignal.any([operation.signal, task.abort.signal]), budget);
939
+ const hedgeMs = Math.min(provider.t1Seconds * 1000, budget);
940
+ let hedgeTimer;
941
+ if (!task.hedged) {
942
+ hedgeTimer = setTimeout(() => {
943
+ task.hedged = true;
944
+ launchNext();
945
+ }, hedgeMs);
946
+ }
947
+ const started = Date.now();
948
+ try {
949
+ const result = await this.runUpstream(tool, args, { signal: reqDeadline.signal, metrics: operation.metrics }, env);
950
+ if (hedgeTimer !== undefined)
951
+ clearTimeout(hedgeTimer);
952
+ task.cumulativeMs += Date.now() - started;
953
+ task.status = 'succeeded';
954
+ task.result = result;
955
+ return;
956
+ }
957
+ catch (error) {
958
+ if (hedgeTimer !== undefined)
959
+ clearTimeout(hedgeTimer);
960
+ task.cumulativeMs += Date.now() - started;
961
+ if (task.abort.signal.aborted) {
962
+ task.status = 'failed';
963
+ task.error = new VisionToolkitError('cancelled', `${tool}: superseded by a higher-priority provider`);
964
+ return;
965
+ }
966
+ if (operation.signal.aborted) {
967
+ task.status = 'failed';
968
+ task.error = new VisionToolkitError('timeout', `${tool}: timed out`);
969
+ return;
784
970
  }
785
- catch (error) {
786
- if (operation.signal.aborted)
787
- throw error;
788
- const classified = error instanceof VisionToolkitError
789
- ? error
790
- : new VisionToolkitError('service', `${tool}: request failed`, { cause: error });
791
- lastError = attemptDeadline.timedOut && classified.code === 'cancelled'
792
- ? new VisionToolkitError('timeout', `${tool}: ${provider.name} request timed out after ${provider.timeoutMs}ms`, { cause: classified })
793
- : classified;
971
+ const classified = error instanceof VisionToolkitError
972
+ ? error
973
+ : new VisionToolkitError('service', `${tool}: request failed`, { cause: error });
974
+ if (reqDeadline.timedOut) {
975
+ task.status = 'failed';
976
+ task.error = new VisionToolkitError('timeout', `${tool}: ${provider.name} exhausted its t2 budget`);
977
+ this.advanceAfterFailure(task, operation, launchNext);
978
+ return;
794
979
  }
795
- finally {
796
- attemptDeadline.cleanup();
980
+ if (classified.code === 'rate_limit') {
981
+ task.status = 'ratelimited';
982
+ task.error = classified;
983
+ launchNext();
984
+ return;
797
985
  }
986
+ if (RETRYABLE_CODES.has(classified.code) && attempt + 1 < provider.attempts) {
987
+ attempt += 1;
988
+ continue;
989
+ }
990
+ task.status = 'failed';
991
+ task.error = classified;
992
+ this.advanceAfterFailure(task, operation, launchNext);
993
+ return;
994
+ }
995
+ finally {
996
+ reqDeadline.cleanup();
798
997
  }
799
- }
800
- finally {
801
- gate.release();
802
998
  }
803
999
  }
804
- if (!attempted) {
805
- throw new VisionToolkitError('capacity', `${tool}: no enabled vision provider accepts the image size or has a free concurrency slot`);
1000
+ finally {
1001
+ gate.release();
1002
+ task.settle();
806
1003
  }
807
- if (lastError instanceof VisionToolkitError)
808
- throw lastError;
809
- throw new VisionToolkitError('service', `${tool}: all vision providers failed`, { cause: lastError });
1004
+ }
1005
+ /**
1006
+ * Revisit parked (429) providers in priority order at a 10s cadence until one
1007
+ * succeeds or every provider exhausts its budget. Returns a success result or
1008
+ * `undefined` when the global deadline or minimum available time stops the loop.
1009
+ */
1010
+ async revisitRateLimited(tool, args, operation, parked) {
1011
+ const minAvailableMs = this.config.minAvailableSeconds * 1000;
1012
+ while (!operation.signal.aborted) {
1013
+ let anyRevisitable = false;
1014
+ for (const task of parked) {
1015
+ if (operation.signal.aborted)
1016
+ break;
1017
+ if (task.status !== 'ratelimited')
1018
+ continue;
1019
+ const budget = this.providerRequestBudget(task, operation);
1020
+ if (budget < minAvailableMs)
1021
+ continue;
1022
+ anyRevisitable = true;
1023
+ await abortableSleep(Math.min(10_000, budget), operation.signal);
1024
+ if (operation.signal.aborted)
1025
+ break;
1026
+ const { provider, env } = task.entry;
1027
+ const gate = this.providerGate(provider);
1028
+ if (!gate.tryAcquire())
1029
+ continue;
1030
+ const reqDeadline = createDeadline(operation.signal, Math.min(provider.t2Seconds * 1000 - task.cumulativeMs, operation.deadlineAt - Date.now()));
1031
+ const started = Date.now();
1032
+ try {
1033
+ const result = await this.runUpstream(tool, args, { signal: reqDeadline.signal, metrics: operation.metrics }, env);
1034
+ task.cumulativeMs += Date.now() - started;
1035
+ task.status = 'succeeded';
1036
+ task.result = result;
1037
+ return result;
1038
+ }
1039
+ catch (error) {
1040
+ task.cumulativeMs += Date.now() - started;
1041
+ if (reqDeadline.timedOut) {
1042
+ task.status = 'failed';
1043
+ task.error = new VisionToolkitError('timeout', `${tool}: ${provider.name} exhausted its t2 budget`);
1044
+ continue;
1045
+ }
1046
+ const classified = error instanceof VisionToolkitError
1047
+ ? error
1048
+ : new VisionToolkitError('service', `${tool}: request failed`, { cause: error });
1049
+ if (classified.code === 'rate_limit')
1050
+ continue;
1051
+ task.status = 'failed';
1052
+ task.error = classified;
1053
+ }
1054
+ finally {
1055
+ reqDeadline.cleanup();
1056
+ gate.release();
1057
+ }
1058
+ }
1059
+ if (!anyRevisitable)
1060
+ break;
1061
+ }
1062
+ return undefined;
810
1063
  }
811
1064
  async glanceCacheKey(request, images, pool, signal) {
812
1065
  const imageFingerprints = await Promise.all(images.map(async (image) => {
@@ -950,7 +1203,7 @@ export class VisionToolkitRuntime {
950
1203
  return cached.result;
951
1204
  }
952
1205
  }
953
- const result = await this.runVisionWithFailover('glance', [
1206
+ const result = await this.runVisionHedge('glance', [
954
1207
  ...images.map(image => image.path),
955
1208
  ...(request.region !== undefined ? ['--region', request.region] : []),
956
1209
  ...(request.ocr === true ? ['--ocr'] : []),
@@ -997,7 +1250,7 @@ export class VisionToolkitRuntime {
997
1250
  }
998
1251
  const image = await this.prepareVisionImage(request.image, pool.map(entry => entry.provider), policy, operation);
999
1252
  this.accountImage(image, operation);
1000
- const result = await this.runVisionWithFailover(tool, [
1253
+ const result = await this.runVisionHedge(tool, [
1001
1254
  image.path,
1002
1255
  request.target,
1003
1256
  ...(request.region !== undefined ? ['--region', request.region] : []),
@@ -1306,9 +1559,6 @@ export class VisionToolkitRuntime {
1306
1559
  const overlap = request.overlap === undefined
1307
1560
  ? undefined
1308
1561
  : integerInRange(request.overlap, request.overlap, 0, 10000, 'long_screenshot_ocr.overlap');
1309
- const chunkTimeoutSeconds = finiteInRange(request.chunkTimeoutSeconds ?? Math.min(180, Math.max(1, Math.ceil(this.timeout(options) / 1000))), 1, 600, 'long_screenshot_ocr.chunkTimeoutSeconds');
1310
- if (chunkTimeoutSeconds === undefined)
1311
- throw new VisionToolkitError('input', 'long_screenshot_ocr chunk timeout is required');
1312
1562
  if (request.prompt !== undefined && request.prompt.trim().length === 0) {
1313
1563
  throw new VisionToolkitError('input', 'long_screenshot_ocr.prompt must not be empty when provided');
1314
1564
  }
@@ -1351,13 +1601,13 @@ export class VisionToolkitRuntime {
1351
1601
  '--jobs',
1352
1602
  String(jobs),
1353
1603
  '--timeout',
1354
- String(chunkTimeoutSeconds),
1604
+ String(this.config.hardTimeoutSeconds),
1355
1605
  ...(splitOnly ? ['--split-only'] : []),
1356
1606
  ...(request.resume === true ? ['--resume'] : []),
1357
1607
  ];
1358
1608
  const result = splitOnly
1359
1609
  ? await this.runUpstream('long_screenshot_ocr', ocrArgs, operation)
1360
- : await this.runVisionWithFailover('long_screenshot_ocr', ocrArgs, [image], operation, pool);
1610
+ : await this.runVisionHedge('long_screenshot_ocr', ocrArgs, [image], operation, pool);
1361
1611
  const reported = result.stdout.trim();
1362
1612
  const expectedReported = splitOnly ? stagedManifest : stagedOutput;
1363
1613
  if (reported !== expectedReported) {
@@ -1689,6 +1939,18 @@ export class VisionToolkitRuntime {
1689
1939
  }
1690
1940
  });
1691
1941
  }
1942
+ async writableDirectoryCheck(path, label) {
1943
+ const probe = join(path, `.vision-toolkit-health-${randomUUID()}`);
1944
+ try {
1945
+ await writeFile(probe, 'ok\n', { encoding: 'utf8', flag: 'wx' });
1946
+ await rm(probe, { force: true });
1947
+ return { status: 'ok', detail: `${label} is writable: ${path}` };
1948
+ }
1949
+ catch {
1950
+ await rm(probe, { force: true }).catch(() => { });
1951
+ return { status: 'error', detail: `${label} is not writable: ${path}` };
1952
+ }
1953
+ }
1692
1954
  /** Health: inspect local readiness, and optionally probe one provider's `/models` plus one real multimodal request. */
1693
1955
  async health(testConnection, options, testModel = false, provider) {
1694
1956
  return this.runOperation('vision_toolkit_health', options, async (operation) => {
@@ -1712,6 +1974,29 @@ export class VisionToolkitRuntime {
1712
1974
  throw new VisionToolkitError('cancelled', 'vision_toolkit_health: cancelled');
1713
1975
  chrome = { status: 'error', detail: 'Chrome availability probe failed' };
1714
1976
  }
1977
+ let resolvedCredential;
1978
+ let credential;
1979
+ try {
1980
+ resolvedCredential = isBuiltInFreeVisionProvider(this.config.provider)
1981
+ ? { value: BUILT_IN_FREE_VISION_KEY, source: 'built-in' }
1982
+ : await this.ctx.credentials.resolve(this.config.provider.credential);
1983
+ credential = resolvedCredential === undefined
1984
+ ? { status: 'error', detail: `credential ${this.config.provider.credential} is not configured` }
1985
+ : { status: 'ok', detail: `credential ${this.config.provider.credential} is resolvable` };
1986
+ }
1987
+ catch {
1988
+ credential = { status: 'error', detail: `credential ${this.config.provider.credential} could not be resolved` };
1989
+ }
1990
+ let artifactDirectory;
1991
+ try {
1992
+ // allowedDirs are session input roots; they do not affect output readiness.
1993
+ const policy = await createPathPolicy(options.workspace, [], this.config.storageDir);
1994
+ artifactDirectory = await this.writableDirectoryCheck(policy.outputDir, 'Artifact directory');
1995
+ }
1996
+ catch {
1997
+ artifactDirectory = { status: 'error', detail: 'Artifact directory could not be prepared' };
1998
+ }
1999
+ const tempDirectory = await this.writableDirectoryCheck(info.runtimeHome, 'Runtime temp directory');
1715
2000
  let service = {
1716
2001
  status: 'not_tested',
1717
2002
  detail: 'Connection was not tested; use the per-provider API test',
@@ -1785,7 +2070,7 @@ export class VisionToolkitRuntime {
1785
2070
  }
1786
2071
  if (testModel) {
1787
2072
  try {
1788
- const attemptDeadline = createDeadline(operation.signal, target.timeoutMs);
2073
+ const attemptDeadline = createDeadline(operation.signal, target.t2Seconds * 1000);
1789
2074
  try {
1790
2075
  const result = await this.runUpstream('glance', [VISION_MODEL_TEST_IMAGE, '-q', VISION_MODEL_TEST_PROMPT], { signal: attemptDeadline.signal, metrics: operation.metrics }, entry.env);
1791
2076
  if (result.stdout.trim().length === 0) {
@@ -1809,7 +2094,7 @@ export class VisionToolkitRuntime {
1809
2094
  }
1810
2095
  }
1811
2096
  }
1812
- const checks = { python, dependencies, chrome, service, model };
2097
+ const checks = { python, dependencies, chrome, credential, artifactDirectory, tempDirectory, service, model };
1813
2098
  const healthy = Object.values(checks).every(check => check.status !== 'error');
1814
2099
  return {
1815
2100
  pluginVersion: PLUGIN_VERSION,