[Rust Frontend] Integrate MM audio support (#48554)
Co-authored-by: Isotr0py <Isotr0py@outlook.com> Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com> Signed-off-by: Bugen Zhao <i@bugenzhao.com> Signed-off-by: Isotr0py <Isotr0py@outlook.com>
Bugen Zhao committed
Jul 15, 2026 at 15:00 UTC
e281ac663a7b772f8b988f270521cd1155f12aa3
18 files changed
+1274
-257
rust/Cargo.lock
+403
-11
@@ -182,7 +182,7 @@ dependencies = [
182
"hex",
183
"hmac",
184
"rand 0.9.2",
185
- "reqwest",
185
+ "reqwest 0.12.28",
186
"reqwest-eventsource",
187
"secrecy",
188
"serde",
@@ -379,6 +379,12 @@ version = "0.22.1"
379
source = "registry+https://github.com/rust-lang/crates.io-index"
380
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
381
382
+[[package]]
383
+name = "base64ct"
384
+version = "1.8.3"
385
+source = "registry+https://github.com/rust-lang/crates.io-index"
386
+checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
387
+
388
[[package]]
389
name = "bit-set"
390
version = "0.5.3"
@@ -665,6 +671,35 @@ version = "0.4.2"
671
source = "registry+https://github.com/rust-lang/crates.io-index"
672
checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b"
673
674
+[[package]]
675
+name = "cookie"
676
+version = "0.18.1"
677
+source = "registry+https://github.com/rust-lang/crates.io-index"
678
+checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747"
679
+dependencies = [
680
+ "percent-encoding",
681
+ "time",
682
+ "version_check",
683
+]
684
+
685
+[[package]]
686
+name = "cookie_store"
687
+version = "0.22.1"
688
+source = "registry+https://github.com/rust-lang/crates.io-index"
689
+checksum = "15b2c103cf610ec6cae3da84a766285b42fd16aad564758459e6ecf128c75206"
690
+dependencies = [
691
+ "cookie",
692
+ "document-features",
693
+ "idna",
694
+ "indexmap 2.13.0",
695
+ "log",
696
+ "serde",
697
+ "serde_derive",
698
+ "serde_json",
699
+ "time",
700
+ "url",
701
+]
702
+
703
[[package]]
704
name = "core-foundation"
705
version = "0.9.4"
@@ -888,6 +923,16 @@ dependencies = [
923
"serde",
924
]
925
926
+[[package]]
927
+name = "der"
928
+version = "0.8.1"
929
+source = "registry+https://github.com/rust-lang/crates.io-index"
930
+checksum = "a69dedd701da44b0536442edf09c81a64b0ab97a7a4a5e3d1971f00027cbc63d"
931
+dependencies = [
932
+ "pem-rfc7468",
933
+ "zeroize",
934
+]
935
+
936
[[package]]
937
name = "deranged"
938
version = "0.5.8"
@@ -1171,6 +1216,12 @@ dependencies = [
1216
"once_cell",
1217
]
1218
1219
+[[package]]
1220
+name = "extended"
1221
+version = "0.1.0"
1222
+source = "registry+https://github.com/rust-lang/crates.io-index"
1223
+checksum = "af9673d8203fcb076b19dfd17e38b3d4ae9f44959416ea532ce72415a6020365"
1224
+
1225
[[package]]
1226
name = "fancy-regex"
1227
version = "0.13.0"
@@ -1589,13 +1640,15 @@ dependencies = [
1640
"indicatif",
1641
"libc",
1642
"log",
1643
+ "native-tls",
1644
"num_cpus",
1645
"rand 0.9.2",
1594
- "reqwest",
1646
+ "reqwest 0.12.28",
1647
"serde",
1648
"serde_json",
1649
"thiserror 2.0.18",
1650
"tokio",
1651
+ "ureq",
1652
"windows-sys 0.61.2",
1653
]
1654
@@ -2167,22 +2220,27 @@ checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
2220
[[package]]
2221
name = "llm-multimodal"
2222
version = "1.7.1"
2170
-source = "git+https://github.com/smg-project/llm-multimodal?rev=c8a29dcc755139fdc26185f400ea48c6d6d48273#c8a29dcc755139fdc26185f400ea48c6d6d48273"
2223
+source = "git+https://github.com/smg-project/llm-multimodal?rev=7df38e53f99aefaebe86934f010aa8084ec99b2f#7df38e53f99aefaebe86934f010aa8084ec99b2f"
2224
dependencies = [
2225
"anyhow",
2226
"base64 0.22.1",
2227
"blake3",
2228
"bytes",
2229
+ "cc",
2230
"fast_image_resize",
2231
"hf-hub",
2232
"image",
2233
"libloading",
2234
"ndarray 0.17.2",
2235
"once_cell",
2182
- "reqwest",
2236
+ "pkg-config",
2237
+ "rayon",
2238
+ "reqwest 0.13.4",
2239
+ "rustfft",
2240
"serde",
2241
"serde_json",
2242
"serde_with",
2243
+ "symphonia",
2244
"tempfile",
2245
"thiserror 2.0.18",
2246
"tokio",
@@ -2740,6 +2798,15 @@ dependencies = [
2798
"pkg-config",
2799
]
2800
2801
+[[package]]
2802
+name = "pem-rfc7468"
2803
+version = "1.0.0"
2804
+source = "registry+https://github.com/rust-lang/crates.io-index"
2805
+checksum = "a6305423e0e7738146434843d1694d621cce767262b2a86910beab705e4493d9"
2806
+dependencies = [
2807
+ "base64ct",
2808
+]
2809
+
2810
[[package]]
2811
name = "percent-encoding"
2812
version = "2.3.2"
@@ -3245,9 +3312,9 @@ checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3"
3312
3313
[[package]]
3314
name = "rayon"
3248
-version = "1.11.0"
3315
+version = "1.12.0"
3316
source = "registry+https://github.com/rust-lang/crates.io-index"
3250
-checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f"
3317
+checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d"
3318
dependencies = [
3319
"either",
3320
"rayon-core",
@@ -3346,6 +3413,12 @@ dependencies = [
3413
"regex-syntax",
3414
]
3415
3416
+[[package]]
3417
+name = "regex-lite"
3418
+version = "0.1.9"
3419
+source = "registry+https://github.com/rust-lang/crates.io-index"
3420
+checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973"
3421
+
3422
[[package]]
3423
name = "regex-syntax"
3424
version = "0.8.10"
@@ -3392,7 +3465,43 @@ dependencies = [
3465
"url",
3466
"wasm-bindgen",
3467
"wasm-bindgen-futures",
3395
- "wasm-streams",
3468
+ "wasm-streams 0.4.2",
3469
+ "web-sys",
3470
+]
3471
+
3472
+[[package]]
3473
+name = "reqwest"
3474
+version = "0.13.4"
3475
+source = "registry+https://github.com/rust-lang/crates.io-index"
3476
+checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3"
3477
+dependencies = [
3478
+ "base64 0.22.1",
3479
+ "bytes",
3480
+ "futures-core",
3481
+ "futures-util",
3482
+ "http",
3483
+ "http-body",
3484
+ "http-body-util",
3485
+ "hyper",
3486
+ "hyper-tls",
3487
+ "hyper-util",
3488
+ "js-sys",
3489
+ "log",
3490
+ "native-tls",
3491
+ "percent-encoding",
3492
+ "pin-project-lite",
3493
+ "rustls-pki-types",
3494
+ "sync_wrapper",
3495
+ "tokio",
3496
+ "tokio-native-tls",
3497
+ "tokio-util",
3498
+ "tower",
3499
+ "tower-http",
3500
+ "tower-service",
3501
+ "url",
3502
+ "wasm-bindgen",
3503
+ "wasm-bindgen-futures",
3504
+ "wasm-streams 0.5.0",
3505
"web-sys",
3506
]
3507
@@ -3408,7 +3517,7 @@ dependencies = [
3517
"mime",
3518
"nom",
3519
"pin-project-lite",
3411
- "reqwest",
3520
+ "reqwest 0.12.28",
3521
"thiserror 1.0.69",
3522
]
3523
@@ -3526,7 +3635,9 @@ version = "0.23.37"
3635
source = "registry+https://github.com/rust-lang/crates.io-index"
3636
checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4"
3637
dependencies = [
3638
+ "log",
3639
"once_cell",
3640
+ "ring",
3641
"rustls-pki-types",
3642
"rustls-webpki",
3643
"subtle",
@@ -4052,6 +4163,17 @@ dependencies = [
4163
"windows-sys 0.61.2",
4164
]
4165
4166
+[[package]]
4167
+name = "socks"
4168
+version = "0.3.4"
4169
+source = "registry+https://github.com/rust-lang/crates.io-index"
4170
+checksum = "f0c3dbbd9ae980613c6dd8e28a9407b50509d3803b57624d5dfe8315218cd58b"
4171
+dependencies = [
4172
+ "byteorder",
4173
+ "libc",
4174
+ "winapi",
4175
+]
4176
+
4177
[[package]]
4178
name = "spm_precompiled"
4179
version = "0.1.4"
@@ -4127,6 +4249,203 @@ version = "2.6.1"
4249
source = "registry+https://github.com/rust-lang/crates.io-index"
4250
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
4251
4252
+[[package]]
4253
+name = "symphonia"
4254
+version = "0.6.0"
4255
+source = "registry+https://github.com/rust-lang/crates.io-index"
4256
+checksum = "1758d6c853020a7244de03cc3e0185eaea3f58715122422dd3cc7452e6d4c16a"
4257
+dependencies = [
4258
+ "lazy_static",
4259
+ "symphonia-bundle-flac",
4260
+ "symphonia-bundle-mp3",
4261
+ "symphonia-codec-aac",
4262
+ "symphonia-codec-adpcm",
4263
+ "symphonia-codec-alac",
4264
+ "symphonia-codec-pcm",
4265
+ "symphonia-codec-vorbis",
4266
+ "symphonia-core",
4267
+ "symphonia-format-caf",
4268
+ "symphonia-format-isomp4",
4269
+ "symphonia-format-mkv",
4270
+ "symphonia-format-ogg",
4271
+ "symphonia-format-riff",
4272
+ "symphonia-metadata",
4273
+]
4274
+
4275
+[[package]]
4276
+name = "symphonia-bundle-flac"
4277
+version = "0.6.0"
4278
+source = "registry+https://github.com/rust-lang/crates.io-index"
4279
+checksum = "ee69ad01236a67260b82fd1ff9790dd75ead29f2f46af145e63b7e72273e0e03"
4280
+dependencies = [
4281
+ "log",
4282
+ "symphonia-common",
4283
+ "symphonia-core",
4284
+ "symphonia-metadata",
4285
+]
4286
+
4287
+[[package]]
4288
+name = "symphonia-bundle-mp3"
4289
+version = "0.6.0"
4290
+source = "registry+https://github.com/rust-lang/crates.io-index"
4291
+checksum = "350f1f2f2e19ad4dd315db94304d1eb361b29af070681f94e51b8fdaad769546"
4292
+dependencies = [
4293
+ "lazy_static",
4294
+ "log",
4295
+ "symphonia-core",
4296
+]
4297
+
4298
+[[package]]
4299
+name = "symphonia-codec-aac"
4300
+version = "0.6.0"
4301
+source = "registry+https://github.com/rust-lang/crates.io-index"
4302
+checksum = "f1979c515a76371b186aad2feff5f23e21cbec775bf95de08bf1e3af92a2ad76"
4303
+dependencies = [
4304
+ "lazy_static",
4305
+ "log",
4306
+ "symphonia-common",
4307
+ "symphonia-core",
4308
+]
4309
+
4310
+[[package]]
4311
+name = "symphonia-codec-adpcm"
4312
+version = "0.6.0"
4313
+source = "registry+https://github.com/rust-lang/crates.io-index"
4314
+checksum = "4ebbdfd76d6cc5a601c6292a44357c5b7c82f2cd7cdc0f171421f5c5cff0ea1f"
4315
+dependencies = [
4316
+ "log",
4317
+ "symphonia-core",
4318
+]
4319
+
4320
+[[package]]
4321
+name = "symphonia-codec-alac"
4322
+version = "0.6.0"
4323
+source = "registry+https://github.com/rust-lang/crates.io-index"
4324
+checksum = "3a149cbfc7fb5c405d123a273227d31de17138419552112bf1aa7b73e65827b8"
4325
+dependencies = [
4326
+ "log",
4327
+ "symphonia-common",
4328
+ "symphonia-core",
4329
+]
4330
+
4331
+[[package]]
4332
+name = "symphonia-codec-pcm"
4333
+version = "0.6.0"
4334
+source = "registry+https://github.com/rust-lang/crates.io-index"
4335
+checksum = "50baee168f0e9dcf6ba7fc06e8b57eb62072a4490cc7cf13af77e72baae5d328"
4336
+dependencies = [
4337
+ "log",
4338
+ "symphonia-core",
4339
+]
4340
+
4341
+[[package]]
4342
+name = "symphonia-codec-vorbis"
4343
+version = "0.6.0"
4344
+source = "registry+https://github.com/rust-lang/crates.io-index"
4345
+checksum = "45b07b4423cd8e0fc472575909a5554b12c2f58e3c190b38c24f042e732fd8de"
4346
+dependencies = [
4347
+ "log",
4348
+ "symphonia-common",
4349
+ "symphonia-core",
4350
+]
4351
+
4352
+[[package]]
4353
+name = "symphonia-common"
4354
+version = "0.6.0"
4355
+source = "registry+https://github.com/rust-lang/crates.io-index"
4356
+checksum = "8257891ffa7f05e02b58f4761e2abf7e5278c8744fd59e981559e050f86eef55"
4357
+dependencies = [
4358
+ "log",
4359
+ "symphonia-core",
4360
+ "symphonia-metadata",
4361
+]
4362
+
4363
+[[package]]
4364
+name = "symphonia-core"
4365
+version = "0.6.0"
4366
+source = "registry+https://github.com/rust-lang/crates.io-index"
4367
+checksum = "95ec293b5f288383b72a7bffcade6b2860b642cf66f28b3bd5967349a49938b1"
4368
+dependencies = [
4369
+ "bitflags",
4370
+ "bytemuck",
4371
+ "lazy_static",
4372
+ "log",
4373
+ "num-complex",
4374
+ "smallvec",
4375
+]
4376
+
4377
+[[package]]
4378
+name = "symphonia-format-caf"
4379
+version = "0.6.0"
4380
+source = "registry+https://github.com/rust-lang/crates.io-index"
4381
+checksum = "cde3ca76633d3400ab57195456c09f8a58d775ff5452329f3f212b6efc8622f5"
4382
+dependencies = [
4383
+ "log",
4384
+ "symphonia-common",
4385
+ "symphonia-core",
4386
+]
4387
+
4388
+[[package]]
4389
+name = "symphonia-format-isomp4"
4390
+version = "0.6.0"
4391
+source = "registry+https://github.com/rust-lang/crates.io-index"
4392
+checksum = "2d179a01305b3505940135a9f0180d6ef4b487912748fe97554756f120fbd05e"
4393
+dependencies = [
4394
+ "log",
4395
+ "symphonia-common",
4396
+ "symphonia-core",
4397
+ "symphonia-metadata",
4398
+]
4399
+
4400
+[[package]]
4401
+name = "symphonia-format-mkv"
4402
+version = "0.6.0"
4403
+source = "registry+https://github.com/rust-lang/crates.io-index"
4404
+checksum = "fb17713e134f5ad316c2690fa3104590ccc85842cdbcf82c3cd1a845cb08aa74"
4405
+dependencies = [
4406
+ "lazy_static",
4407
+ "log",
4408
+ "symphonia-common",
4409
+ "symphonia-core",
4410
+]
4411
+
4412
+[[package]]
4413
+name = "symphonia-format-ogg"
4414
+version = "0.6.0"
4415
+source = "registry+https://github.com/rust-lang/crates.io-index"
4416
+checksum = "b05a67e02b1e4fca1a261ba4fe06910a9357489ad8c36aafdd2960e9c6559433"
4417
+dependencies = [
4418
+ "log",
4419
+ "symphonia-common",
4420
+ "symphonia-core",
4421
+ "symphonia-metadata",
4422
+]
4423
+
4424
+[[package]]
4425
+name = "symphonia-format-riff"
4426
+version = "0.6.0"
4427
+source = "registry+https://github.com/rust-lang/crates.io-index"
4428
+checksum = "17424452a777666d3eaf09a5c651029b15b6a333812fcc5b5474f2a3f0cff3f0"
4429
+dependencies = [
4430
+ "extended",
4431
+ "log",
4432
+ "symphonia-core",
4433
+ "symphonia-metadata",
4434
+]
4435
+
4436
+[[package]]
4437
+name = "symphonia-metadata"
4438
+version = "0.6.0"
4439
+source = "registry+https://github.com/rust-lang/crates.io-index"
4440
+checksum = "a31acf5cd623398a6208e2225d18f4b20f761c55098a796a5247ad516a4a8681"
4441
+dependencies = [
4442
+ "lazy_static",
4443
+ "log",
4444
+ "regex-lite",
4445
+ "smallvec",
4446
+ "symphonia-core",
4447
+]
4448
+
4449
[[package]]
4450
name = "syn"
4451
version = "1.0.109"
@@ -4982,6 +5301,42 @@ version = "0.9.0"
5301
source = "registry+https://github.com/rust-lang/crates.io-index"
5302
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
5303
5304
+[[package]]
5305
+name = "ureq"
5306
+version = "3.3.0"
5307
+source = "registry+https://github.com/rust-lang/crates.io-index"
5308
+checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0"
5309
+dependencies = [
5310
+ "base64 0.22.1",
5311
+ "cookie_store",
5312
+ "der",
5313
+ "flate2",
5314
+ "log",
5315
+ "native-tls",
5316
+ "percent-encoding",
5317
+ "rustls",
5318
+ "rustls-pki-types",
5319
+ "serde",
5320
+ "serde_json",
5321
+ "socks",
5322
+ "ureq-proto",
5323
+ "utf8-zero",
5324
+ "webpki-root-certs",
5325
+ "webpki-roots",
5326
+]
5327
+
5328
+[[package]]
5329
+name = "ureq-proto"
5330
+version = "0.6.0"
5331
+source = "registry+https://github.com/rust-lang/crates.io-index"
5332
+checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c"
5333
+dependencies = [
5334
+ "base64 0.22.1",
5335
+ "http",
5336
+ "httparse",
5337
+ "log",
5338
+]
5339
+
5340
[[package]]
5341
name = "url"
5342
version = "2.5.8"
@@ -5006,6 +5361,12 @@ version = "1.0.5"
5361
source = "registry+https://github.com/rust-lang/crates.io-index"
5362
checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246"
5363
5364
+[[package]]
5365
+name = "utf8-zero"
5366
+version = "0.8.1"
5367
+source = "registry+https://github.com/rust-lang/crates.io-index"
5368
+checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e"
5369
+
5370
[[package]]
5371
name = "utf8_iter"
5372
version = "1.0.4"
@@ -5097,7 +5458,7 @@ dependencies = [
5458
"ndarray 0.17.2",
5459
"oss-harmony",
5460
"paste",
5100
- "reqwest",
5461
+ "reqwest 0.13.4",
5462
"rmp-serde",
5463
"serde",
5464
"serde-json-fmt",
@@ -5344,7 +5705,7 @@ dependencies = [
5705
"futures",
5706
"hf-hub",
5707
"itertools 0.14.0",
5347
- "reqwest",
5708
+ "reqwest 0.12.28",
5709
"serde",
5710
"serde_json",
5711
"serde_with",
@@ -5368,7 +5729,7 @@ dependencies = [
5729
"criterion",
5730
"fastokens",
5731
"hf-hub",
5371
- "reqwest",
5732
+ "reqwest 0.12.28",
5733
"riptoken",
5734
"rustc-hash 1.1.0",
5735
"serde",
@@ -5531,6 +5892,19 @@ dependencies = [
5892
"web-sys",
5893
]
5894
5895
+[[package]]
5896
+name = "wasm-streams"
5897
+version = "0.5.0"
5898
+source = "registry+https://github.com/rust-lang/crates.io-index"
5899
+checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb"
5900
+dependencies = [
5901
+ "futures-util",
5902
+ "js-sys",
5903
+ "wasm-bindgen",
5904
+ "wasm-bindgen-futures",
5905
+ "web-sys",
5906
+]
5907
+
5908
[[package]]
5909
name = "wasmparser"
5910
version = "0.244.0"
@@ -5563,6 +5937,24 @@ dependencies = [
5937
"wasm-bindgen",
5938
]
5939
5940
+[[package]]
5941
+name = "webpki-root-certs"
5942
+version = "1.0.8"
5943
+source = "registry+https://github.com/rust-lang/crates.io-index"
5944
+checksum = "0d46a5a140e6f7afeccd8eae97eff335163939eac8b929834875168b29b3d267"
5945
+dependencies = [
5946
+ "rustls-pki-types",
5947
+]
5948
+
5949
+[[package]]
5950
+name = "webpki-roots"
5951
+version = "1.0.8"
5952
+source = "registry+https://github.com/rust-lang/crates.io-index"
5953
+checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf"
5954
+dependencies = [
5955
+ "rustls-pki-types",
5956
+]
5957
+
5958
[[package]]
5959
name = "weezl"
5960
version = "0.1.12"
rust/Cargo.toml
+2
-1
@@ -53,7 +53,7 @@ hyper-util = { version = "0.1.20", features = [
53
indexmap = "2.13.0"
54
itertools = "0.14.0"
55
libc = "0.2.177"
56
-llm-multimodal = { git = "https://github.com/smg-project/llm-multimodal", rev = "c8a29dcc755139fdc26185f400ea48c6d6d48273" }
56
+llm-multimodal = { git = "https://github.com/smg-project/llm-multimodal", rev = "7df38e53f99aefaebe86934f010aa8084ec99b2f", default-features = false, features = ["native-tls"] }
57
mimalloc = "0.1.52"
58
minijinja = { version = "2.0", features = ["unstable_machinery", "json", "builtins", "loader", "loop_controls", "preserve_order"] }
59
minijinja-contrib = { version = "2.0", features = ["pycompat"] }
@@ -73,6 +73,7 @@ pythonize = "0.28.0"
73
rand = "0.9.2"
74
reasoning-parser = "1.2.2"
75
reqwest = { version = "0.12.8", default-features = false, features = ["native-tls"] }
76
+reqwest-0-13 = { package = "reqwest", version = "0.13.4", default-features = false, features = ["native-tls"] }
77
riptoken = { version = "0.3.0", default-features = false }
78
rmp-serde = "1.3.1"
79
rmpv = { version = "1.3.1", features = ["with-serde"] }
rust/src/chat/Cargo.toml
+1
-1
@@ -16,7 +16,7 @@ llm-multimodal.workspace = true
16
minijinja.workspace = true
17
minijinja-contrib.workspace = true
18
openai-harmony.workspace = true
19
-reqwest.workspace = true
19
+reqwest-0-13.workspace = true
20
serde.workspace = true
21
serde-json-fmt.workspace = true
22
serde_json.workspace = true
rust/src/chat/src/backend/hf.rs
+1
@@ -151,6 +151,7 @@ fn resolve_multimodal_render_info(
151
info.map(|info| MultimodalRenderInfo {
152
image_token: info.placeholder_token(Modality::Image).map(str::to_string),
153
video_token: info.placeholder_token(Modality::Video).map(str::to_string),
154
+ audio_token: info.placeholder_token(Modality::Audio).map(str::to_string),
155
})
156
}
157
rust/src/chat/src/multimodal.rs
+202
-91
@@ -5,24 +5,24 @@
5
//!
6
//! This module owns the multimodal path for chat requests: it extracts media
7
//! parts from structured chat messages, fetches and preprocesses them through
8
-//! `llm-multimodal` one modality at a time, expands rendered prompt
9
-//! placeholders after tokenization, and builds the engine-facing
10
-//! `MmFeatures` payload.
8
+//! `llm-multimodal`, expands rendered prompt placeholders after tokenization,
9
+//! and builds the engine-facing `MmFeatures` payload.
10
//!
11
//! Raw media stays above `vllm-text`; this module lowers it into token IDs and
12
//! opaque tensor payloads before the request is handed to text generation.
13
15
-use std::collections::{HashMap, HashSet};
14
+use std::collections::HashSet;
15
use std::fs;
16
use std::path::Path;
17
use std::sync::{Arc, LazyLock};
18
19
use itertools::izip;
20
use llm_multimodal::{
22
- AsyncMultiModalTracker, FieldLayout, ImageFrame, MediaConnector, MediaConnectorConfig,
23
- MediaContentPart, Modality, ModelMetadata, ModelProcessorSpec, ModelRegistry,
24
- PreProcessorConfig, PreprocessedEncoderInputs, PromptReplacement, Tokenizer as TokenResolver,
25
- TrackedMedia, VideoClip, VisionPreProcessor, VisionProcessorRegistry,
21
+ AsyncMultiModalTracker, AudioClip, AudioPreProcessor, EncoderFieldLayouts, FieldLayout,
22
+ ImageFrame, MediaConnector, MediaConnectorConfig, MediaContentPart, Modality, ModelMetadata,
23
+ ModelProcessorSpec, ModelRegistry, PreProcessorConfig, PreprocessedEncoderInputs,
24
+ PromptReplacement, Tokenizer as TokenResolver, TrackedMedia, VideoClip, VisionPreProcessor,
25
+ VisionProcessorRegistry,
26
};
27
use thiserror_ext::AsReport as _;
28
use tracing::warn;
@@ -35,8 +35,10 @@ use crate::error::{Error, Result, bail_multimodal, multimodal};
35
use crate::renderer::RenderedPrompt;
36
use crate::request::{ChatContent, ChatContentPart, ChatMessage, ChatRequest};
37
38
+mod audio;
39
mod expand;
40
mod image;
41
+mod item;
42
mod tensor;
43
mod video;
44
@@ -46,9 +48,9 @@ use self::expand::expand_prompt_token_ids;
48
#[derive(Clone)]
49
pub struct MultimodalModelInfo {
50
context: MultimodalModelContext,
49
- spec: ResolvedMultimodalSpec,
51
image: Option<ModalitySupport>,
52
video: Option<ModalitySupport>,
53
+ audio: Option<AudioModalitySupport>,
54
media_connector: Arc<MediaConnector>,
55
}
56
@@ -88,22 +90,50 @@ impl MultimodalModelContext {
90
LazyLock::new(VisionProcessorRegistry::with_defaults);
91
REGISTRY.find(&self.model_id, self.model_type.as_deref())
92
}
93
+
94
+ /// Resolve an audio preprocessor for one loaded model.
95
+ fn resolve_audio_processor(
96
+ &self,
97
+ model_spec: &'static dyn ModelProcessorSpec,
98
+ preprocessor_config: &PreProcessorConfig,
99
+ ) -> Option<Arc<dyn AudioPreProcessor>> {
100
+ model_spec.audio_processor(&self.config, preprocessor_config).map(Arc::from)
101
+ }
102
}
103
93
-/// Static model-specific tensor-layout behavior shared across modalities.
104
+/// Static model-specific tensor-layout behavior for one modality.
105
#[derive(Clone)]
106
struct ResolvedMultimodalSpec {
107
raw: &'static dyn ModelProcessorSpec,
97
- field_layouts: HashMap<String, FieldLayout>,
108
+ modality: Modality,
109
+ field_layouts: EncoderFieldLayouts,
110
keep_on_cpu_keys: HashSet<String>,
111
}
112
113
impl ResolvedMultimodalSpec {
102
- fn new(raw: &'static dyn ModelProcessorSpec) -> Self {
114
+ fn new(raw: &'static dyn ModelProcessorSpec, modality: Modality) -> Self {
115
Self {
116
raw,
105
- field_layouts: raw.field_layouts(),
106
- keep_on_cpu_keys: raw.keep_on_cpu_keys().into_iter().collect(),
117
+ modality,
118
+ field_layouts: raw.encoder_field_layouts_for(modality),
119
+ keep_on_cpu_keys: raw.keep_on_cpu_keys_for(modality).into_iter().collect(),
120
+ }
121
+ }
122
+
123
+ fn primary_key(&self) -> &'static str {
124
+ match self.modality {
125
+ Modality::Image => image::IMAGE_PRIMARY_KEY,
126
+ Modality::Video => video::VIDEO_PRIMARY_KEY,
127
+ Modality::Audio => audio::AUDIO_PRIMARY_KEY,
128
+ Modality::ImageEmbeds => unreachable!("image embeds use no encoder preprocessor"),
129
+ }
130
+ }
131
+
132
+ fn field_layout_for(&self, key: &str) -> Option<&FieldLayout> {
133
+ if key == self.primary_key() {
134
+ Some(&self.field_layouts.encoder_input)
135
+ } else {
136
+ self.field_layouts.model_specific.get(key)
137
}
138
}
139
@@ -111,14 +141,15 @@ impl ResolvedMultimodalSpec {
141
&self,
142
context: &MultimodalModelContext,
143
preprocessed: &PreprocessedEncoderInputs,
114
- modality: Modality,
144
) -> Result<Vec<PromptReplacement>> {
116
- Ok(self.raw.prompt_replacements_for(&context.metadata(), preprocessed, modality)?)
145
+ Ok(self
146
+ .raw
147
+ .prompt_replacements_for(&context.metadata(), preprocessed, self.modality)?)
148
}
149
}
150
151
/// Resolved placeholder tokens for one modality.
121
-#[derive(Clone)]
152
+#[derive(Debug, Clone)]
153
struct ResolvedPlaceholder {
154
token: String,
155
/// The token ID emitted for `token` in the rendered prompt.
@@ -152,19 +183,30 @@ impl ResolvedPlaceholder {
183
}
184
}
185
155
-/// Static per-modality vision preprocessor plus its loaded config and
156
-/// resolved placeholder tokens.
186
+/// Static per-modality vision preprocessor plus its loaded config, resolved
187
+/// placeholder tokens, and the model's shared tensor-layout spec.
188
#[derive(Clone)]
189
struct ModalitySupport {
190
+ spec: ResolvedMultimodalSpec,
191
placeholder: ResolvedPlaceholder,
192
processor: &'static dyn VisionPreProcessor,
193
config: PreProcessorConfig,
194
}
195
196
+/// Static audio preprocessor plus the resolved model contract for its output.
197
+#[derive(Clone)]
198
+struct AudioModalitySupport {
199
+ spec: ResolvedMultimodalSpec,
200
+ placeholder: ResolvedPlaceholder,
201
+ processor: Arc<dyn AudioPreProcessor>,
202
+}
203
+
204
/// Model-repo config file locations consumed by multimodal support.
205
#[derive(Debug, Default, Clone, Copy)]
206
pub struct MultimodalConfigFiles<'a> {
207
pub config: Option<&'a Path>,
208
+ /// Shared preprocessor config (`preprocessor_config.json`), consumed by
209
+ /// image/audio processors and used as the video fallback.
210
pub preprocessor_config: Option<&'a Path>,
211
/// Video-specific preprocessor config (`video_preprocessor_config.json`).
212
pub video_preprocessor_config: Option<&'a Path>,
@@ -212,6 +254,8 @@ struct FetchedMedia {
254
image_uuids: Vec<Option<String>>,
255
videos: Vec<Arc<VideoClip>>,
256
video_uuids: Vec<Option<String>>,
257
+ audios: Vec<Arc<AudioClip>>,
258
+ audio_uuids: Vec<Option<String>>,
259
}
260
261
/// One modality's preprocessed output, ready for the shared expansion and
@@ -253,7 +297,7 @@ impl MultimodalModelInfo {
297
}
298
None => serde_json::Value::Object(Default::default()),
299
};
256
- let image_preprocessor_config = load_preprocessor_config(
300
+ let preprocessor_config = load_preprocessor_config(
301
files.preprocessor_config,
302
"preprocessor_config.json",
303
files.processor_config,
@@ -266,7 +310,7 @@ impl MultimodalModelInfo {
310
files.processor_config,
311
"video_processor",
312
)?
269
- .unwrap_or_else(|| image_preprocessor_config.clone());
313
+ .unwrap_or_else(|| preprocessor_config.clone());
314
315
let context = MultimodalModelContext {
316
model_id,
@@ -275,41 +319,76 @@ impl MultimodalModelInfo {
319
tokenizer: TokenizerResolver(tokenizer),
320
};
321
278
- Self::from_loaded(
279
- context,
280
- image_preprocessor_config,
281
- video_preprocessor_config,
282
- )
322
+ Self::from_loaded(context, preprocessor_config, video_preprocessor_config)
323
}
324
325
/// Resolve multimodal support from an assembled context and parsed
326
/// preprocessor configs.
327
fn from_loaded(
328
context: MultimodalModelContext,
289
- image_preprocessor_config: PreProcessorConfig,
329
+ preprocessor_config: PreProcessorConfig,
330
video_preprocessor_config: PreProcessorConfig,
331
) -> Result<Option<Self>> {
292
- let Some(raw_spec) = context.resolve_model_spec() else {
332
+ let (image, video) = Self::resolve_vision_lanes(
333
+ &context,
334
+ preprocessor_config.clone(),
335
+ video_preprocessor_config,
336
+ );
337
+ let audio = Self::resolve_audio_lane(&context, &preprocessor_config)?;
338
+
339
+ if image.is_none() && video.is_none() && audio.is_none() {
340
warn!(
341
model_id = context.model_id,
342
model_type = context.model_type,
296
- "multimodal model spec is not registered; disabling multimodal support for this model"
343
+ "no multimodal modality resolved; disabling multimodal support for this model"
344
);
345
return Ok(None);
346
+ }
347
+
348
+ let media_connector = Arc::new(MediaConnector::new(
349
+ reqwest_0_13::Client::new(),
350
+ MediaConnectorConfig::default(),
351
+ )?);
352
+
353
+ Ok(Some(Self {
354
+ context,
355
+ image,
356
+ video,
357
+ audio,
358
+ media_connector,
359
+ }))
360
+ }
361
+
362
+ /// Resolve the spec-backed vision lanes (image and video).
363
+ ///
364
+ /// A missing model spec or vision processor disables both lanes but is
365
+ /// not an error: unsupported model families are logged and skipped.
366
+ fn resolve_vision_lanes(
367
+ context: &MultimodalModelContext,
368
+ preprocessor_config: PreProcessorConfig,
369
+ video_preprocessor_config: PreProcessorConfig,
370
+ ) -> (Option<ModalitySupport>, Option<ModalitySupport>) {
371
+ let Some(raw_spec) = context.resolve_model_spec() else {
372
+ warn!(
373
+ model_id = context.model_id,
374
+ model_type = context.model_type,
375
+ "multimodal model spec is not registered; disabling image/video support for this model"
376
+ );
377
+ return (None, None);
378
};
379
380
let Some(processor) = context.resolve_vision_processor() else {
381
warn!(
382
model_id = context.model_id,
383
model_type = context.model_type,
305
- "vision processor is not registered; disabling multimodal support for this model"
384
+ "vision processor is not registered; disabling image/video support for this model"
385
);
307
- return Ok(None);
386
+ return (None, None);
387
};
388
389
// Warn and disable the modality if the placeholder resolution fails.
390
let resolve_placeholder =
312
- |modality: Modality| match ResolvedPlaceholder::resolve(raw_spec, &context, modality) {
391
+ |modality: Modality| match ResolvedPlaceholder::resolve(raw_spec, context, modality) {
392
Ok(placeholder) => Some(placeholder),
393
Err(error) => {
394
warn!(
@@ -323,9 +402,10 @@ impl MultimodalModelInfo {
402
};
403
404
let image = resolve_placeholder(Modality::Image).map(|placeholder| ModalitySupport {
405
+ spec: ResolvedMultimodalSpec::new(raw_spec, Modality::Image),
406
placeholder,
407
processor,
328
- config: image_preprocessor_config,
408
+ config: preprocessor_config.clone(),
409
});
410
411
let video = resolve_placeholder(Modality::Video).and_then(|placeholder| {
@@ -341,6 +421,7 @@ impl MultimodalModelInfo {
421
None
422
} else {
423
Some(ModalitySupport {
424
+ spec: ResolvedMultimodalSpec::new(raw_spec, Modality::Video),
425
placeholder,
426
processor,
427
config: video_preprocessor_config,
@@ -348,26 +429,33 @@ impl MultimodalModelInfo {
429
}
430
});
431
351
- if image.is_none() && video.is_none() {
432
+ (image, video)
433
+ }
434
+
435
+ /// Resolve a spec-backed audio lane when the model advertises audio support.
436
+ fn resolve_audio_lane(
437
+ context: &MultimodalModelContext,
438
+ preprocessor_config: &PreProcessorConfig,
439
+ ) -> Result<Option<AudioModalitySupport>> {
440
+ let Some(raw_spec) = context.resolve_model_spec() else {
441
+ return Ok(None);
442
+ };
443
+ if !raw_spec.modality_limits(&context.metadata())?.contains_key(&Modality::Audio) {
444
+ return Ok(None);
445
+ }
446
+
447
+ let Some(processor) = context.resolve_audio_processor(raw_spec, preprocessor_config) else {
448
warn!(
449
model_id = context.model_id,
354
- model_type = context.model_type,
355
- "no multimodal modality resolved; disabling multimodal support for this model"
450
+ model_spec = raw_spec.name(),
451
+ "model spec does not provide an audio processor; disabling audio support for this model"
452
);
453
return Ok(None);
358
- }
359
-
360
- let media_connector = Arc::new(MediaConnector::new(
361
- reqwest::Client::new(),
362
- MediaConnectorConfig::default(),
363
- )?);
364
-
365
- Ok(Some(Self {
366
- context,
367
- spec: ResolvedMultimodalSpec::new(raw_spec),
368
- image,
369
- video,
370
- media_connector,
454
+ };
455
+ Ok(Some(AudioModalitySupport {
456
+ spec: ResolvedMultimodalSpec::new(raw_spec, Modality::Audio),
457
+ placeholder: ResolvedPlaceholder::resolve(raw_spec, context, Modality::Audio)?,
458
+ processor,
459
}))
460
}
461
@@ -380,7 +468,8 @@ impl MultimodalModelInfo {
468
match modality {
469
Modality::Image => self.image.as_ref()?.placeholder.token.as_str().into(),
470
Modality::Video => self.video.as_ref()?.placeholder.token.as_str().into(),
383
- _ => None,
471
+ Modality::Audio => self.audio.as_ref()?.placeholder.token.as_str().into(),
472
+ Modality::ImageEmbeds => None,
473
}
474
}
475
}
@@ -449,12 +538,35 @@ fn extract_media_parts(request: &ChatRequest) -> Result<Vec<MediaContentPart>> {
538
uuid: uuid.clone(),
539
})
540
}
541
+ ChatContentPart::InputAudio { data, format, uuid } => {
542
+ all_parts.push(MediaContentPart::AudioUrl {
543
+ url: input_audio_data_url(data, format.as_deref())?,
544
+ uuid: uuid.clone(),
545
+ })
546
+ }
547
+ ChatContentPart::AudioUrl { audio_url, uuid } => {
548
+ all_parts.push(MediaContentPart::AudioUrl {
549
+ url: audio_url.clone(),
550
+ uuid: uuid.clone(),
551
+ })
552
+ }
553
}
554
}
555
}
556
Ok(all_parts)
557
}
558
559
+/// Wrap OpenAI base64 audio in a data URL consumed by `MediaConnector`.
560
+fn input_audio_data_url(data: &str, format: Option<&str>) -> Result<String> {
561
+ let mime_type = match format {
562
+ Some(format) if format.eq_ignore_ascii_case("wav") => "audio/wav",
563
+ Some(format) if format.eq_ignore_ascii_case("mp3") => "audio/mpeg",
564
+ Some(format) => bail_multimodal!("unsupported input_audio format `{format}`"),
565
+ None => "application/octet-stream",
566
+ };
567
+ Ok(format!("data:{mime_type};base64,{data}"))
568
+}
569
+
570
impl MultimodalModelInfo {
571
/// Run media fetch, per-modality preprocessing, prompt expansion, and
572
/// feature build.
@@ -468,11 +580,10 @@ impl MultimodalModelInfo {
580
prompt_token_ids: &mut Vec<u32>,
581
model_dtype: ModelDtype,
582
) -> Result<MmFeatures> {
471
- if media_parts.is_empty() {
583
+ let media_parts_len = media_parts.len();
584
+ if media_parts_len == 0 {
585
return Ok(Vec::new());
586
}
474
- let media_parts_len = media_parts.len();
475
-
587
// TODO: enforce per-modality item-count limits, aligned with the
588
// engine's `--limit-mm-per-prompt` semantics.
589
let fetched = self.fetch_media(media_parts).await?;
@@ -486,6 +597,9 @@ impl MultimodalModelInfo {
597
prepared
598
.push(self.prepare_videos(fetched.videos, fetched.video_uuids, model_dtype).await?);
599
}
600
+ if !fetched.audios.is_empty() {
601
+ prepared.push(self.prepare_audios(fetched.audios, fetched.audio_uuids).await?);
602
+ }
603
604
let mut ranges = expand_prompt_token_ids(prompt_token_ids, &prepared)?;
605
@@ -524,8 +638,8 @@ impl MultimodalModelInfo {
638
Ok(features)
639
}
640
527
- /// Fetch all media parts and split them per modality, preserving their
528
- /// request-order UUID metadata.
641
+ /// Fetch all connector-backed media parts and split them per modality,
642
+ /// preserving their request-order UUID metadata.
643
async fn fetch_media(&self, media_parts: Vec<MediaContentPart>) -> Result<FetchedMedia> {
644
let mut tracker = AsyncMultiModalTracker::new(Arc::clone(&self.media_connector));
645
for part in media_parts {
@@ -562,11 +676,27 @@ impl MultimodalModelInfo {
676
.collect::<Result<Vec<_>>>()?;
677
let video_uuids = tracker_output.uuids.remove(&Modality::Video).unwrap_or_default();
678
679
+ let audios = tracker_output
680
+ .data
681
+ .remove(&Modality::Audio)
682
+ .unwrap_or_default()
683
+ .into_iter()
684
+ .map(|media| match media {
685
+ TrackedMedia::Audio(clip) => Ok(clip),
686
+ _ => Err(multimodal!(
687
+ "tracker returned non-audio media for the audio modality"
688
+ )),
689
+ })
690
+ .collect::<Result<Vec<_>>>()?;
691
+ let audio_uuids = tracker_output.uuids.remove(&Modality::Audio).unwrap_or_default();
692
+
693
Ok(FetchedMedia {
694
images,
695
image_uuids,
696
videos,
697
video_uuids,
698
+ audios,
699
+ audio_uuids,
700
})
701
}
702
}
@@ -665,42 +795,6 @@ mod tests {
795
test_info("qwen3_vl", config, qwen3_vl_tokenizer())
796
}
797
668
- #[test]
669
- fn from_paths_resolves_image_config_from_processor_config() {
670
- let dir = tempfile::tempdir().unwrap();
671
- let config_path = dir.path().join("config.json");
672
- std::fs::write(
673
- &config_path,
674
- serde_json::json!({
675
- "model_type": "qwen3_vl",
676
- "image_token_id": QWEN3_IMAGE_PAD_ID,
677
- })
678
- .to_string(),
679
- )
680
- .unwrap();
681
- let processor_config_path = dir.path().join("processor_config.json");
682
- std::fs::write(
683
- &processor_config_path,
684
- r#"{"image_processor":{"size":{"shortest_edge":64}}}"#,
685
- )
686
- .unwrap();
687
-
688
- let info = MultimodalModelInfo::from_paths(
689
- "qwen3-vl-test".to_string(),
690
- Some("qwen3_vl".to_string()),
691
- MultimodalConfigFiles {
692
- config: Some(&config_path),
693
- processor_config: Some(&processor_config_path),
694
- ..Default::default()
695
- },
696
- Arc::new(qwen3_vl_tokenizer()),
697
- )
698
- .unwrap()
699
- .unwrap();
700
-
701
- assert_eq!(info.image.unwrap().config.get_shortest_edge(), Some(64));
702
- }
703
-
798
#[test]
799
fn qwen3_vl_resolves_image_and_video_support() {
800
let info = qwen3_vl_info();
@@ -742,4 +836,21 @@ mod tests {
836
assert_eq!(info.placeholder_token(Modality::Image), Some("<|image|>"));
837
assert_eq!(info.placeholder_token(Modality::Video), None);
838
}
839
+
840
+ #[test]
841
+ fn input_audio_uses_connector_data_urls() {
842
+ assert_eq!(
843
+ input_audio_data_url("AAAA", Some("wav")).unwrap(),
844
+ "data:audio/wav;base64,AAAA"
845
+ );
846
+ assert_eq!(
847
+ input_audio_data_url("BBBB", Some("MP3")).unwrap(),
848
+ "data:audio/mpeg;base64,BBBB"
849
+ );
850
+ assert_eq!(
851
+ input_audio_data_url("CCCC", None).unwrap(),
852
+ "data:application/octet-stream;base64,CCCC"
853
+ );
854
+ assert!(input_audio_data_url("AAAA", Some("flac")).is_err());
855
+ }
856
}
rust/src/chat/src/multimodal/audio.rs
new
+213
@@ -0,0 +1,213 @@
1
+// SPDX-License-Identifier: Apache-2.0
2
+// SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+
4
+//! Audio-modality preparation through `llm-multimodal`.
5
+
6
+use std::sync::Arc;
7
+
8
+use llm_multimodal::{AudioClip, Modality, PreprocessedEncoderInputs};
9
+use vllm_engine_core_client::protocol::dtype::ModelDtype;
10
+
11
+use super::{AudioModalitySupport, MultimodalModelInfo, PreparedMedia, item};
12
+use crate::error::{Error, Result, bail_multimodal, multimodal};
13
+
14
+/// Forward-kwargs name of the primary audio encoder input.
15
+pub(super) const AUDIO_PRIMARY_KEY: &str = "input_audio_features";
16
+
17
+impl MultimodalModelInfo {
18
+ /// Preprocess fetched audio clips as one batch and build per-item features.
19
+ pub(super) async fn prepare_audios(
20
+ &self,
21
+ clips: Vec<Arc<AudioClip>>,
22
+ uuids: Vec<Option<String>>,
23
+ ) -> Result<PreparedMedia> {
24
+ let support = self.audio.as_ref().ok_or_else(|| Error::UnsupportedModality {
25
+ modality: Modality::Audio.to_string(),
26
+ })?;
27
+ let preprocessed = self.preprocess_audios(support, &clips).await?;
28
+ let replacements = support.spec.prompt_replacements_for(&self.context, &preprocessed)?;
29
+ if replacements.len() != clips.len() {
30
+ bail_multimodal!(
31
+ "number of audio prompt replacements {} does not match number of audio clips {}",
32
+ replacements.len(),
33
+ clips.len()
34
+ );
35
+ }
36
+
37
+ let hashes = clips.iter().map(|clip| clip.hash.clone()).collect();
38
+ let items = item::build_batched_items(
39
+ &support.spec,
40
+ preprocessed,
41
+ hashes,
42
+ uuids,
43
+ ModelDtype::Float32,
44
+ )?;
45
+
46
+ Ok(PreparedMedia {
47
+ modality: Modality::Audio,
48
+ placeholder: support.placeholder.clone(),
49
+ replacements,
50
+ items,
51
+ })
52
+ }
53
+
54
+ /// Run CPU-heavy audio preprocessing in a blocking task.
55
+ async fn preprocess_audios(
56
+ &self,
57
+ support: &AudioModalitySupport,
58
+ clips: &[Arc<AudioClip>],
59
+ ) -> Result<PreprocessedEncoderInputs> {
60
+ let processor = Arc::clone(&support.processor);
61
+ let clips = clips.to_vec();
62
+ tokio::task::spawn_blocking(move || Ok(processor.preprocess(&clips)?))
63
+ .await
64
+ .map_err(|error| multimodal!("audio preprocessing task failed: {error}"))?
65
+ }
66
+}
67
+
68
+#[cfg(test)]
69
+mod tests {
70
+ use std::collections::HashMap;
71
+ use std::sync::Arc;
72
+
73
+ use llm_multimodal::{MediaContentPart, ModelSpecificValue, PreProcessorConfig};
74
+ use ndarray::ArrayD;
75
+ use vllm_engine_core_client::protocol::multimodal::{MmField, MmKwargValue};
76
+ use vllm_tokenizer::test_utils::TestTokenizer;
77
+
78
+ use super::super::{MultimodalModelContext, TokenizerResolver};
79
+ use super::*;
80
+
81
+ const AUDIO_PAD_ID: u32 = 151_676;
82
+
83
+ fn qwen3_asr_info() -> MultimodalModelInfo {
84
+ let context = MultimodalModelContext {
85
+ model_id: "Qwen/Qwen3-ASR-1.7B".to_string(),
86
+ model_type: Some("qwen3_asr".to_string()),
87
+ config: serde_json::json!({"model_type": "qwen3_asr"}),
88
+ tokenizer: TokenizerResolver(Arc::new(
89
+ TestTokenizer::new().with_regular_token("<|audio_pad|>", AUDIO_PAD_ID),
90
+ )),
91
+ };
92
+
93
+ MultimodalModelInfo::from_loaded(
94
+ context,
95
+ PreProcessorConfig::default(),
96
+ PreProcessorConfig::default(),
97
+ )
98
+ .unwrap()
99
+ .expect("Qwen3-ASR multimodal support")
100
+ }
101
+
102
+ fn wav_i16_mono(sample_rate: u32, samples: &[i16]) -> Vec<u8> {
103
+ let data_bytes = samples.len() as u32 * 2;
104
+ let mut bytes = Vec::new();
105
+ bytes.extend_from_slice(b"RIFF");
106
+ bytes.extend_from_slice(&(36 + data_bytes).to_le_bytes());
107
+ bytes.extend_from_slice(b"WAVEfmt ");
108
+ bytes.extend_from_slice(&16_u32.to_le_bytes());
109
+ bytes.extend_from_slice(&1_u16.to_le_bytes());
110
+ bytes.extend_from_slice(&1_u16.to_le_bytes());
111
+ bytes.extend_from_slice(&sample_rate.to_le_bytes());
112
+ bytes.extend_from_slice(&(sample_rate * 2).to_le_bytes());
113
+ bytes.extend_from_slice(&2_u16.to_le_bytes());
114
+ bytes.extend_from_slice(&16_u16.to_le_bytes());
115
+ bytes.extend_from_slice(b"data");
116
+ bytes.extend_from_slice(&data_bytes.to_le_bytes());
117
+ for sample in samples {
118
+ bytes.extend_from_slice(&sample.to_le_bytes());
119
+ }
120
+ bytes
121
+ }
122
+
123
+ #[test]
124
+ fn resolves_qwen_audio_from_model_spec() {
125
+ let info = qwen3_asr_info();
126
+ let support = info.audio.as_ref().expect("audio support");
127
+
128
+ assert_eq!(
129
+ info.placeholder_token(Modality::Audio),
130
+ Some("<|audio_pad|>")
131
+ );
132
+ assert_eq!(support.placeholder.marker_token_id, AUDIO_PAD_ID);
133
+ assert_eq!(support.placeholder.embed_token_id, AUDIO_PAD_ID);
134
+ assert!(matches!(
135
+ support.spec.field_layouts.encoder_input,
136
+ llm_multimodal::FieldLayout::Batched
137
+ ));
138
+
139
+ let preprocessed = PreprocessedEncoderInputs {
140
+ encoder_input: ArrayD::zeros(vec![1, 128, 4]),
141
+ feature_token_counts: vec![1],
142
+ item_sizes: vec![(128, 4)],
143
+ model_specific: HashMap::from([
144
+ (
145
+ "feature_attention_mask".to_string(),
146
+ ModelSpecificValue::int_2d(vec![1; 4], 1, 4),
147
+ ),
148
+ (
149
+ "audio_feature_lengths".to_string(),
150
+ ModelSpecificValue::int_1d(vec![4]),
151
+ ),
152
+ ]),
153
+ };
154
+ let item = item::build_batched_items(
155
+ &support.spec,
156
+ preprocessed,
157
+ vec!["<hash>".to_string()],
158
+ vec![None],
159
+ ModelDtype::Float32,
160
+ )
161
+ .unwrap()
162
+ .pop()
163
+ .unwrap();
164
+
165
+ assert!(matches!(
166
+ &item.data[AUDIO_PRIMARY_KEY].field,
167
+ MmField::Batched(_)
168
+ ));
169
+ }
170
+
171
+ #[tokio::test]
172
+ async fn tracker_processor_and_lowering_preserve_audio_contract() {
173
+ let info = qwen3_asr_info();
174
+ let wav = wav_i16_mono(16_000, &[0; 1_600]);
175
+ let expected_hash = llm_multimodal::hasher::hash_audio(&wav);
176
+ let fetched = info
177
+ .fetch_media(vec![MediaContentPart::AudioData {
178
+ data: wav,
179
+ mime_type: Some("audio/wav".to_string()),
180
+ uuid: Some("audio-1".to_string()),
181
+ }])
182
+ .await
183
+ .unwrap();
184
+
185
+ let prepared = info.prepare_audios(fetched.audios, fetched.audio_uuids).await.unwrap();
186
+
187
+ assert_eq!(prepared.replacements.len(), 1);
188
+ assert!(
189
+ prepared.replacements[0]
190
+ .tokens
191
+ .iter()
192
+ .all(|token| *token == AUDIO_PAD_ID as i32)
193
+ );
194
+ let item = &prepared.items[0];
195
+ assert_eq!(item.hash, expected_hash);
196
+ assert_eq!(item.uuid.as_deref(), Some("audio-1"));
197
+
198
+ let features = &item.data[AUDIO_PRIMARY_KEY];
199
+ assert!(matches!(&features.field, MmField::Batched(_)));
200
+ assert!(matches!(
201
+ features.data.as_ref(),
202
+ Some(MmKwargValue::Tensor(tensor))
203
+ if tensor.dtype == "float32" && tensor.shape.first() == Some(&128)
204
+ ));
205
+ let lengths = &item.data["audio_feature_lengths"];
206
+ assert!(matches!(&lengths.field, MmField::Batched(_)));
207
+ assert!(matches!(
208
+ lengths.data.as_ref(),
209
+ Some(MmKwargValue::Tensor(tensor))
210
+ if tensor.dtype == "int64" && tensor.shape.is_empty()
211
+ ));
212
+ }
213
+}
rust/src/chat/src/multimodal/expand.rs
+61
@@ -418,6 +418,67 @@ mod tests {
418
assert_bool_mask(&video_ranges[0], &[true, true, true, true]);
419
}
420
421
+ const QWEN3_AUDIO_PAD_ID: u32 = 151_676;
422
+
423
+ fn qwen3_audio_prepared(replacements: Vec<PromptReplacement>) -> PreparedMedia {
424
+ prepared_media(
425
+ Modality::Audio,
426
+ "<|audio_pad|>",
427
+ QWEN3_AUDIO_PAD_ID,
428
+ QWEN3_AUDIO_PAD_ID,
429
+ replacements,
430
+ )
431
+ }
432
+
433
+ fn qwen3_audio_replacement(tokens: usize) -> PromptReplacement {
434
+ PromptReplacement::repeated(
435
+ Modality::Audio,
436
+ "<|audio_pad|>",
437
+ QWEN3_AUDIO_PAD_ID as TokenId,
438
+ tokens,
439
+ )
440
+ }
441
+
442
+ #[test]
443
+ fn expand_prompt_tokens_interleaves_audio_and_image_prepared_media() {
444
+ // Audio marker precedes the image marker: the image range offset must
445
+ // account for the earlier audio expansion growth.
446
+ let mut prompt_token_ids = vec![1, QWEN3_AUDIO_PAD_ID, 2, LLAMA4_IMAGE_ID, 3];
447
+ let prepared = vec![
448
+ llama4_prepared(vec![llama4_single_tile_replacement()]),
449
+ qwen3_audio_prepared(vec![qwen3_audio_replacement(3)]),
450
+ ];
451
+
452
+ let ranges = expand_prompt_token_ids(&mut prompt_token_ids, &prepared).unwrap();
453
+
454
+ assert_eq!(
455
+ prompt_token_ids,
456
+ vec![
457
+ 1,
458
+ QWEN3_AUDIO_PAD_ID,
459
+ QWEN3_AUDIO_PAD_ID,
460
+ QWEN3_AUDIO_PAD_ID,
461
+ 2,
462
+ LLAMA4_IMAGE_START_ID,
463
+ LLAMA4_IMAGE_ID,
464
+ LLAMA4_PATCH_ID,
465
+ LLAMA4_PATCH_ID,
466
+ LLAMA4_IMAGE_END_ID,
467
+ 3,
468
+ ]
469
+ );
470
+
471
+ let audio_ranges = &ranges[&Modality::Audio];
472
+ assert_eq!(audio_ranges[0].offset, 1);
473
+ assert_eq!(audio_ranges[0].length, 3);
474
+ assert_bool_mask(&audio_ranges[0], &[true, true, true]);
475
+
476
+ let image_ranges = &ranges[&Modality::Image];
477
+ assert_eq!(image_ranges[0].offset, 5);
478
+ assert_eq!(image_ranges[0].length, 5);
479
+ assert_bool_mask(&image_ranges[0], &[false, false, true, true, false]);
480
+ }
481
+
482
#[test]
483
fn expand_prompt_tokens_error_names_modality_with_leftover_replacements() {
484
let mut prompt_token_ids = vec![1, QWEN3_IMAGE_PAD_ID, 2];
rust/src/chat/src/multimodal/image.rs
+9
-82
@@ -6,17 +6,15 @@
6
7
use std::sync::Arc;
8
9
-use itertools::izip;
10
-use llm_multimodal::{FieldLayout, ImageFrame, Modality, PreprocessedEncoderInputs};
9
+use llm_multimodal::{ImageFrame, Modality, PreprocessedEncoderInputs};
10
use vllm_engine_core_client::protocol::dtype::ModelDtype;
12
-use vllm_engine_core_client::protocol::multimodal::{
13
- MmBatchedField, MmField, MmFieldElem, MmFlatField, MmKwargsItem, MmSharedField, MmSlice,
14
- SliceSpec,
15
-};
11
17
-use super::{ModalitySupport, MultimodalModelInfo, PreparedItem, PreparedMedia, tensor};
12
+use super::{ModalitySupport, MultimodalModelInfo, PreparedMedia, item};
13
use crate::error::{Error, Result, bail_multimodal, multimodal};
14
15
+/// Forward-kwargs name of the primary image encoder input.
16
+pub(super) const IMAGE_PRIMARY_KEY: &str = "pixel_values";
17
+
18
impl MultimodalModelInfo {
19
/// Preprocess all fetched image frames as one batch and build per-item
20
/// features.
@@ -30,9 +28,7 @@ impl MultimodalModelInfo {
28
modality: Modality::Image.to_string(),
29
})?;
30
let preprocessed = self.preprocess_images(support, &frames).await?;
33
- let replacements =
34
- self.spec
35
- .prompt_replacements_for(&self.context, &preprocessed, Modality::Image)?;
31
+ let replacements = support.spec.prompt_replacements_for(&self.context, &preprocessed)?;
32
if replacements.len() != frames.len() {
33
bail_multimodal!(
34
"number of image prompt replacements {} does not match number of images {}",
@@ -40,7 +36,9 @@ impl MultimodalModelInfo {
36
frames.len()
37
);
38
}
43
- let items = self.build_image_items(preprocessed, &frames, uuids, model_dtype)?;
39
+ let hashes = frames.iter().map(|frame| frame.hash.clone()).collect();
40
+ let items =
41
+ item::build_batched_items(&support.spec, preprocessed, hashes, uuids, model_dtype)?;
42
43
Ok(PreparedMedia {
44
modality: Modality::Image,
@@ -70,75 +68,4 @@ impl MultimodalModelInfo {
68
.await
69
.map_err(|error| multimodal!("image preprocessing task failed: {error}"))?
70
}
73
-
74
- /// Convert one batch of preprocessed image tensors into per-item engine
75
- /// kwargs.
76
- ///
77
- /// Tensor fields are sliced per item according to the model spec's field
78
- /// layout declarations.
79
- fn build_image_items(
80
- &self,
81
- preprocessed: PreprocessedEncoderInputs,
82
- frames: &[Arc<ImageFrame>],
83
- uuids: Vec<Option<String>>,
84
- model_dtype: ModelDtype,
85
- ) -> Result<Vec<PreparedItem>> {
86
- let len = frames.len();
87
- let tensors = tensor::collect_tensors(preprocessed, "pixel_values", model_dtype)?;
88
-
89
- let mut items = Vec::with_capacity(len);
90
- for (index, (frame, uuid)) in izip!(frames, uuids).enumerate() {
91
- let mut data = MmKwargsItem::new();
92
- for (key, tensor) in &tensors {
93
- let keep_on_cpu = self.spec.keep_on_cpu_keys.contains(key);
94
- let (value, field) = match self.spec.field_layouts.get(key) {
95
- Some(FieldLayout::Batched) => (
96
- tensor.batched_value_at(index)?,
97
- MmField::Batched(MmBatchedField { keep_on_cpu }),
98
- ),
99
- Some(FieldLayout::Flat { sizes_key }) => {
100
- let sizes = tensors.get(sizes_key).ok_or_else(|| {
101
- multimodal!("flat tensor sizes key `{sizes_key}` is missing")
102
- })?;
103
- let (start, end) = tensor::flat_range_for_index(sizes, sizes_key, index)?;
104
- (
105
- tensor.flat_value_range(start, end)?,
106
- MmField::Flat(MmFlatField {
107
- slices: vec![MmSlice::Slice(SliceSpec {
108
- start: Some(0),
109
- stop: Some((end - start) as isize),
110
- step: None,
111
- })],
112
- dim: 0,
113
- keep_on_cpu,
114
- }),
115
- )
116
- }
117
- None => (
118
- tensor.clone(),
119
- MmField::Shared(MmSharedField {
120
- batch_size: len,
121
- keep_on_cpu,
122
- }),
123
- ),
124
- };
125
-
126
- data.insert(
127
- key.clone(),
128
- MmFieldElem {
129
- data: Some(value.try_into()?),
130
- field,
131
- },
132
- );
133
- }
134
-
135
- items.push(PreparedItem {
136
- data,
137
- hash: frame.hash.clone(),
138
- uuid,
139
- });
140
- }
141
-
142
- Ok(items)
143
- }
71
}
rust/src/chat/src/multimodal/item.rs
new
+84
@@ -0,0 +1,84 @@
1
+// SPDX-License-Identifier: Apache-2.0
2
+// SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+
4
+//! Shared lowering from batched preprocessor output to per-item engine kwargs.
5
+
6
+use itertools::izip;
7
+use llm_multimodal::{FieldLayout, PreprocessedEncoderInputs};
8
+use vllm_engine_core_client::protocol::dtype::ModelDtype;
9
+use vllm_engine_core_client::protocol::multimodal::{
10
+ MmBatchedField, MmField, MmFieldElem, MmFlatField, MmKwargsItem, MmSharedField, MmSlice,
11
+ SliceSpec,
12
+};
13
+
14
+use super::{PreparedItem, ResolvedMultimodalSpec, tensor};
15
+use crate::error::{Result, bail_multimodal, multimodal};
16
+
17
+/// Split one batch of preprocessed tensors into engine kwargs per media item.
18
+pub(super) fn build_batched_items(
19
+ spec: &ResolvedMultimodalSpec,
20
+ preprocessed: PreprocessedEncoderInputs,
21
+ hashes: Vec<String>,
22
+ uuids: Vec<Option<String>>,
23
+ float_dtype: ModelDtype,
24
+) -> Result<Vec<PreparedItem>> {
25
+ let len = hashes.len();
26
+ if uuids.len() != len {
27
+ bail_multimodal!(
28
+ "number of media UUIDs {} does not match number of media items {len}",
29
+ uuids.len()
30
+ );
31
+ }
32
+ let tensors = tensor::collect_tensors(preprocessed, spec.primary_key(), float_dtype)?;
33
+
34
+ let mut items = Vec::with_capacity(len);
35
+ for (index, (hash, uuid)) in izip!(hashes, uuids).enumerate() {
36
+ let mut data = MmKwargsItem::new();
37
+ for (key, tensor) in &tensors {
38
+ let keep_on_cpu = spec.keep_on_cpu_keys.contains(key);
39
+ let (value, field) = match spec.field_layout_for(key) {
40
+ Some(FieldLayout::Batched) => (
41
+ tensor.batched_value_at(index)?,
42
+ MmField::Batched(MmBatchedField { keep_on_cpu }),
43
+ ),
44
+ Some(FieldLayout::Flat { sizes_key }) => {
45
+ let sizes = tensors.get(sizes_key).ok_or_else(|| {
46
+ multimodal!("flat tensor sizes key `{sizes_key}` is missing")
47
+ })?;
48
+ let (start, end) = tensor::flat_range_for_index(sizes, sizes_key, index)?;
49
+ (
50
+ tensor.flat_value_range(start, end)?,
51
+ MmField::Flat(MmFlatField {
52
+ slices: vec![MmSlice::Slice(SliceSpec {
53
+ start: Some(0),
54
+ stop: Some((end - start) as isize),
55
+ step: None,
56
+ })],
57
+ dim: 0,
58
+ keep_on_cpu,
59
+ }),
60
+ )
61
+ }
62
+ None => (
63
+ tensor.clone(),
64
+ MmField::Shared(MmSharedField {
65
+ batch_size: len,
66
+ keep_on_cpu,
67
+ }),
68
+ ),
69
+ };
70
+
71
+ data.insert(
72
+ key.clone(),
73
+ MmFieldElem {
74
+ data: Some(value.try_into()?),
75
+ field,
76
+ },
77
+ );
78
+ }
79
+
80
+ items.push(PreparedItem { data, hash, uuid });
81
+ }
82
+
83
+ Ok(items)
84
+}
rust/src/chat/src/multimodal/video.rs
+56
-59
@@ -23,7 +23,7 @@ use crate::error::{Error, Result, bail_multimodal, multimodal};
23
///
24
/// Video-capable vLLM models read `pixel_values_videos` alongside
25
/// `video_grid_thw`, mirroring the HF processor output naming.
26
-const VIDEO_PRIMARY_KEY: &str = "pixel_values_videos";
26
+pub(super) const VIDEO_PRIMARY_KEY: &str = "pixel_values_videos";
27
28
impl MultimodalModelInfo {
29
/// Preprocess fetched video clips one at a time and build per-item
@@ -47,8 +47,7 @@ impl MultimodalModelInfo {
47
for (clip, uuid) in izip!(&clips, uuids) {
48
let preprocessed = self.preprocess_video_clip(support, Arc::clone(clip)).await?;
49
let mut clip_replacements =
50
- self.spec
51
- .prompt_replacements_for(&self.context, &preprocessed, Modality::Video)?;
50
+ support.spec.prompt_replacements_for(&self.context, &preprocessed)?;
51
if clip_replacements.len() != 1 {
52
bail_multimodal!(
53
"expected exactly one prompt replacement per video clip, got {}",
@@ -56,7 +55,8 @@ impl MultimodalModelInfo {
55
);
56
}
57
replacements.push(clip_replacements.pop().unwrap());
59
- items.push(self.build_video_item(
58
+ items.push(build_video_item(
59
+ support,
60
preprocessed,
61
clip.hash.clone(),
62
uuid,
@@ -107,30 +107,36 @@ impl MultimodalModelInfo {
107
.await
108
.map_err(|error| multimodal!("video preprocessing task failed: {error}"))?
109
}
110
+}
111
111
- /// Convert one preprocessed video clip into engine kwargs.
112
- ///
113
- /// The clip is a batch of one, so no per-item slicing is required: the
114
- /// primary tensor ships as a full-range flat field (the engine re-batches
115
- /// flat fields by concatenating along the declared dim, matching vLLM's
116
- /// `flat_from_sizes` treatment of video patches), and batched metadata
117
- /// tensors drop their singleton batch axis.
118
- fn build_video_item(
119
- &self,
120
- preprocessed: PreprocessedEncoderInputs,
121
- hash: String,
122
- uuid: Option<String>,
123
- model_dtype: ModelDtype,
124
- ) -> Result<PreparedItem> {
125
- let tensors = tensor::collect_tensors(preprocessed, VIDEO_PRIMARY_KEY, model_dtype)?;
112
+/// Convert one preprocessed video clip into engine kwargs.
113
+///
114
+/// The clip is a batch of one, so no per-item slicing is required: the
115
+/// primary tensor ships as a full-range flat field (the engine re-batches
116
+/// flat fields by concatenating along the declared dim, matching vLLM's
117
+/// `flat_from_sizes` treatment of video patches), and batched metadata
118
+/// tensors drop their singleton batch axis.
119
+fn build_video_item(
120
+ support: &ModalitySupport,
121
+ preprocessed: PreprocessedEncoderInputs,
122
+ hash: String,
123
+ uuid: Option<String>,
124
+ model_dtype: ModelDtype,
125
+) -> Result<PreparedItem> {
126
+ let tensors = tensor::collect_tensors(preprocessed, support.spec.primary_key(), model_dtype)?;
127
127
- let mut data = MmKwargsItem::new();
128
- for (key, tensor) in tensors {
129
- let keep_on_cpu = self.spec.keep_on_cpu_keys.contains(&key);
130
- let (value, field) = if key == VIDEO_PRIMARY_KEY {
128
+ let mut data = MmKwargsItem::new();
129
+ for (key, tensor) in tensors {
130
+ let keep_on_cpu = support.spec.keep_on_cpu_keys.contains(&key);
131
+ let (value, field) = match support.spec.field_layout_for(&key) {
132
+ Some(FieldLayout::Batched) => (
133
+ tensor.batched_value_at(0)?,
134
+ MmField::Batched(MmBatchedField { keep_on_cpu }),
135
+ ),
136
+ Some(FieldLayout::Flat { .. }) => {
137
let len = tensor
138
.first_dim()
133
- .ok_or_else(|| multimodal!("video encoder input `{key}` is not a tensor"))?;
139
+ .ok_or_else(|| multimodal!("flat video input `{key}` is not a tensor"))?;
140
(
141
tensor,
142
MmField::Flat(MmFlatField {
@@ -143,35 +149,26 @@ impl MultimodalModelInfo {
149
keep_on_cpu,
150
}),
151
)
146
- } else if matches!(
147
- self.spec.field_layouts.get(&key),
148
- Some(FieldLayout::Batched)
149
- ) {
150
- (
151
- tensor.batched_value_at(0)?,
152
- MmField::Batched(MmBatchedField { keep_on_cpu }),
153
- )
154
- } else {
155
- (
156
- tensor,
157
- MmField::Shared(MmSharedField {
158
- batch_size: 1,
159
- keep_on_cpu,
160
- }),
161
- )
162
- };
163
-
164
- data.insert(
165
- key,
166
- MmFieldElem {
167
- data: Some(value.try_into()?),
168
- field,
169
- },
170
- );
171
- }
152
+ }
153
+ None => (
154
+ tensor,
155
+ MmField::Shared(MmSharedField {
156
+ batch_size: 1,
157
+ keep_on_cpu,
158
+ }),
159
+ ),
160
+ };
161
173
- Ok(PreparedItem { data, hash, uuid })
162
+ data.insert(
163
+ key,
164
+ MmFieldElem {
165
+ data: Some(value.try_into()?),
166
+ field,
167
+ },
168
+ );
169
}
170
+
171
+ Ok(PreparedItem { data, hash, uuid })
172
}
173
174
#[cfg(test)]
@@ -287,14 +284,14 @@ mod tests {
284
]),
285
};
286
290
- let item = info
291
- .build_video_item(
292
- preprocessed,
293
- "<hash>".to_string(),
294
- None,
295
- ModelDtype::Float32,
296
- )
297
- .unwrap();
287
+ let item = build_video_item(
288
+ info.video.as_ref().unwrap(),
289
+ preprocessed,
290
+ "<hash>".to_string(),
291
+ None,
292
+ ModelDtype::Float32,
293
+ )
294
+ .unwrap();
295
296
let primary = &item.data[VIDEO_PRIMARY_KEY];
297
assert!(matches!(
rust/src/chat/src/renderer/hf/mod.rs
+58
-1
@@ -42,6 +42,7 @@ pub use self::format::ChatTemplateContentFormatOption;
42
pub struct MultimodalRenderInfo {
43
pub image_token: Option<String>,
44
pub video_token: Option<String>,
45
+ pub audio_token: Option<String>,
46
}
47
48
/// Hugging Face chat-template renderer backed by the local Jinja chat-template
@@ -263,6 +264,7 @@ enum TemplateContentPart {
264
Text { text: String },
265
Image,
266
Video,
267
+ Audio,
268
}
269
270
#[derive(Debug, Serialize)]
@@ -437,6 +439,12 @@ fn to_template_openai_content(
439
.ok_or(Error::UnsupportedMultimodalContent("video_url"))?;
440
Ok(TemplateContentPart::Video)
441
}
442
+ ChatContentPart::InputAudio { .. } | ChatContentPart::AudioUrl { .. } => {
443
+ multimodal
444
+ .and_then(|multimodal| multimodal.audio_token.as_ref())
445
+ .ok_or(Error::UnsupportedMultimodalContent("audio"))?;
446
+ Ok(TemplateContentPart::Audio)
447
+ }
448
})
449
.collect(),
450
}
@@ -465,6 +473,12 @@ fn to_template_string_content(
473
.ok_or(Error::UnsupportedMultimodalContent("video_url"))?;
474
out.push_str(video_token);
475
}
476
+ ChatContentPart::InputAudio { .. } | ChatContentPart::AudioUrl { .. } => {
477
+ let audio_token = multimodal
478
+ .and_then(|multimodal| multimodal.audio_token.as_ref())
479
+ .ok_or(Error::UnsupportedMultimodalContent("audio"))?;
480
+ out.push_str(audio_token);
481
+ }
482
}
483
}
484
Ok(out)
@@ -492,7 +506,9 @@ fn append_continue_final_message_tag(message: &mut TemplateMessage) -> Result<St
506
// Pick the last text part in the message.
507
TemplateContent::OpenAi(parts) => parts.iter_mut().rev().find_map(|part| match part {
508
TemplateContentPart::Text { text } => Some(text),
495
- TemplateContentPart::Image | TemplateContentPart::Video => None,
509
+ TemplateContentPart::Image
510
+ | TemplateContentPart::Video
511
+ | TemplateContentPart::Audio => None,
512
}),
513
};
514
let text = text.ok_or_else(|| {
@@ -603,6 +619,7 @@ mod tests {
619
.with_multimodal(Some(MultimodalRenderInfo {
620
image_token: Some("<image>".to_string()),
621
video_token: Some("<video>".to_string()),
622
+ audio_token: Some("<audio>".to_string()),
623
}))
624
.render(request)
625
}
@@ -623,6 +640,15 @@ mod tests {
640
])])
641
}
642
643
+ fn audio_request() -> ChatRequest {
644
+ sample_request(vec![ChatMessage::user(vec![
645
+ ChatContentPart::text("a"),
646
+ ChatContentPart::input_audio("dGVzdA==", Some("wav".to_string())),
647
+ ChatContentPart::audio_url("https://example.com/demo.mp3"),
648
+ ChatContentPart::text("b"),
649
+ ])])
650
+ }
651
+
652
#[test]
653
fn string_content_format_replaces_image_with_placeholder_text() {
654
let rendered = render_mm(
@@ -647,6 +673,21 @@ mod tests {
673
assert_eq!(rendered.prompt, Prompt::Text("a<video>b".to_string()));
674
}
675
676
+ #[test]
677
+ fn string_content_format_replaces_audio_with_placeholder_text() {
678
+ let rendered = render_mm(
679
+ "{{ messages[0].content }}",
680
+ &audio_request(),
681
+ ChatTemplateContentFormatOption::String,
682
+ )
683
+ .unwrap();
684
+
685
+ assert_eq!(
686
+ rendered.prompt,
687
+ Prompt::Text("a<audio><audio>b".to_string())
688
+ );
689
+ }
690
+
691
#[test]
692
fn openai_content_format_normalizes_image_url_for_template() {
693
let rendered = render_mm(
@@ -671,6 +712,21 @@ mod tests {
712
assert_eq!(rendered.prompt, Prompt::Text("a<|video_pad|>b".to_string()));
713
}
714
715
+ #[test]
716
+ fn openai_content_format_normalizes_audio_for_template() {
717
+ let rendered = render_mm(
718
+ "{% for item in messages[0].content %}{% if item.type == 'audio' %}<|audio_pad|>{% else %}{{ item.text }}{% endif %}{% endfor %}",
719
+ &audio_request(),
720
+ ChatTemplateContentFormatOption::OpenAi,
721
+ )
722
+ .unwrap();
723
+
724
+ assert_eq!(
725
+ rendered.prompt,
726
+ Prompt::Text("a<|audio_pad|><|audio_pad|>b".to_string())
727
+ );
728
+ }
729
+
730
#[test]
731
fn video_parts_are_rejected_when_model_lacks_video_support() {
732
let error = HfChatRenderer::new(
@@ -682,6 +738,7 @@ mod tests {
738
.with_multimodal(Some(MultimodalRenderInfo {
739
image_token: Some("<image>".to_string()),
740
video_token: None,
741
+ audio_token: None,
742
}))
743
.render(&video_request())
744
.unwrap_err();
rust/src/chat/src/request.rs
+34
-1
@@ -44,6 +44,17 @@ pub enum ChatContentPart {
44
video_url: String,
45
uuid: Option<String>,
46
},
47
+ /// One `input_audio` content block carrying base64-encoded audio bytes.
48
+ InputAudio {
49
+ data: String,
50
+ format: Option<String>,
51
+ uuid: Option<String>,
52
+ },
53
+ /// One audio URL/data URL content block.
54
+ AudioUrl {
55
+ audio_url: String,
56
+ uuid: Option<String>,
57
+ },
58
// ImageData...
59
// VideoData...
60
// ImageEmbeds...
@@ -72,6 +83,23 @@ impl ChatContentPart {
83
}
84
}
85
86
+ /// Construct one base64-encoded input-audio content part.
87
+ pub fn input_audio(data: impl Into<String>, format: Option<String>) -> Self {
88
+ Self::InputAudio {
89
+ data: data.into(),
90
+ format,
91
+ uuid: None,
92
+ }
93
+ }
94
+
95
+ /// Construct one audio URL content part with the given URL string.
96
+ pub fn audio_url(audio_url: impl Into<String>) -> Self {
97
+ Self::AudioUrl {
98
+ audio_url: audio_url.into(),
99
+ uuid: None,
100
+ }
101
+ }
102
+
103
/// Return the text content of this part when it's a text block, or an
104
/// "unsupported multimodal content" error otherwise.
105
pub(crate) fn as_text(&self) -> Result<&str> {
@@ -79,6 +107,8 @@ impl ChatContentPart {
107
Self::Text { text } => Ok(text),
108
Self::ImageUrl { .. } => Err(Error::UnsupportedMultimodalContent("image_url")),
109
Self::VideoUrl { .. } => Err(Error::UnsupportedMultimodalContent("video_url")),
110
+ Self::InputAudio { .. } => Err(Error::UnsupportedMultimodalContent("input_audio")),
111
+ Self::AudioUrl { .. } => Err(Error::UnsupportedMultimodalContent("audio_url")),
112
}
113
}
114
@@ -91,7 +121,10 @@ impl ChatContentPart {
121
pub(crate) fn is_multimodal(&self) -> bool {
122
match self {
123
Self::Text { .. } => false,
94
- Self::ImageUrl { .. } | Self::VideoUrl { .. } => true,
124
+ Self::ImageUrl { .. }
125
+ | Self::VideoUrl { .. }
126
+ | Self::InputAudio { .. }
127
+ | Self::AudioUrl { .. } => true,
128
}
129
}
130
}
rust/src/server/src/routes/openai/chat_completions/convert.rs
+56
-2
@@ -299,6 +299,15 @@ fn convert_content(content: MessageContent) -> Result<ChatContent, ApiError> {
299
video_url: video_url.url,
300
uuid,
301
}),
302
+ ContentPart::AudioUrl { audio_url, uuid } => Ok(ChatContentPart::AudioUrl {
303
+ audio_url: audio_url.url,
304
+ uuid,
305
+ }),
306
+ ContentPart::InputAudio { input_audio, uuid } => Ok(ChatContentPart::InputAudio {
307
+ data: input_audio.data,
308
+ format: input_audio.format,
309
+ uuid,
310
+ }),
311
})
312
.try_collect()
313
.map(ChatContent::Parts),
@@ -402,8 +411,8 @@ mod tests {
411
AssistantRole, ChatCompletionMessage, ChatCompletionRequest,
412
};
413
use crate::routes::openai::utils::types::{
405
- ChatMessage, ContentPart, Function, FunctionCallResponse, ImageUrl, MessageContent,
406
- StreamOptions, Tool, ToolCall, ToolChoice, ToolChoiceValue, VideoUrl,
414
+ AudioUrl, ChatMessage, ContentPart, Function, FunctionCallResponse, ImageUrl, InputAudio,
415
+ MessageContent, StreamOptions, Tool, ToolCall, ToolChoice, ToolChoiceValue, VideoUrl,
416
};
417
use crate::utils::{ResolvedRequestContext, resolve_request_context};
418
@@ -810,6 +819,51 @@ mod tests {
819
);
820
}
821
822
+ #[test]
823
+ fn prepare_chat_request_accepts_audio_content_parts() {
824
+ let request = ChatCompletionRequest {
825
+ model: "Qwen/Qwen3-ASR-1.7B".to_string(),
826
+ messages: vec![ChatMessage::User {
827
+ content: MessageContent::Parts(vec![
828
+ ContentPart::InputAudio {
829
+ input_audio: InputAudio {
830
+ data: "dGVzdA==".to_string(),
831
+ format: Some("wav".to_string()),
832
+ },
833
+ uuid: Some("audio-1".to_string()),
834
+ },
835
+ ContentPart::AudioUrl {
836
+ audio_url: AudioUrl {
837
+ url: "https://example.com/audio.mp3".to_string(),
838
+ },
839
+ uuid: None,
840
+ },
841
+ ]),
842
+ name: None,
843
+ }],
844
+ ..base_request()
845
+ };
846
+
847
+ let prepared = prepare_chat_request(
848
+ request,
849
+ &served(&["Qwen/Qwen3-ASR-1.7B"]),
850
+ ResolvedRequestContext::default(),
851
+ )
852
+ .expect("request is valid");
853
+
854
+ assert_eq!(
855
+ prepared.chat_request.messages,
856
+ vec![VllmChatMessage::user(vec![
857
+ ChatContentPart::InputAudio {
858
+ data: "dGVzdA==".to_string(),
859
+ format: Some("wav".to_string()),
860
+ uuid: Some("audio-1".to_string()),
861
+ },
862
+ ChatContentPart::audio_url("https://example.com/audio.mp3"),
863
+ ])]
864
+ );
865
+ }
866
+
867
#[test]
868
fn prepare_chat_request_rejects_assistant_image_url_content_parts() {
869
let request = ChatCompletionRequest {
rust/src/server/src/routes/openai/utils/types.rs
+25
@@ -99,6 +99,18 @@ pub enum ContentPart {
99
#[serde(skip_serializing_if = "Option::is_none")]
100
uuid: Option<String>,
101
},
102
+ #[serde(rename = "audio_url")]
103
+ AudioUrl {
104
+ audio_url: AudioUrl,
105
+ #[serde(skip_serializing_if = "Option::is_none")]
106
+ uuid: Option<String>,
107
+ },
108
+ #[serde(rename = "input_audio")]
109
+ InputAudio {
110
+ input_audio: InputAudio,
111
+ #[serde(skip_serializing_if = "Option::is_none")]
112
+ uuid: Option<String>,
113
+ },
114
}
115
116
#[serde_with::skip_serializing_none]
@@ -113,6 +125,19 @@ pub struct VideoUrl {
125
pub url: String,
126
}
127
128
+#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
129
+pub struct AudioUrl {
130
+ pub url: String,
131
+}
132
+
133
+/// Base64-encoded audio bytes in OpenAI `input_audio` form.
134
+#[serde_with::skip_serializing_none]
135
+#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
136
+pub struct InputAudio {
137
+ pub data: String,
138
+ pub format: Option<String>,
139
+}
140
+
141
// ============================================================================
142
// Streaming
143
// ============================================================================
rust/src/server/src/routes/tests.rs
+4
-3
@@ -551,9 +551,10 @@ fn render_fake_content(content: &ChatContent, placeholder: &str) -> vllm_chat::R
551
for part in parts {
552
match part {
553
ChatContentPart::Text { text } => out.push_str(text),
554
- ChatContentPart::ImageUrl { .. } | ChatContentPart::VideoUrl { .. } => {
555
- out.push_str(placeholder)
556
- }
554
+ ChatContentPart::ImageUrl { .. }
555
+ | ChatContentPart::VideoUrl { .. }
556
+ | ChatContentPart::InputAudio { .. }
557
+ | ChatContentPart::AudioUrl { .. } => out.push_str(placeholder),
558
}
559
}
560
out
vllm/model_executor/models/qwen2_5_omni_thinker.py
+28
@@ -114,6 +114,23 @@ except (ImportError, ModuleNotFoundError):
114
logger = init_logger(__name__)
115
116
117
+def unpad_and_flat_audio_features(
118
+ input_audio_features: torch.Tensor,
119
+ audio_feature_lengths: torch.Tensor,
120
+) -> torch.Tensor:
121
+ """Unpad and flatten batched audio features."""
122
+ if len(audio_feature_lengths) != input_audio_features.size(0):
123
+ raise ValueError(
124
+ "Length of audio_feature_lengths must match "
125
+ "the batch size of input_audio_features."
126
+ )
127
+ unpadded_features = [
128
+ audio_feature[..., :length]
129
+ for audio_feature, length in zip(input_audio_features, audio_feature_lengths)
130
+ ]
131
+ return torch.cat(unpadded_features, dim=-1)
132
+
133
+
134
def check_interleaved_audio_video(
135
is_video: torch.Tensor,
136
is_audio: torch.Tensor,
@@ -887,6 +904,17 @@ class Qwen2_5OmniConditionalGenerationMixin:
904
if input_audio_features is None:
905
return None
906
907
+ # inputs features from rust frontend is batched and padded
908
+ # with shape [batch_size, n_mels, padded_seq_len], different
909
+ # from python's shape [n_mels, batch_size * seq_len]
910
+ if (
911
+ isinstance(input_audio_features, torch.Tensor)
912
+ and input_audio_features.dim() == 3
913
+ ):
914
+ input_audio_features = unpad_and_flat_audio_features(
915
+ input_audio_features, audio_feature_lengths
916
+ )
917
+
918
return Qwen2_5OmniAudioFeatureInputs(
919
type="audio_features",
920
input_features=input_audio_features,
vllm/model_executor/models/qwen3_asr.py
+20
-1
@@ -46,9 +46,12 @@ from vllm.model_executor.models.interfaces import (
46
SupportsTranscription,
47
)
48
from vllm.model_executor.models.module_mapping import MultiModelKeys
49
+from vllm.model_executor.models.qwen2_5_omni_thinker import (
50
+ Qwen2_5OmniAudioFeatureInputs,
51
+ unpad_and_flat_audio_features,
52
+)
53
from vllm.model_executor.models.qwen3 import Qwen3ForCausalLM
54
from vllm.model_executor.models.qwen3_omni_moe_thinker import (
51
- Qwen2_5OmniAudioFeatureInputs,
55
Qwen3OmniMoeAudioEncoder,
56
Qwen3OmniMoeThinkerMultiModalProcessor,
57
)
@@ -363,6 +366,11 @@ class Qwen3ASRForConditionalGeneration(
366
"thinker.lm_head.": "language_model.lm_head.",
367
"thinker.model.": "language_model.model.",
368
"thinker.": "",
369
+ # HF format mapper
370
+ "model.audio_tower.": "audio_tower.",
371
+ "model.language_model.": "language_model.model.",
372
+ "model.multi_modal_projector.linear_1.": "audio_tower.proj1.",
373
+ "model.multi_modal_projector.linear_2.": "audio_tower.proj2.",
374
}
375
)
376
@@ -412,6 +420,17 @@ class Qwen3ASRForConditionalGeneration(
420
if input_audio_features is None:
421
return None
422
423
+ # inputs features from rust frontend is batched and padded
424
+ # with shape [batch_size, n_mels, padded_seq_len], different
425
+ # from python's shape [n_mels, batch_size * seq_len]
426
+ if (
427
+ isinstance(input_audio_features, torch.Tensor)
428
+ and input_audio_features.dim() == 3
429
+ ):
430
+ input_audio_features = unpad_and_flat_audio_features(
431
+ input_audio_features, audio_feature_lengths
432
+ )
433
+
434
return Qwen2_5OmniAudioFeatureInputs(
435
type="audio_features",
436
input_features=input_audio_features,
vllm/transformers_utils/configs/qwen3_asr.py
+17
-4
@@ -407,16 +407,29 @@ class Qwen3ASRConfig(PretrainedConfig):
407
def __init__(
408
self,
409
thinker_config=None,
410
+ audio_config=None,
411
+ text_config=None,
412
+ audio_token_id=None,
413
support_languages=None,
414
timestamp_token_id=None,
415
timestamp_segment_time=None,
416
**kwargs,
417
):
418
if thinker_config is None:
416
- thinker_config = {}
417
- logger.info(
418
- "thinker_config is None. Initializing thinker model with default values"
419
- )
419
+ thinker_config = {
420
+ key: value
421
+ for key, value in (
422
+ ("audio_config", audio_config),
423
+ ("text_config", text_config),
424
+ ("audio_token_id", audio_token_id),
425
+ )
426
+ if value is not None
427
+ }
428
+ if not thinker_config:
429
+ logger.info(
430
+ "thinker_config is None. Initializing thinker model with "
431
+ "default values"
432
+ )
433
434
self.thinker_config = Qwen3ASRThinkerConfig(**thinker_config)
435
self.support_languages = support_languages