use bytes::Bytes;
use serde::de;
use serde::{Deserialize, Deserializer, Serialize};
use uuid::Uuid;
#[derive(Debug, Clone)]
#[non_exhaustive]
pub enum FluxSpeakResponse {
Audio(Bytes),
#[non_exhaustive]
Connected {
request_id: Uuid,
model_name: String,
model_version: String,
model_uuids: Vec<Uuid>,
},
#[non_exhaustive]
SpeechStarted {
speech_id: String,
},
#[non_exhaustive]
Flushed {
speech_id: String,
},
SpeechMetadata(TurnMetadata),
#[non_exhaustive]
SpeechInterrupted {
audio_played_ms: u64,
text_spoken: Option<String>,
text_remaining: Option<String>,
metadata: TurnMetadata,
},
#[non_exhaustive]
SessionMetadata {
total_audio_duration_ms: u64,
total_input_character_count: u64,
total_billable_character_count: u64,
},
#[non_exhaustive]
ConfigureSuccess {
applied: AppliedConfiguration,
},
#[non_exhaustive]
ConfigureFailure {
code: String,
field: Option<String>,
value: Option<f64>,
description: String,
},
#[non_exhaustive]
Warning {
code: String,
description: String,
},
#[non_exhaustive]
FatalError {
code: String,
description: String,
},
Unknown(serde_json::Value),
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct TurnMetadata {
pub speech_id: String,
pub audio_duration_ms: u64,
pub input_character_count: u64,
pub billable_character_count: u64,
pub controls_applied: ControlsApplied,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct ControlsApplied {
pub pronunciations_applied: u64,
pub breaks_applied: u64,
pub pronunciation_warnings: u64,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct AppliedConfiguration {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub speed: Option<f64>,
}
#[derive(Deserialize)]
#[serde(tag = "type")]
enum TaggedResponse {
Connected {
request_id: Uuid,
model_name: String,
model_version: String,
model_uuids: Vec<Uuid>,
},
SpeechStarted {
speech_id: String,
},
Flushed {
speech_id: String,
},
SpeechMetadata(TurnMetadata),
SpeechInterrupted {
audio_played_ms: u64,
#[serde(default)]
text_spoken: Option<String>,
#[serde(default)]
text_remaining: Option<String>,
metadata: TurnMetadata,
},
SessionMetadata {
total_audio_duration_ms: u64,
total_input_character_count: u64,
total_billable_character_count: u64,
},
ConfigureSuccess {
applied: AppliedConfiguration,
},
ConfigureFailure {
code: String,
#[serde(default)]
field: Option<String>,
#[serde(default)]
value: Option<f64>,
description: String,
},
Warning {
code: String,
description: String,
},
#[serde(rename = "Error")]
FatalError {
code: String,
description: String,
},
}
impl From<TaggedResponse> for FluxSpeakResponse {
fn from(tagged: TaggedResponse) -> Self {
match tagged {
TaggedResponse::Connected {
request_id,
model_name,
model_version,
model_uuids,
} => FluxSpeakResponse::Connected {
request_id,
model_name,
model_version,
model_uuids,
},
TaggedResponse::SpeechStarted { speech_id } => {
FluxSpeakResponse::SpeechStarted { speech_id }
}
TaggedResponse::Flushed { speech_id } => FluxSpeakResponse::Flushed { speech_id },
TaggedResponse::SpeechMetadata(metadata) => FluxSpeakResponse::SpeechMetadata(metadata),
TaggedResponse::SpeechInterrupted {
audio_played_ms,
text_spoken,
text_remaining,
metadata,
} => FluxSpeakResponse::SpeechInterrupted {
audio_played_ms,
text_spoken,
text_remaining,
metadata,
},
TaggedResponse::SessionMetadata {
total_audio_duration_ms,
total_input_character_count,
total_billable_character_count,
} => FluxSpeakResponse::SessionMetadata {
total_audio_duration_ms,
total_input_character_count,
total_billable_character_count,
},
TaggedResponse::ConfigureSuccess { applied } => {
FluxSpeakResponse::ConfigureSuccess { applied }
}
TaggedResponse::ConfigureFailure {
code,
field,
value,
description,
} => FluxSpeakResponse::ConfigureFailure {
code,
field,
value,
description,
},
TaggedResponse::Warning { code, description } => {
FluxSpeakResponse::Warning { code, description }
}
TaggedResponse::FatalError { code, description } => {
FluxSpeakResponse::FatalError { code, description }
}
}
}
}
impl<'de> Deserialize<'de> for FluxSpeakResponse {
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let value = serde_json::Value::deserialize(deserializer)?;
let type_str = value.get("type").and_then(|t| t.as_str());
match type_str {
Some(
"Connected" | "SpeechStarted" | "Flushed" | "SpeechMetadata" | "SpeechInterrupted"
| "SessionMetadata" | "ConfigureSuccess" | "ConfigureFailure" | "Warning" | "Error",
) => serde_json::from_value::<TaggedResponse>(value)
.map(FluxSpeakResponse::from)
.map_err(de::Error::custom),
_ => Ok(FluxSpeakResponse::Unknown(value)),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn deserialize_connected() {
let json = r#"{"type":"Connected","request_id":"550e8400-e29b-41d4-a716-446655440000","model_name":"flux-haley-en","model_version":"2026.06.01","model_uuids":["660e8400-e29b-41d4-a716-446655440000"]}"#;
let response: FluxSpeakResponse = serde_json::from_str(json).unwrap();
match response {
FluxSpeakResponse::Connected {
model_name,
model_uuids,
..
} => {
assert_eq!(model_name, "flux-haley-en");
assert_eq!(model_uuids.len(), 1);
}
_ => panic!("expected Connected"),
}
}
#[test]
fn deserialize_speech_started_and_flushed() {
let started: FluxSpeakResponse =
serde_json::from_str(r#"{"type":"SpeechStarted","speech_id":"dg_sp_a1b2c3d4e5f6"}"#)
.unwrap();
match started {
FluxSpeakResponse::SpeechStarted { speech_id } => {
assert_eq!(speech_id, "dg_sp_a1b2c3d4e5f6");
}
_ => panic!("expected SpeechStarted"),
}
let flushed: FluxSpeakResponse =
serde_json::from_str(r#"{"type":"Flushed","speech_id":"dg_sp_a1b2c3d4e5f6"}"#).unwrap();
assert!(matches!(flushed, FluxSpeakResponse::Flushed { .. }));
}
#[test]
fn deserialize_speech_metadata() {
let json = r#"{"type":"SpeechMetadata","speech_id":"dg_sp_a1b2c3d4e5f6","audio_duration_ms":2340,"input_character_count":52,"billable_character_count":52,"controls_applied":{"pronunciations_applied":0,"breaks_applied":0,"pronunciation_warnings":0}}"#;
let response: FluxSpeakResponse = serde_json::from_str(json).unwrap();
match response {
FluxSpeakResponse::SpeechMetadata(metadata) => {
assert_eq!(metadata.speech_id, "dg_sp_a1b2c3d4e5f6");
assert_eq!(metadata.audio_duration_ms, 2340);
assert_eq!(metadata.billable_character_count, 52);
assert_eq!(metadata.controls_applied.breaks_applied, 0);
}
_ => panic!("expected SpeechMetadata"),
}
}
#[test]
fn deserialize_speech_interrupted_with_offset() {
let json = r#"{"type":"SpeechInterrupted","audio_played_ms":2340,"text_spoken":"Sure, I can help you cancel your subscription.","text_remaining":" Let me pull up your account.","metadata":{"speech_id":"dg_sp_a1b2c3d4e5f6","audio_duration_ms":4200,"input_character_count":75,"billable_character_count":75,"controls_applied":{"pronunciations_applied":0,"breaks_applied":0,"pronunciation_warnings":0}}}"#;
let response: FluxSpeakResponse = serde_json::from_str(json).unwrap();
match response {
FluxSpeakResponse::SpeechInterrupted {
audio_played_ms,
text_spoken,
text_remaining,
metadata,
} => {
assert_eq!(audio_played_ms, 2340);
assert!(text_spoken.unwrap().starts_with("Sure"));
assert!(text_remaining.is_some());
assert_eq!(metadata.audio_duration_ms, 4200);
}
_ => panic!("expected SpeechInterrupted"),
}
}
#[test]
fn deserialize_speech_interrupted_without_offset() {
let json = r#"{"type":"SpeechInterrupted","audio_played_ms":2340,"metadata":{"speech_id":"dg_sp_a1b2c3d4e5f6","audio_duration_ms":4200,"input_character_count":75,"billable_character_count":75,"controls_applied":{"pronunciations_applied":0,"breaks_applied":0,"pronunciation_warnings":0}}}"#;
let response: FluxSpeakResponse = serde_json::from_str(json).unwrap();
match response {
FluxSpeakResponse::SpeechInterrupted {
text_spoken,
text_remaining,
..
} => {
assert_eq!(text_spoken, None);
assert_eq!(text_remaining, None);
}
_ => panic!("expected SpeechInterrupted"),
}
}
#[test]
fn deserialize_session_metadata() {
let json = r#"{"type":"SessionMetadata","total_audio_duration_ms":10500,"total_input_character_count":230,"total_billable_character_count":230}"#;
let response: FluxSpeakResponse = serde_json::from_str(json).unwrap();
match response {
FluxSpeakResponse::SessionMetadata {
total_audio_duration_ms,
..
} => assert_eq!(total_audio_duration_ms, 10500),
_ => panic!("expected SessionMetadata"),
}
}
#[test]
fn deserialize_configure_success_and_failure() {
let ok: FluxSpeakResponse =
serde_json::from_str(r#"{"type":"ConfigureSuccess","applied":{"speed":1.05}}"#)
.unwrap();
match ok {
FluxSpeakResponse::ConfigureSuccess { applied } => {
assert_eq!(applied.speed, Some(1.05));
}
_ => panic!("expected ConfigureSuccess"),
}
let empty: FluxSpeakResponse =
serde_json::from_str(r#"{"type":"ConfigureSuccess","applied":{}}"#).unwrap();
match empty {
FluxSpeakResponse::ConfigureSuccess { applied } => assert_eq!(applied.speed, None),
_ => panic!("expected ConfigureSuccess"),
}
let failure: FluxSpeakResponse = serde_json::from_str(
r#"{"type":"ConfigureFailure","code":"SPEED_OUT_OF_RANGE","field":"speed","value":3.5,"description":"speed must be between 0.5 and 1.5 in 0.05 increments"}"#,
)
.unwrap();
match failure {
FluxSpeakResponse::ConfigureFailure {
code, field, value, ..
} => {
assert_eq!(code, "SPEED_OUT_OF_RANGE");
assert_eq!(field, Some("speed".to_string()));
assert_eq!(value, Some(3.5));
}
_ => panic!("expected ConfigureFailure"),
}
}
#[test]
fn deserialize_warning() {
let json = r#"{"type":"Warning","code":"NO_ACTIVE_SPEECH","description":"There is no active turn. The request will be ignored."}"#;
let response: FluxSpeakResponse = serde_json::from_str(json).unwrap();
match response {
FluxSpeakResponse::Warning { code, .. } => assert_eq!(code, "NO_ACTIVE_SPEECH"),
_ => panic!("expected Warning"),
}
}
#[test]
fn deserialize_fatal_error() {
let json = r#"{"type":"Error","code":"MESSAGE-0000","description":"The message could not be parsed."}"#;
let response: FluxSpeakResponse = serde_json::from_str(json).unwrap();
match response {
FluxSpeakResponse::FatalError { code, .. } => assert_eq!(code, "MESSAGE-0000"),
_ => panic!("expected FatalError"),
}
}
#[test]
fn deserialize_unknown_type_preserved() {
let json = r#"{"type":"NewFeature","some_field":42}"#;
let response: FluxSpeakResponse = serde_json::from_str(json).unwrap();
match response {
FluxSpeakResponse::Unknown(value) => {
assert_eq!(value["type"], "NewFeature");
assert_eq!(value["some_field"], 42);
}
_ => panic!("expected Unknown"),
}
}
#[test]
fn deserialize_missing_type_field() {
let json = r#"{"some_random":"message"}"#;
let response: FluxSpeakResponse = serde_json::from_str(json).unwrap();
assert!(matches!(response, FluxSpeakResponse::Unknown(_)));
}
}