@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/lib/client.js +38 -13
- package/lib/client.js.map +1 -1
- package/lib/config.js +42 -14
- package/lib/config.js.map +1 -1
- package/lib/errors.js +1 -0
- package/lib/errors.js.map +1 -1
- package/lib/evidence-cache.js +3 -1
- package/lib/evidence-cache.js.map +1 -1
- package/lib/runtime.js +311 -94
- package/lib/runtime.js.map +1 -1
- package/lib/tools.js +68 -35
- package/lib/tools.js.map +1 -1
- package/lib/types/client/index.d.ts +17 -4
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/config.d.ts +18 -8
- package/lib/types/config.d.ts.map +1 -1
- package/lib/types/errors.d.ts +1 -1
- package/lib/types/errors.d.ts.map +1 -1
- package/lib/types/evidence-cache.d.ts.map +1 -1
- package/lib/types/runtime.d.ts +56 -11
- package/lib/types/runtime.d.ts.map +1 -1
- package/lib/types/tools.d.ts +1 -0
- package/lib/types/tools.d.ts.map +1 -1
- package/lib/upstream.js +1 -1
- package/lib/upstream.js.map +1 -1
- package/package.json +1 -1
- package/src/client/index.tsx +54 -17
- package/src/config.ts +61 -22
- package/src/errors.ts +1 -0
- package/src/evidence-cache.ts +3 -1
- package/src/runtime.ts +354 -106
- package/src/tools.ts +78 -46
- package/src/upstream.ts +1 -1
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
|
-
|
|
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
|
|
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
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
|
802
|
-
const
|
|
885
|
+
const hardTimeoutMs = this.hardTimeoutMs(options)
|
|
886
|
+
const startedAt = Date.now()
|
|
887
|
+
const deadlineAt = startedAt + hardTimeoutMs
|
|
803
888
|
const metrics: OperationMetrics = {
|
|
804
|
-
startedAt
|
|
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
|
-
|
|
814
|
-
|
|
815
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
934
|
+
gate.value.release(permits)
|
|
880
935
|
executionDeadline.cleanup()
|
|
881
|
-
if (
|
|
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
|
-
*
|
|
1232
|
-
*
|
|
1233
|
-
*
|
|
1234
|
-
*
|
|
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
|
|
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
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
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
|
-
|
|
1276
|
-
|
|
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
|
-
|
|
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:
|
|
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.
|
|
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.
|
|
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(
|
|
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.
|
|
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.
|
|
2541
|
+
const attemptDeadline = createDeadline(operation.signal, target.t2Seconds * 1000)
|
|
2294
2542
|
try {
|
|
2295
2543
|
const result = await this.runUpstream(
|
|
2296
2544
|
'glance',
|