fix(xiaomi): URL MiMo + header api-key + fallback polling admin SSE
CI / lint (push) Successful in 37s
CI / security (push) Successful in 32s
CI / test (push) Successful in 48s
CI / build (push) Successful in 22s
CI / e2e (push) Successful in 6m1s
Desktop Build / build-windows (push) Canceled after 0s
Desktop Build / build-linux (push) Canceled after 0s
CI / lint (push) Successful in 37s
CI / security (push) Successful in 32s
CI / test (push) Successful in 48s
CI / build (push) Successful in 22s
CI / e2e (push) Successful in 6m1s
Desktop Build / build-windows (push) Canceled after 0s
Desktop Build / build-linux (push) Canceled after 0s
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
This commit is contained in:
+19
-5
@@ -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 = {
|
||||
|
||||
+40
-21
@@ -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",
|
||||
|
||||
+40
-1
@@ -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 ────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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"]
|
||||
|
||||
Reference in New Issue
Block a user