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:
Cameron CordesandClaude Opus 4.8 committed 2026-06-02 22:04:42 -04:00
1 parent 015dc976e3
commit 69268d03fe
6 files changed
+558

No files matched your search

+136
View File
@@ -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");
}
}