@mengruo/dsh-vision-toolkit 0.1.4 → 0.1.6-beta.0
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.md +11 -70
- package/README.zh.md +11 -69
- package/assets/1.mp4 +0 -0
- package/assets/skill/SKILL.md +37 -5
- package/docs/plan-per-tool-visibility.md +82 -0
- package/lib/client.js +157 -6
- package/lib/client.js.map +1 -1
- package/lib/config.js +52 -0
- package/lib/config.js.map +1 -1
- package/lib/exposure.js +35 -8
- package/lib/exposure.js.map +1 -1
- package/lib/index.js +1 -1
- package/lib/index.js.map +1 -1
- package/lib/object-storage.js +141 -0
- package/lib/object-storage.js.map +1 -0
- package/lib/paths.js +9 -0
- package/lib/paths.js.map +1 -1
- package/lib/runtime.js +314 -27
- package/lib/runtime.js.map +1 -1
- package/lib/tools.js +116 -4
- package/lib/tools.js.map +1 -1
- package/lib/types/client/index.d.ts +62 -1
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/config.d.ts +59 -0
- package/lib/types/config.d.ts.map +1 -1
- package/lib/types/exposure.d.ts +14 -2
- package/lib/types/exposure.d.ts.map +1 -1
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/object-storage.d.ts +54 -0
- package/lib/types/object-storage.d.ts.map +1 -0
- package/lib/types/paths.d.ts +7 -0
- package/lib/types/paths.d.ts.map +1 -1
- package/lib/types/runtime.d.ts +50 -0
- package/lib/types/runtime.d.ts.map +1 -1
- package/lib/types/tools.d.ts +22 -1
- package/lib/types/tools.d.ts.map +1 -1
- package/lib/types/upstream.d.ts +1 -0
- package/lib/types/upstream.d.ts.map +1 -1
- package/lib/types/video.d.ts +78 -0
- package/lib/types/video.d.ts.map +1 -0
- package/lib/types/web.d.ts +8 -0
- package/lib/types/web.d.ts.map +1 -1
- package/lib/upstream.js +3 -0
- package/lib/upstream.js.map +1 -1
- package/lib/video.js +169 -0
- package/lib/video.js.map +1 -0
- package/lib/web.js +78 -6
- package/lib/web.js.map +1 -1
- package/package.json +4 -1
- package/src/client/index.tsx +222 -6
- package/src/config.ts +110 -0
- package/src/exposure.ts +34 -9
- package/src/index.ts +10 -5
- package/src/object-storage.ts +174 -0
- package/src/paths.ts +11 -0
- package/src/runtime.ts +332 -25
- package/src/tools.ts +144 -3
- package/src/upstream.ts +4 -0
- package/src/video.ts +222 -0
- package/src/web.ts +93 -7
- package/vendor/agent-vision-toolkit/UPSTREAM_MANIFEST.json +11 -11
- package/vendor/agent-vision-toolkit/__pycache__/detect.cpython-314.pyc +0 -0
- package/vendor/agent-vision-toolkit/__pycache__/ground.cpython-314.pyc +0 -0
- package/vendor/agent-vision-toolkit/__pycache__/vision_client.cpython-314.pyc +0 -0
- package/vendor/agent-vision-toolkit/bin/__pycache__/glancecpython-314.pyc +0 -0
- package/vendor/agent-vision-toolkit/bin/glance +8 -1
- package/vendor/agent-vision-toolkit/detect.py +13 -7
- package/vendor/agent-vision-toolkit/ground.py +43 -18
- package/vendor/agent-vision-toolkit/tests/test_vision_client.py +88 -0
- package/vendor/agent-vision-toolkit/vision_client.py +84 -6
- package/assets/community-group-qr.png +0 -0
- package/assets/logo_aihubmix.png +0 -0
- package/assets/logo_eapi_dark.png +0 -0
- package/assets/wechat-reward.png +0 -0
package/src/runtime.ts
CHANGED
|
@@ -18,6 +18,7 @@ import { isBuiltInFreeVisionProvider, type ResolvedProvider, type ResolvedVision
|
|
|
18
18
|
import { BUILT_IN_FREE_VISION_KEY } from './defaults.ts'
|
|
19
19
|
import { evidenceRuntimeFingerprint } from './evidence-cache.ts'
|
|
20
20
|
import { VisionToolkitError, type VisionToolkitErrorCode } from './errors.ts'
|
|
21
|
+
import { ObjectStorageClient, splitObjectStorageCredential, type ObjectStorageSettings } from './object-storage.ts'
|
|
21
22
|
import {
|
|
22
23
|
assertDistinctOutput,
|
|
23
24
|
commitStagedDirectory,
|
|
@@ -28,11 +29,23 @@ import {
|
|
|
28
29
|
isWithin,
|
|
29
30
|
resolveHtmlFile,
|
|
30
31
|
resolveInputFile,
|
|
32
|
+
resolveInputVideo,
|
|
31
33
|
resolveOutputDirectory,
|
|
32
34
|
resolveOutputFile,
|
|
33
35
|
seedStagedDirectory,
|
|
34
36
|
type PathPolicy,
|
|
35
37
|
} from './paths.ts'
|
|
38
|
+
import {
|
|
39
|
+
extractChatAnswer,
|
|
40
|
+
ffprobeBinaryPath,
|
|
41
|
+
parseFfprobeOutput,
|
|
42
|
+
videoMediaType,
|
|
43
|
+
type VideoInfo,
|
|
44
|
+
type VideoInfoRequest,
|
|
45
|
+
type VideoMetadata,
|
|
46
|
+
type VideoUnderstandRequest,
|
|
47
|
+
type VideoUnderstandResult,
|
|
48
|
+
} from './video.ts'
|
|
36
49
|
import {
|
|
37
50
|
parseCropOutput,
|
|
38
51
|
parseDominantColorsOutput,
|
|
@@ -55,6 +68,8 @@ import { PLUGIN_VERSION } from './version.ts'
|
|
|
55
68
|
const SVG_NAMESPACE = 'http://www.w3.org/2000/svg'
|
|
56
69
|
const VISION_MODEL_TEST_IMAGE = fileURLToPath(new URL('../assets/vision-model-test.png', import.meta.url))
|
|
57
70
|
const VISION_MODEL_TEST_PROMPT = 'This is an explicit service readiness test. Reply with one short sentence confirming that you received the image.'
|
|
71
|
+
const VISION_MODEL_TEST_VIDEO = fileURLToPath(new URL('../assets/1.mp4', import.meta.url))
|
|
72
|
+
const VISION_MODEL_TEST_VIDEO_PROMPT = '这个视频内容是什么?'
|
|
58
73
|
|
|
59
74
|
/** Bump when the Pillow compression ladder changes so stale cache entries are ignored. */
|
|
60
75
|
const COMPRESSED_IMAGE_CACHE_VERSION = 'v2'
|
|
@@ -540,6 +555,17 @@ const FORMAT_BY_EXTENSION = new Map([
|
|
|
540
555
|
])
|
|
541
556
|
const HEX_COLOR_PATTERN = /^#[0-9A-F]{6}$/
|
|
542
557
|
|
|
558
|
+
/** MIME type for one analyzed image format, used when uploading to object storage. */
|
|
559
|
+
function imageMimeType(format: string): string {
|
|
560
|
+
switch (format) {
|
|
561
|
+
case 'png': return 'image/png'
|
|
562
|
+
case 'jpeg': return 'image/jpeg'
|
|
563
|
+
case 'gif': return 'image/gif'
|
|
564
|
+
case 'webp': return 'image/webp'
|
|
565
|
+
default: return 'application/octet-stream'
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
|
|
543
569
|
/**
|
|
544
570
|
* Error codes a provider retries against the SAME provider within its
|
|
545
571
|
* `attempts` budget. Only transient failures are worth re-requesting: a
|
|
@@ -801,6 +827,17 @@ export class VisionToolkitRuntime {
|
|
|
801
827
|
return this.config.storageDir
|
|
802
828
|
}
|
|
803
829
|
|
|
830
|
+
/**
|
|
831
|
+
* Whether at least one enabled OpenAI-compatible provider has video support
|
|
832
|
+
* turned on. A capability probe only: tool visibility is governed by the
|
|
833
|
+
* Settings tool-visibility video bucket, not by this flag. `videoUnderstand`
|
|
834
|
+
* re-checks it at call time and reports "video understanding unavailable"
|
|
835
|
+
* when it is false.
|
|
836
|
+
*/
|
|
837
|
+
get videoSupportEnabled(): boolean {
|
|
838
|
+
return this.videoProvider() !== undefined
|
|
839
|
+
}
|
|
840
|
+
|
|
804
841
|
/** Stable identity for persisted image descriptions produced by this runtime. */
|
|
805
842
|
get evidenceFingerprint(): string {
|
|
806
843
|
return evidenceRuntimeFingerprint(this.config, undefined, process.env.VISION_SSL_VERIFY?.trim())
|
|
@@ -958,6 +995,11 @@ export class VisionToolkitRuntime {
|
|
|
958
995
|
return this.config.providers.find(provider => provider.enabled) ?? this.config.providers[0]!
|
|
959
996
|
}
|
|
960
997
|
|
|
998
|
+
/** Highest-priority enabled OpenAI provider with video support enabled. */
|
|
999
|
+
private videoProvider(): ResolvedProvider | undefined {
|
|
1000
|
+
return this.config.providers.find(provider => provider.enabled && provider.videoSupport && provider.protocol === 'openai')
|
|
1001
|
+
}
|
|
1002
|
+
|
|
961
1003
|
/** Build the upstream environment for one resolved provider. */
|
|
962
1004
|
private providerEnv(provider: ResolvedProvider, resolved: ResolvedCredential): UpstreamEnvironment {
|
|
963
1005
|
const sslVerify = process.env.VISION_SSL_VERIFY?.trim()
|
|
@@ -968,6 +1010,7 @@ export class VisionToolkitRuntime {
|
|
|
968
1010
|
VISION_API_PROTOCOL: provider.protocol === 'anthropic' ? 'anthropic' : 'chat_completions',
|
|
969
1011
|
VISION_ANTHROPIC_THINKING: provider.anthropicThinking,
|
|
970
1012
|
...(sslVerify === undefined ? {} : { VISION_SSL_VERIFY: sslVerify }),
|
|
1013
|
+
...(provider.stream ? { VISION_STREAM: '1' } : {}),
|
|
971
1014
|
VISION_USER_AGENT: provider.userAgent,
|
|
972
1015
|
LANG: this.config.language,
|
|
973
1016
|
}
|
|
@@ -984,6 +1027,9 @@ export class VisionToolkitRuntime {
|
|
|
984
1027
|
protocol: provider.protocol,
|
|
985
1028
|
anthropicThinking: provider.anthropicThinking,
|
|
986
1029
|
userAgent: provider.userAgent,
|
|
1030
|
+
stream: provider.stream,
|
|
1031
|
+
uploadViaUrl: provider.uploadViaUrl,
|
|
1032
|
+
videoSupport: provider.videoSupport,
|
|
987
1033
|
})
|
|
988
1034
|
? { value: BUILT_IN_FREE_VISION_KEY, source: 'built-in' }
|
|
989
1035
|
: await this.ctx.credentials.resolve(provider.credential)
|
|
@@ -1253,6 +1299,249 @@ export class VisionToolkitRuntime {
|
|
|
1253
1299
|
operation.metrics.imagePixels += image.width * image.height
|
|
1254
1300
|
}
|
|
1255
1301
|
|
|
1302
|
+
/** Resolve the configured object storage into a usable client, or undefined. */
|
|
1303
|
+
private async resolveObjectStorageClient(): Promise<ObjectStorageClient | undefined> {
|
|
1304
|
+
const objectStorage = this.config.objectStorage
|
|
1305
|
+
if (objectStorage.credential === undefined || objectStorage.endpoint.length === 0 || objectStorage.bucket.length === 0) {
|
|
1306
|
+
return undefined
|
|
1307
|
+
}
|
|
1308
|
+
let resolved: ResolvedCredential | undefined
|
|
1309
|
+
try {
|
|
1310
|
+
resolved = await this.ctx.credentials.resolve(objectStorage.credential)
|
|
1311
|
+
} catch {
|
|
1312
|
+
resolved = undefined
|
|
1313
|
+
}
|
|
1314
|
+
if (resolved === undefined) return undefined
|
|
1315
|
+
let accessKeyId: string
|
|
1316
|
+
let secretAccessKey: string
|
|
1317
|
+
try {
|
|
1318
|
+
const split = splitObjectStorageCredential(resolved.value)
|
|
1319
|
+
accessKeyId = split.accessKeyId
|
|
1320
|
+
secretAccessKey = split.secretAccessKey
|
|
1321
|
+
} catch (error) {
|
|
1322
|
+
throw new VisionToolkitError('config', 'object storage credential is malformed', { cause: error })
|
|
1323
|
+
}
|
|
1324
|
+
const settings: ObjectStorageSettings = {
|
|
1325
|
+
endpoint: objectStorage.endpoint,
|
|
1326
|
+
bucket: objectStorage.bucket,
|
|
1327
|
+
accessKeyId,
|
|
1328
|
+
secretAccessKey,
|
|
1329
|
+
...(objectStorage.publicBase === undefined ? {} : { publicBase: objectStorage.publicBase }),
|
|
1330
|
+
}
|
|
1331
|
+
return new ObjectStorageClient(settings)
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
/**
|
|
1335
|
+
* Upload the prepared images to object storage and return their URLs plus a
|
|
1336
|
+
* cleanup callback, when the primary provider opts into URL transfer and
|
|
1337
|
+
* object storage is configured. Returns undefined otherwise (base64 path).
|
|
1338
|
+
*/
|
|
1339
|
+
private async maybeTransferImages(
|
|
1340
|
+
pool: readonly ResolvedProviderEnv[],
|
|
1341
|
+
images: readonly ImageInfo[],
|
|
1342
|
+
operation: OperationContext,
|
|
1343
|
+
): Promise<{ urls: string[]; cleanup: () => Promise<void> } | undefined> {
|
|
1344
|
+
const primary = pool[0]
|
|
1345
|
+
if (primary === undefined || primary.provider.uploadViaUrl !== true) return undefined
|
|
1346
|
+
const client = await this.resolveObjectStorageClient()
|
|
1347
|
+
if (client === undefined) {
|
|
1348
|
+
throw new VisionToolkitError('config', 'uploadViaUrl is enabled but object storage is not configured')
|
|
1349
|
+
}
|
|
1350
|
+
const keys: string[] = []
|
|
1351
|
+
const urls: string[] = []
|
|
1352
|
+
try {
|
|
1353
|
+
for (const image of images) {
|
|
1354
|
+
if (operation.signal.aborted) throw new VisionToolkitError('cancelled', 'vision image upload cancelled')
|
|
1355
|
+
const uploaded = await client.uploadImage(image.path, imageMimeType(image.format))
|
|
1356
|
+
keys.push(uploaded.key)
|
|
1357
|
+
urls.push(uploaded.url)
|
|
1358
|
+
}
|
|
1359
|
+
} catch (error) {
|
|
1360
|
+
await Promise.allSettled(keys.map(key => client.deleteObject(key)))
|
|
1361
|
+
throw error
|
|
1362
|
+
}
|
|
1363
|
+
return {
|
|
1364
|
+
urls,
|
|
1365
|
+
cleanup: async () => {
|
|
1366
|
+
await Promise.allSettled(keys.map(key => client.deleteObject(key)))
|
|
1367
|
+
},
|
|
1368
|
+
}
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
/** Settings "test storage" probe: upload → head → delete a tiny marker object. */
|
|
1372
|
+
async testObjectStorage(): Promise<{ detail: string }> {
|
|
1373
|
+
const client = await this.resolveObjectStorageClient()
|
|
1374
|
+
if (client === undefined) {
|
|
1375
|
+
throw new VisionToolkitError('config', 'object storage is not configured (endpoint, bucket, and credential are required)')
|
|
1376
|
+
}
|
|
1377
|
+
return client.test()
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
/**
|
|
1381
|
+
* Settings "test video call" probe: upload the bundled diagnostic video to
|
|
1382
|
+
* object storage and send one OpenAI-compatible (Aliyun Qwen) video request.
|
|
1383
|
+
* The wire content uses the Qwen video block shape (`video_url` + `fps`) plus
|
|
1384
|
+
* the fixed test question. The object is deleted afterwards, best-effort.
|
|
1385
|
+
*/
|
|
1386
|
+
async testVideoCall(options: ToolCallOptions, provider?: ResolvedProvider): Promise<{ detail: string }> {
|
|
1387
|
+
return this.runOperation('vision_toolkit_video_test', options, async (operation) => {
|
|
1388
|
+
const target = provider ?? this.primaryProvider
|
|
1389
|
+
if (target.protocol !== 'openai') {
|
|
1390
|
+
throw new VisionToolkitError('config', 'video support is only available for OpenAI-compatible providers')
|
|
1391
|
+
}
|
|
1392
|
+
const entry = await this.resolveProviderEnv(target)
|
|
1393
|
+
if (entry === undefined) {
|
|
1394
|
+
throw new VisionToolkitError('config', `credential ${String(target.credential)} is not configured`)
|
|
1395
|
+
}
|
|
1396
|
+
const client = await this.resolveObjectStorageClient()
|
|
1397
|
+
if (client === undefined) {
|
|
1398
|
+
throw new VisionToolkitError('config', 'object storage is not configured (endpoint, bucket, and credential are required)')
|
|
1399
|
+
}
|
|
1400
|
+
const uploaded = await client.uploadImage(VISION_MODEL_TEST_VIDEO, 'video/mp4')
|
|
1401
|
+
try {
|
|
1402
|
+
const answer = await this.requestVideoAnswer(target, entry.env.VISION_API_KEY, uploaded.url, VISION_MODEL_TEST_VIDEO_PROMPT, 2, operation)
|
|
1403
|
+
const snippet = answer.trim().slice(0, 160)
|
|
1404
|
+
return {
|
|
1405
|
+
detail: snippet.length === 0
|
|
1406
|
+
? `Video call completed against ${target.model}`
|
|
1407
|
+
: `Video call completed against ${target.model}: ${snippet}`,
|
|
1408
|
+
}
|
|
1409
|
+
} finally {
|
|
1410
|
+
await client.deleteObject(uploaded.key)
|
|
1411
|
+
}
|
|
1412
|
+
})
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
/** Run the bundled ffprobe binary once and parse its JSON metadata. */
|
|
1416
|
+
private async runFfprobe(videoPath: string, operation: OperationContext): Promise<VideoMetadata> {
|
|
1417
|
+
const binary = ffprobeBinaryPath()
|
|
1418
|
+
if (binary === null) {
|
|
1419
|
+
throw new VisionToolkitError('runtime', 'ffprobe is unavailable; the ffprobe-static package must be installed alongside the plugin')
|
|
1420
|
+
}
|
|
1421
|
+
const handle = this.ctx.subprocess.spawn({
|
|
1422
|
+
argv: [binary, '-v', 'error', '-print_format', 'json', '-show_format', '-show_streams', videoPath],
|
|
1423
|
+
cwd: process.cwd(),
|
|
1424
|
+
stdio: {
|
|
1425
|
+
stdin: 'ignore',
|
|
1426
|
+
stdout: { maxBytes: 512 * 1024 },
|
|
1427
|
+
stderr: { maxBytes: 64 * 1024 },
|
|
1428
|
+
},
|
|
1429
|
+
graceMs: 2000,
|
|
1430
|
+
signal: operation.signal,
|
|
1431
|
+
})
|
|
1432
|
+
const outcome = await handle.done
|
|
1433
|
+
const stdout = handle.collected.stdout?.readFrom(0)
|
|
1434
|
+
const stderr = handle.collected.stderr?.readFrom(0)
|
|
1435
|
+
if (outcome.exitCode !== 0) {
|
|
1436
|
+
throw new VisionToolkitError('input', `cannot read video metadata: ${(stderr?.text ?? '').trim() || 'ffprobe failed'}`)
|
|
1437
|
+
}
|
|
1438
|
+
if (stdout?.lossy === true) {
|
|
1439
|
+
throw new VisionToolkitError('output', 'video metadata output exceeded the capture limit')
|
|
1440
|
+
}
|
|
1441
|
+
try {
|
|
1442
|
+
return parseFfprobeOutput(stdout?.text ?? '')
|
|
1443
|
+
} catch (error) {
|
|
1444
|
+
throw new VisionToolkitError('output', 'video metadata output is invalid', { cause: error })
|
|
1445
|
+
}
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1448
|
+
/**
|
|
1449
|
+
* One OpenAI-compatible (Aliyun Qwen) video chat-completions request. The
|
|
1450
|
+
* content array carries the video_url block (with `fps`) plus the prompt text.
|
|
1451
|
+
*/
|
|
1452
|
+
private async requestVideoAnswer(
|
|
1453
|
+
target: ResolvedProvider,
|
|
1454
|
+
apiKey: string,
|
|
1455
|
+
videoUrl: string,
|
|
1456
|
+
prompt: string,
|
|
1457
|
+
fps: number,
|
|
1458
|
+
operation: OperationContext,
|
|
1459
|
+
): Promise<string> {
|
|
1460
|
+
operation.metrics.usedVisionService = true
|
|
1461
|
+
const started = Date.now()
|
|
1462
|
+
const response = await fetch(`${target.baseUrl}/chat/completions`, {
|
|
1463
|
+
method: 'POST',
|
|
1464
|
+
headers: {
|
|
1465
|
+
'Content-Type': 'application/json',
|
|
1466
|
+
Authorization: `Bearer ${apiKey}`,
|
|
1467
|
+
'User-Agent': target.userAgent,
|
|
1468
|
+
},
|
|
1469
|
+
body: JSON.stringify({
|
|
1470
|
+
model: target.model,
|
|
1471
|
+
messages: [{
|
|
1472
|
+
role: 'user',
|
|
1473
|
+
content: [
|
|
1474
|
+
{ type: 'video_url', video_url: { url: videoUrl }, fps },
|
|
1475
|
+
{ type: 'text', text: prompt },
|
|
1476
|
+
],
|
|
1477
|
+
}],
|
|
1478
|
+
}),
|
|
1479
|
+
signal: operation.signal,
|
|
1480
|
+
})
|
|
1481
|
+
operation.metrics.upstreamMs += Date.now() - started
|
|
1482
|
+
const body = await response.text().catch(() => '')
|
|
1483
|
+
if (!response.ok) {
|
|
1484
|
+
throw new VisionToolkitError('service', `video request failed with HTTP ${response.status}: ${body.slice(0, 300)}`)
|
|
1485
|
+
}
|
|
1486
|
+
const answer = extractChatAnswer(body)
|
|
1487
|
+
if (answer.trim().length === 0) {
|
|
1488
|
+
throw new VisionToolkitError('output', 'vision API returned an empty answer')
|
|
1489
|
+
}
|
|
1490
|
+
return answer
|
|
1491
|
+
}
|
|
1492
|
+
|
|
1493
|
+
/** Local video basic-info probe: ffprobe metadata, no API or credential. */
|
|
1494
|
+
async videoInfo(request: VideoInfoRequest, options: ToolCallOptions): Promise<VideoInfo> {
|
|
1495
|
+
return this.runOperation('vision_video_info', options, async (operation) => {
|
|
1496
|
+
const policy = await this.pathPolicy(options.workspace)
|
|
1497
|
+
const resolved = await resolveInputVideo(request.video, policy)
|
|
1498
|
+
const metadata = await this.runFfprobe(resolved.path, operation)
|
|
1499
|
+
return { path: resolved.path, bytes: resolved.bytes, ...metadata }
|
|
1500
|
+
})
|
|
1501
|
+
}
|
|
1502
|
+
|
|
1503
|
+
/**
|
|
1504
|
+
* Video understanding: upload the video to object storage and send it plus a
|
|
1505
|
+
* prompt to the first enabled OpenAI provider with video support. Whether the
|
|
1506
|
+
* tool appears in an Agent is governed by the Settings tool-visibility video
|
|
1507
|
+
* bucket, not by provider capability; this method re-checks capability at
|
|
1508
|
+
* call time and returns a "video understanding unavailable" config error when
|
|
1509
|
+
* no enabled provider supports video.
|
|
1510
|
+
*/
|
|
1511
|
+
async videoUnderstand(request: VideoUnderstandRequest, options: ToolCallOptions): Promise<VideoUnderstandResult> {
|
|
1512
|
+
return this.runOperation('vision_video_understand', options, async (operation) => {
|
|
1513
|
+
const target = this.videoProvider()
|
|
1514
|
+
if (target === undefined) {
|
|
1515
|
+
throw new VisionToolkitError('config', 'no enabled vision service has video support; enable it in Settings first')
|
|
1516
|
+
}
|
|
1517
|
+
const entry = await this.resolveProviderEnv(target)
|
|
1518
|
+
if (entry === undefined) {
|
|
1519
|
+
throw new VisionToolkitError('config', `credential ${String(target.credential)} is not configured`)
|
|
1520
|
+
}
|
|
1521
|
+
const prompt = request.prompt.trim()
|
|
1522
|
+
if (prompt.length === 0) {
|
|
1523
|
+
throw new VisionToolkitError('input', 'prompt must not be empty')
|
|
1524
|
+
}
|
|
1525
|
+
const fps = request.fps ?? 2
|
|
1526
|
+
if (!Number.isInteger(fps) || fps < 1 || fps > 120) {
|
|
1527
|
+
throw new VisionToolkitError('input', 'fps must be an integer between 1 and 120')
|
|
1528
|
+
}
|
|
1529
|
+
const policy = await this.pathPolicy(options.workspace)
|
|
1530
|
+
const resolved = await resolveInputVideo(request.video, policy)
|
|
1531
|
+
const client = await this.resolveObjectStorageClient()
|
|
1532
|
+
if (client === undefined) {
|
|
1533
|
+
throw new VisionToolkitError('config', 'object storage is not configured (endpoint, bucket, and credential are required)')
|
|
1534
|
+
}
|
|
1535
|
+
const uploaded = await client.uploadImage(resolved.path, videoMediaType(extname(resolved.path).toLowerCase()))
|
|
1536
|
+
try {
|
|
1537
|
+
const answer = await this.requestVideoAnswer(target, entry.env.VISION_API_KEY, uploaded.url, prompt, fps, operation)
|
|
1538
|
+
return { path: resolved.path, answer }
|
|
1539
|
+
} finally {
|
|
1540
|
+
await client.deleteObject(uploaded.key)
|
|
1541
|
+
}
|
|
1542
|
+
})
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1256
1545
|
/** Stable gate key for one provider's in-flight request cap. */
|
|
1257
1546
|
private providerGate(provider: ResolvedProvider): Semaphore {
|
|
1258
1547
|
const key = `${provider.baseUrl}\u0000${provider.model}\u0000${String(provider.credential)}`
|
|
@@ -1603,6 +1892,8 @@ export class VisionToolkitRuntime {
|
|
|
1603
1892
|
protocol: env.VISION_API_PROTOCOL,
|
|
1604
1893
|
anthropicThinking: env.VISION_ANTHROPIC_THINKING,
|
|
1605
1894
|
sslVerify: env.VISION_SSL_VERIFY ?? null,
|
|
1895
|
+
stream: env.VISION_STREAM === '1',
|
|
1896
|
+
uploadViaUrl: provider.uploadViaUrl,
|
|
1606
1897
|
userAgent: env.VISION_USER_AGENT,
|
|
1607
1898
|
credentialSha256: createHash('sha256').update(env.VISION_API_KEY).digest('hex'),
|
|
1608
1899
|
maxImageBytes: provider.maxImageBytes,
|
|
@@ -1733,24 +2024,31 @@ export class VisionToolkitRuntime {
|
|
|
1733
2024
|
return cached.result
|
|
1734
2025
|
}
|
|
1735
2026
|
}
|
|
1736
|
-
const
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
images,
|
|
1746
|
-
|
|
1747
|
-
answer,
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
2027
|
+
const transfer = request.region === undefined
|
|
2028
|
+
? await this.maybeTransferImages(pool, images, operation)
|
|
2029
|
+
: undefined
|
|
2030
|
+
try {
|
|
2031
|
+
const result = await this.runVisionHedge('glance', [
|
|
2032
|
+
...(transfer !== undefined ? transfer.urls : images.map(image => image.path)),
|
|
2033
|
+
...(transfer === undefined && request.region !== undefined ? ['--region', request.region] : []),
|
|
2034
|
+
...(request.ocr === true ? ['--ocr'] : []),
|
|
2035
|
+
...(request.query !== undefined ? ['-q', request.query] : []),
|
|
2036
|
+
], images, operation, pool)
|
|
2037
|
+
const answer = result.stdout.trim()
|
|
2038
|
+
if (answer.length === 0) throw new VisionToolkitError('output', 'glance: vision API returned an empty description')
|
|
2039
|
+
const value: GlanceResult = {
|
|
2040
|
+
images,
|
|
2041
|
+
mode: request.ocr === true ? 'ocr' : request.query !== undefined ? 'qa' : 'describe',
|
|
2042
|
+
answer,
|
|
2043
|
+
truncated: false,
|
|
2044
|
+
}
|
|
2045
|
+
if (options.sessionScope !== undefined && cacheKey !== undefined && !operation.signal.aborted) {
|
|
2046
|
+
this.glanceCache.set(options.sessionScope, { key: cacheKey, result: value })
|
|
2047
|
+
}
|
|
2048
|
+
return value
|
|
2049
|
+
} finally {
|
|
2050
|
+
if (transfer !== undefined) await transfer.cleanup()
|
|
1752
2051
|
}
|
|
1753
|
-
return value
|
|
1754
2052
|
})
|
|
1755
2053
|
}
|
|
1756
2054
|
|
|
@@ -1786,14 +2084,23 @@ export class VisionToolkitRuntime {
|
|
|
1786
2084
|
}
|
|
1787
2085
|
const image = await this.prepareVisionImage(request.image, pool.map(entry => entry.provider), policy, operation)
|
|
1788
2086
|
this.accountImage(image, operation)
|
|
1789
|
-
const
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
2087
|
+
const transfer = request.region === undefined
|
|
2088
|
+
? await this.maybeTransferImages(pool, [image], operation)
|
|
2089
|
+
: undefined
|
|
2090
|
+
try {
|
|
2091
|
+
const result = await this.runVisionHedge(tool, transfer !== undefined
|
|
2092
|
+
? [transfer.urls[0]!, request.target, '--size', `${image.width}x${image.height}`]
|
|
2093
|
+
: [
|
|
2094
|
+
image.path,
|
|
2095
|
+
request.target,
|
|
2096
|
+
...(request.region !== undefined ? ['--region', request.region] : []),
|
|
2097
|
+
], [image], operation, pool)
|
|
2098
|
+
const elements = parseLocationOutput(result.stdout)
|
|
2099
|
+
this.validateLocations(elements, image.width, image.height)
|
|
2100
|
+
return { image, elements }
|
|
2101
|
+
} finally {
|
|
2102
|
+
if (transfer !== undefined) await transfer.cleanup()
|
|
2103
|
+
}
|
|
1797
2104
|
}
|
|
1798
2105
|
|
|
1799
2106
|
/** ground: locate one named target and return pixel boxes. */
|
package/src/tools.ts
CHANGED
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
type TraceRequest,
|
|
23
23
|
} from './runtime.ts'
|
|
24
24
|
import { platformTempDirectory } from './paths.ts'
|
|
25
|
+
import type { VideoInfoRequest, VideoUnderstandRequest } from './video.ts'
|
|
25
26
|
|
|
26
27
|
const renderJson = (_args: unknown, value: unknown): ContentBlock[] => [{
|
|
27
28
|
type: 'text',
|
|
@@ -48,8 +49,55 @@ export const VISION_TOOL_NAMES = {
|
|
|
48
49
|
dominantColors: 'vision_dominant_colors',
|
|
49
50
|
htmlScreenshot: 'vision_html_screenshot',
|
|
50
51
|
concurrency: 'vision_concurrency',
|
|
52
|
+
videoInfo: 'vision_video_info',
|
|
51
53
|
} as const
|
|
52
54
|
|
|
55
|
+
/**
|
|
56
|
+
* Opt-in video-understanding tool name. Deliberately outside {@link VISION_TOOL_NAMES}
|
|
57
|
+
* so the always-registered canonical set stays unconditional; this tool enters an
|
|
58
|
+
* Agent only when a vision service has video support enabled.
|
|
59
|
+
*/
|
|
60
|
+
export const VISION_VIDEO_UNDERSTAND_TOOL = 'vision_video_understand'
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Tool-group buckets used by the `toolVisibility` snapshot. A bucket that is
|
|
64
|
+
* off contributes none of its listed tools to an Agent's visible surface.
|
|
65
|
+
* - local: local-processing tools (no on-line fan-out, no concurrency charge).
|
|
66
|
+
* - online: on-line image tools plus the concurrency/status probe.
|
|
67
|
+
* - video: video-understanding tool (experimental).
|
|
68
|
+
*/
|
|
69
|
+
export interface ToolVisibility {
|
|
70
|
+
local: boolean
|
|
71
|
+
online: boolean
|
|
72
|
+
video: boolean
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const TOOL_BUCKETS: Record<string, 'local' | 'online' | 'video'> = {
|
|
76
|
+
[VISION_TOOL_NAMES.glance]: 'online',
|
|
77
|
+
[VISION_TOOL_NAMES.ground]: 'online',
|
|
78
|
+
[VISION_TOOL_NAMES.detect]: 'online',
|
|
79
|
+
[VISION_TOOL_NAMES.longScreenshotOcr]: 'online',
|
|
80
|
+
[VISION_TOOL_NAMES.concurrency]: 'online',
|
|
81
|
+
[VISION_TOOL_NAMES.trace]: 'local',
|
|
82
|
+
[VISION_TOOL_NAMES.crop]: 'local',
|
|
83
|
+
[VISION_TOOL_NAMES.pixelDiff]: 'local',
|
|
84
|
+
[VISION_TOOL_NAMES.extractForeground]: 'local',
|
|
85
|
+
[VISION_TOOL_NAMES.dominantColors]: 'local',
|
|
86
|
+
[VISION_TOOL_NAMES.htmlScreenshot]: 'local',
|
|
87
|
+
[VISION_TOOL_NAMES.videoInfo]: 'local',
|
|
88
|
+
[VISION_VIDEO_UNDERSTAND_TOOL]: 'video',
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Filter one tool definition by the visibility snapshot's matching bucket. */
|
|
92
|
+
function visibleBySnapshot(definition: { name: string }, snapshot: ToolVisibility): boolean {
|
|
93
|
+
switch (TOOL_BUCKETS[definition.name]) {
|
|
94
|
+
case 'local': return snapshot.local
|
|
95
|
+
case 'online': return snapshot.online
|
|
96
|
+
case 'video': return snapshot.video
|
|
97
|
+
default: return true
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
53
101
|
/** Resolve the caller workspace exactly like first-party fs/bash tools. */
|
|
54
102
|
function sessionWorkspace(exec: ToolRunContext): string {
|
|
55
103
|
return exec.agent?.session.header.cwd ?? process.cwd()
|
|
@@ -122,6 +170,19 @@ const requiredBoxSchema = { ...boxSchema, required: true } as const
|
|
|
122
170
|
const requiredImageInfoSchema = { ...imageInfoSchema, required: true } as const
|
|
123
171
|
const requiredArtifactSchema = { ...artifactSchema, required: true } as const
|
|
124
172
|
|
|
173
|
+
const nullableStringSchema = {
|
|
174
|
+
oneOf: [{ type: 'string' }, { type: 'null' }],
|
|
175
|
+
} as const satisfies ValueSchemaSpec
|
|
176
|
+
const nullableIntegerSchema = {
|
|
177
|
+
oneOf: [{ type: 'integer' }, { type: 'null' }],
|
|
178
|
+
} as const satisfies ValueSchemaSpec
|
|
179
|
+
const nullableNumberSchema = {
|
|
180
|
+
oneOf: [{ type: 'number' }, { type: 'null' }],
|
|
181
|
+
} as const satisfies ValueSchemaSpec
|
|
182
|
+
const requiredNullableStringSchema = { ...nullableStringSchema, required: true } as const
|
|
183
|
+
const requiredNullableIntegerSchema = { ...nullableIntegerSchema, required: true } as const
|
|
184
|
+
const requiredNullableNumberSchema = { ...nullableNumberSchema, required: true } as const
|
|
185
|
+
|
|
125
186
|
const locatedMatchSchema = {
|
|
126
187
|
type: 'object',
|
|
127
188
|
additionalProperties: false,
|
|
@@ -181,17 +242,21 @@ function runtimeFrom(source: VisionToolkitRuntimeSource): VisionToolkitRuntime {
|
|
|
181
242
|
* @param source - Current runtime or atomic runtime lookup.
|
|
182
243
|
* @param projectPresentation - Browser-only projection for Artifact capabilities.
|
|
183
244
|
* @param lifecycleSignal - Plugin lifetime; aborting it cancels every active tool call.
|
|
245
|
+
* @param toolVisibility - Session-head visibility snapshot; a bucket that is off
|
|
246
|
+
* contributes none of its tools. Defaults to every bucket on.
|
|
184
247
|
* @returns Native tool definitions registered as one lifecycle generation.
|
|
185
248
|
*/
|
|
186
249
|
export function createVisionTools(
|
|
187
250
|
source: VisionToolkitRuntimeSource,
|
|
188
251
|
projectPresentation: VisionToolkitPresentationProjector = presentationIdentity,
|
|
189
252
|
lifecycleSignal?: AbortSignal,
|
|
253
|
+
toolVisibility: ToolVisibility = { local: true, online: true, video: false },
|
|
190
254
|
): ReturnType<typeof defineTool>[] {
|
|
191
255
|
const presentationMeta = (_args: unknown, value: JsonValue): JsonValue => projectPresentation(value)
|
|
192
|
-
const
|
|
256
|
+
const runtime = runtimeFrom(source)
|
|
257
|
+
const sessionMaxConcurrency = runtime.sessionMaxConcurrency
|
|
193
258
|
const concurrencyNote = `A single session runs at most ${sessionMaxConcurrency} concurrent vision calls; query vision_concurrency for the live available count. `
|
|
194
|
-
|
|
259
|
+
const tools: ReturnType<typeof defineTool>[] = [
|
|
195
260
|
defineTool({
|
|
196
261
|
name: VISION_TOOL_NAMES.glance,
|
|
197
262
|
description: 'Describe, answer a targeted question about, OCR, or compare one or more images with the configured vision model. '
|
|
@@ -591,7 +656,8 @@ export function createVisionTools(
|
|
|
591
656
|
}),
|
|
592
657
|
defineTool({
|
|
593
658
|
name: VISION_TOOL_NAMES.concurrency,
|
|
594
|
-
description: 'Report the current available concurrency for vision tool calls in this session: the smaller of the remaining per-session slots and the total remaining model-request slots across enabled providers.'
|
|
659
|
+
description: 'Report the current available concurrency for ON-LINE vision tool calls (glance, ground, detect, long-screenshot OCR, video understanding) in this session: the smaller of the remaining per-session slots and the total remaining model-request slots across enabled providers. '
|
|
660
|
+
+ 'Local-processing tools (trace, crop, pixel diff, extract foreground, colors, HTML screenshot, video info) are NOT throttled by this and run immediately.',
|
|
595
661
|
parameters: {},
|
|
596
662
|
output: {
|
|
597
663
|
schema: {
|
|
@@ -621,7 +687,72 @@ export function createVisionTools(
|
|
|
621
687
|
isConcurrencySafe: () => true,
|
|
622
688
|
presentCall: () => ({ card: 'generic', title: 'Vision concurrency', kind: 'read', locations: [] }),
|
|
623
689
|
}),
|
|
690
|
+
defineTool({
|
|
691
|
+
name: VISION_TOOL_NAMES.videoInfo,
|
|
692
|
+
description: 'Probe a local video file and return its basic metadata (container format, duration, resolution, frame rate, codecs, and stream counts) using the bundled ffprobe binary. '
|
|
693
|
+
+ 'This is a local operation and never calls a vision API or needs a credential. ' + concurrencyNote + WORKSPACE_NOTE,
|
|
694
|
+
parameters: {
|
|
695
|
+
video: { type: 'string', required: true, description: 'Video file path.' },
|
|
696
|
+
timeoutSeconds: { type: 'integer', description: TIMEOUT_NOTE },
|
|
697
|
+
},
|
|
698
|
+
output: {
|
|
699
|
+
schema: {
|
|
700
|
+
type: 'object', additionalProperties: false, properties: {
|
|
701
|
+
path: { type: 'string', required: true },
|
|
702
|
+
bytes: { type: 'integer', required: true },
|
|
703
|
+
format: { type: 'string', required: true },
|
|
704
|
+
durationSeconds: requiredNullableNumberSchema,
|
|
705
|
+
width: requiredNullableIntegerSchema,
|
|
706
|
+
height: requiredNullableIntegerSchema,
|
|
707
|
+
frameRate: requiredNullableNumberSchema,
|
|
708
|
+
videoCodec: requiredNullableStringSchema,
|
|
709
|
+
audioCodec: requiredNullableStringSchema,
|
|
710
|
+
bitRate: requiredNullableIntegerSchema,
|
|
711
|
+
videoStreamCount: { type: 'integer', required: true },
|
|
712
|
+
audioStreamCount: { type: 'integer', required: true },
|
|
713
|
+
},
|
|
714
|
+
},
|
|
715
|
+
render: renderJson,
|
|
716
|
+
},
|
|
717
|
+
async execute(args: VideoInfoArgs, exec) {
|
|
718
|
+
const request: VideoInfoRequest = { video: args.video }
|
|
719
|
+
return runtimeFrom(source).videoInfo(request, callOptions(exec, args.timeoutSeconds, lifecycleSignal))
|
|
720
|
+
},
|
|
721
|
+
isConcurrencySafe: () => true,
|
|
722
|
+
presentCall: args => ({ card: 'generic', title: `Inspect ${args.video}`, kind: 'read', locations: [{ path: args.video }] }),
|
|
723
|
+
}),
|
|
724
|
+
defineTool({
|
|
725
|
+
name: VISION_VIDEO_UNDERSTAND_TOOL,
|
|
726
|
+
description: 'Send a local video plus a prompt to the configured vision service and return its answer text. '
|
|
727
|
+
+ 'The video is uploaded to object storage and passed as a video_url block (Aliyun Qwen format). '
|
|
728
|
+
+ `This tool exists only when the video tool bucket is enabled in Settings. Calling it without an enabled vision service that supports video returns a "video understanding unavailable" error. ${UNTRUSTED_EVIDENCE_NOTE} ` + concurrencyNote + WORKSPACE_NOTE,
|
|
729
|
+
parameters: {
|
|
730
|
+
video: { type: 'string', required: true, description: 'Video file path.' },
|
|
731
|
+
prompt: { type: 'string', required: true, description: 'Question or instruction about the video.' },
|
|
732
|
+
fps: { type: 'integer', description: 'Video sampling frame rate passed to the model; default 2.' },
|
|
733
|
+
timeoutSeconds: { type: 'integer', description: TIMEOUT_NOTE },
|
|
734
|
+
},
|
|
735
|
+
output: {
|
|
736
|
+
schema: {
|
|
737
|
+
type: 'object', additionalProperties: false, properties: {
|
|
738
|
+
path: { type: 'string', required: true },
|
|
739
|
+
answer: { type: 'string', required: true },
|
|
740
|
+
},
|
|
741
|
+
},
|
|
742
|
+
render: renderJson,
|
|
743
|
+
},
|
|
744
|
+
async execute(args: VideoUnderstandArgs, exec) {
|
|
745
|
+
const request: VideoUnderstandRequest = {
|
|
746
|
+
video: args.video,
|
|
747
|
+
prompt: args.prompt,
|
|
748
|
+
...(args.fps === undefined ? {} : { fps: args.fps }),
|
|
749
|
+
}
|
|
750
|
+
return runtimeFrom(source).videoUnderstand(request, callOptions(exec, args.timeoutSeconds, lifecycleSignal))
|
|
751
|
+
},
|
|
752
|
+
presentCall: args => ({ card: 'generic', title: `Understand ${args.video}`, kind: 'read', locations: [{ path: args.video }] }),
|
|
753
|
+
}),
|
|
624
754
|
]
|
|
755
|
+
return tools.filter(definition => visibleBySnapshot(definition, toolVisibility))
|
|
625
756
|
}
|
|
626
757
|
|
|
627
758
|
interface GlanceArgs {
|
|
@@ -722,3 +853,13 @@ interface HtmlArgs {
|
|
|
722
853
|
output?: string
|
|
723
854
|
timeoutSeconds?: number
|
|
724
855
|
}
|
|
856
|
+
interface VideoInfoArgs {
|
|
857
|
+
video: string
|
|
858
|
+
timeoutSeconds?: number
|
|
859
|
+
}
|
|
860
|
+
interface VideoUnderstandArgs {
|
|
861
|
+
video: string
|
|
862
|
+
prompt: string
|
|
863
|
+
fps?: number
|
|
864
|
+
timeoutSeconds?: number
|
|
865
|
+
}
|
package/src/upstream.ts
CHANGED
|
@@ -41,6 +41,7 @@ export interface UpstreamEnvironment {
|
|
|
41
41
|
VISION_API_PROTOCOL: 'chat_completions' | 'anthropic'
|
|
42
42
|
VISION_ANTHROPIC_THINKING: 'omit' | 'disabled' | 'adaptive'
|
|
43
43
|
VISION_SSL_VERIFY?: string
|
|
44
|
+
VISION_STREAM?: string
|
|
44
45
|
VISION_USER_AGENT: string
|
|
45
46
|
LANG: 'zh' | 'en'
|
|
46
47
|
}
|
|
@@ -761,6 +762,9 @@ export class UpstreamAdapter {
|
|
|
761
762
|
...(options.env.VISION_SSL_VERIFY === undefined
|
|
762
763
|
? {}
|
|
763
764
|
: { VISION_SSL_VERIFY: options.env.VISION_SSL_VERIFY }),
|
|
765
|
+
...(options.env.VISION_STREAM === undefined
|
|
766
|
+
? {}
|
|
767
|
+
: { VISION_STREAM: options.env.VISION_STREAM }),
|
|
764
768
|
VISION_USER_AGENT: options.env.VISION_USER_AGENT,
|
|
765
769
|
LANG: options.env.LANG,
|
|
766
770
|
VISION_ENV_FILE: join(prepared.cleanHome, 'vision.env'),
|