fix(admin,ai): redirect admin.html + modèles fallback + picker provider/modèle par requête
CI / lint (push) Successful in 41s
CI / security (push) Successful in 28s
CI / test (push) Successful in 48s
CI / build (push) Successful in 22s
CI / e2e (push) Successful in 6m5s
Desktop Build / build-windows (push) Canceled after 0s
Desktop Build / build-linux (push) Canceled after 0s
CI / lint (push) Successful in 41s
CI / security (push) Successful in 28s
CI / test (push) Successful in 48s
CI / build (push) Successful in 22s
CI / e2e (push) Successful in 6m5s
Desktop Build / build-windows (push) Canceled after 0s
Desktop Build / build-linux (push) Canceled after 0s
Trois bugs corrigés + une amélioration demandée :
1. **fix(admin): /admin.html redirigeait toujours vers /**
- admin.js _gateAdmin() lisait /api/auth/status qui ne contient PAS le rôle user
- Remplacé par /api/auth/me (retourne username, role, vaults)
- Le code distingue maintenant le cas 'auth désactivé' (admin anonyme) du
cas 'auth requise non admin' (affiche écran forbidden)
2. **fix(ai): les modèles Nvidia/Xiaomi ne se chargeaient pas dans les dropdowns**
- Xiaomi : l'endpoint public /v1/models est instable, échec réseau fréquent
- Toutes les erreurs réseau/d'API renvoyaient models=[] → dropdown vide
- Ajout d'un fallback curé : _FALLBACK_MODELS dict avec 4-8 modèles populaires
par provider, TOUJOURS retourné si la clé manque OU si l'API distante échoue
- Le frontend voit désormais 'fallback' vs 'live' comme hint pour l'utilisateur
- Gemini parsing : strip du préfixe 'models/' retourné par l'API Gemini
- 7 nouveaux tests pytest pour _FALLBACK_MODELS + endpoint /api/config/ai-models
3. **feat(ai): picker provider/model dans la toolbar AI + BooksLM**
- Plusieurs providers peuvent maintenant être activés simultanément
- Sélection provider+model par section (Forge, BooksLM, etc.) via dropdown
- État persisté en localStorage (le choix suit l'utilisateur entre sections)
- Chaque appel AI passe maintenant {provider, model} au backend
- ai.js : aiAction() lit le picker et l'injecte dans le body
- bookslm.js : envoie provider+model à /api/ai/bookslm/chat
- ai_routes.py + bookslm_routes.py : AIRequest et BooksLMChatRequest
acceptent provider+model, avec save/restore du modèle original
pour ne pas affecter les autres requêtes concurrentes
- 7 nouvelles clés i18n (ai.provider, ai.model, ai.model_loading, etc.)
- 1 nouveau test bookslm vérifie que le schema accepte provider+model
- validate-imports.mjs : fix faux positif sur 'export { X as Y }'
Vérifié :
- pytest : 502 passed, 5 skipped (494 baseline + 7 AI models + 1 BooksLM)
- frontend unit : 7 passed
- validate-imports : 30 modules / 204 exports / 0 erreur
- pane-manager JSDOM : 9/9
- ruff check backend/ : All checks passed
This commit is contained in:
@@ -38,8 +38,15 @@ function collectExports(filePath, modName) {
|
||||
const m = exportBlockText.match(/^export\s*\{([^}]+)\}/);
|
||||
if (m) {
|
||||
for (const name of m[1].split(',')) {
|
||||
const n = name.trim().replace(/\s+as\s+\w+.*/, '').trim();
|
||||
if (n && n !== '') exports.add(n);
|
||||
// Handle `original as exported` — export both names
|
||||
const asMatch = name.trim().match(/^(\w+)\s+as\s+(\w+)$/);
|
||||
if (asMatch) {
|
||||
exports.add(asMatch[2]); // exported name
|
||||
exports.add(asMatch[1]); // original name too (in case it's re-imported)
|
||||
} else {
|
||||
const n = name.trim().trim();
|
||||
if (n && n !== '') exports.add(n);
|
||||
}
|
||||
}
|
||||
}
|
||||
exportBlockText = '';
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
"""Tests for the AI models listing endpoint + curated fallback lists (ROADMAP #74/#71).
|
||||
|
||||
Covers:
|
||||
- GET /api/config/ai-models always returns a non-empty list for known providers,
|
||||
even when the live API call fails (network, auth, etc.).
|
||||
- The fallback list is curated with at least one model per provider.
|
||||
- Gemini parsing strips the "models/" prefix.
|
||||
- The default model is prepended if not already in the list.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from backend.main import _FALLBACK_MODELS, app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def admin_client(tmp_path):
|
||||
"""Minimal admin client for the /api/config/ai-models endpoint."""
|
||||
from backend.auth.password import hash_password
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
data_dir = tmp_path / "data"
|
||||
data_dir.mkdir()
|
||||
users = {
|
||||
"version": 1,
|
||||
"users": {
|
||||
"admin": {
|
||||
"id": "admin-1",
|
||||
"username": "admin",
|
||||
"display_name": "admin",
|
||||
"password_hash": hash_password("chab30"),
|
||||
"role": "admin",
|
||||
"vaults": ["*"],
|
||||
"active": True,
|
||||
"created_at": "2026-01-01T00:00:00",
|
||||
},
|
||||
},
|
||||
}
|
||||
(data_dir / "users.json").write_text(json.dumps(users), encoding="utf-8")
|
||||
|
||||
src_secret = Path("data/secret.key")
|
||||
if src_secret.exists():
|
||||
import shutil
|
||||
shutil.copy2(str(src_secret), str(data_dir / "secret.key"))
|
||||
|
||||
orig_cwd = os.getcwd()
|
||||
os.chdir(str(tmp_path))
|
||||
|
||||
os.environ["VAULT_1_NAME"] = "TestVault"
|
||||
os.environ["VAULT_1_PATH"] = str(Path("test-vault").resolve())
|
||||
os.environ["OBSIGATE_AUTH_ENABLED"] = "true"
|
||||
os.environ["OBSIGATE_ADMIN_USER"] = "admin"
|
||||
os.environ["OBSIGATE_ADMIN_PASSWORD"] = "chab30"
|
||||
os.environ["OBSIGATE_WATCHER_ENABLED"] = "false"
|
||||
|
||||
import backend.main
|
||||
backend.main._load_config = lambda: {"watcher_enabled": False}
|
||||
from backend.indexer import build_index, index
|
||||
import asyncio
|
||||
for key in list(index.keys()):
|
||||
del index[key]
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
loop.run_until_complete(build_index())
|
||||
|
||||
client = TestClient(app)
|
||||
yield client
|
||||
client.close()
|
||||
os.chdir(orig_cwd)
|
||||
for k in ["VAULT_1_NAME", "VAULT_1_PATH", "OBSIGATE_AUTH_ENABLED",
|
||||
"OBSIGATE_ADMIN_USER", "OBSIGATE_ADMIN_PASSWORD", "OBSIGATE_WATCHER_ENABLED"]:
|
||||
os.environ.pop(k, None)
|
||||
|
||||
|
||||
def _login_admin(client):
|
||||
resp = client.post("/api/auth/login",
|
||||
json={"username": "admin", "password": "chab30"})
|
||||
assert resp.status_code == 200, resp.text
|
||||
return resp.json()["access_token"]
|
||||
|
||||
|
||||
# ── Unit tests for the fallback table itself ─────────────────────────────
|
||||
|
||||
|
||||
class TestFallbackTable:
|
||||
def test_every_known_provider_has_fallback_list(self):
|
||||
"""Every provider in the dropdown must have a non-empty fallback list."""
|
||||
expected_providers = {
|
||||
"deepseek", "openrouter", "gemini", "nvidia",
|
||||
"qwencloud", "xiaomi", "mistral",
|
||||
}
|
||||
assert set(_FALLBACK_MODELS.keys()) >= expected_providers
|
||||
for p in expected_providers:
|
||||
assert _FALLBACK_MODELS[p], f"fallback list for {p!r} is empty"
|
||||
assert all(isinstance(m, str) and m.strip() for m in _FALLBACK_MODELS[p]), \
|
||||
f"fallback list for {p!r} contains invalid entries: {_FALLBACK_MODELS[p]}"
|
||||
|
||||
def test_fallback_lists_are_short_and_focused(self):
|
||||
"""Fallbacks should be short (≤10 models) and well-known."""
|
||||
for p, models in _FALLBACK_MODELS.items():
|
||||
assert len(models) <= 10, f"too many fallbacks for {p}: {len(models)}"
|
||||
|
||||
def test_xiaomi_fallback_contains_mimo_models(self):
|
||||
"""Xiaomi's MiMo models must be in the fallback list."""
|
||||
mimos = [m for m in _FALLBACK_MODELS["xiaomi"] if "mimo" in m.lower()]
|
||||
assert mimos, "no MiMo model in xiaomi fallback"
|
||||
|
||||
|
||||
# ── Endpoint integration tests ────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestListModelsEndpoint:
|
||||
"""Verify /api/config/ai-models?provider=X always returns a usable list."""
|
||||
|
||||
def test_unknown_provider_returns_empty(self, admin_client):
|
||||
token = _login_admin(admin_client)
|
||||
resp = admin_client.get(
|
||||
"/api/config/ai-models",
|
||||
params={"provider": "nonexistent-provider"},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["models"] == []
|
||||
assert "error" in data or "source" in data
|
||||
|
||||
def test_provider_without_key_returns_fallback(self, admin_client, monkeypatch):
|
||||
"""When the provider has no API key configured, return the curated fallback list."""
|
||||
# Force every provider key to be empty so the endpoint hits its
|
||||
# 'no key configured' branch.
|
||||
from backend import ai
|
||||
monkeypatch.setattr(ai, "get_ai_key", lambda name: None)
|
||||
|
||||
token = _login_admin(admin_client)
|
||||
for provider in ("xiaomi", "nvidia", "deepseek", "mistral"):
|
||||
resp = admin_client.get(
|
||||
"/api/config/ai-models",
|
||||
params={"provider": provider},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert resp.status_code == 200, f"{provider}: {resp.status_code}"
|
||||
data = resp.json()
|
||||
assert data["models"], f"{provider}: fallback list is empty"
|
||||
assert data.get("source") == "fallback", (
|
||||
f"{provider}: expected source=fallback, got {data.get('source')!r}"
|
||||
)
|
||||
|
||||
def test_provider_with_unreachable_api_returns_fallback(
|
||||
self, admin_client, monkeypatch,
|
||||
):
|
||||
"""When the live API call fails (network/DNS), the fallback list is used.
|
||||
|
||||
This test patches both the key lookup AND the urlopen call so we
|
||||
deterministically hit the network-failure branch.
|
||||
"""
|
||||
from backend import ai as aimod
|
||||
|
||||
# Fake key so we don't take the 'no key' short-circuit.
|
||||
monkeypatch.setattr(aimod, "get_ai_key", lambda name: "fake-key-for-test")
|
||||
|
||||
# Patch urllib.request.urlopen to always raise — simulating network down.
|
||||
# NOTE: main.py imports urllib.request at module load, so we patch the
|
||||
# symbol it actually uses (urllib.request.urlopen).
|
||||
import urllib.request
|
||||
def _boom(*args, **kwargs):
|
||||
raise OSError("simulated network down")
|
||||
monkeypatch.setattr(urllib.request, "urlopen", _boom)
|
||||
|
||||
token = _login_admin(admin_client)
|
||||
# Pick a non-Gemini provider so we hit the network code path.
|
||||
resp = admin_client.get(
|
||||
"/api/config/ai-models",
|
||||
params={"provider": "nvidia"},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
# The response should be a usable list — either via fallback after
|
||||
# network failure, or the 'no key' shortcut. Both are acceptable as
|
||||
# long as models is non-empty.
|
||||
assert data["models"], "model list should be non-empty"
|
||||
assert data.get("source") in ("fallback",), (
|
||||
f"unexpected source: {data.get('source')!r}"
|
||||
)
|
||||
|
||||
def test_response_includes_source_field(self, admin_client, monkeypatch):
|
||||
"""All successful responses must include a 'source' field for UI hinting."""
|
||||
from backend import ai as aimod
|
||||
monkeypatch.setattr(aimod, "get_ai_key", lambda name: "fake-key")
|
||||
|
||||
token = _login_admin(admin_client)
|
||||
resp = admin_client.get(
|
||||
"/api/config/ai-models",
|
||||
params={"provider": "gemini"},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "source" in data
|
||||
assert data["source"] in ("live", "fallback")
|
||||
@@ -536,3 +536,41 @@ class TestBooksLMChatEndpoint:
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_chat_request_accepts_provider_and_model(self, bookslm_client):
|
||||
"""The chat endpoint schema accepts provider + model fields without rejecting.
|
||||
|
||||
We don't actually call the AI (would need a live key) — we just verify
|
||||
the request schema is wired correctly so a missing key is the only
|
||||
failure mode, not a 422 validation error.
|
||||
"""
|
||||
token, _ = _login_bookslm(bookslm_client)
|
||||
# Build a tiny valid directory so the chat endpoint doesn't 404.
|
||||
from pathlib import Path
|
||||
vault_dir = Path(os.environ.get("VAULT_1_PATH", "test-vault"))
|
||||
sub = vault_dir / "for_chat_test"
|
||||
sub.mkdir(exist_ok=True)
|
||||
(sub / "note.md").write_text("# hello\n", encoding="utf-8")
|
||||
try:
|
||||
resp = bookslm_client.post(
|
||||
"/api/ai/bookslm/chat",
|
||||
json={
|
||||
"vault": "TestVault",
|
||||
"directory": "for_chat_test",
|
||||
"message": "ping",
|
||||
"provider": "deepseek",
|
||||
"model": "deepseek-chat",
|
||||
},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
# Either 200 (if a real key is configured) or 500 (no key / quota).
|
||||
# Must NOT be 422 — the schema must accept the fields.
|
||||
assert resp.status_code in (200, 500), (
|
||||
f"unexpected status {resp.status_code}: {resp.text[:200]}"
|
||||
)
|
||||
assert resp.status_code != 422, (
|
||||
f"schema rejected provider/model fields: {resp.text[:300]}"
|
||||
)
|
||||
finally:
|
||||
import shutil
|
||||
shutil.rmtree(sub, ignore_errors=True)
|
||||
|
||||
Reference in New Issue
Block a user