feat(agent): v4.10.1 - config LLM dans l'UI (sélecteur provider/modèle, config runtime, admin + test connexion)
FlowDeck CI / test (push) Failing after 7s
FlowDeck CI / docker (push) Skipped

- Panneau agent : selects provider/modèles connus persistés par conversation
  (colonnes agent_conversations.provider/model, migration idempotente)
- GET /api/agent/providers enrichi (liste providers + modèles + requires_key)
- PATCH /api/agent/providers (admin) : config runtime DB-backed (table llm_config)
- POST /api/agent/providers/test : test connexion via LLMClient.ping() sans fallback mock
- Settings > Admin > Agent & IA : provider, modèle, clé API, URL API, save + test
- VERSION -> 4.10.1, CHANGELOG + ROADMAP mis à jour, 232 tests verts
This commit is contained in:
2026-09-05 10:58:09 -04:00
parent e752e46583
commit 3cb9edc1f3
11 changed files with 546 additions and 19 deletions
+93 -10
View File
@@ -15,7 +15,8 @@ from app.db import get_conn
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
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.tool_registry import ToolRegistry
from app.services.permission_manager import PermissionManager
@@ -100,6 +101,25 @@ async def _workspace_id(request: Request) -> int | None:
return None
async def _current_admin(request: Request) -> dict:
"""Require an admin session. Falls back to the single admin row, matching
the agent router's unauthenticated convention (single-user deployments)."""
user = await get_current_user(request)
if user:
if not user.get("is_admin"):
from app.db import get_conn as _gc
with _gc() as conn:
row = conn.execute("SELECT is_admin FROM users WHERE id=?", (user.get("id"),)).fetchone()
if not row or not row["is_admin"]:
raise HTTPException(status_code=403, detail="Accès administrateur requis")
return user
with get_conn() as conn:
row = conn.execute("SELECT * FROM users WHERE login='admin' ORDER BY id LIMIT 1").fetchone()
if not row or not row["is_admin"]:
raise HTTPException(status_code=403, detail="Accès administrateur requis")
return dict(row)
def _default_agent(conn, user_id: int) -> dict:
row = conn.execute(
"SELECT * FROM agents WHERE agent_type='personal' ORDER BY id LIMIT 1"
@@ -174,10 +194,11 @@ async def create_conversation(request: Request):
with get_conn() as conn:
agent = _default_agent(conn, user_id)
cur = conn.execute(
"""INSERT INTO agent_conversations (agent_id, user_id, title, context_json)
VALUES (?,?,?,?)""",
"""INSERT INTO agent_conversations (agent_id, user_id, title, context_json, provider, model)
VALUES (?,?,?,?,?,?)""",
(agent["id"], user_id, body.get("title", "New conversation"),
json.dumps({"workspace_id": ws})),
json.dumps({"workspace_id": ws}),
body.get("provider", ""), body.get("model", "")),
)
conn.commit()
return {"id": cur.lastrowid, "title": body.get("title", "New conversation"), "status": "created"}
@@ -222,17 +243,26 @@ async def run_conversation(request: Request, conversation_id: int):
conv = conn.execute("SELECT * FROM agent_conversations WHERE id=?", (conversation_id,)).fetchone()
if not conv:
raise HTTPException(status_code=404, detail="Conversation introuvable")
eff_provider = body.get("provider") or conv["provider"] or None
eff_model = body.get("model") or conv["model"] or None
# Persist the selection so the same provider/model is reused next time.
if body.get("provider") is not None or body.get("model") is not None:
conn.execute(
"UPDATE agent_conversations SET provider=?, model=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
(body.get("provider", conv["provider"] or ""),
body.get("model", conv["model"] or ""),
conversation_id),
)
conn.commit()
engine = AgentEngine(user_id, workspace_id=ws)
provider = body.get("provider") or None
llm = LLMClient(provider=provider) if provider else None
if llm:
engine.llm = llm
if eff_provider:
engine.llm = LLMClient(provider=eff_provider)
async def event_stream():
async for ev in engine.run(
conversation_id, objective,
model=body.get("model"),
model=eff_model,
mentions=body.get("mentions"),
files=body.get("files"),
skill_id=body.get("skill_id"),
@@ -365,16 +395,69 @@ async def list_tools(request: Request):
@router.get("/providers")
async def list_providers(request: Request):
"""Expose configured LLM provider + availability to the UI."""
"""Expose the LLM providers list + current config to the UI."""
llm = LLMClient()
cfg = get_llm_config()
return {
"provider": llm.provider,
"model": llm.default_model,
"available": await llm.is_available(),
"max_iterations": settings.agent_max_iterations,
"api_base": cfg["api_base"],
"has_api_key": bool(cfg["api_key"]),
"providers": provider_info(),
}
@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()
if provider and provider not in PROVIDERS:
raise HTTPException(status_code=400, detail=f"Provider inconnu: {provider}")
_ = set_llm_config(
provider=provider or None,
model=(body.get("model") or "").strip() or None,
api_key=body.get("api_key"),
api_base=(body.get("api_base") or "").strip() or None,
)
llm = LLMClient()
return {
"status": "saved",
"provider": llm.provider,
"model": llm.default_model,
"available": await llm.is_available(),
"api_base": (body.get("api_base") or "").strip() or "",
"has_api_key": bool(llm.api_key),
}
@router.post("/providers/test")
async def test_provider_config(request: Request):
"""Admin: verify a provider is reachable (no mock fallback)."""
await _current_admin(request)
body = await request.json() if request.headers.get("content-type") else {}
provider = (body.get("provider") or "").strip().lower()
if provider and provider not in PROVIDERS:
raise HTTPException(status_code=400, detail=f"Provider inconnu: {provider}")
llm = LLMClient(
provider=provider or None,
api_key=body.get("api_key"),
api_base=(body.get("api_base") or "").strip() or None,
)
try:
resp = await llm.ping(model=(body.get("model") or "").strip() or None)
return {
"ok": True,
"model": resp.model or (body.get("model") or "").strip() or llm.default_model,
"reply": (resp.text or "").strip()[:200],
}
except Exception as exc: # noqa: BLE001 — surface real connectivity errors
return {"ok": False, "error": str(exc)}
# ── Agents by id (registered LAST so static routes /tools, /skills, … win) ──