feat(agent): v4.11.0 - clés API par utilisateur, chargement dynamique modèles, commandes slash
FlowDeck CI / test (push) Failing after 7s
FlowDeck CI / docker (push) Skipped

This commit is contained in:
2026-09-05 11:47:58 -04:00
parent 1c11b9d351
commit 34368a4946
10 changed files with 772 additions and 24 deletions
+111 -4
View File
@@ -16,7 +16,11 @@ from app.config import settings
from app.auth.session import get_current_user
from app.services.agent_engine import AgentEngine, undo_action
from app.services.llm_client import LLMClient, PROVIDERS
from app.services.llm_config import get_llm_config, set_llm_config, provider_info
from app.services.llm_config import (
get_llm_config, set_llm_config, provider_info,
get_user_llm_key, list_user_llm_keys, upsert_user_llm_key,
delete_user_llm_key, fetch_provider_models,
)
from app.services.tool_registry import ToolRegistry
from app.services.permission_manager import PermissionManager
@@ -227,6 +231,29 @@ async def delete_conversation(request: Request, conversation_id: int):
return {"id": conversation_id, "status": "deleted"}
@router.patch("/conversations/{conversation_id}")
async def patch_conversation(request: Request, conversation_id: int):
"""Update a conversation's title / provider / model (slash-command support)."""
user_id = await _current_user_id(request)
body = await request.json() if request.headers.get("content-type") else {}
with get_conn() as conn:
conv = conn.execute(
"SELECT id FROM agent_conversations WHERE id=? AND user_id=?",
(conversation_id, user_id),
).fetchone()
if not conv:
raise HTTPException(status_code=404, detail="Conversation introuvable")
sets, params = ["updated_at=CURRENT_TIMESTAMP"], []
for col in ("title", "provider", "model"):
if body.get(col) is not None:
sets.append(f"{col}=?")
params.append(str(body[col]))
params.append(conversation_id)
conn.execute(f"UPDATE agent_conversations SET {', '.join(sets)} WHERE id=?", params)
conn.commit()
return {"id": conversation_id, "status": "updated"}
# ── Run (SSE) ──
@@ -257,7 +284,12 @@ async def run_conversation(request: Request, conversation_id: int):
engine = AgentEngine(user_id, workspace_id=ws)
if eff_provider:
engine.llm = LLMClient(provider=eff_provider)
user_key = get_user_llm_key(user_id, eff_provider)
if user_key and user_key.get("api_key"):
engine.llm = LLMClient(provider=eff_provider, api_key=user_key["api_key"],
api_base=user_key.get("api_base") or None)
else:
engine.llm = LLMClient(provider=eff_provider)
async def event_stream():
async for ev in engine.run(
@@ -396,8 +428,19 @@ async def list_tools(request: Request):
@router.get("/providers")
async def list_providers(request: Request):
"""Expose the LLM providers list + current config to the UI."""
user_id = await _current_user_id(request)
llm = LLMClient()
cfg = get_llm_config()
keys = list_user_llm_keys(user_id)
keys_map = {k["provider"]: k for k in keys}
provs = provider_info()
for p in provs:
k = keys_map.get(p["id"])
if k and k["models"]:
for m in k["models"]:
if m not in p["models"]:
p["models"].append(m)
p["has_key"] = bool(k and k["has_key"])
return {
"provider": llm.provider,
"model": llm.default_model,
@@ -405,13 +448,77 @@ async def list_providers(request: Request):
"max_iterations": settings.agent_max_iterations,
"api_base": cfg["api_base"],
"has_api_key": bool(cfg["api_key"]),
"providers": provider_info(),
"providers": provs,
"keys": keys,
}
# ── Per-user provider API keys (non-admin: any authenticated user) ──
@router.get("/keys")
async def list_llm_keys(request: Request):
"""The user's saved provider keys + API keys (masked)."""
user_id = await _current_user_id(request)
return {"keys": list_user_llm_keys(user_id)}
@router.put("/keys/{llm_provider}")
async def save_llm_key(request: Request, llm_provider: str):
"""Upsert a provider key for the current user (masked in responses)."""
user_id = await _current_user_id(request)
provider = llm_provider.lower()
if provider not in PROVIDERS:
raise HTTPException(status_code=400, detail=f"Provider inconnu: {provider}")
body = await request.json() if request.headers.get("content-type") else {}
raw = upsert_user_llm_key(
user_id,
provider,
api_key=(body.get("api_key") or "").strip(),
api_base=(body.get("api_base") or "").strip(),
default_model=(body.get("default_model") or "").strip(),
models=body.get("models"),
)
stored = bool(raw.get("api_key"))
return {"status": "saved", "provider": provider, "has_key": stored}
@router.delete("/keys/{llm_provider}")
async def delete_llm_key(request: Request, llm_provider: str):
"""Remove a saved provider key for the current user."""
user_id = await _current_user_id(request)
provider = llm_provider.lower()
if provider not in PROVIDERS:
raise HTTPException(status_code=400, detail=f"Provider inconnu: {provider}")
delete_user_llm_key(user_id, provider)
return {"status": "deleted", "provider": provider}
@router.post("/keys/{llm_provider}/models")
async def fetch_llm_models(request: Request, llm_provider: str):
"""Fetch the live model list from a provider. Falls back to the user's
stored key when no key is supplied in the body."""
user_id = await _current_user_id(request)
provider = llm_provider.lower()
if provider not in PROVIDERS:
raise HTTPException(status_code=400, detail=f"Provider inconnu: {provider}")
body = await request.json() if request.headers.get("content-type") else {}
api_key = (body.get("api_key") or "").strip()
api_base = (body.get("api_base") or "").strip()
if not api_key:
stored = get_user_llm_key(user_id, provider)
if stored and stored.get("api_key"):
api_key = stored["api_key"]
api_base = api_base or (stored.get("api_base") or "").strip()
try:
models = await fetch_provider_models(provider, api_key=api_key, api_base=api_base)
return {"ok": True, "provider": provider, "models": models}
except Exception as exc: # noqa: BLE001 — surface connectivity errors
return {"ok": False, "provider": provider, "error": str(exc)}
@router.patch("/providers")
async def update_provider_config(request: Request):
"""Admin: persist the global LLM provider/model/key/base (runtime, DB-backed)."""
await _current_admin(request)
body = await request.json() if request.headers.get("content-type") else {}
provider = (body.get("provider") or "").strip().lower()