From 20a37ebb5828cb90ecc120ec964aa8b723c37653 Mon Sep 17 00:00:00 2001 From: manishEMS47 Date: Tue, 9 Jun 2026 11:29:50 +0530 Subject: [PATCH] Added 60dB integration --- Cargo.toml | 2 + README.md | 8 +- examples/sixtydb_stt_example.rs | 29 ++ examples/sixtydb_tts_example.rs | 34 ++ src/backends/mod.rs | 3 + src/backends/sixtydb.rs | 379 ++++++++++++++++++++ src/bin/llm-cli/provider/factory/resolve.rs | 1 + src/bin/llm-cli/provider/keys.rs | 1 + src/bin/llm-cli/provider/registry.rs | 3 +- src/builder/backend.rs | 2 + src/builder/build/backends/mod.rs | 2 + src/builder/build/backends/sixtydb.rs | 34 ++ 12 files changed, 494 insertions(+), 4 deletions(-) create mode 100644 examples/sixtydb_stt_example.rs create mode 100644 examples/sixtydb_tts_example.rs create mode 100644 src/backends/sixtydb.rs create mode 100644 src/builder/build/backends/sixtydb.rs diff --git a/Cargo.toml b/Cargo.toml index b77aebb1..4ab7a116 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,6 +29,7 @@ full = [ "azure_openai", "api", "elevenlabs", + "sixtydb", "agent", "cohere", "mistral", @@ -79,6 +80,7 @@ cli = [ ] api = ["dep:axum", "dep:tower-http", "dep:uuid"] elevenlabs = [] +sixtydb = [] agent = [] rodio = ["dep:rodio"] logging = ["dep:env_logger"] diff --git a/README.md b/README.md index fb123433..56b89a02 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ > **Note**: This crate name previously belonged to another project. The current implementation represents a new and different library. The previous crate is now archived and will not receive any updates. **ref: https://github.com/rustformers/llm** -**LLM** is a **Rust** library that lets you use **multiple LLM backends** in a single project: [OpenAI](https://openai.com), [Anthropic (Claude)](https://www.anthropic.com), [Ollama](https://github.com/ollama/ollama), [DeepSeek](https://www.deepseek.com), [xAI](https://x.ai), [Phind](https://www.phind.com), [Groq](https://www.groq.com), [Google](https://cloud.google.com/gemini), [Cohere](https://cohere.com), [Mistral](https://mistral.ai), [Hugging Face](https://huggingface.co) and [ElevenLabs](https://elevenlabs.io). +**LLM** is a **Rust** library that lets you use **multiple LLM backends** in a single project: [OpenAI](https://openai.com), [Anthropic (Claude)](https://www.anthropic.com), [Ollama](https://github.com/ollama/ollama), [DeepSeek](https://www.deepseek.com), [xAI](https://x.ai), [Phind](https://www.phind.com), [Groq](https://www.groq.com), [Google](https://cloud.google.com/gemini), [Cohere](https://cohere.com), [Mistral](https://mistral.ai), [Hugging Face](https://huggingface.co), [ElevenLabs](https://elevenlabs.io) and [60db](https://60db.ai). With a **unified API** and **builder style** - similar to the Stripe experience - you can easily create **chat**, text **completion**, speak-to-text requests without multiplying structures and crates. ## Key Features @@ -36,7 +36,7 @@ Simply add **LLM** to your `Cargo.toml`: ```toml [dependencies] -llm = { version = "1.3.8", features = ["openai", "anthropic", "ollama", "deepseek", "xai", "phind", "google", "groq", "mistral", "elevenlabs"] } +llm = { version = "1.3.8", features = ["openai", "anthropic", "ollama", "deepseek", "xai", "phind", "google", "groq", "mistral", "elevenlabs", "sixtydb"] } ``` ## Use any LLM on cli @@ -57,7 +57,7 @@ LLM includes a command-line tool for easily interacting with different LLM model ```shell [dependencies] -llm = { version = "1.3.8", features = ["openai", "anthropic", "ollama", "deepseek", "xai", "phind", "google", "groq", "api", "mistral", "elevenlabs"] } +llm = { version = "1.3.8", features = ["openai", "anthropic", "ollama", "deepseek", "xai", "phind", "google", "groq", "api", "mistral", "elevenlabs", "sixtydb"] } ``` More details in the [`api_example`](examples/api_example.rs) @@ -101,6 +101,8 @@ More details in the [`api_example`](examples/api_example.rs) | [`anthropic_thinking_example`](examples/anthropic_thinking_example.rs) | Anthropic reasoning example | | [`elevenlabs_stt_example`](examples/elevenlabs_stt_example.rs) | Speech-to-text transcription example using ElevenLabs | | [`elevenlabs_tts_example`](examples/elevenlabs_tts_example.rs) | Text-to-speech example using ElevenLabs | +| [`sixtydb_stt_example`](examples/sixtydb_stt_example.rs) | Speech-to-text transcription example using 60db | +| [`sixtydb_tts_example`](examples/sixtydb_tts_example.rs) | Text-to-speech example using 60db | | [`openai_stt_example`](examples/openai_stt_example.rs) | Speech-to-text transcription example using OpenAI | | [`openai_tts_example`](examples/openai_tts_example.rs) | Text-to-speech example using OpenAI | | [`tts_rodio_example`](examples/tts_rodio_example.rs) | Text-to-speech with rodio example using OpenAI | diff --git a/examples/sixtydb_stt_example.rs b/examples/sixtydb_stt_example.rs new file mode 100644 index 00000000..fa0ca56f --- /dev/null +++ b/examples/sixtydb_stt_example.rs @@ -0,0 +1,29 @@ +//! Example demonstrating speech-to-text transcription using 60db +//! +//! This example shows how to: +//! 1. Initialize the 60db speech-to-text provider +//! 2. Load an audio file +//! 3. Transcribe the audio content + +use llm::builder::{LLMBackend, LLMBuilder}; + +#[tokio::main] +async fn main() -> Result<(), Box> { + // Get API key from environment variable or use test key + let api_key = std::env::var("SIXTYDB_API_KEY").unwrap_or("test_key".into()); + + // Initialize 60db speech-to-text provider + let stt = LLMBuilder::new() + .backend(LLMBackend::SixtyDb) + .api_key(api_key) + .build()?; + + // Read audio file from disk (WAV, MP3, M4A, OGG, FLAC, WebM, MP4) + let audio_bytes = std::fs::read("test-stt.m4a")?; + + // Transcribe audio content + let resp = stt.transcribe(audio_bytes).await?; + + println!("Transcription: {resp}"); + Ok(()) +} diff --git a/examples/sixtydb_tts_example.rs b/examples/sixtydb_tts_example.rs new file mode 100644 index 00000000..41848862 --- /dev/null +++ b/examples/sixtydb_tts_example.rs @@ -0,0 +1,34 @@ +//! Example demonstrating text-to-speech synthesis using 60db +//! +//! This example shows how to: +//! 1. Initialize the 60db text-to-speech provider +//! 2. Generate speech from text +//! 3. Save the audio output to a file + +use llm::builder::{LLMBackend, LLMBuilder}; + +#[tokio::main] +async fn main() -> Result<(), Box> { + // Get API key from environment variable or use test key + let api_key = std::env::var("SIXTYDB_API_KEY").unwrap_or("test_key".into()); + + // Initialize 60db text-to-speech provider + let tts = LLMBuilder::new() + .backend(LLMBackend::SixtyDb) + .api_key(api_key) + // Optional: set a voice id from your 60db account (GET /myvoices). + // .voice("fbb75ed2-975a-40c7-9e06-38e30524a9a1") + .build()?; + + // Text to convert to speech + let text = "Hello! This is an example of text-to-speech synthesis using 60db."; + + // Generate speech + let audio_data = tts.speech(text).await?; + + // Save the audio to a file + std::fs::write("output-speech-60db.mp3", audio_data)?; + + println!("Audio file generated successfully: output-speech-60db.mp3"); + Ok(()) +} diff --git a/src/backends/mod.rs b/src/backends/mod.rs index b44a2a2e..4659d6bb 100644 --- a/src/backends/mod.rs +++ b/src/backends/mod.rs @@ -31,6 +31,9 @@ pub mod azure_openai; #[cfg(feature = "elevenlabs")] pub mod elevenlabs; +#[cfg(feature = "sixtydb")] +pub mod sixtydb; + #[cfg(feature = "cohere")] pub mod cohere; diff --git a/src/backends/sixtydb.rs b/src/backends/sixtydb.rs new file mode 100644 index 00000000..5820742d --- /dev/null +++ b/src/backends/sixtydb.rs @@ -0,0 +1,379 @@ +use std::sync::Arc; + +use crate::chat::{ChatMessage, ChatProvider, ChatResponse, Tool}; +use crate::completion::{CompletionProvider, CompletionRequest, CompletionResponse}; +use crate::embedding::EmbeddingProvider; +#[cfg(feature = "sixtydb")] +use crate::error::LLMError; +use crate::models::ModelsProvider; +use crate::stt::SpeechToTextProvider; +use crate::tts::TextToSpeechProvider; +use crate::LLMProvider; +use async_trait::async_trait; +use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _}; +use reqwest::Client; +use serde::Deserialize; +use std::time::Duration; + +/// Configuration for the 60db client. +#[derive(Debug)] +pub struct SixtyDbConfig { + /// API key for authentication (sent as a Bearer token). + pub api_key: String, + /// Model identifier (kept for builder parity; 60db's TTS endpoint + /// selects the model implicitly from the voice, so it is not sent). + pub model_id: String, + /// Base URL for API requests. + pub base_url: String, + /// Request timeout in seconds. + pub timeout_seconds: Option, + /// Voice setting for TTS (maps to `voice_id`). + pub voice: Option, +} + +/// 60db audio backend implementation +/// +/// This struct provides text-to-speech synthesis and speech-to-text +/// transcription using the [60db](https://60db.ai) API. It implements every +/// LLM provider trait for uniformity but only supports audio (TTS + STT); all +/// other capabilities (chat, completion, embedding) return an unsupported error. +/// +/// The client uses `Arc` internally for configuration, making cloning cheap. +#[derive(Debug, Clone)] +pub struct SixtyDb { + /// Shared configuration wrapped in Arc for cheap cloning. + pub config: Arc, + /// HTTP client for making requests. + pub client: Client, +} + +/// Response structure from the 60db text-to-speech API. +/// +/// The endpoint returns the synthesized audio as a base64-encoded payload +/// rather than raw bytes, so it must be decoded before use. +#[allow(dead_code)] +#[derive(Debug, Deserialize)] +struct SixtyDbTtsResponse { + /// Whether the request succeeded. + #[serde(default)] + success: bool, + /// Human-readable status description. + #[serde(default)] + message: Option, + /// Base64-encoded audio payload. + #[serde(default)] + audio_base64: Option, + /// Sample rate of the returned audio in Hz. + #[serde(default)] + sample_rate: Option, + /// Length of the returned audio in seconds. + #[serde(default)] + duration_seconds: Option, + /// Encoding of the returned audio. + #[serde(default)] + encoding: Option, + /// Format of the returned audio (mp3, wav, ogg, flac). + #[serde(default)] + output_format: Option, +} + +/// Response structure from the 60db speech-to-text API. +/// +/// The endpoint returns the full transcript directly in the `text` field +/// (alongside richer per-segment / per-word data we currently ignore). +#[allow(dead_code)] +#[derive(Debug, Deserialize)] +struct SixtyDbSttResponse { + /// Unique identifier for the request. + #[serde(default)] + request_id: Option, + /// Full transcribed text. + text: String, + /// Detected language code if available. + #[serde(default)] + language: Option, + /// Human-readable detected language name if available. + #[serde(default)] + language_name: Option, + /// Audio duration in seconds. + #[serde(default)] + duration_sec: Option, +} + +impl SixtyDb { + /// Creates a new 60db instance. + /// + /// # Arguments + /// + /// * `api_key` - API key for 60db authentication + /// * `model_id` - Model identifier (kept for builder parity) + /// * `base_url` - Base URL for API requests + /// * `timeout_seconds` - Optional timeout duration in seconds + /// * `voice` - Optional voice id used for synthesis + /// + /// # Returns + /// + /// A new 60db instance + pub fn new( + api_key: String, + model_id: String, + base_url: String, + timeout_seconds: Option, + voice: Option, + ) -> Self { + Self::with_client( + Client::new(), + api_key, + model_id, + base_url, + timeout_seconds, + voice, + ) + } + + /// Creates a new 60db instance with a custom HTTP client. + pub fn with_client( + client: Client, + api_key: String, + model_id: String, + base_url: String, + timeout_seconds: Option, + voice: Option, + ) -> Self { + Self { + config: Arc::new(SixtyDbConfig { + api_key, + model_id, + base_url, + timeout_seconds, + voice, + }), + client, + } + } + + pub fn api_key(&self) -> &str { + &self.config.api_key + } + + pub fn model_id(&self) -> &str { + &self.config.model_id + } + + pub fn base_url(&self) -> &str { + &self.config.base_url + } + + pub fn timeout_seconds(&self) -> Option { + self.config.timeout_seconds + } + + pub fn voice(&self) -> Option<&str> { + self.config.voice.as_deref() + } + + pub fn client(&self) -> &Client { + &self.client + } +} + +#[async_trait] +impl SpeechToTextProvider for SixtyDb { + /// Transcribes audio data to text using the 60db API. + /// + /// Uploads the raw audio bytes to the `/stt` endpoint as multipart form + /// data and returns the full transcript from the response. + /// + /// # Arguments + /// + /// * `audio` - Raw audio data as bytes + /// + /// # Returns + /// + /// * `Ok(String)` - Transcribed text + /// * `Err(LLMError)` - Error if transcription fails + async fn transcribe(&self, audio: Vec) -> Result { + let url = format!("{}/stt", self.config.base_url); + let part = reqwest::multipart::Part::bytes(audio).file_name("audio.wav"); + let form = reqwest::multipart::Form::new().part("file", part); + + let mut req = self + .client + .post(url) + .bearer_auth(&self.config.api_key) + .multipart(form); + + if let Some(t) = self.config.timeout_seconds { + req = req.timeout(Duration::from_secs(t)); + } + + let resp = req.send().await?.error_for_status()?; + let body = resp.text().await?; + let parsed: SixtyDbSttResponse = + serde_json::from_str(&body).map_err(|e| LLMError::ResponseFormatError { + message: e.to_string(), + raw_response: body, + })?; + + Ok(parsed.text) + } + + /// Transcribes an audio file to text using the 60db API. + /// + /// # Arguments + /// + /// * `file_path` - Path to the audio file + /// + /// # Returns + /// + /// * `Ok(String)` - Transcribed text + /// * `Err(LLMError)` - Error if transcription fails + async fn transcribe_file(&self, file_path: &str) -> Result { + let url = format!("{}/stt", self.config.base_url); + let form = reqwest::multipart::Form::new() + .file("file", file_path) + .await + .map_err(|e| LLMError::HttpError(e.to_string()))?; + + let mut req = self + .client + .post(url) + .bearer_auth(&self.config.api_key) + .multipart(form); + + if let Some(t) = self.config.timeout_seconds { + req = req.timeout(Duration::from_secs(t)); + } + + let resp = req.send().await?.error_for_status()?; + let body = resp.text().await?; + let parsed: SixtyDbSttResponse = + serde_json::from_str(&body).map_err(|e| LLMError::ResponseFormatError { + message: e.to_string(), + raw_response: body, + })?; + + Ok(parsed.text) + } +} + +#[async_trait] +impl CompletionProvider for SixtyDb { + /// Returns a not implemented message for completion requests + async fn complete(&self, _req: &CompletionRequest) -> Result { + Ok(CompletionResponse { + text: "60db completion not implemented.".into(), + }) + } +} + +#[async_trait] +impl EmbeddingProvider for SixtyDb { + /// Returns an error indicating embedding is not supported + async fn embed(&self, _text: Vec) -> Result>, LLMError> { + Err(LLMError::ProviderError( + "Embedding not supported".to_string(), + )) + } +} + +#[async_trait] +impl ChatProvider for SixtyDb { + /// Returns an error indicating chat is not supported + async fn chat(&self, _messages: &[ChatMessage]) -> Result, LLMError> { + Err(LLMError::ProviderError("Chat not supported".to_string())) + } + + /// Returns an error indicating chat with tools is not supported + async fn chat_with_tools( + &self, + _messages: &[ChatMessage], + _tools: Option<&[Tool]>, + ) -> Result, LLMError> { + Err(LLMError::ProviderError( + "Chat with tools not supported".to_string(), + )) + } +} + +#[async_trait] +impl ModelsProvider for SixtyDb {} + +impl LLMProvider for SixtyDb { + /// Returns None as no tools are supported + fn tools(&self) -> Option<&[Tool]> { + None + } +} + +#[async_trait] +impl TextToSpeechProvider for SixtyDb { + /// Converts text to speech using the 60db API. + /// + /// Sends the text to the `/tts-synthesize` endpoint, then decodes the + /// base64-encoded audio payload from the JSON response. + /// + /// # Arguments + /// + /// * `text` - Text to convert to speech + /// + /// # Returns + /// + /// * `Ok(Vec)` - Decoded audio data as bytes + /// * `Err(LLMError)` - Error if conversion fails + async fn speech(&self, text: &str) -> Result, LLMError> { + let url = format!("{}/tts-synthesize", self.config.base_url); + + let mut body = serde_json::json!({ + "text": text, + "output_format": "mp3", + }); + if let Some(voice) = &self.config.voice { + body["voice_id"] = serde_json::Value::String(voice.clone()); + } + + let mut req = self + .client + .post(url) + .bearer_auth(&self.config.api_key) + .header("Content-Type", "application/json") + .json(&body); + + if let Some(t) = self.config.timeout_seconds { + req = req.timeout(Duration::from_secs(t)); + } + + let resp = req.send().await?.error_for_status()?; + let text_body = resp.text().await?; + let raw = text_body.clone(); + let parsed: SixtyDbTtsResponse = + serde_json::from_str(&text_body).map_err(|e| LLMError::ResponseFormatError { + message: e.to_string(), + raw_response: raw, + })?; + + if !parsed.success { + return Err(LLMError::ProviderError( + parsed + .message + .unwrap_or_else(|| "60db text-to-speech request failed".to_string()), + )); + } + + let audio_base64 = parsed.audio_base64.ok_or_else(|| { + LLMError::ResponseFormatError { + message: "60db response did not contain audio_base64".to_string(), + raw_response: text_body.clone(), + } + })?; + + let audio_data = BASE64 + .decode(audio_base64.as_bytes()) + .map_err(|e| LLMError::ResponseFormatError { + message: format!("failed to decode base64 audio: {e}"), + raw_response: text_body, + })?; + + Ok(audio_data) + } +} diff --git a/src/bin/llm-cli/provider/factory/resolve.rs b/src/bin/llm-cli/provider/factory/resolve.rs index e2451cb1..72c44c71 100644 --- a/src/bin/llm-cli/provider/factory/resolve.rs +++ b/src/bin/llm-cli/provider/factory/resolve.rs @@ -124,6 +124,7 @@ fn backend_label(backend: &LLMBackend) -> String { LLMBackend::Ollama => "ollama", LLMBackend::Phind => "phind", LLMBackend::ElevenLabs => "elevenlabs", + LLMBackend::SixtyDb => "60db", LLMBackend::AwsBedrock => "aws-bedrock", } .to_string() diff --git a/src/bin/llm-cli/provider/keys.rs b/src/bin/llm-cli/provider/keys.rs index 2241993e..f6755652 100644 --- a/src/bin/llm-cli/provider/keys.rs +++ b/src/bin/llm-cli/provider/keys.rs @@ -13,6 +13,7 @@ pub fn backend_env_key(backend: &LLMBackend) -> Option<&'static str> { LLMBackend::Mistral => Some("MISTRAL_API_KEY"), LLMBackend::OpenRouter => Some("OPENROUTER_API_KEY"), LLMBackend::HuggingFace => Some("HF_TOKEN"), + LLMBackend::SixtyDb => Some("SIXTYDB_API_KEY"), LLMBackend::Ollama | LLMBackend::Phind | LLMBackend::ElevenLabs diff --git a/src/bin/llm-cli/provider/registry.rs b/src/bin/llm-cli/provider/registry.rs index f86ae443..78c023b7 100644 --- a/src/bin/llm-cli/provider/registry.rs +++ b/src/bin/llm-cli/provider/registry.rs @@ -82,6 +82,7 @@ fn builtin_provider_list() -> Vec { provider_info("huggingface", "HuggingFace", LLMBackend::HuggingFace), provider_info("aws-bedrock", "AWS Bedrock", LLMBackend::AwsBedrock), provider_info("elevenlabs", "ElevenLabs", LLMBackend::ElevenLabs), + provider_info("60db", "60db", LLMBackend::SixtyDb), ] } @@ -109,6 +110,6 @@ fn provider_capabilities(backend: &LLMBackend) -> ProviderCapabilities { LLMBackend::Google | LLMBackend::AwsBedrock => ProviderCapabilities::TOOLS_NO_STREAM, LLMBackend::Ollama => ProviderCapabilities::LOCAL_BASIC, LLMBackend::Phind => ProviderCapabilities::STREAM_ONLY, - LLMBackend::ElevenLabs => ProviderCapabilities::NONE, + LLMBackend::ElevenLabs | LLMBackend::SixtyDb => ProviderCapabilities::NONE, } } diff --git a/src/builder/backend.rs b/src/builder/backend.rs index 3edc0357..dfb11579 100644 --- a/src/builder/backend.rs +++ b/src/builder/backend.rs @@ -13,6 +13,7 @@ pub enum LLMBackend { Groq, AzureOpenAI, ElevenLabs, + SixtyDb, Cohere, Mistral, OpenRouter, @@ -35,6 +36,7 @@ impl std::str::FromStr for LLMBackend { "groq" => Ok(LLMBackend::Groq), "azure-openai" => Ok(LLMBackend::AzureOpenAI), "elevenlabs" => Ok(LLMBackend::ElevenLabs), + "60db" | "sixtydb" => Ok(LLMBackend::SixtyDb), "cohere" => Ok(LLMBackend::Cohere), "mistral" => Ok(LLMBackend::Mistral), "openrouter" => Ok(LLMBackend::OpenRouter), diff --git a/src/builder/build/backends/mod.rs b/src/builder/build/backends/mod.rs index b26c94bd..7cdd4d88 100644 --- a/src/builder/build/backends/mod.rs +++ b/src/builder/build/backends/mod.rs @@ -7,6 +7,7 @@ mod ollama; mod openai; mod openai_compatible; mod phind; +mod sixtydb; mod xai; use crate::{ @@ -39,6 +40,7 @@ pub(super) fn build_backend( LLMBackend::HuggingFace => openai_compatible::build_huggingface(state, tools, tool_choice), LLMBackend::AzureOpenAI => azure::build_azure_openai(state, tools, tool_choice), LLMBackend::ElevenLabs => elevenlabs::build_elevenlabs(state), + LLMBackend::SixtyDb => sixtydb::build_sixtydb(state), LLMBackend::AwsBedrock => azure::build_bedrock(state, tools, tool_choice), } } diff --git a/src/builder/build/backends/sixtydb.rs b/src/builder/build/backends/sixtydb.rs new file mode 100644 index 00000000..042f60a0 --- /dev/null +++ b/src/builder/build/backends/sixtydb.rs @@ -0,0 +1,34 @@ +use crate::{error::LLMError, LLMProvider}; + +use super::super::helpers; +use crate::builder::state::BuilderState; + +const DEFAULT_SIXTYDB_MODEL: &str = "60db Fast"; +const DEFAULT_SIXTYDB_URL: &str = "https://api.60db.ai"; + +#[cfg(feature = "sixtydb")] +pub(super) fn build_sixtydb(state: &mut BuilderState) -> Result, LLMError> { + let api_key = helpers::require_api_key(state, "60db")?; + let timeout = helpers::timeout_or_default(state); + let model = state + .model + .take() + .unwrap_or_else(|| DEFAULT_SIXTYDB_MODEL.to_string()); + + let provider = crate::backends::sixtydb::SixtyDb::new( + api_key, + model, + DEFAULT_SIXTYDB_URL.to_string(), + timeout, + state.voice.take(), + ); + + Ok(Box::new(provider)) +} + +#[cfg(not(feature = "sixtydb"))] +pub(super) fn build_sixtydb(_state: &mut BuilderState) -> Result, LLMError> { + Err(LLMError::InvalidRequest( + "60db feature not enabled".to_string(), + )) +}