diff --git a/Cargo.lock b/Cargo.lock index 17034d42e5..8f48687f3e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -336,6 +336,7 @@ dependencies = [ name = "am" version = "0.1.0" dependencies = [ + "api-asset-client", "dirs 6.0.0", "download-interface", "file", @@ -514,6 +515,7 @@ name = "api" version = "0.1.0" dependencies = [ "analytics", + "api-asset", "api-auth", "api-calendar", "api-env", @@ -544,6 +546,25 @@ dependencies = [ "vergen-gix", ] +[[package]] +name = "api-asset" +version = "0.1.0" +dependencies = [ + "axum 0.8.8", + "serde", + "serde_json", +] + +[[package]] +name = "api-asset-client" +version = "0.1.0" +dependencies = [ + "reqwest 0.13.2", + "serde", + "thiserror 2.0.18", + "tokio", +] + [[package]] name = "api-auth" version = "0.1.0" @@ -17917,6 +17938,7 @@ dependencies = [ name = "tauri-plugin-local-llm" version = "0.1.0" dependencies = [ + "api-asset-client", "async-openai", "async-stream", "axum 0.8.8", @@ -17953,6 +17975,7 @@ name = "tauri-plugin-local-stt" version = "0.1.0" dependencies = [ "am", + "api-asset-client", "audio-utils", "axum 0.8.8", "axum-extra", diff --git a/Cargo.toml b/Cargo.toml index af6ee272fb..9da967d557 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,6 +27,8 @@ hypr-agc = { path = "crates/agc", package = "agc" } hypr-am = { path = "crates/am", package = "am" } hypr-am2 = { path = "crates/am2", package = "am2" } hypr-analytics = { path = "crates/analytics", package = "analytics" } +hypr-api-asset = { path = "crates/api-asset", package = "api-asset" } +hypr-api-asset-client = { path = "crates/api-asset-client", package = "api-asset-client" } hypr-api-auth = { path = "crates/api-auth", package = "api-auth" } hypr-api-calendar = { path = "crates/api-calendar", package = "api-calendar" } hypr-api-env = { path = "crates/api-env", package = "api-env" } diff --git a/apps/api/Cargo.toml b/apps/api/Cargo.toml index 0919688aa5..14f9351fc6 100644 --- a/apps/api/Cargo.toml +++ b/apps/api/Cargo.toml @@ -5,6 +5,7 @@ edition = "2024" [dependencies] hypr-analytics = { workspace = true } +hypr-api-asset = { workspace = true } hypr-api-auth = { workspace = true } hypr-api-calendar = { workspace = true } hypr-api-env = { workspace = true } diff --git a/apps/api/src/main.rs b/apps/api/src/main.rs index 679f6f7b12..34b30e01aa 100644 --- a/apps/api/src/main.rs +++ b/apps/api/src/main.rs @@ -165,6 +165,8 @@ async fn app() -> Router { auth::optional_auth, )); + let asset_routes = Router::new().nest("/v1/assets", hypr_api_asset::router()); + Router::new() .route("/health", axum::routing::get(version)) .route("/openapi.json", axum::routing::get(openapi_json)) @@ -173,6 +175,7 @@ async fn app() -> Router { .merge(pro_routes) .merge(integration_routes) .merge(auth_routes) + .merge(asset_routes) .layer( CorsLayer::new() .allow_origin(cors::Any) diff --git a/crates/am/Cargo.toml b/crates/am/Cargo.toml index cac334543e..0ef203f78e 100644 --- a/crates/am/Cargo.toml +++ b/crates/am/Cargo.toml @@ -8,6 +8,7 @@ hf-hub = "0.4.3" tokio = { workspace = true, features = ["rt", "macros"] } [dependencies] +hypr-api-asset-client = { workspace = true } hypr-download-interface = { workspace = true } hypr-file = { workspace = true } diff --git a/crates/am/src/model.rs b/crates/am/src/model.rs index fb93926af9..2af3f5999f 100644 --- a/crates/am/src/model.rs +++ b/crates/am/src/model.rs @@ -120,7 +120,11 @@ impl AmModel { output_path: impl AsRef, progress_callback: F, ) -> Result<(), crate::Error> { - hypr_file::download_file_parallel(self.tar_url(), output_path, progress_callback).await?; + let url = hypr_api_asset_client::resolve_model(&self.to_string()) + .await + .map(|a| a.url) + .unwrap_or_else(|_| self.tar_url().to_owned()); + hypr_file::download_file_parallel(url, output_path, progress_callback).await?; Ok(()) } } diff --git a/crates/api-asset-client/Cargo.toml b/crates/api-asset-client/Cargo.toml new file mode 100644 index 0000000000..55c20f11b2 --- /dev/null +++ b/crates/api-asset-client/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "api-asset-client" +version = "0.1.0" +edition = "2024" + +[dependencies] +reqwest = { workspace = true, features = ["json"] } +serde = { workspace = true, features = ["derive"] } +thiserror = { workspace = true } +tokio = { workspace = true, features = ["sync"] } diff --git a/crates/api-asset-client/src/client.rs b/crates/api-asset-client/src/client.rs new file mode 100644 index 0000000000..fbbb2c5c9c --- /dev/null +++ b/crates/api-asset-client/src/client.rs @@ -0,0 +1,46 @@ +use crate::error::Error; + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct ModelAsset { + pub id: String, + pub url: String, + pub checksum: u32, + pub size_bytes: u64, +} + +pub struct AssetClient { + base_url: String, + http: reqwest::Client, + cache: tokio::sync::OnceCell>, +} + +impl AssetClient { + pub fn new(base_url: impl Into) -> Self { + Self { + base_url: base_url.into(), + http: reqwest::Client::new(), + cache: tokio::sync::OnceCell::new(), + } + } + + async fn manifest(&self) -> Result<&[ModelAsset], Error> { + let assets = self + .cache + .get_or_try_init(|| async { + let url = format!("{}/v1/assets/models", self.base_url); + let assets: Vec = self.http.get(&url).send().await?.json().await?; + Ok::<_, Error>(assets) + }) + .await?; + Ok(assets) + } + + pub async fn resolve(&self, asset_id: &str) -> Result { + let manifest = self.manifest().await?; + manifest + .iter() + .find(|a| a.id == asset_id) + .cloned() + .ok_or_else(|| Error::NotFound(asset_id.to_owned())) + } +} diff --git a/crates/api-asset-client/src/error.rs b/crates/api-asset-client/src/error.rs new file mode 100644 index 0000000000..004b2c3494 --- /dev/null +++ b/crates/api-asset-client/src/error.rs @@ -0,0 +1,7 @@ +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error(transparent)] + Http(#[from] reqwest::Error), + #[error("asset not found: {0}")] + NotFound(String), +} diff --git a/crates/api-asset-client/src/lib.rs b/crates/api-asset-client/src/lib.rs new file mode 100644 index 0000000000..2b75ad0ec5 --- /dev/null +++ b/crates/api-asset-client/src/lib.rs @@ -0,0 +1,17 @@ +mod client; +mod error; + +pub use client::{AssetClient, ModelAsset}; +pub use error::Error; + +use std::sync::OnceLock; + +static DEFAULT: OnceLock = OnceLock::new(); + +fn default_client() -> &'static AssetClient { + DEFAULT.get_or_init(|| AssetClient::new("https://api.hyprnote.com")) +} + +pub async fn resolve_model(asset_id: &str) -> Result { + default_client().resolve(asset_id).await +} diff --git a/crates/api-asset/Cargo.toml b/crates/api-asset/Cargo.toml new file mode 100644 index 0000000000..e7baf63d9c --- /dev/null +++ b/crates/api-asset/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "api-asset" +version = "0.1.0" +edition = "2024" + +[dependencies] +axum = { workspace = true } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } diff --git a/crates/api-asset/src/lib.rs b/crates/api-asset/src/lib.rs new file mode 100644 index 0000000000..8ea4b83cde --- /dev/null +++ b/crates/api-asset/src/lib.rs @@ -0,0 +1,103 @@ +use axum::{Router, routing::get}; + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct ModelAsset { + pub id: String, + pub url: String, + pub checksum: u32, + pub size_bytes: u64, +} + +fn model_assets() -> Vec { + vec![ + // AmModel + ModelAsset { + id: "am-parakeet-v2".into(), + url: "https://hyprnote.s3.us-east-1.amazonaws.com/v0/nvidia_parakeet-v2_476MB.tar".into(), + checksum: 1906983049, + size_bytes: 476134400, + }, + ModelAsset { + id: "am-parakeet-v3".into(), + url: "https://hyprnote.s3.us-east-1.amazonaws.com/v0/nvidia_parakeet-v3_494MB.tar".into(), + checksum: 3016060540, + size_bytes: 494141440, + }, + ModelAsset { + id: "am-whisper-large-v3".into(), + url: "https://hyprnote.s3.us-east-1.amazonaws.com/v0/openai_whisper-large-v3-v20240930_626MB.tar".into(), + checksum: 1964673816, + size_bytes: 625990656, + }, + // WhisperModel + ModelAsset { + id: "QuantizedTiny".into(), + url: "https://hyprnote.s3.us-east-1.amazonaws.com/v0/ggerganov/whisper.cpp/main/ggml-tiny-q8_0.bin".into(), + checksum: 1235175537, + size_bytes: 43537433, + }, + ModelAsset { + id: "QuantizedTinyEn".into(), + url: "https://hyprnote.s3.us-east-1.amazonaws.com/v0/ggerganov/whisper.cpp/main/ggml-tiny.en-q8_0.bin".into(), + checksum: 230334082, + size_bytes: 43550795, + }, + ModelAsset { + id: "QuantizedBase".into(), + url: "https://hyprnote.s3.us-east-1.amazonaws.com/v0/ggerganov/whisper.cpp/main/ggml-base-q8_0.bin".into(), + checksum: 4019564439, + size_bytes: 81768585, + }, + ModelAsset { + id: "QuantizedBaseEn".into(), + url: "https://hyprnote.s3.us-east-1.amazonaws.com/v0/ggerganov/whisper.cpp/main/ggml-base.en-q8_0.bin".into(), + checksum: 2554759952, + size_bytes: 81781811, + }, + ModelAsset { + id: "QuantizedSmall".into(), + url: "https://hyprnote.s3.us-east-1.amazonaws.com/v0/ggerganov/whisper.cpp/main/ggml-small-q8_0.bin".into(), + checksum: 3764849512, + size_bytes: 264464607, + }, + ModelAsset { + id: "QuantizedSmallEn".into(), + url: "https://hyprnote.s3.us-east-1.amazonaws.com/v0/ggerganov/whisper.cpp/main/ggml-small.en-q8_0.bin".into(), + checksum: 3958576310, + size_bytes: 264477561, + }, + ModelAsset { + id: "QuantizedLargeTurbo".into(), + url: "https://hyprnote.s3.us-east-1.amazonaws.com/v0/ggerganov/whisper.cpp/main/ggml-large-v3-turbo-q8_0.bin".into(), + checksum: 3055274469, + size_bytes: 874188075, + }, + // SupportedModel (local-llm) + ModelAsset { + id: "Llama3p2_3bQ4".into(), + url: "https://hyprnote.s3.us-east-1.amazonaws.com/v0/lmstudio-community/Llama-3.2-3B-Instruct-GGUF/main/Llama-3.2-3B-Instruct-Q4_K_M.gguf".into(), + checksum: 2831308098, + size_bytes: 2019377440, + }, + ModelAsset { + id: "Gemma3_4bQ4".into(), + url: "https://hyprnote.s3.us-east-1.amazonaws.com/v0/unsloth/gemma-3-4b-it-GGUF/gemma-3-4b-it-Q4_K_M.gguf".into(), + checksum: 2760830291, + size_bytes: 2489894016, + }, + ModelAsset { + id: "HyprLLM".into(), + url: "https://hyprnote.s3.us-east-1.amazonaws.com/v0/yujonglee/hypr-llm-sm/model_q4_k_m.gguf".into(), + checksum: 4037351144, + size_bytes: 1107409056, + }, + ] +} + +async fn get_models() -> axum::Json> { + axum::Json(model_assets()) +} + +pub fn router() -> Router { + Router::new().route("/models", get(get_models)) +} diff --git a/plugins/local-llm/Cargo.toml b/plugins/local-llm/Cargo.toml index d0cd166d9d..75afbda86a 100644 --- a/plugins/local-llm/Cargo.toml +++ b/plugins/local-llm/Cargo.toml @@ -17,6 +17,7 @@ specta-typescript = { workspace = true } tauri-plugin-store = { workspace = true } [dependencies] +hypr-api-asset-client = { workspace = true } hypr-download-interface = { workspace = true } hypr-file = { workspace = true } hypr-gbnf = { workspace = true } diff --git a/plugins/local-llm/src/ext.rs b/plugins/local-llm/src/ext.rs index 16b0b2d2d5..b1118a39f0 100644 --- a/plugins/local-llm/src/ext.rs +++ b/plugins/local-llm/src/ext.rs @@ -135,7 +135,12 @@ impl> LocalLlmPluginExt for T { } }; - if let Err(e) = download_file_parallel(m.model_url(), path, callback).await { + let url = hypr_api_asset_client::resolve_model(&m.to_string()) + .await + .map(|a| a.url) + .unwrap_or_else(|_| m.model_url().to_owned()); + + if let Err(e) = download_file_parallel(url, path, callback).await { tracing::error!("model_download_error: {}", e); let _ = channel.send(-1); } diff --git a/plugins/local-llm/src/model.rs b/plugins/local-llm/src/model.rs index 7c7f390025..0ba08e25f9 100644 --- a/plugins/local-llm/src/model.rs +++ b/plugins/local-llm/src/model.rs @@ -49,7 +49,17 @@ impl ModelSelection { } } -#[derive(Debug, Eq, Hash, PartialEq, Clone, serde::Serialize, serde::Deserialize, specta::Type)] +#[derive( + Debug, + Eq, + Hash, + PartialEq, + Clone, + serde::Serialize, + serde::Deserialize, + specta::Type, + strum::Display, +)] pub enum SupportedModel { Llama3p2_3bQ4, Gemma3_4bQ4, diff --git a/plugins/local-stt/Cargo.toml b/plugins/local-stt/Cargo.toml index ef5d5f41d5..d8b9ed184a 100644 --- a/plugins/local-stt/Cargo.toml +++ b/plugins/local-stt/Cargo.toml @@ -38,6 +38,7 @@ tower = { workspace = true } [dependencies] hypr-am = { workspace = true } +hypr-api-asset-client = { workspace = true } hypr-audio-utils = { workspace = true } hypr-download-interface = { workspace = true } hypr-file = { workspace = true } diff --git a/plugins/local-stt/src/ext.rs b/plugins/local-stt/src/ext.rs index 88faf3c6e4..7431c2ec89 100644 --- a/plugins/local-stt/src/ext.rs +++ b/plugins/local-stt/src/ext.rs @@ -249,8 +249,13 @@ impl<'a, R: Runtime, M: Manager> LocalStt<'a, R, M> { let task = tokio::spawn(async move { let callback = create_progress_callback(model_for_task.clone()); + let url = hypr_api_asset_client::resolve_model(&m.to_string()) + .await + .map(|a| a.url) + .unwrap_or_else(|_| m.tar_url().to_owned()); + let result = download_file_parallel_cancellable( - m.tar_url(), + url, &tar_path, callback, Some(token_clone), @@ -309,8 +314,13 @@ impl<'a, R: Runtime, M: Manager> LocalStt<'a, R, M> { let task = tokio::spawn(async move { let callback = create_progress_callback(model_for_task.clone()); + let url = hypr_api_asset_client::resolve_model(&m.to_string()) + .await + .map(|a| a.url) + .unwrap_or_else(|_| m.model_url().to_owned()); + let result = download_file_parallel_cancellable( - m.model_url(), + url, &model_path, callback, Some(token_clone),