unsloth/studio/frontend/src/features/chat/hooks/use-chat-model-runtime.ts

231 lines
6.8 KiB
TypeScript

import { useCallback } from "react";
import { toast } from "sonner";
import {
getInferenceStatus,
listLoras,
listModels,
loadModel,
unloadModel,
} from "../api/chat-api";
import { useChatRuntimeStore } from "../stores/chat-runtime-store";
import type { LoadModelResponse } from "../types/api";
import type {
ChatLoraSummary,
ChatModelSummary,
InferenceParams,
} from "../types/runtime";
const DEFAULT_MODEL_MAX_SEQ_LENGTH = 2048;
type SelectedModelInput = {
id: string;
isLora?: boolean;
};
const LORA_SUFFIX_RE = /_(\d{9,})$/;
function parseTrailingEpoch(input: string): number | undefined {
const match = input.match(LORA_SUFFIX_RE);
if (!match) {
return undefined;
}
const parsed = Number.parseInt(match[1], 10);
return Number.isFinite(parsed) ? parsed : undefined;
}
function stripTrailingEpoch(input: string): string {
const cleaned = input.replace(LORA_SUFFIX_RE, "").replace(/[_-]+$/, "").trim();
return cleaned || input;
}
function describeModel(model: {
is_lora?: boolean;
is_vision?: boolean;
}): string | undefined {
const tags: string[] = [];
if (model.is_lora) tags.push("LoRA");
if (model.is_vision) tags.push("Vision");
if (!model.is_lora && !model.is_vision) tags.push("Base");
return tags.join(" · ");
}
function toChatModelSummary(model: {
id: string;
name?: string | null;
is_lora?: boolean;
is_vision?: boolean;
}): ChatModelSummary {
return {
id: model.id,
name: model.name || model.id,
description: describeModel(model),
isLora: Boolean(model.is_lora),
isVision: Boolean(model.is_vision),
};
}
function toLoraSummary(lora: {
display_name: string;
adapter_path: string;
base_model?: string | null;
}): ChatLoraSummary {
const idTail = lora.adapter_path.split("/").filter(Boolean).at(-1) ?? "";
const updatedAt =
parseTrailingEpoch(lora.display_name) ?? parseTrailingEpoch(idTail);
return {
id: lora.adapter_path,
name: stripTrailingEpoch(lora.display_name),
baseModel: lora.base_model || "Unknown base model",
updatedAt,
};
}
function toFiniteNumber(value: unknown): number | undefined {
if (typeof value !== "number" || !Number.isFinite(value)) {
return undefined;
}
return value;
}
function mergeRecommendedInference(
current: InferenceParams,
response: LoadModelResponse,
modelId: string,
): InferenceParams {
const inference = response.inference;
return {
...current,
checkpoint: modelId,
temperature:
toFiniteNumber(inference?.temperature) ?? current.temperature,
topP: toFiniteNumber(inference?.top_p) ?? current.topP,
topK: toFiniteNumber(inference?.top_k) ?? current.topK,
minP: toFiniteNumber(inference?.min_p) ?? current.minP,
};
}
export function useChatModelRuntime() {
const params = useChatRuntimeStore((state) => state.params);
const models = useChatRuntimeStore((state) => state.models);
const loras = useChatRuntimeStore((state) => state.loras);
const setModels = useChatRuntimeStore((state) => state.setModels);
const setLoras = useChatRuntimeStore((state) => state.setLoras);
const setParams = useChatRuntimeStore((state) => state.setParams);
const setModelsError = useChatRuntimeStore((state) => state.setModelsError);
const setCheckpoint = useChatRuntimeStore((state) => state.setCheckpoint);
const clearCheckpoint = useChatRuntimeStore((state) => state.clearCheckpoint);
const refresh = useCallback(async () => {
setModelsError(null);
try {
const [listRes, statusRes, lorasRes] = await Promise.all([
listModels(),
getInferenceStatus(),
listLoras(),
]);
setModels(listRes.models.map(toChatModelSummary));
setLoras(lorasRes.loras.map(toLoraSummary));
if (statusRes.active_model) {
setCheckpoint(statusRes.active_model);
}
} catch (error) {
const message =
error instanceof Error ? error.message : "Failed to load models";
setModelsError(message);
toast.error("Failed to refresh models", {
description: message,
});
}
}, [setCheckpoint, setLoras, setModels, setModelsError]);
const selectModel = useCallback(
async (selection: string | SelectedModelInput) => {
const modelId = typeof selection === "string" ? selection : selection.id;
if (!modelId || params.checkpoint === modelId) {
return;
}
const explicitIsLora =
typeof selection === "string" ? undefined : selection.isLora;
const model = models.find((entry) => entry.id === modelId);
const lora = loras.find((entry) => entry.id === modelId);
const isLora =
explicitIsLora ?? model?.isLora ?? (lora ? true : false);
const displayName = model?.name || lora?.name || modelId;
setModelsError(null);
try {
async function performLoad(): Promise<void> {
if (params.checkpoint) {
await unloadModel({ model_path: params.checkpoint });
}
const loadResponse = await loadModel({
model_path: modelId,
hf_token: null,
max_seq_length: DEFAULT_MODEL_MAX_SEQ_LENGTH,
load_in_4bit: true,
is_lora: isLora,
});
const currentParams = useChatRuntimeStore.getState().params;
setParams(mergeRecommendedInference(currentParams, loadResponse, modelId));
await refresh();
}
let description = "Base model selected.";
if (isLora) {
description = "Fine-tuned (LoRA) selected.";
}
await toast.promise(performLoad(), {
loading: `Loading ${displayName}`,
success: `${displayName} loaded`,
error: (err) =>
err instanceof Error ? err.message : "Failed to load model",
description,
});
} catch (error) {
const message =
error instanceof Error ? error.message : "Failed to load model";
setModelsError(message);
}
},
[loras, models, params.checkpoint, refresh, setModelsError, setParams],
);
const ejectModel = useCallback(async () => {
if (!params.checkpoint) {
return;
}
setModelsError(null);
try {
async function performUnload(): Promise<void> {
await unloadModel({ model_path: params.checkpoint });
clearCheckpoint();
await refresh();
}
await toast.promise(performUnload(), {
loading: "Unloading model",
success: "Model unloaded",
error: (err) =>
err instanceof Error ? err.message : "Failed to unload model",
description: "Releases VRAM and resets inference state.",
});
} catch (error) {
const message =
error instanceof Error ? error.message : "Failed to unload model";
setModelsError(message);
}
}, [clearCheckpoint, params.checkpoint, refresh, setModelsError]);
return {
refresh,
selectModel,
ejectModel,
};
}