feat(agent): v4.11.0 - clés API par utilisateur, chargement dynamique modèles, commandes slash
This commit is contained in:
@@ -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