@mengruo/dsh-vision-toolkit 0.0.1 → 0.1.1
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 +17 -42
- package/README.zh.md +16 -41
- package/assets/logo_eapi_dark.png +0 -0
- package/docs/aihubmix-gemini-vision.i18n.yaml +2 -2
- package/docs/aihubmix-gemini-vision.md +2 -2
- package/docs/aihubmix-gemini-vision.zh.md +2 -2
- package/lib/client.js +246 -68
- package/lib/client.js.map +1 -1
- package/lib/config.js +143 -24
- package/lib/config.js.map +1 -1
- package/lib/evidence-cache.js +14 -0
- package/lib/evidence-cache.js.map +1 -1
- package/lib/image-input-variants.js +48 -24
- package/lib/image-input-variants.js.map +1 -1
- package/lib/runtime-install.js +204 -19
- package/lib/runtime-install.js.map +1 -1
- package/lib/runtime.js +310 -154
- package/lib/runtime.js.map +1 -1
- package/lib/tools.js +1 -0
- package/lib/tools.js.map +1 -1
- package/lib/types/client/index.d.ts +45 -3
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/config.d.ts +53 -0
- package/lib/types/config.d.ts.map +1 -1
- package/lib/types/evidence-cache.d.ts.map +1 -1
- package/lib/types/image-input-variants.d.ts +9 -4
- package/lib/types/image-input-variants.d.ts.map +1 -1
- package/lib/types/runtime-install.d.ts +21 -0
- package/lib/types/runtime-install.d.ts.map +1 -1
- package/lib/types/runtime.d.ts +45 -10
- package/lib/types/runtime.d.ts.map +1 -1
- package/lib/types/tools.d.ts.map +1 -1
- package/lib/types/upstream.d.ts +3 -0
- package/lib/types/upstream.d.ts.map +1 -1
- package/lib/types/web.d.ts +8 -0
- package/lib/types/web.d.ts.map +1 -1
- package/lib/upstream.js +11 -6
- package/lib/upstream.js.map +1 -1
- package/lib/web.js +50 -7
- package/lib/web.js.map +1 -1
- package/package.json +1 -1
- package/src/client/index.tsx +331 -88
- package/src/config.ts +210 -28
- package/src/evidence-cache.ts +14 -0
- package/src/image-input-variants.ts +54 -22
- package/src/runtime-install.ts +231 -20
- package/src/runtime.ts +333 -180
- package/src/tools.ts +1 -0
- package/src/upstream.ts +15 -8
- package/src/web.ts +67 -8
package/src/runtime.ts
CHANGED
|
@@ -14,7 +14,7 @@ import type { Context } from '@deepseek-ai/cordis'
|
|
|
14
14
|
import type { ResolvedCredential } from '@deepseek-ai/dsh-credentials'
|
|
15
15
|
import { SaxesParser } from 'saxes'
|
|
16
16
|
import { describeArtifact, type ArtifactDescriptor } from './artifacts.ts'
|
|
17
|
-
import { isBuiltInFreeVisionProvider, type ResolvedVisionToolkitConfig } from './config.ts'
|
|
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
20
|
import { VisionToolkitError } from './errors.ts'
|
|
@@ -194,6 +194,16 @@ export class Semaphore {
|
|
|
194
194
|
next.resolve()
|
|
195
195
|
}
|
|
196
196
|
}
|
|
197
|
+
|
|
198
|
+
/** Non-blocking acquisition: claim a free slot immediately, else return false. */
|
|
199
|
+
tryAcquire(permits = 1): boolean {
|
|
200
|
+
if (!Number.isInteger(permits) || permits < 1 || permits > this.limit) return false
|
|
201
|
+
if (this.waiters.length === 0 && this.active + permits <= this.limit) {
|
|
202
|
+
this.active += permits
|
|
203
|
+
return true
|
|
204
|
+
}
|
|
205
|
+
return false
|
|
206
|
+
}
|
|
197
207
|
}
|
|
198
208
|
|
|
199
209
|
/** Validated image metadata retained in structured results and diagnostics. */
|
|
@@ -203,6 +213,8 @@ export interface ImageInfo {
|
|
|
203
213
|
width: number
|
|
204
214
|
height: number
|
|
205
215
|
format: string
|
|
216
|
+
/** True when the analyzed image carries an alpha (transparency) channel. */
|
|
217
|
+
hasAlpha: boolean
|
|
206
218
|
/** Original user-facing image path before any automatic compression. */
|
|
207
219
|
originalPath: string
|
|
208
220
|
}
|
|
@@ -451,7 +463,7 @@ export interface HealthCheck {
|
|
|
451
463
|
detail: string
|
|
452
464
|
}
|
|
453
465
|
|
|
454
|
-
/** Runtime, dependency,
|
|
466
|
+
/** Runtime, dependency, browser, and optional per-provider service health. */
|
|
455
467
|
export interface VisionToolkitHealthResult {
|
|
456
468
|
pluginVersion: string
|
|
457
469
|
upstream: UpstreamVersionInfo
|
|
@@ -459,15 +471,14 @@ export interface VisionToolkitHealthResult {
|
|
|
459
471
|
python: HealthCheck
|
|
460
472
|
dependencies: HealthCheck
|
|
461
473
|
chrome: HealthCheck
|
|
462
|
-
credential: HealthCheck
|
|
463
|
-
artifactDirectory: HealthCheck
|
|
464
|
-
tempDirectory: HealthCheck
|
|
465
474
|
service: HealthCheck
|
|
466
475
|
model: HealthCheck
|
|
467
476
|
}
|
|
468
477
|
healthy: boolean
|
|
469
478
|
connectionTested: boolean
|
|
470
479
|
modelTested: boolean
|
|
480
|
+
/** Provider label when the service/model checks targeted one specific provider. */
|
|
481
|
+
providerName?: string
|
|
471
482
|
}
|
|
472
483
|
|
|
473
484
|
/** Shared per-call execution options. */
|
|
@@ -686,10 +697,17 @@ export function parseRegion(region: string): { x1: number; y1: number; x2: numbe
|
|
|
686
697
|
return box
|
|
687
698
|
}
|
|
688
699
|
|
|
700
|
+
/** One enabled provider paired with its resolved upstream environment. */
|
|
701
|
+
interface ResolvedProviderEnv {
|
|
702
|
+
provider: ResolvedProvider
|
|
703
|
+
env: UpstreamEnvironment
|
|
704
|
+
}
|
|
705
|
+
|
|
689
706
|
/** Runtime facade used by every native tool. */
|
|
690
707
|
export class VisionToolkitRuntime {
|
|
691
708
|
private readonly semaphores = new Map<string, Semaphore>()
|
|
692
709
|
private readonly glanceCache = new WeakMap<object, GlanceCacheEntry>()
|
|
710
|
+
private readonly providerGates = new Map<string, Semaphore>()
|
|
693
711
|
private readonly adapter: UpstreamAdapter
|
|
694
712
|
|
|
695
713
|
constructor(
|
|
@@ -712,26 +730,39 @@ export class VisionToolkitRuntime {
|
|
|
712
730
|
|
|
713
731
|
/** Capture the credential and provider identity used by one evidence conversion. */
|
|
714
732
|
async captureEvidenceRuntime(): Promise<CapturedEvidenceRuntime> {
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
733
|
+
// The primary provider's key hash sharpens cache invalidation, but a
|
|
734
|
+
// missing primary credential must not block evidence conversion when a
|
|
735
|
+
// later provider in the failover pool is available.
|
|
736
|
+
let credentialSha256: string | undefined
|
|
737
|
+
let sslVerify: string | undefined
|
|
738
|
+
try {
|
|
739
|
+
const env = await this.resolveVisionEnv()
|
|
740
|
+
credentialSha256 = createHash('sha256').update(env.VISION_API_KEY).digest('hex')
|
|
741
|
+
sslVerify = env.VISION_SSL_VERIFY
|
|
742
|
+
} catch {
|
|
743
|
+
credentialSha256 = undefined
|
|
744
|
+
}
|
|
745
|
+
const evidenceFingerprint = evidenceRuntimeFingerprint(this.config, credentialSha256, sslVerify)
|
|
721
746
|
return Object.freeze({
|
|
722
747
|
evidenceFingerprint,
|
|
723
|
-
glance: (request: GlanceRequest, options: ToolCallOptions) => this.glanceWithEnv(request, options
|
|
748
|
+
glance: (request: GlanceRequest, options: ToolCallOptions) => this.glanceWithEnv(request, options),
|
|
724
749
|
})
|
|
725
750
|
}
|
|
726
751
|
|
|
727
752
|
private timeout(options: ToolCallOptions): number {
|
|
728
|
-
const value = options.timeoutMs ?? this.
|
|
753
|
+
const value = options.timeoutMs ?? this.operationTimeoutMs()
|
|
729
754
|
if (!Number.isInteger(value) || value < 1000 || value > MAX_TIMEOUT_MS) {
|
|
730
755
|
throw new VisionToolkitError('input', `timeoutMs must be an integer between 1000 and ${MAX_TIMEOUT_MS}`)
|
|
731
756
|
}
|
|
732
757
|
return value
|
|
733
758
|
}
|
|
734
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))
|
|
764
|
+
}
|
|
765
|
+
|
|
735
766
|
private operationError(
|
|
736
767
|
tool: string,
|
|
737
768
|
error: unknown,
|
|
@@ -851,34 +882,78 @@ export class VisionToolkitRuntime {
|
|
|
851
882
|
}
|
|
852
883
|
}
|
|
853
884
|
|
|
854
|
-
/**
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
? { value: BUILT_IN_FREE_VISION_KEY, source: 'built-in' }
|
|
858
|
-
: await this.ctx.credentials.resolve(this.config.provider.credential)
|
|
859
|
-
if (resolved === undefined) {
|
|
860
|
-
throw new VisionToolkitError(
|
|
861
|
-
'config',
|
|
862
|
-
`credential ${this.config.provider.credential} is not configured; set it through DSH credentials`,
|
|
863
|
-
)
|
|
864
|
-
}
|
|
865
|
-
return this.visionEnv(resolved)
|
|
885
|
+
/** Highest-priority enabled provider, falling back to the first entry. */
|
|
886
|
+
private get primaryProvider(): ResolvedProvider {
|
|
887
|
+
return this.config.providers.find(provider => provider.enabled) ?? this.config.providers[0]!
|
|
866
888
|
}
|
|
867
889
|
|
|
868
|
-
|
|
890
|
+
/** Build the upstream environment for one resolved provider. */
|
|
891
|
+
private providerEnv(provider: ResolvedProvider, resolved: ResolvedCredential): UpstreamEnvironment {
|
|
869
892
|
const sslVerify = process.env.VISION_SSL_VERIFY?.trim()
|
|
870
893
|
return {
|
|
871
894
|
VISION_API_KEY: resolved.value,
|
|
872
|
-
VISION_BASE_URL:
|
|
873
|
-
VISION_MODEL:
|
|
874
|
-
VISION_API_PROTOCOL:
|
|
875
|
-
VISION_ANTHROPIC_THINKING:
|
|
895
|
+
VISION_BASE_URL: provider.baseUrl,
|
|
896
|
+
VISION_MODEL: provider.model,
|
|
897
|
+
VISION_API_PROTOCOL: provider.protocol === 'anthropic' ? 'anthropic' : 'chat_completions',
|
|
898
|
+
VISION_ANTHROPIC_THINKING: provider.anthropicThinking,
|
|
876
899
|
...(sslVerify === undefined ? {} : { VISION_SSL_VERIFY: sslVerify }),
|
|
877
|
-
VISION_USER_AGENT:
|
|
900
|
+
VISION_USER_AGENT: provider.userAgent,
|
|
878
901
|
LANG: this.config.language,
|
|
879
902
|
}
|
|
880
903
|
}
|
|
881
904
|
|
|
905
|
+
/** Resolve one provider's credential into its environment, or undefined when unavailable. */
|
|
906
|
+
private async resolveProviderEnv(provider: ResolvedProvider): Promise<ResolvedProviderEnv | undefined> {
|
|
907
|
+
let resolved: ResolvedCredential | undefined
|
|
908
|
+
try {
|
|
909
|
+
resolved = isBuiltInFreeVisionProvider({
|
|
910
|
+
baseUrl: provider.baseUrl,
|
|
911
|
+
credential: provider.credential,
|
|
912
|
+
model: provider.model,
|
|
913
|
+
protocol: provider.protocol,
|
|
914
|
+
anthropicThinking: provider.anthropicThinking,
|
|
915
|
+
userAgent: provider.userAgent,
|
|
916
|
+
})
|
|
917
|
+
? { value: BUILT_IN_FREE_VISION_KEY, source: 'built-in' }
|
|
918
|
+
: await this.ctx.credentials.resolve(provider.credential)
|
|
919
|
+
} catch {
|
|
920
|
+
resolved = undefined
|
|
921
|
+
}
|
|
922
|
+
if (resolved === undefined) return undefined
|
|
923
|
+
return { provider, env: this.providerEnv(provider, resolved) }
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
/** Resolve every enabled provider in priority order, skipping unreadable credentials. */
|
|
927
|
+
async resolveProviderPool(): Promise<ResolvedProviderEnv[]> {
|
|
928
|
+
const pool: ResolvedProviderEnv[] = []
|
|
929
|
+
for (const provider of this.config.providers) {
|
|
930
|
+
if (!provider.enabled) continue
|
|
931
|
+
const entry = await this.resolveProviderEnv(provider)
|
|
932
|
+
if (entry === undefined) {
|
|
933
|
+
this.ctx.logger.warn(
|
|
934
|
+
'dsh-vision-toolkit provider=%s credential=%s unavailable; skipped from the failover pool',
|
|
935
|
+
provider.name,
|
|
936
|
+
String(provider.credential),
|
|
937
|
+
)
|
|
938
|
+
continue
|
|
939
|
+
}
|
|
940
|
+
pool.push(entry)
|
|
941
|
+
}
|
|
942
|
+
return pool
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
/** Resolve the primary provider's credential at the remote-operation boundary. */
|
|
946
|
+
async resolveVisionEnv(): Promise<UpstreamEnvironment> {
|
|
947
|
+
const entry = await this.resolveProviderEnv(this.primaryProvider)
|
|
948
|
+
if (entry === undefined) {
|
|
949
|
+
throw new VisionToolkitError(
|
|
950
|
+
'config',
|
|
951
|
+
`credential ${String(this.primaryProvider.credential)} is not configured; set it through DSH credentials`,
|
|
952
|
+
)
|
|
953
|
+
}
|
|
954
|
+
return entry.env
|
|
955
|
+
}
|
|
956
|
+
|
|
882
957
|
private pathPolicy(workspace: string): Promise<PathPolicy> {
|
|
883
958
|
return createPathPolicy(workspace, this.config.allowedDirs)
|
|
884
959
|
}
|
|
@@ -915,7 +990,7 @@ export class VisionToolkitRuntime {
|
|
|
915
990
|
maxBytes: number,
|
|
916
991
|
maxPixels: number,
|
|
917
992
|
operation: OperationContext,
|
|
918
|
-
): Promise<{ path: string; bytes: number; width: number; height: number; format: string } | undefined> {
|
|
993
|
+
): Promise<{ path: string; bytes: number; width: number; height: number; format: string; hasAlpha: boolean } | undefined> {
|
|
919
994
|
const candidate = join(root, name)
|
|
920
995
|
let info
|
|
921
996
|
try {
|
|
@@ -939,7 +1014,7 @@ export class VisionToolkitRuntime {
|
|
|
939
1014
|
}
|
|
940
1015
|
const digest = createHash('sha256').update(bytes).digest('hex')
|
|
941
1016
|
if (bytes.length !== info.size || !digest.startsWith(expectedOutDigestPrefix)) return undefined
|
|
942
|
-
let probed: { width: number; height: number; format: string } | undefined
|
|
1017
|
+
let probed: { width: number; height: number; format: string; mode: string; hasAlpha: boolean } | undefined
|
|
943
1018
|
try {
|
|
944
1019
|
probed = await this.adapter.probeImageSize(real, { signal: operation.signal })
|
|
945
1020
|
} catch {
|
|
@@ -953,7 +1028,7 @@ export class VisionToolkitRuntime {
|
|
|
953
1028
|
) {
|
|
954
1029
|
return undefined
|
|
955
1030
|
}
|
|
956
|
-
return { path: real, bytes: bytes.length, width: probed.width, height: probed.height, format: probed.format }
|
|
1031
|
+
return { path: real, bytes: bytes.length, width: probed.width, height: probed.height, format: probed.format, hasAlpha: probed.hasAlpha }
|
|
957
1032
|
}
|
|
958
1033
|
|
|
959
1034
|
private cacheEntryOutDigest(entry: string, prefix: string): string | undefined {
|
|
@@ -1016,6 +1091,8 @@ export class VisionToolkitRuntime {
|
|
|
1016
1091
|
image: { path: string; bytes: number },
|
|
1017
1092
|
policy: PathPolicy,
|
|
1018
1093
|
operation: OperationContext,
|
|
1094
|
+
maxBytes: number,
|
|
1095
|
+
maxPixels: number,
|
|
1019
1096
|
): Promise<ImageInfo> {
|
|
1020
1097
|
let bytes: Buffer
|
|
1021
1098
|
try {
|
|
@@ -1029,7 +1106,7 @@ export class VisionToolkitRuntime {
|
|
|
1029
1106
|
const digest = createHash('sha256').update(bytes).digest('hex').slice(0, COMPRESSED_IMAGE_CACHE_KEY_DIGEST_LENGTH)
|
|
1030
1107
|
const root = await this.compressedImageRoot(policy)
|
|
1031
1108
|
await this.pruneCompressedCache(root)
|
|
1032
|
-
const prefix = `${COMPRESSED_IMAGE_CACHE_VERSION}-${digest}-b${
|
|
1109
|
+
const prefix = `${COMPRESSED_IMAGE_CACHE_VERSION}-${digest}-b${maxBytes}-p${maxPixels}`
|
|
1033
1110
|
for (const entry of await readdir(root)) {
|
|
1034
1111
|
if (!entry.startsWith(`${prefix}-`) || entry.startsWith('.')) continue
|
|
1035
1112
|
const outDigestPrefix = this.cacheEntryOutDigest(entry, prefix)
|
|
@@ -1037,14 +1114,7 @@ export class VisionToolkitRuntime {
|
|
|
1037
1114
|
await rm(join(root, entry), { force: true }).catch(() => {})
|
|
1038
1115
|
continue
|
|
1039
1116
|
}
|
|
1040
|
-
const cached = await this.readCacheCandidate(
|
|
1041
|
-
root,
|
|
1042
|
-
entry,
|
|
1043
|
-
outDigestPrefix,
|
|
1044
|
-
this.config.maxImageBytes,
|
|
1045
|
-
this.config.maxImagePixels,
|
|
1046
|
-
operation,
|
|
1047
|
-
)
|
|
1117
|
+
const cached = await this.readCacheCandidate(root, entry, outDigestPrefix, maxBytes, maxPixels, operation)
|
|
1048
1118
|
if (cached !== undefined) {
|
|
1049
1119
|
return { ...cached, originalPath: image.path }
|
|
1050
1120
|
}
|
|
@@ -1053,13 +1123,7 @@ export class VisionToolkitRuntime {
|
|
|
1053
1123
|
const staged = join(root, `.${prefix}-${randomUUID()}.partial`)
|
|
1054
1124
|
let compressed: CompressedImageInfo
|
|
1055
1125
|
try {
|
|
1056
|
-
compressed = await this.adapter.compressImage(
|
|
1057
|
-
image.path,
|
|
1058
|
-
staged,
|
|
1059
|
-
this.config.maxImageBytes,
|
|
1060
|
-
this.config.maxImagePixels,
|
|
1061
|
-
{ signal: operation.signal },
|
|
1062
|
-
)
|
|
1126
|
+
compressed = await this.adapter.compressImage(image.path, staged, maxBytes, maxPixels, { signal: operation.signal })
|
|
1063
1127
|
} catch (error) {
|
|
1064
1128
|
await rm(staged, { force: true }).catch(() => {})
|
|
1065
1129
|
throw error
|
|
@@ -1069,14 +1133,7 @@ export class VisionToolkitRuntime {
|
|
|
1069
1133
|
const outDigest = createHash('sha256').update(stagedBytes).digest('hex').slice(0, COMPRESSED_IMAGE_CACHE_KEY_DIGEST_LENGTH)
|
|
1070
1134
|
const finalName = `${prefix}-${outDigest}-${compressed.width}x${compressed.height}.${extension}`
|
|
1071
1135
|
const finalPath = join(root, finalName)
|
|
1072
|
-
const existing = await this.readCacheCandidate(
|
|
1073
|
-
root,
|
|
1074
|
-
finalName,
|
|
1075
|
-
outDigest,
|
|
1076
|
-
this.config.maxImageBytes,
|
|
1077
|
-
this.config.maxImagePixels,
|
|
1078
|
-
operation,
|
|
1079
|
-
)
|
|
1136
|
+
const existing = await this.readCacheCandidate(root, finalName, outDigest, maxBytes, maxPixels, operation)
|
|
1080
1137
|
if (existing !== undefined) {
|
|
1081
1138
|
await rm(staged, { force: true }).catch(() => {})
|
|
1082
1139
|
return { ...existing, originalPath: image.path }
|
|
@@ -1095,10 +1152,12 @@ export class VisionToolkitRuntime {
|
|
|
1095
1152
|
width: compressed.width,
|
|
1096
1153
|
height: compressed.height,
|
|
1097
1154
|
format: compressed.format,
|
|
1155
|
+
hasAlpha: compressed.hasAlpha,
|
|
1098
1156
|
originalPath: image.path,
|
|
1099
1157
|
}
|
|
1100
1158
|
}
|
|
1101
1159
|
|
|
1160
|
+
/** Validate one image against the configured global limits (used by local tools). */
|
|
1102
1161
|
private async validateImage(raw: string, policy: PathPolicy, operation: OperationContext): Promise<ImageInfo> {
|
|
1103
1162
|
const image = await resolveInputFile(raw, policy)
|
|
1104
1163
|
const decoded = await this.adapter.probeImageSize(image.path, { signal: operation.signal })
|
|
@@ -1112,9 +1171,9 @@ export class VisionToolkitRuntime {
|
|
|
1112
1171
|
throw new VisionToolkitError('input', `image content is ${decoded.format}, but the filename uses ${extension}`)
|
|
1113
1172
|
}
|
|
1114
1173
|
if (image.bytes <= this.config.maxImageBytes && pixels <= this.config.maxImagePixels) {
|
|
1115
|
-
return { ...image, width: decoded.width, height: decoded.height, format: decoded.format, originalPath: image.path }
|
|
1174
|
+
return { ...image, width: decoded.width, height: decoded.height, format: decoded.format, hasAlpha: decoded.hasAlpha, originalPath: image.path }
|
|
1116
1175
|
}
|
|
1117
|
-
return this.autoCompressImage(image, policy, operation)
|
|
1176
|
+
return this.autoCompressImage(image, policy, operation, this.config.maxImageBytes, this.config.maxImagePixels)
|
|
1118
1177
|
}
|
|
1119
1178
|
|
|
1120
1179
|
private accountImage(image: ImageInfo, operation: OperationContext): void {
|
|
@@ -1123,10 +1182,107 @@ export class VisionToolkitRuntime {
|
|
|
1123
1182
|
operation.metrics.imagePixels += image.width * image.height
|
|
1124
1183
|
}
|
|
1125
1184
|
|
|
1185
|
+
/** Stable gate key for one provider's in-flight request cap. */
|
|
1186
|
+
private providerGate(provider: ResolvedProvider): Semaphore {
|
|
1187
|
+
const key = `${provider.baseUrl}\u0000${provider.model}\u0000${String(provider.credential)}`
|
|
1188
|
+
let gate = this.providerGates.get(key)
|
|
1189
|
+
if (gate === undefined) {
|
|
1190
|
+
gate = new Semaphore(provider.concurrency)
|
|
1191
|
+
this.providerGates.set(key, gate)
|
|
1192
|
+
}
|
|
1193
|
+
return gate
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
/**
|
|
1197
|
+
* Prepare one image for an online vision request against the enabled
|
|
1198
|
+
* provider pool. The raw image is kept when at least one enabled provider
|
|
1199
|
+
* accepts it; otherwise it is compressed once to the first (highest
|
|
1200
|
+
* priority) provider's limits so the priority route can proceed.
|
|
1201
|
+
*/
|
|
1202
|
+
private async prepareVisionImage(
|
|
1203
|
+
raw: string,
|
|
1204
|
+
providers: readonly ResolvedProvider[],
|
|
1205
|
+
policy: PathPolicy,
|
|
1206
|
+
operation: OperationContext,
|
|
1207
|
+
): Promise<ImageInfo> {
|
|
1208
|
+
const image = await resolveInputFile(raw, policy)
|
|
1209
|
+
const decoded = await this.adapter.probeImageSize(image.path, { signal: operation.signal })
|
|
1210
|
+
const pixels = decoded.width * decoded.height
|
|
1211
|
+
if (!Number.isSafeInteger(pixels) || pixels < 1) {
|
|
1212
|
+
throw new VisionToolkitError('input', `image dimensions are invalid: ${decoded.width}x${decoded.height}`)
|
|
1213
|
+
}
|
|
1214
|
+
const extension = extname(image.path).toLowerCase()
|
|
1215
|
+
const expected = FORMAT_BY_EXTENSION.get(extension)
|
|
1216
|
+
if (expected !== decoded.format) {
|
|
1217
|
+
throw new VisionToolkitError('input', `image content is ${decoded.format}, but the filename uses ${extension}`)
|
|
1218
|
+
}
|
|
1219
|
+
const fits = providers.some(provider => image.bytes <= provider.maxImageBytes && pixels <= provider.maxImagePixels)
|
|
1220
|
+
if (fits) {
|
|
1221
|
+
return { ...image, width: decoded.width, height: decoded.height, format: decoded.format, hasAlpha: decoded.hasAlpha, originalPath: image.path }
|
|
1222
|
+
}
|
|
1223
|
+
const first = providers[0]
|
|
1224
|
+
if (first === undefined) {
|
|
1225
|
+
throw new VisionToolkitError('config', 'no enabled vision provider is available for this image')
|
|
1226
|
+
}
|
|
1227
|
+
return this.autoCompressImage(image, policy, operation, first.maxImageBytes, first.maxImagePixels)
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
/**
|
|
1231
|
+
* Run one online-vision upstream command across the enabled provider pool in
|
|
1232
|
+
* priority order. A provider is skipped when its size limits or concurrency
|
|
1233
|
+
* are exhausted, retried up to its attempt count, and the next provider
|
|
1234
|
+
* takes over on failure. Throws once every provider has failed.
|
|
1235
|
+
*/
|
|
1236
|
+
private async runVisionWithFailover(
|
|
1237
|
+
tool: 'glance' | 'ground' | 'detect' | 'long_screenshot_ocr',
|
|
1238
|
+
args: readonly string[],
|
|
1239
|
+
images: readonly ImageInfo[],
|
|
1240
|
+
operation: OperationContext,
|
|
1241
|
+
pool: readonly ResolvedProviderEnv[],
|
|
1242
|
+
): Promise<UpstreamRunResult> {
|
|
1243
|
+
if (pool.length === 0) {
|
|
1244
|
+
throw new VisionToolkitError('config', 'no enabled vision provider has a resolvable credential')
|
|
1245
|
+
}
|
|
1246
|
+
let lastError: unknown
|
|
1247
|
+
let attempted = false
|
|
1248
|
+
for (const { provider, env } of pool) {
|
|
1249
|
+
const fits = images.every(image => image.bytes <= provider.maxImageBytes && image.width * image.height <= provider.maxImagePixels)
|
|
1250
|
+
if (!fits) continue
|
|
1251
|
+
const gate = this.providerGate(provider)
|
|
1252
|
+
if (!gate.tryAcquire()) continue
|
|
1253
|
+
attempted = true
|
|
1254
|
+
try {
|
|
1255
|
+
for (let attempt = 1; attempt <= provider.attempts; attempt++) {
|
|
1256
|
+
const attemptDeadline = createDeadline(operation.signal, provider.timeoutMs)
|
|
1257
|
+
try {
|
|
1258
|
+
return await this.runUpstream(tool, args, { signal: attemptDeadline.signal, metrics: operation.metrics }, env)
|
|
1259
|
+
} catch (error) {
|
|
1260
|
+
if (operation.signal.aborted) throw error
|
|
1261
|
+
const classified = error instanceof VisionToolkitError
|
|
1262
|
+
? error
|
|
1263
|
+
: new VisionToolkitError('service', `${tool}: request failed`, { cause: error })
|
|
1264
|
+
lastError = attemptDeadline.timedOut && classified.code === 'cancelled'
|
|
1265
|
+
? new VisionToolkitError('timeout', `${tool}: ${provider.name} request timed out after ${provider.timeoutMs}ms`, { cause: classified })
|
|
1266
|
+
: classified
|
|
1267
|
+
} finally {
|
|
1268
|
+
attemptDeadline.cleanup()
|
|
1269
|
+
}
|
|
1270
|
+
}
|
|
1271
|
+
} finally {
|
|
1272
|
+
gate.release()
|
|
1273
|
+
}
|
|
1274
|
+
}
|
|
1275
|
+
if (!attempted) {
|
|
1276
|
+
throw new VisionToolkitError('capacity', `${tool}: no enabled vision provider accepts the image size or has a free concurrency slot`)
|
|
1277
|
+
}
|
|
1278
|
+
if (lastError instanceof VisionToolkitError) throw lastError
|
|
1279
|
+
throw new VisionToolkitError('service', `${tool}: all vision providers failed`, { cause: lastError })
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1126
1282
|
private async glanceCacheKey(
|
|
1127
1283
|
request: GlanceRequest,
|
|
1128
1284
|
images: readonly ImageInfo[],
|
|
1129
|
-
|
|
1285
|
+
pool: readonly ResolvedProviderEnv[],
|
|
1130
1286
|
signal: AbortSignal,
|
|
1131
1287
|
): Promise<string> {
|
|
1132
1288
|
const imageFingerprints = await Promise.all(images.map(async (image) => {
|
|
@@ -1149,16 +1305,20 @@ export class VisionToolkitRuntime {
|
|
|
1149
1305
|
query: request.query ?? null,
|
|
1150
1306
|
ocr: request.ocr === true,
|
|
1151
1307
|
region: request.region ?? null,
|
|
1152
|
-
|
|
1308
|
+
language: this.config.language,
|
|
1309
|
+
providers: pool.map(({ provider, env }) => ({
|
|
1310
|
+
name: provider.name,
|
|
1153
1311
|
baseUrl: env.VISION_BASE_URL,
|
|
1154
1312
|
model: env.VISION_MODEL,
|
|
1155
1313
|
protocol: env.VISION_API_PROTOCOL,
|
|
1156
1314
|
anthropicThinking: env.VISION_ANTHROPIC_THINKING,
|
|
1157
1315
|
sslVerify: env.VISION_SSL_VERIFY ?? null,
|
|
1158
1316
|
userAgent: env.VISION_USER_AGENT,
|
|
1159
|
-
language: env.LANG,
|
|
1160
1317
|
credentialSha256: createHash('sha256').update(env.VISION_API_KEY).digest('hex'),
|
|
1161
|
-
|
|
1318
|
+
maxImageBytes: provider.maxImageBytes,
|
|
1319
|
+
maxImagePixels: provider.maxImagePixels,
|
|
1320
|
+
attempts: provider.attempts,
|
|
1321
|
+
})),
|
|
1162
1322
|
})
|
|
1163
1323
|
}
|
|
1164
1324
|
|
|
@@ -1245,7 +1405,6 @@ export class VisionToolkitRuntime {
|
|
|
1245
1405
|
private async glanceWithEnv(
|
|
1246
1406
|
request: GlanceRequest,
|
|
1247
1407
|
options: ToolCallOptions,
|
|
1248
|
-
capturedEnv?: UpstreamEnvironment,
|
|
1249
1408
|
): Promise<GlanceResult> {
|
|
1250
1409
|
return this.runOperation('vision_glance', options, async (operation) => {
|
|
1251
1410
|
if (request.images.length === 0) throw new VisionToolkitError('input', 'glance requires at least one image')
|
|
@@ -1257,10 +1416,15 @@ export class VisionToolkitRuntime {
|
|
|
1257
1416
|
}
|
|
1258
1417
|
if (request.region !== undefined) parseRegion(request.region)
|
|
1259
1418
|
const policy = await this.pathPolicy(options.workspace)
|
|
1419
|
+
const pool = await this.resolveProviderPool()
|
|
1420
|
+
if (pool.length === 0) {
|
|
1421
|
+
throw new VisionToolkitError('config', 'no enabled vision provider has a resolvable credential')
|
|
1422
|
+
}
|
|
1423
|
+
const providers = pool.map(entry => entry.provider)
|
|
1260
1424
|
const images: ImageInfo[] = []
|
|
1261
1425
|
const seen = new Set<string>()
|
|
1262
1426
|
for (const raw of request.images) {
|
|
1263
|
-
const image = await this.
|
|
1427
|
+
const image = await this.prepareVisionImage(raw, providers, policy, operation)
|
|
1264
1428
|
if (seen.has(image.path)) {
|
|
1265
1429
|
operation.metrics.cacheHits += 1
|
|
1266
1430
|
continue
|
|
@@ -1269,10 +1433,9 @@ export class VisionToolkitRuntime {
|
|
|
1269
1433
|
this.accountImage(image, operation)
|
|
1270
1434
|
images.push(image)
|
|
1271
1435
|
}
|
|
1272
|
-
const env = capturedEnv ?? await this.resolveVisionEnv()
|
|
1273
1436
|
const cacheKey = options.sessionScope === undefined
|
|
1274
1437
|
? undefined
|
|
1275
|
-
: await this.glanceCacheKey(request, images,
|
|
1438
|
+
: await this.glanceCacheKey(request, images, pool, operation.signal)
|
|
1276
1439
|
if (options.sessionScope !== undefined && cacheKey !== undefined) {
|
|
1277
1440
|
const cached = this.glanceCache.get(options.sessionScope)
|
|
1278
1441
|
if (cached?.key === cacheKey) {
|
|
@@ -1280,12 +1443,12 @@ export class VisionToolkitRuntime {
|
|
|
1280
1443
|
return cached.result
|
|
1281
1444
|
}
|
|
1282
1445
|
}
|
|
1283
|
-
const result = await this.
|
|
1446
|
+
const result = await this.runVisionWithFailover('glance', [
|
|
1284
1447
|
...images.map(image => image.path),
|
|
1285
1448
|
...(request.region !== undefined ? ['--region', request.region] : []),
|
|
1286
1449
|
...(request.ocr === true ? ['--ocr'] : []),
|
|
1287
1450
|
...(request.query !== undefined ? ['-q', request.query] : []),
|
|
1288
|
-
], operation,
|
|
1451
|
+
], images, operation, pool)
|
|
1289
1452
|
const answer = result.stdout.trim()
|
|
1290
1453
|
if (answer.length === 0) throw new VisionToolkitError('output', 'glance: vision API returned an empty description')
|
|
1291
1454
|
const value: GlanceResult = {
|
|
@@ -1327,14 +1490,17 @@ export class VisionToolkitRuntime {
|
|
|
1327
1490
|
if (request.target.trim().length === 0) throw new VisionToolkitError('input', 'target must not be empty')
|
|
1328
1491
|
if (request.region !== undefined) parseRegion(request.region)
|
|
1329
1492
|
const policy = await this.pathPolicy(options.workspace)
|
|
1330
|
-
const
|
|
1493
|
+
const pool = await this.resolveProviderPool()
|
|
1494
|
+
if (pool.length === 0) {
|
|
1495
|
+
throw new VisionToolkitError('config', 'no enabled vision provider has a resolvable credential')
|
|
1496
|
+
}
|
|
1497
|
+
const image = await this.prepareVisionImage(request.image, pool.map(entry => entry.provider), policy, operation)
|
|
1331
1498
|
this.accountImage(image, operation)
|
|
1332
|
-
const
|
|
1333
|
-
const result = await this.runUpstream(tool, [
|
|
1499
|
+
const result = await this.runVisionWithFailover(tool, [
|
|
1334
1500
|
image.path,
|
|
1335
1501
|
request.target,
|
|
1336
1502
|
...(request.region !== undefined ? ['--region', request.region] : []),
|
|
1337
|
-
], operation,
|
|
1503
|
+
], [image], operation, pool)
|
|
1338
1504
|
const elements = parseLocationOutput(result.stdout)
|
|
1339
1505
|
this.validateLocations(elements, image.width, image.height)
|
|
1340
1506
|
return { image, elements }
|
|
@@ -1665,7 +1831,13 @@ export class VisionToolkitRuntime {
|
|
|
1665
1831
|
throw new VisionToolkitError('input', 'long_screenshot_ocr.prompt must not be empty when provided')
|
|
1666
1832
|
}
|
|
1667
1833
|
const policy = await this.pathPolicy(options.workspace)
|
|
1668
|
-
const
|
|
1834
|
+
const pool = splitOnly ? [] : await this.resolveProviderPool()
|
|
1835
|
+
if (!splitOnly && pool.length === 0) {
|
|
1836
|
+
throw new VisionToolkitError('config', 'no enabled vision provider has a resolvable credential')
|
|
1837
|
+
}
|
|
1838
|
+
const image = splitOnly
|
|
1839
|
+
? await this.validateImage(request.image, policy, operation)
|
|
1840
|
+
: await this.prepareVisionImage(request.image, pool.map(entry => entry.provider), policy, operation)
|
|
1669
1841
|
this.accountImage(image, operation)
|
|
1670
1842
|
const stem = basename(image.originalPath, extname(image.originalPath))
|
|
1671
1843
|
const finalDirectory = resolveOutputDirectory(request.runName, policy, `${stem}.long-ocr`)
|
|
@@ -1680,7 +1852,7 @@ export class VisionToolkitRuntime {
|
|
|
1680
1852
|
const finalOutput = join(finalDirectory, basename(stagedOutput))
|
|
1681
1853
|
const stagedChunks = join(stagedDirectory, 'chunks')
|
|
1682
1854
|
const stagedManifest = join(stagedChunks, 'manifest.json')
|
|
1683
|
-
const
|
|
1855
|
+
const ocrArgs = [
|
|
1684
1856
|
image.path,
|
|
1685
1857
|
'--mode',
|
|
1686
1858
|
mode,
|
|
@@ -1699,7 +1871,10 @@ export class VisionToolkitRuntime {
|
|
|
1699
1871
|
String(chunkTimeoutSeconds),
|
|
1700
1872
|
...(splitOnly ? ['--split-only'] : []),
|
|
1701
1873
|
...(request.resume === true ? ['--resume'] : []),
|
|
1702
|
-
]
|
|
1874
|
+
]
|
|
1875
|
+
const result = splitOnly
|
|
1876
|
+
? await this.runUpstream('long_screenshot_ocr', ocrArgs, operation)
|
|
1877
|
+
: await this.runVisionWithFailover('long_screenshot_ocr', ocrArgs, [image], operation, pool)
|
|
1703
1878
|
const reported = result.stdout.trim()
|
|
1704
1879
|
const expectedReported = splitOnly ? stagedManifest : stagedOutput
|
|
1705
1880
|
if (reported !== expectedReported) {
|
|
@@ -2030,20 +2205,8 @@ export class VisionToolkitRuntime {
|
|
|
2030
2205
|
})
|
|
2031
2206
|
}
|
|
2032
2207
|
|
|
2033
|
-
|
|
2034
|
-
|
|
2035
|
-
try {
|
|
2036
|
-
await writeFile(probe, 'ok\n', { encoding: 'utf8', flag: 'wx' })
|
|
2037
|
-
await rm(probe, { force: true })
|
|
2038
|
-
return { status: 'ok', detail: `${label} is writable: ${path}` }
|
|
2039
|
-
} catch {
|
|
2040
|
-
await rm(probe, { force: true }).catch(() => {})
|
|
2041
|
-
return { status: 'error', detail: `${label} is not writable: ${path}` }
|
|
2042
|
-
}
|
|
2043
|
-
}
|
|
2044
|
-
|
|
2045
|
-
/** Health: inspect local readiness, optionally probe `/models`, and explicitly test one real multimodal request. */
|
|
2046
|
-
async health(testConnection: boolean, options: ToolCallOptions, testModel = false): Promise<VisionToolkitHealthResult> {
|
|
2208
|
+
/** Health: inspect local readiness, and optionally probe one provider's `/models` plus one real multimodal request. */
|
|
2209
|
+
async health(testConnection: boolean, options: ToolCallOptions, testModel = false, provider?: ResolvedProvider): Promise<VisionToolkitHealthResult> {
|
|
2047
2210
|
return this.runOperation('vision_toolkit_health', options, async (operation) => {
|
|
2048
2211
|
const info = this.upstreamVersion
|
|
2049
2212
|
const python: HealthCheck = { status: 'ok', detail: `${info.pythonVersion} via ${info.python}` }
|
|
@@ -2063,108 +2226,97 @@ export class VisionToolkitRuntime {
|
|
|
2063
2226
|
if (operation.signal.aborted) throw new VisionToolkitError('cancelled', 'vision_toolkit_health: cancelled')
|
|
2064
2227
|
chrome = { status: 'error', detail: 'Chrome availability probe failed' }
|
|
2065
2228
|
}
|
|
2066
|
-
let resolvedCredential: ResolvedCredential | undefined
|
|
2067
|
-
let credential: HealthCheck
|
|
2068
|
-
try {
|
|
2069
|
-
resolvedCredential = isBuiltInFreeVisionProvider(this.config.provider)
|
|
2070
|
-
? { value: BUILT_IN_FREE_VISION_KEY, source: 'built-in' }
|
|
2071
|
-
: await this.ctx.credentials.resolve(this.config.provider.credential)
|
|
2072
|
-
credential = resolvedCredential === undefined
|
|
2073
|
-
? { status: 'error', detail: `credential ${this.config.provider.credential} is not configured` }
|
|
2074
|
-
: { status: 'ok', detail: `credential ${this.config.provider.credential} is resolvable` }
|
|
2075
|
-
} catch {
|
|
2076
|
-
credential = { status: 'error', detail: `credential ${this.config.provider.credential} could not be resolved` }
|
|
2077
|
-
}
|
|
2078
|
-
let artifactDirectory: HealthCheck
|
|
2079
|
-
try {
|
|
2080
|
-
// allowedDirs are session input roots; they do not affect output readiness.
|
|
2081
|
-
const policy = await createPathPolicy(options.workspace, [])
|
|
2082
|
-
artifactDirectory = await this.writableDirectoryCheck(policy.outputDir, 'Artifact directory')
|
|
2083
|
-
} catch {
|
|
2084
|
-
artifactDirectory = { status: 'error', detail: 'Artifact directory could not be prepared' }
|
|
2085
|
-
}
|
|
2086
|
-
const tempDirectory = await this.writableDirectoryCheck(info.runtimeHome, 'Runtime temp directory')
|
|
2087
2229
|
let service: HealthCheck = {
|
|
2088
2230
|
status: 'not_tested',
|
|
2089
|
-
detail: 'Connection was not tested;
|
|
2231
|
+
detail: 'Connection was not tested; use the per-provider API test',
|
|
2090
2232
|
}
|
|
2091
2233
|
let model: HealthCheck = {
|
|
2092
2234
|
status: 'not_tested',
|
|
2093
|
-
detail: 'Vision model was not tested;
|
|
2235
|
+
detail: 'Vision model was not tested; use the per-provider model test',
|
|
2094
2236
|
}
|
|
2095
|
-
|
|
2096
|
-
|
|
2097
|
-
|
|
2098
|
-
|
|
2099
|
-
|
|
2100
|
-
|
|
2101
|
-
|
|
2102
|
-
|
|
2103
|
-
|
|
2104
|
-
Accept: 'application/json',
|
|
2105
|
-
'User-Agent': this.config.provider.userAgent,
|
|
2106
|
-
}
|
|
2107
|
-
if (this.config.provider.protocol === 'anthropic') {
|
|
2108
|
-
headers['x-api-key'] = resolvedCredential.value
|
|
2109
|
-
headers['anthropic-version'] = '2023-06-01'
|
|
2110
|
-
} else {
|
|
2111
|
-
headers.Authorization = `Bearer ${resolvedCredential.value}`
|
|
2112
|
-
}
|
|
2113
|
-
const response = await fetch(endpoint, {
|
|
2114
|
-
method: 'GET',
|
|
2115
|
-
headers,
|
|
2116
|
-
signal: operation.signal,
|
|
2117
|
-
})
|
|
2118
|
-
operation.metrics.upstreamMs += Date.now() - started
|
|
2119
|
-
await response.body?.cancel().catch(() => {})
|
|
2120
|
-
if (response.ok) {
|
|
2121
|
-
service = { status: 'ok', detail: `Service responded at ${endpoint} (HTTP ${response.status})` }
|
|
2122
|
-
} else if (response.status === 401) {
|
|
2123
|
-
service = { status: 'error', detail: `Service rejected the configured credential (HTTP ${response.status})` }
|
|
2124
|
-
} else if (response.status === 403) {
|
|
2125
|
-
// Some providers (e.g. Groq preview/account restrictions) block GET /models
|
|
2126
|
-
// while real multimodal requests still work. Treat 403 as a warning so the
|
|
2127
|
-
// explicit vision-model test, not the model list endpoint, decides access.
|
|
2128
|
-
service = { status: 'warning', detail: `Service is reachable but restricted GET /models (HTTP 403); the credential may still be valid for real vision requests` }
|
|
2129
|
-
} else if (response.status === 404 || response.status === 405) {
|
|
2130
|
-
service = { status: 'warning', detail: `Service is reachable but does not expose GET /models (HTTP ${response.status})` }
|
|
2131
|
-
} else if (response.status === 429) {
|
|
2132
|
-
service = { status: 'warning', detail: 'Service is reachable but rate-limited the connection test (HTTP 429)' }
|
|
2133
|
-
} else {
|
|
2134
|
-
service = { status: 'error', detail: `Service connection test failed with HTTP ${response.status}` }
|
|
2135
|
-
}
|
|
2136
|
-
} catch {
|
|
2137
|
-
if (operation.signal.aborted) throw new VisionToolkitError('cancelled', 'vision_toolkit_health: connection test cancelled')
|
|
2138
|
-
service = { status: 'error', detail: `Service could not be reached at ${endpoint}` }
|
|
2237
|
+
const target = provider ?? (testConnection || testModel ? this.primaryProvider : undefined)
|
|
2238
|
+
if (target !== undefined && (testConnection || testModel)) {
|
|
2239
|
+
const entry = await this.resolveProviderEnv(target)
|
|
2240
|
+
if (entry === undefined) {
|
|
2241
|
+
if (testConnection) {
|
|
2242
|
+
service = { status: 'error', detail: `Connection test skipped because credential ${String(target.credential)} is unavailable` }
|
|
2243
|
+
}
|
|
2244
|
+
if (testModel) {
|
|
2245
|
+
model = { status: 'error', detail: `Vision model test skipped because credential ${String(target.credential)} is unavailable` }
|
|
2139
2246
|
}
|
|
2140
|
-
}
|
|
2141
|
-
}
|
|
2142
|
-
if (testModel) {
|
|
2143
|
-
if (resolvedCredential === undefined) {
|
|
2144
|
-
model = { status: 'error', detail: 'Vision model test skipped because the configured credential is unavailable' }
|
|
2145
2247
|
} else {
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
|
|
2248
|
+
if (testConnection) {
|
|
2249
|
+
operation.metrics.usedVisionService = true
|
|
2250
|
+
const endpoint = `${target.baseUrl}/models`
|
|
2251
|
+
try {
|
|
2252
|
+
const started = Date.now()
|
|
2253
|
+
const headers: Record<string, string> = {
|
|
2254
|
+
Accept: 'application/json',
|
|
2255
|
+
'User-Agent': target.userAgent,
|
|
2256
|
+
}
|
|
2257
|
+
if (target.protocol === 'anthropic') {
|
|
2258
|
+
headers['x-api-key'] = entry.env.VISION_API_KEY
|
|
2259
|
+
headers['anthropic-version'] = '2023-06-01'
|
|
2260
|
+
} else {
|
|
2261
|
+
headers.Authorization = `Bearer ${entry.env.VISION_API_KEY}`
|
|
2262
|
+
}
|
|
2263
|
+
const response = await fetch(endpoint, {
|
|
2264
|
+
method: 'GET',
|
|
2265
|
+
headers,
|
|
2266
|
+
signal: operation.signal,
|
|
2267
|
+
})
|
|
2268
|
+
operation.metrics.upstreamMs += Date.now() - started
|
|
2269
|
+
await response.body?.cancel().catch(() => {})
|
|
2270
|
+
if (response.ok) {
|
|
2271
|
+
service = { status: 'ok', detail: `Service responded at ${endpoint} (HTTP ${response.status})` }
|
|
2272
|
+
} else if (response.status === 401) {
|
|
2273
|
+
service = { status: 'error', detail: `Service rejected the configured credential (HTTP ${response.status})` }
|
|
2274
|
+
} else if (response.status === 403) {
|
|
2275
|
+
// Some providers (e.g. Groq preview/account restrictions) block GET /models
|
|
2276
|
+
// while real multimodal requests still work. Treat 403 as a warning so the
|
|
2277
|
+
// explicit vision-model test, not the model list endpoint, decides access.
|
|
2278
|
+
service = { status: 'warning', detail: `Service is reachable but restricted GET /models (HTTP 403); the credential may still be valid for real vision requests` }
|
|
2279
|
+
} else if (response.status === 404 || response.status === 405) {
|
|
2280
|
+
service = { status: 'warning', detail: `Service is reachable but does not expose GET /models (HTTP ${response.status})` }
|
|
2281
|
+
} else if (response.status === 429) {
|
|
2282
|
+
service = { status: 'warning', detail: 'Service is reachable but rate-limited the connection test (HTTP 429)' }
|
|
2283
|
+
} else {
|
|
2284
|
+
service = { status: 'error', detail: `Service connection test failed with HTTP ${response.status}` }
|
|
2285
|
+
}
|
|
2286
|
+
} catch {
|
|
2287
|
+
if (operation.signal.aborted) throw new VisionToolkitError('cancelled', 'vision_toolkit_health: connection test cancelled')
|
|
2288
|
+
service = { status: 'error', detail: `Service could not be reached at ${endpoint}` }
|
|
2155
2289
|
}
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
2290
|
+
}
|
|
2291
|
+
if (testModel) {
|
|
2292
|
+
try {
|
|
2293
|
+
const attemptDeadline = createDeadline(operation.signal, target.timeoutMs)
|
|
2294
|
+
try {
|
|
2295
|
+
const result = await this.runUpstream(
|
|
2296
|
+
'glance',
|
|
2297
|
+
[VISION_MODEL_TEST_IMAGE, '-q', VISION_MODEL_TEST_PROMPT],
|
|
2298
|
+
{ signal: attemptDeadline.signal, metrics: operation.metrics },
|
|
2299
|
+
entry.env,
|
|
2300
|
+
)
|
|
2301
|
+
if (result.stdout.trim().length === 0) {
|
|
2302
|
+
throw new VisionToolkitError('output', 'glance: vision API returned an empty description')
|
|
2303
|
+
}
|
|
2304
|
+
model = {
|
|
2305
|
+
status: 'ok',
|
|
2306
|
+
detail: `Vision model ${target.model} completed a multimodal request`,
|
|
2307
|
+
}
|
|
2308
|
+
} finally {
|
|
2309
|
+
attemptDeadline.cleanup()
|
|
2310
|
+
}
|
|
2311
|
+
} catch (error) {
|
|
2312
|
+
if (operation.signal.aborted) throw error
|
|
2313
|
+
const detail = error instanceof Error ? error.message : String(error)
|
|
2314
|
+
model = { status: 'error', detail: `Vision model test failed: ${detail.slice(0, 600)}` }
|
|
2159
2315
|
}
|
|
2160
|
-
} catch (error) {
|
|
2161
|
-
if (operation.signal.aborted) throw error
|
|
2162
|
-
const detail = error instanceof Error ? error.message : String(error)
|
|
2163
|
-
model = { status: 'error', detail: `Vision model test failed: ${detail.slice(0, 600)}` }
|
|
2164
2316
|
}
|
|
2165
2317
|
}
|
|
2166
2318
|
}
|
|
2167
|
-
const checks = { python, dependencies, chrome,
|
|
2319
|
+
const checks = { python, dependencies, chrome, service, model }
|
|
2168
2320
|
const healthy = Object.values(checks).every(check => check.status !== 'error')
|
|
2169
2321
|
return {
|
|
2170
2322
|
pluginVersion: PLUGIN_VERSION,
|
|
@@ -2173,6 +2325,7 @@ export class VisionToolkitRuntime {
|
|
|
2173
2325
|
healthy,
|
|
2174
2326
|
connectionTested: testConnection,
|
|
2175
2327
|
modelTested: testModel,
|
|
2328
|
+
...(provider === undefined ? {} : { providerName: provider.name }),
|
|
2176
2329
|
}
|
|
2177
2330
|
})
|
|
2178
2331
|
}
|