297
}
298
}
299
300
-/// How many images a history message carries as `image_url` content parts.
301
-pub fn message_image_count(message: &serde_json::Value) -> usize {
300
+/// How many content parts of `part_type` a history message carries.
301
+fn message_part_count(message: &serde_json::Value, part_type: &str) -> usize {
302
message["content"]
303
.as_array()
304
.map(|parts| {
305
parts
306
.iter()
307
- .filter(|part| part["type"] == "image_url")
307
+ .filter(|part| part["type"] == part_type)
308
.count()
309
})
310
.unwrap_or(0)
311
}
312
313
+/// How many images a history message carries as `image_url` content parts.
314
+pub fn message_image_count(message: &serde_json::Value) -> usize {
315
+ message_part_count(message, "image_url")
316
+}
317
+
318
+/// How many audio clips a history message carries as `input_audio` content
319
+/// parts.
320
+pub fn message_audio_count(message: &serde_json::Value) -> usize {
321
+ message_part_count(message, "input_audio")
322
+}
323
+
324
/// A sink for streaming assistant text deltas to the UI as they are produced.
325
///
326
/// When a caller passes `Some(sink)`, a streaming-capable backend forwards each
338
pub data: String,
339
}
340
341
+/// An audio clip attached to a user message: base64 data and its media type,
342
+/// as an ACP client sends it.
343
+#[derive(Debug, Clone, PartialEq, Eq)]
344
+pub struct AudioInput {
345
+ pub mime_type: String,
346
+ pub data: String,
347
+}
348
+
349
+impl AudioInput {
350
+ /// The `format` OpenAI's `input_audio` part expects (`wav`, `mp3`, ...),
351
+ /// from the MIME type an ACP client sends (`audio/wav`, `audio/mpeg`, ...).
352
+ fn format(&self) -> String {
353
+ let subtype = self
354
+ .mime_type
355
+ .split(';')
356
+ .next()
357
+ .unwrap_or_default()
358
+ .trim()
359
+ .to_lowercase();
360
+ let subtype = subtype.strip_prefix("audio/").unwrap_or(&subtype);
361
+ match subtype.strip_prefix("x-").unwrap_or(subtype) {
362
+ "mpeg" | "mp3" => "mp3".to_string(),
363
+ "wave" | "wav" | "vnd.wave" => "wav".to_string(),
364
+ other => other.to_string(),
365
+ }
366
+ }
367
+}
368
+
369
/// What a model that cannot read images is shown in place of one.
370
const IMAGE_OMITTED_NOTE: &str = "[image omitted: this model cannot read images]";
371
372
+/// What a model that cannot take audio is shown in place of a clip.
373
+const AUDIO_OMITTED_NOTE: &str = "[audio omitted: this model cannot take audio]";
374
+
375
/// A user message in history form. Plain text keeps the string `content` every
334
-/// other message uses; with images it becomes OpenAI's content-part array,
335
-/// text first, each image as a base64 `data:` URL.
336
-fn user_message(text: &str, images: &[ImageInput]) -> serde_json::Value {
337
- if images.is_empty() {
376
+/// other message uses; with attachments it becomes OpenAI's content-part
377
+/// array, text first, each image as a base64 `data:` URL and each audio clip
378
+/// as an `input_audio` part.
379
+fn user_message(text: &str, images: &[ImageInput], audio: &[AudioInput]) -> serde_json::Value {
380
+ if images.is_empty() && audio.is_empty() {
381
return serde_json::json!({ "role": "user", "content": text });
382
}
340
- let mut parts = Vec::with_capacity(images.len() + 1);
383
+ let mut parts = Vec::with_capacity(images.len() + audio.len() + 1);
384
if !text.is_empty() {
385
parts.push(serde_json::json!({ "type": "text", "text": text }));
386
}
392
},
393
}));
394
}
395
+ for clip in audio {
396
+ parts.push(serde_json::json!({
397
+ "type": "input_audio",
398
+ "input_audio": { "data": clip.data, "format": clip.format() },
399
+ }));
400
+ }
401
serde_json::json!({ "role": "user", "content": parts })
402
}
403
355
-/// `history` as a text-only model has to receive it: a message that carries
356
-/// images is flattened back to a string, with a note where each image was.
404
+/// `history` with every content part of `part_type` replaced by `note`, for a
405
+/// model that cannot take that kind of attachment. A message left with only
406
+/// text is flattened back to a plain string.
407
///
358
-/// History itself keeps the images. A thread can move to a model that reads
359
-/// them (or back to one), so what was attached is not thrown away just because
360
-/// the model active right now cannot use it.
361
-fn without_images(history: &[serde_json::Value]) -> Vec<serde_json::Value> {
408
+/// History itself keeps the attachments. A thread can move to a model that
409
+/// reads them (or back to one), so what was attached is not thrown away just
410
+/// because the model active right now cannot use it.
411
+fn without_parts(
412
+ history: &[serde_json::Value],
413
+ part_type: &str,
414
+ note: &str,
415
+) -> Vec<serde_json::Value> {
416
history
417
.iter()
418
.map(|message| {
365
- let images = message_image_count(message);
366
- if images == 0 {
419
+ let Some(parts) = message["content"].as_array() else {
420
+ return message.clone();
421
+ };
422
+ if !parts.iter().any(|part| part["type"] == part_type) {
423
return message.clone();
424
}
369
- let mut text = message_text(message);
370
- for _ in 0..images {
371
- if !text.is_empty() {
372
- text.push('\n');
425
+ let mut kept: Vec<serde_json::Value> = Vec::with_capacity(parts.len());
426
+ let mut notes = Vec::new();
427
+ for part in parts {
428
+ if part["type"] == part_type {
429
+ notes.push(serde_json::json!({ "type": "text", "text": note }));
430
+ } else {
431
+ kept.push(part.clone());
432
}
374
- text.push_str(IMAGE_OMITTED_NOTE);
433
}
376
- let mut flattened = message.clone();
377
- flattened["content"] = serde_json::Value::String(text);
378
- flattened
434
+ kept.extend(notes);
435
+ let mut stripped = message.clone();
436
+ stripped["content"] = if kept.iter().all(|part| part["type"] == "text") {
437
+ let text = kept
438
+ .iter()
439
+ .filter_map(|part| part["text"].as_str())
440
+ .filter(|text| !text.is_empty())
441
+ .collect::<Vec<_>>()
442
+ .join("\n");
443
+ serde_json::Value::String(text)
444
+ } else {
445
+ serde_json::Value::Array(kept)
446
+ };
447
+ stripped
448
})
449
.collect()
450
}
451
452
+/// `history` as a model that cannot read images has to receive it, with a
453
+/// note where each image was.
454
+fn without_images(history: &[serde_json::Value]) -> Vec<serde_json::Value> {
455
+ without_parts(history, "image_url", IMAGE_OMITTED_NOTE)
456
+}
457
+
458
+/// `history` as a model that cannot take audio has to receive it, with a note
459
+/// where each clip was.
460
+fn without_audio(history: &[serde_json::Value]) -> Vec<serde_json::Value> {
461
+ without_parts(history, "input_audio", AUDIO_OMITTED_NOTE)
462
+}
463
+
464
// ── The trait ───────────────────────────────────────────────────────────────────
465
466
/// A swappable inference backend driving siGit Code's agent loop.
499
/// request in the session.
500
async fn record_cancelled_tool_results(&self, results: Vec<ToolResult>);
501
421
- /// Start a turn from a user message that carries images.
502
+ /// Start a turn from a user message that carries images or audio.
503
///
423
- /// The default drops the images and sends the text, which is right for a
424
- /// backend that cannot read them. Callers check [`Self::accepts_images`]
425
- /// first so the user can be told, instead of the image vanishing.
426
- async fn send_message_with_images(
504
+ /// The default drops the attachments and sends the text, which is right
505
+ /// for a backend that cannot read them. Callers check
506
+ /// [`Self::accepts_images`] and [`Self::accepts_audio`] first so the user
507
+ /// can be told, instead of the attachment vanishing.
508
+ async fn send_message_with_attachments(
509
&self,
510
text: &str,
511
images: &[ImageInput],
512
+ audio: &[AudioInput],
513
tools: &[ToolSpec],
514
sink: Option<&TokenSink>,
515
) -> Result<TurnResult, BackendError> {
433
- let _ = images;
516
+ let _ = (images, audio);
517
self.send_message_with_tools(text, tools, sink).await
518
}
519
523
false
524
}
525
526
+ /// Whether the model behind this backend takes audio. On-device models do
527
+ /// not; a remote one answers from its model id.
528
+ fn accepts_audio(&self) -> bool {
529
+ false
530
+ }
531
+
532
/// Whether inference runs over the network (a configured provider) rather
533
/// than on-device. Drives UI labelling so the displayed model can't claim a
534
/// local model while requests actually go to the cloud.
926
/// When it does not, images already in `history` are left out of each
927
/// request rather than sent to an endpoint that would refuse them.
928
accepts_images: bool,
929
+ /// Whether `model` takes audio (see `provider::model_accepts_audio`), with
930
+ /// the same effect on audio already in `history`.
931
+ accepts_audio: bool,
932
}
933
934
impl OpenAiBackend {
950
base_url: base_url.into(),
951
api_key: api_key.into(),
952
accepts_images: crate::provider::model_accepts_images(&model),
953
+ accepts_audio: crate::provider::model_accepts_audio(&model),
954
model,
955
http: reqwest::Client::new(),
956
history: Mutex::new(history),
1039
1040
let messages = {
1041
let history = self.history.lock().await;
949
- if self.accepts_images {
1042
+ let messages = if self.accepts_images {
1043
history.clone()
1044
} else {
1045
without_images(&history)
1046
+ };
1047
+ if self.accepts_audio {
1048
+ messages
1049
+ } else {
1050
+ without_audio(&messages)
1051
}
1052
};
1053
let mut body = serde_json::json!({
1642
}
1643
}
1644
1547
- async fn send_message_with_images(
1645
+ async fn send_message_with_attachments(
1646
&self,
1647
text: &str,
1648
images: &[ImageInput],
1649
+ audio: &[AudioInput],
1650
tools: &[ToolSpec],
1651
sink: Option<&TokenSink>,
1652
) -> Result<TurnResult, BackendError> {
1554
- self.history.lock().await.push(user_message(text, images));
1653
+ self.history
1654
+ .lock()
1655
+ .await
1656
+ .push(user_message(text, images, audio));
1657
self.complete(tools, true, sink).await
1658
}
1659
1661
self.accepts_images
1662
}
1663
1664
+ fn accepts_audio(&self) -> bool {
1665
+ self.accepts_audio
1666
+ }
1667
+
1668
fn is_remote(&self) -> bool {
1669
true
1670
}
1679
http: self.http.clone(),
1680
history: Mutex::new(Vec::new()),
1681
accepts_images: self.accepts_images,
1682
+ accepts_audio: self.accepts_audio,
1683
}))
1684
}
1685
2451
#[test]
2452
fn user_message_uses_content_parts_only_when_there_is_an_image() {
2453
assert_eq!(
2347
- user_message("hello", &[]),
2454
+ user_message("hello", &[], &[]),
2455
serde_json::json!({ "role": "user", "content": "hello" })
2456
);
2457
2460
data: "AAAA".to_string(),
2461
};
2462
assert_eq!(
2356
- user_message("what is this?", std::slice::from_ref(&image)),
2463
+ user_message("what is this?", std::slice::from_ref(&image), &[]),
2464
serde_json::json!({
2465
"role": "user",
2466
"content": [
2470
})
2471
);
2472
// An image with no text sends no empty text part.
2366
- let only_image = user_message("", &[image]);
2473
+ let only_image = user_message("", &[image], &[]);
2474
assert_eq!(only_image["content"].as_array().unwrap().len(), 1);
2475
}
2476
2477
+ #[test]
2478
+ fn audio_is_sent_as_an_input_audio_part() {
2479
+ let clip = AudioInput {
2480
+ mime_type: "audio/mpeg".to_string(),
2481
+ data: "BBBB".to_string(),
2482
+ };
2483
+ assert_eq!(
2484
+ user_message("transcribe this", &[], &[clip]),
2485
+ serde_json::json!({
2486
+ "role": "user",
2487
+ "content": [
2488
+ { "type": "text", "text": "transcribe this" },
2489
+ { "type": "input_audio", "input_audio": { "data": "BBBB", "format": "mp3" } },
2490
+ ],
2491
+ })
2492
+ );
2493
+ }
2494
+
2495
+ #[test]
2496
+ fn audio_format_follows_the_mime_type() {
2497
+ let format = |mime: &str| {
2498
+ AudioInput {
2499
+ mime_type: mime.to_string(),
2500
+ data: String::new(),
2501
+ }
2502
+ .format()
2503
+ };
2504
+ assert_eq!(format("audio/wav"), "wav");
2505
+ assert_eq!(format("audio/x-wav"), "wav");
2506
+ assert_eq!(format("audio/wave"), "wav");
2507
+ assert_eq!(format("audio/mpeg"), "mp3");
2508
+ assert_eq!(format("Audio/MP3; codecs=mp3"), "mp3");
2509
+ assert_eq!(format("audio/flac"), "flac");
2510
+ }
2511
+
2512
+ #[test]
2513
+ fn a_model_without_audio_gets_a_note_and_keeps_the_image() {
2514
+ let history = vec![serde_json::json!({
2515
+ "role": "user",
2516
+ "content": [
2517
+ { "type": "text", "text": "what is this?" },
2518
+ { "type": "image_url", "image_url": { "url": "data:image/png;base64,AAAA" } },
2519
+ { "type": "input_audio", "input_audio": { "data": "BBBB", "format": "wav" } },
2520
+ ],
2521
+ })];
2522
+
2523
+ let sent = without_audio(&history);
2524
+ assert_eq!(
2525
+ sent[0]["content"],
2526
+ serde_json::json!([
2527
+ { "type": "text", "text": "what is this?" },
2528
+ { "type": "image_url", "image_url": { "url": "data:image/png;base64,AAAA" } },
2529
+ { "type": "text", "text": AUDIO_OMITTED_NOTE },
2530
+ ])
2531
+ );
2532
+ // With neither, the message is plain text again.
2533
+ assert_eq!(
2534
+ without_images(&sent)[0]["content"],
2535
+ format!("what is this?\n{AUDIO_OMITTED_NOTE}\n{IMAGE_OMITTED_NOTE}")
2536
+ );
2537
+ // History keeps the clip for a model that can take it later.
2538
+ assert_eq!(message_audio_count(&history[0]), 1);
2539
+ }
2540
+
2541
#[test]
2542
fn a_text_only_model_is_sent_a_note_in_place_of_each_image() {
2543
let history = vec![
2567
assert!(image_tier.accepts_images());
2568
}
2569
2570
+ #[test]
2571
+ fn a_remote_backend_takes_audio_only_on_the_users_own_endpoint() {
2572
+ let own_endpoint = OpenAiBackend::new("http://localhost", "", "gpt-4o-audio", None);
2573
+ assert!(own_endpoint.accepts_audio());
2574
+ let cloud_tier = OpenAiBackend::new("http://localhost", "", "onde-large", None);
2575
+ assert!(!cloud_tier.accepts_audio());
2576
+ }
2577
+
2578
#[test]
2579
fn message_text_reads_both_content_shapes() {
2580
assert_eq!(