* studio: allow huggingface.co and datasets-server.huggingface.co in CSP connect-src
The security hardening pass (0881a7a5) added connect-src 'self', which
blocked the Training page's direct browser calls to HuggingFace. Model
search (@huggingface/hub listModels/modelInfo/whoAmI -> huggingface.co)
and dataset subset/split discovery (datasets-server.huggingface.co/splits)
both returned nothing as a result.
Extend connect-src to permit the two HF hosts the SPA actually talks to.
No other directive changes; HF tokens still stay client-side.
* studio: format FastAPI 422 detail arrays in training error messages
readError in train-api.ts stringified payload.detail directly. On a 422
the detail is an array of {loc, msg} objects, which JS coerces to
'[object Object],[object Object]' -- the UI showed that instead of the
actual validator message.
Format the array into 'field.path: msg; ...' so the offending field and
the validator's message surface in the UI and toast.
* studio: allow num_epochs/max_steps = 0 sentinel through TrainingStartRequest
The hyperparameter validators added in the security pass rejected 0 for
both num_epochs and max_steps. But Studio's steps-vs-epochs toggle uses
0 as a sentinel: when training by max_steps the frontend sends
num_epochs=0, and when training by epochs it sends max_steps=0. The
trainer expects this and ignores the zeroed field.
Widen both validators to [0, MAX]. They still catch the actual
out-of-range and non-integer inputs they were added for.
* studio: reject TrainingStartRequest when num_epochs and max_steps are both 0
Each field's validator accepts 0 as a "use the other one" sentinel, but
on their own they don't catch the case where both are 0 (or max_steps
is None and num_epochs is 0). That payload would otherwise produce a
no-op training job. Add a model-level validator that rejects it with a
clear 422 message.
* studio: add Optional[int] type hints to _check_max_steps and _check_warmup_steps
Brings these two validators in line with the rest of the TrainingStartRequest
validators in the same file, which all carry explicit cls/v/return hints.
218 lines
5.9 KiB
TypeScript
218 lines
5.9 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 {
|
|
TrainingStartRequest,
|
|
TrainingStartResponse,
|
|
TrainingStopResponse,
|
|
} from "../types/api";
|
|
import type {
|
|
TrainingMetricsResponse,
|
|
TrainingProgressPayload,
|
|
TrainingStatusResponse,
|
|
} from "../types/runtime";
|
|
|
|
function isAbortError(error: unknown): boolean {
|
|
return error instanceof DOMException && error.name === "AbortError";
|
|
}
|
|
|
|
type FastApiValidationError = {
|
|
loc?: unknown[];
|
|
msg?: string;
|
|
};
|
|
|
|
function formatDetail(detail: unknown): string | null {
|
|
if (typeof detail === "string" && detail) return detail;
|
|
if (!Array.isArray(detail)) return null;
|
|
const parts = detail
|
|
.map((entry) => {
|
|
if (!entry || typeof entry !== "object") return "";
|
|
const { loc, msg } = entry as FastApiValidationError;
|
|
const path = Array.isArray(loc)
|
|
? loc.filter((segment) => segment !== "body").join(".")
|
|
: "";
|
|
const message = typeof msg === "string" ? msg : "";
|
|
if (path && message) return `${path}: ${message}`;
|
|
return path || message;
|
|
})
|
|
.filter(Boolean);
|
|
return parts.length > 0 ? parts.join("; ") : null;
|
|
}
|
|
|
|
async function readError(response: Response): Promise<string> {
|
|
try {
|
|
const payload = (await response.json()) as {
|
|
detail?: unknown;
|
|
message?: string;
|
|
};
|
|
const formattedDetail = formatDetail(payload.detail);
|
|
if (formattedDetail) return formattedDetail;
|
|
if (typeof payload.message === "string" && payload.message) {
|
|
return payload.message;
|
|
}
|
|
return `Request failed (${response.status})`;
|
|
} catch {
|
|
return `Request failed (${response.status})`;
|
|
}
|
|
}
|
|
|
|
async function parseJson<T>(response: Response): Promise<T> {
|
|
if (!response.ok) {
|
|
throw new Error(await readError(response));
|
|
}
|
|
return (await response.json()) as T;
|
|
}
|
|
|
|
export async function startTraining(
|
|
payload: TrainingStartRequest,
|
|
): Promise<TrainingStartResponse> {
|
|
const response = await authFetch("/api/train/start", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(payload),
|
|
});
|
|
return parseJson<TrainingStartResponse>(response);
|
|
}
|
|
|
|
export async function stopTraining(save = true): Promise<TrainingStopResponse> {
|
|
const response = await authFetch("/api/train/stop", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ save }),
|
|
});
|
|
return parseJson<TrainingStopResponse>(response);
|
|
}
|
|
|
|
export async function resetTraining(): Promise<void> {
|
|
const response = await authFetch("/api/train/reset", { method: "POST" });
|
|
if (!response.ok) {
|
|
throw new Error(await readError(response));
|
|
}
|
|
}
|
|
|
|
export async function getTrainingStatus(): Promise<TrainingStatusResponse> {
|
|
const response = await authFetch("/api/train/status");
|
|
return parseJson<TrainingStatusResponse>(response);
|
|
}
|
|
|
|
export async function getTrainingMetrics(): Promise<TrainingMetricsResponse> {
|
|
const response = await authFetch("/api/train/metrics");
|
|
return parseJson<TrainingMetricsResponse>(response);
|
|
}
|
|
|
|
type ProgressEventName = "progress" | "heartbeat" | "complete" | "error";
|
|
|
|
interface ParsedSseEvent {
|
|
event: ProgressEventName;
|
|
payload: TrainingProgressPayload;
|
|
id: number | null;
|
|
}
|
|
|
|
function parseSseEvent(rawEvent: string): ParsedSseEvent | null {
|
|
const lines = rawEvent.split(/\r?\n/);
|
|
let eventName: ProgressEventName = "progress";
|
|
let id: number | null = null;
|
|
const dataLines: string[] = [];
|
|
|
|
for (const line of lines) {
|
|
if (!line) {
|
|
continue;
|
|
}
|
|
if (line.startsWith("event:")) {
|
|
const value = line.slice(6).trim();
|
|
if (
|
|
value === "progress" ||
|
|
value === "heartbeat" ||
|
|
value === "complete" ||
|
|
value === "error"
|
|
) {
|
|
eventName = value;
|
|
}
|
|
continue;
|
|
}
|
|
if (line.startsWith("id:")) {
|
|
const value = Number(line.slice(3).trim());
|
|
id = Number.isFinite(value) ? value : null;
|
|
continue;
|
|
}
|
|
if (line.startsWith("data:")) {
|
|
dataLines.push(line.slice(5).trimStart());
|
|
}
|
|
}
|
|
|
|
if (dataLines.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
const parsed = JSON.parse(dataLines.join("\n")) as TrainingProgressPayload;
|
|
return { event: eventName, payload: parsed, id };
|
|
}
|
|
|
|
export async function streamTrainingProgress(options: {
|
|
signal: AbortSignal;
|
|
lastEventId?: number | null;
|
|
onOpen?: () => void;
|
|
onEvent: (event: ParsedSseEvent) => void;
|
|
}): Promise<void> {
|
|
const headers = new Headers();
|
|
if (typeof options.lastEventId === "number") {
|
|
headers.set("Last-Event-ID", String(options.lastEventId));
|
|
}
|
|
|
|
const response = await authFetch("/api/train/progress", {
|
|
method: "GET",
|
|
headers,
|
|
signal: options.signal,
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(await readError(response));
|
|
}
|
|
|
|
if (!response.body) {
|
|
throw new Error("Progress stream unavailable");
|
|
}
|
|
|
|
options.onOpen?.();
|
|
|
|
const reader = response.body.getReader();
|
|
const decoder = new TextDecoder();
|
|
let buffer = "";
|
|
|
|
while (true) {
|
|
const { value, done } = 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);
|
|
|
|
if (rawEvent.startsWith("retry:")) {
|
|
separatorIndex = buffer.search(/\r?\n\r?\n/);
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
const event = parseSseEvent(rawEvent);
|
|
if (event) {
|
|
options.onEvent(event);
|
|
}
|
|
} catch (error) {
|
|
if (!isAbortError(error)) {
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
separatorIndex = buffer.search(/\r?\n\r?\n/);
|
|
}
|
|
}
|
|
}
|
|
|
|
export { isAbortError };
|