From d44148193064a7b291042fd35e774610d69db8ef Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Mon, 7 Sep 2026 12:47:24 -0400 Subject: [PATCH] fix(xiaomi): URL MiMo + header api-key + fallback polling admin SSE MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. **fix(xiaomi): la clé Xiaomi échouait avec 'Name or service not known'** - URL précédente api.xiaomi.com n'existe pas (DNS fail) - Vraie URL: https://api.xiaomimimo.com/v1/models - Xiaomi MiMo utilise un header custom 'api-key:' (PAS 'Authorization: Bearer') - Modèles par défaut mis à jour: mimo-v2.5-pro, mimo-v2.5, mimo-v2.5-asr, etc. - Le chat dans ai.py supporte maintenant un header custom via auth_header_name 2. **fix(admin): EventSource 503 → fallback polling** - Ajout d'un fallback: si EventSource ne reçoit jamais le premier event (cookie expiré, réseau bloqué, proxy timeout), on bascule sur du polling /api/admin/stats toutes les 5s. Le UI continue de se mettre à jour. - Cleanup correct du timer dans disconnectSSE 3. **fix(test_ai_models)**: 2 nouveaux tests de régression - test_xiaomi_uses_api_key_header_not_bearer: vérifie URL + header - test_xiaomi_test_endpoint_uses_real_url: vérifie /api/config/ai-keys/test - Patche backend.ai ET backend.main (import local) - Header case-insensitive (urllib normalise à 'Api-key', HTTP est insensible) Vérifié : - pytest : 504 passed (502 + 2 nouveaux Xiaomi) - frontend unit : 7 passed - validate-imports : 30 modules / 204 exports - pane-manager JSDOM : 9/9 - ruff check backend/ : All checks passed --- backend/ai.py | 24 +++++++-- backend/main.py | 61 +++++++++++++++-------- frontend/js/admin.js | 41 +++++++++++++++- tests/test_ai_models.py | 106 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 205 insertions(+), 27 deletions(-) diff --git a/backend/ai.py b/backend/ai.py index cc0ad45..02f18ff 100644 --- a/backend/ai.py +++ b/backend/ai.py @@ -75,9 +75,13 @@ def _load_provider_keys(): }, "xiaomi": { "api_key": get_ai_key("XIAOMI_API_KEY"), - "base_url": "https://api.xiaomi.com/v1", - "model": os.getenv("XIAOMI_MODEL", "mimo-v2-pro"), - "auth_header": "Bearer {api_key}", + "base_url": os.getenv("XIAOMI_BASE_URL", "https://api.xiaomimimo.com/v1"), + "model": os.getenv("XIAOMI_MODEL", "mimo-v2.5-pro"), + # Xiaomi MiMo uses a dedicated `api-key` header (NOT Authorization: Bearer). + # The `_call_deepseek_openrouter` helper substitutes {api_key} verbatim, + # so we just emit the raw key value here. + "auth_header": "{api_key}", + "auth_header_name": "api-key", }, "mistral": { "api_key": get_ai_key("MISTRAL_API_KEY"), @@ -110,13 +114,23 @@ def _get_provider_config(provider: ProviderName | None = None) -> dict: async def _call_deepseek_openrouter(prompt: str, system: str, provider: ProviderName | None = None, temperature: float = 0.7, max_tokens: int = 2048) -> str: - """Call OpenAI-compatible API (DeepSeek, OpenRouter).""" + """Call OpenAI-compatible API (DeepSeek, OpenRouter, Xiaomi MiMo, etc.).""" cfg = _get_provider_config(provider) # Debug: log masked key to diagnose 401 key_preview = cfg["api_key"][:8] + "..." + cfg["api_key"][-4:] if len(cfg["api_key"]) > 12 else "***" logger.info(f"AI call: provider={cfg['name']} model={cfg['model']} key={key_preview}") + # Most providers use "Authorization: Bearer KEY". Some (Xiaomi MiMo) use a + # dedicated header like "api-key: KEY". We support both via the + # `auth_header_name` key in PROVIDERS — defaults to "Authorization". + header_name = cfg.get("auth_header_name") or "Authorization" + header_value = cfg["auth_header"].format(api_key=cfg["api_key"]) + # If the auth_header template doesn't include "Bearer " but the default + # header is Authorization, prepend it. This preserves backward compatibility + # for providers that store just the raw key. + if header_name == "Authorization" and not header_value.lower().startswith("bearer "): + header_value = "Bearer " + header_value headers = { - "Authorization": cfg["auth_header"].format(api_key=cfg["api_key"]), + header_name: header_value, "Content-Type": "application/json", } payload = { diff --git a/backend/main.py b/backend/main.py index 3ae057f..70b49ef 100644 --- a/backend/main.py +++ b/backend/main.py @@ -4024,16 +4024,25 @@ async def api_set_ai_keys(body: dict = Body(...), current_user=Depends(require_a @app.post("/api/config/ai-keys/test") async def api_test_ai_keys(current_user=Depends(require_admin)): - """Test which AI providers are configured.""" + """Test which AI providers are configured. + + Each provider has a dedicated (URL, header-name) test pair. + - Most OpenAI-compatible APIs use `Authorization: Bearer KEY` + - Xiaomi MiMo uses `api-key: KEY` + - Gemini uses a query-string key + """ results = {} - for key_name, label, test_url, test_header in [ - ("DEEPSEEK_API_KEY", "deepseek", "https://api.deepseek.com/v1/models", "Authorization"), - ("OPENROUTER_API_KEY", "openrouter", "https://openrouter.ai/api/v1/models", "Authorization"), - ("GEMINI_API_KEY", "gemini", "https://generativelanguage.googleapis.com/v1beta/models?key={key}", None), - ("NVIDIA_API_KEY", "nvidia", "https://integrate.api.nvidia.com/v1/models", "Authorization"), - ("QWENCLOUD_API_KEY", "qwencloud", "https://dashscope.aliyuncs.com/compatible-mode/v1/models", "Authorization"), - ("XIAOMI_API_KEY", "xiaomi", "https://api.xiaomi.com/v1/models", "Authorization"), - ("MISTRAL_API_KEY", "mistral", "https://api.mistral.ai/v1/models", "Authorization"), + for key_name, label, test_url_tmpl, header_name in [ + # OpenAI-compatible — Authorization: Bearer + ("DEEPSEEK_API_KEY", "deepseek", "https://api.deepseek.com/v1/models", "Authorization"), + ("OPENROUTER_API_KEY","openrouter", "https://openrouter.ai/api/v1/models", "Authorization"), + ("NVIDIA_API_KEY", "nvidia", "https://integrate.api.nvidia.com/v1/models", "Authorization"), + ("QWENCLOUD_API_KEY", "qwencloud", "https://dashscope.aliyuncs.com/compatible-mode/v1/models", "Authorization"), + ("MISTRAL_API_KEY", "mistral", "https://api.mistral.ai/v1/models", "Authorization"), + # Xiaomi MiMo — dedicated api-key header (NOT Authorization: Bearer) + ("XIAOMI_API_KEY", "xiaomi", "https://api.xiaomimimo.com/v1/models", "api-key"), + # Gemini — key in query string + ("GEMINI_API_KEY", "gemini", "https://generativelanguage.googleapis.com/v1beta/models?key={key}", None), ]: key = get_ai_key(key_name) if not key: @@ -4041,13 +4050,15 @@ async def api_test_ai_keys(current_user=Depends(require_admin)): continue try: import urllib.request - if test_header: - req = urllib.request.Request(test_url, headers={test_header: "Bearer " + key}) + url = test_url_tmpl.replace("{key}", key) if "{key}" in test_url_tmpl else test_url_tmpl + if header_name: + req = urllib.request.Request(url, headers={header_name: key}) else: - req = urllib.request.Request(test_url.replace("{key}", key)) + req = urllib.request.Request(url) urllib.request.urlopen(req, timeout=5) results[label] = "ok" except Exception as e: + # Truncate the error to keep the response small. results[label] = "erreur: " + str(e)[:80] return results @@ -4093,15 +4104,19 @@ async def api_list_ai_models(provider: str = Query(...), current_user=Depends(re elif provider == "qwencloud": url = "https://dashscope.aliyuncs.com/compatible-mode/v1/models" elif provider == "xiaomi": - # Xiaomi's public /v1/models endpoint is not stable — fetch if reachable, - # otherwise fall back to a curated list of mimo models. - url = "https://api.xiaomi.com/v1/models" + # Xiaomi MiMo — dedicated api-key header (NOT Authorization: Bearer). + # Endpoint: https://api.xiaomimimo.com/v1/models + url = "https://api.xiaomimimo.com/v1/models" + models = [] # parsed below with the custom header elif provider == "mistral": url = "https://api.mistral.ai/v1/models" try: if provider == "gemini": req = urllib.request.Request(url) + elif provider == "xiaomi": + # Xiaomi MiMo uses a dedicated api-key header. + req = urllib.request.Request(url, headers={"api-key": key}) else: req = urllib.request.Request(url, headers={"Authorization": "Bearer " + key}) @@ -4173,12 +4188,16 @@ _FALLBACK_MODELS: dict[str, list[str]] = { "qwen-vl-plus", ], "xiaomi": [ - # Xiaomi MiMo models — the public /v1/models endpoint is unreliable, - # so we ship a known-good list as fallback. - "mimo-v2-pro", - "mimo-v2-flash", - "mimo-v2-vl", - "mimo-v2-tts", + # Xiaomi MiMo models — the public /v1/models endpoint requires the + # `api-key` custom header (NOT Authorization: Bearer), so the live + # call often fails with 401 even with the right key. We ship a + # known-good list as fallback. See https://mimo.mi.com/docs/ + "mimo-v2.5-pro", + "mimo-v2.5", + "mimo-v2.5-asr", + "mimo-v2.5-tts", + "mimo-v2.5-tts-voiceclone", + "mimo-v2.5-tts-voicedesign", ], "mistral": [ "mistral-large-latest", diff --git a/frontend/js/admin.js b/frontend/js/admin.js index b29c9d0..ba080f9 100644 --- a/frontend/js/admin.js +++ b/frontend/js/admin.js @@ -22,6 +22,7 @@ import { t, getLocale } from "./i18n.js"; // ── Module state ───────────────────────────────────────────────────────── let _eventSource = null; +let _pollTimer = null; let _auditFilters = { user: "", action: "" }; // ── Helpers ────────────────────────────────────────────────────────────── @@ -161,15 +162,28 @@ export async function loadStatsOnce() { /** * Open a Server-Sent Events connection on /api/admin/stream. * The endpoint emits `event: stats\ndata: {...}` every 5 seconds. + * + * The browser's EventSource doesn't support custom headers, so we rely on + * the httpOnly cookie (set by /api/auth/login with samesite=lax) to authenticate. + * `withCredentials: true` ensures the cookie is sent. + * + * If the connection fails to open (4xx/5xx or network), we fall back to + * periodic polling of /api/admin/stats every 5s so the UI keeps updating. */ export function connectSSE() { if (_eventSource) { try { _eventSource.close(); } catch { /* */ } _eventSource = null; } + if (_pollTimer) { + clearInterval(_pollTimer); + _pollTimer = null; + } + let sseReady = false; try { _eventSource = new EventSource("/api/admin/stream", { withCredentials: true }); _eventSource.addEventListener("stats", (ev) => { + sseReady = true; try { const data = JSON.parse(ev.data); renderStatsWidget(data); @@ -177,13 +191,34 @@ export function connectSSE() { console.warn("admin SSE parse error", err); } }); + _eventSource.onopen = () => { + sseReady = true; + // Stop the polling fallback once SSE works. + if (_pollTimer) { clearInterval(_pollTimer); _pollTimer = null; } + }; _eventSource.onerror = () => { - // EventSource auto-reconnects. We just log quietly. + // EventSource auto-reconnects. Only start polling fallback if we + // never received the first event yet (e.g. 401/403/network blocked). + if (!sseReady) { + _startPollingFallback(); + } console.warn("admin SSE error (will retry)"); }; } catch (err) { console.warn("admin SSE init failed", err); + _startPollingFallback(); } + // Kick off a single initial fetch right away so the UI doesn't show + // zeros for 5 seconds waiting for the first SSE event. + loadStatsOnce(); +} + +function _startPollingFallback() { + if (_pollTimer) return; + console.warn("admin: falling back to polling /api/admin/stats every 5s"); + _pollTimer = setInterval(() => { + loadStatsOnce().catch(() => { /* ignore — error already shown */ }); + }, 5000); } /** Close the SSE connection if any (useful before navigation). */ @@ -192,6 +227,10 @@ export function disconnectSSE() { try { _eventSource.close(); } catch { /* */ } _eventSource = null; } + if (_pollTimer) { + clearInterval(_pollTimer); + _pollTimer = null; + } } // ── Audit log ──────────────────────────────────────────────────────────── diff --git a/tests/test_ai_models.py b/tests/test_ai_models.py index 5445b69..e90180c 100644 --- a/tests/test_ai_models.py +++ b/tests/test_ai_models.py @@ -202,3 +202,109 @@ class TestListModelsEndpoint: data = resp.json() assert "source" in data assert data["source"] in ("live", "fallback") + + def test_xiaomi_uses_api_key_header_not_bearer(self, admin_client, monkeypatch): + """Regression test: Xiaomi MiMo must use `api-key` header, NOT Authorization. + + Without this, the live call always fails with 401 even with a valid key. + """ + # Fake key — patch BOTH the source module AND the imported reference + # in backend.main (which does `from backend.ai import ... get_ai_key`). + import backend.ai as aimod + import backend.main as bmain + monkeypatch.setattr(aimod, "get_ai_key", lambda name: "fake-xiaomi-key") + if hasattr(bmain, "get_ai_key"): + monkeypatch.setattr(bmain, "get_ai_key", lambda name: "fake-xiaomi-key") + + captured = {} + + def fake_Request(url, *args, **kwargs): + captured["url"] = url + captured["headers"] = dict(kwargs.get("headers") or {}) + + class FakeResp: + def __enter__(self): return self + def __exit__(self, *a): pass + def read(self): + return b'{"object":"list","data":[{"id":"mimo-v2.5-pro","object":"model","owned_by":"xiaomi"}]}' + + captured["response"] = FakeResp() + return captured["response"] + + def fake_urlopen(req, timeout=None): + return req + + # Patch urllib.request at the point where backend.main imported it. + import urllib.request as global_urllib_mod + monkeypatch.setattr(global_urllib_mod, "Request", fake_Request, raising=True) + monkeypatch.setattr(global_urllib_mod, "urlopen", fake_urlopen, raising=True) + + token = _login_admin(admin_client) + resp = admin_client.get( + "/api/config/ai-models", + params={"provider": "xiaomi"}, + headers={"Authorization": f"Bearer {token}"}, + ) + assert resp.status_code == 200, f"resp: {resp.text[:300]}" + # Verify the URL + headers used. + assert captured.get("url"), f"Request was not called (captured={captured!r})" + assert captured["url"].startswith("https://api.xiaomimimo.com/v1/models"), ( + f"unexpected URL: {captured.get('url')!r}" + ) + hdrs = {k.lower(): v for k, v in captured.get("headers", {}).items()} + assert "api-key" in hdrs, f"missing api-key header in {hdrs!r}" + assert hdrs["api-key"] == "fake-xiaomi-key" + assert "authorization" not in hdrs, f"Authorization leaked: {hdrs!r}" + + def test_xiaomi_test_endpoint_uses_real_url(self, admin_client, monkeypatch): + """The /api/config/ai-keys/test endpoint must also use api.xiaomimimo.com. + + Regression: it previously used api.xiaomi.com which doesn't exist + (DNS error Name or service not known). + """ + # Patch BOTH the source module AND the imported reference in backend.main + import backend.ai as aimod + import backend.main as bmain + monkeypatch.setattr(aimod, "get_ai_key", lambda name: "fake-key") + if hasattr(bmain, "get_ai_key"): + monkeypatch.setattr(bmain, "get_ai_key", lambda name: "fake-key") + + import urllib.request as global_urllib_mod + captured_urls = [] + captured_headers = [] + + def fake_urlopen(req, timeout=None): + captured_urls.append(req.full_url) + captured_headers.append(dict(req.headers)) + raise OSError("simulated network error") + + monkeypatch.setattr(global_urllib_mod, "urlopen", fake_urlopen, raising=True) + + token = _login_admin(admin_client) + admin_client.post( + "/api/config/ai-keys/test", + headers={"Authorization": f"Bearer {token}"}, + ) + # Find the xiaomi URL among the captured calls. + xiaomi_idx = next( + (i for i, u in enumerate(captured_urls) if "xiaomi" in u.lower()), + None, + ) + assert xiaomi_idx is not None, ( + f"xiaomi URL not found in captured URLs: {captured_urls!r}" + ) + xiaomi_url = captured_urls[xiaomi_idx] + # Must use the real MiMo endpoint, NOT the dead api.xiaomi.com. + assert "api.xiaomimimo.com" in xiaomi_url, ( + f"xiaomi test URL is wrong: {xiaomi_url!r}" + ) + # Must use api-key header, not Authorization. urllib.request + # normalizes header names to title-case ("Api-key"), but HTTP + # headers are case-insensitive on the wire — both forms are valid. + xiaomi_hdrs = {k.lower(): v for k, v in captured_headers[xiaomi_idx].items()} + assert "api-key" in xiaomi_hdrs, ( + f"xiaomi api-key header missing: {xiaomi_hdrs!r}" + ) + assert xiaomi_hdrs["api-key"] == "fake-key" + # Bearer prefix must NOT be in the api-key value + assert "Bearer" not in xiaomi_hdrs["api-key"]