feat(agent): v4.11.0 - clés API par utilisateur, chargement dynamique modèles, commandes slash
FlowDeck CI / test (push) Failing after 7s
FlowDeck CI / docker (push) Skipped

This commit is contained in:
2026-09-05 11:47:58 -04:00
parent 1c11b9d351
commit 34368a4946
10 changed files with 772 additions and 24 deletions
+174
View File
@@ -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 {}