v2.0: Multi-user platform with agent catalog, admin panel, LLM providers

This commit is contained in:
2026-04-13 02:21:45 +00:00
parent b299ea701a
commit 26156543f6
8 changed files with 1002 additions and 580 deletions
+421 -191
View File
@@ -1,63 +1,125 @@
from fastapi import FastAPI, Depends, HTTPException, Request, Response, Cookie
from fastapi import FastAPI, Depends, HTTPException, Response, Cookie
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse, RedirectResponse, JSONResponse
from fastapi.responses import FileResponse, RedirectResponse
from sqlalchemy.orm import Session
from pydantic import BaseModel
from datetime import datetime, timezone
from typing import Optional
import hashlib
import hmac
import json
import os
import secrets
from database import get_db, init_db
from models import Agent, Run
app = FastAPI(title="Agent Command Center", version="1.0.0")
from models import User, AgentCatalog, AgentInstance, Run, LLMProvider
app = FastAPI(title="Agent Command Center", version="2.0.0")
# --- Auth ---
AUTH_USER = os.environ.get("AUTH_USER", "eric")
AUTH_PASS = os.environ.get("AUTH_PASS", "Kj8#mPx2vQ!nR4wL")
SESSION_SECRET = os.environ.get("SESSION_SECRET", secrets.token_hex(32))
# In-memory session store
_sessions: dict[str, str] = {}
_sessions: dict[str, dict] = {} # token -> {user_id, username, role}
def create_session(username: str) -> str:
def hash_password(password: str) -> str:
salt = secrets.token_hex(16)
h = hashlib.sha256((salt + password).encode()).hexdigest()
return f"{salt}:{h}"
def verify_password(password: str, password_hash: str) -> bool:
salt, h = password_hash.split(":", 1)
return hashlib.sha256((salt + password).encode()).hexdigest() == h
def create_session(user: User) -> str:
token = secrets.token_urlsafe(32)
_sessions[token] = username
_sessions[token] = {"user_id": user.id, "username": user.username, "role": user.role}
return token
def get_current_user(session: Optional[str] = Cookie(None)) -> Optional[str]:
def get_current_user(session: Optional[str] = Cookie(None)) -> Optional[dict]:
if session and session in _sessions:
return _sessions[session]
return None
def require_auth(session: Optional[str] = Cookie(None)):
def require_auth(session: Optional[str] = Cookie(None)) -> dict:
user = get_current_user(session)
if not user:
raise HTTPException(status_code=401, detail="Not authenticated")
return user
def require_admin(session: Optional[str] = Cookie(None)) -> dict:
user = require_auth(session)
if user["role"] != "admin":
raise HTTPException(status_code=403, detail="Admin access required")
return user
# --- Schemas ---
class LoginRequest(BaseModel):
username: str
password: str
class InstanceCreate(BaseModel):
catalog_id: str
name: Optional[str] = None
config: dict = {}
schedule: Optional[str] = None
class InstanceUpdate(BaseModel):
name: Optional[str] = None
config: Optional[dict] = None
schedule: Optional[str] = None
status: Optional[str] = None
class RunCreate(BaseModel):
status: str = "running"
output: str = ""
error: str = ""
metadata: dict = {}
class UserCreate(BaseModel):
username: str
password: str
display_name: str = ""
role: str = "user"
class UserUpdate(BaseModel):
display_name: Optional[str] = None
role: Optional[str] = None
password: Optional[str] = None
class LLMProviderCreate(BaseModel):
name: str
provider_type: str = "anthropic"
api_url: str = ""
api_key: str = ""
default_model: str = ""
is_default: bool = False
class LLMProviderUpdate(BaseModel):
name: Optional[str] = None
provider_type: Optional[str] = None
api_url: Optional[str] = None
api_key: Optional[str] = None
default_model: Optional[str] = None
is_default: Optional[bool] = None
# --- Auth Routes ---
@app.post("/api/login")
def login(creds: LoginRequest, response: Response):
if creds.username == AUTH_USER and creds.password == AUTH_PASS:
token = create_session(creds.username)
response.set_cookie("session", token, httponly=True, samesite="lax", max_age=86400 * 7)
return {"status": "ok", "user": creds.username}
raise HTTPException(status_code=401, detail="Invalid credentials")
def login(creds: LoginRequest, response: Response, db: Session = Depends(get_db)):
user = db.query(User).filter(User.username == creds.username).first()
if not user or not verify_password(creds.password, user.password_hash):
raise HTTPException(status_code=401, detail="Invalid credentials")
token = create_session(user)
response.set_cookie("session", token, httponly=True, samesite="lax", max_age=86400 * 7)
return {"status": "ok", "user": user.username, "role": user.role, "display_name": user.display_name}
@app.post("/api/logout")
@@ -68,155 +130,174 @@ def logout(response: Response, session: Optional[str] = Cookie(None)):
return {"status": "ok"}
@app.get("/login")
def login_page():
return FileResponse("static/login.html")
# --- Pydantic schemas ---
class AgentCreate(BaseModel):
id: str
name: str
description: str = ""
schedule: str = "manual"
config: dict = {}
class AgentUpdate(BaseModel):
name: Optional[str] = None
description: Optional[str] = None
schedule: Optional[str] = None
status: Optional[str] = None
config: Optional[dict] = None
class RunCreate(BaseModel):
status: str = "running"
output: str = ""
error: str = ""
metadata: dict = {}
class RunUpdate(BaseModel):
status: Optional[str] = None
output: Optional[str] = None
error: Optional[str] = None
finished_at: Optional[str] = None
metadata: Optional[dict] = None
# --- API routes ---
@app.get("/api/health")
def health():
return {"status": "ok", "service": "agent-command-center"}
@app.get("/api/agents/{agent_id}/config")
def get_agent_config(agent_id: str, db: Session = Depends(get_db)):
"""Internal endpoint for agents to fetch their config. No auth required."""
agent = db.query(Agent).filter(Agent.id == agent_id).first()
if not agent:
raise HTTPException(status_code=404, detail="Agent not found")
return agent.config or {}
@app.get("/api/agents")
def list_agents(user: str = Depends(require_auth), db: Session = Depends(get_db)):
agents = db.query(Agent).all()
result = []
for a in agents:
last_run = db.query(Run).filter(Run.agent_id == a.id).order_by(Run.started_at.desc()).first()
recent_runs = db.query(Run).filter(Run.agent_id == a.id).order_by(Run.started_at.desc()).limit(10).all()
success_streak = 0
for r in recent_runs:
if r.status == "success":
success_streak += 1
else:
break
result.append({
"id": a.id,
"name": a.name,
"description": a.description,
"schedule": a.schedule,
"status": a.status,
"config": a.config or {},
"created_at": a.created_at.isoformat() if a.created_at else None,
"last_run": {
"status": last_run.status,
"started_at": last_run.started_at.isoformat() if last_run.started_at else None,
"finished_at": last_run.finished_at.isoformat() if last_run.finished_at else None,
} if last_run else None,
"success_streak": success_streak,
"total_runs": db.query(Run).filter(Run.agent_id == a.id).count(),
})
return result
@app.post("/api/agents")
def create_agent(agent: AgentCreate, db: Session = Depends(get_db)):
existing = db.query(Agent).filter(Agent.id == agent.id).first()
if existing:
raise HTTPException(status_code=409, detail="Agent already exists")
new_agent = Agent(
id=agent.id,
name=agent.name,
description=agent.description,
schedule=agent.schedule,
config=agent.config,
)
db.add(new_agent)
db.commit()
return {"id": new_agent.id, "status": "created"}
@app.get("/api/agents/{agent_id}")
def get_agent(agent_id: str, user: str = Depends(require_auth), db: Session = Depends(get_db)):
agent = db.query(Agent).filter(Agent.id == agent_id).first()
if not agent:
raise HTTPException(status_code=404, detail="Agent not found")
runs = db.query(Run).filter(Run.agent_id == agent_id).order_by(Run.started_at.desc()).limit(50).all()
@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()
return {
"id": agent.id,
"name": agent.name,
"description": agent.description,
"schedule": agent.schedule,
"status": agent.status,
"config": agent.config or {},
"created_at": agent.created_at.isoformat() if agent.created_at else None,
"runs": [{
"id": r.id,
"started_at": r.started_at.isoformat() if r.started_at else None,
"finished_at": r.finished_at.isoformat() if r.finished_at else None,
"status": r.status,
"output": r.output,
"error": r.error,
"metadata": r.metadata_,
} for r in runs],
"id": u.id, "username": u.username, "display_name": u.display_name,
"role": u.role, "created_at": u.created_at.isoformat() if u.created_at else None,
}
@app.put("/api/agents/{agent_id}")
def update_agent(agent_id: str, update: AgentUpdate, db: Session = Depends(get_db)):
agent = db.query(Agent).filter(Agent.id == agent_id).first()
if not agent:
raise HTTPException(status_code=404, detail="Agent not found")
updates = update.model_dump(exclude_none=True)
if "config" in updates:
current_config = agent.config or {}
current_config.update(updates.pop("config"))
agent.config = current_config
for field, value in updates.items():
setattr(agent, field, value)
# --- Health ---
@app.get("/api/health")
def health():
return {"status": "ok", "service": "agent-command-center", "version": "2.0.0"}
# --- Agent Catalog ---
@app.get("/api/catalog")
def list_catalog(user: dict = Depends(require_auth), db: Session = Depends(get_db)):
entries = db.query(AgentCatalog).all()
# Check which ones this user already has instances of
user_instance_ids = {
i.catalog_id for i in db.query(AgentInstance).filter(AgentInstance.user_id == user["user_id"]).all()
}
return [{
"id": e.id, "name": e.name, "description": e.description,
"category": e.category, "config_schema": e.config_schema or {},
"default_config": e.default_config or {},
"supports_schedule": e.supports_schedule, "is_sub_agent": e.is_sub_agent,
"enabled": e.id in user_instance_ids,
} for e in entries]
@app.get("/api/catalog/{catalog_id}")
def get_catalog_entry(catalog_id: str, user: dict = Depends(require_auth), db: Session = Depends(get_db)):
entry = db.query(AgentCatalog).filter(AgentCatalog.id == catalog_id).first()
if not entry:
raise HTTPException(status_code=404)
return {
"id": entry.id, "name": entry.name, "description": entry.description,
"category": entry.category, "config_schema": entry.config_schema or {},
"default_config": entry.default_config or {},
"supports_schedule": entry.supports_schedule, "is_sub_agent": entry.is_sub_agent,
}
# --- Agent Instances (user-scoped) ---
def serialize_instance(inst, db):
last_run = db.query(Run).filter(Run.instance_id == inst.id).order_by(Run.started_at.desc()).first()
recent = db.query(Run).filter(Run.instance_id == inst.id).order_by(Run.started_at.desc()).limit(10).all()
streak = 0
for r in recent:
if r.status == "success":
streak += 1
else:
break
return {
"id": inst.id, "catalog_id": inst.catalog_id, "name": inst.name,
"config": inst.config or {}, "schedule": inst.schedule, "status": inst.status,
"created_at": inst.created_at.isoformat() if inst.created_at else None,
"last_run": {
"status": last_run.status,
"started_at": last_run.started_at.isoformat() if last_run.started_at else None,
"finished_at": last_run.finished_at.isoformat() if last_run.finished_at else None,
} if last_run else None,
"success_streak": streak,
"total_runs": db.query(Run).filter(Run.instance_id == inst.id).count(),
}
@app.get("/api/instances")
def list_instances(user: dict = Depends(require_auth), db: Session = Depends(get_db)):
instances = db.query(AgentInstance).filter(AgentInstance.user_id == user["user_id"]).all()
return [serialize_instance(i, db) for i in instances]
@app.post("/api/instances")
def create_instance(data: InstanceCreate, user: dict = Depends(require_auth), db: Session = Depends(get_db)):
catalog = db.query(AgentCatalog).filter(AgentCatalog.id == data.catalog_id).first()
if not catalog:
raise HTTPException(status_code=404, detail="Agent type not found in catalog")
config = {**(catalog.default_config or {}), **data.config}
inst = AgentInstance(
user_id=user["user_id"],
catalog_id=data.catalog_id,
name=data.name or catalog.name,
config=config,
schedule=data.schedule or ("sub-agent" if catalog.is_sub_agent else "manual"),
)
db.add(inst)
db.commit()
return {"id": agent.id, "status": "updated"}
return {"id": inst.id, "status": "created"}
@app.post("/api/agents/{agent_id}/runs")
def create_run(agent_id: str, run: RunCreate, db: Session = Depends(get_db)):
agent = db.query(Agent).filter(Agent.id == agent_id).first()
if not agent:
raise HTTPException(status_code=404, detail="Agent not found")
@app.get("/api/instances/{instance_id}")
def get_instance(instance_id: int, user: dict = Depends(require_auth), db: Session = Depends(get_db)):
inst = db.query(AgentInstance).filter(
AgentInstance.id == instance_id, AgentInstance.user_id == user["user_id"]
).first()
if not inst:
raise HTTPException(status_code=404)
runs = db.query(Run).filter(Run.instance_id == instance_id).order_by(Run.started_at.desc()).limit(50).all()
catalog = db.query(AgentCatalog).filter(AgentCatalog.id == inst.catalog_id).first()
result = serialize_instance(inst, db)
result["config_schema"] = catalog.config_schema if catalog else {}
result["runs"] = [{
"id": r.id,
"started_at": r.started_at.isoformat() if r.started_at else None,
"finished_at": r.finished_at.isoformat() if r.finished_at else None,
"status": r.status, "output": r.output, "error": r.error, "metadata": r.metadata_,
} for r in runs]
return result
@app.put("/api/instances/{instance_id}")
def update_instance(instance_id: int, update: InstanceUpdate, user: dict = Depends(require_auth), db: Session = Depends(get_db)):
inst = db.query(AgentInstance).filter(
AgentInstance.id == instance_id, AgentInstance.user_id == user["user_id"]
).first()
if not inst:
raise HTTPException(status_code=404)
if update.name is not None:
inst.name = update.name
if update.schedule is not None:
inst.schedule = update.schedule
if update.status is not None:
inst.status = update.status
if update.config is not None:
current = inst.config or {}
current.update(update.config)
inst.config = current
db.commit()
return {"id": inst.id, "status": "updated"}
@app.delete("/api/instances/{instance_id}")
def delete_instance(instance_id: int, user: dict = Depends(require_auth), db: Session = Depends(get_db)):
inst = db.query(AgentInstance).filter(
AgentInstance.id == instance_id, AgentInstance.user_id == user["user_id"]
).first()
if not inst:
raise HTTPException(status_code=404)
db.query(Run).filter(Run.instance_id == instance_id).delete()
db.delete(inst)
db.commit()
return {"status": "deleted"}
# --- Internal endpoints (no auth, for agent scripts) ---
@app.get("/api/instances/{instance_id}/config")
def get_instance_config(instance_id: int, db: Session = Depends(get_db)):
inst = db.query(AgentInstance).filter(AgentInstance.id == instance_id).first()
if not inst:
raise HTTPException(status_code=404)
return inst.config or {}
@app.post("/api/instances/{instance_id}/runs")
def create_run(instance_id: int, run: RunCreate, db: Session = Depends(get_db)):
inst = db.query(AgentInstance).filter(AgentInstance.id == instance_id).first()
if not inst:
raise HTTPException(status_code=404)
new_run = Run(
agent_id=agent_id,
instance_id=instance_id,
user_id=inst.user_id,
status=run.status,
output=run.output,
error=run.error,
@@ -229,46 +310,195 @@ def create_run(agent_id: str, run: RunCreate, db: Session = Depends(get_db)):
return {"id": new_run.id, "status": new_run.status}
@app.put("/api/runs/{run_id}")
def update_run(run_id: int, update: RunUpdate, db: Session = Depends(get_db)):
run = db.query(Run).filter(Run.id == run_id).first()
if not run:
raise HTTPException(status_code=404, detail="Run not found")
if update.status is not None:
run.status = update.status
if update.output is not None:
run.output = update.output
if update.error is not None:
run.error = update.error
if update.metadata is not None:
run.metadata_ = update.metadata
if update.finished_at is not None:
run.finished_at = datetime.fromisoformat(update.finished_at)
elif update.status in ("success", "failed"):
run.finished_at = datetime.now(timezone.utc)
db.commit()
return {"id": run.id, "status": run.status}
# --- Runs (user-scoped) ---
@app.get("/api/runs")
def list_runs(limit: int = 50, user: str = Depends(require_auth), db: Session = Depends(get_db)):
runs = db.query(Run).order_by(Run.started_at.desc()).limit(limit).all()
def list_runs(limit: int = 50, user: dict = Depends(require_auth), db: Session = Depends(get_db)):
runs = db.query(Run).filter(Run.user_id == user["user_id"]).order_by(Run.started_at.desc()).limit(limit).all()
return [{
"id": r.id,
"agent_id": r.agent_id,
"id": r.id, "instance_id": r.instance_id,
"started_at": r.started_at.isoformat() if r.started_at else None,
"finished_at": r.finished_at.isoformat() if r.finished_at else None,
"status": r.status,
"output": r.output,
"error": r.error,
"metadata": r.metadata_,
"status": r.status, "output": r.output, "error": r.error, "metadata": r.metadata_,
} for r in runs]
# --- Static files (frontend) ---
# --- Admin: Users ---
@app.get("/api/admin/users")
def admin_list_users(admin: dict = Depends(require_admin), db: Session = Depends(get_db)):
users = db.query(User).all()
return [{
"id": u.id, "username": u.username, "display_name": u.display_name,
"role": u.role, "created_at": u.created_at.isoformat() if u.created_at else None,
"instance_count": db.query(AgentInstance).filter(AgentInstance.user_id == u.id).count(),
} for u in users]
@app.post("/api/admin/users")
def admin_create_user(data: UserCreate, admin: dict = Depends(require_admin), db: Session = Depends(get_db)):
if db.query(User).filter(User.username == data.username).first():
raise HTTPException(status_code=409, detail="Username exists")
user = User(
username=data.username,
password_hash=hash_password(data.password),
display_name=data.display_name or data.username,
role=data.role,
)
db.add(user)
db.commit()
return {"id": user.id, "status": "created"}
@app.put("/api/admin/users/{user_id}")
def admin_update_user(user_id: int, update: UserUpdate, admin: dict = Depends(require_admin), db: Session = Depends(get_db)):
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404)
if update.display_name is not None:
user.display_name = update.display_name
if update.role is not None:
user.role = update.role
if update.password is not None:
user.password_hash = hash_password(update.password)
db.commit()
return {"id": user.id, "status": "updated"}
@app.delete("/api/admin/users/{user_id}")
def admin_delete_user(user_id: int, admin: dict = Depends(require_admin), db: Session = Depends(get_db)):
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404)
for inst in db.query(AgentInstance).filter(AgentInstance.user_id == user_id).all():
db.query(Run).filter(Run.instance_id == inst.id).delete()
db.delete(inst)
db.delete(user)
db.commit()
return {"status": "deleted"}
# --- Admin: LLM Providers ---
@app.get("/api/admin/llm-providers")
def admin_list_providers(admin: dict = Depends(require_admin), db: Session = Depends(get_db)):
providers = db.query(LLMProvider).all()
return [{
"id": p.id, "name": p.name, "provider_type": p.provider_type,
"api_url": p.api_url, "api_key": "***" if p.api_key else "",
"default_model": p.default_model, "is_default": p.is_default,
} for p in providers]
@app.post("/api/admin/llm-providers")
def admin_create_provider(data: LLMProviderCreate, admin: dict = Depends(require_admin), db: Session = Depends(get_db)):
if data.is_default:
db.query(LLMProvider).update({"is_default": False})
provider = LLMProvider(
name=data.name, provider_type=data.provider_type, api_url=data.api_url,
api_key=data.api_key, default_model=data.default_model, is_default=data.is_default,
)
db.add(provider)
db.commit()
return {"id": provider.id, "status": "created"}
@app.put("/api/admin/llm-providers/{provider_id}")
def admin_update_provider(provider_id: int, update: LLMProviderUpdate, admin: dict = Depends(require_admin), db: Session = Depends(get_db)):
provider = db.query(LLMProvider).filter(LLMProvider.id == provider_id).first()
if not provider:
raise HTTPException(status_code=404)
if update.is_default:
db.query(LLMProvider).update({"is_default": False})
for field, value in update.model_dump(exclude_none=True).items():
setattr(provider, field, value)
db.commit()
return {"id": provider.id, "status": "updated"}
@app.delete("/api/admin/llm-providers/{provider_id}")
def admin_delete_provider(provider_id: int, admin: dict = Depends(require_admin), db: Session = Depends(get_db)):
provider = db.query(LLMProvider).filter(LLMProvider.id == provider_id).first()
if not provider:
raise HTTPException(status_code=404)
db.delete(provider)
db.commit()
return {"status": "deleted"}
# --- Admin: Catalog Management ---
class CatalogCreate(BaseModel):
id: str
name: str
description: str = ""
category: str = "utility"
config_schema: dict = {}
default_config: dict = {}
supports_schedule: bool = True
is_sub_agent: bool = False
class CatalogUpdate(BaseModel):
name: Optional[str] = None
description: Optional[str] = None
category: Optional[str] = None
config_schema: Optional[dict] = None
default_config: Optional[dict] = None
supports_schedule: Optional[bool] = None
is_sub_agent: Optional[bool] = None
@app.post("/api/admin/catalog")
def admin_create_catalog(data: CatalogCreate, admin: dict = Depends(require_admin), db: Session = Depends(get_db)):
if db.query(AgentCatalog).filter(AgentCatalog.id == data.id).first():
raise HTTPException(status_code=409, detail="Catalog entry exists")
entry = AgentCatalog(**data.model_dump())
db.add(entry)
db.commit()
return {"id": entry.id, "status": "created"}
@app.put("/api/admin/catalog/{catalog_id}")
def admin_update_catalog(catalog_id: str, update: CatalogUpdate, admin: dict = Depends(require_admin), db: Session = Depends(get_db)):
entry = db.query(AgentCatalog).filter(AgentCatalog.id == catalog_id).first()
if not entry:
raise HTTPException(status_code=404)
for field, value in update.model_dump(exclude_none=True).items():
setattr(entry, field, value)
db.commit()
return {"id": entry.id, "status": "updated"}
@app.delete("/api/admin/catalog/{catalog_id}")
def admin_delete_catalog(catalog_id: str, admin: dict = Depends(require_admin), db: Session = Depends(get_db)):
entry = db.query(AgentCatalog).filter(AgentCatalog.id == catalog_id).first()
if not entry:
raise HTTPException(status_code=404)
db.delete(entry)
db.commit()
return {"status": "deleted"}
# --- Static files ---
app.mount("/static", StaticFiles(directory="static"), name="static")
@app.get("/login")
def login_page():
return FileResponse("static/login.html")
@app.get("/admin")
def admin_page(session: Optional[str] = Cookie(None)):
user = get_current_user(session)
if not user:
return RedirectResponse("/login", status_code=302)
if user["role"] != "admin":
return RedirectResponse("/", status_code=302)
return FileResponse("static/admin.html")
@app.get("/")
def root(session: Optional[str] = Cookie(None)):
user = get_current_user(session)