@anionex/dsh-vision-toolkit 0.1.37 → 0.1.39
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 +13 -42
- package/README.zh.md +12 -41
- package/lib/evidence-cache.js +350 -0
- package/lib/evidence-cache.js.map +1 -0
- package/lib/image-input-variants.js +49 -81
- package/lib/image-input-variants.js.map +1 -1
- package/lib/runtime-install.js +57 -15
- package/lib/runtime-install.js.map +1 -1
- package/lib/runtime.js +18 -1
- package/lib/runtime.js.map +1 -1
- package/lib/types/evidence-cache.d.ts +91 -0
- package/lib/types/evidence-cache.d.ts.map +1 -0
- package/lib/types/image-input-variants.d.ts +8 -26
- package/lib/types/image-input-variants.d.ts.map +1 -1
- package/lib/types/runtime-install.d.ts +13 -0
- package/lib/types/runtime-install.d.ts.map +1 -1
- package/lib/types/runtime.d.ts +10 -0
- package/lib/types/runtime.d.ts.map +1 -1
- package/lib/types/upstream.d.ts.map +1 -1
- package/lib/upstream.js +10 -0
- package/lib/upstream.js.map +1 -1
- package/package.json +4 -2
- package/src/evidence-cache.ts +418 -0
- package/src/image-input-variants.ts +58 -89
- package/src/runtime-install.ts +63 -15
- package/src/runtime.ts +35 -1
- package/src/upstream.ts +10 -0
package/src/runtime-install.ts
CHANGED
|
@@ -88,6 +88,8 @@ const PYPI_MIRROR_BASE_URL = 'https://mirrors.cloud.tencent.com/pypi/simple'
|
|
|
88
88
|
const PROBE_TIMEOUT_MS = 30_000
|
|
89
89
|
const LOCK_STALE_MS = 15 * 60 * 1000
|
|
90
90
|
const LOCK_HEARTBEAT_MS = 5_000
|
|
91
|
+
const WINDOWS_FILE_RETRY_ATTEMPTS = 5
|
|
92
|
+
const WINDOWS_FILE_RETRY_DELAY_MS = 250
|
|
91
93
|
|
|
92
94
|
/** Absolute root of the packaged upstream snapshot. */
|
|
93
95
|
export function bundledUpstreamRoot(): string {
|
|
@@ -103,6 +105,47 @@ function sha256(bytes: string | Buffer): string {
|
|
|
103
105
|
return createHash('sha256').update(bytes).digest('hex')
|
|
104
106
|
}
|
|
105
107
|
|
|
108
|
+
/**
|
|
109
|
+
* Windows Defender/antivirus real-time scanning briefly locks freshly written
|
|
110
|
+
* Python DLLs, so recursive removal and directory replacement can fail with
|
|
111
|
+
* EBUSY/EPERM immediately after installation. Retry those transient Windows
|
|
112
|
+
* errors before surfacing them; non-Windows platforms pass through unchanged.
|
|
113
|
+
*/
|
|
114
|
+
export async function withWindowsTransientRetry<T>(operation: () => Promise<T>): Promise<T> {
|
|
115
|
+
let lastError: unknown
|
|
116
|
+
for (let attempt = 1; attempt <= WINDOWS_FILE_RETRY_ATTEMPTS; attempt += 1) {
|
|
117
|
+
try {
|
|
118
|
+
return await operation()
|
|
119
|
+
} catch (error) {
|
|
120
|
+
lastError = error
|
|
121
|
+
const code = error instanceof Error ? (error as NodeJS.ErrnoException).code : undefined
|
|
122
|
+
const transient = process.platform === 'win32' && (code === 'EBUSY' || code === 'EPERM' || code === 'EACCES')
|
|
123
|
+
if (!transient) break
|
|
124
|
+
if (attempt < WINDOWS_FILE_RETRY_ATTEMPTS) {
|
|
125
|
+
await new Promise(resolveWait => setTimeout(resolveWait, WINDOWS_FILE_RETRY_DELAY_MS * attempt))
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
throw lastError
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Best-effort removal used after the primary runtime path has already
|
|
134
|
+
* succeeded or failed. Transient Windows locks must not turn a usable runtime
|
|
135
|
+
* into an error, but leaving the directory behind should still be audible.
|
|
136
|
+
*/
|
|
137
|
+
export async function ignoreCleanupFailure(ctx: Context, label: string, path: string): Promise<void> {
|
|
138
|
+
try {
|
|
139
|
+
await withWindowsTransientRetry(() => rm(path, { recursive: true, force: true }))
|
|
140
|
+
} catch (error) {
|
|
141
|
+
ctx.logger.warn(
|
|
142
|
+
'dsh-vision-toolkit: %s cleanup failed: %s',
|
|
143
|
+
label,
|
|
144
|
+
error instanceof Error ? error.message : String(error),
|
|
145
|
+
)
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
106
149
|
export function isolatedPythonEnvironment(home: string): NodeJS.ProcessEnv {
|
|
107
150
|
return {
|
|
108
151
|
HOME: home,
|
|
@@ -488,7 +531,7 @@ async function withDirectoryLock<T>(lockPath: string, fn: () => Promise<T>): Pro
|
|
|
488
531
|
await writeFile(join(lockPath, 'owner'), `${owner}\n`, { flag: 'wx' })
|
|
489
532
|
} catch (error) {
|
|
490
533
|
if (acquired) {
|
|
491
|
-
await rm(lockPath, { recursive: true, force: true }).catch(() => {})
|
|
534
|
+
await withWindowsTransientRetry(() => rm(lockPath, { recursive: true, force: true })).catch(() => {})
|
|
492
535
|
throw error
|
|
493
536
|
}
|
|
494
537
|
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error
|
|
@@ -497,7 +540,7 @@ async function withDirectoryLock<T>(lockPath: string, fn: () => Promise<T>): Pro
|
|
|
497
540
|
try {
|
|
498
541
|
const info = await stat(lockPath)
|
|
499
542
|
if (Date.now() - info.mtimeMs > LOCK_STALE_MS) {
|
|
500
|
-
await rm(lockPath, { recursive: true, force: true })
|
|
543
|
+
await withWindowsTransientRetry(() => rm(lockPath, { recursive: true, force: true }))
|
|
501
544
|
return withDirectoryLock(lockPath, fn)
|
|
502
545
|
}
|
|
503
546
|
} catch {
|
|
@@ -518,7 +561,7 @@ async function withDirectoryLock<T>(lockPath: string, fn: () => Promise<T>): Pro
|
|
|
518
561
|
clearInterval(heartbeat)
|
|
519
562
|
try {
|
|
520
563
|
if ((await readFile(join(lockPath, 'owner'), 'utf8')).trim() === owner) {
|
|
521
|
-
await rm(lockPath, { recursive: true, force: true })
|
|
564
|
+
await withWindowsTransientRetry(() => rm(lockPath, { recursive: true, force: true }))
|
|
522
565
|
}
|
|
523
566
|
} catch {
|
|
524
567
|
// The lock was already removed or replaced.
|
|
@@ -552,7 +595,7 @@ export async function acquireBundledPython(
|
|
|
552
595
|
if (ready !== undefined) return
|
|
553
596
|
const parent = dirname(root)
|
|
554
597
|
await mkdir(parent, { recursive: true })
|
|
555
|
-
await rm(root, { recursive: true, force: true })
|
|
598
|
+
await withWindowsTransientRetry(() => rm(root, { recursive: true, force: true }))
|
|
556
599
|
const work = await mkdtemp(join(parent, '.python-bootstrap-'))
|
|
557
600
|
try {
|
|
558
601
|
const archive = join(work, 'python.tar.gz')
|
|
@@ -589,9 +632,9 @@ export async function acquireBundledPython(
|
|
|
589
632
|
)
|
|
590
633
|
}
|
|
591
634
|
if (process.platform !== 'win32') await chmod(extractedInterpreter, 0o755)
|
|
592
|
-
await rename(extractDir, root)
|
|
635
|
+
await withWindowsTransientRetry(() => rename(extractDir, root))
|
|
593
636
|
} finally {
|
|
594
|
-
await
|
|
637
|
+
await ignoreCleanupFailure(ctx, 'bundled Python staging', work)
|
|
595
638
|
}
|
|
596
639
|
})
|
|
597
640
|
const metadata = await pythonMetadata(ctx, command, cwd)
|
|
@@ -827,7 +870,7 @@ async function waitForManagedRuntime(
|
|
|
827
870
|
try {
|
|
828
871
|
const info = await stat(lockPath)
|
|
829
872
|
if (Date.now() - info.mtimeMs > LOCK_STALE_MS) {
|
|
830
|
-
await rm(lockPath, { recursive: true, force: true })
|
|
873
|
+
await withWindowsTransientRetry(() => rm(lockPath, { recursive: true, force: true }))
|
|
831
874
|
return undefined
|
|
832
875
|
}
|
|
833
876
|
} catch {
|
|
@@ -844,7 +887,7 @@ async function releaseManagedLock(lockPath: string, owner: string): Promise<void
|
|
|
844
887
|
} catch {
|
|
845
888
|
return
|
|
846
889
|
}
|
|
847
|
-
await rm(lockPath, { recursive: true, force: true })
|
|
890
|
+
await withWindowsTransientRetry(() => rm(lockPath, { recursive: true, force: true }))
|
|
848
891
|
}
|
|
849
892
|
|
|
850
893
|
async function prepareManaged(
|
|
@@ -899,7 +942,7 @@ async function prepareManaged(
|
|
|
899
942
|
await writeFile(join(lockPath, 'owner'), `${lockOwner}\n`, { flag: 'wx' })
|
|
900
943
|
} catch (error) {
|
|
901
944
|
if (lockAcquired) {
|
|
902
|
-
await rm(lockPath, { recursive: true, force: true })
|
|
945
|
+
await withWindowsTransientRetry(() => rm(lockPath, { recursive: true, force: true })).catch(() => {})
|
|
903
946
|
throw error
|
|
904
947
|
}
|
|
905
948
|
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error
|
|
@@ -1016,17 +1059,17 @@ async function prepareManaged(
|
|
|
1016
1059
|
const quarantine = `${finalRoot}.replaced-${randomUUID()}`
|
|
1017
1060
|
let quarantined = false
|
|
1018
1061
|
try {
|
|
1019
|
-
await rename(finalRoot, quarantine)
|
|
1062
|
+
await withWindowsTransientRetry(() => rename(finalRoot, quarantine))
|
|
1020
1063
|
quarantined = true
|
|
1021
1064
|
} catch (error) {
|
|
1022
1065
|
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
|
1023
1066
|
}
|
|
1024
1067
|
try {
|
|
1025
|
-
await rename(staging, finalRoot)
|
|
1068
|
+
await withWindowsTransientRetry(() => rename(staging, finalRoot))
|
|
1026
1069
|
} catch (error) {
|
|
1027
1070
|
if (quarantined) {
|
|
1028
1071
|
try {
|
|
1029
|
-
await rename(quarantine, finalRoot)
|
|
1072
|
+
await withWindowsTransientRetry(() => rename(quarantine, finalRoot))
|
|
1030
1073
|
} catch (restoreError) {
|
|
1031
1074
|
throw new VisionToolkitError(
|
|
1032
1075
|
'runtime',
|
|
@@ -1037,13 +1080,18 @@ async function prepareManaged(
|
|
|
1037
1080
|
}
|
|
1038
1081
|
throw error
|
|
1039
1082
|
}
|
|
1040
|
-
await
|
|
1083
|
+
await ignoreCleanupFailure(ctx, 'managed runtime quarantine', quarantine)
|
|
1041
1084
|
const python: RuntimeCommand = { program: interpreter, prefix: [], display: interpreter }
|
|
1042
1085
|
return { source: 'managed', root: BUNDLED_ROOT, python, cleanHome, pythonVersion: metadata.version, dependencies }
|
|
1043
1086
|
} finally {
|
|
1044
1087
|
clearInterval(heartbeat)
|
|
1045
|
-
await
|
|
1046
|
-
await releaseManagedLock(lockPath, lockOwner)
|
|
1088
|
+
await ignoreCleanupFailure(ctx, 'managed runtime staging', staging)
|
|
1089
|
+
await releaseManagedLock(lockPath, lockOwner).catch(error => {
|
|
1090
|
+
ctx.logger.warn(
|
|
1091
|
+
'dsh-vision-toolkit: managed runtime lock cleanup failed: %s',
|
|
1092
|
+
error instanceof Error ? error.message : String(error),
|
|
1093
|
+
)
|
|
1094
|
+
})
|
|
1047
1095
|
}
|
|
1048
1096
|
}
|
|
1049
1097
|
|
package/src/runtime.ts
CHANGED
|
@@ -16,6 +16,7 @@ import { SaxesParser } from 'saxes'
|
|
|
16
16
|
import { describeArtifact, type ArtifactDescriptor } from './artifacts.ts'
|
|
17
17
|
import { isBuiltInFreeVisionProvider, type ResolvedVisionToolkitConfig } from './config.ts'
|
|
18
18
|
import { BUILT_IN_FREE_VISION_KEY } from './defaults.ts'
|
|
19
|
+
import { evidenceRuntimeFingerprint } from './evidence-cache.ts'
|
|
19
20
|
import { VisionToolkitError } from './errors.ts'
|
|
20
21
|
import {
|
|
21
22
|
assertDistinctOutput,
|
|
@@ -480,6 +481,12 @@ export interface ToolCallOptions {
|
|
|
480
481
|
sessionScope?: object
|
|
481
482
|
}
|
|
482
483
|
|
|
484
|
+
/** One immutable vision-service snapshot used to generate cache-keyed evidence. */
|
|
485
|
+
export interface CapturedEvidenceRuntime {
|
|
486
|
+
readonly evidenceFingerprint: string
|
|
487
|
+
glance(request: GlanceRequest, options: ToolCallOptions): Promise<GlanceResult>
|
|
488
|
+
}
|
|
489
|
+
|
|
483
490
|
interface GlanceCacheEntry {
|
|
484
491
|
key: string
|
|
485
492
|
result: GlanceResult
|
|
@@ -698,6 +705,25 @@ export class VisionToolkitRuntime {
|
|
|
698
705
|
return this.adapter.versionInfo
|
|
699
706
|
}
|
|
700
707
|
|
|
708
|
+
/** Stable identity for persisted image descriptions produced by this runtime. */
|
|
709
|
+
get evidenceFingerprint(): string {
|
|
710
|
+
return evidenceRuntimeFingerprint(this.config, undefined, process.env.VISION_SSL_VERIFY?.trim())
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
/** Capture the credential and provider identity used by one evidence conversion. */
|
|
714
|
+
async captureEvidenceRuntime(): Promise<CapturedEvidenceRuntime> {
|
|
715
|
+
const env = await this.resolveVisionEnv()
|
|
716
|
+
const evidenceFingerprint = evidenceRuntimeFingerprint(
|
|
717
|
+
this.config,
|
|
718
|
+
createHash('sha256').update(env.VISION_API_KEY).digest('hex'),
|
|
719
|
+
env.VISION_SSL_VERIFY,
|
|
720
|
+
)
|
|
721
|
+
return Object.freeze({
|
|
722
|
+
evidenceFingerprint,
|
|
723
|
+
glance: (request: GlanceRequest, options: ToolCallOptions) => this.glanceWithEnv(request, options, env),
|
|
724
|
+
})
|
|
725
|
+
}
|
|
726
|
+
|
|
701
727
|
private timeout(options: ToolCallOptions): number {
|
|
702
728
|
const value = options.timeoutMs ?? this.config.timeoutMs
|
|
703
729
|
if (!Number.isInteger(value) || value < 1000 || value > MAX_TIMEOUT_MS) {
|
|
@@ -1213,6 +1239,14 @@ export class VisionToolkitRuntime {
|
|
|
1213
1239
|
|
|
1214
1240
|
/** glance: describe, targeted QA, OCR, or multi-image comparison. */
|
|
1215
1241
|
async glance(request: GlanceRequest, options: ToolCallOptions): Promise<GlanceResult> {
|
|
1242
|
+
return this.glanceWithEnv(request, options)
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
private async glanceWithEnv(
|
|
1246
|
+
request: GlanceRequest,
|
|
1247
|
+
options: ToolCallOptions,
|
|
1248
|
+
capturedEnv?: UpstreamEnvironment,
|
|
1249
|
+
): Promise<GlanceResult> {
|
|
1216
1250
|
return this.runOperation('vision_glance', options, async (operation) => {
|
|
1217
1251
|
if (request.images.length === 0) throw new VisionToolkitError('input', 'glance requires at least one image')
|
|
1218
1252
|
if (request.query !== undefined && request.ocr === true) {
|
|
@@ -1235,7 +1269,7 @@ export class VisionToolkitRuntime {
|
|
|
1235
1269
|
this.accountImage(image, operation)
|
|
1236
1270
|
images.push(image)
|
|
1237
1271
|
}
|
|
1238
|
-
const env = await this.resolveVisionEnv()
|
|
1272
|
+
const env = capturedEnv ?? await this.resolveVisionEnv()
|
|
1239
1273
|
const cacheKey = options.sessionScope === undefined
|
|
1240
1274
|
? undefined
|
|
1241
1275
|
: await this.glanceCacheKey(request, images, env, operation.signal)
|
package/src/upstream.ts
CHANGED
|
@@ -541,9 +541,19 @@ const VISION_MODEL_GUARD = [
|
|
|
541
541
|
' requested=prompt or vision_client.DEFAULT_PROMPT',
|
|
542
542
|
' return original_describe(image_url,f"{policy}\\n\\n{requested}",*args,**kwargs)',
|
|
543
543
|
' vision_client.describe_image=guarded_describe',
|
|
544
|
+
' ground_module=None',
|
|
545
|
+
' original_parse_matches=None',
|
|
546
|
+
' if Path(script).name in {"ground","detect"} and importlib.util.find_spec("ground") is not None:',
|
|
547
|
+
' import ground as ground_module',
|
|
548
|
+
' original_parse_matches=ground_module.parse_matches',
|
|
549
|
+
' def normalized_parse_matches(*args,**kwargs):',
|
|
550
|
+
' matches=original_parse_matches(*args,**kwargs)',
|
|
551
|
+
' return [ground_module.Match(" ".join(str(match.label).split()),match.bbox) for match in matches]',
|
|
552
|
+
' ground_module.parse_matches=normalized_parse_matches',
|
|
544
553
|
' try:',
|
|
545
554
|
' runpy.run_path(script,run_name="__main__")',
|
|
546
555
|
' finally:',
|
|
556
|
+
' if ground_module is not None: ground_module.parse_matches=original_parse_matches',
|
|
547
557
|
' vision_client.describe_image=original_describe',
|
|
548
558
|
].join('\n')
|
|
549
559
|
|