Add vision/image support for local inference models (#8442)
Signed-off-by: jh-block <jhugo@block.xyz>
This commit is contained in:
@@ -1,3 +1,5 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::routes::errors::ErrorResponse;
|
||||
use crate::state::AppState;
|
||||
use axum::{
|
||||
@@ -13,9 +15,9 @@ use goose::providers::local_inference::{
|
||||
available_inference_memory_bytes,
|
||||
hf_models::{resolve_model_spec, HfGgufFile},
|
||||
local_model_registry::{
|
||||
default_settings_for_model, get_registry, is_featured_model, model_id_from_repo,
|
||||
LocalModelEntry, ModelDownloadStatus as RegistryDownloadStatus, ModelSettings,
|
||||
FEATURED_MODELS,
|
||||
default_settings_for_model, featured_mmproj_spec, get_registry, is_featured_model,
|
||||
model_id_from_repo, LocalModelEntry, ModelDownloadStatus as RegistryDownloadStatus,
|
||||
ModelSettings, FEATURED_MODELS,
|
||||
},
|
||||
recommend_local_model,
|
||||
};
|
||||
@@ -47,10 +49,14 @@ pub struct LocalModelResponse {
|
||||
pub status: ModelDownloadStatus,
|
||||
pub recommended: bool,
|
||||
pub settings: ModelSettings,
|
||||
pub vision_capable: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub mmproj_status: Option<ModelDownloadStatus>,
|
||||
}
|
||||
|
||||
async fn ensure_featured_models_in_registry() -> Result<(), ErrorResponse> {
|
||||
let mut entries_to_add = Vec::new();
|
||||
let mut mmproj_downloads_needed: Vec<(String, String, PathBuf)> = Vec::new();
|
||||
|
||||
for featured in FEATURED_MODELS {
|
||||
let (repo_id, quantization) = match hf_models::parse_model_spec(featured.spec) {
|
||||
@@ -64,8 +70,27 @@ async fn ensure_featured_models_in_registry() -> Result<(), ErrorResponse> {
|
||||
let registry = get_registry()
|
||||
.lock()
|
||||
.map_err(|_| ErrorResponse::internal("Failed to acquire registry lock"))?;
|
||||
if registry.has_model(&model_id) {
|
||||
continue;
|
||||
if let Some(existing) = registry.get_model(&model_id) {
|
||||
let needs_backfill = existing.mmproj_path.is_none() && featured.mmproj.is_some();
|
||||
let needs_download = existing.is_downloaded()
|
||||
&& featured.mmproj.is_some()
|
||||
&& !existing.mmproj_path.as_ref().is_some_and(|p| p.exists());
|
||||
|
||||
if needs_download {
|
||||
if let Some(mmproj) = featured.mmproj.as_ref() {
|
||||
let path = mmproj.local_path();
|
||||
let url = format!(
|
||||
"https://huggingface.co/{}/resolve/main/{}",
|
||||
mmproj.repo, mmproj.filename
|
||||
);
|
||||
mmproj_downloads_needed.push((model_id.clone(), url, path));
|
||||
}
|
||||
}
|
||||
|
||||
if !needs_backfill {
|
||||
continue;
|
||||
}
|
||||
// Fall through to build the entry for sync_with_featured backfill
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,6 +116,8 @@ async fn ensure_featured_models_in_registry() -> Result<(), ErrorResponse> {
|
||||
|
||||
let local_path = Paths::in_data_dir("models").join(&hf_file.filename);
|
||||
|
||||
// enrich_with_featured_mmproj is called by sync_with_featured/add_model,
|
||||
// so we don't need to populate mmproj fields here.
|
||||
entries_to_add.push(LocalModelEntry {
|
||||
id: model_id.clone(),
|
||||
repo_id,
|
||||
@@ -100,14 +127,58 @@ async fn ensure_featured_models_in_registry() -> Result<(), ErrorResponse> {
|
||||
source_url: hf_file.download_url,
|
||||
settings: default_settings_for_model(&model_id),
|
||||
size_bytes: hf_file.size_bytes,
|
||||
mmproj_path: None,
|
||||
mmproj_source_url: None,
|
||||
mmproj_size_bytes: 0,
|
||||
});
|
||||
}
|
||||
|
||||
if !entries_to_add.is_empty() {
|
||||
{
|
||||
let mut registry = get_registry()
|
||||
.lock()
|
||||
.map_err(|_| ErrorResponse::internal("Failed to acquire registry lock"))?;
|
||||
registry.sync_with_featured(entries_to_add);
|
||||
|
||||
if !entries_to_add.is_empty() {
|
||||
registry.sync_with_featured(entries_to_add);
|
||||
}
|
||||
|
||||
// Backfill mmproj data for all registry models and collect any
|
||||
// needed mmproj downloads for models already on disk.
|
||||
for model in registry.list_models_mut() {
|
||||
model.enrich_with_featured_mmproj();
|
||||
if model.is_downloaded() {
|
||||
if let Some(mmproj) = featured_mmproj_spec(&model.id) {
|
||||
let path = mmproj.local_path();
|
||||
if !path.exists() {
|
||||
let url = format!(
|
||||
"https://huggingface.co/{}/resolve/main/{}",
|
||||
mmproj.repo, mmproj.filename
|
||||
);
|
||||
mmproj_downloads_needed.push((model.id.clone(), url, path));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let _ = registry.save();
|
||||
}
|
||||
|
||||
// Auto-download mmproj files for models that are already downloaded.
|
||||
// Deduplicate by path since multiple quants share one mmproj file.
|
||||
let dm = get_download_manager();
|
||||
let mut started_paths = std::collections::HashSet::new();
|
||||
for (model_id, url, path) in mmproj_downloads_needed {
|
||||
if !path.exists() && started_paths.insert(path.clone()) {
|
||||
let download_id = format!("{}-mmproj", model_id);
|
||||
let dominated_by_active = dm
|
||||
.get_progress(&download_id)
|
||||
.is_some_and(|p| p.status == goose::download_manager::DownloadStatus::Downloading);
|
||||
if !dominated_by_active {
|
||||
tracing::info!(model_id = %model_id, "Auto-downloading vision encoder for existing model");
|
||||
if let Err(e) = dm.download_model(download_id, url, path, None).await {
|
||||
tracing::warn!(model_id = %model_id, error = %e, "Failed to start mmproj download");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -154,6 +225,28 @@ pub async fn list_local_models(
|
||||
|
||||
let size_bytes = entry.file_size();
|
||||
|
||||
let vision_capable = entry.settings.vision_capable;
|
||||
let mmproj_status = if vision_capable {
|
||||
let ms = entry.mmproj_download_status();
|
||||
Some(match ms {
|
||||
RegistryDownloadStatus::NotDownloaded => ModelDownloadStatus::NotDownloaded,
|
||||
RegistryDownloadStatus::Downloading {
|
||||
progress_percent,
|
||||
bytes_downloaded,
|
||||
total_bytes,
|
||||
speed_bps,
|
||||
} => ModelDownloadStatus::Downloading {
|
||||
progress_percent,
|
||||
bytes_downloaded,
|
||||
total_bytes,
|
||||
speed_bps: Some(speed_bps),
|
||||
},
|
||||
RegistryDownloadStatus::Downloaded => ModelDownloadStatus::Downloaded,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
models.push(LocalModelResponse {
|
||||
id: entry.id.clone(),
|
||||
repo_id: entry.repo_id.clone(),
|
||||
@@ -163,6 +256,8 @@ pub async fn list_local_models(
|
||||
status,
|
||||
recommended: recommended_id == entry.id,
|
||||
settings: entry.settings.clone(),
|
||||
vision_capable,
|
||||
mmproj_status,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -276,16 +371,26 @@ pub async fn download_hf_model(
|
||||
source_url: download_url.clone(),
|
||||
settings: default_settings_for_model(&model_id),
|
||||
size_bytes: hf_file.size_bytes,
|
||||
mmproj_path: None,
|
||||
mmproj_source_url: None,
|
||||
mmproj_size_bytes: 0,
|
||||
};
|
||||
|
||||
{
|
||||
// add_model enriches the entry with mmproj metadata from the featured table
|
||||
let mmproj_path = {
|
||||
let mut registry = get_registry()
|
||||
.lock()
|
||||
.map_err(|_| ErrorResponse::internal("Failed to acquire registry lock"))?;
|
||||
registry
|
||||
.add_model(entry)
|
||||
.map_err(|e| ErrorResponse::internal(format!("{}", e)))?;
|
||||
}
|
||||
registry.get_model(&model_id).and_then(|e| {
|
||||
e.mmproj_path
|
||||
.as_ref()
|
||||
.zip(e.mmproj_source_url.as_ref())
|
||||
.map(|(p, u)| (p.clone(), u.clone()))
|
||||
})
|
||||
};
|
||||
|
||||
let dm = get_download_manager();
|
||||
dm.download_model(
|
||||
@@ -297,6 +402,19 @@ pub async fn download_hf_model(
|
||||
.await
|
||||
.map_err(|e| ErrorResponse::internal(format!("Download failed: {}", e)))?;
|
||||
|
||||
if let Some((mmproj_path, mmproj_url)) = mmproj_path {
|
||||
if !mmproj_path.exists() {
|
||||
dm.download_model(
|
||||
format!("{}-mmproj", model_id),
|
||||
mmproj_url,
|
||||
mmproj_path,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| ErrorResponse::internal(format!("mmproj download failed: {}", e)))?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok((StatusCode::ACCEPTED, Json(model_id)))
|
||||
}
|
||||
|
||||
@@ -338,6 +456,7 @@ pub async fn cancel_local_model_download(
|
||||
manager
|
||||
.cancel_download(&format!("{}-model", model_id))
|
||||
.map_err(|e| ErrorResponse::internal(format!("{}", e)))?;
|
||||
let _ = manager.cancel_download(&format!("{}-mmproj", model_id));
|
||||
|
||||
Ok(StatusCode::OK)
|
||||
}
|
||||
@@ -351,14 +470,22 @@ pub async fn cancel_local_model_download(
|
||||
)
|
||||
)]
|
||||
pub async fn delete_local_model(Path(model_id): Path<String>) -> Result<StatusCode, ErrorResponse> {
|
||||
let local_path = {
|
||||
let (local_path, mmproj_path, other_uses_mmproj) = {
|
||||
let registry = get_registry()
|
||||
.lock()
|
||||
.map_err(|_| ErrorResponse::internal("Failed to acquire registry lock"))?;
|
||||
let entry = registry
|
||||
.get_model(&model_id)
|
||||
.ok_or_else(|| ErrorResponse::not_found("Model not found"))?;
|
||||
entry.local_path.clone()
|
||||
let lp = entry.local_path.clone();
|
||||
let mp = entry.mmproj_path.clone();
|
||||
// Check if another downloaded model shares this mmproj file
|
||||
let shared = mp.as_ref().is_some_and(|target| {
|
||||
registry.list_models().iter().any(|m| {
|
||||
m.id != model_id && m.is_downloaded() && m.mmproj_path.as_ref() == Some(target)
|
||||
})
|
||||
});
|
||||
(lp, mp, shared)
|
||||
};
|
||||
|
||||
if local_path.exists() {
|
||||
@@ -367,6 +494,14 @@ pub async fn delete_local_model(Path(model_id): Path<String>) -> Result<StatusCo
|
||||
.map_err(|e| ErrorResponse::internal(format!("Failed to delete: {}", e)))?;
|
||||
}
|
||||
|
||||
if !other_uses_mmproj {
|
||||
if let Some(mmproj) = mmproj_path {
|
||||
if mmproj.exists() {
|
||||
let _ = tokio::fs::remove_file(&mmproj).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Only remove non-featured models from registry (featured ones stay as placeholders)
|
||||
if !is_featured_model(&model_id) {
|
||||
let mut registry = get_registry()
|
||||
|
||||
Reference in New Issue
Block a user