@mengruo/dsh-vision-toolkit 0.1.1 → 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/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
  }
@@ -484,7 +488,8 @@ export interface VisionToolkitHealthResult {
484
488
  /** Shared per-call execution options. */
485
489
  export interface ToolCallOptions {
486
490
  signal: AbortSignal
487
- timeoutMs?: number
491
+ /** Override the global hard timeout (seconds) for this call. */
492
+ timeoutSeconds?: number
488
493
  workspace: string
489
494
  /** Session identity for the per-session concurrency cap. */
490
495
  sessionId?: string
@@ -517,10 +522,12 @@ interface OperationMetrics {
517
522
  interface OperationContext {
518
523
  signal: AbortSignal
519
524
  metrics: OperationMetrics
525
+ /** Absolute epoch-millisecond timestamp when the global hard timeout fires. */
526
+ deadlineAt: number
520
527
  }
521
528
 
522
529
  const REGION_PATTERN = /^\s*(-?\d+)\s*,\s*(-?\d+)\s*,\s*(-?\d+)\s*,\s*(-?\d+)\s*$/
523
- const MAX_TIMEOUT_MS = 600_000
530
+ const MAX_TIMEOUT_SECONDS = 600
524
531
  const FORMAT_BY_EXTENSION = new Map([
525
532
  ['.png', 'png'],
526
533
  ['.jpg', 'jpeg'],
@@ -530,6 +537,25 @@ const FORMAT_BY_EXTENSION = new Map([
530
537
  ])
531
538
  const HEX_COLOR_PATTERN = /^#[0-9A-F]{6}$/
532
539
 
540
+ /** Error codes a provider retries within its attempt budget (429 is handled separately). */
541
+ const RETRYABLE_CODES: ReadonlySet<VisionToolkitErrorCode> = new Set(['service', 'timeout'])
542
+
543
+ /** Resolve as soon as `signal` aborts (or immediately when already aborted). */
544
+ function untilAbort(signal: AbortSignal): Promise<void> {
545
+ if (signal.aborted) return Promise.resolve()
546
+ return new Promise(resolve => signal.addEventListener('abort', () => resolve(), { once: true }))
547
+ }
548
+
549
+ /** Sleep for `ms`, resolving early when `signal` aborts. */
550
+ function abortableSleep(ms: number, signal: AbortSignal): Promise<void> {
551
+ if (signal.aborted) return Promise.resolve()
552
+ return new Promise(resolve => {
553
+ const onAbort = (): void => { clearTimeout(timer); resolve() }
554
+ const timer = setTimeout(() => { signal.removeEventListener('abort', onAbort); resolve() }, ms)
555
+ signal.addEventListener('abort', onAbort, { once: true })
556
+ })
557
+ }
558
+
533
559
  function integerInRange(value: number | undefined, fallback: number, minimum: number, maximum: number, name: string): number {
534
560
  const resolved = value ?? fallback
535
561
  if (!Number.isInteger(resolved) || resolved < minimum || resolved > maximum) {
@@ -703,6 +729,37 @@ interface ResolvedProviderEnv {
703
729
  env: UpstreamEnvironment
704
730
  }
705
731
 
732
+ /** Mutable in-flight state for one provider during a hedge-based failover run. */
733
+ interface ProviderTask {
734
+ index: number
735
+ entry: ResolvedProviderEnv
736
+ cumulativeMs: number
737
+ status: 'idle' | 'running' | 'succeeded' | 'failed' | 'ratelimited'
738
+ result?: UpstreamRunResult
739
+ error?: VisionToolkitError
740
+ hedged: boolean
741
+ launched: boolean
742
+ settled: Promise<void>
743
+ settle: () => void
744
+ abort: AbortController
745
+ }
746
+
747
+ /** Live concurrency accounting returned by the availability query tool. */
748
+ export interface ConcurrencyStatus {
749
+ /** New tool calls this session may start right now. */
750
+ available: number
751
+ /** Per-session cap on concurrent tool operations. */
752
+ sessionMax: number
753
+ /** Tool operations currently in flight in this session. */
754
+ sessionInUse: number
755
+ /** Free per-session slots. */
756
+ sessionFree: number
757
+ /** Total free model-request slots summed across enabled providers. */
758
+ modelFree: number
759
+ /** Per-provider breakdown. */
760
+ models: Array<{ name: string; concurrency: number; inUse: number; free: number }>
761
+ }
762
+
706
763
  /** Runtime facade used by every native tool. */
707
764
  export class VisionToolkitRuntime {
708
765
  private readonly semaphores = new Map<string, Semaphore>()
@@ -723,6 +780,11 @@ export class VisionToolkitRuntime {
723
780
  return this.adapter.versionInfo
724
781
  }
725
782
 
783
+ /** Per-session cap on concurrent tool operations. */
784
+ get sessionMaxConcurrency(): number {
785
+ return this.config.sessionMaxConcurrency
786
+ }
787
+
726
788
  /** Stable identity for persisted image descriptions produced by this runtime. */
727
789
  get evidenceFingerprint(): string {
728
790
  return evidenceRuntimeFingerprint(this.config, undefined, process.env.VISION_SSL_VERIFY?.trim())
@@ -749,18 +811,13 @@ export class VisionToolkitRuntime {
749
811
  })
750
812
  }
751
813
 
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}`)
814
+ /** Global hard timeout (ms) for one tool invocation, honoring the per-call override. */
815
+ private hardTimeoutMs(options: ToolCallOptions): number {
816
+ const seconds = options.timeoutSeconds ?? this.config.hardTimeoutSeconds
817
+ if (!Number.isInteger(seconds) || seconds < 1 || seconds > MAX_TIMEOUT_SECONDS) {
818
+ throw new VisionToolkitError('input', `timeoutSeconds must be an integer between 1 and ${MAX_TIMEOUT_SECONDS}`)
756
819
  }
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))
820
+ return seconds * 1000
764
821
  }
765
822
 
766
823
  private operationError(
@@ -785,23 +842,51 @@ export class VisionToolkitRuntime {
785
842
  return new VisionToolkitError('runtime', `${tool}: execution failed`, { cause: error })
786
843
  }
787
844
 
788
- private semaphore(options: ToolCallOptions): { key: string; value: Semaphore } {
845
+ /** Per-session concurrency gate; callers acquire without queuing (excess is rejected). */
846
+ private sessionGate(options: ToolCallOptions): { key: string; value: Semaphore } {
789
847
  const key = options.sessionId ?? `workspace:${options.workspace}`
790
- const value = this.semaphores.get(key) ?? new Semaphore(this.config.concurrency)
848
+ const value = this.semaphores.get(key) ?? new Semaphore(this.config.sessionMaxConcurrency)
791
849
  this.semaphores.set(key, value)
792
850
  return { key, value }
793
851
  }
794
852
 
853
+ /** Live concurrency accounting for the calling session across the enabled provider pool. */
854
+ concurrencyStatus(options: ToolCallOptions): ConcurrencyStatus {
855
+ const gate = this.sessionGate(options)
856
+ const sessionFree = gate.value.available
857
+ const models = this.config.providers
858
+ .filter(provider => provider.enabled)
859
+ .map(provider => {
860
+ const modelGate = this.providerGate(provider)
861
+ return {
862
+ name: provider.name,
863
+ concurrency: provider.concurrency,
864
+ inUse: provider.concurrency - modelGate.available,
865
+ free: modelGate.available,
866
+ }
867
+ })
868
+ const modelFree = models.reduce((sum, model) => sum + model.free, 0)
869
+ return {
870
+ available: Math.min(sessionFree, modelFree),
871
+ sessionMax: this.config.sessionMaxConcurrency,
872
+ sessionInUse: this.config.sessionMaxConcurrency - sessionFree,
873
+ sessionFree,
874
+ modelFree,
875
+ models,
876
+ }
877
+ }
878
+
795
879
  private async runOperation<T>(
796
880
  tool: string,
797
881
  options: ToolCallOptions,
798
882
  action: (operation: OperationContext) => Promise<T>,
799
883
  permits = 1,
800
884
  ): Promise<T> {
801
- const timeoutMs = this.timeout(options)
802
- const semaphore = this.semaphore(options)
885
+ const hardTimeoutMs = this.hardTimeoutMs(options)
886
+ const startedAt = Date.now()
887
+ const deadlineAt = startedAt + hardTimeoutMs
803
888
  const metrics: OperationMetrics = {
804
- startedAt: Date.now(),
889
+ startedAt,
805
890
  queueMs: 0,
806
891
  upstreamMs: 0,
807
892
  imageBytes: 0,
@@ -810,48 +895,19 @@ export class VisionToolkitRuntime {
810
895
  cacheHits: 0,
811
896
  usedVisionService: false,
812
897
  }
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)
898
+ const gate = this.sessionGate(options)
899
+ if (!gate.value.tryAcquire(permits)) {
900
+ throw new VisionToolkitError('capacity', `${tool}: exceeded the session concurrency limit`)
843
901
  }
844
-
845
- const executionDeadline = createDeadline(options.signal, timeoutMs)
902
+ const executionDeadline = createDeadline(options.signal, hardTimeoutMs)
846
903
  try {
847
904
  if (executionDeadline.signal.aborted) throw this.operationError(tool, undefined, executionDeadline)
848
- const value = await action({ signal: executionDeadline.signal, metrics })
905
+ const value = await action({ signal: executionDeadline.signal, metrics, deadlineAt })
849
906
  if (executionDeadline.signal.aborted) throw this.operationError(tool, undefined, executionDeadline)
850
907
  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',
908
+ 'dsh-vision-toolkit tool=%s outcome=ok totalMs=%d upstreamMs=%d images=%d imageBytes=%d imagePixels=%d cacheHits=%d model=%s',
852
909
  tool,
853
910
  Date.now() - metrics.startedAt,
854
- metrics.queueMs,
855
911
  metrics.upstreamMs,
856
912
  metrics.imageCount,
857
913
  metrics.imageBytes,
@@ -863,11 +919,10 @@ export class VisionToolkitRuntime {
863
919
  } catch (error) {
864
920
  const classified = this.operationError(tool, error, executionDeadline)
865
921
  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',
922
+ 'dsh-vision-toolkit tool=%s outcome=error category=%s totalMs=%d upstreamMs=%d images=%d imageBytes=%d imagePixels=%d cacheHits=%d',
867
923
  tool,
868
924
  classified.code,
869
925
  Date.now() - metrics.startedAt,
870
- metrics.queueMs,
871
926
  metrics.upstreamMs,
872
927
  metrics.imageCount,
873
928
  metrics.imageBytes,
@@ -876,9 +931,9 @@ export class VisionToolkitRuntime {
876
931
  )
877
932
  throw classified
878
933
  } finally {
879
- if (acquired) semaphore.value.release(permits)
934
+ gate.value.release(permits)
880
935
  executionDeadline.cleanup()
881
- if (semaphore.value.idle) this.semaphores.delete(semaphore.key)
936
+ if (gate.value.idle) this.semaphores.delete(gate.key)
882
937
  }
883
938
  }
884
939
 
@@ -1228,12 +1283,15 @@ export class VisionToolkitRuntime {
1228
1283
  }
1229
1284
 
1230
1285
  /**
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.
1286
+ * Hedge-based failover across the enabled provider pool. The highest-priority
1287
+ * provider runs first; when one of its requests crosses t1 it keeps running
1288
+ * while the next provider starts in parallel. A provider whose cumulative
1289
+ * request time reaches t2 is terminated. A 429 provider is parked and moved
1290
+ * past immediately; parked providers are revisited at a 10s cadence once
1291
+ * every other provider is exhausted. The result always prefers the earliest
1292
+ * (highest-priority) provider.
1235
1293
  */
1236
- private async runVisionWithFailover(
1294
+ private async runVisionHedge(
1237
1295
  tool: 'glance' | 'ground' | 'detect' | 'long_screenshot_ocr',
1238
1296
  args: readonly string[],
1239
1297
  images: readonly ImageInfo[],
@@ -1243,40 +1301,237 @@ export class VisionToolkitRuntime {
1243
1301
  if (pool.length === 0) {
1244
1302
  throw new VisionToolkitError('config', 'no enabled vision provider has a resolvable credential')
1245
1303
  }
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()
1304
+ // Providers that cannot accept the image size are skipped entirely.
1305
+ const eligible = pool.filter(({ provider }) =>
1306
+ images.every(image => image.bytes <= provider.maxImageBytes && image.width * image.height <= provider.maxImagePixels))
1307
+ if (eligible.length === 0) {
1308
+ throw new VisionToolkitError('capacity', `${tool}: no enabled vision provider accepts the image size`)
1309
+ }
1310
+
1311
+ const tasks: ProviderTask[] = eligible.map((entry, index) => {
1312
+ let settle!: () => void
1313
+ const settled = new Promise<void>(resolve => { settle = resolve })
1314
+ return {
1315
+ index,
1316
+ entry,
1317
+ cumulativeMs: 0,
1318
+ status: 'idle',
1319
+ hedged: false,
1320
+ launched: false,
1321
+ settled,
1322
+ settle,
1323
+ abort: new AbortController(),
1324
+ }
1325
+ })
1326
+ const n = tasks.length
1327
+
1328
+ const launch = (from: number): void => {
1329
+ for (let i = from; i < n; i++) {
1330
+ const task = tasks[i]
1331
+ if (task === undefined || task.launched || task.status !== 'idle') continue
1332
+ task.launched = true
1333
+ void this.runProviderTask(tool, args, operation, task, () => launch(i + 1))
1334
+ return
1335
+ }
1336
+ }
1337
+
1338
+ launch(0)
1339
+
1340
+ const settleAny = (subset: ProviderTask[]): Promise<void> =>
1341
+ Promise.race([...subset.map(task => task.settled), untilAbort(operation.signal)])
1342
+
1343
+ while (true) {
1344
+ if (operation.signal.aborted) break
1345
+ const running = tasks.filter(task => task.status === 'running')
1346
+ const successIndex = tasks.findIndex(task => task.status === 'succeeded')
1347
+ if (successIndex >= 0) {
1348
+ const blocking = running.filter(task => task.index < successIndex)
1349
+ if (blocking.length === 0) {
1350
+ for (const task of running) task.abort.abort()
1351
+ return tasks[successIndex]!.result!
1352
+ }
1353
+ await settleAny(blocking)
1354
+ } else {
1355
+ if (running.length === 0) break
1356
+ await settleAny(running)
1357
+ }
1358
+ }
1359
+
1360
+ // No success from the main pass. Revisit parked (429) providers at a 10s cadence.
1361
+ if (!operation.signal.aborted) {
1362
+ const parked = tasks.filter(task => task.status === 'ratelimited')
1363
+ if (parked.length > 0) {
1364
+ const revisited = await this.revisitRateLimited(tool, args, operation, parked)
1365
+ if (revisited !== undefined) return revisited
1366
+ }
1367
+ }
1368
+
1369
+ const success = tasks.find(task => task.status === 'succeeded')
1370
+ if (success !== undefined) return success.result!
1371
+ if (operation.signal.aborted) {
1372
+ throw new VisionToolkitError('timeout', `${tool}: timed out`)
1373
+ }
1374
+ const firstError = tasks.find(task => task.status === 'failed' || task.status === 'ratelimited')
1375
+ if (firstError?.error !== undefined) throw firstError.error
1376
+ throw new VisionToolkitError('service', `${tool}: all vision providers failed`)
1377
+ }
1378
+
1379
+ /** Remaining request budget (ms) for one provider: the tighter of its t2 and the global deadline. */
1380
+ private providerRequestBudget(task: ProviderTask, operation: OperationContext): number {
1381
+ const t2Remaining = task.entry.provider.t2Seconds * 1000 - task.cumulativeMs
1382
+ const globalRemaining = operation.deadlineAt - Date.now()
1383
+ return Math.max(0, Math.min(t2Remaining, globalRemaining))
1384
+ }
1385
+
1386
+ /**
1387
+ * Run one provider to a terminal state: retryable errors retry within
1388
+ * `attempts`, a single request crossing t1 hedges the next provider, and the
1389
+ * provider is terminated once its cumulative time reaches t2. A 429 parks the
1390
+ * provider and moves on. No request is issued once the remaining budget drops
1391
+ * below the configured minimum available time.
1392
+ */
1393
+ private async runProviderTask(
1394
+ tool: 'glance' | 'ground' | 'detect' | 'long_screenshot_ocr',
1395
+ args: readonly string[],
1396
+ operation: OperationContext,
1397
+ task: ProviderTask,
1398
+ launchNext: () => void,
1399
+ ): Promise<void> {
1400
+ const { provider, env } = task.entry
1401
+ const gate = this.providerGate(provider)
1402
+ if (!gate.tryAcquire()) {
1403
+ task.status = 'failed'
1404
+ task.error = new VisionToolkitError('capacity', `${tool}: ${provider.name} has no free concurrency slot`)
1405
+ task.settle()
1406
+ launchNext()
1407
+ return
1408
+ }
1409
+ task.status = 'running'
1410
+ try {
1411
+ let attempt = 0
1412
+ const minAvailableMs = this.config.minAvailableSeconds * 1000
1413
+ while (true) {
1414
+ const budget = this.providerRequestBudget(task, operation)
1415
+ if (budget < minAvailableMs) {
1416
+ task.status = 'failed'
1417
+ task.error = new VisionToolkitError('timeout', `${tool}: ${provider.name} has insufficient remaining time`)
1418
+ return
1419
+ }
1420
+ const reqDeadline = createDeadline(AbortSignal.any([operation.signal, task.abort.signal]), budget)
1421
+ const hedgeMs = Math.min(provider.t1Seconds * 1000, budget)
1422
+ let hedgeTimer: ReturnType<typeof setTimeout> | undefined
1423
+ if (!task.hedged) {
1424
+ hedgeTimer = setTimeout(() => {
1425
+ task.hedged = true
1426
+ launchNext()
1427
+ }, hedgeMs)
1428
+ }
1429
+ const started = Date.now()
1430
+ try {
1431
+ const result = await this.runUpstream(tool, args, { signal: reqDeadline.signal, metrics: operation.metrics }, env)
1432
+ if (hedgeTimer !== undefined) clearTimeout(hedgeTimer)
1433
+ task.cumulativeMs += Date.now() - started
1434
+ task.status = 'succeeded'
1435
+ task.result = result
1436
+ return
1437
+ } catch (error) {
1438
+ if (hedgeTimer !== undefined) clearTimeout(hedgeTimer)
1439
+ task.cumulativeMs += Date.now() - started
1440
+ if (task.abort.signal.aborted) {
1441
+ task.status = 'failed'
1442
+ task.error = new VisionToolkitError('cancelled', `${tool}: superseded by a higher-priority provider`)
1443
+ return
1444
+ }
1445
+ if (operation.signal.aborted) {
1446
+ task.status = 'failed'
1447
+ task.error = new VisionToolkitError('timeout', `${tool}: timed out`)
1448
+ return
1449
+ }
1450
+ const classified = error instanceof VisionToolkitError
1451
+ ? error
1452
+ : new VisionToolkitError('service', `${tool}: request failed`, { cause: error })
1453
+ if (reqDeadline.timedOut) {
1454
+ task.status = 'failed'
1455
+ task.error = new VisionToolkitError('timeout', `${tool}: ${provider.name} exhausted its t2 budget`)
1456
+ return
1457
+ }
1458
+ if (classified.code === 'rate_limit') {
1459
+ task.status = 'ratelimited'
1460
+ task.error = classified
1461
+ launchNext()
1462
+ return
1269
1463
  }
1464
+ if (RETRYABLE_CODES.has(classified.code) && attempt + 1 < provider.attempts) {
1465
+ attempt += 1
1466
+ continue
1467
+ }
1468
+ task.status = 'failed'
1469
+ task.error = classified
1470
+ return
1471
+ } finally {
1472
+ reqDeadline.cleanup()
1270
1473
  }
1271
- } finally {
1272
- gate.release()
1273
1474
  }
1475
+ } finally {
1476
+ gate.release()
1477
+ task.settle()
1274
1478
  }
1275
- if (!attempted) {
1276
- throw new VisionToolkitError('capacity', `${tool}: no enabled vision provider accepts the image size or has a free concurrency slot`)
1479
+ }
1480
+
1481
+ /**
1482
+ * Revisit parked (429) providers in priority order at a 10s cadence until one
1483
+ * succeeds or every provider exhausts its budget. Returns a success result or
1484
+ * `undefined` when the global deadline or minimum available time stops the loop.
1485
+ */
1486
+ private async revisitRateLimited(
1487
+ tool: 'glance' | 'ground' | 'detect' | 'long_screenshot_ocr',
1488
+ args: readonly string[],
1489
+ operation: OperationContext,
1490
+ parked: ProviderTask[],
1491
+ ): Promise<UpstreamRunResult | undefined> {
1492
+ const minAvailableMs = this.config.minAvailableSeconds * 1000
1493
+ while (!operation.signal.aborted) {
1494
+ let anyRevisitable = false
1495
+ for (const task of parked) {
1496
+ if (operation.signal.aborted) break
1497
+ if (task.status !== 'ratelimited') continue
1498
+ const budget = this.providerRequestBudget(task, operation)
1499
+ if (budget < minAvailableMs) continue
1500
+ anyRevisitable = true
1501
+ await abortableSleep(Math.min(10_000, budget), operation.signal)
1502
+ if (operation.signal.aborted) break
1503
+ const { provider, env } = task.entry
1504
+ const gate = this.providerGate(provider)
1505
+ if (!gate.tryAcquire()) continue
1506
+ const reqDeadline = createDeadline(operation.signal, Math.min(provider.t2Seconds * 1000 - task.cumulativeMs, operation.deadlineAt - Date.now()))
1507
+ const started = Date.now()
1508
+ try {
1509
+ const result = await this.runUpstream(tool, args, { signal: reqDeadline.signal, metrics: operation.metrics }, env)
1510
+ task.cumulativeMs += Date.now() - started
1511
+ task.status = 'succeeded'
1512
+ task.result = result
1513
+ return result
1514
+ } catch (error) {
1515
+ task.cumulativeMs += Date.now() - started
1516
+ if (reqDeadline.timedOut) {
1517
+ task.status = 'failed'
1518
+ task.error = new VisionToolkitError('timeout', `${tool}: ${provider.name} exhausted its t2 budget`)
1519
+ continue
1520
+ }
1521
+ const classified = error instanceof VisionToolkitError
1522
+ ? error
1523
+ : new VisionToolkitError('service', `${tool}: request failed`, { cause: error })
1524
+ if (classified.code === 'rate_limit') continue
1525
+ task.status = 'failed'
1526
+ task.error = classified
1527
+ } finally {
1528
+ reqDeadline.cleanup()
1529
+ gate.release()
1530
+ }
1531
+ }
1532
+ if (!anyRevisitable) break
1277
1533
  }
1278
- if (lastError instanceof VisionToolkitError) throw lastError
1279
- throw new VisionToolkitError('service', `${tool}: all vision providers failed`, { cause: lastError })
1534
+ return undefined
1280
1535
  }
1281
1536
 
1282
1537
  private async glanceCacheKey(
@@ -1325,7 +1580,7 @@ export class VisionToolkitRuntime {
1325
1580
  private async runUpstream(
1326
1581
  tool: UpstreamTool,
1327
1582
  args: readonly string[],
1328
- operation: OperationContext,
1583
+ operation: { signal: AbortSignal; metrics: OperationMetrics },
1329
1584
  env?: UpstreamEnvironment,
1330
1585
  ): Promise<UpstreamRunResult> {
1331
1586
  const started = Date.now()
@@ -1443,7 +1698,7 @@ export class VisionToolkitRuntime {
1443
1698
  return cached.result
1444
1699
  }
1445
1700
  }
1446
- const result = await this.runVisionWithFailover('glance', [
1701
+ const result = await this.runVisionHedge('glance', [
1447
1702
  ...images.map(image => image.path),
1448
1703
  ...(request.region !== undefined ? ['--region', request.region] : []),
1449
1704
  ...(request.ocr === true ? ['--ocr'] : []),
@@ -1496,7 +1751,7 @@ export class VisionToolkitRuntime {
1496
1751
  }
1497
1752
  const image = await this.prepareVisionImage(request.image, pool.map(entry => entry.provider), policy, operation)
1498
1753
  this.accountImage(image, operation)
1499
- const result = await this.runVisionWithFailover(tool, [
1754
+ const result = await this.runVisionHedge(tool, [
1500
1755
  image.path,
1501
1756
  request.target,
1502
1757
  ...(request.region !== undefined ? ['--region', request.region] : []),
@@ -1820,13 +2075,6 @@ export class VisionToolkitRuntime {
1820
2075
  const overlap = request.overlap === undefined
1821
2076
  ? undefined
1822
2077
  : 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
2078
  if (request.prompt !== undefined && request.prompt.trim().length === 0) {
1831
2079
  throw new VisionToolkitError('input', 'long_screenshot_ocr.prompt must not be empty when provided')
1832
2080
  }
@@ -1868,13 +2116,13 @@ export class VisionToolkitRuntime {
1868
2116
  '--jobs',
1869
2117
  String(jobs),
1870
2118
  '--timeout',
1871
- String(chunkTimeoutSeconds),
2119
+ String(this.config.hardTimeoutSeconds),
1872
2120
  ...(splitOnly ? ['--split-only'] : []),
1873
2121
  ...(request.resume === true ? ['--resume'] : []),
1874
2122
  ]
1875
2123
  const result = splitOnly
1876
2124
  ? await this.runUpstream('long_screenshot_ocr', ocrArgs, operation)
1877
- : await this.runVisionWithFailover('long_screenshot_ocr', ocrArgs, [image], operation, pool)
2125
+ : await this.runVisionHedge('long_screenshot_ocr', ocrArgs, [image], operation, pool)
1878
2126
  const reported = result.stdout.trim()
1879
2127
  const expectedReported = splitOnly ? stagedManifest : stagedOutput
1880
2128
  if (reported !== expectedReported) {
@@ -2290,7 +2538,7 @@ export class VisionToolkitRuntime {
2290
2538
  }
2291
2539
  if (testModel) {
2292
2540
  try {
2293
- const attemptDeadline = createDeadline(operation.signal, target.timeoutMs)
2541
+ const attemptDeadline = createDeadline(operation.signal, target.t2Seconds * 1000)
2294
2542
  try {
2295
2543
  const result = await this.runUpstream(
2296
2544
  'glance',