@mengruo/dsh-vision-toolkit 0.1.2 → 0.1.3

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.
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,24 @@ const FORMAT_BY_EXTENSION = new Map([
170
174
  ['.webp', 'webp'],
171
175
  ]);
172
176
  const HEX_COLOR_PATTERN = /^#[0-9A-F]{6}$/;
177
+ /** Error codes a provider retries within its attempt budget (429 is handled separately). */
178
+ const RETRYABLE_CODES = new Set(['service', 'timeout']);
179
+ /** Resolve as soon as `signal` aborts (or immediately when already aborted). */
180
+ function untilAbort(signal) {
181
+ if (signal.aborted)
182
+ return Promise.resolve();
183
+ return new Promise(resolve => signal.addEventListener('abort', () => resolve(), { once: true }));
184
+ }
185
+ /** Sleep for `ms`, resolving early when `signal` aborts. */
186
+ function abortableSleep(ms, signal) {
187
+ if (signal.aborted)
188
+ return Promise.resolve();
189
+ return new Promise(resolve => {
190
+ const onAbort = () => { clearTimeout(timer); resolve(); };
191
+ const timer = setTimeout(() => { signal.removeEventListener('abort', onAbort); resolve(); }, ms);
192
+ signal.addEventListener('abort', onAbort, { once: true });
193
+ });
194
+ }
173
195
  function integerInRange(value, fallback, minimum, maximum, name) {
174
196
  const resolved = value ?? fallback;
175
197
  if (!Number.isInteger(resolved) || resolved < minimum || resolved > maximum) {
@@ -322,6 +344,10 @@ export class VisionToolkitRuntime {
322
344
  get upstreamVersion() {
323
345
  return this.adapter.versionInfo;
324
346
  }
347
+ /** Per-session cap on concurrent tool operations. */
348
+ get sessionMaxConcurrency() {
349
+ return this.config.sessionMaxConcurrency;
350
+ }
325
351
  /** Stable identity for persisted image descriptions produced by this runtime. */
326
352
  get evidenceFingerprint() {
327
353
  return evidenceRuntimeFingerprint(this.config, undefined, process.env.VISION_SSL_VERIFY?.trim());
@@ -347,17 +373,13 @@ export class VisionToolkitRuntime {
347
373
  glance: (request, options) => this.glanceWithEnv(request, options),
348
374
  });
349
375
  }
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}`);
376
+ /** Global hard timeout (ms) for one tool invocation, honoring the per-call override. */
377
+ hardTimeoutMs(options) {
378
+ const seconds = options.timeoutSeconds ?? this.config.hardTimeoutSeconds;
379
+ if (!Number.isInteger(seconds) || seconds < 1 || seconds > MAX_TIMEOUT_SECONDS) {
380
+ throw new VisionToolkitError('input', `timeoutSeconds must be an integer between 1 and ${MAX_TIMEOUT_SECONDS}`);
354
381
  }
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));
382
+ return seconds * 1000;
361
383
  }
362
384
  operationError(tool, error, deadline, phase = 'execution') {
363
385
  if (deadline.cancelled) {
@@ -370,17 +392,44 @@ export class VisionToolkitRuntime {
370
392
  return error;
371
393
  return new VisionToolkitError('runtime', `${tool}: execution failed`, { cause: error });
372
394
  }
373
- semaphore(options) {
395
+ /** Per-session concurrency gate; callers acquire without queuing (excess is rejected). */
396
+ sessionGate(options) {
374
397
  const key = options.sessionId ?? `workspace:${options.workspace}`;
375
- const value = this.semaphores.get(key) ?? new Semaphore(this.config.concurrency);
398
+ const value = this.semaphores.get(key) ?? new Semaphore(this.config.sessionMaxConcurrency);
376
399
  this.semaphores.set(key, value);
377
400
  return { key, value };
378
401
  }
402
+ /** Live concurrency accounting for the calling session across the enabled provider pool. */
403
+ concurrencyStatus(options) {
404
+ const gate = this.sessionGate(options);
405
+ const sessionFree = gate.value.available;
406
+ const models = this.config.providers
407
+ .filter(provider => provider.enabled)
408
+ .map(provider => {
409
+ const modelGate = this.providerGate(provider);
410
+ return {
411
+ name: provider.name,
412
+ concurrency: provider.concurrency,
413
+ inUse: provider.concurrency - modelGate.available,
414
+ free: modelGate.available,
415
+ };
416
+ });
417
+ const modelFree = models.reduce((sum, model) => sum + model.free, 0);
418
+ return {
419
+ available: Math.min(sessionFree, modelFree),
420
+ sessionMax: this.config.sessionMaxConcurrency,
421
+ sessionInUse: this.config.sessionMaxConcurrency - sessionFree,
422
+ sessionFree,
423
+ modelFree,
424
+ models,
425
+ };
426
+ }
379
427
  async runOperation(tool, options, action, permits = 1) {
380
- const timeoutMs = this.timeout(options);
381
- const semaphore = this.semaphore(options);
428
+ const hardTimeoutMs = this.hardTimeoutMs(options);
429
+ const startedAt = Date.now();
430
+ const deadlineAt = startedAt + hardTimeoutMs;
382
431
  const metrics = {
383
- startedAt: Date.now(),
432
+ startedAt,
384
433
  queueMs: 0,
385
434
  upstreamMs: 0,
386
435
  imageBytes: 0,
@@ -389,51 +438,30 @@ export class VisionToolkitRuntime {
389
438
  cacheHits: 0,
390
439
  usedVisionService: false,
391
440
  };
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;
441
+ const gate = this.sessionGate(options);
442
+ if (!gate.value.tryAcquire(permits)) {
443
+ throw new VisionToolkitError('capacity', `${tool}: exceeded the session concurrency limit`);
400
444
  }
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;
410
- }
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);
445
+ const executionDeadline = createDeadline(options.signal, hardTimeoutMs);
417
446
  try {
418
447
  if (executionDeadline.signal.aborted)
419
448
  throw this.operationError(tool, undefined, executionDeadline);
420
- const value = await action({ signal: executionDeadline.signal, metrics });
449
+ const value = await action({ signal: executionDeadline.signal, metrics, deadlineAt });
421
450
  if (executionDeadline.signal.aborted)
422
451
  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');
452
+ 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
453
  return value;
425
454
  }
426
455
  catch (error) {
427
456
  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);
457
+ 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
458
  throw classified;
430
459
  }
431
460
  finally {
432
- if (acquired)
433
- semaphore.value.release(permits);
461
+ gate.value.release(permits);
434
462
  executionDeadline.cleanup();
435
- if (semaphore.value.idle)
436
- this.semaphores.delete(semaphore.key);
463
+ if (gate.value.idle)
464
+ this.semaphores.delete(gate.key);
437
465
  }
438
466
  }
439
467
  /** Highest-priority enabled provider, falling back to the first entry. */
@@ -757,56 +785,248 @@ export class VisionToolkitRuntime {
757
785
  return this.autoCompressImage(image, policy, operation, first.maxImageBytes, first.maxImagePixels);
758
786
  }
759
787
  /**
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.
788
+ * Hedge-based failover across the enabled provider pool. The highest-priority
789
+ * provider runs first; when one of its requests crosses t1 it keeps running
790
+ * while the next provider starts in parallel. A provider whose cumulative
791
+ * request time reaches t2 is terminated. A 429 provider is parked and moved
792
+ * past immediately; parked providers are revisited at a 10s cadence once
793
+ * every other provider is exhausted. The result always prefers the earliest
794
+ * (highest-priority) provider.
764
795
  */
765
- async runVisionWithFailover(tool, args, images, operation, pool) {
796
+ async runVisionHedge(tool, args, images, operation, pool) {
766
797
  if (pool.length === 0) {
767
798
  throw new VisionToolkitError('config', 'no enabled vision provider has a resolvable credential');
768
799
  }
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);
800
+ // Providers that cannot accept the image size are skipped entirely.
801
+ const eligible = pool.filter(({ provider }) => images.every(image => image.bytes <= provider.maxImageBytes && image.width * image.height <= provider.maxImagePixels));
802
+ if (eligible.length === 0) {
803
+ throw new VisionToolkitError('capacity', `${tool}: no enabled vision provider accepts the image size`);
804
+ }
805
+ const tasks = eligible.map((entry, index) => {
806
+ let settle;
807
+ const settled = new Promise(resolve => { settle = resolve; });
808
+ return {
809
+ index,
810
+ entry,
811
+ cumulativeMs: 0,
812
+ status: 'idle',
813
+ hedged: false,
814
+ launched: false,
815
+ settled,
816
+ settle,
817
+ abort: new AbortController(),
818
+ };
819
+ });
820
+ const n = tasks.length;
821
+ const launch = (from) => {
822
+ for (let i = from; i < n; i++) {
823
+ const task = tasks[i];
824
+ if (task === undefined || task.launched || task.status !== 'idle')
825
+ continue;
826
+ task.launched = true;
827
+ void this.runProviderTask(tool, args, operation, task, () => launch(i + 1));
828
+ return;
829
+ }
830
+ };
831
+ launch(0);
832
+ const settleAny = (subset) => Promise.race([...subset.map(task => task.settled), untilAbort(operation.signal)]);
833
+ while (true) {
834
+ if (operation.signal.aborted)
835
+ break;
836
+ const running = tasks.filter(task => task.status === 'running');
837
+ const successIndex = tasks.findIndex(task => task.status === 'succeeded');
838
+ if (successIndex >= 0) {
839
+ const blocking = running.filter(task => task.index < successIndex);
840
+ if (blocking.length === 0) {
841
+ for (const task of running)
842
+ task.abort.abort();
843
+ return tasks[successIndex].result;
844
+ }
845
+ await settleAny(blocking);
846
+ }
847
+ else {
848
+ if (running.length === 0)
849
+ break;
850
+ await settleAny(running);
851
+ }
852
+ }
853
+ // No success from the main pass. Revisit parked (429) providers at a 10s cadence.
854
+ if (!operation.signal.aborted) {
855
+ const parked = tasks.filter(task => task.status === 'ratelimited');
856
+ if (parked.length > 0) {
857
+ const revisited = await this.revisitRateLimited(tool, args, operation, parked);
858
+ if (revisited !== undefined)
859
+ return revisited;
860
+ }
861
+ }
862
+ const success = tasks.find(task => task.status === 'succeeded');
863
+ if (success !== undefined)
864
+ return success.result;
865
+ if (operation.signal.aborted) {
866
+ throw new VisionToolkitError('timeout', `${tool}: timed out`);
867
+ }
868
+ const firstError = tasks.find(task => task.status === 'failed' || task.status === 'ratelimited');
869
+ if (firstError?.error !== undefined)
870
+ throw firstError.error;
871
+ throw new VisionToolkitError('service', `${tool}: all vision providers failed`);
872
+ }
873
+ /** Remaining request budget (ms) for one provider: the tighter of its t2 and the global deadline. */
874
+ providerRequestBudget(task, operation) {
875
+ const t2Remaining = task.entry.provider.t2Seconds * 1000 - task.cumulativeMs;
876
+ const globalRemaining = operation.deadlineAt - Date.now();
877
+ return Math.max(0, Math.min(t2Remaining, globalRemaining));
878
+ }
879
+ /**
880
+ * Run one provider to a terminal state: retryable errors retry within
881
+ * `attempts`, a single request crossing t1 hedges the next provider, and the
882
+ * provider is terminated once its cumulative time reaches t2. A 429 parks the
883
+ * provider and moves on. No request is issued once the remaining budget drops
884
+ * below the configured minimum available time.
885
+ */
886
+ async runProviderTask(tool, args, operation, task, launchNext) {
887
+ const { provider, env } = task.entry;
888
+ const gate = this.providerGate(provider);
889
+ if (!gate.tryAcquire()) {
890
+ task.status = 'failed';
891
+ task.error = new VisionToolkitError('capacity', `${tool}: ${provider.name} has no free concurrency slot`);
892
+ task.settle();
893
+ launchNext();
894
+ return;
895
+ }
896
+ task.status = 'running';
897
+ try {
898
+ let attempt = 0;
899
+ const minAvailableMs = this.config.minAvailableSeconds * 1000;
900
+ while (true) {
901
+ const budget = this.providerRequestBudget(task, operation);
902
+ if (budget < minAvailableMs) {
903
+ task.status = 'failed';
904
+ task.error = new VisionToolkitError('timeout', `${tool}: ${provider.name} has insufficient remaining time`);
905
+ return;
906
+ }
907
+ const reqDeadline = createDeadline(AbortSignal.any([operation.signal, task.abort.signal]), budget);
908
+ const hedgeMs = Math.min(provider.t1Seconds * 1000, budget);
909
+ let hedgeTimer;
910
+ if (!task.hedged) {
911
+ hedgeTimer = setTimeout(() => {
912
+ task.hedged = true;
913
+ launchNext();
914
+ }, hedgeMs);
915
+ }
916
+ const started = Date.now();
917
+ try {
918
+ const result = await this.runUpstream(tool, args, { signal: reqDeadline.signal, metrics: operation.metrics }, env);
919
+ if (hedgeTimer !== undefined)
920
+ clearTimeout(hedgeTimer);
921
+ task.cumulativeMs += Date.now() - started;
922
+ task.status = 'succeeded';
923
+ task.result = result;
924
+ return;
925
+ }
926
+ catch (error) {
927
+ if (hedgeTimer !== undefined)
928
+ clearTimeout(hedgeTimer);
929
+ task.cumulativeMs += Date.now() - started;
930
+ if (task.abort.signal.aborted) {
931
+ task.status = 'failed';
932
+ task.error = new VisionToolkitError('cancelled', `${tool}: superseded by a higher-priority provider`);
933
+ return;
934
+ }
935
+ if (operation.signal.aborted) {
936
+ task.status = 'failed';
937
+ task.error = new VisionToolkitError('timeout', `${tool}: timed out`);
938
+ return;
784
939
  }
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;
940
+ const classified = error instanceof VisionToolkitError
941
+ ? error
942
+ : new VisionToolkitError('service', `${tool}: request failed`, { cause: error });
943
+ if (reqDeadline.timedOut) {
944
+ task.status = 'failed';
945
+ task.error = new VisionToolkitError('timeout', `${tool}: ${provider.name} exhausted its t2 budget`);
946
+ return;
794
947
  }
795
- finally {
796
- attemptDeadline.cleanup();
948
+ if (classified.code === 'rate_limit') {
949
+ task.status = 'ratelimited';
950
+ task.error = classified;
951
+ launchNext();
952
+ return;
797
953
  }
954
+ if (RETRYABLE_CODES.has(classified.code) && attempt + 1 < provider.attempts) {
955
+ attempt += 1;
956
+ continue;
957
+ }
958
+ task.status = 'failed';
959
+ task.error = classified;
960
+ return;
961
+ }
962
+ finally {
963
+ reqDeadline.cleanup();
798
964
  }
799
- }
800
- finally {
801
- gate.release();
802
965
  }
803
966
  }
804
- if (!attempted) {
805
- throw new VisionToolkitError('capacity', `${tool}: no enabled vision provider accepts the image size or has a free concurrency slot`);
967
+ finally {
968
+ gate.release();
969
+ task.settle();
970
+ }
971
+ }
972
+ /**
973
+ * Revisit parked (429) providers in priority order at a 10s cadence until one
974
+ * succeeds or every provider exhausts its budget. Returns a success result or
975
+ * `undefined` when the global deadline or minimum available time stops the loop.
976
+ */
977
+ async revisitRateLimited(tool, args, operation, parked) {
978
+ const minAvailableMs = this.config.minAvailableSeconds * 1000;
979
+ while (!operation.signal.aborted) {
980
+ let anyRevisitable = false;
981
+ for (const task of parked) {
982
+ if (operation.signal.aborted)
983
+ break;
984
+ if (task.status !== 'ratelimited')
985
+ continue;
986
+ const budget = this.providerRequestBudget(task, operation);
987
+ if (budget < minAvailableMs)
988
+ continue;
989
+ anyRevisitable = true;
990
+ await abortableSleep(Math.min(10_000, budget), operation.signal);
991
+ if (operation.signal.aborted)
992
+ break;
993
+ const { provider, env } = task.entry;
994
+ const gate = this.providerGate(provider);
995
+ if (!gate.tryAcquire())
996
+ continue;
997
+ const reqDeadline = createDeadline(operation.signal, Math.min(provider.t2Seconds * 1000 - task.cumulativeMs, operation.deadlineAt - Date.now()));
998
+ const started = Date.now();
999
+ try {
1000
+ const result = await this.runUpstream(tool, args, { signal: reqDeadline.signal, metrics: operation.metrics }, env);
1001
+ task.cumulativeMs += Date.now() - started;
1002
+ task.status = 'succeeded';
1003
+ task.result = result;
1004
+ return result;
1005
+ }
1006
+ catch (error) {
1007
+ task.cumulativeMs += Date.now() - started;
1008
+ if (reqDeadline.timedOut) {
1009
+ task.status = 'failed';
1010
+ task.error = new VisionToolkitError('timeout', `${tool}: ${provider.name} exhausted its t2 budget`);
1011
+ continue;
1012
+ }
1013
+ const classified = error instanceof VisionToolkitError
1014
+ ? error
1015
+ : new VisionToolkitError('service', `${tool}: request failed`, { cause: error });
1016
+ if (classified.code === 'rate_limit')
1017
+ continue;
1018
+ task.status = 'failed';
1019
+ task.error = classified;
1020
+ }
1021
+ finally {
1022
+ reqDeadline.cleanup();
1023
+ gate.release();
1024
+ }
1025
+ }
1026
+ if (!anyRevisitable)
1027
+ break;
806
1028
  }
807
- if (lastError instanceof VisionToolkitError)
808
- throw lastError;
809
- throw new VisionToolkitError('service', `${tool}: all vision providers failed`, { cause: lastError });
1029
+ return undefined;
810
1030
  }
811
1031
  async glanceCacheKey(request, images, pool, signal) {
812
1032
  const imageFingerprints = await Promise.all(images.map(async (image) => {
@@ -950,7 +1170,7 @@ export class VisionToolkitRuntime {
950
1170
  return cached.result;
951
1171
  }
952
1172
  }
953
- const result = await this.runVisionWithFailover('glance', [
1173
+ const result = await this.runVisionHedge('glance', [
954
1174
  ...images.map(image => image.path),
955
1175
  ...(request.region !== undefined ? ['--region', request.region] : []),
956
1176
  ...(request.ocr === true ? ['--ocr'] : []),
@@ -997,7 +1217,7 @@ export class VisionToolkitRuntime {
997
1217
  }
998
1218
  const image = await this.prepareVisionImage(request.image, pool.map(entry => entry.provider), policy, operation);
999
1219
  this.accountImage(image, operation);
1000
- const result = await this.runVisionWithFailover(tool, [
1220
+ const result = await this.runVisionHedge(tool, [
1001
1221
  image.path,
1002
1222
  request.target,
1003
1223
  ...(request.region !== undefined ? ['--region', request.region] : []),
@@ -1306,9 +1526,6 @@ export class VisionToolkitRuntime {
1306
1526
  const overlap = request.overlap === undefined
1307
1527
  ? undefined
1308
1528
  : 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
1529
  if (request.prompt !== undefined && request.prompt.trim().length === 0) {
1313
1530
  throw new VisionToolkitError('input', 'long_screenshot_ocr.prompt must not be empty when provided');
1314
1531
  }
@@ -1351,13 +1568,13 @@ export class VisionToolkitRuntime {
1351
1568
  '--jobs',
1352
1569
  String(jobs),
1353
1570
  '--timeout',
1354
- String(chunkTimeoutSeconds),
1571
+ String(this.config.hardTimeoutSeconds),
1355
1572
  ...(splitOnly ? ['--split-only'] : []),
1356
1573
  ...(request.resume === true ? ['--resume'] : []),
1357
1574
  ];
1358
1575
  const result = splitOnly
1359
1576
  ? await this.runUpstream('long_screenshot_ocr', ocrArgs, operation)
1360
- : await this.runVisionWithFailover('long_screenshot_ocr', ocrArgs, [image], operation, pool);
1577
+ : await this.runVisionHedge('long_screenshot_ocr', ocrArgs, [image], operation, pool);
1361
1578
  const reported = result.stdout.trim();
1362
1579
  const expectedReported = splitOnly ? stagedManifest : stagedOutput;
1363
1580
  if (reported !== expectedReported) {
@@ -1785,7 +2002,7 @@ export class VisionToolkitRuntime {
1785
2002
  }
1786
2003
  if (testModel) {
1787
2004
  try {
1788
- const attemptDeadline = createDeadline(operation.signal, target.timeoutMs);
2005
+ const attemptDeadline = createDeadline(operation.signal, target.t2Seconds * 1000);
1789
2006
  try {
1790
2007
  const result = await this.runUpstream('glance', [VISION_MODEL_TEST_IMAGE, '-q', VISION_MODEL_TEST_PROMPT], { signal: attemptDeadline.signal, metrics: operation.metrics }, entry.env);
1791
2008
  if (result.stdout.trim().length === 0) {