From d28143ec007aeafdba41bb4fb4443d8c42751719 Mon Sep 17 00:00:00 2001 From: Eric Jungbauer Date: Mon, 13 Apr 2026 14:13:02 +0000 Subject: [PATCH] Bring Your Own LLM: per-user LLM config with system default fallback --- dashboard/app.py | 76 +++++++++++++++++++++++++++++++++++++ dashboard/models.py | 1 + dashboard/static/index.html | 66 ++++++++++++++++++++++++++++++++ 3 files changed, 143 insertions(+) diff --git a/dashboard/app.py b/dashboard/app.py index 4077ee3..c8bf038 100644 --- a/dashboard/app.py +++ b/dashboard/app.py @@ -224,13 +224,89 @@ def verify_magic_link(token: str, response: Response, db: Session = Depends(get_ @app.get("/api/me") def me(user: dict = Depends(require_auth), db: Session = Depends(get_db)): u = db.query(User).filter(User.id == user["user_id"]).first() + llm = u.llm_config or {} return { "id": u.id, "username": u.username, "email": u.email or "", "display_name": u.display_name, "role": u.role, + "has_llm": bool(llm.get("api_key")), + "llm_provider": llm.get("provider_type", ""), + "llm_model": llm.get("default_model", ""), "created_at": u.created_at.isoformat() if u.created_at else None, } +class UserLLMConfig(BaseModel): + provider_type: str = "" # anthropic, openai, litellm, ollama + api_url: str = "" + api_key: str = "" + default_model: str = "" + + +@app.get("/api/me/llm") +def get_my_llm(user: dict = Depends(require_auth), db: Session = Depends(get_db)): + """Get current user's LLM config.""" + u = db.query(User).filter(User.id == user["user_id"]).first() + llm = u.llm_config or {} + return { + "provider_type": llm.get("provider_type", ""), + "api_url": llm.get("api_url", ""), + "api_key": "***" if llm.get("api_key") else "", + "default_model": llm.get("default_model", ""), + "configured": bool(llm.get("api_key")), + } + + +@app.put("/api/me/llm") +def update_my_llm(data: UserLLMConfig, user: dict = Depends(require_auth), db: Session = Depends(get_db)): + """Update current user's LLM config (bring your own LLM).""" + u = db.query(User).filter(User.id == user["user_id"]).first() + current = u.llm_config or {} + update = data.model_dump() + # Only update api_key if a real value was provided (not "***") + if update.get("api_key") == "***" or not update.get("api_key"): + update["api_key"] = current.get("api_key", "") + u.llm_config = update + db.commit() + return {"status": "updated"} + + +@app.delete("/api/me/llm") +def delete_my_llm(user: dict = Depends(require_auth), db: Session = Depends(get_db)): + """Remove user's LLM config (fall back to system default).""" + u = db.query(User).filter(User.id == user["user_id"]).first() + u.llm_config = {} + db.commit() + return {"status": "removed"} + + +@app.get("/api/users/{user_id}/llm") +def get_user_llm(user_id: int, db: Session = Depends(get_db)): + """Internal: resolve LLM config for a user. Returns user's own config if set, otherwise system default.""" + u = db.query(User).filter(User.id == user_id).first() + if not u: + raise HTTPException(status_code=404) + user_llm = u.llm_config or {} + if user_llm.get("api_key"): + return { + "source": "user", + "provider_type": user_llm.get("provider_type", ""), + "api_url": user_llm.get("api_url", ""), + "api_key": user_llm["api_key"], + "default_model": user_llm.get("default_model", ""), + } + # Fall back to system default + default = db.query(LLMProvider).filter(LLMProvider.is_default == True).first() + if default: + return { + "source": "system", + "provider_type": default.provider_type, + "api_url": default.api_url, + "api_key": default.api_key, + "default_model": default.default_model, + } + return {"source": "none", "provider_type": "", "api_url": "", "api_key": "", "default_model": ""} + + # --- Health --- @app.get("/api/health") diff --git a/dashboard/models.py b/dashboard/models.py index 33e62e4..05940cd 100644 --- a/dashboard/models.py +++ b/dashboard/models.py @@ -13,6 +13,7 @@ class User(Base): password_hash = Column(String, nullable=False) display_name = Column(String, default="") role = Column(String, default="user") # admin or user + llm_config = Column(JSON, default=dict) # user's own LLM provider config created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc)) instances = relationship("AgentInstance", back_populates="user") diff --git a/dashboard/static/index.html b/dashboard/static/index.html index 5c20093..2fcdbb6 100644 --- a/dashboard/static/index.html +++ b/dashboard/static/index.html @@ -106,6 +106,7 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b
0 agents + @@ -340,6 +341,71 @@ async function enableAgent(catalogId,name){ if(res.ok){closeModal();refresh()} } +// --- LLM Settings --- +async function showLLMSettings(){ + const res=await fetch(API+'/api/me/llm'); + if(res.status===401){location.href='/login';return} + const llm=await res.json(); + document.getElementById('modal-content').innerHTML=` + +

LLM Provider

+

Bring your own LLM API key, or leave blank to use the system default.

+
+

Your LLM Configuration

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + + +
+

+ ${llm.configured?'Status: Configured ('+llm.provider_type+')':'Status: Using system default'} +

+
`; + document.getElementById('modal-overlay').classList.add('open'); +} + +async function saveLLM(){ + const body={ + provider_type:document.getElementById('llm-provider').value, + api_url:document.getElementById('llm-url').value, + api_key:document.getElementById('llm-key').value, + default_model:document.getElementById('llm-model').value, + }; + const res=await fetch(API+'/api/me/llm',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)}); + const msg=document.getElementById('llm-save-msg'); + if(res.ok){msg.textContent='Saved';msg.style.color='var(--green)';setTimeout(()=>msg.textContent='',2000)} + else{msg.textContent='Error';msg.style.color='var(--red)'} +} + +async function removeLLM(){ + await fetch(API+'/api/me/llm',{method:'DELETE'}); + showLLMSettings(); +} + // --- Bridge --- async function loadBridge(){ try{