* feat: add scan_folders table and CRUD functions to studio_db * feat: add scan folders API endpoints and integrate into model scan * feat: add scan folders API client and update source types * feat: add custom source to model filters and selector * feat: add Model Folders section to chat settings sidebar * style: fix biome formatting in ModelFoldersSection * fix: address review findings for custom scan folders empty string bypass, concurrent delete crash guard, Windows case normalization, response_model on endpoints, logging, deduplicated filter/map, module level cache for custom folder models, consistent source labels, handleRemove error surfacing, per folder scan cap * fix: show custom folders section regardless of chatOnly mode * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * refactor: extract shared refreshLocalModelsList in pickers * Harden custom scan folder validation and scanning - Validate path exists, is a directory, and is readable before persisting - Apply per-folder model cap during traversal instead of after (avoids scanning millions of inodes in large directories) - Wrap per-folder scan in try/except so one unreadable folder does not break the entire /api/models/local endpoint for all callers - Normalize case on Windows before storing so C:\Models and c:\models dedup correctly - Extend macOS denylist to cover /private/etc and /private/tmp (realpath resolves /etc -> /private/etc, bypassing the original denylist) - Add /boot and /run to Linux denylist * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Improve scan robustness and preserve Windows path casing - Preserve original Windows path casing in DB instead of lowercasing (normcase used only for dedup comparison, not storage) - Catch PermissionError per child directory so one unreadable subdirectory does not skip the entire custom folder scan - Wrap list_scan_folders() DB call in try/except so a DB issue does not break the entire /api/models/local endpoint * fix: scan custom folders for both flat and HF cache layouts * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Fix Windows case-insensitive path dedup with COLLATE NOCASE Use COLLATE NOCASE on the scan_folders.path column so that the UNIQUE constraint correctly deduplicates C:\Models and c:\models on Windows without lowercasing the stored path. Also use COLLATE NOCASE in the pre-insert lookup query on Windows to catch existing rows with different casing. * Restore early-exit limit in _scan_models_dir for custom folders Keep the limit parameter so _scan_models_dir stops iterating once enough models are found, avoiding unbounded traversal of large directories. The post-traversal slice is still applied after combining with _scan_hf_cache results. * feat: scan custom folders with LM Studio layout too * Fix custom folder models being hidden by dedup Custom folder entries were appended after HF cache and models_dir entries. The dedup loop kept the first occurrence of each model id, so custom models with the same id as an existing HF cache entry were silently dropped -- they never appeared in the "Custom Folders" UI section. Use a separate dedup key for custom-source entries so they always survive deduplication. This way a model can appear under both "Downloaded" (from HF cache) and "Custom Folders" (from the user-registered directory) at the same time. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Harden LM Studio scan and fix COLLATE NOCASE on Linux - Add per-child and per-publisher OSError handling in _scan_lmstudio_dir so one unreadable subdirectory does not discard the entire custom folder's results - Only apply COLLATE NOCASE on the scan_folders schema on Windows where paths are case-insensitive; keep default BINARY collation on Linux and macOS where /Models and /models are distinct directories * Use COLLATE NOCASE in post-IntegrityError fallback SELECT on Windows The fallback SELECT after an IntegrityError race now uses the same case-insensitive collation as the pre-insert check, so a concurrent writer that stored the path with different casing does not cause a false "Folder was concurrently removed" error. --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Daniel Han <danielhanchen@gmail.com>
315 lines
9.5 KiB
TypeScript
315 lines
9.5 KiB
TypeScript
// SPDX-License-Identifier: AGPL-3.0-only
|
|
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
|
|
|
import { authFetch } from "@/features/auth";
|
|
import type {
|
|
AudioGenerationResponse,
|
|
GgufVariantsResponse,
|
|
InferenceStatusResponse,
|
|
ListLorasResponse,
|
|
ListModelsResponse,
|
|
LoadModelRequest,
|
|
LoadModelResponse,
|
|
OpenAIChatChunk,
|
|
OpenAIChatCompletionsRequest,
|
|
UnloadModelRequest,
|
|
ValidateModelResponse,
|
|
} from "../types/api";
|
|
|
|
function parseErrorText(status: number, body: unknown): string {
|
|
if (
|
|
body &&
|
|
typeof body === "object" &&
|
|
"detail" in body &&
|
|
typeof body.detail === "string"
|
|
) {
|
|
return body.detail;
|
|
}
|
|
if (
|
|
body &&
|
|
typeof body === "object" &&
|
|
"message" in body &&
|
|
typeof body.message === "string"
|
|
) {
|
|
return body.message;
|
|
}
|
|
return `Request failed (${status})`;
|
|
}
|
|
|
|
async function parseJsonOrThrow<T>(response: Response): Promise<T> {
|
|
const body = await response.json().catch(() => null);
|
|
if (!response.ok) {
|
|
throw new Error(parseErrorText(response.status, body));
|
|
}
|
|
return body as T;
|
|
}
|
|
|
|
export async function listModels(): Promise<ListModelsResponse> {
|
|
const response = await authFetch("/api/models/list");
|
|
return parseJsonOrThrow<ListModelsResponse>(response);
|
|
}
|
|
|
|
export async function listLoras(outputsDir?: string): Promise<ListLorasResponse> {
|
|
const query = outputsDir
|
|
? `?${new URLSearchParams({ outputs_dir: outputsDir }).toString()}`
|
|
: "";
|
|
const response = await authFetch(`/api/models/loras${query}`);
|
|
return parseJsonOrThrow<ListLorasResponse>(response);
|
|
}
|
|
|
|
export async function getInferenceStatus(): Promise<InferenceStatusResponse> {
|
|
const response = await authFetch("/api/inference/status");
|
|
return parseJsonOrThrow<InferenceStatusResponse>(response);
|
|
}
|
|
|
|
export async function loadModel(
|
|
payload: LoadModelRequest,
|
|
): Promise<LoadModelResponse> {
|
|
const response = await authFetch("/api/inference/load", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(payload),
|
|
});
|
|
return parseJsonOrThrow<LoadModelResponse>(response);
|
|
}
|
|
|
|
export async function validateModel(
|
|
payload: LoadModelRequest,
|
|
): Promise<ValidateModelResponse> {
|
|
const response = await authFetch("/api/inference/validate", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
model_path: payload.model_path,
|
|
hf_token: payload.hf_token,
|
|
gguf_variant: payload.gguf_variant ?? null,
|
|
}),
|
|
});
|
|
return parseJsonOrThrow<ValidateModelResponse>(response);
|
|
}
|
|
|
|
export async function unloadModel(payload: UnloadModelRequest): Promise<void> {
|
|
const response = await authFetch("/api/inference/unload", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(payload),
|
|
});
|
|
await parseJsonOrThrow<unknown>(response);
|
|
}
|
|
|
|
export interface CachedGgufRepo {
|
|
repo_id: string;
|
|
size_bytes: number;
|
|
cache_path: string;
|
|
}
|
|
|
|
export async function getGgufDownloadProgress(
|
|
repoId: string,
|
|
variant: string,
|
|
expectedBytes: number,
|
|
): Promise<{ downloaded_bytes: number; expected_bytes: number; progress: number }> {
|
|
const params = new URLSearchParams({
|
|
repo_id: repoId,
|
|
variant,
|
|
expected_bytes: String(expectedBytes),
|
|
});
|
|
const response = await authFetch(`/api/models/gguf-download-progress?${params}`);
|
|
return parseJsonOrThrow(response);
|
|
}
|
|
|
|
export async function getDownloadProgress(
|
|
repoId: string,
|
|
): Promise<{ downloaded_bytes: number; expected_bytes: number; progress: number }> {
|
|
const params = new URLSearchParams({ repo_id: repoId });
|
|
const response = await authFetch(`/api/models/download-progress?${params}`);
|
|
return parseJsonOrThrow(response);
|
|
}
|
|
|
|
export interface LocalModelInfo {
|
|
id: string;
|
|
display_name: string;
|
|
path: string;
|
|
source: "models_dir" | "hf_cache" | "lmstudio" | "custom";
|
|
model_id?: string | null;
|
|
updated_at?: number | null;
|
|
}
|
|
|
|
interface LocalModelListResponse {
|
|
models_dir: string;
|
|
hf_cache_dir?: string | null;
|
|
lmstudio_dirs: string[];
|
|
models: LocalModelInfo[];
|
|
}
|
|
|
|
export async function listLocalModels(): Promise<LocalModelListResponse> {
|
|
const response = await authFetch("/api/models/local");
|
|
return parseJsonOrThrow<LocalModelListResponse>(response);
|
|
}
|
|
|
|
export async function listCachedGguf(): Promise<CachedGgufRepo[]> {
|
|
const response = await authFetch("/api/models/cached-gguf");
|
|
const data = await parseJsonOrThrow<{ cached: CachedGgufRepo[] }>(response);
|
|
return data.cached;
|
|
}
|
|
|
|
export interface CachedModelRepo {
|
|
repo_id: string;
|
|
size_bytes: number;
|
|
}
|
|
|
|
export async function listCachedModels(): Promise<CachedModelRepo[]> {
|
|
const response = await authFetch("/api/models/cached-models");
|
|
const data = await parseJsonOrThrow<{ cached: CachedModelRepo[] }>(response);
|
|
return data.cached;
|
|
}
|
|
|
|
export async function deleteCachedModel(repoId: string, variant?: string): Promise<void> {
|
|
const payload: Record<string, string> = { repo_id: repoId };
|
|
if (variant) payload.variant = variant;
|
|
const response = await authFetch("/api/models/delete-cached", {
|
|
method: "DELETE",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(payload),
|
|
});
|
|
await parseJsonOrThrow<unknown>(response);
|
|
}
|
|
|
|
export interface ScanFolderInfo {
|
|
id: number;
|
|
path: string;
|
|
created_at: string;
|
|
}
|
|
|
|
export async function listScanFolders(): Promise<ScanFolderInfo[]> {
|
|
const response = await authFetch("/api/models/scan-folders");
|
|
const data = await parseJsonOrThrow<{ folders: ScanFolderInfo[] }>(response);
|
|
return data.folders;
|
|
}
|
|
|
|
export async function addScanFolder(path: string): Promise<ScanFolderInfo> {
|
|
const response = await authFetch("/api/models/scan-folders", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ path }),
|
|
});
|
|
return parseJsonOrThrow<ScanFolderInfo>(response);
|
|
}
|
|
|
|
export async function removeScanFolder(id: number): Promise<void> {
|
|
const response = await authFetch(`/api/models/scan-folders/${id}`, {
|
|
method: "DELETE",
|
|
});
|
|
await parseJsonOrThrow<unknown>(response);
|
|
}
|
|
|
|
export async function listGgufVariants(
|
|
repoId: string,
|
|
hfToken?: string,
|
|
): Promise<GgufVariantsResponse> {
|
|
const params = new URLSearchParams({ repo_id: repoId });
|
|
if (hfToken) params.set("hf_token", hfToken);
|
|
const response = await authFetch(`/api/models/gguf-variants?${params}`);
|
|
return parseJsonOrThrow<GgufVariantsResponse>(response);
|
|
}
|
|
|
|
function parseSseEvent(rawEvent: string): string[] {
|
|
const dataLines: string[] = [];
|
|
for (const line of rawEvent.split(/\r?\n/)) {
|
|
if (line.startsWith("data:")) {
|
|
dataLines.push(line.slice(5).trimStart());
|
|
}
|
|
}
|
|
return dataLines;
|
|
}
|
|
|
|
export async function* streamChatCompletions(
|
|
payload: OpenAIChatCompletionsRequest,
|
|
signal: AbortSignal,
|
|
): AsyncGenerator<OpenAIChatChunk> {
|
|
const response = await authFetch("/v1/chat/completions", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(payload),
|
|
signal,
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const body = await response.json().catch(() => null);
|
|
throw new Error(parseErrorText(response.status, body));
|
|
}
|
|
|
|
if (!response.body) {
|
|
throw new Error("Stream response missing body");
|
|
}
|
|
|
|
const reader = response.body.getReader();
|
|
const decoder = new TextDecoder();
|
|
let buffer = "";
|
|
|
|
while (true) {
|
|
const { done, value } = await reader.read();
|
|
if (done) {
|
|
break;
|
|
}
|
|
|
|
buffer += decoder.decode(value, { stream: true });
|
|
|
|
let separatorIndex = buffer.search(/\r?\n\r?\n/);
|
|
while (separatorIndex >= 0) {
|
|
const rawEvent = buffer.slice(0, separatorIndex);
|
|
const separatorLength = buffer[separatorIndex] === "\r" ? 4 : 2;
|
|
buffer = buffer.slice(separatorIndex + separatorLength);
|
|
|
|
const dataLines = parseSseEvent(rawEvent);
|
|
if (dataLines.length === 0) {
|
|
separatorIndex = buffer.search(/\r?\n\r?\n/);
|
|
continue;
|
|
}
|
|
|
|
const dataText = dataLines.join("\n");
|
|
if (dataText === "[DONE]") {
|
|
return;
|
|
}
|
|
|
|
const parsed = JSON.parse(dataText) as
|
|
| OpenAIChatChunk
|
|
| { type?: string; content?: string; error?: { message?: string } };
|
|
if ("error" in parsed && parsed.error) {
|
|
throw new Error(parsed.error.message || "Stream error");
|
|
}
|
|
// Tool status events are custom SSE payloads, not OpenAI chunks
|
|
if ("type" in parsed && parsed.type === "tool_status") {
|
|
yield { _toolStatus: parsed.content ?? "" } as unknown as OpenAIChatChunk;
|
|
separatorIndex = buffer.search(/\r?\n\r?\n/);
|
|
continue;
|
|
}
|
|
// Tool start/end events carry full input/output for the tool outputs panel
|
|
if ("type" in parsed && (parsed.type === "tool_start" || parsed.type === "tool_end")) {
|
|
yield { _toolEvent: parsed } as unknown as OpenAIChatChunk;
|
|
separatorIndex = buffer.search(/\r?\n\r?\n/);
|
|
continue;
|
|
}
|
|
yield parsed as OpenAIChatChunk;
|
|
separatorIndex = buffer.search(/\r?\n\r?\n/);
|
|
}
|
|
}
|
|
}
|
|
|
|
export async function generateAudio(
|
|
payload: OpenAIChatCompletionsRequest,
|
|
signal: AbortSignal,
|
|
): Promise<AudioGenerationResponse> {
|
|
const response = await authFetch("/api/inference/chat/completions", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ ...payload, stream: false }),
|
|
signal,
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const body = await response.json().catch(() => null);
|
|
throw new Error(parseErrorText(response.status, body));
|
|
}
|
|
|
|
return (await response.json()) as AudioGenerationResponse;
|
|
}
|