feat(agent): v4.10.1 - config LLM dans l'UI (sélecteur provider/modèle, config runtime, admin + test connexion)
- 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:
+93
-10
@@ -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) ──
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user