feat(agent): v4.11.0 - clés API par utilisateur, chargement dynamique modèles, commandes slash
This commit is contained in:
+36
-1
@@ -1,6 +1,41 @@
|
||||
# Changelog — FlowDeck
|
||||
|
||||
## v4.10.1 (2026-09-05) — Agent IA : configuration LLM dans l'UI
|
||||
## v4.11.0 (2026-09-05) — Agent IA : clés API par utilisateur & chargement dynamique des modèles
|
||||
|
||||
> Suite de v4.10.1 : migre la config LLM d'un **global admin** vers des **credentials par
|
||||
> utilisateur**. Chacun peut enregistrer plusieurs provider + clé API dans son profil, les modèles
|
||||
> disponibles sont **chargés automatiquement depuis le fournisseur**, et le provider/modèle actif
|
||||
> d'une conversation se pilote **directement dans le chat** via des commandes slash.
|
||||
|
||||
### Added
|
||||
- **Clés par utilisateur** : table `user_llm_keys` (UNIQUE(user_id, provider)) + service
|
||||
`app/services/llm_config.py` : `get_user_llm_key`, `list_user_llm_keys` (masqué),
|
||||
`upsert_user_llm_key`, `delete_user_llm_key`. Une clé vide conserve l'existante.
|
||||
- **Chargement dynamique des modèles** : `fetch_provider_models()` (httpx) — GET `{base}/models`
|
||||
pour OpenAI-compatible (Bearer), headers `x-api-key`/`anthropic-version` (Anthropic),
|
||||
`x-goog-api-key` (Gemini) ; stockés dans `models_json` et fusionnés dans `GET /providers`.
|
||||
- **Endpoints API** (tout utilisateur authentifié) :
|
||||
- `GET /api/agent/keys`, `PUT/DELETE /api/agent/keys/{provider}`,
|
||||
- `POST /api/agent/keys/{provider}/models` (fallback sur la clé stockée, pas de fuite de clé),
|
||||
- `PATCH /api/agent/conversations/{id}` (title / provider / model — commande slash).
|
||||
- **`POST /run`** : utilise la clé API de l'utilisateur pour le provider de la conversation
|
||||
(sinon config globale / `.env` / offline mock).
|
||||
- **UI Settings → Agent & IA** (visible pour tous, plus seulement admin) : carte par provider
|
||||
(clé API masquée, URL API, « Charger les modèles » en live, modèle par défaut,
|
||||
Enregistrer / Supprimer). La config globale reste admin sous « Configuration globale ».
|
||||
- **Commandes slash dans le panel agent** : `/help`, `/providers`, `/keys`,
|
||||
`/provider <nom>`, `/model <nom>` — menu de suggestions live, appliquées via `PATCH`
|
||||
et sans appel LLM.
|
||||
|
||||
### Infra
|
||||
- `VERSION` → 4.11.0 ; migration idempotente (création table `user_llm_keys` + index).
|
||||
|
||||
### Tests
|
||||
- **239 tests verts** (+7 : CRUD clés + masquage des secrets, isolation par utilisateur,
|
||||
provider invalide, fetch modèles (clé stockée / body gagnant / erreur), PATCH conversation,
|
||||
`/run` utilise la clé de l'utilisateur).
|
||||
|
||||
|
||||
|
||||
> Comble le trou UX de v4.10.0 : l'utilisateur pouvait discuter avec l'agent mais **ne pouvait
|
||||
> configurer ni provider ni modèle**. Ajout d'un sélecteur provider/modèle dans le panneau, d'une
|
||||
|
||||
@@ -323,6 +323,13 @@ Détails livrés :
|
||||
- [x] **Settings** — toggles réels dans Settings → Notifications (Commentaires / Mentions)
|
||||
- [x] **208 tests passent** (+9 v4.9.0)
|
||||
|
||||
### v4.11.0 — Agent IA : clés API par utilisateur & commandes slash ✅
|
||||
|
||||
> **Livré (2026-09-05)** : credentials par utilisateur (table `user_llm_keys`, plusieurs providers),
|
||||
> chargement dynamique des modèles depuis le fournisseur (`POST /keys/{p}/models`),
|
||||
> commandes slash dans le chat (`/provider`, `/model`, `/keys`, … via `PATCH` conversation),
|
||||
> `/run` utilise la clé de l'utilisateur. 239 tests verts.
|
||||
|
||||
### v4.10.1 — Agent IA : config LLM dans l'UI ✅
|
||||
|
||||
> **Livré (2026-09-05)** : sélecteur provider/modèle dans le panneau agent (persisté par
|
||||
|
||||
@@ -740,6 +740,24 @@ def init_db():
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""")
|
||||
# ─── v4.10.2: per-user provider API keys ──────────────────────────────
|
||||
# Each user can save several providers with their own key/base + the
|
||||
# live model list fetched from the provider (models_json cache).
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS user_llm_keys (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
provider TEXT NOT NULL,
|
||||
api_key TEXT NOT NULL DEFAULT '',
|
||||
api_base TEXT NOT NULL DEFAULT '',
|
||||
default_model TEXT DEFAULT '',
|
||||
models_json TEXT NOT NULL DEFAULT '[]',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(user_id, provider)
|
||||
)
|
||||
""")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_user_llm_keys_user ON user_llm_keys(user_id)")
|
||||
# Migration: per-conversation provider/model override columns
|
||||
for col in ("provider", "model"):
|
||||
try:
|
||||
|
||||
+1
-1
@@ -60,7 +60,7 @@ async def lifespan(_app: FastAPI):
|
||||
|
||||
app = FastAPI(
|
||||
title="FlowDeck",
|
||||
version="4.10.1",
|
||||
version="4.11.0",
|
||||
docs_url="/docs" if settings.log_level == "DEBUG" else None,
|
||||
redoc_url=None,
|
||||
lifespan=lifespan,
|
||||
|
||||
+111
-4
@@ -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()
|
||||
|
||||
+154
-6
@@ -1,15 +1,26 @@
|
||||
"""FlowDeck — Runtime LLM configuration store (v4.10.1).
|
||||
"""FlowDeck — Runtime LLM configuration store (v4.10.2).
|
||||
|
||||
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).
|
||||
Precedence (per conversation):
|
||||
1. explicit `provider`/`model` passed to the run endpoint,
|
||||
2. the user's saved key for that provider (`user_llm_keys`),
|
||||
3. the global `llm_config` row (id=1) — admin UI,
|
||||
4. `settings.llm_*` (.env), default « offline mock ».
|
||||
|
||||
Rows are created lazily, so .env stays the default until saved from the UI.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Optional
|
||||
|
||||
from app.config import settings
|
||||
from app.services.llm_client import PROVIDERS, PROVIDER_MODELS
|
||||
|
||||
__all__ = ["get_llm_config", "set_llm_config", "provider_info"]
|
||||
__all__ = [
|
||||
"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",
|
||||
]
|
||||
|
||||
|
||||
def get_llm_config() -> dict:
|
||||
@@ -79,4 +90,141 @@ def provider_info() -> list[dict]:
|
||||
"models": models,
|
||||
"requires_key": name not in ("offline", "ollama"),
|
||||
})
|
||||
return out
|
||||
return out
|
||||
|
||||
|
||||
# ── Per-user provider keys ──
|
||||
|
||||
|
||||
def _mask_key(row) -> dict:
|
||||
"""Public view of a stored key row: never exposes the raw api_key."""
|
||||
return {
|
||||
"provider": row["provider"],
|
||||
"api_base": row["api_base"] or "",
|
||||
"default_model": row["default_model"] or "",
|
||||
"models": json.loads(row["models_json"] or "[]"),
|
||||
"has_key": bool(row["api_key"]),
|
||||
}
|
||||
|
||||
|
||||
def get_user_llm_key(user_id: int, provider: str) -> Optional[dict]:
|
||||
"""Return a stored key row (includes the raw api_key — server-side only)."""
|
||||
from app.db import get_conn
|
||||
|
||||
provider = provider.lower()
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM user_llm_keys WHERE user_id=? AND provider=?",
|
||||
(user_id, provider),
|
||||
).fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
def list_user_llm_keys(user_id: int) -> list[dict]:
|
||||
"""Public (masked) list of the user's saved provider keys."""
|
||||
from app.db import get_conn
|
||||
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM user_llm_keys WHERE user_id=? ORDER BY provider",
|
||||
(user_id,),
|
||||
).fetchall()
|
||||
return [_mask_key(r) for r in rows]
|
||||
|
||||
|
||||
def upsert_user_llm_key(user_id: int, provider: str, *, api_key: str = "",
|
||||
api_base: str = "", default_model: str = "",
|
||||
models: Optional[list[str]] = None) -> dict:
|
||||
"""Upsert a user's provider key. Empty api_key keeps the existing one
|
||||
(allows saving model/base without re-typing the key)."""
|
||||
from app.db import get_conn
|
||||
|
||||
provider = provider.lower()
|
||||
existing = get_user_llm_key(user_id, provider)
|
||||
new_key = api_key if api_key else (existing.get("api_key", "") if existing else "")
|
||||
new_base = api_base if api_base else (existing.get("api_base", "") if existing else "")
|
||||
new_model = default_model if default_model else (existing.get("default_model", "") if existing else "")
|
||||
new_models = models if models is not None else (
|
||||
json.loads(existing["models_json"]) if existing and existing.get("models_json") else []
|
||||
)
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"""INSERT INTO user_llm_keys (user_id, provider, api_key, api_base, default_model, models_json, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT(user_id, provider) DO UPDATE SET
|
||||
api_key=excluded.api_key, api_base=excluded.api_base,
|
||||
default_model=excluded.default_model, models_json=excluded.models_json,
|
||||
updated_at=CURRENT_TIMESTAMP""",
|
||||
(user_id, provider, new_key, new_base, new_model,
|
||||
json.dumps(new_models, ensure_ascii=False)),
|
||||
)
|
||||
conn.commit()
|
||||
return get_user_llm_key(user_id, provider) or {
|
||||
"provider": provider, "api_key": new_key, "api_base": new_base,
|
||||
"default_model": new_model, "models_json": json.dumps(new_models, ensure_ascii=False),
|
||||
}
|
||||
|
||||
|
||||
def delete_user_llm_key(user_id: int, provider: str) -> None:
|
||||
from app.db import get_conn
|
||||
|
||||
provider = provider.lower()
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"DELETE FROM user_llm_keys WHERE user_id=? AND provider=?",
|
||||
(user_id, provider),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
async def fetch_provider_models(provider: str, *, api_key: str = "",
|
||||
api_base: str = "", timeout: int = 20) -> list[str]:
|
||||
"""Fetch the live model list from a provider (best-effort, no mock).
|
||||
|
||||
OpenAI-compatible providers use `GET {base}/models` with a Bearer token;
|
||||
Anthropic uses `x-api-key` + `anthropic-version`; Gemini an `x-goog-api-key`.
|
||||
Returns a de-duplicated list capped at 300 models.
|
||||
"""
|
||||
import httpx
|
||||
|
||||
provider = provider.lower()
|
||||
base = (api_base or "").strip() or (PROVIDERS.get(provider) or (None, None))[0]
|
||||
if not base:
|
||||
return [] # offline — nothing to fetch
|
||||
|
||||
base_url = base.rstrip("/")
|
||||
headers: dict = {}
|
||||
if provider == "anthropic":
|
||||
headers = {"x-api-key": api_key, "anthropic-version": "2023-06-01"}
|
||||
elif provider == "google":
|
||||
headers = {"x-goog-api-key": api_key}
|
||||
elif api_key:
|
||||
headers = {"Authorization": f"Bearer {api_key}"}
|
||||
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
resp = await client.get(f"{base_url}/models", headers=headers)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
|
||||
ids: list[str] = []
|
||||
for item in data.get("data") or []:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
i = (item.get("id") or "").strip()
|
||||
if i:
|
||||
ids.append(i)
|
||||
for item in data.get("models") or []:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
n = (item.get("name") or item.get("id") or "").strip()
|
||||
if provider == "google" and n.startswith("models/"):
|
||||
n = n[len("models/"):]
|
||||
if n:
|
||||
ids.append(n)
|
||||
seen: set[str] = set()
|
||||
out: list[str] = []
|
||||
for i in ids:
|
||||
if i not in seen:
|
||||
seen.add(i)
|
||||
out.append(i)
|
||||
return out[:300]
|
||||
@@ -31,6 +31,12 @@
|
||||
.fd-agent-config{display:flex;align-items:center;gap:6px;padding:6px 10px;border-bottom:1px solid var(--border,rgba(255,255,255,.06))}
|
||||
.fd-agent-select{flex:1;min-width:0;background:var(--bg-tertiary,#262626);border:1px solid var(--border,rgba(255,255,255,.1));border-radius:7px;color:var(--text,#fff);font-size:12px;padding:5px 8px;outline:none;font-family:inherit}
|
||||
.fd-agent-select:focus{border-color:var(--accent,#2383E2)}
|
||||
.fd-agent-cmd-menu{position:absolute;bottom:100%;left:0;right:0;margin-bottom:6px;background:var(--bg-tertiary,#262626);border:1px solid var(--border,rgba(255,255,255,.1));border-radius:10px;max-height:180px;overflow-y:auto;box-shadow:0 -8px 24px rgba(0,0,0,.4);z-index:20;display:none}
|
||||
.fd-agent-cmd-menu.show{display:block}
|
||||
.fd-agent-cmd-item{padding:7px 12px;font-size:12px;color:var(--text,#eee);cursor:pointer;border-radius:6px;margin:2px;display:flex;gap:8px;align-items:baseline}
|
||||
.fd-agent-cmd-item:hover{background:var(--bg-hover,#333)}
|
||||
.fd-agent-cmd-item b{color:var(--accent,#2383E2);font-family:monospace;white-space:nowrap}
|
||||
.fd-agent-cmd-item span{color:var(--text-dim,#999);font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.fd-agent-gear{background:none;border:none;color:var(--text-dim,#999);font-size:14px;cursor:pointer;padding:2px 4px;border-radius:6px;text-decoration:none;line-height:1}
|
||||
.fd-agent-gear:hover{background:var(--bg-hover,#333);color:var(--text,#fff)}
|
||||
</style>
|
||||
@@ -55,7 +61,7 @@
|
||||
<option :value="m" x-text="m"></option>
|
||||
</template>
|
||||
</select>
|
||||
<a href="/settings" target="_blank" class="fd-agent-gear" title="Configurer (clé API, admin)" @click.stop>⚙️</a>
|
||||
<a href="/settings" target="_blank" class="fd-agent-gear" title="Configurer (clés API, Settings → Agent & IA)" @click.stop>⚙️</a>
|
||||
</div>
|
||||
|
||||
<div class="fd-agent-tabs">
|
||||
@@ -94,7 +100,16 @@
|
||||
</div>
|
||||
|
||||
<div class="fd-agent-input-area">
|
||||
<textarea class="fd-agent-input" rows="2" placeholder="Demandez à l'agent (ex: crée une collection Projets)…" x-model="input" @keydown.enter.exact.prevent="send()"></textarea>
|
||||
<div style="position:relative;flex:1;">
|
||||
<div class="fd-agent-cmd-menu" :class="{show: commandOpen && commandSuggestions.length}">
|
||||
<template x-for="c in commandSuggestions" :key="c.cmd">
|
||||
<div class="fd-agent-cmd-item" @click="useSuggestion(c)">
|
||||
<b x-text="c.cmd"></b><span x-text="c.desc"></span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<textarea class="fd-agent-input" x-ref="cmdInput" rows="2" placeholder="Demandez à l'agent (ex: crée une collection Projets)… tapez / pour les commandes" x-model="input" @keydown.enter.exact.prevent="send()"></textarea>
|
||||
</div>
|
||||
<button class="fd-agent-send" @click="send()" :disabled="streaming || !input.trim()">▶</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -108,7 +123,7 @@
|
||||
agents: [], conversations: [], currentConv: null,
|
||||
messages: [], input: '', streaming: false, liveReasoning: '',
|
||||
provider: 'offline', providerAvailable: false, errorMsg: '',
|
||||
providers: [], providerModels: [], llmProvider: 'offline', llmModel: '',
|
||||
providers: [], providerModels: [], llmProvider: 'offline', llmModel: '', llmKeys: [],
|
||||
|
||||
init(){
|
||||
var self = this;
|
||||
@@ -128,6 +143,7 @@
|
||||
self.provider = d.provider || 'offline';
|
||||
self.providerAvailable = !!d.available;
|
||||
self.providers = d.providers || [];
|
||||
self.llmKeys = d.keys || [];
|
||||
if(!self.llmProvider) self.llmProvider = d.provider || 'offline';
|
||||
if(!self.llmModel) self.llmModel = d.model || '';
|
||||
self.applyModels();
|
||||
@@ -188,10 +204,100 @@
|
||||
},
|
||||
openAgent(){ this.open=true; this.loadConversations(); },
|
||||
close(){ this.open=false; },
|
||||
get commandOpen(){ return /^\s*\//.test(this.input) && !this.streaming; },
|
||||
get commandSuggestions(){
|
||||
var raw = (this.input||'').trim();
|
||||
if(!/^\//.test(raw)) return [];
|
||||
var q = raw.replace(/^\/+/, '').toLowerCase();
|
||||
var first = (q.split(/\s+/).filter(Boolean)[0] || '');
|
||||
var all = [
|
||||
{cmd:'/help', desc:'liste des commandes'},
|
||||
{cmd:'/providers', desc:'liste les fournisseurs et leurs clés'},
|
||||
{cmd:'/provider <nom>', desc:'changer de fournisseur LLM'},
|
||||
{cmd:'/model <nom>', desc:'changer de modèle LLM'},
|
||||
{cmd:'/keys', desc:'fournisseurs avec clé API enregistrée'}
|
||||
];
|
||||
if(!first) return all;
|
||||
return all.filter(function(c){ return c.cmd.toLowerCase().replace(/^\/+/, '').indexOf(first) === 0; });
|
||||
},
|
||||
useSuggestion(c){
|
||||
var v = c.cmd.replace(/\s*<[^>]*>\s*$/, ' ');
|
||||
this.input = v;
|
||||
if(this.$refs.cmdInput) this.$refs.cmdInput.focus();
|
||||
},
|
||||
echo(msg){
|
||||
this.messages.push({id:'cmd-'+Date.now()+Math.random(), role:'assistant', content: msg, model:''});
|
||||
this.scrollBottom();
|
||||
},
|
||||
patchConversation(){
|
||||
var self = this;
|
||||
if(!self.currentConv) return;
|
||||
var body = {};
|
||||
if(self.llmProvider) body.provider = self.llmProvider;
|
||||
if(self.llmModel) body.model = self.llmModel;
|
||||
fetch('/api/agent/conversations/'+self.currentConv.id, {
|
||||
method:'PATCH',
|
||||
headers:{'Content-Type':'application/json'},
|
||||
body: JSON.stringify(body)
|
||||
}).catch(function(){});
|
||||
},
|
||||
handleCommand(text){
|
||||
var self = this;
|
||||
var parts = text.replace(/^\/+/, '').trim().split(/\s+/);
|
||||
var cmd = (parts.shift() || '').toLowerCase();
|
||||
var arg = parts.join(' ').trim();
|
||||
if(cmd === 'help' || cmd === 'aide' || cmd === '?'){
|
||||
this.echo('Commandes : /providers · /keys · /provider <nom> · /model <nom> · /help');
|
||||
return true;
|
||||
}
|
||||
if(cmd === 'providers'){
|
||||
var list = this.providers.map(function(x){ return x.id + (x.has_key ? ' ✓' : ''); }).join(', ');
|
||||
this.echo('Fournisseurs : ' + (list || 'aucun'));
|
||||
return true;
|
||||
}
|
||||
if(cmd === 'keys'){
|
||||
var saved = this.llmKeys.map(function(k){ return k.provider + ' ✓'; }).join(', ');
|
||||
this.echo('Clés enregistrées : ' + (saved || 'aucune — configurez-les dans Paramètres → Agent & IA'));
|
||||
return true;
|
||||
}
|
||||
if(cmd === 'provider'){
|
||||
var avail = this.providers.map(function(x){ return x.id; });
|
||||
if(!arg){
|
||||
this.echo('Utilisez : /provider <nom>. Fournisseurs : ' + avail.join(', '));
|
||||
return true;
|
||||
}
|
||||
var p = this.providers.find(function(x){ return x.id === arg.toLowerCase(); });
|
||||
if(!p){
|
||||
this.echo('Fournisseur inconnu : ' + arg + ' (disponibles : ' + avail.join(', ') + ')');
|
||||
return true;
|
||||
}
|
||||
this.llmProvider = p.id;
|
||||
this.onProviderChange();
|
||||
if(p.models && p.models.length && this.providerModels.indexOf(this.llmModel) === -1){
|
||||
this.llmModel = (p.default_model) || p.models[0];
|
||||
}
|
||||
this.patchConversation();
|
||||
this.echo('Provider activé : ' + p.id + ' · modèle : ' + (this.llmModel || 'défaut'));
|
||||
return true;
|
||||
}
|
||||
if(cmd === 'model'){
|
||||
if(!arg){
|
||||
this.echo('Utilisez : /model <nom>. Modèles pour ' + this.llmProvider + ' : ' + this.providerModels.join(', '));
|
||||
return true;
|
||||
}
|
||||
this.llmModel = arg;
|
||||
this.patchConversation();
|
||||
this.echo('Modèle activé : ' + arg + ' (' + this.llmProvider + ')');
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
send(){
|
||||
var self = this;
|
||||
if(!self.currentConv || self.streaming || !self.input.trim()) return;
|
||||
if(!self.currentConv || self.streaming) return;
|
||||
var text = self.input.trim();
|
||||
if(!text) return;
|
||||
if(self.commandOpen && self.handleCommand(text)){ self.input=''; return; }
|
||||
self.input = '';
|
||||
self.errorMsg = '';
|
||||
self.streaming = true;
|
||||
@@ -257,7 +363,7 @@
|
||||
this.$nextTick(function(){ var el = document.querySelector('.fd-agent-body'); if(el) el.scrollTop = el.scrollHeight; });
|
||||
},
|
||||
get providerLabel(){
|
||||
if(this.providerAvailable) return this.provider;
|
||||
if(this.providerAvailable) return (this.llmProvider || this.provider);
|
||||
return 'offline mock';
|
||||
}
|
||||
};
|
||||
|
||||
+159
-6
@@ -88,13 +88,13 @@
|
||||
<div class="settings-nav-item" :class="{ active: activeSection==='tags' }" @click="activeSection='tags'">{{ fd_icon("tag",14) }} Tags</div>
|
||||
<div class="settings-nav-header">Features</div>
|
||||
<div class="settings-nav-item" :class="{ active: activeSection==='features' }" @click="activeSection='features'">{{ fd_icon("link",14) }} Integrations</div>
|
||||
<div class="settings-nav-item" :class="{ active: activeSection==='llm' }" @click="activeSection='llm'">{{ fd_icon("bot",14) }} Agent & IA</div>
|
||||
<!-- Admin nav: only visible to admins -->
|
||||
<template x-if="userIsAdmin">
|
||||
<div>
|
||||
<div class="settings-nav-header">Admin</div>
|
||||
<div class="settings-nav-item" :class="{ active: activeSection==='admin-users' }" @click="activeSection='admin-users'; loadAdminUsers()">{{ fd_icon("users",14) }} Users & Roles</div>
|
||||
<div class="settings-nav-item" :class="{ active: activeSection==='admin-audit' }" @click="activeSection='admin-audit'; loadAdminAudit()">{{ fd_icon("file-text",14) }} Audit Log</div>
|
||||
<div class="settings-nav-item" :class="{ active: activeSection==='admin-llm' }" @click="activeSection='admin-llm'">{{ fd_icon("bot",14) }} Agent & IA</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
@@ -506,13 +506,68 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Admin: Agent & IA (LLM config) -->
|
||||
<div x-show="activeSection==='admin-llm'">
|
||||
<!-- Agent & IA: per-user provider keys + admin global default -->
|
||||
<div x-show="activeSection==='llm'">
|
||||
<h2>Agent & IA</h2>
|
||||
<p class="section-desc">Configurez le fournisseur LLM utilisé par l'agent FlowDeck. Les changements s'appliquent immédiatement, sans redémarrage.</p>
|
||||
<p class="section-desc">Sauvegardez vos propres clés API par fournisseur : l'agent FlowDeck utilisera la clé du provider sélectionné dans chaque conversation. Les modèles disponibles sont chargés automatiquement depuis le fournisseur.</p>
|
||||
|
||||
<div class="setting-group">
|
||||
<h3>Configuration LLM</h3>
|
||||
<h3>Mes fournisseurs</h3>
|
||||
<template x-for="p in llmProviders" :key="p.id">
|
||||
<div style="border:1px solid var(--border);border-radius:10px;padding:12px;margin-bottom:10px;">
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px;">
|
||||
<strong style="flex:1;font-size:13px;" x-text="p.name"></strong>
|
||||
<span x-show="keyForm(p.id).has_key" style="font-size:11px;color:var(--toast-success-bg);">✓ clé enregistrée</span>
|
||||
<span x-show="!keyForm(p.id).has_key" style="font-size:11px;color:var(--text-dim);">aucune clé enregistrée</span>
|
||||
</div>
|
||||
<div class="setting-row" style="flex-direction:column;align-items:stretch;gap:4px;">
|
||||
<div class="setting-label">
|
||||
Clé API
|
||||
<span style="font-size:11px;color:var(--text-dim);">(laisser vide pour conserver l'existante)</span>
|
||||
</div>
|
||||
<div class="pw-wrapper" style="max-width:340px;">
|
||||
<input :type="keyForm(p.id).keyVisible ? 'text' : 'password'" class="settings-input"
|
||||
x-model="keyForm(p.id).api_key" placeholder="sk-…" style="padding-right:36px;">
|
||||
<button type="button" class="pw-toggle" @click="keyForm(p.id).keyVisible=!keyForm(p.id).keyVisible"
|
||||
x-text="keyForm(p.id).keyVisible ? '{{ fd_icon('eye-off',14) }}' : '{{ fd_icon('eye',14) }}'" style="top:50%;"></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-row" style="flex-direction:column;align-items:stretch;gap:4px;">
|
||||
<div class="setting-label">URL API (optionnelle — Ollama, proxy OpenAI-compatible…)</div>
|
||||
<input type="text" class="settings-input" x-model="keyForm(p.id).api_base" placeholder="https://…" style="max-width:340px;">
|
||||
</div>
|
||||
<div class="setting-row" style="flex-direction:column;align-items:stretch;gap:4px;">
|
||||
<div class="setting-label">Modèle par défaut</div>
|
||||
<select class="settings-input" x-model="keyForm(p.id).model" style="max-width:340px;">
|
||||
<template x-for="m in keyModels(p.id)" :key="m">
|
||||
<option :value="m" x-text="m"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;margin-top:10px;flex-wrap:wrap;">
|
||||
<button class="btn" @click="loadProviderModels(p.id)" :disabled="keyForm(p.id).modelLoading" style="font-size:12px;padding:6px 12px;">
|
||||
<span x-show="!keyForm(p.id).modelLoading">{{ fd_icon("refresh",14) }} Charger les modèles</span>
|
||||
<span x-show="keyForm(p.id).modelLoading">Chargement…</span>
|
||||
</button>
|
||||
<button class="btn btn-primary" @click="saveUserKey(p.id)" :disabled="keyForm(p.id).saving" style="font-size:12px;padding:6px 12px;">
|
||||
<span x-show="!keyForm(p.id).saving">{{ fd_icon("save",14) }} Enregistrer</span>
|
||||
<span x-show="keyForm(p.id).saving">Enregistrement…</span>
|
||||
</button>
|
||||
<button class="btn" @click="deleteUserKey(p.id)" :disabled="keyForm(p.id).deleting" x-show="keyForm(p.id).has_key" style="font-size:12px;padding:6px 12px;color:var(--danger);">Supprimer</button>
|
||||
</div>
|
||||
<div style="margin-top:8px;font-size:12px;">
|
||||
<span x-show="keyForm(p.id).msg" x-text="keyForm(p.id).msg" :style="{color: keyForm(p.id).ok ? 'var(--toast-success-bg)' : 'var(--danger)'}"></span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<p style="font-size:12px;color:var(--text-dim);margin-top:6px;">
|
||||
Sans clé enregistrée pour le provider choisi, l'agent utilise la configuration globale par défaut (ou le mode « offline mock »).
|
||||
Activez un provider / modèle dans chaque conversation via les sélecteurs du panneau Agent (🤖 en bas à droite) ou la commande <code>/provider</code> et <code>/model</code>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="setting-group" x-show="userIsAdmin">
|
||||
<h3>Configuration globale (défaut pour tous les utilisateurs)</h3>
|
||||
<div class="setting-row" style="flex-direction:column;align-items:stretch;gap:4px;">
|
||||
<div class="setting-label">Provider</div>
|
||||
<select class="settings-input" x-model="llmForm.provider" @change="onLlmProviderChange()" style="max-width:300px;">
|
||||
@@ -552,7 +607,6 @@
|
||||
<span x-show="llmTestMsg" x-text="llmTestMsg" :style="{color: llmTestOk ? 'var(--toast-success-bg)' : 'var(--danger)'}"></span>
|
||||
</div>
|
||||
<p style="font-size:12px;color:var(--text-dim);margin-top:14px;">
|
||||
Tous les utilisateurs peuvent choisir un provider / modèle par conversation via le panneau Agent (🤖 en bas à droite).
|
||||
Sans clé API, l'agent fonctionne en mode « offline mock » (plannificateur déterministe, aucun réseau).
|
||||
</p>
|
||||
</div>
|
||||
@@ -610,12 +664,15 @@ document.addEventListener('alpine:init', function() {
|
||||
llmMsg: '', llmMsgOk: false,
|
||||
llmTestMsg: '', llmTestOk: false,
|
||||
llmKeyVisible: false,
|
||||
// Per-user provider keys (multi-providers)
|
||||
keyForms: {},
|
||||
|
||||
async init() {
|
||||
await this.loadTags();
|
||||
await this.loadGiteaStatus();
|
||||
await this.loadGithubStatus();
|
||||
await this.loadNotifPrefs();
|
||||
await this.loadUserLlm();
|
||||
if (this.userIsAdmin) await this.loadLlmConfig();
|
||||
},
|
||||
|
||||
@@ -779,6 +836,102 @@ document.addEventListener('alpine:init', function() {
|
||||
},
|
||||
|
||||
// ── LLM / Agent ──
|
||||
keyForm(id) {
|
||||
if (!this.keyForms[id]) {
|
||||
this.keyForms[id] = {api_key:'', api_base:'', model:'', models:[], has_key:false,
|
||||
keyVisible:false, modelLoading:false, saving:false, deleting:false, msg:'', ok:false};
|
||||
}
|
||||
return this.keyForms[id];
|
||||
},
|
||||
keyModels(id) {
|
||||
var p = this.llmProviders.find(x => x.id === id);
|
||||
var base = ((p && p.models) || []).slice();
|
||||
var f = this.keyForms[id];
|
||||
if (f && f.models) f.models.forEach(m => { if (base.indexOf(m) === -1) base.push(m); });
|
||||
return base;
|
||||
},
|
||||
async loadUserLlm() {
|
||||
try {
|
||||
var r = await fetch('/api/agent/providers');
|
||||
var d = await r.json();
|
||||
this.llmProviders = d.providers || [];
|
||||
var kr = await fetch('/api/agent/keys');
|
||||
var kd = await kr.json();
|
||||
var keys = kd.keys || [];
|
||||
this.llmProviders.forEach(p => {
|
||||
var k = keys.find(x => x.provider === p.id);
|
||||
this.keyForm(p.id).api_base = (k && k.api_base) || '';
|
||||
this.keyForm(p.id).model = (k && k.default_model) || (p.default_model) || '';
|
||||
this.keyForm(p.id).models = (k && k.models) || [];
|
||||
this.keyForm(p.id).has_key = !!(k && k.has_key);
|
||||
});
|
||||
} catch(e) {}
|
||||
},
|
||||
async loadProviderModels(id) {
|
||||
var f = this.keyForm(id);
|
||||
if (f.modelLoading) return;
|
||||
f.modelLoading = true; f.msg = ''; f.ok = false;
|
||||
try {
|
||||
var r = await fetch('/api/agent/keys/' + id + '/models', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type':'application/json'},
|
||||
body: JSON.stringify({api_key: f.api_key, api_base: f.api_base})
|
||||
});
|
||||
var d = await r.json();
|
||||
if (d.ok) {
|
||||
f.models = d.models || [];
|
||||
if (f.models.length) {
|
||||
if (f.models.indexOf(f.model) === -1) f.model = f.models[0];
|
||||
f.msg = f.models.length + ' modèles chargés';
|
||||
f.ok = true;
|
||||
} else {
|
||||
f.msg = 'Aucun modèle renvoyé par le fournisseur';
|
||||
}
|
||||
} else {
|
||||
f.msg = 'Erreur: ' + (d.error || 'échec de connexion');
|
||||
}
|
||||
} catch(e) { f.msg = 'Erreur réseau'; }
|
||||
finally { f.modelLoading = false; }
|
||||
},
|
||||
async saveUserKey(id) {
|
||||
var f = this.keyForm(id);
|
||||
f.saving = true; f.msg = ''; f.ok = false;
|
||||
try {
|
||||
var body = {api_key: f.api_key, api_base: f.api_base, default_model: f.model};
|
||||
if (f.models && f.models.length) body.models = f.models;
|
||||
var r = await fetch('/api/agent/keys/' + id, {
|
||||
method: 'PUT',
|
||||
headers: {'Content-Type':'application/json'},
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
var d = await r.json();
|
||||
if (r.ok) {
|
||||
f.has_key = !!d.has_key;
|
||||
f.api_key = '';
|
||||
f.msg = 'Enregistré pour ' + id + (f.has_key ? ' — clé active ✓' : ' — aucune clé stockée');
|
||||
f.ok = true;
|
||||
} else {
|
||||
f.msg = d.detail || 'Erreur lors de l\'enregistrement';
|
||||
}
|
||||
} catch(e) { f.msg = 'Erreur réseau'; }
|
||||
finally { f.saving = false; }
|
||||
},
|
||||
async deleteUserKey(id) {
|
||||
var f = this.keyForm(id);
|
||||
f.deleting = true; f.msg = ''; f.ok = false;
|
||||
try {
|
||||
var r = await fetch('/api/agent/keys/' + id, { method: 'DELETE' });
|
||||
var d = await r.json();
|
||||
if (r.ok) {
|
||||
f.has_key = false; f.api_key = ''; f.models = []; f.model = '';
|
||||
f.msg = 'Clé supprimée pour ' + id;
|
||||
f.ok = true;
|
||||
} else {
|
||||
f.msg = d.detail || 'Erreur lors de la suppression';
|
||||
}
|
||||
} catch(e) { f.msg = 'Erreur réseau'; }
|
||||
finally { f.deleting = false; }
|
||||
},
|
||||
async loadLlmConfig() {
|
||||
try {
|
||||
var r = await fetch('/api/agent/providers');
|
||||
|
||||
@@ -418,4 +418,178 @@ def _parse_sse(line: str) -> dict:
|
||||
return json.loads(part[len("data:"):].strip())
|
||||
except json.JSONDecodeError:
|
||||
return {}
|
||||
|
||||
|
||||
# ── Per-user provider keys (v4.10.2) ──
|
||||
|
||||
|
||||
def _session_for_login(login: str) -> str:
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
with get_conn() as conn:
|
||||
user = conn.execute("SELECT * FROM users WHERE login=?", (login,)).fetchone()
|
||||
return SessionManager.create_session(dict(user))
|
||||
|
||||
|
||||
def test_router_user_keys_crud(client):
|
||||
# no session → falls back to the single admin user
|
||||
r = client.put("/api/agent/keys/openai",
|
||||
json={"api_key": "sk-secret-1", "api_base": "", "default_model": "gpt-4o-mini"})
|
||||
assert r.status_code == 200
|
||||
assert r.json()["has_key"] is True
|
||||
|
||||
keys = client.get("/api/agent/keys").json()["keys"]
|
||||
entry = [k for k in keys if k["provider"] == "openai"][0]
|
||||
assert entry["has_key"] is True
|
||||
assert entry["default_model"] == "gpt-4o-mini"
|
||||
assert "sk-secret-1" not in json.dumps(entry), "raw api_key must never be exposed"
|
||||
|
||||
# empty key keeps the stored one
|
||||
r = client.put("/api/agent/keys/openai",
|
||||
json={"api_key": "", "models": ["gpt-4o", "gpt-4o-custom"]})
|
||||
assert r.status_code == 200
|
||||
keys = client.get("/api/agent/keys").json()["keys"]
|
||||
entry = [k for k in keys if k["provider"] == "openai"][0]
|
||||
assert entry["has_key"] is True
|
||||
assert "gpt-4o-custom" in entry["models"]
|
||||
|
||||
# providers endpoint merges saved models + has_key flag
|
||||
prov = client.get("/api/agent/providers").json()
|
||||
p = [x for x in prov["providers"] if x["id"] == "openai"][0]
|
||||
assert p["has_key"] is True
|
||||
assert "gpt-4o-custom" in p["models"]
|
||||
|
||||
r = client.delete("/api/agent/keys/openai")
|
||||
assert r.status_code == 200
|
||||
keys = client.get("/api/agent/keys").json()["keys"]
|
||||
assert all(k["provider"] != "openai" for k in keys)
|
||||
|
||||
|
||||
def test_router_user_keys_scoped_per_user(client):
|
||||
from app.db import get_conn
|
||||
|
||||
with get_conn() as conn:
|
||||
conn.execute("INSERT INTO users (login, full_name, is_admin) VALUES ('alice', 'Alice', 0)")
|
||||
conn.commit()
|
||||
|
||||
# admin saves a key
|
||||
client.put("/api/agent/keys/deepseek", json={"api_key": "sk-admin-deepseek"})
|
||||
assert client.get("/api/agent/keys").json()["keys"]
|
||||
|
||||
# alice saves her own key and must NOT see the admin's
|
||||
client.cookies.set("flowdeck_session", _session_for_login("alice"))
|
||||
client.put("/api/agent/keys/deepseek", json={"api_key": "sk-alice-deepseek"})
|
||||
keys = client.get("/api/agent/keys").json()["keys"]
|
||||
entry = [k for k in keys if k["provider"] == "deepseek"][0]
|
||||
assert entry["has_key"] is True
|
||||
|
||||
from app.db import get_conn as _gc
|
||||
with _gc() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT user_id, api_key FROM user_llm_keys WHERE provider='deepseek'"
|
||||
).fetchall()
|
||||
by_user = {r["user_id"]: r["api_key"] for r in rows}
|
||||
assert len(by_user) == 2
|
||||
assert "sk-admin-deepseek" in by_user.values()
|
||||
assert "sk-alice-deepseek" in by_user.values()
|
||||
# masked API never leaks a raw key
|
||||
assert "sk-alice-deepseek" not in json.dumps(keys)
|
||||
|
||||
|
||||
def test_router_user_keys_invalid_provider(client):
|
||||
assert client.put("/api/agent/keys/bogus", json={"api_key": "x"}).status_code == 400
|
||||
assert client.post("/api/agent/keys/bogus/models").status_code == 400
|
||||
assert client.delete("/api/agent/keys/bogus").status_code == 400
|
||||
|
||||
|
||||
def test_router_fetch_models_uses_stored_key(client, monkeypatch):
|
||||
from app.routers import agent as agent_router
|
||||
|
||||
calls = []
|
||||
|
||||
async def fake_fetch(provider, *, api_key="", api_base="", timeout=20):
|
||||
calls.append({"provider": provider, "api_key": api_key, "api_base": api_base})
|
||||
return ["stub-a", "stub-b"]
|
||||
|
||||
monkeypatch.setattr(agent_router, "fetch_provider_models", fake_fetch)
|
||||
|
||||
# no stored key yet → empty key is sent
|
||||
r = client.post("/api/agent/keys/openai/models", json={})
|
||||
assert r.status_code == 200
|
||||
assert r.json()["ok"] is True
|
||||
assert r.json()["models"] == ["stub-a", "stub-b"]
|
||||
assert calls[-1]["api_key"] == ""
|
||||
|
||||
# stored key is used as fallback when the body carries none
|
||||
client.put("/api/agent/keys/openai", json={"api_key": "sk-stored"})
|
||||
r = client.post("/api/agent/keys/openai/models", json={"api_base": "https://proxy.home/v1"})
|
||||
assert r.status_code == 200
|
||||
assert calls[-1]["api_key"] == "sk-stored"
|
||||
assert calls[-1]["api_base"] == "https://proxy.home/v1"
|
||||
|
||||
# body-provided key wins over the stored one
|
||||
r = client.post("/api/agent/keys/openai/models", json={"api_key": "sk-typed"})
|
||||
assert calls[-1]["api_key"] == "sk-typed"
|
||||
|
||||
|
||||
def test_router_fetch_models_error_surfaced(client, monkeypatch):
|
||||
from app.routers import agent as agent_router
|
||||
|
||||
async def fake_fetch(provider, *, api_key="", api_base="", timeout=20):
|
||||
raise ConnectionError("injoignable")
|
||||
|
||||
monkeypatch.setattr(agent_router, "fetch_provider_models", fake_fetch)
|
||||
r = client.post("/api/agent/keys/openai/models", json={"api_key": "sk-x"})
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["ok"] is False
|
||||
assert "injoignable" in body["error"]
|
||||
|
||||
|
||||
def test_router_patch_conversation(client):
|
||||
cid = _make_conversation(client)
|
||||
r = client.patch(f"/api/agent/conversations/{cid}",
|
||||
json={"provider": "openai", "model": "gpt-4o-mini", "title": "Sprint préparé"})
|
||||
assert r.status_code == 200
|
||||
detail = client.get(f"/api/agent/conversations/{cid}").json()
|
||||
conv = detail["conversation"]
|
||||
assert conv["provider"] == "openai"
|
||||
assert conv["model"] == "gpt-4o-mini"
|
||||
assert conv["title"] == "Sprint préparé"
|
||||
|
||||
|
||||
def test_router_run_uses_user_key(client, monkeypatch):
|
||||
from app.routers import agent as agent_router
|
||||
|
||||
created = {}
|
||||
|
||||
class FakeLLM(agent_router.LLMClient):
|
||||
def __init__(self, *args, **kwargs):
|
||||
created.update(kwargs)
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
async def complete(self, messages, *, model=None, tools=None, stream=False):
|
||||
return await self._mock_complete(messages, model, tools)
|
||||
|
||||
monkeypatch.setattr(agent_router, "LLMClient", FakeLLM)
|
||||
|
||||
# user key saved → run must create the LLM client with that key
|
||||
client.put("/api/agent/keys/openai", json={"api_key": "sk-user-secret", "api_base": "https://proxy.home/v1"})
|
||||
cid = _make_conversation(client)
|
||||
resp = client.post(f"/api/agent/conversations/{cid}/run",
|
||||
json={"message": "crée une collection Bilan",
|
||||
"provider": "openai", "model": "gpt-4o-mini"})
|
||||
assert resp.status_code == 200
|
||||
assert created.get("api_key") == "sk-user-secret"
|
||||
assert created.get("api_base") == "https://proxy.home/v1"
|
||||
|
||||
# without a saved key the user key is not injected (global config/environment wins)
|
||||
assert client.delete("/api/agent/keys/openai").status_code == 200
|
||||
created.clear()
|
||||
cid2 = _make_conversation(client)
|
||||
resp = client.post(f"/api/agent/conversations/{cid2}/run",
|
||||
json={"message": "crée une collection Bilan",
|
||||
"provider": "openai", "model": "gpt-4o-mini"})
|
||||
assert resp.status_code == 200
|
||||
assert created.get("api_key", "") == ""
|
||||
return {}
|
||||
Reference in New Issue
Block a user