@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/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
|
}
|
|
@@ -471,6 +475,9 @@ export interface VisionToolkitHealthResult {
|
|
|
471
475
|
python: HealthCheck
|
|
472
476
|
dependencies: HealthCheck
|
|
473
477
|
chrome: HealthCheck
|
|
478
|
+
credential: HealthCheck
|
|
479
|
+
artifactDirectory: HealthCheck
|
|
480
|
+
tempDirectory: HealthCheck
|
|
474
481
|
service: HealthCheck
|
|
475
482
|
model: HealthCheck
|
|
476
483
|
}
|
|
@@ -484,7 +491,8 @@ export interface VisionToolkitHealthResult {
|
|
|
484
491
|
/** Shared per-call execution options. */
|
|
485
492
|
export interface ToolCallOptions {
|
|
486
493
|
signal: AbortSignal
|
|
487
|
-
|
|
494
|
+
/** Override the global hard timeout (seconds) for this call. */
|
|
495
|
+
timeoutSeconds?: number
|
|
488
496
|
workspace: string
|
|
489
497
|
/** Session identity for the per-session concurrency cap. */
|
|
490
498
|
sessionId?: string
|
|
@@ -517,10 +525,12 @@ interface OperationMetrics {
|
|
|
517
525
|
interface OperationContext {
|
|
518
526
|
signal: AbortSignal
|
|
519
527
|
metrics: OperationMetrics
|
|
528
|
+
/** Absolute epoch-millisecond timestamp when the global hard timeout fires. */
|
|
529
|
+
deadlineAt: number
|
|
520
530
|
}
|
|
521
531
|
|
|
522
532
|
const REGION_PATTERN = /^\s*(-?\d+)\s*,\s*(-?\d+)\s*,\s*(-?\d+)\s*,\s*(-?\d+)\s*$/
|
|
523
|
-
const
|
|
533
|
+
const MAX_TIMEOUT_SECONDS = 600
|
|
524
534
|
const FORMAT_BY_EXTENSION = new Map([
|
|
525
535
|
['.png', 'png'],
|
|
526
536
|
['.jpg', 'jpeg'],
|
|
@@ -530,6 +540,32 @@ const FORMAT_BY_EXTENSION = new Map([
|
|
|
530
540
|
])
|
|
531
541
|
const HEX_COLOR_PATTERN = /^#[0-9A-F]{6}$/
|
|
532
542
|
|
|
543
|
+
/**
|
|
544
|
+
* Error codes a provider retries against the SAME provider within its
|
|
545
|
+
* `attempts` budget. Only transient failures are worth re-requesting: a
|
|
546
|
+
* timeout may clear on the next attempt and a 5xx / network drop is usually
|
|
547
|
+
* ephemeral. Deterministic failures (auth, quota, rate_limit, invalid_request,
|
|
548
|
+
* region, tos) must fail over to the next provider immediately instead of
|
|
549
|
+
* re-requesting a backend that cannot succeed with the same input.
|
|
550
|
+
*/
|
|
551
|
+
const RETRYABLE_CODES: ReadonlySet<VisionToolkitErrorCode> = new Set(['timeout', 'server', 'network'])
|
|
552
|
+
|
|
553
|
+
/** Resolve as soon as `signal` aborts (or immediately when already aborted). */
|
|
554
|
+
function untilAbort(signal: AbortSignal): Promise<void> {
|
|
555
|
+
if (signal.aborted) return Promise.resolve()
|
|
556
|
+
return new Promise(resolve => signal.addEventListener('abort', () => resolve(), { once: true }))
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/** Sleep for `ms`, resolving early when `signal` aborts. */
|
|
560
|
+
function abortableSleep(ms: number, signal: AbortSignal): Promise<void> {
|
|
561
|
+
if (signal.aborted) return Promise.resolve()
|
|
562
|
+
return new Promise(resolve => {
|
|
563
|
+
const onAbort = (): void => { clearTimeout(timer); resolve() }
|
|
564
|
+
const timer = setTimeout(() => { signal.removeEventListener('abort', onAbort); resolve() }, ms)
|
|
565
|
+
signal.addEventListener('abort', onAbort, { once: true })
|
|
566
|
+
})
|
|
567
|
+
}
|
|
568
|
+
|
|
533
569
|
function integerInRange(value: number | undefined, fallback: number, minimum: number, maximum: number, name: string): number {
|
|
534
570
|
const resolved = value ?? fallback
|
|
535
571
|
if (!Number.isInteger(resolved) || resolved < minimum || resolved > maximum) {
|
|
@@ -703,6 +739,37 @@ interface ResolvedProviderEnv {
|
|
|
703
739
|
env: UpstreamEnvironment
|
|
704
740
|
}
|
|
705
741
|
|
|
742
|
+
/** Mutable in-flight state for one provider during a hedge-based failover run. */
|
|
743
|
+
interface ProviderTask {
|
|
744
|
+
index: number
|
|
745
|
+
entry: ResolvedProviderEnv
|
|
746
|
+
cumulativeMs: number
|
|
747
|
+
status: 'idle' | 'running' | 'succeeded' | 'failed' | 'ratelimited'
|
|
748
|
+
result?: UpstreamRunResult
|
|
749
|
+
error?: VisionToolkitError
|
|
750
|
+
hedged: boolean
|
|
751
|
+
launched: boolean
|
|
752
|
+
settled: Promise<void>
|
|
753
|
+
settle: () => void
|
|
754
|
+
abort: AbortController
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
/** Live concurrency accounting returned by the availability query tool. */
|
|
758
|
+
export interface ConcurrencyStatus {
|
|
759
|
+
/** New tool calls this session may start right now. */
|
|
760
|
+
available: number
|
|
761
|
+
/** Per-session cap on concurrent tool operations. */
|
|
762
|
+
sessionMax: number
|
|
763
|
+
/** Tool operations currently in flight in this session. */
|
|
764
|
+
sessionInUse: number
|
|
765
|
+
/** Free per-session slots. */
|
|
766
|
+
sessionFree: number
|
|
767
|
+
/** Total free model-request slots summed across enabled providers. */
|
|
768
|
+
modelFree: number
|
|
769
|
+
/** Per-provider breakdown. */
|
|
770
|
+
models: Array<{ name: string; concurrency: number; inUse: number; free: number }>
|
|
771
|
+
}
|
|
772
|
+
|
|
706
773
|
/** Runtime facade used by every native tool. */
|
|
707
774
|
export class VisionToolkitRuntime {
|
|
708
775
|
private readonly semaphores = new Map<string, Semaphore>()
|
|
@@ -714,6 +781,7 @@ export class VisionToolkitRuntime {
|
|
|
714
781
|
private readonly ctx: Context,
|
|
715
782
|
private readonly config: ResolvedVisionToolkitConfig,
|
|
716
783
|
adapter?: UpstreamAdapter,
|
|
784
|
+
private readonly readableStorageDirs: readonly string[] = [],
|
|
717
785
|
) {
|
|
718
786
|
this.adapter = adapter ?? new UpstreamAdapter(ctx, config)
|
|
719
787
|
}
|
|
@@ -723,6 +791,16 @@ export class VisionToolkitRuntime {
|
|
|
723
791
|
return this.adapter.versionInfo
|
|
724
792
|
}
|
|
725
793
|
|
|
794
|
+
/** Per-session cap on concurrent tool operations. */
|
|
795
|
+
get sessionMaxConcurrency(): number {
|
|
796
|
+
return this.config.sessionMaxConcurrency
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
/** Shared storage root belonging to this immutable runtime generation. */
|
|
800
|
+
get storageDirectory(): string | undefined {
|
|
801
|
+
return this.config.storageDir
|
|
802
|
+
}
|
|
803
|
+
|
|
726
804
|
/** Stable identity for persisted image descriptions produced by this runtime. */
|
|
727
805
|
get evidenceFingerprint(): string {
|
|
728
806
|
return evidenceRuntimeFingerprint(this.config, undefined, process.env.VISION_SSL_VERIFY?.trim())
|
|
@@ -749,18 +827,13 @@ export class VisionToolkitRuntime {
|
|
|
749
827
|
})
|
|
750
828
|
}
|
|
751
829
|
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
830
|
+
/** Global hard timeout (ms) for one tool invocation, honoring the per-call override. */
|
|
831
|
+
private hardTimeoutMs(options: ToolCallOptions): number {
|
|
832
|
+
const seconds = options.timeoutSeconds ?? this.config.hardTimeoutSeconds
|
|
833
|
+
if (!Number.isInteger(seconds) || seconds < 1 || seconds > MAX_TIMEOUT_SECONDS) {
|
|
834
|
+
throw new VisionToolkitError('input', `timeoutSeconds must be an integer between 1 and ${MAX_TIMEOUT_SECONDS}`)
|
|
756
835
|
}
|
|
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))
|
|
836
|
+
return seconds * 1000
|
|
764
837
|
}
|
|
765
838
|
|
|
766
839
|
private operationError(
|
|
@@ -785,23 +858,51 @@ export class VisionToolkitRuntime {
|
|
|
785
858
|
return new VisionToolkitError('runtime', `${tool}: execution failed`, { cause: error })
|
|
786
859
|
}
|
|
787
860
|
|
|
788
|
-
|
|
861
|
+
/** Per-session concurrency gate; callers acquire without queuing (excess is rejected). */
|
|
862
|
+
private sessionGate(options: ToolCallOptions): { key: string; value: Semaphore } {
|
|
789
863
|
const key = options.sessionId ?? `workspace:${options.workspace}`
|
|
790
|
-
const value = this.semaphores.get(key) ?? new Semaphore(this.config.
|
|
864
|
+
const value = this.semaphores.get(key) ?? new Semaphore(this.config.sessionMaxConcurrency)
|
|
791
865
|
this.semaphores.set(key, value)
|
|
792
866
|
return { key, value }
|
|
793
867
|
}
|
|
794
868
|
|
|
869
|
+
/** Live concurrency accounting for the calling session across the enabled provider pool. */
|
|
870
|
+
concurrencyStatus(options: ToolCallOptions): ConcurrencyStatus {
|
|
871
|
+
const gate = this.sessionGate(options)
|
|
872
|
+
const sessionFree = gate.value.available
|
|
873
|
+
const models = this.config.providers
|
|
874
|
+
.filter(provider => provider.enabled)
|
|
875
|
+
.map(provider => {
|
|
876
|
+
const modelGate = this.providerGate(provider)
|
|
877
|
+
return {
|
|
878
|
+
name: provider.name,
|
|
879
|
+
concurrency: provider.concurrency,
|
|
880
|
+
inUse: provider.concurrency - modelGate.available,
|
|
881
|
+
free: modelGate.available,
|
|
882
|
+
}
|
|
883
|
+
})
|
|
884
|
+
const modelFree = models.reduce((sum, model) => sum + model.free, 0)
|
|
885
|
+
return {
|
|
886
|
+
available: Math.min(sessionFree, modelFree),
|
|
887
|
+
sessionMax: this.config.sessionMaxConcurrency,
|
|
888
|
+
sessionInUse: this.config.sessionMaxConcurrency - sessionFree,
|
|
889
|
+
sessionFree,
|
|
890
|
+
modelFree,
|
|
891
|
+
models,
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
|
|
795
895
|
private async runOperation<T>(
|
|
796
896
|
tool: string,
|
|
797
897
|
options: ToolCallOptions,
|
|
798
898
|
action: (operation: OperationContext) => Promise<T>,
|
|
799
899
|
permits = 1,
|
|
800
900
|
): Promise<T> {
|
|
801
|
-
const
|
|
802
|
-
const
|
|
901
|
+
const hardTimeoutMs = this.hardTimeoutMs(options)
|
|
902
|
+
const startedAt = Date.now()
|
|
903
|
+
const deadlineAt = startedAt + hardTimeoutMs
|
|
803
904
|
const metrics: OperationMetrics = {
|
|
804
|
-
startedAt
|
|
905
|
+
startedAt,
|
|
805
906
|
queueMs: 0,
|
|
806
907
|
upstreamMs: 0,
|
|
807
908
|
imageBytes: 0,
|
|
@@ -810,48 +911,19 @@ export class VisionToolkitRuntime {
|
|
|
810
911
|
cacheHits: 0,
|
|
811
912
|
usedVisionService: false,
|
|
812
913
|
}
|
|
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)
|
|
914
|
+
const gate = this.sessionGate(options)
|
|
915
|
+
if (!gate.value.tryAcquire(permits)) {
|
|
916
|
+
throw new VisionToolkitError('capacity', `${tool}: exceeded the session concurrency limit`)
|
|
843
917
|
}
|
|
844
|
-
|
|
845
|
-
const executionDeadline = createDeadline(options.signal, timeoutMs)
|
|
918
|
+
const executionDeadline = createDeadline(options.signal, hardTimeoutMs)
|
|
846
919
|
try {
|
|
847
920
|
if (executionDeadline.signal.aborted) throw this.operationError(tool, undefined, executionDeadline)
|
|
848
|
-
const value = await action({ signal: executionDeadline.signal, metrics })
|
|
921
|
+
const value = await action({ signal: executionDeadline.signal, metrics, deadlineAt })
|
|
849
922
|
if (executionDeadline.signal.aborted) throw this.operationError(tool, undefined, executionDeadline)
|
|
850
923
|
this.ctx.logger.info(
|
|
851
|
-
'dsh-vision-toolkit tool=%s outcome=ok totalMs=%d
|
|
924
|
+
'dsh-vision-toolkit tool=%s outcome=ok totalMs=%d upstreamMs=%d images=%d imageBytes=%d imagePixels=%d cacheHits=%d model=%s',
|
|
852
925
|
tool,
|
|
853
926
|
Date.now() - metrics.startedAt,
|
|
854
|
-
metrics.queueMs,
|
|
855
927
|
metrics.upstreamMs,
|
|
856
928
|
metrics.imageCount,
|
|
857
929
|
metrics.imageBytes,
|
|
@@ -863,11 +935,10 @@ export class VisionToolkitRuntime {
|
|
|
863
935
|
} catch (error) {
|
|
864
936
|
const classified = this.operationError(tool, error, executionDeadline)
|
|
865
937
|
this.ctx.logger.warn(
|
|
866
|
-
'dsh-vision-toolkit tool=%s outcome=error category=%s totalMs=%d
|
|
938
|
+
'dsh-vision-toolkit tool=%s outcome=error category=%s totalMs=%d upstreamMs=%d images=%d imageBytes=%d imagePixels=%d cacheHits=%d',
|
|
867
939
|
tool,
|
|
868
940
|
classified.code,
|
|
869
941
|
Date.now() - metrics.startedAt,
|
|
870
|
-
metrics.queueMs,
|
|
871
942
|
metrics.upstreamMs,
|
|
872
943
|
metrics.imageCount,
|
|
873
944
|
metrics.imageBytes,
|
|
@@ -876,9 +947,9 @@ export class VisionToolkitRuntime {
|
|
|
876
947
|
)
|
|
877
948
|
throw classified
|
|
878
949
|
} finally {
|
|
879
|
-
|
|
950
|
+
gate.value.release(permits)
|
|
880
951
|
executionDeadline.cleanup()
|
|
881
|
-
if (
|
|
952
|
+
if (gate.value.idle) this.semaphores.delete(gate.key)
|
|
882
953
|
}
|
|
883
954
|
}
|
|
884
955
|
|
|
@@ -955,13 +1026,13 @@ export class VisionToolkitRuntime {
|
|
|
955
1026
|
}
|
|
956
1027
|
|
|
957
1028
|
private pathPolicy(workspace: string): Promise<PathPolicy> {
|
|
958
|
-
return createPathPolicy(workspace, this.config.allowedDirs)
|
|
1029
|
+
return createPathPolicy(workspace, this.config.allowedDirs, this.config.storageDir, this.readableStorageDirs)
|
|
959
1030
|
}
|
|
960
1031
|
|
|
961
1032
|
private async compressedImageRoot(policy: PathPolicy): Promise<string> {
|
|
962
|
-
const root = join(policy.
|
|
963
|
-
let current = policy.
|
|
964
|
-
for (const segment of ['
|
|
1033
|
+
const root = join(policy.storageRoot, 'tmp', 'compressed-images')
|
|
1034
|
+
let current = policy.storageRoot
|
|
1035
|
+
for (const segment of ['tmp', 'compressed-images']) {
|
|
965
1036
|
current = join(current, segment)
|
|
966
1037
|
try {
|
|
967
1038
|
await mkdir(current, { mode: 0o700 })
|
|
@@ -972,13 +1043,13 @@ export class VisionToolkitRuntime {
|
|
|
972
1043
|
if (info.isSymbolicLink() || !info.isDirectory()) {
|
|
973
1044
|
throw new VisionToolkitError('path', `compressed-image cache path is not a real directory: ${current}`)
|
|
974
1045
|
}
|
|
975
|
-
if (!isWithin(policy.
|
|
976
|
-
throw new VisionToolkitError('path', `compressed-image cache path escaped
|
|
1046
|
+
if (!isWithin(policy.storageRoot, current)) {
|
|
1047
|
+
throw new VisionToolkitError('path', `compressed-image cache path escaped plugin storage: ${current}`)
|
|
977
1048
|
}
|
|
978
1049
|
}
|
|
979
1050
|
const canonical = await realpath(root)
|
|
980
|
-
if (!isWithin(policy.
|
|
981
|
-
throw new VisionToolkitError('path', 'compressed-image cache resolved outside
|
|
1051
|
+
if (!isWithin(policy.storageRoot, canonical)) {
|
|
1052
|
+
throw new VisionToolkitError('path', 'compressed-image cache resolved outside plugin storage')
|
|
982
1053
|
}
|
|
983
1054
|
return canonical
|
|
984
1055
|
}
|
|
@@ -1228,12 +1299,15 @@ export class VisionToolkitRuntime {
|
|
|
1228
1299
|
}
|
|
1229
1300
|
|
|
1230
1301
|
/**
|
|
1231
|
-
*
|
|
1232
|
-
*
|
|
1233
|
-
*
|
|
1234
|
-
*
|
|
1302
|
+
* Hedge-based failover across the enabled provider pool. The highest-priority
|
|
1303
|
+
* provider runs first; when one of its requests crosses t1 it keeps running
|
|
1304
|
+
* while the next provider starts in parallel. A provider whose cumulative
|
|
1305
|
+
* request time reaches t2 is terminated. A 429 provider is parked and moved
|
|
1306
|
+
* past immediately; parked providers are revisited at a 10s cadence once
|
|
1307
|
+
* every other provider is exhausted. The result always prefers the earliest
|
|
1308
|
+
* (highest-priority) provider.
|
|
1235
1309
|
*/
|
|
1236
|
-
private async
|
|
1310
|
+
private async runVisionHedge(
|
|
1237
1311
|
tool: 'glance' | 'ground' | 'detect' | 'long_screenshot_ocr',
|
|
1238
1312
|
args: readonly string[],
|
|
1239
1313
|
images: readonly ImageInfo[],
|
|
@@ -1243,40 +1317,256 @@ export class VisionToolkitRuntime {
|
|
|
1243
1317
|
if (pool.length === 0) {
|
|
1244
1318
|
throw new VisionToolkitError('config', 'no enabled vision provider has a resolvable credential')
|
|
1245
1319
|
}
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1320
|
+
// Providers that cannot accept the image size are skipped entirely.
|
|
1321
|
+
const eligible = pool.filter(({ provider }) =>
|
|
1322
|
+
images.every(image => image.bytes <= provider.maxImageBytes && image.width * image.height <= provider.maxImagePixels))
|
|
1323
|
+
if (eligible.length === 0) {
|
|
1324
|
+
throw new VisionToolkitError('capacity', `${tool}: no enabled vision provider accepts the image size`)
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
const tasks: ProviderTask[] = eligible.map((entry, index) => {
|
|
1328
|
+
let settle!: () => void
|
|
1329
|
+
const settled = new Promise<void>(resolve => { settle = resolve })
|
|
1330
|
+
return {
|
|
1331
|
+
index,
|
|
1332
|
+
entry,
|
|
1333
|
+
cumulativeMs: 0,
|
|
1334
|
+
status: 'idle',
|
|
1335
|
+
hedged: false,
|
|
1336
|
+
launched: false,
|
|
1337
|
+
settled,
|
|
1338
|
+
settle,
|
|
1339
|
+
abort: new AbortController(),
|
|
1340
|
+
}
|
|
1341
|
+
})
|
|
1342
|
+
const n = tasks.length
|
|
1343
|
+
|
|
1344
|
+
const launch = (from: number): void => {
|
|
1345
|
+
for (let i = from; i < n; i++) {
|
|
1346
|
+
const task = tasks[i]
|
|
1347
|
+
if (task === undefined || task.launched || task.status !== 'idle') continue
|
|
1348
|
+
task.launched = true
|
|
1349
|
+
void this.runProviderTask(tool, args, operation, task, () => launch(i + 1))
|
|
1350
|
+
return
|
|
1351
|
+
}
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
launch(0)
|
|
1355
|
+
|
|
1356
|
+
const settleAny = (subset: ProviderTask[]): Promise<void> =>
|
|
1357
|
+
Promise.race([...subset.map(task => task.settled), untilAbort(operation.signal)])
|
|
1358
|
+
|
|
1359
|
+
while (true) {
|
|
1360
|
+
if (operation.signal.aborted) break
|
|
1361
|
+
const running = tasks.filter(task => task.status === 'running')
|
|
1362
|
+
const successIndex = tasks.findIndex(task => task.status === 'succeeded')
|
|
1363
|
+
if (successIndex >= 0) {
|
|
1364
|
+
const blocking = running.filter(task => task.index < successIndex)
|
|
1365
|
+
if (blocking.length === 0) {
|
|
1366
|
+
for (const task of running) task.abort.abort()
|
|
1367
|
+
return tasks[successIndex]!.result!
|
|
1368
|
+
}
|
|
1369
|
+
await settleAny(blocking)
|
|
1370
|
+
} else {
|
|
1371
|
+
if (running.length === 0) break
|
|
1372
|
+
await settleAny(running)
|
|
1373
|
+
}
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
// No success from the main pass. Revisit parked (429) providers at a 10s cadence.
|
|
1377
|
+
if (!operation.signal.aborted) {
|
|
1378
|
+
const parked = tasks.filter(task => task.status === 'ratelimited')
|
|
1379
|
+
if (parked.length > 0) {
|
|
1380
|
+
const revisited = await this.revisitRateLimited(tool, args, operation, parked)
|
|
1381
|
+
if (revisited !== undefined) return revisited
|
|
1382
|
+
}
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
const success = tasks.find(task => task.status === 'succeeded')
|
|
1386
|
+
if (success !== undefined) return success.result!
|
|
1387
|
+
if (operation.signal.aborted) {
|
|
1388
|
+
throw new VisionToolkitError('timeout', `${tool}: timed out`)
|
|
1389
|
+
}
|
|
1390
|
+
const firstError = tasks.find(task => task.status === 'failed' || task.status === 'ratelimited')
|
|
1391
|
+
if (firstError?.error !== undefined) throw firstError.error
|
|
1392
|
+
throw new VisionToolkitError('service', `${tool}: all vision providers failed`)
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
/** Remaining request budget (ms) for one provider: the tighter of its t2 and the global deadline. */
|
|
1396
|
+
private providerRequestBudget(task: ProviderTask, operation: OperationContext): number {
|
|
1397
|
+
const t2Remaining = task.entry.provider.t2Seconds * 1000 - task.cumulativeMs
|
|
1398
|
+
const globalRemaining = operation.deadlineAt - Date.now()
|
|
1399
|
+
return Math.max(0, Math.min(t2Remaining, globalRemaining))
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
/**
|
|
1403
|
+
* Advance to the next provider after one provider reached a terminal
|
|
1404
|
+
* failure. This is what makes failover work for FAST failures too: the
|
|
1405
|
+
* hedge timer only launches the next provider when the current one is SLOW
|
|
1406
|
+
* (crosses t1), so a quick auth/5xx/network failure must explicitly launch
|
|
1407
|
+
* the successor. Never advances when a higher-priority provider superseded
|
|
1408
|
+
* this task, when the whole operation was cancelled, or when the global
|
|
1409
|
+
* deadline has too little room left for another request. `launch` is
|
|
1410
|
+
* idempotent, so an earlier hedge timer cannot cause a double launch.
|
|
1411
|
+
*/
|
|
1412
|
+
private advanceAfterFailure(task: ProviderTask, operation: OperationContext, launchNext: () => void): void {
|
|
1413
|
+
if (task.abort.signal.aborted || operation.signal.aborted) return
|
|
1414
|
+
if (operation.deadlineAt - Date.now() < this.config.minAvailableSeconds * 1000) return
|
|
1415
|
+
launchNext()
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
/**
|
|
1419
|
+
* Run one provider to a terminal state: retryable errors retry within
|
|
1420
|
+
* `attempts`, a single request crossing t1 hedges the next provider, and the
|
|
1421
|
+
* provider is terminated once its cumulative time reaches t2. A 429 parks the
|
|
1422
|
+
* provider and moves on. No request is issued once the remaining budget drops
|
|
1423
|
+
* below the configured minimum available time.
|
|
1424
|
+
*/
|
|
1425
|
+
private async runProviderTask(
|
|
1426
|
+
tool: 'glance' | 'ground' | 'detect' | 'long_screenshot_ocr',
|
|
1427
|
+
args: readonly string[],
|
|
1428
|
+
operation: OperationContext,
|
|
1429
|
+
task: ProviderTask,
|
|
1430
|
+
launchNext: () => void,
|
|
1431
|
+
): Promise<void> {
|
|
1432
|
+
const { provider, env } = task.entry
|
|
1433
|
+
const gate = this.providerGate(provider)
|
|
1434
|
+
if (!gate.tryAcquire()) {
|
|
1435
|
+
task.status = 'failed'
|
|
1436
|
+
task.error = new VisionToolkitError('capacity', `${tool}: ${provider.name} has no free concurrency slot`)
|
|
1437
|
+
task.settle()
|
|
1438
|
+
launchNext()
|
|
1439
|
+
return
|
|
1440
|
+
}
|
|
1441
|
+
task.status = 'running'
|
|
1442
|
+
try {
|
|
1443
|
+
let attempt = 0
|
|
1444
|
+
const minAvailableMs = this.config.minAvailableSeconds * 1000
|
|
1445
|
+
while (true) {
|
|
1446
|
+
const budget = this.providerRequestBudget(task, operation)
|
|
1447
|
+
if (budget < minAvailableMs) {
|
|
1448
|
+
task.status = 'failed'
|
|
1449
|
+
task.error = new VisionToolkitError('timeout', `${tool}: ${provider.name} has insufficient remaining time`)
|
|
1450
|
+
this.advanceAfterFailure(task, operation, launchNext)
|
|
1451
|
+
return
|
|
1452
|
+
}
|
|
1453
|
+
const reqDeadline = createDeadline(AbortSignal.any([operation.signal, task.abort.signal]), budget)
|
|
1454
|
+
const hedgeMs = Math.min(provider.t1Seconds * 1000, budget)
|
|
1455
|
+
let hedgeTimer: ReturnType<typeof setTimeout> | undefined
|
|
1456
|
+
if (!task.hedged) {
|
|
1457
|
+
hedgeTimer = setTimeout(() => {
|
|
1458
|
+
task.hedged = true
|
|
1459
|
+
launchNext()
|
|
1460
|
+
}, hedgeMs)
|
|
1461
|
+
}
|
|
1462
|
+
const started = Date.now()
|
|
1463
|
+
try {
|
|
1464
|
+
const result = await this.runUpstream(tool, args, { signal: reqDeadline.signal, metrics: operation.metrics }, env)
|
|
1465
|
+
if (hedgeTimer !== undefined) clearTimeout(hedgeTimer)
|
|
1466
|
+
task.cumulativeMs += Date.now() - started
|
|
1467
|
+
task.status = 'succeeded'
|
|
1468
|
+
task.result = result
|
|
1469
|
+
return
|
|
1470
|
+
} catch (error) {
|
|
1471
|
+
if (hedgeTimer !== undefined) clearTimeout(hedgeTimer)
|
|
1472
|
+
task.cumulativeMs += Date.now() - started
|
|
1473
|
+
if (task.abort.signal.aborted) {
|
|
1474
|
+
task.status = 'failed'
|
|
1475
|
+
task.error = new VisionToolkitError('cancelled', `${tool}: superseded by a higher-priority provider`)
|
|
1476
|
+
return
|
|
1477
|
+
}
|
|
1478
|
+
if (operation.signal.aborted) {
|
|
1479
|
+
task.status = 'failed'
|
|
1480
|
+
task.error = new VisionToolkitError('timeout', `${tool}: timed out`)
|
|
1481
|
+
return
|
|
1482
|
+
}
|
|
1483
|
+
const classified = error instanceof VisionToolkitError
|
|
1484
|
+
? error
|
|
1485
|
+
: new VisionToolkitError('service', `${tool}: request failed`, { cause: error })
|
|
1486
|
+
if (reqDeadline.timedOut) {
|
|
1487
|
+
task.status = 'failed'
|
|
1488
|
+
task.error = new VisionToolkitError('timeout', `${tool}: ${provider.name} exhausted its t2 budget`)
|
|
1489
|
+
this.advanceAfterFailure(task, operation, launchNext)
|
|
1490
|
+
return
|
|
1491
|
+
}
|
|
1492
|
+
if (classified.code === 'rate_limit') {
|
|
1493
|
+
task.status = 'ratelimited'
|
|
1494
|
+
task.error = classified
|
|
1495
|
+
launchNext()
|
|
1496
|
+
return
|
|
1269
1497
|
}
|
|
1498
|
+
if (RETRYABLE_CODES.has(classified.code) && attempt + 1 < provider.attempts) {
|
|
1499
|
+
attempt += 1
|
|
1500
|
+
continue
|
|
1501
|
+
}
|
|
1502
|
+
task.status = 'failed'
|
|
1503
|
+
task.error = classified
|
|
1504
|
+
this.advanceAfterFailure(task, operation, launchNext)
|
|
1505
|
+
return
|
|
1506
|
+
} finally {
|
|
1507
|
+
reqDeadline.cleanup()
|
|
1270
1508
|
}
|
|
1271
|
-
} finally {
|
|
1272
|
-
gate.release()
|
|
1273
1509
|
}
|
|
1510
|
+
} finally {
|
|
1511
|
+
gate.release()
|
|
1512
|
+
task.settle()
|
|
1274
1513
|
}
|
|
1275
|
-
|
|
1276
|
-
|
|
1514
|
+
}
|
|
1515
|
+
|
|
1516
|
+
/**
|
|
1517
|
+
* Revisit parked (429) providers in priority order at a 10s cadence until one
|
|
1518
|
+
* succeeds or every provider exhausts its budget. Returns a success result or
|
|
1519
|
+
* `undefined` when the global deadline or minimum available time stops the loop.
|
|
1520
|
+
*/
|
|
1521
|
+
private async revisitRateLimited(
|
|
1522
|
+
tool: 'glance' | 'ground' | 'detect' | 'long_screenshot_ocr',
|
|
1523
|
+
args: readonly string[],
|
|
1524
|
+
operation: OperationContext,
|
|
1525
|
+
parked: ProviderTask[],
|
|
1526
|
+
): Promise<UpstreamRunResult | undefined> {
|
|
1527
|
+
const minAvailableMs = this.config.minAvailableSeconds * 1000
|
|
1528
|
+
while (!operation.signal.aborted) {
|
|
1529
|
+
let anyRevisitable = false
|
|
1530
|
+
for (const task of parked) {
|
|
1531
|
+
if (operation.signal.aborted) break
|
|
1532
|
+
if (task.status !== 'ratelimited') continue
|
|
1533
|
+
const budget = this.providerRequestBudget(task, operation)
|
|
1534
|
+
if (budget < minAvailableMs) continue
|
|
1535
|
+
anyRevisitable = true
|
|
1536
|
+
await abortableSleep(Math.min(10_000, budget), operation.signal)
|
|
1537
|
+
if (operation.signal.aborted) break
|
|
1538
|
+
const { provider, env } = task.entry
|
|
1539
|
+
const gate = this.providerGate(provider)
|
|
1540
|
+
if (!gate.tryAcquire()) continue
|
|
1541
|
+
const reqDeadline = createDeadline(operation.signal, Math.min(provider.t2Seconds * 1000 - task.cumulativeMs, operation.deadlineAt - Date.now()))
|
|
1542
|
+
const started = Date.now()
|
|
1543
|
+
try {
|
|
1544
|
+
const result = await this.runUpstream(tool, args, { signal: reqDeadline.signal, metrics: operation.metrics }, env)
|
|
1545
|
+
task.cumulativeMs += Date.now() - started
|
|
1546
|
+
task.status = 'succeeded'
|
|
1547
|
+
task.result = result
|
|
1548
|
+
return result
|
|
1549
|
+
} catch (error) {
|
|
1550
|
+
task.cumulativeMs += Date.now() - started
|
|
1551
|
+
if (reqDeadline.timedOut) {
|
|
1552
|
+
task.status = 'failed'
|
|
1553
|
+
task.error = new VisionToolkitError('timeout', `${tool}: ${provider.name} exhausted its t2 budget`)
|
|
1554
|
+
continue
|
|
1555
|
+
}
|
|
1556
|
+
const classified = error instanceof VisionToolkitError
|
|
1557
|
+
? error
|
|
1558
|
+
: new VisionToolkitError('service', `${tool}: request failed`, { cause: error })
|
|
1559
|
+
if (classified.code === 'rate_limit') continue
|
|
1560
|
+
task.status = 'failed'
|
|
1561
|
+
task.error = classified
|
|
1562
|
+
} finally {
|
|
1563
|
+
reqDeadline.cleanup()
|
|
1564
|
+
gate.release()
|
|
1565
|
+
}
|
|
1566
|
+
}
|
|
1567
|
+
if (!anyRevisitable) break
|
|
1277
1568
|
}
|
|
1278
|
-
|
|
1279
|
-
throw new VisionToolkitError('service', `${tool}: all vision providers failed`, { cause: lastError })
|
|
1569
|
+
return undefined
|
|
1280
1570
|
}
|
|
1281
1571
|
|
|
1282
1572
|
private async glanceCacheKey(
|
|
@@ -1325,7 +1615,7 @@ export class VisionToolkitRuntime {
|
|
|
1325
1615
|
private async runUpstream(
|
|
1326
1616
|
tool: UpstreamTool,
|
|
1327
1617
|
args: readonly string[],
|
|
1328
|
-
operation:
|
|
1618
|
+
operation: { signal: AbortSignal; metrics: OperationMetrics },
|
|
1329
1619
|
env?: UpstreamEnvironment,
|
|
1330
1620
|
): Promise<UpstreamRunResult> {
|
|
1331
1621
|
const started = Date.now()
|
|
@@ -1443,7 +1733,7 @@ export class VisionToolkitRuntime {
|
|
|
1443
1733
|
return cached.result
|
|
1444
1734
|
}
|
|
1445
1735
|
}
|
|
1446
|
-
const result = await this.
|
|
1736
|
+
const result = await this.runVisionHedge('glance', [
|
|
1447
1737
|
...images.map(image => image.path),
|
|
1448
1738
|
...(request.region !== undefined ? ['--region', request.region] : []),
|
|
1449
1739
|
...(request.ocr === true ? ['--ocr'] : []),
|
|
@@ -1496,7 +1786,7 @@ export class VisionToolkitRuntime {
|
|
|
1496
1786
|
}
|
|
1497
1787
|
const image = await this.prepareVisionImage(request.image, pool.map(entry => entry.provider), policy, operation)
|
|
1498
1788
|
this.accountImage(image, operation)
|
|
1499
|
-
const result = await this.
|
|
1789
|
+
const result = await this.runVisionHedge(tool, [
|
|
1500
1790
|
image.path,
|
|
1501
1791
|
request.target,
|
|
1502
1792
|
...(request.region !== undefined ? ['--region', request.region] : []),
|
|
@@ -1820,13 +2110,6 @@ export class VisionToolkitRuntime {
|
|
|
1820
2110
|
const overlap = request.overlap === undefined
|
|
1821
2111
|
? undefined
|
|
1822
2112
|
: 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
2113
|
if (request.prompt !== undefined && request.prompt.trim().length === 0) {
|
|
1831
2114
|
throw new VisionToolkitError('input', 'long_screenshot_ocr.prompt must not be empty when provided')
|
|
1832
2115
|
}
|
|
@@ -1868,13 +2151,13 @@ export class VisionToolkitRuntime {
|
|
|
1868
2151
|
'--jobs',
|
|
1869
2152
|
String(jobs),
|
|
1870
2153
|
'--timeout',
|
|
1871
|
-
String(
|
|
2154
|
+
String(this.config.hardTimeoutSeconds),
|
|
1872
2155
|
...(splitOnly ? ['--split-only'] : []),
|
|
1873
2156
|
...(request.resume === true ? ['--resume'] : []),
|
|
1874
2157
|
]
|
|
1875
2158
|
const result = splitOnly
|
|
1876
2159
|
? await this.runUpstream('long_screenshot_ocr', ocrArgs, operation)
|
|
1877
|
-
: await this.
|
|
2160
|
+
: await this.runVisionHedge('long_screenshot_ocr', ocrArgs, [image], operation, pool)
|
|
1878
2161
|
const reported = result.stdout.trim()
|
|
1879
2162
|
const expectedReported = splitOnly ? stagedManifest : stagedOutput
|
|
1880
2163
|
if (reported !== expectedReported) {
|
|
@@ -2205,6 +2488,18 @@ export class VisionToolkitRuntime {
|
|
|
2205
2488
|
})
|
|
2206
2489
|
}
|
|
2207
2490
|
|
|
2491
|
+
private async writableDirectoryCheck(path: string, label: string): Promise<HealthCheck> {
|
|
2492
|
+
const probe = join(path, `.vision-toolkit-health-${randomUUID()}`)
|
|
2493
|
+
try {
|
|
2494
|
+
await writeFile(probe, 'ok\n', { encoding: 'utf8', flag: 'wx' })
|
|
2495
|
+
await rm(probe, { force: true })
|
|
2496
|
+
return { status: 'ok', detail: `${label} is writable: ${path}` }
|
|
2497
|
+
} catch {
|
|
2498
|
+
await rm(probe, { force: true }).catch(() => {})
|
|
2499
|
+
return { status: 'error', detail: `${label} is not writable: ${path}` }
|
|
2500
|
+
}
|
|
2501
|
+
}
|
|
2502
|
+
|
|
2208
2503
|
/** Health: inspect local readiness, and optionally probe one provider's `/models` plus one real multimodal request. */
|
|
2209
2504
|
async health(testConnection: boolean, options: ToolCallOptions, testModel = false, provider?: ResolvedProvider): Promise<VisionToolkitHealthResult> {
|
|
2210
2505
|
return this.runOperation('vision_toolkit_health', options, async (operation) => {
|
|
@@ -2226,6 +2521,27 @@ export class VisionToolkitRuntime {
|
|
|
2226
2521
|
if (operation.signal.aborted) throw new VisionToolkitError('cancelled', 'vision_toolkit_health: cancelled')
|
|
2227
2522
|
chrome = { status: 'error', detail: 'Chrome availability probe failed' }
|
|
2228
2523
|
}
|
|
2524
|
+
let resolvedCredential: ResolvedCredential | undefined
|
|
2525
|
+
let credential: HealthCheck
|
|
2526
|
+
try {
|
|
2527
|
+
resolvedCredential = isBuiltInFreeVisionProvider(this.config.provider)
|
|
2528
|
+
? { value: BUILT_IN_FREE_VISION_KEY, source: 'built-in' }
|
|
2529
|
+
: await this.ctx.credentials.resolve(this.config.provider.credential)
|
|
2530
|
+
credential = resolvedCredential === undefined
|
|
2531
|
+
? { status: 'error', detail: `credential ${this.config.provider.credential} is not configured` }
|
|
2532
|
+
: { status: 'ok', detail: `credential ${this.config.provider.credential} is resolvable` }
|
|
2533
|
+
} catch {
|
|
2534
|
+
credential = { status: 'error', detail: `credential ${this.config.provider.credential} could not be resolved` }
|
|
2535
|
+
}
|
|
2536
|
+
let artifactDirectory: HealthCheck
|
|
2537
|
+
try {
|
|
2538
|
+
// allowedDirs are session input roots; they do not affect output readiness.
|
|
2539
|
+
const policy = await createPathPolicy(options.workspace, [], this.config.storageDir)
|
|
2540
|
+
artifactDirectory = await this.writableDirectoryCheck(policy.outputDir, 'Artifact directory')
|
|
2541
|
+
} catch {
|
|
2542
|
+
artifactDirectory = { status: 'error', detail: 'Artifact directory could not be prepared' }
|
|
2543
|
+
}
|
|
2544
|
+
const tempDirectory = await this.writableDirectoryCheck(info.runtimeHome, 'Runtime temp directory')
|
|
2229
2545
|
let service: HealthCheck = {
|
|
2230
2546
|
status: 'not_tested',
|
|
2231
2547
|
detail: 'Connection was not tested; use the per-provider API test',
|
|
@@ -2290,7 +2606,7 @@ export class VisionToolkitRuntime {
|
|
|
2290
2606
|
}
|
|
2291
2607
|
if (testModel) {
|
|
2292
2608
|
try {
|
|
2293
|
-
const attemptDeadline = createDeadline(operation.signal, target.
|
|
2609
|
+
const attemptDeadline = createDeadline(operation.signal, target.t2Seconds * 1000)
|
|
2294
2610
|
try {
|
|
2295
2611
|
const result = await this.runUpstream(
|
|
2296
2612
|
'glance',
|
|
@@ -2316,7 +2632,7 @@ export class VisionToolkitRuntime {
|
|
|
2316
2632
|
}
|
|
2317
2633
|
}
|
|
2318
2634
|
}
|
|
2319
|
-
const checks = { python, dependencies, chrome, service, model }
|
|
2635
|
+
const checks = { python, dependencies, chrome, credential, artifactDirectory, tempDirectory, service, model }
|
|
2320
2636
|
const healthy = Object.values(checks).every(check => check.status !== 'error')
|
|
2321
2637
|
return {
|
|
2322
2638
|
pluginVersion: PLUGIN_VERSION,
|