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
+38 -4
View File
@@ -40,6 +40,20 @@ PROVIDERS = {
"offline": (None, None),
}
# Curated model presets surfaced by /api/agent/providers for the UI selectors.
PROVIDER_MODELS: dict[str, list[str]] = {
"openai": ["gpt-4o", "gpt-4o-mini", "gpt-4.1", "gpt-4.1-mini", "o3-mini", "gpt-4-turbo"],
"anthropic": ["claude-opus-4-8", "claude-sonnet-4-5", "claude-3-5-sonnet", "claude-haiku-4-5"],
"google": ["gemini-2.0-pro", "gemini-2.0-flash", "gemini-1.5-pro", "gemini-1.5-flash"],
"deepseek": ["deepseek-chat", "deepseek-reasoner"],
"qwencloud": ["qwen-max", "qwen-plus", "qwen-turbo", "qwen-long"],
"nvidia": ["nvidia/llama-3.1-70b-instruct", "nvidia/nemotron-4-340b-instruct"],
"openrouter": ["meta-llama/llama-3.3-70b-instruct", "anthropic/claude-3.5-sonnet",
"openai/gpt-4o", "mistralai/mistral-large"],
"ollama": ["llama3.1", "llama3", "mistral", "qwen2.5", "gemma2", "mixtral"],
"offline": [],
}
# Llama-style / ChatML tool markers used by the mock planner.
_CREATE_PATTERNS = [
(re.compile(r"cr[eéé]er\s+(?:une\s+)?collection[:\s]+[\"']?([A-Za-zÀ-ÿ0-9 _\-]+)"),
@@ -76,12 +90,15 @@ class LLMClient:
def __init__(self, provider: str | None = None, api_key: str | None = None,
api_base: str | None = None):
self.provider = (provider or settings.llm_provider or "offline").lower()
self.api_key = api_key if api_key is not None else settings.llm_api_key
self.api_base = api_base if api_base is not None else settings.llm_api_base
from .llm_config import get_llm_config # local import avoids a cycle
cfg = get_llm_config()
self.provider = (provider or cfg["provider"] or "offline").lower()
self.api_key = api_key if api_key is not None else cfg["api_key"]
self.api_base = api_base if api_base is not None else cfg["api_base"]
base, model = PROVIDERS.get(self.provider, (None, None))
self.api_base = self.api_base or base
self.default_model = settings.llm_model or model or "gpt-4o"
self.default_model = cfg["model"] or model or "gpt-4o"
# ── Public API ──
@@ -107,6 +124,23 @@ class LLMClient:
"""True when a real provider is configured."""
return self.provider != "offline" and self._has_credentials()
async def ping(self, *, model: str | None = None) -> LLMResponse:
"""Reach the provider without mock fallback (used by the "Test connection"
UI). Raises on any real error so the caller can surface it."""
model = model or self.default_model
if self.provider == "offline":
return LLMResponse(text="Mode hors-ligne (mock) — aucun appel réseau nécessaire.", model=model)
if not self._has_credentials():
raise PermissionError(f"Clé API manquante pour le provider « {self.provider} »")
return await asyncio.wait_for(
self._http_complete(
[{"role": "user", "content": "Réponds uniquement par le mot : PONG"}],
model,
None,
),
timeout=settings.agent_run_timeout_seconds,
)
# ── Helpers ──
def _has_credentials(self) -> bool:
+82
View File
@@ -0,0 +1,82 @@
"""FlowDeck — Runtime LLM configuration store (v4.10.1).
Precedence: a saved `llm_config` DB row (id=1) overrides the .env settings.
The row is created only once an admin saves the LLM settings from the UI;
until then the app falls back to `settings.llm_*` (default: offline mock).
"""
from __future__ import annotations
from app.config import settings
from app.services.llm_client import PROVIDERS, PROVIDER_MODELS
__all__ = ["get_llm_config", "set_llm_config", "provider_info"]
def get_llm_config() -> dict:
"""Return the effective LLM config — DB overrides .env when present."""
cfg = {
"provider": settings.llm_provider or "offline",
"model": settings.llm_model or "gpt-4o",
"api_key": settings.llm_api_key or "",
"api_base": settings.llm_api_base or "",
}
try:
from app.db import get_conn
with get_conn() as conn:
row = conn.execute(
"SELECT provider, model, api_key, api_base FROM llm_config WHERE id=1"
).fetchone()
except Exception: # noqa: BLE001 — DB not ready yet → env defaults
return cfg
if row:
for key in ("provider", "model", "api_key", "api_base"):
if row[key]:
cfg[key] = row[key]
return cfg
def set_llm_config(*, provider: str | None = None, model: str | None = None,
api_key: str | None = None, api_base: str | None = None) -> dict:
"""Upsert the runtime LLM config row (id=1) and return the new effective config."""
from app.db import get_conn
cfg = get_llm_config()
if provider is not None:
cfg["provider"] = provider
if model is not None:
cfg["model"] = model
if api_key is not None:
cfg["api_key"] = api_key
if api_base is not None:
cfg["api_base"] = api_base
with get_conn() as conn:
conn.execute(
"""INSERT INTO llm_config (id, provider, model, api_key, api_base, updated_at)
VALUES (1, ?, ?, ?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(id) DO UPDATE SET
provider=excluded.provider, model=excluded.model,
api_key=excluded.api_key, api_base=excluded.api_base,
updated_at=CURRENT_TIMESTAMP""",
(cfg["provider"], cfg["model"], cfg["api_key"], cfg["api_base"]),
)
conn.commit()
return cfg
def provider_info() -> list[dict]:
"""Providers list for the UI: known models + whether an API key is required."""
out: list[dict] = []
for name, (_base, default_model) in PROVIDERS.items():
models = list(PROVIDER_MODELS.get(name) or ())
if default_model and default_model not in models:
models.insert(0, default_model)
out.append({
"id": name,
"name": name.capitalize(),
"default_model": default_model,
"models": models,
"requires_key": name not in ("offline", "ollama"),
})
return out