Training progress: - Show row counts in status messages: "Loaded dataset from HuggingFace: Open-Orca/OpenOrca (4,233,923 rows)" instead of just the dataset name - Emit "Formatting dataset (N rows)..." and "Applying chat template (N rows)..." status updates so users see progress during the preprocessing stages that previously appeared stuck Deferred llama.cpp compilation: - Add LlamaCppBuilder that runs cmake build in a background thread at server startup if the llama-server binary is missing - Studio starts immediately and is usable for training/non-GGUF tasks while llama.cpp compiles in the background - GGUF model loads wait for the build to finish with a helpful message - Add /api/inference/llama-cpp-status endpoint for build status - Frontend shows "Waiting for llama.cpp to compile..." toast when loading a GGUF while build is in progress
252 lines
7.3 KiB
TypeScript
252 lines
7.3 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 getLlamaCppStatus(): Promise<{
|
|
ready: boolean;
|
|
building: boolean;
|
|
error: string | null;
|
|
}> {
|
|
const response = await authFetch("/api/inference/llama-cpp-status");
|
|
return parseJsonOrThrow(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 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 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
|
|
| { error?: { message?: string } };
|
|
if ("error" in parsed && parsed.error) {
|
|
throw new Error(parsed.error.message || "Stream error");
|
|
}
|
|
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;
|
|
}
|