diff --git a/plugins/festinger/festinger/db.py b/plugins/festinger/festinger/db.py index 394dc79..0352031 100644 --- a/plugins/festinger/festinger/db.py +++ b/plugins/festinger/festinger/db.py @@ -52,6 +52,10 @@ async def init_schema(pool: asyncpg.Pool) -> None: sql = SCHEMA_PATH.read_text() async with pool.acquire() as conn: 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") diff --git a/plugins/festinger/festinger/llm_client.py b/plugins/festinger/festinger/llm_client.py index d9f4a0b..346b139 100644 --- a/plugins/festinger/festinger/llm_client.py +++ b/plugins/festinger/festinger/llm_client.py @@ -18,9 +18,10 @@ log = logging.getLogger("festinger.llm") @dataclass class ModelConfig: - provider: str + provider: str # 'claude', 'openai', or 'lm-studio' model_name: 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]: @@ -32,7 +33,7 @@ async def get_model_config(pool: asyncpg.Pool, model_id: str) -> Optional[ModelC return None async with pool.acquire() as conn: 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: return None @@ -40,6 +41,7 @@ async def get_model_config(pool: asyncpg.Pool, model_id: str) -> Optional[ModelC provider=row["provider"], model_name=row["model_name"], 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.""" if model.provider == "claude": return await _call_claude(model, prompt) - elif model.provider == "openai": + elif model.provider in ("openai", "lm-studio"): return await _call_openai(model, prompt) else: 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: 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( model=model.model_name, messages=[{"role": "user", "content": prompt}], diff --git a/plugins/festinger/festinger/main.py b/plugins/festinger/festinger/main.py index 0d7e01a..c4bb9a9 100644 --- a/plugins/festinger/festinger/main.py +++ b/plugins/festinger/festinger/main.py @@ -779,11 +779,11 @@ async def list_models(request: Request) -> dict: pool = request.app.state.pool async with pool.acquire() as conn: 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": [ {"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 ]} @@ -795,16 +795,19 @@ async def create_model(request: Request) -> dict: provider = data.get("provider", "").strip() model_name = data.get("model_name", "").strip() api_key = data.get("api_key", "").strip() - if not provider or not model_name or not api_key: - return {"error": "provider, model_name, and api_key are required"} - if provider not in ("claude", "openai"): - return {"error": "provider must be 'claude' or 'openai'"} + base_url = data.get("base_url", "").strip() + if not provider or not model_name: + return {"error": "provider and model_name are required"} + 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: row = await conn.fetchrow( - "INSERT INTO models (provider, model_name, api_key) VALUES ($1,$2,$3) RETURNING id", - provider, model_name, api_key, + "INSERT INTO models (provider, model_name, api_key, base_url) VALUES ($1,$2,$3,$4) RETURNING id", + 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"]} @@ -820,6 +823,28 @@ async def delete_model(model_id: int, request: Request) -> dict: 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") async def get_all_config(request: Request) -> dict: pool = request.app.state.pool @@ -1711,6 +1736,7 @@ ADMIN_HTML = """
Ollama-compatible inference middleware — loop detection & Recollections world model — Knowledge Graph Explorer + — Model Manager
| ID | Provider | Model | Endpoint | +Role | Actions | +
|---|---|---|---|---|---|
| Loading… | |||||
+ Start LM Studio, load a model, and enable the local server + (default port 1234). Festinger will discover available models + and register them for conflict resolution. +
+