First Version

This commit is contained in:
2026-07-10 15:02:09 +01:00
commit 38494a85a9
562 changed files with 196313 additions and 0 deletions
+378
View File
@@ -0,0 +1,378 @@
use std::sync::Arc;
use anyhow::{Context, Result, anyhow};
use async_trait::async_trait;
use parking_lot::Mutex;
use serde_json::Value;
use tracing::{debug, info};
use core_api::plugin::{Plugin, PluginContext};
use core_api::provider::{
ApiProvider, LlmProviderRecord, ProviderField, ProviderUiMeta, RemoteLlmModelInfo,
ServiceType,
};
use core_api::transcribe::{RemoteTranscribeModelInfo, Transcribe, TranscribeModelRecord};
use core_api::tts::{RemoteTtsModelInfo, TextToSpeech, TtsModelRecord};
// ── Constants ─────────────────────────────────────────────────────────────────
const EL_BASE_URL: &str = "https://api.elevenlabs.io/v1";
const EL_DEFAULT_MODEL: &str = "eleven_multilingual_v2";
// ── TTS Synthesiser ───────────────────────────────────────────────────────────
/// ElevenLabsTtsSynthesiser — cloud TTS via the ElevenLabs v1 API.
///
/// Endpoint: `POST https://api.elevenlabs.io/v1/text-to-speech/{voice_id}`
/// Auth: `xi-api-key` header (not Bearer).
pub struct ElevenLabsTtsSynthesiser {
id: String,
api_key: String,
model_id: String,
voice_id: String,
instructions: Option<String>,
http: reqwest::Client,
}
impl ElevenLabsTtsSynthesiser {
pub fn new(
id: impl Into<String>,
api_key: impl Into<String>,
model_id: impl Into<String>,
voice_id: Option<String>,
instructions: Option<String>,
) -> Self {
let model_id = model_id.into();
// Legacy fallback: if no voice_id, treat model_id as voice_id and use default model.
let (resolved_model, resolved_voice) = match voice_id {
Some(v) => (model_id, v),
None => (EL_DEFAULT_MODEL.to_string(), model_id),
};
Self {
id: id.into(),
api_key: api_key.into(),
model_id: resolved_model,
voice_id: resolved_voice,
instructions,
http: reqwest::Client::new(),
}
}
}
#[async_trait]
impl TextToSpeech for ElevenLabsTtsSynthesiser {
fn id(&self) -> &str { &self.id }
fn name(&self) -> &str { &self.id }
fn instructions(&self) -> Option<&str> { self.instructions.as_deref() }
async fn synthesize(&self, text: &str, _instructions: Option<&str>) -> Result<Vec<u8>> {
debug!(
chars = text.len(),
voice_id = %self.voice_id,
"elevenlabs_tts: synthesising",
);
let url = format!("{EL_BASE_URL}/text-to-speech/{}", self.voice_id);
let body = serde_json::json!({
"text": text,
"model_id": self.model_id,
});
let resp = self.http
.post(&url)
.header("xi-api-key", &self.api_key)
.json(&body)
.send()
.await
.map_err(|e| anyhow!("elevenlabs_tts: request failed: {e}"))?;
let status = resp.status();
if !status.is_success() {
let err: serde_json::Value = resp.json().await.unwrap_or_default();
let msg = err["detail"]["message"].as_str()
.or_else(|| err["detail"].as_str())
.unwrap_or("unknown error");
anyhow::bail!("elevenlabs_tts: API error {status}: {msg}");
}
let audio = resp
.bytes()
.await
.map_err(|e| anyhow!("elevenlabs_tts: failed to read audio bytes: {e}"))?
.to_vec();
info!(bytes = audio.len(), voice_id = %self.voice_id, "elevenlabs_tts: synthesis complete");
Ok(audio)
}
}
// ── Transcriber ───────────────────────────────────────────────────────────────
/// ElevenLabsTranscriber — cloud Speech-to-Text via the ElevenLabs Scribe API.
///
/// Endpoint: `POST https://api.elevenlabs.io/v1/speech-to-text`
/// Auth: `xi-api-key` header (not Bearer).
pub struct ElevenLabsTranscriber {
id: String,
api_key: String,
model_id: String,
http: reqwest::Client,
}
impl ElevenLabsTranscriber {
pub fn new(
id: impl Into<String>,
api_key: impl Into<String>,
model_id: impl Into<String>,
) -> Self {
Self {
id: id.into(),
api_key: api_key.into(),
model_id: model_id.into(),
http: reqwest::Client::new(),
}
}
}
#[async_trait]
impl Transcribe for ElevenLabsTranscriber {
fn id(&self) -> &str { &self.id }
async fn transcribe(&self, audio: Vec<u8>, format: &str) -> Result<String> {
debug!(
bytes = audio.len(),
format = %format,
model_id = %self.model_id,
"elevenlabs_transcribe: transcribing",
);
let url = format!("{EL_BASE_URL}/speech-to-text");
let filename = format!("audio.{format}");
let part = reqwest::multipart::Part::bytes(audio)
.file_name(filename)
.mime_str("audio/wav")?;
let form = reqwest::multipart::Form::new()
.text("model_id", self.model_id.clone())
.part("file", part);
let resp = self.http
.post(&url)
.header("xi-api-key", &self.api_key)
.multipart(form)
.send()
.await
.map_err(|e| anyhow!("elevenlabs_transcribe: request failed: {e}"))?;
let status = resp.status();
if !status.is_success() {
let err: serde_json::Value = resp.json().await.unwrap_or_default();
let msg = err["detail"]["message"].as_str()
.or_else(|| err["detail"].as_str())
.unwrap_or("unknown error");
anyhow::bail!("elevenlabs_transcribe: API error {status}: {msg}");
}
let body: serde_json::Value = resp
.json()
.await
.map_err(|e| anyhow!("elevenlabs_transcribe: failed to parse response: {e}"))?;
let text = body["text"]
.as_str()
.ok_or_else(|| anyhow!("elevenlabs_transcribe: missing 'text' in response"))?
.to_string();
info!(chars = text.len(), "elevenlabs_transcribe: done");
Ok(text)
}
}
// ── ApiProvider ───────────────────────────────────────────────────────────────
/// ElevenLabs supports TTS and Transcription only — no LLM chat/completion.
pub struct ElevenLabsProvider {
http: reqwest::Client,
}
impl ElevenLabsProvider {
pub fn new() -> Self {
Self { http: reqwest::Client::new() }
}
async fn fetch_models(&self, api_key: &str) -> Result<serde_json::Value> {
self.http
.get("https://api.elevenlabs.io/v1/models")
.header("xi-api-key", api_key)
.send()
.await
.map_err(|e| anyhow!("ElevenLabs request failed: {e}"))?
.error_for_status()
.map_err(|e| anyhow!("ElevenLabs error response: {e}"))?
.json()
.await
.map_err(|e| anyhow!("ElevenLabs response parse failed: {e}"))
}
}
#[async_trait]
impl ApiProvider for ElevenLabsProvider {
fn type_id(&self) -> &'static str { "elevenlabs" }
fn display_name(&self) -> &'static str { "ElevenLabs" }
fn supported_types(&self) -> &'static [ServiceType] {
&[ServiceType::Tts, ServiceType::Transcribe]
}
async fn list_llm_models(&self, _record: &LlmProviderRecord) -> Result<Option<Vec<RemoteLlmModelInfo>>> {
Ok(None)
}
async fn list_tts_models(&self, record: &LlmProviderRecord) -> Result<Option<Vec<RemoteTtsModelInfo>>> {
let api_key = record.api_key.as_deref()
.ok_or_else(|| anyhow!("provider '{}': api_key required for elevenlabs model listing", record.name))?;
let resp = self.fetch_models(api_key).await?;
let models = resp.as_array()
.ok_or_else(|| anyhow!("unexpected ElevenLabs response shape"))?
.iter()
.filter(|m| m["can_do_text_to_speech"].as_bool().unwrap_or(false))
.map(|m| {
let id = m["model_id"].as_str().unwrap_or("").to_string();
let name = m["name"].as_str().unwrap_or(&id).to_string();
let description = m["description"].as_str().map(str::to_string);
let languages = m["languages"].as_array()
.map(|langs| langs.iter()
.filter_map(|l| l["language_id"].as_str().map(str::to_string))
.collect())
.unwrap_or_default();
let cost_factor = m["token_cost_factor"].as_f64();
let instructions = elevenlabs_tts_instructions(&id);
RemoteTtsModelInfo { id, name, description, languages, cost_factor, instructions }
})
.collect();
Ok(Some(models))
}
async fn list_transcribe_models(&self, record: &LlmProviderRecord) -> Result<Option<Vec<RemoteTranscribeModelInfo>>> {
let api_key = record.api_key.as_deref()
.ok_or_else(|| anyhow!("provider '{}': api_key required for elevenlabs model listing", record.name))?;
let resp = self.fetch_models(api_key).await?;
let models = resp.as_array()
.ok_or_else(|| anyhow!("unexpected ElevenLabs response shape"))?
.iter()
.filter(|m| m["can_do_voice_conversion"].as_bool().unwrap_or(false)
|| m["model_id"].as_str().map(|id| id.starts_with("scribe")).unwrap_or(false))
.map(|m| {
let id = m["model_id"].as_str().unwrap_or("").to_string();
let name = m["name"].as_str().unwrap_or(&id).to_string();
let description = m["description"].as_str().map(str::to_string);
let languages = m["languages"].as_array()
.map(|langs| langs.iter()
.filter_map(|l| l["language_id"].as_str().map(str::to_string))
.collect())
.unwrap_or_default();
RemoteTranscribeModelInfo { id, name, description, languages }
})
.collect();
Ok(Some(models))
}
fn build_tts(&self, record: &LlmProviderRecord, model: &TtsModelRecord) -> Option<Result<Arc<dyn TextToSpeech>>> {
Some((|| {
let api_key = record.api_key.clone()
.with_context(|| format!("provider '{}': api_key required for elevenlabs", record.name))?;
Ok(Arc::new(ElevenLabsTtsSynthesiser::new(
&model.name, api_key, &model.model_id, model.voice_id.clone(), model.instructions.clone(),
)) as Arc<dyn TextToSpeech>)
})())
}
fn build_transcriber(&self, record: &LlmProviderRecord, model: &TranscribeModelRecord) -> Option<Result<Arc<dyn Transcribe>>> {
Some((|| {
let api_key = record.api_key.clone()
.with_context(|| format!("provider '{}': api_key required for elevenlabs", record.name))?;
Ok(Arc::new(ElevenLabsTranscriber::new(
&model.name, api_key, &model.model_id,
)) as Arc<dyn Transcribe>)
})())
}
fn ui_meta(&self) -> ProviderUiMeta {
ProviderUiMeta {
type_id: "elevenlabs",
display_name: "ElevenLabs",
description: Some("Text-to-speech and transcription"),
color: "#f59e0b",
icon: "bi-waveform",
fields: &[
ProviderField { key: "api_key", label: "API Key", required: true, secret: true },
],
}
}
}
fn elevenlabs_tts_instructions(model_id: &str) -> Option<String> {
match model_id {
"eleven_multilingual_v2" | "eleven_turbo_v2_5" | "eleven_turbo_v2" | "eleven_flash_v2_5" | "eleven_flash_v2" => Some(
"You can use the following markers in text to add expressiveness:\n\
- <break time=\"0.5s\" /> — pause of given duration\n\
- <phoneme alphabet=\"ipa\" ph=\"...\">word</phoneme> — explicit pronunciation\n\
Laugh, cough, or sigh naturally by writing them as actions in parentheses, e.g. (laughs), (sighs), (coughs).\n\
Emphasise a word with ALL CAPS or by repeating letters (e.g. sooo goood).\n\
Keep sentences short for best pacing.".to_string()
),
"eleven_monolingual_v1" => Some(
"English-only model. Supports (laughs), (sighs), (coughs) for non-verbal sounds.\n\
Use ALL CAPS for emphasis. Avoid non-English characters.".to_string()
),
_ => None,
}
}
// ── Plugin ────────────────────────────────────────────────────────────────────
pub struct ElevenLabsPlugin {
running: Mutex<bool>,
}
impl ElevenLabsPlugin {
pub fn new() -> Self {
Self { running: Mutex::new(false) }
}
}
#[async_trait]
impl Plugin for ElevenLabsPlugin {
fn id(&self) -> &str { "elevenlabs" }
fn name(&self) -> &str { "ElevenLabs" }
fn description(&self) -> &str { "ElevenLabs TTS and transcription provider" }
fn is_running(&self) -> bool { *self.running.lock() }
async fn start(&self, ctx: PluginContext) -> Result<()> {
ctx.api_provider_registry.register_plugin(Arc::new(ElevenLabsProvider::new()));
*self.running.lock() = true;
Ok(())
}
async fn reload(&self, enabled: bool, _config: Value, ctx: PluginContext) -> Result<()> {
if enabled {
ctx.api_provider_registry.register_plugin(Arc::new(ElevenLabsProvider::new()));
*self.running.lock() = true;
} else {
ctx.api_provider_registry.unregister_plugin("elevenlabs");
*self.running.lock() = false;
}
Ok(())
}
async fn stop(&self) -> Result<()> {
*self.running.lock() = false;
Ok(())
}
fn as_any(&self) -> &dyn std::any::Any { self }
fn as_arc_any(self: Arc<Self>) -> Arc<dyn std::any::Any + Send + Sync> { self }
}