feat(agent): v4.11.0 - clés API par utilisateur, chargement dynamique modèles, commandes slash
This commit is contained in:
+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]
|
||||
Reference in New Issue
Block a user