From f0801785840f27ebd4e8c92e11da209c8c1a2147 Mon Sep 17 00:00:00 2001 From: Yujong Lee Date: Wed, 18 Feb 2026 20:43:58 +0900 Subject: [PATCH 1/3] feat: add api-asset-client crate and use for model download URLs Introduces a new `api-asset-client` crate to resolve model asset URLs from the Hyprnote API, falling back to hardcoded URLs when the API is unavailable. Co-authored-by: Cursor --- Cargo.lock | 11 +++++++ Cargo.toml | 1 + crates/api-asset-client/Cargo.toml | 10 ++++++ crates/api-asset-client/src/client.rs | 46 +++++++++++++++++++++++++++ crates/api-asset-client/src/error.rs | 7 ++++ crates/api-asset-client/src/lib.rs | 17 ++++++++++ plugins/local-stt/Cargo.toml | 1 + plugins/local-stt/src/ext.rs | 14 ++++++-- 8 files changed, 105 insertions(+), 2 deletions(-) create mode 100644 crates/api-asset-client/Cargo.toml create mode 100644 crates/api-asset-client/src/client.rs create mode 100644 crates/api-asset-client/src/error.rs create mode 100644 crates/api-asset-client/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index f3bd5a63a9..60dc9f3ce0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -544,6 +544,16 @@ dependencies = [ "vergen-gix", ] +[[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" @@ -19006,6 +19016,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 7f30792e2d..f30de1f108 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,6 +27,7 @@ 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-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/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/plugins/local-stt/Cargo.toml b/plugins/local-stt/Cargo.toml index 7814ce41a9..604b655636 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 d499a0deb0..de7e64d991 100644 --- a/plugins/local-stt/src/ext.rs +++ b/plugins/local-stt/src/ext.rs @@ -261,8 +261,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), @@ -321,8 +326,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), From 848acf175dc89387ea724d5c3c2753637729cfc7 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 18 Feb 2026 12:01:17 +0000 Subject: [PATCH 2/3] feat: use api-asset-client for downloads in local-llm and am crates Co-Authored-By: yujonglee --- Cargo.lock | 2 ++ crates/am/Cargo.toml | 1 + crates/am/src/model.rs | 6 +++++- plugins/local-llm/Cargo.toml | 1 + plugins/local-llm/src/ext/plugin.rs | 7 ++++++- plugins/local-llm/src/model.rs | 12 +++++++++++- 6 files changed, 26 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 60dc9f3ce0..ea70a019f2 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", @@ -18977,6 +18978,7 @@ dependencies = [ name = "tauri-plugin-local-llm" version = "0.1.0" dependencies = [ + "api-asset-client", "async-openai", "async-stream", "axum 0.8.8", 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/plugins/local-llm/Cargo.toml b/plugins/local-llm/Cargo.toml index fa192c88d3..82e84be500 100644 --- a/plugins/local-llm/Cargo.toml +++ b/plugins/local-llm/Cargo.toml @@ -24,6 +24,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/plugin.rs b/plugins/local-llm/src/ext/plugin.rs index cba6ef4ffd..9a42848395 100644 --- a/plugins/local-llm/src/ext/plugin.rs +++ b/plugins/local-llm/src/ext/plugin.rs @@ -147,7 +147,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, From b8dcbf448f064f32741d246bc6ba1a306d8e5e0c Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 19 Feb 2026 04:41:58 +0000 Subject: [PATCH 3/3] feat: add api-asset crate and wire up /v1/assets/models endpoint in apps/api Co-Authored-By: yujonglee --- Cargo.lock | 10 ++++ Cargo.toml | 1 + apps/api/Cargo.toml | 1 + apps/api/src/main.rs | 3 ++ crates/api-asset/Cargo.toml | 9 ++++ crates/api-asset/src/lib.rs | 103 ++++++++++++++++++++++++++++++++++++ 6 files changed, 127 insertions(+) create mode 100644 crates/api-asset/Cargo.toml create mode 100644 crates/api-asset/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index ea70a019f2..f79bec31b8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -515,6 +515,7 @@ name = "api" version = "0.1.0" dependencies = [ "analytics", + "api-asset", "api-auth", "api-calendar", "api-env", @@ -545,6 +546,15 @@ 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" diff --git a/Cargo.toml b/Cargo.toml index f30de1f108..022934a21a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,6 +27,7 @@ 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" } 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/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)) +}