Put the description back

This commit is contained in:
Douwe Osinga
2026-02-19 01:19:29 +01:00
parent a28345104a
commit 488c9e2927
9 changed files with 145 additions and 207 deletions
+3 -2
View File
@@ -602,12 +602,13 @@ derive_utoipa!(Icon as IconSchema);
super::routes::dictation::DictationProviderStatus,
super::routes::dictation::WhisperModelResponse,
super::routes::local_inference::LocalModelResponse,
super::routes::local_inference::RepoVariantsResponse,
super::routes::local_inference::ModelDownloadStatus,
super::routes::local_inference::DownloadProgressResponse,
super::routes::local_inference::HfSearchResult,
super::routes::local_inference::HfQuantVariant,
super::routes::local_inference::DownloadModelRequest,
goose::providers::local_inference::hf_models::HfModelInfo,
goose::providers::local_inference::hf_models::HfQuantVariant,
goose::providers::local_inference::hf_models::HfQuantVariant,
goose::providers::local_inference::hf_models::HfGgufFile,
goose::providers::local_inference::local_model_registry::ModelSettings,
goose::providers::local_inference::local_model_registry::SamplingConfig,
@@ -1,14 +1,15 @@
use crate::routes::errors::ErrorResponse;
use crate::state::AppState;
use axum::{
extract::Path,
extract::{Path, Query},
http::StatusCode,
routing::{delete, get, post},
Json, Router,
};
use goose::config::paths::Paths;
use goose::providers::local_inference::{
hf_models::{get_repo_gguf_files, resolve_model_spec, search_gguf_models, HfGgufFile},
available_inference_memory_bytes, hf_models,
hf_models::{resolve_model_spec, HfGgufFile, HfModelInfo, HfQuantVariant},
local_model_registry::{
display_name_from_repo, get_registry, is_featured_model, model_id_from_repo,
parse_model_spec, LocalModelEntry, ModelDownloadStatus as RegistryDownloadStatus,
@@ -197,93 +198,69 @@ pub async fn list_local_models() -> Result<Json<Vec<LocalModelResponse>>, ErrorR
Ok(Json(models))
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct HfSearchResult {
pub id: String,
pub author: String,
pub name: String,
pub downloads: u64,
pub likes: u64,
#[derive(Debug, Deserialize)]
pub struct SearchQuery {
pub q: String,
pub limit: Option<usize>,
}
#[derive(Debug, Serialize, ToSchema)]
pub struct RepoVariantsResponse {
pub variants: Vec<HfQuantVariant>,
pub recommended_index: Option<usize>,
}
#[utoipa::path(
get,
path = "/local-inference/search",
params(
("q" = String, Query, description = "Search query")
("q" = String, Query, description = "Search query"),
("limit" = Option<usize>, Query, description = "Max results")
),
responses(
(status = 200, description = "Search results", body = Vec<HfSearchResult>)
(status = 200, description = "Search results", body = Vec<HfModelInfo>),
(status = 500, description = "Search failed")
)
)]
pub async fn search_hf_models(
axum::extract::Query(params): axum::extract::Query<std::collections::HashMap<String, String>>,
) -> Result<Json<Vec<HfSearchResult>>, ErrorResponse> {
let query = params.get("q").cloned().unwrap_or_default();
let results = search_gguf_models(&query, 20)
Query(params): Query<SearchQuery>,
) -> Result<Json<Vec<HfModelInfo>>, ErrorResponse> {
let limit = params.limit.unwrap_or(20).min(50);
let results = hf_models::search_gguf_models(&params.q, limit)
.await
.map_err(|e| ErrorResponse::internal(format!("HF search failed: {}", e)))?;
let search_results: Vec<HfSearchResult> = results
.into_iter()
.map(|m| HfSearchResult {
id: m.repo_id.clone(),
author: m.author,
name: m.model_name,
downloads: m.downloads,
likes: 0, // HfModelInfo doesn't have likes
})
.collect();
Ok(Json(search_results))
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct HfQuantVariant {
pub filename: String,
pub size_bytes: u64,
pub quantization: String,
pub download_url: String,
pub quality_rank: u32,
.map_err(|e| ErrorResponse::internal(format!("Search failed: {}", e)))?;
Ok(Json(results))
}
#[utoipa::path(
get,
path = "/local-inference/repo/{author}/{repo}/files",
responses(
(status = 200, description = "GGUF files in the repo", body = Vec<HfQuantVariant>)
(status = 200, description = "GGUF files in the repo", body = RepoVariantsResponse)
)
)]
pub async fn get_repo_files(
Path((author, repo)): Path<(String, String)>,
) -> Result<Json<Vec<HfQuantVariant>>, ErrorResponse> {
) -> Result<Json<RepoVariantsResponse>, ErrorResponse> {
let repo_id = format!("{}/{}", author, repo);
let files = get_repo_gguf_files(&repo_id)
let variants = hf_models::get_repo_gguf_variants(&repo_id)
.await
.map_err(|e| ErrorResponse::internal(format!("Failed to fetch files: {}", e)))?;
.map_err(|e| ErrorResponse::internal(format!("Failed to fetch repo files: {}", e)))?;
let variants: Vec<HfQuantVariant> = files
.into_iter()
.map(|f| HfQuantVariant {
filename: f.filename,
size_bytes: f.size_bytes,
quantization: f.quantization,
download_url: f.download_url,
quality_rank: 0,
})
.collect();
let runtime = InferenceRuntime::get_or_init();
let available_memory = available_inference_memory_bytes(&runtime);
let recommended_index = hf_models::recommend_variant(&variants, available_memory);
Ok(Json(variants))
Ok(Json(RepoVariantsResponse {
variants,
recommended_index,
}))
}
#[derive(Debug, Deserialize, ToSchema)]
pub struct DownloadModelRequest {
/// Model spec like "bartowski/Llama-3.2-3B-Instruct-GGUF:Q4_K_M"
pub spec: Option<String>,
/// Alternative: provide repo_id and filename separately
pub repo_id: Option<String>,
pub filename: Option<String>,
pub spec: String,
}
#[utoipa::path(
@@ -298,34 +275,13 @@ pub struct DownloadModelRequest {
pub async fn download_hf_model(
Json(req): Json<DownloadModelRequest>,
) -> Result<(StatusCode, Json<String>), ErrorResponse> {
let (repo_id, quantization, hf_file) = if let Some(spec) = &req.spec {
// Parse spec like "bartowski/Llama-3.2-3B-Instruct-GGUF:Q4_K_M"
let (_repo, file) = resolve_model_spec(spec)
.await
.map_err(|e| ErrorResponse::bad_request(format!("Invalid spec: {}", e)))?;
// Parse spec like "bartowski/Llama-3.2-3B-Instruct-GGUF:Q4_K_M"
let (repo_id, quantization) = parse_model_spec(&req.spec)
.ok_or_else(|| ErrorResponse::bad_request("Invalid spec format"))?;
let (repo, quant) = parse_model_spec(spec)
.ok_or_else(|| ErrorResponse::bad_request("Invalid spec format"))?;
(repo.to_string(), quant.to_string(), file)
} else if let (Some(repo_id), Some(filename)) = (&req.repo_id, &req.filename) {
// Get file info from repo
let files = get_repo_gguf_files(repo_id)
.await
.map_err(|e| ErrorResponse::internal(format!("Failed to fetch files: {}", e)))?;
let file = files
.into_iter()
.find(|f| f.filename == *filename)
.ok_or_else(|| ErrorResponse::not_found("File not found in repo"))?;
let quantization = file.quantization.clone();
(repo_id.clone(), quantization, file)
} else {
return Err(ErrorResponse::bad_request(
"Must provide either 'spec' or both 'repo_id' and 'filename'",
));
};
let (_repo, hf_file) = resolve_model_spec(&req.spec)
.await
.map_err(|e| ErrorResponse::bad_request(format!("Invalid spec: {}", e)))?;
let model_id = model_id_from_repo(&repo_id, &quantization);
let local_path = Paths::in_data_dir("models").join(&hf_file.filename);
+46 -49
View File
@@ -2073,10 +2073,7 @@
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/HfQuantVariant"
}
"$ref": "#/components/schemas/RepoVariantsResponse"
}
}
}
@@ -2099,6 +2096,17 @@
"schema": {
"type": "string"
}
},
{
"name": "limit",
"in": "query",
"description": "Max results",
"required": false,
"schema": {
"type": "integer",
"nullable": true,
"minimum": 0
}
}
],
"responses": {
@@ -2109,11 +2117,14 @@
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/HfSearchResult"
"$ref": "#/components/schemas/HfModelInfo"
}
}
}
}
},
"500": {
"description": "Search failed"
}
}
}
@@ -4247,20 +4258,13 @@
},
"DownloadModelRequest": {
"type": "object",
"required": [
"spec"
],
"properties": {
"filename": {
"type": "string",
"nullable": true
},
"repo_id": {
"type": "string",
"description": "Alternative: provide repo_id and filename separately",
"nullable": true
},
"spec": {
"type": "string",
"description": "Model spec like \"bartowski/Llama-3.2-3B-Instruct-GGUF:Q4_K_M\"",
"nullable": true
"description": "Model spec like \"bartowski/Llama-3.2-3B-Instruct-GGUF:Q4_K_M\""
}
}
},
@@ -5002,14 +5006,19 @@
},
"HfQuantVariant": {
"type": "object",
"description": "A quantization variant — groups sharded files into one logical entry.",
"required": [
"filename",
"size_bytes",
"quantization",
"size_bytes",
"filename",
"download_url",
"description",
"quality_rank"
],
"properties": {
"description": {
"type": "string"
},
"download_url": {
"type": "string"
},
@@ -5031,37 +5040,6 @@
}
}
},
"HfSearchResult": {
"type": "object",
"required": [
"id",
"author",
"name",
"downloads",
"likes"
],
"properties": {
"author": {
"type": "string"
},
"downloads": {
"type": "integer",
"format": "int64",
"minimum": 0
},
"id": {
"type": "string"
},
"likes": {
"type": "integer",
"format": "int64",
"minimum": 0
},
"name": {
"type": "string"
}
}
},
"Icon": {
"type": "object",
"required": [
@@ -6667,6 +6645,25 @@
}
}
},
"RepoVariantsResponse": {
"type": "object",
"required": [
"variants"
],
"properties": {
"recommended_index": {
"type": "integer",
"nullable": true,
"minimum": 0
},
"variants": {
"type": "array",
"items": {
"$ref": "#/components/schemas/HfQuantVariant"
}
}
}
},
"ResourceContents": {
"anyOf": [
{
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+23 -16
View File
@@ -233,15 +233,10 @@ export type DictationProviderStatus = {
};
export type DownloadModelRequest = {
filename?: string | null;
/**
* Alternative: provide repo_id and filename separately
*/
repo_id?: string | null;
/**
* Model spec like "bartowski/Llama-3.2-3B-Instruct-GGUF:Q4_K_M"
*/
spec?: string | null;
spec: string;
};
export type DownloadProgress = {
@@ -486,7 +481,11 @@ export type HfModelInfo = {
repo_id: string;
};
/**
* A quantization variant — groups sharded files into one logical entry.
*/
export type HfQuantVariant = {
description: string;
download_url: string;
filename: string;
quality_rank: number;
@@ -494,14 +493,6 @@ export type HfQuantVariant = {
size_bytes: number;
};
export type HfSearchResult = {
author: string;
downloads: number;
id: string;
likes: number;
name: string;
};
export type Icon = {
mimeType?: string;
sizes?: Array<string>;
@@ -1007,6 +998,11 @@ export type RemoveExtensionRequest = {
session_id: string;
};
export type RepoVariantsResponse = {
recommended_index?: number | null;
variants: Array<HfQuantVariant>;
};
export type ResourceContents = {
_meta?: {
[key: string]: unknown;
@@ -3128,7 +3124,7 @@ export type GetRepoFilesResponses = {
/**
* GGUF files in the repo
*/
200: Array<HfQuantVariant>;
200: RepoVariantsResponse;
};
export type GetRepoFilesResponse = GetRepoFilesResponses[keyof GetRepoFilesResponses];
@@ -3141,15 +3137,26 @@ export type SearchHfModelsData = {
* Search query
*/
q: string;
/**
* Max results
*/
limit?: number | null;
};
url: '/local-inference/search';
};
export type SearchHfModelsErrors = {
/**
* Search failed
*/
500: unknown;
};
export type SearchHfModelsResponses = {
/**
* Search results
*/
200: Array<HfSearchResult>;
200: Array<HfModelInfo>;
};
export type SearchHfModelsResponse = SearchHfModelsResponses[keyof SearchHfModelsResponses];
@@ -106,10 +106,7 @@ export function LocalModelSetup({ onSuccess, onCancel }: LocalModelSetupProps) {
try {
await downloadHfModel({
body: {
repo_id: model.repo_id,
filename: model.filename,
},
body: { spec: model.id },
});
} catch (error) {
console.error('Failed to start download:', error);
@@ -5,8 +5,9 @@ import {
searchHfModels,
getRepoFiles,
downloadHfModel,
type HfSearchResult,
type HfModelInfo,
type HfQuantVariant,
type RepoVariantsResponse,
} from '../../../api';
const formatBytes = (bytes: number): string => {
@@ -34,7 +35,7 @@ interface Props {
export const HuggingFaceModelSearch = ({ onDownloadStarted }: Props) => {
const [query, setQuery] = useState('');
const [results, setResults] = useState<HfSearchResult[]>([]);
const [results, setResults] = useState<HfModelInfo[]>([]);
const [expandedRepo, setExpandedRepo] = useState<string | null>(null);
const [repoData, setRepoData] = useState<Record<string, RepoData>>({});
const [searching, setSearching] = useState(false);
@@ -45,8 +46,8 @@ export const HuggingFaceModelSearch = ({ onDownloadStarted }: Props) => {
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Build repo_id from search result
const getRepoId = (result: HfSearchResult): string => {
return `${result.author}/${result.name}`;
const getRepoId = (result: HfModelInfo): string => {
return result.repo_id;
};
const doSearch = useCallback(async (q: string) => {
@@ -69,8 +70,8 @@ export const HuggingFaceModelSearch = ({ onDownloadStarted }: Props) => {
const repoId = getRepoId(model);
const [author, repo] = repoId.split('/');
const filesResponse = await getRepoFiles({ path: { author, repo } });
if (filesResponse.data && filesResponse.data.length > 0) {
return { model, variants: filesResponse.data };
if (filesResponse.data && filesResponse.data.variants.length > 0) {
return { model, variantsResponse: filesResponse.data };
}
} catch {
// Skip repos we can't fetch
@@ -80,8 +81,8 @@ export const HuggingFaceModelSearch = ({ onDownloadStarted }: Props) => {
);
const validResults = modelsWithVariants.filter(Boolean) as {
model: HfSearchResult;
variants: HfQuantVariant[];
model: HfModelInfo;
variantsResponse: RepoVariantsResponse;
}[];
setResults(validResults.map((r) => r.model));
@@ -89,21 +90,9 @@ export const HuggingFaceModelSearch = ({ onDownloadStarted }: Props) => {
const next = { ...prev };
for (const r of validResults) {
const repoId = getRepoId(r.model);
// Find the best variant (highest quality_rank that's not too big)
let recommendedIdx: number | null = null;
const sorted = [...r.variants].sort((a, b) => b.quality_rank - a.quality_rank);
// Recommend the highest quality variant under 8GB, or the smallest if all are big
const under8GB = sorted.filter((v) => v.size_bytes < 8 * 1024 * 1024 * 1024);
if (under8GB.length > 0) {
recommendedIdx = r.variants.findIndex((v) => v.filename === under8GB[0].filename);
} else {
// Pick smallest
const smallest = [...r.variants].sort((a, b) => a.size_bytes - b.size_bytes)[0];
recommendedIdx = r.variants.findIndex((v) => v.filename === smallest.filename);
}
next[repoId] = {
variants: r.variants,
recommendedIndex: recommendedIdx,
variants: r.variantsResponse.variants,
recommendedIndex: r.variantsResponse.recommended_index ?? null,
};
}
return next;
@@ -150,20 +139,12 @@ export const HuggingFaceModelSearch = ({ onDownloadStarted }: Props) => {
const [author, repo] = repoId.split('/');
const response = await getRepoFiles({ path: { author, repo } });
if (response.data) {
const variants = response.data;
// Find recommended
let recommendedIdx: number | null = null;
const sorted = [...variants].sort((a, b) => b.quality_rank - a.quality_rank);
const under8GB = sorted.filter((v) => v.size_bytes < 8 * 1024 * 1024 * 1024);
if (under8GB.length > 0) {
recommendedIdx = variants.findIndex((v) => v.filename === under8GB[0].filename);
} else {
const smallest = [...variants].sort((a, b) => a.size_bytes - b.size_bytes)[0];
recommendedIdx = variants.findIndex((v) => v.filename === smallest.filename);
}
setRepoData((prev) => ({
...prev,
[repoId]: { variants, recommendedIndex: recommendedIdx },
[repoId]: {
variants: response.data.variants,
recommendedIndex: response.data.recommended_index ?? null,
},
}));
}
} catch (e) {
@@ -178,12 +159,13 @@ export const HuggingFaceModelSearch = ({ onDownloadStarted }: Props) => {
}
};
const startDownload = async (repoId: string, filename: string) => {
const dlKey = `${repoId}/${filename}`;
const startDownload = async (repoId: string, quantization: string) => {
const spec = `${repoId}:${quantization}`;
const dlKey = spec;
setDownloading((prev) => new Set(prev).add(dlKey));
try {
const response = await downloadHfModel({
body: { repo_id: repoId, filename },
body: { spec },
});
if (response.data) {
onDownloadStarted(response.data);
@@ -256,10 +238,7 @@ export const HuggingFaceModelSearch = ({ onDownloadStarted }: Props) => {
const isLoading = loadingFiles.has(repoId);
return (
<div
key={model.id}
className="border border-border-subtle rounded-lg overflow-hidden"
>
<div key={repoId} className="border border-border-subtle rounded-lg overflow-hidden">
<button
className="w-full flex items-center justify-between p-3 text-left hover:bg-background-subtle transition-colors"
onClick={() => expandRepo(repoId)}
@@ -268,7 +247,6 @@ export const HuggingFaceModelSearch = ({ onDownloadStarted }: Props) => {
<span className="text-sm font-medium text-text-default truncate">{repoId}</span>
<div className="flex items-center gap-3 text-xs text-text-muted">
<span>{formatDownloads(model.downloads)} downloads</span>
{model.likes > 0 && <span>❤️ {formatDownloads(model.likes)}</span>}
</div>
</div>
{isExpanded ? (
@@ -287,7 +265,7 @@ export const HuggingFaceModelSearch = ({ onDownloadStarted }: Props) => {
</div>
)}
{variants.map((variant, idx) => {
const dlKey = `${repoId}/${variant.filename}`;
const dlKey = `${repoId}:${variant.quantization}`;
const isStarting = downloading.has(dlKey);
const isRecommended = idx === recommendedIndex;
@@ -308,6 +286,11 @@ export const HuggingFaceModelSearch = ({ onDownloadStarted }: Props) => {
<span className="text-xs text-text-muted">
{formatBytes(variant.size_bytes)}
</span>
{variant.description && (
<span className="text-xs text-text-muted italic">
{variant.description}
</span>
)}
{isRecommended && (
<span className="inline-flex items-center gap-1 text-xs bg-blue-500 text-white px-1.5 py-0.5 rounded">
<Star className="w-3 h-3" />
@@ -320,7 +303,7 @@ export const HuggingFaceModelSearch = ({ onDownloadStarted }: Props) => {
variant="outline"
size="sm"
disabled={isStarting}
onClick={() => startDownload(repoId, variant.filename)}
onClick={() => startDownload(repoId, variant.quantization)}
>
{isStarting ? (
<Loader2 className="w-3 h-3 animate-spin" />
@@ -109,10 +109,7 @@ export const LocalInferenceSettings = () => {
try {
await downloadHfModel({
body: {
repo_id: model.repo_id,
filename: model.filename,
},
body: { spec: model.id },
});
pollDownloadProgress(modelId);
scrollToDownloads();