mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-11 17:26:57 +02:00
Fiabilise le préchargement et le benchmark
This commit is contained in:
1 parent
d9169a1d27
commit
0f6abf6dde
7 files changed
+154
-49
No files matched your search
@@ -17,7 +17,7 @@ from typing import AsyncIterator
|
||||
import httpx
|
||||
|
||||
from . import db
|
||||
from .ollama_client import ollama
|
||||
from .ollama_client import OllamaError, ollama
|
||||
|
||||
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"
|
||||
ok_nom = "marie" in str(data.get("nom", "")).lower()
|
||||
ok_ville = "lyon" in str(data.get("ville", "")).lower()
|
||||
return 10 + 5 * ok_nom + 5 * ok_ville, "extraction correcte" \
|
||||
if ok_nom and ok_ville else (10 + 5 * ok_nom + 5 * ok_ville, "extraction partielle")
|
||||
score = 10 + 5 * ok_nom + 5 * ok_ville
|
||||
detail = "extraction correcte" if ok_nom and ok_ville else "extraction partielle"
|
||||
return score, detail
|
||||
|
||||
|
||||
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}
|
||||
try:
|
||||
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]}"
|
||||
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
|
||||
details.append({"task": name, "score": score, "detail": detail})
|
||||
yield {"type": "task_done", "task": name, "score": score, "detail": detail}
|
||||
|
||||
@@ -125,7 +125,7 @@ class OllamaClient:
|
||||
async with httpx.AsyncClient(timeout=120.0, follow_redirects=True) as client:
|
||||
resp = await client.post(
|
||||
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()
|
||||
return resp.json()
|
||||
|
||||
@@ -24,9 +24,16 @@ async def scores() -> dict:
|
||||
@router.post("")
|
||||
async def run(req: BenchRequest) -> StreamingResponse:
|
||||
async def event_stream():
|
||||
async for ev in bench.run_bench(req.model):
|
||||
etype = ev.pop("type")
|
||||
yield f"event: {etype}\ndata: {json.dumps(ev, ensure_ascii=False)}\n\n"
|
||||
try:
|
||||
async for ev in bench.run_bench(req.model):
|
||||
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(
|
||||
event_stream(),
|
||||
|
||||
+43
-27
@@ -47,16 +47,24 @@ export interface LoadedModel {
|
||||
gpu_percent: number;
|
||||
}
|
||||
|
||||
/** Précharge un modèle en VRAM (best-effort, ne lève jamais). */
|
||||
export async function warmModel(name: string, keepAlive = "30m"): Promise<void> {
|
||||
async function apiError(res: Response, fallback: string): Promise<Error> {
|
||||
try {
|
||||
await fetch("/api/models/warm", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name, keep_alive: keepAlive }),
|
||||
});
|
||||
const payload = await res.json();
|
||||
return new Error(payload?.detail ?? payload?.error ?? fallback);
|
||||
} 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" },
|
||||
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 decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
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) {
|
||||
const { done, value } = await reader.read();
|
||||
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");
|
||||
buffer = events.pop() ?? "";
|
||||
for (const block of events) {
|
||||
let event = "";
|
||||
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 */
|
||||
}
|
||||
if (block.trim()) dispatch(block);
|
||||
}
|
||||
}
|
||||
buffer += decoder.decode();
|
||||
if (buffer.trim()) dispatch(buffer);
|
||||
if (!final) throw new Error("le benchmark s'est interrompu avant le résultat");
|
||||
return final;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,14 @@ import { ChevronDown } from "./Icon";
|
||||
|
||||
/** Sélecteur de modèle Ollama (chip orange de la barre supérieure). */
|
||||
export function ModelSelector() {
|
||||
const { models, selectedModel, setSelectedModel, loadedModels } = useStore();
|
||||
const {
|
||||
models,
|
||||
selectedModel,
|
||||
setSelectedModel,
|
||||
loadedModels,
|
||||
warmingModel,
|
||||
warmError,
|
||||
} = useStore();
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -30,14 +37,20 @@ export function ModelSelector() {
|
||||
>
|
||||
<span
|
||||
className={`h-2.5 w-2.5 border-2 border-line ${
|
||||
selectedLoaded
|
||||
warmingModel === selectedModel
|
||||
? "bg-accent animate-pulse"
|
||||
: selectedLoaded
|
||||
? selectedLoaded.on_gpu
|
||||
? "bg-ok"
|
||||
: "bg-warn"
|
||||
: "bg-white"
|
||||
}`}
|
||||
title={
|
||||
selectedLoaded
|
||||
warmError
|
||||
? warmError
|
||||
: warmingModel === selectedModel
|
||||
? "préchargement en cours…"
|
||||
: selectedLoaded
|
||||
? selectedLoaded.on_gpu
|
||||
? "chargé sur GPU"
|
||||
: `chargé (${selectedLoaded.gpu_percent}% GPU)`
|
||||
|
||||
@@ -23,6 +23,9 @@ export function SettingsView() {
|
||||
selectedModel,
|
||||
setSelectedModel,
|
||||
refreshModels,
|
||||
loadedModels,
|
||||
warmingModel,
|
||||
warmError,
|
||||
status,
|
||||
config,
|
||||
availableTools,
|
||||
@@ -153,6 +156,8 @@ export function SettingsView() {
|
||||
)}
|
||||
{models.map((m) => {
|
||||
const on = m.name === selectedModel;
|
||||
const loaded = loadedModels.find((item) => item.name === m.name);
|
||||
const warming = warmingModel === m.name;
|
||||
return (
|
||||
<div
|
||||
key={m.name}
|
||||
@@ -187,6 +192,28 @@ export function SettingsView() {
|
||||
.filter(Boolean)
|
||||
.join(" · ")}
|
||||
</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>
|
||||
</button>
|
||||
<button
|
||||
@@ -205,6 +232,11 @@ export function SettingsView() {
|
||||
{deleteError}
|
||||
</div>
|
||||
)}
|
||||
{warmError && (
|
||||
<div className="border-2 border-warn bg-base px-3 py-2 text-[12px] text-warn">
|
||||
Préchargement : {warmError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="my-4 h-[3px] bg-line" />
|
||||
@@ -461,6 +493,7 @@ function BenchCard() {
|
||||
const selectedModel = useStore((s) => s.selectedModel);
|
||||
const [scores, setScores] = useState<Record<string, BenchResult>>({});
|
||||
const [running, setRunning] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [progress, setProgress] = useState<
|
||||
{ task: string; score: number | null; detail?: string }[]
|
||||
>([]);
|
||||
@@ -472,6 +505,7 @@ function BenchCard() {
|
||||
const launch = async () => {
|
||||
if (!selectedModel || running) return;
|
||||
setRunning(true);
|
||||
setError(null);
|
||||
setProgress([]);
|
||||
try {
|
||||
const result = await runBench(selectedModel, (task, score, detail) => {
|
||||
@@ -481,6 +515,8 @@ function BenchCard() {
|
||||
});
|
||||
});
|
||||
if (result) setScores((s) => ({ ...s, [selectedModel]: result }));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "benchmark impossible");
|
||||
} finally {
|
||||
setRunning(false);
|
||||
}
|
||||
@@ -524,6 +560,12 @@ function BenchCard() {
|
||||
</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 && (
|
||||
<div className="border-2 border-line bg-base px-3 py-2">
|
||||
<div className="mb-1 flex items-center justify-between">
|
||||
|
||||
@@ -30,6 +30,8 @@ interface LokiState {
|
||||
status: OllamaStatus | null;
|
||||
systemStats: SystemStats | null;
|
||||
loadedModels: LoadedModel[];
|
||||
warmingModel: string | null;
|
||||
warmError: string | null;
|
||||
models: OllamaModel[];
|
||||
selectedModel: string;
|
||||
loadingModels: boolean;
|
||||
@@ -81,6 +83,8 @@ export const useStore = create<LokiState>((set, get) => ({
|
||||
status: null,
|
||||
systemStats: null,
|
||||
loadedModels: [],
|
||||
warmingModel: null,
|
||||
warmError: null,
|
||||
models: [],
|
||||
selectedModel: "",
|
||||
loadingModels: false,
|
||||
@@ -129,10 +133,11 @@ export const useStore = create<LokiState>((set, get) => ({
|
||||
},
|
||||
|
||||
refreshConfig: async () => {
|
||||
const { config, available_tools } = await getConfig(
|
||||
get().selectedModel || undefined
|
||||
);
|
||||
set({ config, availableTools: available_tools });
|
||||
const model = get().selectedModel;
|
||||
const { config, available_tools } = await getConfig(model || undefined);
|
||||
if (get().selectedModel === model) {
|
||||
set({ config, availableTools: available_tools });
|
||||
}
|
||||
},
|
||||
|
||||
updateConfig: async (patch) => {
|
||||
@@ -146,11 +151,26 @@ export const useStore = create<LokiState>((set, get) => ({
|
||||
},
|
||||
|
||||
setSelectedModel: (name) => {
|
||||
set({ selectedModel: name });
|
||||
void get().refreshConfig();
|
||||
// Préchargement : le modèle est chargé en VRAM dès la sélection.
|
||||
const ka = get().config?.keep_alive ?? "30m";
|
||||
void warmModel(name, ka).then(() => get().refreshLoadedModels());
|
||||
set({ selectedModel: name, warmingModel: name || null, warmError: null });
|
||||
void (async () => {
|
||||
try {
|
||||
await get().refreshConfig();
|
||||
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 () => {
|
||||
@@ -193,8 +213,10 @@ export const useStore = create<LokiState>((set, get) => ({
|
||||
: installed.has(def)
|
||||
? def
|
||||
: models[0]?.name ?? "";
|
||||
set({ models, selectedModel });
|
||||
await get().refreshConfig();
|
||||
set({ models });
|
||||
// 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 {
|
||||
set({ loadingModels: false });
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user