unsloth/studio/frontend/src/features/chat/api/chat-api.ts
Daniel Han 34cdbf42bf studio: training progress visibility + deferred llama.cpp compilation
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
2026-03-16 14:02:20 +00:00

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;
}