@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.
- package/README.i18n.yaml +2 -2
- package/README.md +4 -0
- package/README.zh.md +4 -0
- package/docs/requirements-traceability/README.i18n.yaml +2 -2
- package/docs/requirements-traceability/README.md +1 -1
- package/docs/requirements-traceability/README.zh.md +1 -1
- package/lib/artifact-access.js +20 -2
- package/lib/artifact-access.js.map +1 -1
- package/lib/client.js +59 -19
- package/lib/client.js.map +1 -1
- package/lib/config.js +64 -12
- package/lib/config.js.map +1 -1
- package/lib/errors.js +25 -1
- package/lib/errors.js.map +1 -1
- package/lib/evidence-cache.js +5 -2
- package/lib/evidence-cache.js.map +1 -1
- package/lib/exposure.js +14 -1
- package/lib/exposure.js.map +1 -1
- package/lib/image-input-variants.js +22 -12
- package/lib/image-input-variants.js.map +1 -1
- package/lib/index.js +53 -6
- package/lib/index.js.map +1 -1
- package/lib/paste-images.js +67 -19
- package/lib/paste-images.js.map +1 -1
- package/lib/paths.js +214 -28
- package/lib/paths.js.map +1 -1
- package/lib/runtime-manager.js +76 -10
- package/lib/runtime-manager.js.map +1 -1
- package/lib/runtime.js +389 -104
- package/lib/runtime.js.map +1 -1
- package/lib/storage-history.js +154 -0
- package/lib/storage-history.js.map +1 -0
- package/lib/tools.js +68 -35
- package/lib/tools.js.map +1 -1
- package/lib/types/artifact-access.d.ts.map +1 -1
- package/lib/types/client/index.d.ts +23 -5
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/paste-images.d.ts +2 -0
- package/lib/types/client/paste-images.d.ts.map +1 -1
- package/lib/types/config.d.ts +38 -7
- package/lib/types/config.d.ts.map +1 -1
- package/lib/types/errors.d.ts +18 -2
- package/lib/types/errors.d.ts.map +1 -1
- package/lib/types/evidence-cache.d.ts +1 -1
- package/lib/types/evidence-cache.d.ts.map +1 -1
- package/lib/types/exposure.d.ts.map +1 -1
- package/lib/types/image-input-variants.d.ts +5 -3
- package/lib/types/image-input-variants.d.ts.map +1 -1
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/paste-images.d.ts +12 -4
- package/lib/types/paste-images.d.ts.map +1 -1
- package/lib/types/paths.d.ts +31 -5
- package/lib/types/paths.d.ts.map +1 -1
- package/lib/types/runtime-manager.d.ts +28 -4
- package/lib/types/runtime-manager.d.ts.map +1 -1
- package/lib/types/runtime.d.ts +75 -12
- package/lib/types/runtime.d.ts.map +1 -1
- package/lib/types/storage-history.d.ts +63 -0
- package/lib/types/storage-history.d.ts.map +1 -0
- package/lib/types/tools.d.ts +1 -0
- package/lib/types/tools.d.ts.map +1 -1
- package/lib/types/upstream.d.ts.map +1 -1
- package/lib/types/web.d.ts.map +1 -1
- package/lib/upstream.js +31 -8
- package/lib/upstream.js.map +1 -1
- package/lib/web.js +9 -3
- package/lib/web.js.map +1 -1
- package/package.json +1 -1
- package/src/artifact-access.ts +22 -2
- package/src/client/index.tsx +72 -20
- package/src/client/paste-images.tsx +14 -4
- package/src/config.ts +107 -20
- package/src/errors.ts +25 -1
- package/src/evidence-cache.ts +5 -2
- package/src/exposure.ts +16 -2
- package/src/image-input-variants.ts +21 -6
- package/src/index.ts +65 -6
- package/src/paste-images.ts +81 -19
- package/src/paths.ts +249 -28
- package/src/runtime-manager.ts +93 -10
- package/src/runtime.ts +431 -115
- package/src/storage-history.ts +172 -0
- package/src/tools.ts +78 -46
- package/src/upstream.ts +33 -8
- 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
|
|
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(
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
|
381
|
-
const
|
|
441
|
+
const hardTimeoutMs = this.hardTimeoutMs(options);
|
|
442
|
+
const startedAt = Date.now();
|
|
443
|
+
const deadlineAt = startedAt + hardTimeoutMs;
|
|
382
444
|
const metrics = {
|
|
383
|
-
startedAt
|
|
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
|
-
|
|
393
|
-
|
|
394
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
433
|
-
semaphore.value.release(permits);
|
|
474
|
+
gate.value.release(permits);
|
|
434
475
|
executionDeadline.cleanup();
|
|
435
|
-
if (
|
|
436
|
-
this.semaphores.delete(
|
|
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.
|
|
507
|
-
let current = policy.
|
|
508
|
-
for (const segment of ['
|
|
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.
|
|
522
|
-
throw new VisionToolkitError('path', `compressed-image cache path escaped
|
|
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.
|
|
527
|
-
throw new VisionToolkitError('path', 'compressed-image cache resolved outside
|
|
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
|
-
*
|
|
761
|
-
*
|
|
762
|
-
*
|
|
763
|
-
*
|
|
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
|
|
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
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
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
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
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
|
-
|
|
796
|
-
|
|
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
|
-
|
|
805
|
-
|
|
1000
|
+
finally {
|
|
1001
|
+
gate.release();
|
|
1002
|
+
task.settle();
|
|
806
1003
|
}
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
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.
|
|
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.
|
|
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(
|
|
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.
|
|
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.
|
|
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,
|