feat(agent): v4.12.0 - refonte config 'Agent & IA', fournisseurs actifs/testes dans le panneau, contexte document + guide d'utilisation
FlowDeck CI / test (push) Failing after 9s
FlowDeck CI / docker (push) Skipped

This commit is contained in:
2026-09-05 22:47:16 -04:00
parent bd109c273a
commit c322f30801
10 changed files with 798 additions and 150 deletions
+126 -13
View File
@@ -15,11 +15,12 @@ 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, PROVIDERS
from app.services.llm_client import LLMClient, PROVIDERS, PROVIDER_MODELS
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,
mark_llm_config_verified, mark_user_llm_key_verified,
)
from app.services.tool_registry import ToolRegistry
from app.services.permission_manager import PermissionManager
@@ -298,6 +299,7 @@ async def run_conversation(request: Request, conversation_id: int):
mentions=body.get("mentions"),
files=body.get("files"),
skill_id=body.get("skill_id"),
extra_context=body.get("context"),
):
yield f"data: {json.dumps(ev, ensure_ascii=False)}\n\n"
@@ -427,12 +429,26 @@ 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."""
"""Expose the LLM providers list + current config to the UI.
Every provider known by FlowDeck is returned (the Settings UI lets the user
configure any of them). Each provider also carries activation flags used by
the Agent panel to offer only the providers that are *configured and
functional* for the current user:
- ``configured`` : a usable credential exists (own key OR the workspace
default provider carries a key, OR no key is required).
- ``verified`` : the last connection test / model fetch succeeded.
- ``functional`` : the provider is ready to chat (verified, or `offline`).
"""
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}
default_provider = (cfg.get("provider") or "offline").lower()
global_has_key = bool(cfg.get("api_key")) and default_provider != "offline"
global_verified = bool(cfg.get("verified")) and global_has_key
provs = provider_info()
for p in provs:
k = keys_map.get(p["id"])
@@ -440,7 +456,28 @@ async def list_providers(request: Request):
for m in k["models"]:
if m not in p["models"]:
p["models"].append(m)
p["has_key"] = bool(k and k["has_key"])
user_has_key = bool(k and k["has_key"])
is_default = p["id"] == default_provider
configured = bool(k and k["has_key"]) or (is_default and global_has_key) \
or not p["requires_key"]
user_verified = bool(k and k.get("verified"))
verified = (user_verified and bool(k and k["has_key"])) or \
(is_default and global_has_key and global_verified)
if not p["requires_key"] and not (k and k["has_key"]) and not (is_default and global_has_key):
# ollama / offline: no key needed, but only offline is usable as-is;
# local providers must still pass a connection test.
verified = bool(user_verified or (is_default and global_verified)) \
if p["id"] != "offline" else True
p["has_key"] = user_has_key
p["configured"] = bool(configured)
p["source"] = ("user" if user_has_key else
"global" if is_default and global_has_key else "open")
p["verified"] = bool(verified)
p["verified_model"] = (k.get("verified_model") if user_has_key and k else
cfg.get("verified_model") if is_default and global_has_key else "") or ""
p["last_error"] = (k.get("last_error") if user_has_key and k else
cfg.get("last_error") if is_default and global_has_key else "") or ""
p["functional"] = p["id"] == "offline" or bool(verified)
return {
"provider": llm.provider,
"model": llm.default_model,
@@ -448,6 +485,8 @@ async def list_providers(request: Request):
"max_iterations": settings.agent_max_iterations,
"api_base": cfg["api_base"],
"has_api_key": bool(cfg["api_key"]),
"default_provider": default_provider,
"default_verified": bool(cfg.get("verified")),
"providers": provs,
"keys": keys,
}
@@ -494,10 +533,68 @@ async def delete_llm_key(request: Request, llm_provider: str):
return {"status": "deleted", "provider": provider}
@router.post("/keys/{llm_provider}/test")
async def test_user_llm_key(request: Request, llm_provider: str):
"""Non-admin: verify one of the user's own provider keys (no mock fallback).
On success the provider is flagged ``verified`` so it can be offered in the
Agent panel; on failure the stored error is kept for display in Settings.
"""
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 {}
stored = get_user_llm_key(user_id, provider)
api_key = (body.get("api_key") or "").strip()
api_base = (body.get("api_base") or "").strip()
if not api_key and stored and stored.get("api_key"):
api_key = stored["api_key"]
if not api_base:
api_base = (stored.get("api_base") or "").strip() if stored else ""
_, provider_default = PROVIDERS.get(provider, (None, "gpt-4o"))
candidates = PROVIDER_MODELS.get(provider) or [provider_default]
model = (body.get("model") or "").strip() or \
((stored or {}).get("default_model") or "") or \
next((m for m in candidates if m), provider_default)
# Only record the result against the *stored* credential when it is the one
# being tested (avoids flagging a stored key from a test run on a typed key).
can_record = bool(stored) and (
not (stored or {}).get("api_key") or
api_key == (stored or {}).get("api_key")
)
llm = LLMClient(provider=provider, api_key=api_key, api_base=api_base or None)
try:
resp = await llm.ping(model=model or None)
except Exception as exc: # noqa: BLE001 — surface connectivity errors
if can_record:
mark_user_llm_key_verified(user_id, provider, False, error=str(exc))
return {"ok": False, "provider": provider, "error": str(exc), "verified": False}
if not stored and provider in ("ollama", "offline"):
stored = upsert_user_llm_key(user_id, provider, api_key="", api_base=api_base)
can_record = True
if can_record:
mark_user_llm_key_verified(
user_id, provider, True,
model=resp.model or model or "",
)
return {
"ok": True,
"provider": provider,
"model": resp.model or model or "",
"reply": (resp.text or "").strip()[:200],
"verified": bool(can_record),
}
@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."""
stored key when no key is supplied in the body.
A successful fetch proves connectivity, so when it used the *stored* key the
provider is flagged ``verified`` (functional) for the Agent panel.
"""
user_id = await _current_user_id(request)
provider = llm_provider.lower()
if provider not in PROVIDERS:
@@ -505,13 +602,18 @@ async def fetch_llm_models(request: Request, llm_provider: str):
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()
used_stored = False
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()
used_stored = True
try:
models = await fetch_provider_models(provider, api_key=api_key, api_base=api_base)
if used_stored and models:
mark_user_llm_key_verified(user_id, provider, True,
model=(body.get("default_model") or "").strip())
return {"ok": True, "provider": provider, "models": models}
except Exception as exc: # noqa: BLE001 — surface connectivity errors
return {"ok": False, "provider": provider, "error": str(exc)}
@@ -543,26 +645,37 @@ async def update_provider_config(request: Request):
@router.post("/providers/test")
async def test_provider_config(request: Request):
"""Admin: verify a provider is reachable (no mock fallback)."""
"""Admin: verify a provider is reachable (no mock fallback).
A successful test flags the workspace default provider as ``verified`` so it
becomes available (functional) for every user in the Agent panel.
"""
await _current_admin(request)
body = await request.json() if request.headers.get("content-type") else {}
provider = (body.get("provider") or "").strip().lower()
provider = (body.get("provider") or "").strip().lower() or None
if provider and provider not in PROVIDERS:
raise HTTPException(status_code=400, detail=f"Provider inconnu: {provider}")
llm = LLMClient(
provider=provider or None,
provider=provider,
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)}
cfg = get_llm_config()
if (provider or cfg.get("provider")) == cfg.get("provider"):
mark_llm_config_verified(False, error=str(exc))
return {"ok": False, "error": str(exc), "verified": False}
cfg = get_llm_config()
if (provider or cfg.get("provider")) == cfg.get("provider"):
mark_llm_config_verified(True, model=resp.model or llm.default_model or "")
return {
"ok": True,
"model": resp.model or (body.get("model") or "").strip() or llm.default_model,
"reply": (resp.text or "").strip()[:200],
"verified": True,
}
# ── Agents by id (registered LAST so static routes /tools, /skills, … win) ──