fixed model unload before load

This commit is contained in:
Samit 2026-03-06 22:01:27 -08:00
commit 5f902af456
5 changed files with 161 additions and 14 deletions

View file

@ -25,6 +25,34 @@ class UnloadRequest(BaseModel):
model_path: str = Field(..., description="Model identifier to unload")
class ValidateModelRequest(BaseModel):
"""
Lightweight validation request to check whether a model identifier
*can be resolved* into a ModelConfig.
This does NOT actually load weights into GPU memory.
"""
model_path: str = Field(..., description="Model identifier or local path")
hf_token: Optional[str] = Field(None, description="HuggingFace token for gated models")
gguf_variant: Optional[str] = Field(None, description="GGUF quantization variant (e.g. 'Q4_K_M')")
class ValidateModelResponse(BaseModel):
"""
Result of model validation.
valid == True means ModelConfig.from_identifier() succeeded and basic
introspection (GGUF / LoRA / vision flags) is available.
"""
valid: bool = Field(..., description="Whether the model identifier looks valid")
message: str = Field(..., description="Human-readable validation message")
identifier: Optional[str] = Field(None, description="Resolved model identifier")
display_name: Optional[str] = Field(None, description="Display name derived from identifier")
is_gguf: bool = Field(False, description="Whether this is a GGUF model (llama.cpp)")
is_lora: bool = Field(False, description="Whether this is a LoRA adapter")
is_vision: bool = Field(False, description="Whether this is a vision-capable model")
class GenerateRequest(BaseModel):
"""Request for text generation (legacy /generate/stream endpoint)"""
messages: List[dict] = Field(..., description="Chat messages in OpenAI format")

View file

@ -49,6 +49,8 @@ from models.inference import (
ChoiceDelta,
CompletionChoice,
CompletionMessage,
ValidateModelRequest,
ValidateModelResponse,
)
from auth.authentication import get_current_subject
@ -198,6 +200,50 @@ async def load_model(
)
@router.post("/validate", response_model=ValidateModelResponse)
async def validate_model(
request: ValidateModelRequest,
current_subject: str = Depends(get_current_subject),
):
"""
Lightweight validation endpoint for model identifiers.
This checks that ModelConfig.from_identifier() can resolve the given
model_path, but it does NOT actually load model weights into GPU memory.
"""
try:
config = ModelConfig.from_identifier(
model_id=request.model_path,
hf_token=request.hf_token,
gguf_variant=request.gguf_variant,
)
if not config:
raise HTTPException(
status_code=400,
detail=f"Invalid model identifier: {request.model_path}",
)
return ValidateModelResponse(
valid=True,
message="Model identifier is valid.",
identifier=config.identifier,
display_name=getattr(config, "display_name", config.identifier),
is_gguf=getattr(config, "is_gguf", False),
is_lora=getattr(config, "is_lora", False),
is_vision=getattr(config, "is_vision", False),
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Error validating model identifier '{request.model_path}': {e}", exc_info=True)
raise HTTPException(
status_code=400,
detail=f"Invalid model: {str(e)}",
)
@router.post("/unload", response_model=UnloadResponse)
async def unload_model(
request: UnloadRequest,

View file

@ -9,6 +9,7 @@ import type {
OpenAIChatChunk,
OpenAIChatCompletionsRequest,
UnloadModelRequest,
ValidateModelResponse,
} from "../types/api";
function parseErrorText(status: number, body: unknown): string {
@ -66,6 +67,21 @@ export async function loadModel(
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",

View file

@ -6,6 +6,7 @@ import {
listModels,
loadModel,
unloadModel,
validateModel,
} from "../api/chat-api";
import { useChatRuntimeStore } from "../stores/chat-runtime-store";
import type { LoadModelResponse } from "../types/api";
@ -177,6 +178,17 @@ export function useChatModelRuntime() {
const displayName = model?.name || lora?.name || modelId;
const currentCheckpoint =
useChatRuntimeStore.getState().params.checkpoint;
const previousCheckpoint = currentCheckpoint;
const previousVariant =
useChatRuntimeStore.getState().activeGgufVariant ?? null;
const previousModel = previousCheckpoint
? models.find((entry) => entry.id === previousCheckpoint)
: undefined;
const previousLora = previousCheckpoint
? loras.find((entry) => entry.id === previousCheckpoint)
: undefined;
const previousIsLora =
previousModel?.isLora ?? (previousLora ? true : false);
const loadingDescription = [
currentCheckpoint ? "Unloading previous model first." : null,
extraLoadingDescription ?? null,
@ -189,24 +201,59 @@ export function useChatModelRuntime() {
setLoadingModel({ id: modelId, displayName });
try {
async function performLoad(): Promise<void> {
let previousWasUnloaded = false;
const currentCheckpoint =
useChatRuntimeStore.getState().params.checkpoint;
if (currentCheckpoint) {
await unloadModel({ model_path: currentCheckpoint });
try {
// Lightweight pre-flight validation: avoid unloading a working model
// if the new identifier is clearly invalid (e.g. bad HF id / path).
await validateModel({
model_path: modelId,
hf_token: null,
max_seq_length: DEFAULT_MODEL_MAX_SEQ_LENGTH,
load_in_4bit: true,
is_lora: isLora,
gguf_variant: ggufVariant ?? null,
});
if (currentCheckpoint) {
await unloadModel({ model_path: currentCheckpoint });
previousWasUnloaded = true;
}
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,
gguf_variant: ggufVariant ?? null,
});
const currentParams = useChatRuntimeStore.getState().params;
setParams(
mergeRecommendedInference(currentParams, loadResponse, modelId),
);
await refresh();
} catch (error) {
// If we unloaded a previous model and the new load failed, attempt a rollback.
if (previousWasUnloaded && previousCheckpoint) {
try {
await loadModel({
model_path: previousCheckpoint,
hf_token: null,
max_seq_length: DEFAULT_MODEL_MAX_SEQ_LENGTH,
load_in_4bit: true,
is_lora: previousIsLora,
gguf_variant: previousVariant,
});
await refresh();
} catch {
// If rollback also fails, surface the original error.
}
}
throw error;
}
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,
gguf_variant: ggufVariant ?? null,
});
const currentParams = useChatRuntimeStore.getState().params;
setParams(mergeRecommendedInference(currentParams, loadResponse, modelId));
await refresh();
}
const loadPromise = performLoad().finally(() => {

View file

@ -33,6 +33,16 @@ export interface LoadModelRequest {
gguf_variant?: string | null;
}
export interface ValidateModelResponse {
valid: boolean;
message: string;
identifier?: string | null;
display_name?: string | null;
is_gguf?: boolean;
is_lora?: boolean;
is_vision?: boolean;
}
export interface GgufVariantDetail {
filename: string;
quant: string;