diff --git a/Cargo.toml b/Cargo.toml index 2224391..c88fff9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,13 @@ edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] +async-trait = "0.1.77" +lazy_static = "1.4.0" +regex = "1.10.3" +reqwest = { version = "0.11.24", features = ["json"] } +serde = { version = "1.0.197", features = ["derive"] } +serde_json = "1.0.114" +tokio = { version = "1.36.0", features = ["full"] } [dependencies.pyo3] version = "0.20.2" diff --git a/src/lib.rs b/src/lib.rs index 09225ac..d18a674 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,4 +1,10 @@ +use async_trait::async_trait; +use lazy_static::lazy_static; use pyo3::{prelude::*, types::IntoPyDict}; +use regex::Regex; +use reqwest::{self}; +use serde::{Deserialize, Serialize}; +use std::fmt; /// The options for generation, including the maximum number of tokens to generate, /// the temperature, and the top p sampling. @@ -33,8 +39,134 @@ pub fn is_cuda_available() -> bool { .unwrap_or(false) } +/// The options for building a language model +#[derive(Debug, Clone, Default)] +pub struct LLMOptions { + /// Whether or not to use the vllm version (HuggingFace Hub models only) + pub use_vllm: Option, + /// The device to use for the model (HuggingFace Hub models only) + pub device: Option, + /// The OpenAI API key (OpenAI models only) + pub api_key: Option, +} + +/// A builder for LLMOptions +pub struct LLMOptionsBuilder { + options: LLMOptions, +} + +impl LLMOptionsBuilder { + pub fn new() -> Self { + Self { + options: LLMOptions::default(), + } + } + + pub fn use_vllm(mut self, use_vllm: bool) -> Self { + self.options.use_vllm = Some(use_vllm); + self + } + + pub fn device(mut self, device: impl Into) -> Self { + self.options.device = Some(device.into()); + self + } + + pub fn api_key(mut self, api_key: impl Into) -> Self { + self.options.api_key = Some(api_key.into()); + self + } + + pub fn build(self) -> LLMOptions { + self.options + } +} + +/// Builds a new language model from a model name and a set of options +pub fn llm_factory( + name: impl Into, + options: LLMOptions, +) -> Result, Box> { + let name = name.into(); + match get_model_source_type(name.clone()) { + LanguageModelSourceType::OpenAI => { + if let Some(api_key) = options.api_key { + Ok(Box::new(OpenAILLM::new(name, api_key))) + } else { + Err(Box::new(ChatError { + message: "OpenAI API key is required".to_string(), + })) + } + } + LanguageModelSourceType::HuggingFace => { + let device = options.device.unwrap_or_else(|| { + if is_cuda_available() { + "cuda:0".to_string() + } else { + "cpu".to_string() + } + }); + if options.use_vllm.unwrap_or(false) { + if let Ok(vllm) = VLLM::from_pretrained(name.clone(), device) { + Ok(Box::new(vllm)) + } else { + Err(Box::new(ChatError { + message: format!("Failed to load vLLM model: {}", name), + })) + } + } else { + if let Ok(hf_llm) = HuggingFaceLLM::from_pretrained(name.clone(), device) { + Ok(Box::new(hf_llm)) + } else { + Err(Box::new(ChatError { + message: format!("Failed to load HuggingFace model: {}", name), + })) + } + } + } + } +} + +enum LanguageModelSourceType { + HuggingFace, + OpenAI, +} + +fn get_model_source_type(name: impl Into) -> LanguageModelSourceType { + if is_openai_model(&name.into()) { + LanguageModelSourceType::OpenAI + } else { + LanguageModelSourceType::HuggingFace + } +} + +lazy_static! { + static ref REGISTERED_OPENAI_MODEL_PATTERNS: [Regex; 2] = [ + Regex::new(r"^gpt-4-.*$").unwrap(), + Regex::new(r"^gpt-3\.5-turbo-.*$").unwrap(), + ]; +} + +fn is_openai_model(name: &str) -> bool { + REGISTERED_OPENAI_MODEL_PATTERNS + .iter() + .any(|pattern| pattern.is_match(name)) +} + /// Defines the interface for a language model +#[async_trait] pub trait LanguageModel { + /// Generates text from a list of prompts; returns a list of generated text. + /// Takes in generation options. + async fn generate( + &self, + prompts: Vec, + options: GenerationOptions, + ) -> Result, Box>; +} + +/// Defines the interface for a language model loader that is modeled after HuggingFace's ModelHubMixin +pub trait FromPretrained { /// Creates a new language model from a pretrained model name /// and device name. fn from_pretrained( @@ -43,14 +175,6 @@ pub trait LanguageModel { ) -> Result> where Self: Sized; - - /// Generates text from a list of prompts; returns a list of generated text. - /// Takes in generation options. - fn generate( - &self, - prompts: Vec, - options: GenerationOptions, - ) -> Result, Box>; } /// Simple HuggingFace LLM wrapper @@ -66,7 +190,7 @@ pub struct HuggingFaceLLM { pub device: String, } -impl LanguageModel for HuggingFaceLLM { +impl FromPretrained for HuggingFaceLLM { fn from_pretrained( name: impl Into, device: impl Into, @@ -102,8 +226,11 @@ impl LanguageModel for HuggingFaceLLM { }) }) } +} - fn generate( +#[async_trait] +impl LanguageModel for HuggingFaceLLM { + async fn generate( &self, prompts: Vec, options: GenerationOptions, @@ -190,7 +317,7 @@ pub fn autodetect_dtype() -> String { .unwrap_or("auto".to_string()) } -impl LanguageModel for VLLM { +impl FromPretrained for VLLM { fn from_pretrained( name: impl Into, _device: impl Into, @@ -210,8 +337,11 @@ impl LanguageModel for VLLM { Ok(Self { name, model }) }) } +} - fn generate( +#[async_trait] +impl LanguageModel for VLLM { + async fn generate( &self, prompts: Vec, options: GenerationOptions, @@ -252,6 +382,187 @@ impl LanguageModel for VLLM { } } +/// An OpenAI language model +#[derive(Debug, Clone)] +pub struct OpenAILLM { + pub name: String, + pub api_key: String, + pub client: reqwest::Client, +} + +impl OpenAILLM { + pub fn new(name: impl Into, api_key: impl Into) -> Self { + Self { + name: name.into(), + api_key: api_key.into(), + client: reqwest::Client::new(), + } + } +} + +const OPENAI_API_HOST: &str = "api.openai.com"; +const OPENAI_API_CHAT_ENDPOINT: &str = "/v1/chat/completions"; + +#[async_trait] +impl LanguageModel for OpenAILLM { + async fn generate( + &self, + prompts: Vec, + options: GenerationOptions, + ) -> Result, Box> { + let url = format!("https://{}{}", OPENAI_API_HOST, OPENAI_API_CHAT_ENDPOINT); + let body = ChatRequestBody { + model: self.name.clone(), + messages: prompts + .iter() + .map(|p| Message { + role: Role::User, + content: p.clone(), + }) + .collect(), + temperature: options.temperature.unwrap_or(0.0), + max_tokens: options.max_new_tokens, + top_p: options.top_p, + }; + let response = self + .client + .post(&url) + .bearer_auth(self.api_key.clone()) + .header("Content-Type", "application/json") + .json(&body) + .send() + .await; + if let Ok(response) = response { + let response: ChatResponse = response.json().await?; + let mut completions = Vec::with_capacity(response.choices.len()); + for (i, c) in response.choices.iter().enumerate() { + let content = c.message.content.clone().unwrap_or_default(); + if options.remove_prompt { + completions.push(content); + } else { + completions.push(format!("{}{}", prompts[i], content)); + } + } + Ok(completions) + } else { + Err(Box::new(ChatError { + message: format!("Failed to generate: {:?}", response), + })) + } + } +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct ChatRequestBody { + pub model: String, + pub messages: Vec, + pub temperature: f64, + #[serde(skip_serializing_if = "Option::is_none")] + pub max_tokens: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub top_p: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Message { + pub role: Role, + pub content: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ResponseMessage { + pub role: Role, + pub content: Option, +} + +#[derive(Debug, Clone)] +pub enum Role { + System, + User, + Assistant, +} + +impl fmt::Display for Role { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Role::System => write!(f, "system"), + Role::User => write!(f, "user"), + Role::Assistant => write!(f, "assistant"), + } + } +} + +impl Serialize for Role { + fn serialize(&self, serializer: S) -> Result + where + S: serde::ser::Serializer, + { + match self { + Role::System => serializer.serialize_str("system"), + Role::User => serializer.serialize_str("user"), + Role::Assistant => serializer.serialize_str("assistant"), + } + } +} + +impl<'de> Deserialize<'de> for Role { + fn deserialize(deserializer: D) -> Result + where + D: serde::de::Deserializer<'de>, + { + let s = String::deserialize(deserializer)?; + match s.as_str() { + "system" => Ok(Role::System), + "user" => Ok(Role::User), + "assistant" => Ok(Role::Assistant), + _ => Err(serde::de::Error::custom(format!("unknown role: {}", s))), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ChatResponse { + pub choices: Vec, + pub usage: Usage, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Choice { + pub message: ResponseMessage, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Usage { + pub prompt_tokens: u32, + pub completion_tokens: u32, + pub total_tokens: u32, +} + +#[derive(Debug)] +pub struct ChatError { + pub message: String, +} + +impl std::error::Error for ChatError { + fn description(&self) -> &str { + &self.message + } +} + +impl fmt::Display for ChatError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "ChatError: {}", self.message) + } +} + +impl From for ChatError { + fn from(error: serde_json::Error) -> Self { + ChatError { + message: format!("{}", error), + } + } +} + /// A wrapper around a dataset from the HuggingFace Datasets library. #[derive(Debug, Clone)] pub struct HuggingFaceDataset { diff --git a/src/main.rs b/src/main.rs index ac24738..d1c16a8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,7 +1,37 @@ -pub fn main() { - let ds = rusty_llms::HuggingFaceDataset::load_dataset("nuprl/CanItEdit", "test").unwrap(); - for ex in ds.into_iter() { - let before: String = ex.get("before").unwrap(); - println!("{}", before); +use std::vec; + +use tokio; + +// pub fn main() { +// let ds = rusty_llms::HuggingFaceDataset::load_dataset("nuprl/CanItEdit", "test").unwrap(); +// for ex in ds.into_iter() { +// let before: String = ex.get("before").unwrap(); +// println!("{}", before); +// } +// } + +#[tokio::main] +pub async fn main() { + let api_key = std::env::var("OPENAI_API_KEY").unwrap(); + let llm_options = rusty_llms::LLMOptionsBuilder::new() + .api_key(api_key) + .build(); + let model = rusty_llms::llm_factory("gpt-3.5-turbo-0125", llm_options).unwrap(); + let inputs = vec!["The quick brown fox jumps over the lazy dog.".to_string()]; + let output = model + .generate( + inputs, + rusty_llms::GenerationOptions { + max_new_tokens: Some(10), + temperature: Some(0.0), + top_p: Some(0.95), + remove_prompt: true, + }, + ) + .await; + if let Ok(output) = output { + println!("{:?}", output); + } else { + println!("{:?}", output); } }