Adding model edit

This commit is contained in:
2026-04-20 17:57:48 +02:00
parent 402e10901a
commit 84b4a88ba1
3 changed files with 480 additions and 13 deletions
+4
View File
@@ -52,6 +52,10 @@ async def init_schema(pool: asyncpg.Pool) -> None:
sql = SCHEMA_PATH.read_text() sql = SCHEMA_PATH.read_text()
async with pool.acquire() as conn: async with pool.acquire() as conn:
await conn.execute(sql) await conn.execute(sql)
# Migration: base_url column for LM Studio / custom OpenAI-compatible endpoints
await conn.execute(
"ALTER TABLE models ADD COLUMN IF NOT EXISTS base_url TEXT NOT NULL DEFAULT ''"
)
log.info("schema applied") log.info("schema applied")
+9 -4
View File
@@ -18,9 +18,10 @@ log = logging.getLogger("festinger.llm")
@dataclass @dataclass
class ModelConfig: class ModelConfig:
provider: str provider: str # 'claude', 'openai', or 'lm-studio'
model_name: str model_name: str
api_key: str api_key: str
base_url: str = "" # custom endpoint for lm-studio / openai-compatible
async def get_model_config(pool: asyncpg.Pool, model_id: str) -> Optional[ModelConfig]: async def get_model_config(pool: asyncpg.Pool, model_id: str) -> Optional[ModelConfig]:
@@ -32,7 +33,7 @@ async def get_model_config(pool: asyncpg.Pool, model_id: str) -> Optional[ModelC
return None return None
async with pool.acquire() as conn: async with pool.acquire() as conn:
row = await conn.fetchrow( row = await conn.fetchrow(
"SELECT provider, model_name, api_key FROM models WHERE id=$1", mid "SELECT provider, model_name, api_key, base_url FROM models WHERE id=$1", mid
) )
if not row: if not row:
return None return None
@@ -40,6 +41,7 @@ async def get_model_config(pool: asyncpg.Pool, model_id: str) -> Optional[ModelC
provider=row["provider"], provider=row["provider"],
model_name=row["model_name"], model_name=row["model_name"],
api_key=row["api_key"], api_key=row["api_key"],
base_url=row["base_url"] or "",
) )
@@ -47,7 +49,7 @@ async def call_llm(model: ModelConfig, prompt: str) -> str:
"""Call the configured LLM and return the text response.""" """Call the configured LLM and return the text response."""
if model.provider == "claude": if model.provider == "claude":
return await _call_claude(model, prompt) return await _call_claude(model, prompt)
elif model.provider == "openai": elif model.provider in ("openai", "lm-studio"):
return await _call_openai(model, prompt) return await _call_openai(model, prompt)
else: else:
raise ValueError(f"Unknown LLM provider: {model.provider!r}") raise ValueError(f"Unknown LLM provider: {model.provider!r}")
@@ -66,7 +68,10 @@ async def _call_claude(model: ModelConfig, prompt: str) -> str:
async def _call_openai(model: ModelConfig, prompt: str) -> str: async def _call_openai(model: ModelConfig, prompt: str) -> str:
from openai import AsyncOpenAI from openai import AsyncOpenAI
client = AsyncOpenAI(api_key=model.api_key) kwargs: dict = {"api_key": model.api_key or "lm-studio"}
if model.base_url:
kwargs["base_url"] = model.base_url
client = AsyncOpenAI(**kwargs)
response = await client.chat.completions.create( response = await client.chat.completions.create(
model=model.model_name, model=model.model_name,
messages=[{"role": "user", "content": prompt}], messages=[{"role": "user", "content": prompt}],
+467 -9
View File
@@ -779,11 +779,11 @@ async def list_models(request: Request) -> dict:
pool = request.app.state.pool pool = request.app.state.pool
async with pool.acquire() as conn: async with pool.acquire() as conn:
rows = await conn.fetch( rows = await conn.fetch(
"SELECT id, provider, model_name, created_at FROM models ORDER BY id" "SELECT id, provider, model_name, base_url, created_at FROM models ORDER BY id"
) )
return {"models": [ return {"models": [
{"id": r["id"], "provider": r["provider"], "model_name": r["model_name"], {"id": r["id"], "provider": r["provider"], "model_name": r["model_name"],
"created_at": r["created_at"].isoformat()} "base_url": r["base_url"] or "", "created_at": r["created_at"].isoformat()}
for r in rows for r in rows
]} ]}
@@ -795,16 +795,19 @@ async def create_model(request: Request) -> dict:
provider = data.get("provider", "").strip() provider = data.get("provider", "").strip()
model_name = data.get("model_name", "").strip() model_name = data.get("model_name", "").strip()
api_key = data.get("api_key", "").strip() api_key = data.get("api_key", "").strip()
if not provider or not model_name or not api_key: base_url = data.get("base_url", "").strip()
return {"error": "provider, model_name, and api_key are required"} if not provider or not model_name:
if provider not in ("claude", "openai"): return {"error": "provider and model_name are required"}
return {"error": "provider must be 'claude' or 'openai'"} if provider not in ("claude", "openai", "lm-studio"):
return {"error": "provider must be 'claude', 'openai', or 'lm-studio'"}
if provider == "claude" and not api_key:
return {"error": "api_key is required for claude provider"}
async with pool.acquire() as conn: async with pool.acquire() as conn:
row = await conn.fetchrow( row = await conn.fetchrow(
"INSERT INTO models (provider, model_name, api_key) VALUES ($1,$2,$3) RETURNING id", "INSERT INTO models (provider, model_name, api_key, base_url) VALUES ($1,$2,$3,$4) RETURNING id",
provider, model_name, api_key, provider, model_name, api_key, base_url,
) )
log.info("model created id=%d provider=%s model=%s", row["id"], provider, model_name) log.info("model created id=%d provider=%s model=%s base_url=%s", row["id"], provider, model_name, base_url)
return {"status": "ok", "id": row["id"]} return {"status": "ok", "id": row["id"]}
@@ -820,6 +823,28 @@ async def delete_model(model_id: int, request: Request) -> dict:
return {"status": "ok", "deleted": model_id} return {"status": "ok", "deleted": model_id}
@app.get("/models/discover")
async def discover_models(base_url: str = "http://host.docker.internal:1234") -> dict:
"""
Proxy a GET to {base_url}/v1/models to discover models available in LM Studio
(or any OpenAI-compatible server). Avoids browser CORS restrictions.
"""
url = base_url.rstrip("/") + "/v1/models"
log.info("discover_models url=%s", url)
try:
async with httpx.AsyncClient(timeout=10.0) as client:
r = await client.get(url)
if not r.is_success:
return {"error": f"server returned {r.status_code}", "models": []}
data = r.json()
model_ids = [m.get("id", m) for m in data.get("data", [])]
return {"models": model_ids, "raw": data}
except httpx.ConnectError:
return {"error": f"could not connect to {url} — is LM Studio running?", "models": []}
except Exception as exc:
return {"error": str(exc), "models": []}
@app.get("/config") @app.get("/config")
async def get_all_config(request: Request) -> dict: async def get_all_config(request: Request) -> dict:
pool = request.app.state.pool pool = request.app.state.pool
@@ -1711,6 +1736,7 @@ ADMIN_HTML = """<!DOCTYPE html>
<h1>Festinger</h1> <h1>Festinger</h1>
<p class="subtitle">Ollama-compatible inference middleware — loop detection &amp; Recollections world model <p class="subtitle">Ollama-compatible inference middleware — loop detection &amp; Recollections world model
&nbsp;&mdash;&nbsp;<a href="/graph" style="color:#1a1a2e">Knowledge Graph Explorer</a> &nbsp;&mdash;&nbsp;<a href="/graph" style="color:#1a1a2e">Knowledge Graph Explorer</a>
&nbsp;&mdash;&nbsp;<a href="/models-ui" style="color:#1a1a2e">Model Manager</a>
</p> </p>
<h2>World model stats</h2> <h2>World model stats</h2>
@@ -2002,6 +2028,438 @@ async def admin() -> str:
return ADMIN_HTML.format() return ADMIN_HTML.format()
# ---------------------------------------------------------------------------
# /models-ui — model manager page
# ---------------------------------------------------------------------------
MODELS_HTML = r"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Festinger — Model Manager</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: monospace; background: #f5f5f5; color: #222; min-height: 100vh; }
/* ── Top bar ── */
.topbar {
background: #1a1a2e; color: #fff;
padding: 0 28px; height: 52px;
display: flex; align-items: center; gap: 20px;
}
.topbar-title { font-size: 1.1em; font-weight: bold; letter-spacing: 0.02em; }
.topbar-sub { font-size: 0.78em; color: #666; }
.topbar a { color: #7070cc; font-size: 0.8em; text-decoration: none; }
.topbar a:hover { color: #9999ff; }
.topbar-spacer { flex: 1; }
/* ── Layout ── */
.page { max-width: 900px; margin: 0 auto; padding: 28px 20px 60px; }
/* ── Cards ── */
.card {
background: #fff; border: 1px solid #e0e0e0; border-radius: 6px;
margin-bottom: 20px; overflow: hidden;
}
.card-header {
background: #f8f8f8; border-bottom: 1px solid #e8e8e8;
padding: 12px 18px; display: flex; align-items: center; gap: 10px;
}
.card-title { font-size: 0.95em; font-weight: bold; }
.card-sub { font-size: 0.75em; color: #888; margin-left: auto; }
.card-body { padding: 18px; }
/* ── Active badge ── */
.badge {
display: inline-block; padding: 2px 9px; border-radius: 10px;
font-size: 0.72em; font-weight: bold; letter-spacing: 0.03em;
}
.badge-resolve { background: #d4edda; color: #155724; }
.badge-write { background: #cce5ff; color: #004085; }
.badge-both { background: #fff3cd; color: #856404; }
/* ── Tables ── */
table { width: 100%; border-collapse: collapse; font-size: 0.84em; }
th {
text-align: left; border-bottom: 2px solid #eee; padding: 6px 10px;
font-size: 0.72em; text-transform: uppercase; letter-spacing: 0.06em; color: #888;
}
td { border-bottom: 1px solid #f2f2f2; padding: 7px 10px; vertical-align: middle; }
tr:last-child td { border-bottom: none; }
tr:hover td { background: #fafafa; }
/* ── Forms ── */
.field-row { display: flex; gap: 10px; flex-wrap: wrap; align-items: flex-end; margin-bottom: 14px; }
.field { display: flex; flex-direction: column; gap: 4px; }
.field label { font-size: 0.78em; color: #666; }
input[type="text"], input[type="password"], input[type="url"], select {
font-family: monospace; font-size: 0.85em;
padding: 7px 10px; border: 1px solid #ccc; border-radius: 4px;
background: #fff; color: #222;
}
input[type="text"]:focus, input[type="password"]:focus,
input[type="url"]:focus, select:focus {
outline: none; border-color: #7070cc;
}
input[type="text"].wide { width: 280px; }
input[type="password"].wide { width: 280px; }
input[type="url"].wide { width: 300px; }
/* ── Buttons ── */
.btn {
padding: 7px 16px; border-radius: 4px; border: 1px solid #bbb;
cursor: pointer; font-family: monospace; font-size: 0.84em;
background: #fff; color: #333; white-space: nowrap;
}
.btn:hover { background: #f0f0f0; }
.btn:disabled { opacity: 0.45; cursor: not-allowed; }
.btn-primary { background: #1a1a2e; color: #fff; border-color: #1a1a2e; }
.btn-primary:hover { background: #2a2a4e; }
.btn-success { background: #1e6e3e; color: #fff; border-color: #1e6e3e; }
.btn-success:hover { background: #27844b; }
.btn-sm { padding: 3px 10px; font-size: 0.78em; }
.btn-danger { color: #b00; border-color: #e0b0b0; }
.btn-danger:hover { background: #fff0f0; }
.btn-active { background: #d4edda; color: #155724; border-color: #b0d8bc; cursor: default; }
/* ── Divider ── */
.divider { height: 1px; background: #eee; margin: 16px 0; }
/* ── Discovery list ── */
#disc-list { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; min-height: 20px; }
.disc-item {
background: #eef0ff; border: 1px solid #c8ccee; border-radius: 4px;
padding: 5px 12px; font-size: 0.82em; display: flex; align-items: center; gap: 8px;
}
.disc-item button { padding: 2px 8px; font-size: 0.78em; }
/* ── Status / toast ── */
#toast {
position: fixed; bottom: 24px; right: 24px; z-index: 100;
background: #1a1a2e; color: #fff; padding: 10px 18px; border-radius: 6px;
font-size: 0.85em; opacity: 0; transition: opacity 0.25s;
pointer-events: none; max-width: 360px;
}
#toast.show { opacity: 1; }
#toast.err { background: #8b0000; }
/* ── Separator label ── */
.sep { font-size: 0.72em; color: #aaa; text-transform: uppercase; letter-spacing: 0.08em;
margin: 18px 0 10px; }
/* ── Provider pill ── */
.pill {
display: inline-block; padding: 1px 8px; border-radius: 10px; font-size: 0.75em;
}
.pill-claude { background: #fce8d5; color: #6b3000; }
.pill-openai { background: #d5f0e8; color: #00502a; }
.pill-lm-studio { background: #e8d5fc; color: #3a006b; }
</style>
</head>
<body>
<div class="topbar">
<span class="topbar-title">Festinger</span>
<span class="topbar-sub">Model Manager</span>
<div class="topbar-spacer"></div>
<a href="/admin">← admin</a>
</div>
<div class="page">
<!-- ── Currently configured models ── -->
<div class="card">
<div class="card-header">
<span class="card-title">Configured models</span>
<span class="card-sub" id="cfg-sub">Loading…</span>
</div>
<div class="card-body" style="padding:0">
<table>
<thead><tr>
<th>ID</th><th>Provider</th><th>Model</th><th>Endpoint</th>
<th>Role</th><th>Actions</th>
</tr></thead>
<tbody id="models-tbody">
<tr><td colspan="6" style="color:#aaa;padding:18px">Loading…</td></tr>
</tbody>
</table>
</div>
</div>
<!-- ── LM Studio ── -->
<div class="card">
<div class="card-header">
<span class="card-title">LM Studio</span>
<span class="card-sub">OpenAI-compatible local inference</span>
</div>
<div class="card-body">
<p style="font-size:0.83em;color:#666;margin-bottom:14px">
Start LM Studio, load a model, and enable the local server
(default port&nbsp;1234). Festinger will discover available models
and register them for conflict resolution.
</p>
<div class="field-row">
<div class="field">
<label>LM Studio base URL</label>
<input type="url" id="lms-url" class="wide" value="http://host.docker.internal:1234">
</div>
<button class="btn btn-primary" onclick="discoverModels(this)">Discover models</button>
</div>
<div id="disc-error" style="display:none;color:#b00;font-size:0.82em;margin-top:6px"></div>
<div id="disc-list"></div>
</div>
</div>
<!-- ── Add Claude / OpenAI ── -->
<div class="card">
<div class="card-header">
<span class="card-title">Add cloud model</span>
<span class="card-sub">Claude or OpenAI-compatible</span>
</div>
<div class="card-body">
<div class="field-row">
<div class="field">
<label>Provider</label>
<select id="add-provider" onchange="onProviderChange()">
<option value="claude">Claude (Anthropic)</option>
<option value="openai">OpenAI</option>
<option value="lm-studio">Custom OpenAI-compatible</option>
</select>
</div>
<div class="field">
<label>Model name</label>
<input type="text" id="add-name" class="wide" value="claude-haiku-4-5-20251001">
</div>
<div class="field" id="add-key-field">
<label>API key</label>
<input type="password" id="add-key" class="wide" placeholder="sk-ant-…">
</div>
</div>
<div class="field-row" id="add-url-row" style="display:none">
<div class="field">
<label>Base URL</label>
<input type="url" id="add-url" style="width:360px" placeholder="http://host.docker.internal:1234/v1">
</div>
</div>
<button class="btn btn-primary" onclick="addModel(this)">Add model</button>
</div>
</div>
</div>
<div id="toast"></div>
<script>
// ─── State ────────────────────────────────────────────────────────────────────
let _cfg = {};
let _models = [];
// ─── Toast ────────────────────────────────────────────────────────────────────
let _toastTimer = null;
function toast(msg, err = false) {
const el = document.getElementById('toast');
el.textContent = msg;
el.className = 'show' + (err ? ' err' : '');
clearTimeout(_toastTimer);
_toastTimer = setTimeout(() => el.className = '', 3000);
}
// ─── Load ─────────────────────────────────────────────────────────────────────
async function load() {
const [mr, cr] = await Promise.all([fetch('/models'), fetch('/config')]);
_models = (await mr.json()).models || [];
_cfg = (await cr.json()).config || {};
renderModels();
}
function roleBadge(id) {
const sid = String(id);
const isResolve = _cfg['resolve_model_id'] === sid;
const isWrite = _cfg['write_model_id'] === sid;
if (isResolve && isWrite) return '<span class="badge badge-both">resolve + write</span>';
if (isResolve) return '<span class="badge badge-resolve">resolve</span>';
if (isWrite) return '<span class="badge badge-write">write</span>';
return '<span style="color:#bbb;font-size:0.8em">—</span>';
}
function providerPill(p) {
const cls = 'pill-' + p;
return `<span class="pill ${cls}">${p}</span>`;
}
function renderModels() {
const tbody = document.getElementById('models-tbody');
const sub = document.getElementById('cfg-sub');
if (!_models.length) {
tbody.innerHTML = '<tr><td colspan="6" style="color:#aaa;padding:18px">No models configured yet.</td></tr>';
sub.textContent = '0 models';
return;
}
sub.textContent = _models.length + ' model' + (_models.length !== 1 ? 's' : '');
const resolveId = _cfg['resolve_model_id'] || '';
const writeId = _cfg['write_model_id'] || '';
tbody.innerHTML = _models.map(m => {
const sid = String(m.id);
const endpoint = m.base_url
? `<span style="color:#555;font-size:0.85em">${m.base_url}</span>`
: '<span style="color:#ccc">—</span>';
const rBtn = resolveId === sid
? `<button class="btn btn-sm btn-active" disabled>✓ resolve</button>`
: `<button class="btn btn-sm" onclick="setRole('resolve_model_id','${sid}')">set resolve</button>`;
const wBtn = writeId === sid
? `<button class="btn btn-sm btn-active" disabled>✓ write</button>`
: `<button class="btn btn-sm" onclick="setRole('write_model_id','${sid}')">set write</button>`;
return `<tr>
<td style="color:#aaa">#${m.id}</td>
<td>${providerPill(m.provider)}</td>
<td>${m.model_name}</td>
<td>${endpoint}</td>
<td>${roleBadge(m.id)}</td>
<td style="display:flex;gap:6px;padding:6px 10px">
${rBtn} ${wBtn}
<button class="btn btn-sm btn-danger" onclick="deleteModel(${m.id},this)">✕</button>
</td>
</tr>`;
}).join('');
}
// ─── Set role ─────────────────────────────────────────────────────────────────
async function setRole(key, value) {
const r = await fetch('/config', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({key, value}),
});
const d = await r.json();
if (d.error) { toast('Error: ' + d.error, true); return; }
_cfg[key] = value;
renderModels();
toast(key.replace('_model_id', '') + ' model set to #' + value);
}
// ─── Delete ───────────────────────────────────────────────────────────────────
async function deleteModel(id, btn) {
if (!confirm(`Delete model #${id}?`)) return;
btn.disabled = true;
const r = await fetch('/models/' + id, {method: 'DELETE'});
const d = await r.json();
btn.disabled = false;
if (d.error) { toast('Error: ' + d.error, true); return; }
toast('Model #' + id + ' deleted');
await load();
}
// ─── LM Studio discover ───────────────────────────────────────────────────────
async function discoverModels(btn) {
const base = document.getElementById('lms-url').value.trim();
const errEl = document.getElementById('disc-error');
const listEl = document.getElementById('disc-list');
errEl.style.display = 'none';
listEl.innerHTML = '<span style="color:#aaa;font-size:0.82em">Connecting…</span>';
btn.disabled = true;
try {
const r = await fetch('/models/discover?base_url=' + encodeURIComponent(base));
const d = await r.json();
btn.disabled = false;
if (d.error) {
errEl.textContent = d.error;
errEl.style.display = 'block';
listEl.innerHTML = '';
return;
}
if (!d.models.length) {
listEl.innerHTML = '<span style="color:#aaa;font-size:0.82em">No models loaded in LM Studio.</span>';
return;
}
listEl.innerHTML = d.models.map(name => `
<div class="disc-item">
<span>${name}</span>
<button class="btn btn-sm btn-success"
onclick="addLmStudioModel('${name}','${base}/v1',this)">
+ add
</button>
</div>
`).join('');
} catch(e) {
btn.disabled = false;
errEl.textContent = 'Request failed: ' + e.message;
errEl.style.display = 'block';
listEl.innerHTML = '';
}
}
async function addLmStudioModel(modelName, baseUrl, btn) {
btn.disabled = true;
const r = await fetch('/models', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({provider: 'lm-studio', model_name: modelName, api_key: '', base_url: baseUrl}),
});
const d = await r.json();
btn.disabled = false;
if (d.error) { toast('Error: ' + d.error, true); return; }
toast(`Added "${modelName}" (#${d.id}). Click "set resolve" to activate it.`);
btn.textContent = '✓ added';
btn.classList.add('btn-active');
await load();
}
// ─── Add cloud model ──────────────────────────────────────────────────────────
function onProviderChange() {
const p = document.getElementById('add-provider').value;
document.getElementById('add-url-row').style.display = p === 'lm-studio' ? 'flex' : 'none';
document.getElementById('add-key-field').style.display = p === 'lm-studio' ? 'none' : 'flex';
const nameDefaults = {
claude: 'claude-haiku-4-5-20251001',
openai: 'gpt-4o-mini',
'lm-studio': '',
};
document.getElementById('add-name').value = nameDefaults[p] || '';
}
async function addModel(btn) {
const provider = document.getElementById('add-provider').value;
const modelName = document.getElementById('add-name').value.trim();
const apiKey = document.getElementById('add-key').value.trim();
const baseUrl = document.getElementById('add-url').value.trim();
if (!modelName) { toast('Model name is required', true); return; }
if (provider === 'claude' && !apiKey) { toast('API key required for Claude', true); return; }
btn.disabled = true;
const r = await fetch('/models', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({provider, model_name: modelName, api_key: apiKey, base_url: baseUrl}),
});
const d = await r.json();
btn.disabled = false;
if (d.error) { toast('Error: ' + d.error, true); return; }
toast(`Model added (#${d.id}). Click "set resolve" to activate it.`);
document.getElementById('add-key').value = '';
await load();
}
// ─── Boot ─────────────────────────────────────────────────────────────────────
load();
</script>
</body>
</html>"""
@app.get("/models-ui", response_class=HTMLResponse)
async def models_ui() -> str:
return MODELS_HTML
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Passthrough — everything else forwarded to upstream Ollama # Passthrough — everything else forwarded to upstream Ollama
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------