feat(llm): ein Modell pro Karte, Clients waehlen nur aus — und Doku fuer Drittprogramme
Bisher konnte jeder Client per Modellname llama-swap zum Wechseln bringen — ein Fremdprogramm haette ARIA das Modell unter den Fuessen weggetauscht. Jetzt: - Jede LLM-Karte einer Box (LLM_GPUS=1,2, alt LLM_GPU=1) haelt GENAU ein Modell, dauerhaft. Welches, legt nur die Diagnostic fest. Karten, die nicht fuers LLM vorgesehen sind, tauchen beim LLM nicht auf und werden als Ziel abgelehnt. - Clients (ARIA, Fremdprogramme ueber RVS, OpenHands ueber den OpenAI-Port) sehen nur die geladenen Modelle und laden nie: Anfrage fuer ein anderes → model_not_loaded mit Liste des Geladenen (RVS) bzw. HTTP 404 (OpenAI-Port). Nichts wird verdraengt. - Mehrere Clients koennen dasselbe Modell gleichzeitig nutzen. llm-adapter: Belegung in /models/aria_slots.json. Die llama-swap-Config enthaelt nur belegte Karten: pro Modell CUDA_VISIBLE_DEVICES=<Karte>, ttl 0, groups.swap=false, hooks.on_startup.preload. Alles gegen llama-swap ausprobiert (zwei Modelle gleichzeitig ready, keins verdraengt, Unbekanntes 404, env kommt beim jeweiligen llama-server an). llama-swap laeuft mit --watch-config; die frueher aufgerufenen Reload-Endpunkte (/reload, /api/config/reload) gibt es nicht (404). Neu: llm_unload_model (Karte leeren), llm_models_request → llm_models (Erkundung, jede Box antwortet einzeln). Fehlgeschlagenes Laden setzt die Karte auf ihr vorheriges Modell zurueck, sonst risse jedes Neuladen die anderen Karten mit. Uebernahme: ohne Belegungsdatei landet LLM_MODEL auf der ersten LLM-Karte. Compose: llama-swap sieht alle Karten (NVIDIA_VISIBLE_DEVICES=all) und zaehlt sie wie nvidia-smi (CUDA_DEVICE_ORDER=PCI_BUS_ID); das Pinnen pro Modell macht der Adapter. Bridge: uebernimmt models/slots auch aus worker_ping (nach "Entladen" sonst 30 s veraltet) und sagt sofort ab, wenn das Modell auf keiner Box liegt, statt per Broadcast in den Timeout zu laufen. Diagnostic: Flotte zeigt pro LLM-Karte Modell + Zustand + "Entladen"; Katalog waehlt eine Ziel-KARTE (leere vorausgewaehlt, Ersetzen mit Rueckfrage); Download-Fenster nennt Karte und was ersetzt wird. RVS: llm_unload_model, llm_models_request, llm_models freigegeben. docs/LLM-SCHNITTSTELLE.md: Schnittstelle fuer Drittprogramme (RVS + OpenAI- Port), Fehlercodes, Gleichzeitigkeit, Python-Beispiel. Tests: 23 gegen llama-swap (Adapter direkt) + Ende-zu-Ende ueber den echten RVS mit dem Adapter als Prozess: Laden per RVS mit Fortschritt, das Doku-Beispiel unveraendert (nur Adresse) findet beide Karten und bekommt eine Antwort, Absage/Schweigen wie dokumentiert, zwei Clients parallel. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+86
-26
@@ -1873,9 +1873,10 @@
|
||||
</div>
|
||||
<div id="llm-dl-list" style="padding:14px;overflow-y:auto;display:flex;flex-direction:column;gap:12px;"></div>
|
||||
<div style="padding:0 14px 12px;color:#555570;font-size:11px;line-height:1.5;">
|
||||
Pro LLM-Box läuft immer <strong style="color:#8888AA;">ein</strong> Modell auf ihrer LLM-Karte
|
||||
(<code>LLM_GPU</code> in der .env der Box). Laden eines anderen wechselt dorthin — das vorige
|
||||
wird entladen, bleibt aber heruntergeladen.
|
||||
Jede LLM-Karte hält genau <strong style="color:#8888AA;">ein</strong> Modell, dauerhaft. Welche
|
||||
Karten dem LLM gehören, steht in der .env der Box (<code>LLM_GPUS</code>). Laden ersetzt, was auf
|
||||
der Karte lag; die anderen Karten der Box starten dabei kurz neu. Clients (ARIA, Fremdprogramme,
|
||||
OpenHands) wählen nur unter den geladenen Modellen und laden nie selbst.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2609,6 +2610,7 @@
|
||||
const st = document.getElementById('llm-catalog-status');
|
||||
if (st) {
|
||||
if (p.removed) { st.textContent = `✓ '${p.key}' entfernt`; st.style.color = '#8888AA'; }
|
||||
else if (p.unloaded) { st.textContent = `✓ Karte ${p.gpu} geleert (${p.key})`; st.style.color = '#8888AA'; }
|
||||
else if (p.ok) { st.textContent = `✓ '${p.key}' geladen/aktiv auf ${p.instanceId || '?'}`; st.style.color = '#3FFF3F'; }
|
||||
else { st.textContent = `✗ '${p.key}': ${p.error || 'Fehler'}`; st.style.color = '#FF6B6B'; }
|
||||
}
|
||||
@@ -5156,21 +5158,15 @@
|
||||
const meta = WORKER_SVC_META[w.service] || { icon: '⚙️', label: w.service };
|
||||
const dot = !w.online ? '#666' : (w.busy ? '#FFB020' : '#3FFF3F');
|
||||
const stat = !w.online ? 'offline' : (w.busy ? 'beschaeftigt' : 'frei – idle – keine Auslastung');
|
||||
// llm-Boxen koennen mehrere Modelle FAHREN (llama-swap), aber nur
|
||||
// eins liegt gerade in der Karte — das ist gruen markiert. Die
|
||||
// anderen werden beim ersten Aufruf geladen (und verdraengen es).
|
||||
// LLM-Boxen: pro Karte genau ein Modell, dauerhaft geladen. Laden und
|
||||
// Entladen nur hier (Modell-Katalog), Clients waehlen nur aus.
|
||||
let modelHtml;
|
||||
if (w.service === 'llm' && Array.isArray(w.models) && w.models.length) {
|
||||
const loaded = Array.isArray(w.loaded) ? w.loaded : null;
|
||||
const isLoaded = id => loaded && loaded.some(l => l.model === id);
|
||||
const state = id => (loaded.find(l => l.model === id) || {}).state;
|
||||
modelHtml = w.models.map(id => isLoaded(id)
|
||||
? '<span style="color:#3FFF3F;font-weight:600;" title="liegt gerade in der Karte">' + escapeHtml(id)
|
||||
+ (state(id) && state(id) !== 'ready' ? ' (' + escapeHtml(state(id)) + ' …)' : ' ● geladen') + '</span>'
|
||||
: '<span style="color:#8888AA;" title="lädt beim ersten Aufruf und verdrängt das geladene">' + escapeHtml(id) + '</span>'
|
||||
).join('<span style="color:#555570;">, </span>');
|
||||
if (loaded && !loaded.length) modelHtml += ' <span style="color:#6a6a88;">— keins geladen (lädt beim ersten Aufruf)</span>';
|
||||
else if (!loaded && w.online) modelHtml += ' <span style="color:#6a6a88;" title="Box meldet es nicht — Adapter noch alt oder llama-swap nicht erreichbar">— geladen: ?</span>';
|
||||
if (w.service === 'llm' && Array.isArray(w.slots)) {
|
||||
modelHtml = ''; // Karten folgen als eigene Zeilen unten
|
||||
} else if (w.service === 'llm' && Array.isArray(w.models) && w.models.length) {
|
||||
// Box mit altem Adapter (ohne Karten): nur die Liste
|
||||
modelHtml = '<span style="color:#8888AA;" title="Box meldet noch keine Karten — llm-adapter aktualisieren">'
|
||||
+ escapeHtml(w.models.join(', ')) + '</span>';
|
||||
} else {
|
||||
modelHtml = '<span style="color:#8888AA;">' + escapeHtml(w.model || '') + '</span>';
|
||||
}
|
||||
@@ -5178,9 +5174,10 @@
|
||||
'<span style="width:8px;height:8px;border-radius:50%;background:' + dot + ';display:inline-block;"></span>' +
|
||||
'<span>' + meta.icon + ' <b>' + escapeHtml(meta.label) + '</b></span>' +
|
||||
'<span>' + modelHtml + '</span>' +
|
||||
(w.gpus ? '<span style="color:#8888AA;">GPU ' + escapeHtml(w.gpus) + '</span>' : '') +
|
||||
(w.gpus && !(w.service === 'llm' && Array.isArray(w.slots)) ? '<span style="color:#8888AA;">GPU ' + escapeHtml(w.gpus) + '</span>' : '') +
|
||||
'<span style="margin-left:auto;color:' + dot + ';">' + stat + '</span>' +
|
||||
'</div>' +
|
||||
(w.service === 'llm' && Array.isArray(w.slots) ? renderLlmSlots(w) : '') +
|
||||
// Nur LLM-Boxen: OpenAI-Endpunkt fuer Fremd-Tools (OpenHands & Co.)
|
||||
(w.service === 'llm' ? renderOpenAiEndpoint(w) : '');
|
||||
}).join('');
|
||||
@@ -8869,7 +8866,9 @@
|
||||
const order = Array.from(ids).sort();
|
||||
sel.innerHTML = order.map(id => {
|
||||
const n = counts[id] || 0;
|
||||
const avail = n > 0 ? ` · ${n} Box${n > 1 ? 'en' : ''}` : ' · offline';
|
||||
// Nur geladene Modelle zaehlen (Karten-Belegung) — alles andere kann
|
||||
// ARIA nicht nutzen; laden geht im Modell-Katalog.
|
||||
const avail = n > 0 ? ` · geladen auf ${n} Box${n > 1 ? 'en' : ''}` : ' · nicht geladen';
|
||||
return `<option value="${id}">${escapeHtml(nameOf(id))}${avail}</option>`;
|
||||
}).join('') || '<option value="">(keine)</option>';
|
||||
if (order.includes(_currentLocalModel)) sel.value = _currentLocalModel;
|
||||
@@ -8966,11 +8965,18 @@
|
||||
const td = 'style="padding:6px 8px;vertical-align:top;"';
|
||||
const rows = _llmCatalog.map((m, i) => {
|
||||
const have = boxesServing(m.id);
|
||||
const haveTxt = have.length ? `<span style="color:#3FFF3F;">✓ ${have.map(b => escapeHtml(b.node)).join(', ')}</span>` : '<span style="color:#6a6a88;">nicht geladen</span>';
|
||||
const where = b => {
|
||||
const sl = (b.slots || []).find(x => x.model === m.id);
|
||||
return escapeHtml(b.node) + (sl ? ' · Karte ' + escapeHtml(sl.gpu) : '');
|
||||
};
|
||||
const haveTxt = have.length ? `<span style="color:#3FFF3F;">✓ ${have.map(where).join(', ')}</span>` : '<span style="color:#6a6a88;">nicht geladen</span>';
|
||||
const vram = m.sizeGB ? '~' + m.sizeGB + ' GB' : '—';
|
||||
const vramStyle = m.sizeGB >= 12 ? 'color:#FFB020;' : 'color:#AAB;';
|
||||
const opts = boxes.length
|
||||
? boxes.map(b => `<option value="${escapeHtml(b.instanceId)}">${escapeHtml(b.node)}</option>`).join('')
|
||||
// Ziel = Karte. Vorauswahl: eine leere Karte, sonst die erste.
|
||||
const targets = llmCardTargets();
|
||||
const pre = (targets.find(t => t.slot && !t.slot.model) || targets[0] || {}).value;
|
||||
const opts = targets.length
|
||||
? targets.map(t => `<option value="${escapeHtml(t.value)}"${t.value === pre ? ' selected' : ''}>${escapeHtml(t.label)}</option>`).join('')
|
||||
: '<option value="">(keine Box online)</option>';
|
||||
return '<tr style="border-bottom:1px solid #1E1E2E;">' +
|
||||
'<td ' + td + 'white-space:nowrap;"><b>' + escapeHtml(m.id) + '</b></td>' +
|
||||
@@ -8984,7 +8990,7 @@
|
||||
box.innerHTML = '<table style="width:100%;border-collapse:collapse;font-size:12px;min-width:760px;">' +
|
||||
'<thead><tr style="color:#8888AA;border-bottom:1px solid #2A2A3E;">' +
|
||||
'<th ' + th + '>Modell</th><th ' + th + '>Kann gut / Beschreibung</th><th ' + th + '>VRAM</th>' +
|
||||
'<th ' + th + '>Status</th><th ' + th + '>Ziel-Box</th><th ' + th + '></th>' +
|
||||
'<th ' + th + '>Status</th><th ' + th + '>Ziel-Karte</th><th ' + th + '></th>' +
|
||||
'</tr></thead><tbody>' + rows + '</tbody></table>';
|
||||
}
|
||||
// ── Download-Fenster ──────────────────────────────────────────────
|
||||
@@ -9029,7 +9035,8 @@
|
||||
return;
|
||||
}
|
||||
box.innerHTML = jobs.map(j => {
|
||||
const box_ = escapeHtml(_boxName(j.instanceId));
|
||||
const box_ = escapeHtml(_boxName(j.instanceId)) + (j.gpu != null && j.gpu !== '' ? ' · Karte ' + escapeHtml(String(j.gpu)) : '')
|
||||
+ (j.replaces ? ' <span style="color:#6a6a88;">(ersetzt ' + escapeHtml(j.replaces) + ')</span>' : '');
|
||||
let pct = 0, color = '#0096FF', line = '', sub = '';
|
||||
if (j.phase === 'queued') {
|
||||
// Kommt hier nie mehr was, erreicht der Auftrag die Box nicht.
|
||||
@@ -9086,15 +9093,68 @@
|
||||
if (Object.values(_llmJobs).some(j => j.phase === 'queued')) renderLlmDownloads();
|
||||
}, 5000);
|
||||
|
||||
// ── LLM-Karten ────────────────────────────────────────────────────
|
||||
const SLOT_STATE = {
|
||||
ready: ['#3FFF3F', '● geladen'], starting: ['#FFD60A', '◐ startet …'],
|
||||
stopping: ['#FF9F0A', 'stoppt …'], stopped: ['#FF6B6B', '✗ nicht gestartet'],
|
||||
unknown: ['#8888AA', '? (llama-swap antwortet nicht)'],
|
||||
};
|
||||
function renderLlmSlots(w) {
|
||||
const rows = (w.slots || []).map(sl => {
|
||||
const card = 'Karte ' + escapeHtml(sl.gpu) + (sl.gpuName ? ' <span style="color:#555570;">(' + escapeHtml(sl.gpuName) + ')</span>' : '');
|
||||
if (!sl.model) {
|
||||
return `<div style="display:flex;gap:10px;align-items:center;padding:2px 0;">
|
||||
<span style="min-width:200px;color:#8888AA;">${card}</span>
|
||||
<span style="color:#555570;">— leer · im Modell-Katalog „Laden" → diese Karte</span></div>`;
|
||||
}
|
||||
const [c, label] = SLOT_STATE[sl.state] || ['#8888AA', escapeHtml(sl.state || '?')];
|
||||
return `<div style="display:flex;gap:10px;align-items:center;padding:2px 0;">
|
||||
<span style="min-width:200px;color:#8888AA;">${card}</span>
|
||||
<b style="color:#E0E0F0;">${escapeHtml(sl.model)}</b>
|
||||
<span style="color:${c};">${label}</span>
|
||||
<button class="btn secondary" onclick="unloadLlmCard('${escapeHtmlAttr(w.instanceId)}','${escapeHtmlAttr(sl.gpu)}','${escapeHtmlAttr(sl.model)}')"
|
||||
title="Karte leeren — das Modell bleibt auf der Box gespeichert" style="margin-left:auto;padding:2px 8px;font-size:10px;">Entladen</button>
|
||||
</div>`;
|
||||
}).join('');
|
||||
return `<div style="margin:2px 0 6px 18px;padding:6px 10px;background:#0D0D18;border-left:2px solid #2A2A3E;font-size:11px;">${rows || '<span style="color:#555570;">Keine LLM-Karte konfiguriert (LLM_GPUS in der .env der Box).</span>'}</div>`;
|
||||
}
|
||||
function unloadLlmCard(instanceId, gpu, model) {
|
||||
if (!confirm(`Karte ${gpu} leeren?\n\n${model} wird entladen; Clients können es danach nicht mehr wählen. `
|
||||
+ `Die Datei bleibt auf der Box — erneutes Laden geht ohne Download.\n\n`
|
||||
+ `Die anderen Karten dieser Box starten dabei kurz neu (wenige Sekunden).`)) return;
|
||||
send({ action: 'llm_unload_model', targetInstance: instanceId, gpu });
|
||||
}
|
||||
// Ziele im Katalog: jede LLM-Karte jeder Box ("instanceId|karte").
|
||||
function llmCardTargets() {
|
||||
const out = [];
|
||||
for (const b of onlineLlmBoxes()) {
|
||||
if (Array.isArray(b.slots) && b.slots.length) {
|
||||
for (const sl of b.slots) out.push({ value: b.instanceId + '|' + sl.gpu, box: b, slot: sl,
|
||||
label: `${b.node} · Karte ${sl.gpu}` + (sl.model ? ` (${sl.model})` : ' (leer)') });
|
||||
} else {
|
||||
out.push({ value: b.instanceId + '|', box: b, slot: null, label: b.node }); // alter Adapter
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function provisionModel(i) {
|
||||
const m = _llmCatalog[i];
|
||||
if (!m) return;
|
||||
const sel = document.getElementById('llm-cat-box-' + i);
|
||||
const target = sel && sel.value;
|
||||
const [target, gpu] = String((sel && sel.value) || '').split('|');
|
||||
if (!target) return;
|
||||
const tgt = llmCardTargets().find(t => t.value === sel.value);
|
||||
if (tgt && tgt.slot && tgt.slot.model === m.id) { alert(`${m.id} liegt schon auf dieser Karte.`); return; }
|
||||
const busyBox = ((tgt && tgt.box.slots) || []).find(sl => sl.model === m.id);
|
||||
if (busyBox) { alert(`${m.id} liegt auf dieser Box schon auf Karte ${busyBox.gpu}. Ein Modell pro Box nur einmal.`); return; }
|
||||
if (tgt && tgt.slot && tgt.slot.model &&
|
||||
!confirm(`Karte ${gpu} auf ${tgt.box.node} hält gerade ${tgt.slot.model}.\n\n`
|
||||
+ `${m.id} ersetzt es. Clients, die ${tgt.slot.model} nutzen, verlieren es dann.\n`
|
||||
+ `Die anderen Karten dieser Box starten dabei kurz neu.\n\nErsetzen?`)) return;
|
||||
const st = document.getElementById('llm-catalog-status');
|
||||
if (st) { st.textContent = `Lade '${m.id}' auf ${target}… (GGUF-Download kann dauern)`; st.style.color = '#FFD60A'; }
|
||||
send({ action: 'llm_provision_model', targetInstance: target, key: m.id, hfRepo: m.hfRepo, quant: m.quant, ctx: m.ctx });
|
||||
send({ action: 'llm_provision_model', targetInstance: target, gpu: gpu || '', key: m.id, hfRepo: m.hfRepo, quant: m.quant, ctx: m.ctx });
|
||||
openLlmDownloads();
|
||||
}
|
||||
function runLlmTest() {
|
||||
|
||||
+16
-5
@@ -1260,9 +1260,11 @@ function connectRVS(forcePlain) {
|
||||
instanceId: p.instanceId, service: p.service || "",
|
||||
node: p.node || "", gpus: p.gpus || "", model: p.model || "",
|
||||
models: Array.isArray(p.models) ? p.models : (p.model ? [p.model] : []),
|
||||
// Was GERADE in der Karte liegt (nur llm, aus llama-swap /running).
|
||||
// null = Box konnte llama-swap nicht fragen, [] = nichts geladen.
|
||||
loaded: Array.isArray(p.loaded) ? p.loaded : (prev.loaded ?? null),
|
||||
// Nur llm: pro LLM-Karte das Modell + Zustand. "models" ist dann nur
|
||||
// noch die Auswahl der GELADENEN Modelle; "knownModels" das, was man
|
||||
// in eine Karte laden koennte.
|
||||
slots: Array.isArray(p.slots) ? p.slots : (prev.slots || null),
|
||||
knownModels: Array.isArray(p.knownModels) ? p.knownModels : (prev.knownModels || []),
|
||||
// OpenAI-Endpunkt (nur llm-Worker): Port/Bind kommen aus der .env der
|
||||
// Box, der Key wird NIE uebertragen — nur ob einer gesetzt ist und ob
|
||||
// die .env ihn vorgibt (dann ist das Feld hier schreibgeschuetzt).
|
||||
@@ -1285,7 +1287,10 @@ function connectRVS(forcePlain) {
|
||||
workers.set(p.instanceId, w);
|
||||
}
|
||||
w.busy = !!p.busy;
|
||||
if ("loaded" in p) w.loaded = Array.isArray(p.loaded) ? p.loaded : null;
|
||||
// LLM-Boxen schicken mit jedem Ping die Karten-Belegung — Laden,
|
||||
// Entladen und Neustarts sind so nach spaetestens 10 s sichtbar.
|
||||
if (Array.isArray(p.slots)) w.slots = p.slots;
|
||||
if (Array.isArray(p.models)) w.models = p.models;
|
||||
w.last_seen = Date.now();
|
||||
broadcastWorkers();
|
||||
}
|
||||
@@ -1377,7 +1382,7 @@ function connectRVS(forcePlain) {
|
||||
} else if (msg.type === "llm_provision_result") {
|
||||
// Ergebnis eines Modell-Downloads/Aktivierens → an Browser (Katalog-Status).
|
||||
const p = msg.payload || {};
|
||||
if (p.key && p.instanceId && !p.removed) {
|
||||
if (p.key && p.instanceId && !p.removed && !p.unloaded) {
|
||||
const job = llmJobUpdate(p.instanceId, p.key, p.ok ? {} : { phase: "error", error: p.error || "Fehler" });
|
||||
if (job) broadcast({ type: "llm_provision_progress", payload: job });
|
||||
}
|
||||
@@ -3371,8 +3376,14 @@ wss.on("connection", (ws) => {
|
||||
sendToRVS_raw({ type: "llm_provision_model", payload: {
|
||||
targetInstance: msg.targetInstance || "",
|
||||
key: msg.key, hfRepo: msg.hfRepo, quant: msg.quant, ctx: msg.ctx, ngl: msg.ngl,
|
||||
gpu: msg.gpu ?? "", // Ziel-Karte auf der Box
|
||||
}, timestamp: Date.now() });
|
||||
log("info", "llm", `provision '${msg.key}' (${msg.hfRepo}) → ${msg.targetInstance || "?"}`);
|
||||
} else if (msg.action === "llm_unload_model") {
|
||||
// LLM-Karte leeren (das GGUF bleibt auf der Box).
|
||||
sendToRVS_raw({ type: "llm_unload_model", payload: {
|
||||
targetInstance: msg.targetInstance || "", gpu: String(msg.gpu ?? "") }, timestamp: Date.now() });
|
||||
log("info", "llm", `Karte ${msg.gpu} leeren → ${msg.targetInstance || "?"}`);
|
||||
} else if (msg.action === "llm_remove_model") {
|
||||
sendToRVS_raw({ type: "llm_remove_model", payload: {
|
||||
targetInstance: msg.targetInstance || "", key: msg.key }, timestamp: Date.now() });
|
||||
|
||||
Reference in New Issue
Block a user