Fiabilise le préchargement et le benchmark

This commit is contained in:
Michael committed 2026-07-07 15:25:12 +02:00
1 parent d9169a1d27
commit 0f6abf6dde
7 files changed
+154 -49

No files matched your search

+9 -4
View File
@@ -17,7 +17,7 @@ from typing import AsyncIterator
import httpx import httpx
from . import db from . import db
from .ollama_client import ollama from .ollama_client import OllamaError, ollama
BENCH_KEY = "bench" # config[bench] = {model: {score, details, at}} BENCH_KEY = "bench" # config[bench] = {model: {score, details, at}}
@@ -155,8 +155,9 @@ async def _task_json(model: str) -> tuple[int, str]:
return 5, "JSON invalide" return 5, "JSON invalide"
ok_nom = "marie" in str(data.get("nom", "")).lower() ok_nom = "marie" in str(data.get("nom", "")).lower()
ok_ville = "lyon" in str(data.get("ville", "")).lower() ok_ville = "lyon" in str(data.get("ville", "")).lower()
return 10 + 5 * ok_nom + 5 * ok_ville, "extraction correcte" \ score = 10 + 5 * ok_nom + 5 * ok_ville
if ok_nom and ok_ville else (10 + 5 * ok_nom + 5 * ok_ville, "extraction partielle") detail = "extraction correcte" if ok_nom and ok_ville else "extraction partielle"
return score, detail
async def _task_format(model: str) -> tuple[int, str]: async def _task_format(model: str) -> tuple[int, str]:
@@ -190,8 +191,12 @@ async def run_bench(model: str) -> AsyncIterator[dict]:
yield {"type": "task_start", "task": name} yield {"type": "task_start", "task": name}
try: try:
score, detail = await fn(model) score, detail = await fn(model)
except (httpx.HTTPError, OSError) as exc: except (OllamaError, httpx.HTTPError, OSError) as exc:
score, detail = 0, f"erreur : {str(exc)[:80]}" score, detail = 0, f"erreur : {str(exc)[:80]}"
except Exception as exc:
# Une épreuve défaillante ne doit pas couper silencieusement le SSE :
# elle vaut zéro et les autres épreuves continuent.
score, detail = 0, f"épreuve interrompue : {str(exc)[:80]}"
total += score total += score
details.append({"task": name, "score": score, "detail": detail}) details.append({"task": name, "score": score, "detail": detail})
yield {"type": "task_done", "task": name, "score": score, "detail": detail} yield {"type": "task_done", "task": name, "score": score, "detail": detail}
+1 -1
View File
@@ -125,7 +125,7 @@ class OllamaClient:
async with httpx.AsyncClient(timeout=120.0, follow_redirects=True) as client: async with httpx.AsyncClient(timeout=120.0, follow_redirects=True) as client:
resp = await client.post( resp = await client.post(
f"{self.host}/api/generate", f"{self.host}/api/generate",
json={"model": model, "keep_alive": keep_alive}, json={"model": model, "keep_alive": keep_alive, "stream": False},
) )
resp.raise_for_status() resp.raise_for_status()
return resp.json() return resp.json()
+10 -3
View File
@@ -24,9 +24,16 @@ async def scores() -> dict:
@router.post("") @router.post("")
async def run(req: BenchRequest) -> StreamingResponse: async def run(req: BenchRequest) -> StreamingResponse:
async def event_stream(): async def event_stream():
async for ev in bench.run_bench(req.model): try:
etype = ev.pop("type") async for ev in bench.run_bench(req.model):
yield f"event: {etype}\ndata: {json.dumps(ev, ensure_ascii=False)}\n\n" etype = ev.pop("type")
yield f"event: {etype}\ndata: {json.dumps(ev, ensure_ascii=False)}\n\n"
except Exception as exc:
payload = json.dumps(
{"message": f"benchmark interrompu : {str(exc)[:200]}"},
ensure_ascii=False,
)
yield f"event: error\ndata: {payload}\n\n"
return StreamingResponse( return StreamingResponse(
event_stream(), event_stream(),
+43 -27
View File
@@ -47,16 +47,24 @@ export interface LoadedModel {
gpu_percent: number; gpu_percent: number;
} }
/** Précharge un modèle en VRAM (best-effort, ne lève jamais). */ async function apiError(res: Response, fallback: string): Promise<Error> {
export async function warmModel(name: string, keepAlive = "30m"): Promise<void> {
try { try {
await fetch("/api/models/warm", { const payload = await res.json();
method: "POST", return new Error(payload?.detail ?? payload?.error ?? fallback);
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, keep_alive: keepAlive }),
});
} catch { } catch {
/* préchargement best-effort */ return new Error(fallback);
}
}
/** Précharge un modèle en mémoire et remonte toute erreur à l'interface. */
export async function warmModel(name: string, keepAlive = "30m"): Promise<void> {
const res = await fetch("/api/models/warm", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, keep_alive: keepAlive }),
});
if (!res.ok) {
throw await apiError(res, `préchargement refusé (${res.status})`);
} }
} }
@@ -175,39 +183,47 @@ export async function runBench(
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ model }), body: JSON.stringify({ model }),
}); });
if (!res.body) return null; if (!res.ok) {
throw await apiError(res, `benchmark refusé (${res.status})`);
}
if (!res.body) throw new Error("le serveur n'a pas renvoyé de progression");
const reader = res.body.getReader(); const reader = res.body.getReader();
const decoder = new TextDecoder(); const decoder = new TextDecoder();
let buffer = ""; let buffer = "";
let final: BenchResult | null = null; let final: BenchResult | null = null;
const dispatch = (block: string) => {
let event = "message";
const dataLines: string[] = [];
for (const line of block.replace(/\r\n/g, "\n").split("\n")) {
if (line.startsWith("event:")) event = line.slice(6).trim();
else if (line.startsWith("data:")) dataLines.push(line.slice(5).trimStart());
}
if (dataLines.length === 0) return;
const payload = JSON.parse(dataLines.join("\n"));
if (event === "task_start") onProgress(payload.task, null);
else if (event === "task_done")
onProgress(payload.task, payload.score, payload.detail);
else if (event === "error")
throw new Error(payload.message ?? "le benchmark a échoué");
else if (event === "done")
final = { score: payload.score, details: payload.details, at: Date.now() / 1000 };
};
while (true) { while (true) {
const { done, value } = await reader.read(); const { done, value } = await reader.read();
if (done) break; if (done) break;
buffer += decoder.decode(value, { stream: true }); buffer += decoder.decode(value, { stream: true }).replace(/\r\n/g, "\n");
const events = buffer.split("\n\n"); const events = buffer.split("\n\n");
buffer = events.pop() ?? ""; buffer = events.pop() ?? "";
for (const block of events) { for (const block of events) {
let event = ""; if (block.trim()) dispatch(block);
let data = "";
for (const line of block.split("\n")) {
if (line.startsWith("event: ")) event = line.slice(7).trim();
else if (line.startsWith("data: ")) data += line.slice(6);
}
if (!data) continue;
try {
const payload = JSON.parse(data);
if (event === "task_start") onProgress(payload.task, null);
else if (event === "task_done")
onProgress(payload.task, payload.score, payload.detail);
else if (event === "done")
final = { score: payload.score, details: payload.details, at: Date.now() / 1000 };
} catch {
/* bloc partiel */
}
} }
} }
buffer += decoder.decode();
if (buffer.trim()) dispatch(buffer);
if (!final) throw new Error("le benchmark s'est interrompu avant le résultat");
return final; return final;
} }
+16 -3
View File
@@ -4,7 +4,14 @@ import { ChevronDown } from "./Icon";
/** Sélecteur de modèle Ollama (chip orange de la barre supérieure). */ /** Sélecteur de modèle Ollama (chip orange de la barre supérieure). */
export function ModelSelector() { export function ModelSelector() {
const { models, selectedModel, setSelectedModel, loadedModels } = useStore(); const {
models,
selectedModel,
setSelectedModel,
loadedModels,
warmingModel,
warmError,
} = useStore();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
@@ -30,14 +37,20 @@ export function ModelSelector() {
> >
<span <span
className={`h-2.5 w-2.5 border-2 border-line ${ className={`h-2.5 w-2.5 border-2 border-line ${
selectedLoaded warmingModel === selectedModel
? "bg-accent animate-pulse"
: selectedLoaded
? selectedLoaded.on_gpu ? selectedLoaded.on_gpu
? "bg-ok" ? "bg-ok"
: "bg-warn" : "bg-warn"
: "bg-white" : "bg-white"
}`} }`}
title={ title={
selectedLoaded warmError
? warmError
: warmingModel === selectedModel
? "préchargement en cours…"
: selectedLoaded
? selectedLoaded.on_gpu ? selectedLoaded.on_gpu
? "chargé sur GPU" ? "chargé sur GPU"
: `chargé (${selectedLoaded.gpu_percent}% GPU)` : `chargé (${selectedLoaded.gpu_percent}% GPU)`
+42
View File
@@ -23,6 +23,9 @@ export function SettingsView() {
selectedModel, selectedModel,
setSelectedModel, setSelectedModel,
refreshModels, refreshModels,
loadedModels,
warmingModel,
warmError,
status, status,
config, config,
availableTools, availableTools,
@@ -153,6 +156,8 @@ export function SettingsView() {
)} )}
{models.map((m) => { {models.map((m) => {
const on = m.name === selectedModel; const on = m.name === selectedModel;
const loaded = loadedModels.find((item) => item.name === m.name);
const warming = warmingModel === m.name;
return ( return (
<div <div
key={m.name} key={m.name}
@@ -187,6 +192,28 @@ export function SettingsView() {
.filter(Boolean) .filter(Boolean)
.join(" · ")} .join(" · ")}
</div> </div>
<div className="mt-1.5 flex items-center gap-1.5 text-[11px]">
<span
className={`h-2 w-2 border border-line ${
warming
? "bg-accent animate-pulse"
: loaded?.on_gpu
? "bg-ok"
: loaded
? "bg-warn"
: "bg-card"
}`}
/>
<span className={on ? "text-on-dark-2" : "text-muted-2"}>
{warming
? "Préchargement en cours…"
: loaded?.on_gpu
? "Chargé sur GPU"
: loaded
? `Chargé · ${loaded.gpu_percent}% GPU`
: "Non chargé"}
</span>
</div>
</div> </div>
</button> </button>
<button <button
@@ -205,6 +232,11 @@ export function SettingsView() {
{deleteError} {deleteError}
</div> </div>
)} )}
{warmError && (
<div className="border-2 border-warn bg-base px-3 py-2 text-[12px] text-warn">
Préchargement : {warmError}
</div>
)}
</div> </div>
<div className="my-4 h-[3px] bg-line" /> <div className="my-4 h-[3px] bg-line" />
@@ -461,6 +493,7 @@ function BenchCard() {
const selectedModel = useStore((s) => s.selectedModel); const selectedModel = useStore((s) => s.selectedModel);
const [scores, setScores] = useState<Record<string, BenchResult>>({}); const [scores, setScores] = useState<Record<string, BenchResult>>({});
const [running, setRunning] = useState(false); const [running, setRunning] = useState(false);
const [error, setError] = useState<string | null>(null);
const [progress, setProgress] = useState< const [progress, setProgress] = useState<
{ task: string; score: number | null; detail?: string }[] { task: string; score: number | null; detail?: string }[]
>([]); >([]);
@@ -472,6 +505,7 @@ function BenchCard() {
const launch = async () => { const launch = async () => {
if (!selectedModel || running) return; if (!selectedModel || running) return;
setRunning(true); setRunning(true);
setError(null);
setProgress([]); setProgress([]);
try { try {
const result = await runBench(selectedModel, (task, score, detail) => { const result = await runBench(selectedModel, (task, score, detail) => {
@@ -481,6 +515,8 @@ function BenchCard() {
}); });
}); });
if (result) setScores((s) => ({ ...s, [selectedModel]: result })); if (result) setScores((s) => ({ ...s, [selectedModel]: result }));
} catch (err) {
setError(err instanceof Error ? err.message : "benchmark impossible");
} finally { } finally {
setRunning(false); setRunning(false);
} }
@@ -524,6 +560,12 @@ function BenchCard() {
</div> </div>
)} )}
{error && (
<div className="border-2 border-warn bg-base px-3 py-2 text-[12px] text-warn">
Échec du test : {error}
</div>
)}
{!running && current && ( {!running && current && (
<div className="border-2 border-line bg-base px-3 py-2"> <div className="border-2 border-line bg-base px-3 py-2">
<div className="mb-1 flex items-center justify-between"> <div className="mb-1 flex items-center justify-between">
+33 -11
View File
@@ -30,6 +30,8 @@ interface LokiState {
status: OllamaStatus | null; status: OllamaStatus | null;
systemStats: SystemStats | null; systemStats: SystemStats | null;
loadedModels: LoadedModel[]; loadedModels: LoadedModel[];
warmingModel: string | null;
warmError: string | null;
models: OllamaModel[]; models: OllamaModel[];
selectedModel: string; selectedModel: string;
loadingModels: boolean; loadingModels: boolean;
@@ -81,6 +83,8 @@ export const useStore = create<LokiState>((set, get) => ({
status: null, status: null,
systemStats: null, systemStats: null,
loadedModels: [], loadedModels: [],
warmingModel: null,
warmError: null,
models: [], models: [],
selectedModel: "", selectedModel: "",
loadingModels: false, loadingModels: false,
@@ -129,10 +133,11 @@ export const useStore = create<LokiState>((set, get) => ({
}, },
refreshConfig: async () => { refreshConfig: async () => {
const { config, available_tools } = await getConfig( const model = get().selectedModel;
get().selectedModel || undefined const { config, available_tools } = await getConfig(model || undefined);
); if (get().selectedModel === model) {
set({ config, availableTools: available_tools }); set({ config, availableTools: available_tools });
}
}, },
updateConfig: async (patch) => { updateConfig: async (patch) => {
@@ -146,11 +151,26 @@ export const useStore = create<LokiState>((set, get) => ({
}, },
setSelectedModel: (name) => { setSelectedModel: (name) => {
set({ selectedModel: name }); set({ selectedModel: name, warmingModel: name || null, warmError: null });
void get().refreshConfig(); void (async () => {
// Préchargement : le modèle est chargé en VRAM dès la sélection. try {
const ka = get().config?.keep_alive ?? "30m"; await get().refreshConfig();
void warmModel(name, ka).then(() => get().refreshLoadedModels()); if (!name || get().selectedModel !== name) return;
// La configuration est propre au modèle : on attend son chargement avant
// d'utiliser keep_alive, sinon la valeur du modèle précédent est envoyée.
const ka = get().config?.keep_alive ?? "30m";
await warmModel(name, ka);
await get().refreshLoadedModels();
} catch (err) {
if (get().selectedModel === name) {
set({
warmError: err instanceof Error ? err.message : "préchargement impossible",
});
}
} finally {
if (get().warmingModel === name) set({ warmingModel: null });
}
})();
}, },
refreshFiles: async () => { refreshFiles: async () => {
@@ -193,8 +213,10 @@ export const useStore = create<LokiState>((set, get) => ({
: installed.has(def) : installed.has(def)
? def ? def
: models[0]?.name ?? ""; : models[0]?.name ?? "";
set({ models, selectedModel }); set({ models });
await get().refreshConfig(); // Passe par l'action de sélection afin de précharger aussi le modèle choisi
// automatiquement au démarrage (défaut ou premier modèle installé).
get().setSelectedModel(selectedModel);
} finally { } finally {
set({ loadingModels: false }); set({ loadingModels: false });
} }