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