Files
vessel/frontend/src/lib/api/model-registry.ts
vikingowl 802db229a6
Some checks failed
Create Release / release (push) Has been cancelled
feat: add model filters and last updated display
- Add size filter (≤3B, 4-13B, 14-70B, >70B) based on model tags
- Add model family filter dropdown with dynamic family list
- Display last updated date on model cards (scraped from ollama.com)
- Add /api/v1/models/remote/families endpoint
- Convert relative time strings ("2 weeks ago") to timestamps during sync
2026-01-02 21:54:50 +01:00

323 lines
8.4 KiB
TypeScript

/**
* Model Registry API Client
* Interacts with the backend model registry for browsing/searching ollama.com models
*/
/** Remote model from ollama.com (cached in backend) */
export interface RemoteModel {
slug: string;
name: string;
description: string;
modelType: 'official' | 'community';
architecture?: string;
parameterSize?: string;
contextLength?: number;
embeddingLength?: number;
quantization?: string;
capabilities: string[];
defaultParams?: Record<string, unknown>;
license?: string;
pullCount: number;
tags: string[];
tagSizes?: Record<string, number>; // Maps tag name to file size in bytes
ollamaUpdatedAt?: string;
detailsFetchedAt?: string;
scrapedAt: string;
url: string;
}
/** Response from listing/searching models */
export interface ModelListResponse {
models: RemoteModel[];
total: number;
limit: number;
offset: number;
}
/** Response from sync operation */
export interface SyncResponse {
synced: number;
message: string;
}
/** Sync status */
export interface SyncStatus {
modelCount: number;
lastSync: string | null;
}
/** Sort options for model list */
export type ModelSortOption = 'name_asc' | 'name_desc' | 'pulls_asc' | 'pulls_desc' | 'updated_desc';
/** Size range filter options */
export type SizeRange = 'small' | 'medium' | 'large' | 'xlarge';
/** Context length range filter options */
export type ContextRange = 'standard' | 'extended' | 'large' | 'unlimited';
/** Search/filter options */
export interface ModelSearchOptions {
search?: string;
type?: 'official' | 'community';
capabilities?: string[];
sizeRanges?: SizeRange[];
contextRanges?: ContextRange[];
family?: string;
sort?: ModelSortOption;
limit?: number;
offset?: number;
}
// Backend API base URL (relative to frontend)
const API_BASE = '/api/v1/models';
/**
* Fetch remote models with optional search/filter
*/
export async function fetchRemoteModels(options: ModelSearchOptions = {}): Promise<ModelListResponse> {
const params = new URLSearchParams();
if (options.search) params.set('search', options.search);
if (options.type) params.set('type', options.type);
if (options.capabilities && options.capabilities.length > 0) {
params.set('capabilities', options.capabilities.join(','));
}
if (options.sizeRanges && options.sizeRanges.length > 0) {
params.set('sizeRange', options.sizeRanges.join(','));
}
if (options.contextRanges && options.contextRanges.length > 0) {
params.set('contextRange', options.contextRanges.join(','));
}
if (options.family) params.set('family', options.family);
if (options.sort) params.set('sort', options.sort);
if (options.limit) params.set('limit', String(options.limit));
if (options.offset) params.set('offset', String(options.offset));
const url = `${API_BASE}/remote${params.toString() ? '?' + params.toString() : ''}`;
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to fetch models: ${response.statusText}`);
}
return response.json();
}
/**
* Get unique model families for filter dropdowns (remote models)
*/
export async function fetchRemoteFamilies(): Promise<string[]> {
const response = await fetch(`${API_BASE}/remote/families`);
if (!response.ok) {
throw new Error(`Failed to fetch families: ${response.statusText}`);
}
const data = await response.json();
return data.families;
}
/**
* Get a single remote model by slug
*/
export async function getRemoteModel(slug: string): Promise<RemoteModel> {
const response = await fetch(`${API_BASE}/remote/${encodeURIComponent(slug)}`);
if (!response.ok) {
if (response.status === 404) {
throw new Error(`Model not found: ${slug}`);
}
throw new Error(`Failed to fetch model: ${response.statusText}`);
}
return response.json();
}
/**
* Fetch detailed model info via ollama show (requires model to be available locally)
*/
export async function fetchModelDetails(slug: string): Promise<RemoteModel> {
const response = await fetch(`${API_BASE}/remote/${encodeURIComponent(slug)}/details`, {
method: 'POST'
});
if (!response.ok) {
throw new Error(`Failed to fetch model details: ${response.statusText}`);
}
return response.json();
}
/**
* Fetch file sizes per tag from ollama.com (scrapes model detail page)
*/
export async function fetchTagSizes(slug: string): Promise<RemoteModel> {
const response = await fetch(`${API_BASE}/remote/${encodeURIComponent(slug)}/sizes`, {
method: 'POST'
});
if (!response.ok) {
throw new Error(`Failed to fetch tag sizes: ${response.statusText}`);
}
return response.json();
}
/**
* Sync models from ollama.com
*/
export async function syncModels(): Promise<SyncResponse> {
const response = await fetch(`${API_BASE}/remote/sync`, {
method: 'POST'
});
if (!response.ok) {
throw new Error(`Failed to sync models: ${response.statusText}`);
}
return response.json();
}
/**
* Get sync status
*/
export async function getSyncStatus(): Promise<SyncStatus> {
const response = await fetch(`${API_BASE}/remote/status`);
if (!response.ok) {
throw new Error(`Failed to get sync status: ${response.statusText}`);
}
return response.json();
}
/**
* Format pull count for display (e.g., "108.2M")
*/
export function formatPullCount(count: number): string {
if (count >= 1_000_000_000) {
return `${(count / 1_000_000_000).toFixed(1)}B`;
}
if (count >= 1_000_000) {
return `${(count / 1_000_000).toFixed(1)}M`;
}
if (count >= 1_000) {
return `${(count / 1_000).toFixed(1)}K`;
}
return String(count);
}
/**
* Format context length for display
*/
export function formatContextLength(length: number): string {
if (length >= 1_000_000) {
return `${(length / 1_000_000).toFixed(0)}M`;
}
if (length >= 1_000) {
return `${(length / 1_000).toFixed(0)}K`;
}
return String(length);
}
/**
* Check if a model has a specific capability
*/
export function hasCapability(model: RemoteModel, capability: string): boolean {
return model.capabilities.includes(capability);
}
// ============================================================================
// Local Models API (backend-powered with filter/sort/pagination)
// ============================================================================
/** Local model from Ollama instance */
export interface LocalModel {
name: string;
model: string;
modifiedAt: string;
size: number;
digest: string;
family: string;
parameterSize: string;
quantizationLevel: string;
hasUpdate?: boolean;
remoteUpdatedAt?: string;
}
/** Response from listing local models */
export interface LocalModelsResponse {
models: LocalModel[];
total: number;
limit: number;
offset: number;
}
/** Update check response */
export interface UpdateCheckResponse {
updates: LocalModel[];
totalLocal: number;
updatesAvailable: number;
}
/** Sort options for local models */
export type LocalModelSortOption = 'name_asc' | 'name_desc' | 'size_asc' | 'size_desc' | 'modified_asc' | 'modified_desc';
/** Search/filter options for local models */
export interface LocalModelSearchOptions {
search?: string;
family?: string;
sort?: LocalModelSortOption;
limit?: number;
offset?: number;
}
/**
* Fetch local models with filtering, sorting, and pagination (server-side)
*/
export async function fetchLocalModels(options: LocalModelSearchOptions = {}): Promise<LocalModelsResponse> {
const params = new URLSearchParams();
if (options.search) params.set('search', options.search);
if (options.family) params.set('family', options.family);
if (options.sort) params.set('sort', options.sort);
if (options.limit) params.set('limit', String(options.limit));
if (options.offset) params.set('offset', String(options.offset));
const url = `${API_BASE}/local${params.toString() ? '?' + params.toString() : ''}`;
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to fetch local models: ${response.statusText}`);
}
return response.json();
}
/**
* Get unique model families for filter dropdowns
*/
export async function fetchLocalFamilies(): Promise<string[]> {
const response = await fetch(`${API_BASE}/local/families`);
if (!response.ok) {
throw new Error(`Failed to fetch families: ${response.statusText}`);
}
const data = await response.json();
return data.families;
}
/**
* Check for available updates (backend compares local vs remote registry)
*/
export async function checkForUpdates(): Promise<UpdateCheckResponse> {
const response = await fetch(`${API_BASE}/local/updates`);
if (!response.ok) {
throw new Error(`Failed to check updates: ${response.statusText}`);
}
return response.json();
}