Add TTS endpoints backed by Chatterbox via llama-swap
LlamaCppClient gains text_to_speech (OpenAI /audio/speech), list_voices and
create_voice (voice library at the swap-root /upstream/<model>/voices
passthrough), plus a tts_model slot configured via LLAMA_SWAP_TTS_MODEL
(default "chatterbox").
New Claims-gated routes:
- POST /tts/speech -> { audio_base64, format } for data: URI playback
- GET /tts/voices -> voice library passthrough
- POST /tts/voices/upload -> clone a voice from an uploaded clip (multipart)
- POST /tts/voices/from-library -> clone from a library file (ffmpeg-extracts
audio from video; audio forwarded as-is)
Security: voice_name sanitized to [A-Za-z0-9_-] (it becomes an upstream
filename), 25 MB upload cap, library refs restricted to real audio/video,
path confined via is_valid_full_path. Adds is_audio_file + unit tests for the
sanitizer, mime guesser, and swap-root derivation.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
015dc976e3
commit
69268d03fe
6 files changed
+558
No files matched your search
@@ -36,6 +36,7 @@ const DEFAULT_BASE_URL: &str = "http://localhost:9292/v1";
|
||||
const DEFAULT_PRIMARY_MODEL: &str = "chat";
|
||||
const DEFAULT_VISION_MODEL: &str = "vision";
|
||||
const DEFAULT_EMBEDDING_MODEL: &str = "embed";
|
||||
const DEFAULT_TTS_MODEL: &str = "chatterbox";
|
||||
const DEFAULT_REQUEST_TIMEOUT_SECS: u64 = 180;
|
||||
|
||||
/// OpenAI-compatible client targeting a llama-swap proxy in front of one or
|
||||
@@ -54,6 +55,10 @@ pub struct LlamaCppClient {
|
||||
/// to `primary_model` so describe_image works out of the box; override
|
||||
/// via `LLAMA_SWAP_VISION_MODEL` for a dedicated vision slot.
|
||||
pub vision_model: String,
|
||||
/// TTS model slot id (e.g. `"chatterbox"`). Routes `text_to_speech` and
|
||||
/// is the `/upstream/<id>/voices` path segment for the voice library.
|
||||
/// Override via `LLAMA_SWAP_TTS_MODEL`.
|
||||
pub tts_model: String,
|
||||
num_ctx: Option<i32>,
|
||||
temperature: Option<f32>,
|
||||
top_p: Option<f32>,
|
||||
@@ -78,6 +83,7 @@ impl LlamaCppClient {
|
||||
primary_model: pm.clone(),
|
||||
embedding_model: DEFAULT_EMBEDDING_MODEL.to_string(),
|
||||
vision_model: pm,
|
||||
tts_model: DEFAULT_TTS_MODEL.to_string(),
|
||||
num_ctx: None,
|
||||
temperature: None,
|
||||
top_p: None,
|
||||
@@ -111,6 +117,116 @@ impl LlamaCppClient {
|
||||
self.min_p = min_p;
|
||||
}
|
||||
|
||||
pub fn set_tts_model(&mut self, model: String) {
|
||||
self.tts_model = model;
|
||||
}
|
||||
|
||||
// --- TTS (Chatterbox behind llama-swap) ---------------------------------
|
||||
//
|
||||
// Speech synthesis uses the OpenAI-compatible `{base_url}/audio/speech`
|
||||
// endpoint (llama-swap routes by the `model` field). The voice *library*
|
||||
// (list / create cloned voices) is NOT an OpenAI endpoint — it lives on the
|
||||
// upstream server directly, reached via llama-swap's passthrough at
|
||||
// `{swap_root}/upstream/<tts_model>/voices`.
|
||||
|
||||
/// Root of the llama-swap proxy: `base_url` with a trailing `/v1` removed.
|
||||
/// The `/upstream/...` passthrough lives here, not under `/v1`.
|
||||
fn swap_root(&self) -> &str {
|
||||
let b = self.base_url.trim_end_matches('/');
|
||||
b.strip_suffix("/v1").unwrap_or(b)
|
||||
}
|
||||
|
||||
/// Synthesize speech for `input` in an optional named `voice`, returning
|
||||
/// the raw audio bytes (format per `response_format`, e.g. `"mp3"`/`"wav"`).
|
||||
pub async fn text_to_speech(
|
||||
&self,
|
||||
input: &str,
|
||||
voice: Option<&str>,
|
||||
response_format: &str,
|
||||
) -> Result<Vec<u8>> {
|
||||
let url = format!("{}/audio/speech", self.base_url);
|
||||
let mut body = json!({
|
||||
"model": self.tts_model,
|
||||
"input": input,
|
||||
"response_format": response_format,
|
||||
});
|
||||
if let Some(v) = voice {
|
||||
body["voice"] = Value::String(v.to_string());
|
||||
}
|
||||
|
||||
let resp = self
|
||||
.client
|
||||
.post(&url)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.with_context(|| format!("POST {} failed", url))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
bail!("llama-swap TTS request failed: {} — {}", status, text);
|
||||
}
|
||||
|
||||
Ok(resp
|
||||
.bytes()
|
||||
.await
|
||||
.context("reading TTS audio bytes")?
|
||||
.to_vec())
|
||||
}
|
||||
|
||||
/// List voices in the Chatterbox voice library (raw JSON passthrough).
|
||||
pub async fn list_voices(&self) -> Result<Value> {
|
||||
let url = format!("{}/upstream/{}/voices", self.swap_root(), self.tts_model);
|
||||
let resp = self
|
||||
.client
|
||||
.get(&url)
|
||||
.send()
|
||||
.await
|
||||
.with_context(|| format!("GET {} failed", url))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
bail!("llama-swap list_voices failed: {} — {}", status, text);
|
||||
}
|
||||
resp.json().await.context("parsing voices response")
|
||||
}
|
||||
|
||||
/// Register a cloned voice from raw audio bytes (multipart `voice_name` +
|
||||
/// `voice_file`). Returns the upstream JSON response.
|
||||
pub async fn create_voice(
|
||||
&self,
|
||||
voice_name: &str,
|
||||
audio_bytes: Vec<u8>,
|
||||
filename: &str,
|
||||
mime: &str,
|
||||
) -> Result<Value> {
|
||||
let url = format!("{}/upstream/{}/voices", self.swap_root(), self.tts_model);
|
||||
let part = reqwest::multipart::Part::bytes(audio_bytes)
|
||||
.file_name(filename.to_string())
|
||||
.mime_str(mime)
|
||||
.context("invalid audio mime type")?;
|
||||
let form = reqwest::multipart::Form::new()
|
||||
.text("voice_name", voice_name.to_string())
|
||||
.part("voice_file", part);
|
||||
|
||||
let resp = self
|
||||
.client
|
||||
.post(&url)
|
||||
.multipart(form)
|
||||
.send()
|
||||
.await
|
||||
.with_context(|| format!("POST {} (multipart) failed", url))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
bail!("llama-swap create_voice failed: {} — {}", status, text);
|
||||
}
|
||||
resp.json().await.context("parsing create_voice response")
|
||||
}
|
||||
|
||||
/// Translate canonical messages to the OpenAI-compatible wire shape.
|
||||
/// Behaviorally identical to `OpenRouterClient::messages_to_openai` —
|
||||
/// stringify tool-call arguments, rewrite images into content-parts, attach
|
||||
@@ -1140,4 +1256,24 @@ mod tests {
|
||||
let wire = LlamaCppClient::messages_to_openai(&[msg]);
|
||||
assert_eq!(wire[0]["content"], "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn swap_root_strips_v1_suffix() {
|
||||
let c = LlamaCppClient::new(Some("http://localhost:9292/v1".to_string()), None);
|
||||
assert_eq!(c.swap_root(), "http://localhost:9292");
|
||||
|
||||
// Tolerates a trailing slash on the base URL.
|
||||
let c2 = LlamaCppClient::new(Some("http://localhost:9292/v1/".to_string()), None);
|
||||
assert_eq!(c2.swap_root(), "http://localhost:9292");
|
||||
|
||||
// No /v1 suffix → returned unchanged.
|
||||
let c3 = LlamaCppClient::new(Some("http://host:1234".to_string()), None);
|
||||
assert_eq!(c3.swap_root(), "http://host:1234");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tts_model_defaults_to_chatterbox() {
|
||||
let c = LlamaCppClient::new(None, None);
|
||||
assert_eq!(c.tts_model, "chatterbox");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user