refactor: #85 T7 extrait le domaine config vers backend/routers (comportement inchange)
This commit is contained in:
+11
-1
@@ -6,7 +6,7 @@ Format basé sur [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/),
|
|||||||
et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
> **En cours de développement** : les changements à venir sont listés dans la section
|
> **En cours de développement** : les changements à venir sont listés dans la section
|
||||||
> [Unreleased](#unreleased). La dernière version livrée est **2.27.9**.
|
> [Unreleased](#unreleased). La dernière version livrée est **2.27.10**.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -14,6 +14,10 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## [2.27.10] — 2026-09-26
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [2.27.9] — 2026-09-26
|
## [2.27.9] — 2026-09-26
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -46,6 +50,12 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|||||||
|
|
||||||
### Modifié
|
### Modifié
|
||||||
|
|
||||||
|
- **#85 (T7) — extraction du domaine `config` hors du monolithe `backend/main.py`.**
|
||||||
|
`/api/config`, ai-keys (get/post/delete/test), tool-keys (×3), ai-models,
|
||||||
|
diagnostics et dashboard sont servis par `backend/routers/config.py`
|
||||||
|
(`_FALLBACK_MODELS`, store clés et config déplacés ; `main` réimporte
|
||||||
|
`_load_config` pour son lifespan, les fixtures de tests inchangées).
|
||||||
|
`tests/test_ai_models.py` patch désormais la référence du router.
|
||||||
- **#85 (T6c) — extraction media/pdf/export/guide hors du monolithe `backend/main.py`.**
|
- **#85 (T6c) — extraction media/pdf/export/guide hors du monolithe `backend/main.py`.**
|
||||||
file/pdf, exports (html/md-bundle/epub), guide/download, pdf/stream|info,
|
file/pdf, exports (html/md-bundle/epub), guide/download, pdf/stream|info,
|
||||||
image, media+thumb, attachments (rescan/stats), vault settings (get/post/all)
|
image, media+thumb, attachments (rescan/stats), vault settings (get/post/all)
|
||||||
|
|||||||
+3
-3
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.
|
**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.
|
||||||
|
|
||||||
[]()
|
[]()
|
||||||
[](https://opensource.org/licenses/MIT)
|
[](https://opensource.org/licenses/MIT)
|
||||||
[](https://www.docker.com/)
|
[](https://www.docker.com/)
|
||||||
[](https://www.python.org/)
|
[](https://www.python.org/)
|
||||||
@@ -976,8 +976,8 @@ Ce projet est sous licence **MIT** — voir le fichier [LICENSE](LICENSE) pour l
|
|||||||
|
|
||||||
## 📝 Changelog
|
## 📝 Changelog
|
||||||
|
|
||||||
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.27.9).
|
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.27.10).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
*Projet : ObsiGate | Version : 2.27.9 | Dernière mise à jour : Septembre 2026*
|
*Projet : ObsiGate | Version : 2.27.10 | Dernière mise à jour : Septembre 2026*
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
**Ultra-light web gateway for your Obsidian vaults** — Access, browse, and search all your Obsidian notes from any device via a modern, responsive web interface.
|
**Ultra-light web gateway for your Obsidian vaults** — Access, browse, and search all your Obsidian notes from any device via a modern, responsive web interface.
|
||||||
|
|
||||||
[]()
|
[]()
|
||||||
[](https://opensource.org/licenses/MIT)
|
[](https://opensource.org/licenses/MIT)
|
||||||
[](https://www.docker.com/)
|
[](https://www.docker.com/)
|
||||||
[](https://www.python.org/)
|
[](https://www.python.org/)
|
||||||
@@ -1151,8 +1151,8 @@ This project is licensed under the **MIT License** - see the [LICENSE](LICENSE)
|
|||||||
|
|
||||||
## 📝 Changelog
|
## 📝 Changelog
|
||||||
|
|
||||||
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.27.9).
|
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.27.10).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
*Project: ObsiGate | Version: 2.27.9 | Last updated: September 2026*
|
*Project: ObsiGate | Version: 2.27.10 | Last updated: September 2026*
|
||||||
|
|||||||
+18
-510
@@ -7,7 +7,6 @@ import re
|
|||||||
import secrets
|
import secrets
|
||||||
import shutil
|
import shutil
|
||||||
import string
|
import string
|
||||||
import urllib.request
|
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -37,7 +36,6 @@ from backend.indexer import (
|
|||||||
remove_vault_from_index,
|
remove_vault_from_index,
|
||||||
update_single_file,
|
update_single_file,
|
||||||
)
|
)
|
||||||
from backend.media_types import IMAGE_EXTENSIONS
|
|
||||||
from backend.openapi_docs import (
|
from backend.openapi_docs import (
|
||||||
API_DESCRIPTION,
|
API_DESCRIPTION,
|
||||||
TAGS_METADATA,
|
TAGS_METADATA,
|
||||||
@@ -45,17 +43,10 @@ from backend.openapi_docs import (
|
|||||||
render_api_landing,
|
render_api_landing,
|
||||||
)
|
)
|
||||||
from backend.schemas import (
|
from backend.schemas import (
|
||||||
AIKeyDeleteResponse,
|
|
||||||
AIKeysResponse,
|
|
||||||
AIModelsResponse,
|
|
||||||
AITestResponse,
|
|
||||||
AppConfigResponse,
|
|
||||||
BookmarksResponse,
|
BookmarksResponse,
|
||||||
BookmarkToggleResponse,
|
BookmarkToggleResponse,
|
||||||
ConflictResolveResponse,
|
ConflictResolveResponse,
|
||||||
ConflictsResponse,
|
ConflictsResponse,
|
||||||
DashboardResponse,
|
|
||||||
DiagnosticsResponse,
|
|
||||||
RecentResponse,
|
RecentResponse,
|
||||||
SavedSearch,
|
SavedSearch,
|
||||||
StatusResponse,
|
StatusResponse,
|
||||||
@@ -482,6 +473,8 @@ from backend.auth.middleware import (
|
|||||||
from backend.auth.router import router as auth_router
|
from backend.auth.router import router as auth_router
|
||||||
from backend.bookslm_routes import router as bookslm_router
|
from backend.bookslm_routes import router as bookslm_router
|
||||||
from backend.routers.backups import router as backups_router
|
from backend.routers.backups import router as backups_router
|
||||||
|
from backend.routers.config import _load_config
|
||||||
|
from backend.routers.config import router as config_router
|
||||||
from backend.routers.files_media import router as files_media_router
|
from backend.routers.files_media import router as files_media_router
|
||||||
from backend.routers.files_read import router as files_read_router
|
from backend.routers.files_read import router as files_read_router
|
||||||
from backend.routers.files_write import router as files_write_router
|
from backend.routers.files_write import router as files_write_router
|
||||||
@@ -500,6 +493,7 @@ app.include_router(skills_router)
|
|||||||
app.include_router(health_router) # ROADMAP #85 T1 — System / health
|
app.include_router(health_router) # ROADMAP #85 T1 — System / health
|
||||||
app.include_router(search_router) # ROADMAP #85 T5 — Search
|
app.include_router(search_router) # ROADMAP #85 T5 — Search
|
||||||
app.include_router(backups_router) # ROADMAP #85 T4 — Backups
|
app.include_router(backups_router) # ROADMAP #85 T4 — Backups
|
||||||
|
app.include_router(config_router) # ROADMAP #85 T7 — Config
|
||||||
app.include_router(files_read_router) # ROADMAP #85 T6a — Files read
|
app.include_router(files_read_router) # ROADMAP #85 T6a — Files read
|
||||||
app.include_router(files_media_router) # ROADMAP #85 T6c — Media/export
|
app.include_router(files_media_router) # ROADMAP #85 T6c — Media/export
|
||||||
app.include_router(files_write_router) # ROADMAP #85 T6b — Files write
|
app.include_router(files_write_router) # ROADMAP #85 T6b — Files write
|
||||||
@@ -1124,521 +1118,35 @@ async def api_vaults_status(current_user=Depends(require_auth)):
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Configuration API
|
# Configuration API : voir backend.routers.config (#85 T7)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
_BASE_DIR = Path(__file__).resolve().parent.parent
|
# ---------------------------------------------------------------------------
|
||||||
_CONFIG_PATH = _BASE_DIR / "data" / "config.json"
|
# AI API Keys : voir backend.routers.config (#85 T7)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
_DEFAULT_CONFIG = {
|
# ---------------------------------------------------------------------------
|
||||||
"search_workers": 2,
|
# Tool & connected-source keys (#103) : voir backend.routers.config (#85 T7)
|
||||||
"debounce_ms": 300,
|
# ---------------------------------------------------------------------------
|
||||||
"results_per_page": 50,
|
|
||||||
"min_query_length": 2,
|
|
||||||
"search_timeout_ms": 30000,
|
|
||||||
"max_content_size": 100000,
|
|
||||||
"snippet_context_chars": 120,
|
|
||||||
"max_snippet_highlights": 5,
|
|
||||||
"title_boost": 3.0,
|
|
||||||
"path_boost": 1.5,
|
|
||||||
"watcher_enabled": True,
|
|
||||||
"watcher_use_polling": False,
|
|
||||||
"watcher_polling_interval": 5.0,
|
|
||||||
"watcher_debounce": 2.0,
|
|
||||||
"tag_boost": 2.0,
|
|
||||||
"prefix_max_expansions": 50,
|
|
||||||
"recent_files_limit": 20,
|
|
||||||
"max_backups_per_file": 10,
|
|
||||||
"ai_default_provider": "deepseek",
|
|
||||||
"ai_default_models": {},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# AI Models : voir backend.routers.config (#85 T7)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def _load_config() -> dict:
|
# ---------------------------------------------------------------------------
|
||||||
"""Load config from disk, merging with defaults."""
|
# AI Models : voir backend.routers.config (#85 T7)
|
||||||
config = dict(_DEFAULT_CONFIG)
|
# ---------------------------------------------------------------------------
|
||||||
if _CONFIG_PATH.exists():
|
|
||||||
try:
|
|
||||||
stored = _json.loads(_CONFIG_PATH.read_text(encoding="utf-8"))
|
|
||||||
config.update(stored)
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Failed to read config.json: {e}")
|
|
||||||
return config
|
|
||||||
|
|
||||||
|
|
||||||
def _save_config(config: dict) -> None:
|
|
||||||
"""Persist config to disk."""
|
|
||||||
try:
|
|
||||||
_CONFIG_PATH.write_text(
|
|
||||||
_json.dumps(config, indent=2, ensure_ascii=False),
|
|
||||||
encoding="utf-8",
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to write config.json: {e}")
|
|
||||||
raise HTTPException(status_code=500, detail=f"Failed to save config: {e}")
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/config", response_model=AppConfigResponse)
|
|
||||||
async def api_get_config(current_user=Depends(require_auth)):
|
|
||||||
"""Return current configuration with defaults for missing keys."""
|
|
||||||
return _load_config()
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/config", response_model=AppConfigResponse)
|
|
||||||
async def api_set_config(body: dict = Body(...), current_user=Depends(require_admin)):
|
|
||||||
"""Update configuration. Only known keys are accepted.
|
|
||||||
|
|
||||||
Keys matching ``_DEFAULT_CONFIG`` are validated and persisted.
|
|
||||||
Unknown keys are silently ignored.
|
|
||||||
Returns the full merged config after update.
|
|
||||||
"""
|
|
||||||
current = _load_config()
|
|
||||||
updated_keys = []
|
|
||||||
for key, value in body.items():
|
|
||||||
if key in _DEFAULT_CONFIG:
|
|
||||||
expected_type = type(_DEFAULT_CONFIG[key])
|
|
||||||
if isinstance(value, expected_type) or (expected_type is float and isinstance(value, (int, float))):
|
|
||||||
current[key] = value
|
|
||||||
updated_keys.append(key)
|
|
||||||
else:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=400,
|
|
||||||
detail=f"Invalid type for '{key}': expected {expected_type.__name__}, got {type(value).__name__}",
|
|
||||||
)
|
|
||||||
_save_config(current)
|
|
||||||
if any(k.startswith("ai_") for k in updated_keys):
|
|
||||||
try:
|
|
||||||
from backend.ai import reload_ai_config
|
|
||||||
reload_ai_config()
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Failed to reload AI config: {e}")
|
|
||||||
logger.info(f"Config updated: {updated_keys}")
|
|
||||||
return current
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# AI API Keys — stored in data/api_keys.json, fallback to .env
|
# Diagnostics API : voir backend.routers.config (#85 T7)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
from backend.ai import PROVIDERS, _read_ai_keys, get_ai_key
|
|
||||||
|
|
||||||
AI_KEYS_FILE = Path("data/api_keys.json")
|
|
||||||
|
|
||||||
def _write_ai_keys(data: dict):
|
|
||||||
AI_KEYS_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
tmp = AI_KEYS_FILE.with_suffix(".tmp")
|
|
||||||
tmp.write_text(_json.dumps(data, indent=2), encoding="utf-8")
|
|
||||||
tmp.replace(AI_KEYS_FILE)
|
|
||||||
|
|
||||||
@app.get("/api/config/ai-keys", response_model=AIKeysResponse)
|
|
||||||
async def api_get_ai_keys(current_user=Depends(require_admin)):
|
|
||||||
"""Return stored AI keys (values masked)."""
|
|
||||||
keys = _read_ai_keys()
|
|
||||||
masked = {}
|
|
||||||
for k in ["DEEPSEEK_API_KEY", "OPENROUTER_API_KEY", "GEMINI_API_KEY", "NVIDIA_API_KEY", "QWENCLOUD_API_KEY", "XIAOMI_API_KEY", "MISTRAL_API_KEY"]:
|
|
||||||
val = keys.get(k, "") or os.environ.get(k, "")
|
|
||||||
if val:
|
|
||||||
masked[k] = val[:4] + "..." + val[-4:] if len(val) > 8 else "***"
|
|
||||||
else:
|
|
||||||
masked[k] = ""
|
|
||||||
return masked
|
|
||||||
|
|
||||||
@app.post("/api/config/ai-keys", response_model=StatusResponse)
|
|
||||||
async def api_set_ai_keys(body: dict = Body(...), current_user=Depends(require_admin)):
|
|
||||||
"""Save AI keys. Pass {"DEEPSEEK_API_KEY":"sk-...","OPENROUTER_API_KEY":"...","GEMINI_API_KEY":"..."}"""
|
|
||||||
keys = _read_ai_keys()
|
|
||||||
for k in ["DEEPSEEK_API_KEY", "OPENROUTER_API_KEY", "GEMINI_API_KEY", "NVIDIA_API_KEY", "QWENCLOUD_API_KEY", "XIAOMI_API_KEY", "MISTRAL_API_KEY"]:
|
|
||||||
if body.get(k):
|
|
||||||
keys[k] = body[k]
|
|
||||||
_write_ai_keys(keys)
|
|
||||||
logger.info("AI keys updated")
|
|
||||||
return {"status": "ok"}
|
|
||||||
|
|
||||||
|
|
||||||
@app.delete("/api/config/ai-keys/{provider_env}", response_model=AIKeyDeleteResponse)
|
|
||||||
async def api_delete_ai_key(provider_env: str, current_user=Depends(require_admin)):
|
|
||||||
"""Delete a specific AI provider key from storage."""
|
|
||||||
allowed = {"DEEPSEEK_API_KEY", "OPENROUTER_API_KEY", "GEMINI_API_KEY",
|
|
||||||
"NVIDIA_API_KEY", "QWENCLOUD_API_KEY", "XIAOMI_API_KEY", "MISTRAL_API_KEY"}
|
|
||||||
key_name = provider_env.upper()
|
|
||||||
if key_name not in allowed:
|
|
||||||
raise HTTPException(status_code=400, detail=f"Clé inconnue: {provider_env}")
|
|
||||||
keys = _read_ai_keys()
|
|
||||||
if key_name in keys:
|
|
||||||
del keys[key_name]
|
|
||||||
_write_ai_keys(keys)
|
|
||||||
# Also clear from env at runtime so get_ai_key() no longer finds it
|
|
||||||
os.environ.pop(key_name, None)
|
|
||||||
logger.info(f"AI key deleted: {key_name}")
|
|
||||||
return {"status": "deleted", "key": key_name}
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Tool & connected-source keys (#103) — same store as the AI provider keys
|
# Dashboard endpoint (aggregated stats) : voir backend.routers.config (#85 T7)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
from backend.tools.secrets import (
|
|
||||||
TOOL_KEY_NAMES as _TOOL_KEY_NAMES,
|
|
||||||
)
|
|
||||||
from backend.tools.secrets import (
|
|
||||||
delete_tool_key as _delete_tool_key,
|
|
||||||
)
|
|
||||||
from backend.tools.secrets import (
|
|
||||||
get_tool_key as _get_tool_key,
|
|
||||||
)
|
|
||||||
from backend.tools.secrets import (
|
|
||||||
mask_value as _mask_tool_value,
|
|
||||||
)
|
|
||||||
from backend.tools.secrets import (
|
|
||||||
set_tool_key as _set_tool_key,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/config/tool-keys", response_model=AIKeysResponse)
|
|
||||||
async def api_get_tool_keys(current_user=Depends(require_admin)):
|
|
||||||
"""Return tool/connected-source configuration (tokens masked, URLs clear)."""
|
|
||||||
masked = {}
|
|
||||||
for name in _TOOL_KEY_NAMES:
|
|
||||||
masked[name] = _mask_tool_value(name, _get_tool_key(name))
|
|
||||||
return masked
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/config/tool-keys", response_model=StatusResponse)
|
|
||||||
async def api_set_tool_keys(body: dict = Body(...), current_user=Depends(require_admin)):
|
|
||||||
"""Save tool/connected-source keys.
|
|
||||||
|
|
||||||
Only whitelisted names (``backend.tools.secrets.TOOL_KEY_NAMES``) are
|
|
||||||
accepted: Tavily/Brave/SerpAPI/Exa API keys, Gitea URL + token, GitHub
|
|
||||||
token. Empty values delete the stored entry.
|
|
||||||
"""
|
|
||||||
updated = []
|
|
||||||
for name, value in body.items():
|
|
||||||
if name not in _TOOL_KEY_NAMES:
|
|
||||||
raise HTTPException(status_code=400, detail=f"Clé inconnue: {name}")
|
|
||||||
if value is not None and not isinstance(value, str):
|
|
||||||
raise HTTPException(status_code=400, detail=f"Type invalide pour {name}")
|
|
||||||
_set_tool_key(name, value or "")
|
|
||||||
updated.append(name)
|
|
||||||
logger.info(f"Tool keys updated: {updated}")
|
|
||||||
return {"status": "ok"}
|
|
||||||
|
|
||||||
|
|
||||||
@app.delete("/api/config/tool-keys/{name}", response_model=AIKeyDeleteResponse)
|
|
||||||
async def api_delete_tool_key(name: str, current_user=Depends(require_admin)):
|
|
||||||
"""Delete a stored tool key (the environment fallback still applies)."""
|
|
||||||
key_name = name.upper()
|
|
||||||
try:
|
|
||||||
existed = _delete_tool_key(key_name)
|
|
||||||
except ValueError as e:
|
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
|
||||||
logger.info(f"Tool key deleted: {key_name} (existed={existed})")
|
|
||||||
return {"status": "deleted", "key": key_name}
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/config/ai-keys/test", response_model=AITestResponse)
|
|
||||||
async def api_test_ai_keys(current_user=Depends(require_admin)):
|
|
||||||
"""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_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:
|
|
||||||
results[label] = "non configuré"
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
import urllib.request
|
|
||||||
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(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
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# AI Models — list available models per provider
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
@app.get("/api/config/ai-models", response_model=AIModelsResponse)
|
|
||||||
async def api_list_ai_models(provider: str = Query(...), current_user=Depends(require_admin)):
|
|
||||||
"""List available models for a given AI provider.
|
|
||||||
|
|
||||||
Strategy:
|
|
||||||
1. Try the provider's public models endpoint (OpenAI-compatible /v1/models or Gemini).
|
|
||||||
2. If the network call fails (timeout, 4xx, 5xx, DNS, etc.), fall back to a
|
|
||||||
curated static list of known-good models for that provider.
|
|
||||||
3. Always return a non-empty list when the provider is known, so the UI
|
|
||||||
dropdown is never empty.
|
|
||||||
"""
|
|
||||||
provider = provider.lower()
|
|
||||||
|
|
||||||
from backend.model_capabilities import get_capabilities_for_models
|
|
||||||
from backend.provider_capabilities import remember_declared_capabilities
|
|
||||||
|
|
||||||
all_providers = ("deepseek", "openrouter", "gemini", "nvidia", "qwencloud", "xiaomi", "mistral")
|
|
||||||
if provider not in all_providers:
|
|
||||||
return {"models": [], "error": f"Unknown provider: {provider}", "source": "validation"}
|
|
||||||
|
|
||||||
key_name = f"{provider.upper()}_API_KEY"
|
|
||||||
key = get_ai_key(key_name)
|
|
||||||
if not key:
|
|
||||||
# No key configured — return curated fallback list so the UI can
|
|
||||||
# still show what WOULD be available once a key is set.
|
|
||||||
fallback = _FALLBACK_MODELS.get(provider, [])
|
|
||||||
return {"models": fallback, "source": "fallback",
|
|
||||||
"capabilities": get_capabilities_for_models(provider, fallback),
|
|
||||||
"note": "API key not configured — showing default model list"}
|
|
||||||
|
|
||||||
# Build URL
|
|
||||||
if provider == "gemini":
|
|
||||||
url = f"https://generativelanguage.googleapis.com/v1beta/models?key={key}"
|
|
||||||
elif provider == "deepseek":
|
|
||||||
url = "https://api.deepseek.com/v1/models"
|
|
||||||
elif provider == "openrouter":
|
|
||||||
url = "https://openrouter.ai/api/v1/models"
|
|
||||||
elif provider == "nvidia":
|
|
||||||
url = "https://integrate.api.nvidia.com/v1/models"
|
|
||||||
elif provider == "qwencloud":
|
|
||||||
url = "https://dashscope.aliyuncs.com/compatible-mode/v1/models"
|
|
||||||
elif provider == "xiaomi":
|
|
||||||
# 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})
|
|
||||||
|
|
||||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
|
||||||
data = _json.loads(resp.read().decode())
|
|
||||||
|
|
||||||
if provider == "gemini":
|
|
||||||
models = [m.get("name", "") for m in data.get("models", []) if m.get("name")]
|
|
||||||
# Gemini returns names like "models/gemini-1.5-flash" — strip prefix
|
|
||||||
models = [m.replace("models/", "") for m in models]
|
|
||||||
else:
|
|
||||||
models = [m.get("id", "") for m in data.get("data", []) if m.get("id")]
|
|
||||||
|
|
||||||
# Cache the capabilities the provider declares for these models
|
|
||||||
# (BUG-044) — get_capabilities_for_models() below then returns the
|
|
||||||
# provider's own truth for the flags it declares, the curated table
|
|
||||||
# for the rest. Providers that declare nothing are left untouched.
|
|
||||||
remember_declared_capabilities(provider, data)
|
|
||||||
|
|
||||||
if models:
|
|
||||||
# Prepend the configured default if not already present
|
|
||||||
default = PROVIDERS.get(provider, {}).get("model")
|
|
||||||
if default and default not in models:
|
|
||||||
models = [default] + models
|
|
||||||
return {"models": models, "source": "live", "count": len(models),
|
|
||||||
"capabilities": get_capabilities_for_models(provider, models)}
|
|
||||||
# Empty list from API — fall through to fallback
|
|
||||||
raise ValueError("empty model list from provider API")
|
|
||||||
except Exception as e:
|
|
||||||
# Network error, auth error, parsing error — use curated fallback
|
|
||||||
fallback = _FALLBACK_MODELS.get(provider, [])
|
|
||||||
return {"models": fallback, "source": "fallback", "error": str(e)[:200],
|
|
||||||
"capabilities": get_capabilities_for_models(provider, fallback),
|
|
||||||
"note": "Could not reach provider API — showing default model list"}
|
|
||||||
|
|
||||||
|
|
||||||
# ── Curated fallback model lists ──────────────────────────────────────────
|
|
||||||
# Used when the provider API is unreachable or returns empty.
|
|
||||||
# Keep these short and focused on models known to work with the
|
|
||||||
# OpenAI-compatible chat completions interface (or Gemini's generateContent).
|
|
||||||
_FALLBACK_MODELS: dict[str, list[str]] = {
|
|
||||||
"deepseek": [
|
|
||||||
"deepseek-chat",
|
|
||||||
"deepseek-reasoner",
|
|
||||||
],
|
|
||||||
"openrouter": [
|
|
||||||
"openai/gpt-4o-mini",
|
|
||||||
"openai/gpt-4o",
|
|
||||||
"anthropic/claude-3.5-sonnet",
|
|
||||||
"anthropic/claude-3-haiku",
|
|
||||||
"google/gemini-2.0-flash-exp:free",
|
|
||||||
"meta-llama/llama-3.1-70b-instruct",
|
|
||||||
"meta-llama/llama-3.1-8b-instruct:free",
|
|
||||||
"mistralai/mistral-large-latest",
|
|
||||||
],
|
|
||||||
"gemini": [
|
|
||||||
"gemini-2.0-flash",
|
|
||||||
"gemini-2.0-flash-exp",
|
|
||||||
"gemini-1.5-pro",
|
|
||||||
"gemini-1.5-flash",
|
|
||||||
"gemini-1.5-flash-8b",
|
|
||||||
],
|
|
||||||
"nvidia": [
|
|
||||||
"meta/llama-3.1-405b-instruct",
|
|
||||||
"meta/llama-3.1-70b-instruct",
|
|
||||||
"meta/llama-3.1-8b-instruct",
|
|
||||||
"mistralai/mistral-large",
|
|
||||||
"google/gemma-2-27b-it",
|
|
||||||
"nvidia/llama-3.1-nemotron-70b-instruct",
|
|
||||||
],
|
|
||||||
"qwencloud": [
|
|
||||||
"qwen-max",
|
|
||||||
"qwen-plus",
|
|
||||||
"qwen-turbo",
|
|
||||||
"qwen-long",
|
|
||||||
"qwen-vl-max",
|
|
||||||
"qwen-vl-plus",
|
|
||||||
],
|
|
||||||
"xiaomi": [
|
|
||||||
# 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",
|
|
||||||
"mistral-medium-latest",
|
|
||||||
"mistral-small-latest",
|
|
||||||
"open-mistral-7b",
|
|
||||||
"open-mixtral-8x7b",
|
|
||||||
"codestral-latest",
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Diagnostics API
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
@app.get("/api/diagnostics", response_model=DiagnosticsResponse)
|
|
||||||
async def api_diagnostics(current_user=Depends(require_admin)):
|
|
||||||
"""Return index statistics and system diagnostics.
|
|
||||||
|
|
||||||
Includes document counts, token counts, memory estimates,
|
|
||||||
and inverted index status.
|
|
||||||
"""
|
|
||||||
import sys
|
|
||||||
|
|
||||||
from backend.search import get_inverted_index
|
|
||||||
|
|
||||||
inv = get_inverted_index()
|
|
||||||
|
|
||||||
# Per-vault stats
|
|
||||||
vault_stats = {}
|
|
||||||
total_files = 0
|
|
||||||
total_tags = 0
|
|
||||||
# Snapshot both dicts first: the indexer mutates them from background
|
|
||||||
# threads, and iterating a live dict raises "dictionary changed size".
|
|
||||||
for vname, vdata in list(index.items()):
|
|
||||||
file_count = len(vdata.get("files", []))
|
|
||||||
tag_count = len(vdata.get("tags", {}))
|
|
||||||
vault_stats[vname] = {"file_count": file_count, "tag_count": tag_count}
|
|
||||||
total_files += file_count
|
|
||||||
total_tags += tag_count
|
|
||||||
|
|
||||||
# Memory estimate for inverted index
|
|
||||||
word_index = inv.word_index.copy()
|
|
||||||
word_index_entries = sum(len(docs) for docs in word_index.values())
|
|
||||||
mem_estimate_mb = round(
|
|
||||||
(sys.getsizeof(inv.word_index) + word_index_entries * 80
|
|
||||||
+ len(inv.doc_info) * 200
|
|
||||||
+ len(inv._sorted_tokens) * 60) / (1024 * 1024), 2
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"index": {
|
|
||||||
"total_files": total_files,
|
|
||||||
"total_tags": total_tags,
|
|
||||||
"vaults": vault_stats,
|
|
||||||
},
|
|
||||||
"inverted_index": {
|
|
||||||
"unique_tokens": len(word_index),
|
|
||||||
"total_postings": word_index_entries,
|
|
||||||
"documents": inv.doc_count,
|
|
||||||
"sorted_tokens": len(inv._sorted_tokens),
|
|
||||||
"is_stale": inv.is_stale(),
|
|
||||||
"memory_estimate_mb": mem_estimate_mb,
|
|
||||||
},
|
|
||||||
"config": _load_config(),
|
|
||||||
"search_executor": {
|
|
||||||
"active": get_search_executor() is not None,
|
|
||||||
"max_workers": get_search_executor()._max_workers if get_search_executor() else 0,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Dashboard endpoint (aggregated stats)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
@app.get("/api/dashboard", response_model=DashboardResponse)
|
|
||||||
async def api_dashboard(current_user=Depends(require_auth)):
|
|
||||||
"""Aggregated dashboard statistics across all accessible vaults."""
|
|
||||||
user_vaults = current_user.get("_token_vaults") or current_user.get("vaults", [])
|
|
||||||
vault_stats = []
|
|
||||||
total_files = 0
|
|
||||||
total_tags = set()
|
|
||||||
total_size = 0
|
|
||||||
total_images = 0
|
|
||||||
for vname, vdata in index.items():
|
|
||||||
if "*" not in user_vaults and vname not in user_vaults:
|
|
||||||
continue
|
|
||||||
files = vdata.get("files", [])
|
|
||||||
fc = len(files)
|
|
||||||
total_files += fc
|
|
||||||
vtags = set()
|
|
||||||
vsize = 0
|
|
||||||
vimages = 0
|
|
||||||
for f in files:
|
|
||||||
vtags.update(f.get("tags", []))
|
|
||||||
vsize += f.get("size", 0)
|
|
||||||
if (f.get("extension") or "").lower() in IMAGE_EXTENSIONS:
|
|
||||||
vimages += 1
|
|
||||||
total_tags.update(vtags)
|
|
||||||
total_size += vsize
|
|
||||||
total_images += vimages
|
|
||||||
vault_stats.append({
|
|
||||||
"name": vname, "file_count": fc, "tag_count": len(vtags),
|
|
||||||
"total_size_bytes": vsize, "image_count": vimages,
|
|
||||||
})
|
|
||||||
return {
|
|
||||||
"vaults": vault_stats,
|
|
||||||
"total_files": total_files,
|
|
||||||
"total_tags": len(total_tags),
|
|
||||||
"total_size_bytes": total_size,
|
|
||||||
"total_images": total_images,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Webhook CRUD endpoints : voir backend.routers.webhooks (#85 T2)
|
# Webhook CRUD endpoints : voir backend.routers.webhooks (#85 T2)
|
||||||
|
|||||||
@@ -0,0 +1,531 @@
|
|||||||
|
"""Configuration, AI keys, diagnostics & dashboard endpoints (ROADMAP #85, tranche 7).
|
||||||
|
|
||||||
|
Handlers déplacés depuis :mod:`backend.main` sans changement de
|
||||||
|
comportement : mêmes chemins (``/api/config*``, ``/api/diagnostics``,
|
||||||
|
``/api/dashboard``), mêmes modèles de réponse, mêmes dépendances
|
||||||
|
d'authentification.
|
||||||
|
|
||||||
|
Adaptations strictement équivalentes :
|
||||||
|
- ``_load_config`` / ``_save_config`` / ``_DEFAULT_CONFIG`` /
|
||||||
|
``_CONFIG_PATH`` / ``_BASE_DIR`` ont déménagé ici : ``main`` les
|
||||||
|
réimporte pour son lifespan (pas de cycle : ce module ne dépend pas de
|
||||||
|
``main``).
|
||||||
|
- ``AI_KEYS_FILE`` / ``_write_ai_keys`` / ``_FALLBACK_MODELS`` ont déménagé
|
||||||
|
ici (``AI_KEYS_FILE`` garde son chemin relatif ``data/api_keys.json``,
|
||||||
|
résolu depuis le même CWD au runtime).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json as _json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import urllib.request
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Body, Depends, HTTPException, Query
|
||||||
|
|
||||||
|
from backend.ai import PROVIDERS, _read_ai_keys, get_ai_key
|
||||||
|
from backend.auth.middleware import require_admin, require_auth
|
||||||
|
from backend.indexer import index
|
||||||
|
from backend.media_types import IMAGE_EXTENSIONS
|
||||||
|
from backend.schemas import (
|
||||||
|
AIKeyDeleteResponse,
|
||||||
|
AIKeysResponse,
|
||||||
|
AIModelsResponse,
|
||||||
|
AITestResponse,
|
||||||
|
AppConfigResponse,
|
||||||
|
DashboardResponse,
|
||||||
|
DiagnosticsResponse,
|
||||||
|
StatusResponse,
|
||||||
|
)
|
||||||
|
from backend.search_executor import get_search_executor
|
||||||
|
from backend.tools.secrets import (
|
||||||
|
TOOL_KEY_NAMES as _TOOL_KEY_NAMES,
|
||||||
|
)
|
||||||
|
from backend.tools.secrets import (
|
||||||
|
delete_tool_key as _delete_tool_key,
|
||||||
|
)
|
||||||
|
from backend.tools.secrets import (
|
||||||
|
get_tool_key as _get_tool_key,
|
||||||
|
)
|
||||||
|
from backend.tools.secrets import (
|
||||||
|
mask_value as _mask_tool_value,
|
||||||
|
)
|
||||||
|
from backend.tools.secrets import (
|
||||||
|
set_tool_key as _set_tool_key,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger("obsigate")
|
||||||
|
|
||||||
|
router = APIRouter(tags=["System"])
|
||||||
|
|
||||||
|
_BASE_DIR = Path(__file__).resolve().parent.parent.parent
|
||||||
|
_CONFIG_PATH = _BASE_DIR / "data" / "config.json"
|
||||||
|
|
||||||
|
_DEFAULT_CONFIG = {
|
||||||
|
"search_workers": 2,
|
||||||
|
"debounce_ms": 300,
|
||||||
|
"results_per_page": 50,
|
||||||
|
"min_query_length": 2,
|
||||||
|
"search_timeout_ms": 30000,
|
||||||
|
"max_content_size": 100000,
|
||||||
|
"snippet_context_chars": 120,
|
||||||
|
"max_snippet_highlights": 5,
|
||||||
|
"title_boost": 3.0,
|
||||||
|
"path_boost": 1.5,
|
||||||
|
"watcher_enabled": True,
|
||||||
|
"watcher_use_polling": False,
|
||||||
|
"watcher_polling_interval": 5.0,
|
||||||
|
"watcher_debounce": 2.0,
|
||||||
|
"tag_boost": 2.0,
|
||||||
|
"prefix_max_expansions": 50,
|
||||||
|
"recent_files_limit": 20,
|
||||||
|
"max_backups_per_file": 10,
|
||||||
|
"ai_default_provider": "deepseek",
|
||||||
|
"ai_default_models": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _load_config() -> dict:
|
||||||
|
"""Load config from disk, merging with defaults."""
|
||||||
|
config = dict(_DEFAULT_CONFIG)
|
||||||
|
if _CONFIG_PATH.exists():
|
||||||
|
try:
|
||||||
|
stored = _json.loads(_CONFIG_PATH.read_text(encoding="utf-8"))
|
||||||
|
config.update(stored)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to read config.json: {e}")
|
||||||
|
return config
|
||||||
|
|
||||||
|
|
||||||
|
def _save_config(config: dict) -> None:
|
||||||
|
"""Persist config to disk."""
|
||||||
|
try:
|
||||||
|
_CONFIG_PATH.write_text(
|
||||||
|
_json.dumps(config, indent=2, ensure_ascii=False),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to write config.json: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=f"Failed to save config: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
AI_KEYS_FILE = Path("data/api_keys.json")
|
||||||
|
|
||||||
|
def _write_ai_keys(data: dict):
|
||||||
|
AI_KEYS_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
tmp = AI_KEYS_FILE.with_suffix(".tmp")
|
||||||
|
tmp.write_text(_json.dumps(data, indent=2), encoding="utf-8")
|
||||||
|
tmp.replace(AI_KEYS_FILE)
|
||||||
|
|
||||||
|
@router.get("/api/config", response_model=AppConfigResponse)
|
||||||
|
async def api_get_config(current_user=Depends(require_auth)):
|
||||||
|
"""Return current configuration with defaults for missing keys."""
|
||||||
|
return _load_config()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/config", response_model=AppConfigResponse)
|
||||||
|
async def api_set_config(body: dict = Body(...), current_user=Depends(require_admin)):
|
||||||
|
"""Update configuration. Only known keys are accepted.
|
||||||
|
|
||||||
|
Keys matching ``_DEFAULT_CONFIG`` are validated and persisted.
|
||||||
|
Unknown keys are silently ignored.
|
||||||
|
Returns the full merged config after update.
|
||||||
|
"""
|
||||||
|
current = _load_config()
|
||||||
|
updated_keys = []
|
||||||
|
for key, value in body.items():
|
||||||
|
if key in _DEFAULT_CONFIG:
|
||||||
|
expected_type = type(_DEFAULT_CONFIG[key])
|
||||||
|
if isinstance(value, expected_type) or (expected_type is float and isinstance(value, (int, float))):
|
||||||
|
current[key] = value
|
||||||
|
updated_keys.append(key)
|
||||||
|
else:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"Invalid type for '{key}': expected {expected_type.__name__}, got {type(value).__name__}",
|
||||||
|
)
|
||||||
|
_save_config(current)
|
||||||
|
if any(k.startswith("ai_") for k in updated_keys):
|
||||||
|
try:
|
||||||
|
from backend.ai import reload_ai_config
|
||||||
|
reload_ai_config()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to reload AI config: {e}")
|
||||||
|
logger.info(f"Config updated: {updated_keys}")
|
||||||
|
return current
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/config/ai-keys", response_model=AIKeysResponse)
|
||||||
|
async def api_get_ai_keys(current_user=Depends(require_admin)):
|
||||||
|
"""Return stored AI keys (values masked)."""
|
||||||
|
keys = _read_ai_keys()
|
||||||
|
masked = {}
|
||||||
|
for k in ["DEEPSEEK_API_KEY", "OPENROUTER_API_KEY", "GEMINI_API_KEY", "NVIDIA_API_KEY", "QWENCLOUD_API_KEY", "XIAOMI_API_KEY", "MISTRAL_API_KEY"]:
|
||||||
|
val = keys.get(k, "") or os.environ.get(k, "")
|
||||||
|
if val:
|
||||||
|
masked[k] = val[:4] + "..." + val[-4:] if len(val) > 8 else "***"
|
||||||
|
else:
|
||||||
|
masked[k] = ""
|
||||||
|
return masked
|
||||||
|
|
||||||
|
@router.post("/api/config/ai-keys", response_model=StatusResponse)
|
||||||
|
async def api_set_ai_keys(body: dict = Body(...), current_user=Depends(require_admin)):
|
||||||
|
"""Save AI keys. Pass {"DEEPSEEK_API_KEY":"sk-...","OPENROUTER_API_KEY":"...","GEMINI_API_KEY":"..."}"""
|
||||||
|
keys = _read_ai_keys()
|
||||||
|
for k in ["DEEPSEEK_API_KEY", "OPENROUTER_API_KEY", "GEMINI_API_KEY", "NVIDIA_API_KEY", "QWENCLOUD_API_KEY", "XIAOMI_API_KEY", "MISTRAL_API_KEY"]:
|
||||||
|
if body.get(k):
|
||||||
|
keys[k] = body[k]
|
||||||
|
_write_ai_keys(keys)
|
||||||
|
logger.info("AI keys updated")
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/api/config/ai-keys/{provider_env}", response_model=AIKeyDeleteResponse)
|
||||||
|
async def api_delete_ai_key(provider_env: str, current_user=Depends(require_admin)):
|
||||||
|
"""Delete a specific AI provider key from storage."""
|
||||||
|
allowed = {"DEEPSEEK_API_KEY", "OPENROUTER_API_KEY", "GEMINI_API_KEY",
|
||||||
|
"NVIDIA_API_KEY", "QWENCLOUD_API_KEY", "XIAOMI_API_KEY", "MISTRAL_API_KEY"}
|
||||||
|
key_name = provider_env.upper()
|
||||||
|
if key_name not in allowed:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Clé inconnue: {provider_env}")
|
||||||
|
keys = _read_ai_keys()
|
||||||
|
if key_name in keys:
|
||||||
|
del keys[key_name]
|
||||||
|
_write_ai_keys(keys)
|
||||||
|
# Also clear from env at runtime so get_ai_key() no longer finds it
|
||||||
|
os.environ.pop(key_name, None)
|
||||||
|
logger.info(f"AI key deleted: {key_name}")
|
||||||
|
return {"status": "deleted", "key": key_name}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/config/tool-keys", response_model=AIKeysResponse)
|
||||||
|
async def api_get_tool_keys(current_user=Depends(require_admin)):
|
||||||
|
"""Return tool/connected-source configuration (tokens masked, URLs clear)."""
|
||||||
|
masked = {}
|
||||||
|
for name in _TOOL_KEY_NAMES:
|
||||||
|
masked[name] = _mask_tool_value(name, _get_tool_key(name))
|
||||||
|
return masked
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/config/tool-keys", response_model=StatusResponse)
|
||||||
|
async def api_set_tool_keys(body: dict = Body(...), current_user=Depends(require_admin)):
|
||||||
|
"""Save tool/connected-source keys.
|
||||||
|
|
||||||
|
Only whitelisted names (``backend.tools.secrets.TOOL_KEY_NAMES``) are
|
||||||
|
accepted: Tavily/Brave/SerpAPI/Exa API keys, Gitea URL + token, GitHub
|
||||||
|
token. Empty values delete the stored entry.
|
||||||
|
"""
|
||||||
|
updated = []
|
||||||
|
for name, value in body.items():
|
||||||
|
if name not in _TOOL_KEY_NAMES:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Clé inconnue: {name}")
|
||||||
|
if value is not None and not isinstance(value, str):
|
||||||
|
raise HTTPException(status_code=400, detail=f"Type invalide pour {name}")
|
||||||
|
_set_tool_key(name, value or "")
|
||||||
|
updated.append(name)
|
||||||
|
logger.info(f"Tool keys updated: {updated}")
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/api/config/tool-keys/{name}", response_model=AIKeyDeleteResponse)
|
||||||
|
async def api_delete_tool_key(name: str, current_user=Depends(require_admin)):
|
||||||
|
"""Delete a stored tool key (the environment fallback still applies)."""
|
||||||
|
key_name = name.upper()
|
||||||
|
try:
|
||||||
|
existed = _delete_tool_key(key_name)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
logger.info(f"Tool key deleted: {key_name} (existed={existed})")
|
||||||
|
return {"status": "deleted", "key": key_name}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/config/ai-keys/test", response_model=AITestResponse)
|
||||||
|
async def api_test_ai_keys(current_user=Depends(require_admin)):
|
||||||
|
"""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_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:
|
||||||
|
results[label] = "non configuré"
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
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(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
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/config/ai-models", response_model=AIModelsResponse)
|
||||||
|
async def api_list_ai_models(provider: str = Query(...), current_user=Depends(require_admin)):
|
||||||
|
"""List available models for a given AI provider.
|
||||||
|
|
||||||
|
Strategy:
|
||||||
|
1. Try the provider's public models endpoint (OpenAI-compatible /v1/models or Gemini).
|
||||||
|
2. If the network call fails (timeout, 4xx, 5xx, DNS, etc.), fall back to a
|
||||||
|
curated static list of known-good models for that provider.
|
||||||
|
3. Always return a non-empty list when the provider is known, so the UI
|
||||||
|
dropdown is never empty.
|
||||||
|
"""
|
||||||
|
provider = provider.lower()
|
||||||
|
|
||||||
|
from backend.model_capabilities import get_capabilities_for_models
|
||||||
|
from backend.provider_capabilities import remember_declared_capabilities
|
||||||
|
|
||||||
|
all_providers = ("deepseek", "openrouter", "gemini", "nvidia", "qwencloud", "xiaomi", "mistral")
|
||||||
|
if provider not in all_providers:
|
||||||
|
return {"models": [], "error": f"Unknown provider: {provider}", "source": "validation"}
|
||||||
|
|
||||||
|
key_name = f"{provider.upper()}_API_KEY"
|
||||||
|
key = get_ai_key(key_name)
|
||||||
|
if not key:
|
||||||
|
# No key configured — return curated fallback list so the UI can
|
||||||
|
# still show what WOULD be available once a key is set.
|
||||||
|
fallback = _FALLBACK_MODELS.get(provider, [])
|
||||||
|
return {"models": fallback, "source": "fallback",
|
||||||
|
"capabilities": get_capabilities_for_models(provider, fallback),
|
||||||
|
"note": "API key not configured — showing default model list"}
|
||||||
|
|
||||||
|
# Build URL
|
||||||
|
if provider == "gemini":
|
||||||
|
url = f"https://generativelanguage.googleapis.com/v1beta/models?key={key}"
|
||||||
|
elif provider == "deepseek":
|
||||||
|
url = "https://api.deepseek.com/v1/models"
|
||||||
|
elif provider == "openrouter":
|
||||||
|
url = "https://openrouter.ai/api/v1/models"
|
||||||
|
elif provider == "nvidia":
|
||||||
|
url = "https://integrate.api.nvidia.com/v1/models"
|
||||||
|
elif provider == "qwencloud":
|
||||||
|
url = "https://dashscope.aliyuncs.com/compatible-mode/v1/models"
|
||||||
|
elif provider == "xiaomi":
|
||||||
|
# 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})
|
||||||
|
|
||||||
|
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||||
|
data = _json.loads(resp.read().decode())
|
||||||
|
|
||||||
|
if provider == "gemini":
|
||||||
|
models = [m.get("name", "") for m in data.get("models", []) if m.get("name")]
|
||||||
|
# Gemini returns names like "models/gemini-1.5-flash" — strip prefix
|
||||||
|
models = [m.replace("models/", "") for m in models]
|
||||||
|
else:
|
||||||
|
models = [m.get("id", "") for m in data.get("data", []) if m.get("id")]
|
||||||
|
|
||||||
|
# Cache the capabilities the provider declares for these models
|
||||||
|
# (BUG-044) — get_capabilities_for_models() below then returns the
|
||||||
|
# provider's own truth for the flags it declares, the curated table
|
||||||
|
# for the rest. Providers that declare nothing are left untouched.
|
||||||
|
remember_declared_capabilities(provider, data)
|
||||||
|
|
||||||
|
if models:
|
||||||
|
# Prepend the configured default if not already present
|
||||||
|
default = PROVIDERS.get(provider, {}).get("model")
|
||||||
|
if default and default not in models:
|
||||||
|
models = [default] + models
|
||||||
|
return {"models": models, "source": "live", "count": len(models),
|
||||||
|
"capabilities": get_capabilities_for_models(provider, models)}
|
||||||
|
# Empty list from API — fall through to fallback
|
||||||
|
raise ValueError("empty model list from provider API")
|
||||||
|
except Exception as e:
|
||||||
|
# Network error, auth error, parsing error — use curated fallback
|
||||||
|
fallback = _FALLBACK_MODELS.get(provider, [])
|
||||||
|
return {"models": fallback, "source": "fallback", "error": str(e)[:200],
|
||||||
|
"capabilities": get_capabilities_for_models(provider, fallback),
|
||||||
|
"note": "Could not reach provider API — showing default model list"}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Curated fallback model lists ──────────────────────────────────────────
|
||||||
|
# Used when the provider API is unreachable or returns empty.
|
||||||
|
# Keep these short and focused on models known to work with the
|
||||||
|
# OpenAI-compatible chat completions interface (or Gemini's generateContent).
|
||||||
|
_FALLBACK_MODELS: dict[str, list[str]] = {
|
||||||
|
"deepseek": [
|
||||||
|
"deepseek-chat",
|
||||||
|
"deepseek-reasoner",
|
||||||
|
],
|
||||||
|
"openrouter": [
|
||||||
|
"openai/gpt-4o-mini",
|
||||||
|
"openai/gpt-4o",
|
||||||
|
"anthropic/claude-3.5-sonnet",
|
||||||
|
"anthropic/claude-3-haiku",
|
||||||
|
"google/gemini-2.0-flash-exp:free",
|
||||||
|
"meta-llama/llama-3.1-70b-instruct",
|
||||||
|
"meta-llama/llama-3.1-8b-instruct:free",
|
||||||
|
"mistralai/mistral-large-latest",
|
||||||
|
],
|
||||||
|
"gemini": [
|
||||||
|
"gemini-2.0-flash",
|
||||||
|
"gemini-2.0-flash-exp",
|
||||||
|
"gemini-1.5-pro",
|
||||||
|
"gemini-1.5-flash",
|
||||||
|
"gemini-1.5-flash-8b",
|
||||||
|
],
|
||||||
|
"nvidia": [
|
||||||
|
"meta/llama-3.1-405b-instruct",
|
||||||
|
"meta/llama-3.1-70b-instruct",
|
||||||
|
"meta/llama-3.1-8b-instruct",
|
||||||
|
"mistralai/mistral-large",
|
||||||
|
"google/gemma-2-27b-it",
|
||||||
|
"nvidia/llama-3.1-nemotron-70b-instruct",
|
||||||
|
],
|
||||||
|
"qwencloud": [
|
||||||
|
"qwen-max",
|
||||||
|
"qwen-plus",
|
||||||
|
"qwen-turbo",
|
||||||
|
"qwen-long",
|
||||||
|
"qwen-vl-max",
|
||||||
|
"qwen-vl-plus",
|
||||||
|
],
|
||||||
|
"xiaomi": [
|
||||||
|
# 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",
|
||||||
|
"mistral-medium-latest",
|
||||||
|
"mistral-small-latest",
|
||||||
|
"open-mistral-7b",
|
||||||
|
"open-mixtral-8x7b",
|
||||||
|
"codestral-latest",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/diagnostics", response_model=DiagnosticsResponse)
|
||||||
|
async def api_diagnostics(current_user=Depends(require_admin)):
|
||||||
|
"""Return index statistics and system diagnostics.
|
||||||
|
|
||||||
|
Includes document counts, token counts, memory estimates,
|
||||||
|
and inverted index status.
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from backend.search import get_inverted_index
|
||||||
|
|
||||||
|
inv = get_inverted_index()
|
||||||
|
|
||||||
|
# Per-vault stats
|
||||||
|
vault_stats = {}
|
||||||
|
total_files = 0
|
||||||
|
total_tags = 0
|
||||||
|
# Snapshot both dicts first: the indexer mutates them from background
|
||||||
|
# threads, and iterating a live dict raises "dictionary changed size".
|
||||||
|
for vname, vdata in list(index.items()):
|
||||||
|
file_count = len(vdata.get("files", []))
|
||||||
|
tag_count = len(vdata.get("tags", {}))
|
||||||
|
vault_stats[vname] = {"file_count": file_count, "tag_count": tag_count}
|
||||||
|
total_files += file_count
|
||||||
|
total_tags += tag_count
|
||||||
|
|
||||||
|
# Memory estimate for inverted index
|
||||||
|
word_index = inv.word_index.copy()
|
||||||
|
word_index_entries = sum(len(docs) for docs in word_index.values())
|
||||||
|
mem_estimate_mb = round(
|
||||||
|
(sys.getsizeof(inv.word_index) + word_index_entries * 80
|
||||||
|
+ len(inv.doc_info) * 200
|
||||||
|
+ len(inv._sorted_tokens) * 60) / (1024 * 1024), 2
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"index": {
|
||||||
|
"total_files": total_files,
|
||||||
|
"total_tags": total_tags,
|
||||||
|
"vaults": vault_stats,
|
||||||
|
},
|
||||||
|
"inverted_index": {
|
||||||
|
"unique_tokens": len(word_index),
|
||||||
|
"total_postings": word_index_entries,
|
||||||
|
"documents": inv.doc_count,
|
||||||
|
"sorted_tokens": len(inv._sorted_tokens),
|
||||||
|
"is_stale": inv.is_stale(),
|
||||||
|
"memory_estimate_mb": mem_estimate_mb,
|
||||||
|
},
|
||||||
|
"config": _load_config(),
|
||||||
|
"search_executor": {
|
||||||
|
"active": get_search_executor() is not None,
|
||||||
|
"max_workers": get_search_executor()._max_workers if get_search_executor() else 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/dashboard", response_model=DashboardResponse)
|
||||||
|
async def api_dashboard(current_user=Depends(require_auth)):
|
||||||
|
"""Aggregated dashboard statistics across all accessible vaults."""
|
||||||
|
user_vaults = current_user.get("_token_vaults") or current_user.get("vaults", [])
|
||||||
|
vault_stats = []
|
||||||
|
total_files = 0
|
||||||
|
total_tags = set()
|
||||||
|
total_size = 0
|
||||||
|
total_images = 0
|
||||||
|
for vname, vdata in index.items():
|
||||||
|
if "*" not in user_vaults and vname not in user_vaults:
|
||||||
|
continue
|
||||||
|
files = vdata.get("files", [])
|
||||||
|
fc = len(files)
|
||||||
|
total_files += fc
|
||||||
|
vtags = set()
|
||||||
|
vsize = 0
|
||||||
|
vimages = 0
|
||||||
|
for f in files:
|
||||||
|
vtags.update(f.get("tags", []))
|
||||||
|
vsize += f.get("size", 0)
|
||||||
|
if (f.get("extension") or "").lower() in IMAGE_EXTENSIONS:
|
||||||
|
vimages += 1
|
||||||
|
total_tags.update(vtags)
|
||||||
|
total_size += vsize
|
||||||
|
total_images += vimages
|
||||||
|
vault_stats.append({
|
||||||
|
"name": vname, "file_count": fc, "tag_count": len(vtags),
|
||||||
|
"total_size_bytes": vsize, "image_count": vimages,
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
"vaults": vault_stats,
|
||||||
|
"total_files": total_files,
|
||||||
|
"total_tags": len(total_tags),
|
||||||
|
"total_size_bytes": total_size,
|
||||||
|
"total_images": total_images,
|
||||||
|
}
|
||||||
@@ -31,7 +31,7 @@ DEFAULT_MAX_BACKUPS = 10
|
|||||||
def _default_max_backups() -> int:
|
def _default_max_backups() -> int:
|
||||||
"""Read ``max_backups_per_file`` from app config (lazy, best-effort)."""
|
"""Read ``max_backups_per_file`` from app config (lazy, best-effort)."""
|
||||||
try:
|
try:
|
||||||
from backend.main import _load_config
|
from backend.routers.config import _load_config # ROADMAP #85 T7 — déménagé depuis backend.main
|
||||||
|
|
||||||
return int(_load_config().get("max_backups_per_file", DEFAULT_MAX_BACKUPS))
|
return int(_load_config().get("max_backups_per_file", DEFAULT_MAX_BACKUPS))
|
||||||
except Exception: # pragma: no cover - config unavailable
|
except Exception: # pragma: no cover - config unavailable
|
||||||
|
|||||||
Generated
+1
-1
@@ -2626,7 +2626,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "obsigate-desktop"
|
name = "obsigate-desktop"
|
||||||
version = "2.27.9"
|
version = "2.27.10"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"env_logger",
|
"env_logger",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "obsigate-desktop"
|
name = "obsigate-desktop"
|
||||||
version = "2.27.9"
|
version = "2.27.10"
|
||||||
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
|
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
|
||||||
authors = ["Bruno Charest"]
|
authors = ["Bruno Charest"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json",
|
"$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json",
|
||||||
"productName": "ObsiGate",
|
"productName": "ObsiGate",
|
||||||
"version": "2.27.9",
|
"version": "2.27.10",
|
||||||
"identifier": "com.obsigate.desktop",
|
"identifier": "com.obsigate.desktop",
|
||||||
"build": {
|
"build": {
|
||||||
"frontendDist": "../frontend",
|
"frontendDist": "../frontend",
|
||||||
|
|||||||
+3
-3
@@ -1,6 +1,6 @@
|
|||||||
# ObsiGate — Roadmap
|
# ObsiGate — Roadmap
|
||||||
|
|
||||||
> **Version :** 2.27.9 | **Dernière mise à jour :** 2026-09-26
|
> **Version :** 2.27.10 | **Dernière mise à jour :** 2026-09-26
|
||||||
> **Ce fichier ne contient que le travail à venir** (🔵 En cours + ⚪ Backlog) et un index compact
|
> **Ce fichier ne contient que le travail à venir** (🔵 En cours + ⚪ Backlog) et un index compact
|
||||||
> vers les fonctionnalités livrées.
|
> vers les fonctionnalités livrées.
|
||||||
> - **Méthode de livraison à appliquer pour toute tâche : [DELIVERY_WORKFLOW.md](./DELIVERY_WORKFLOW.md)**
|
> - **Méthode de livraison à appliquer pour toute tâche : [DELIVERY_WORKFLOW.md](./DELIVERY_WORKFLOW.md)**
|
||||||
@@ -67,10 +67,10 @@
|
|||||||
|
|
||||||
- **Effort :** 8-12 jours | **Impact :** 🟡 | **Zone :** backend
|
- **Effort :** 8-12 jours | **Impact :** 🟡 | **Zone :** backend
|
||||||
- **Décision 2026-09-26 : prioritaire (axe Dette & sécurité).**
|
- **Décision 2026-09-26 : prioritaire (axe Dette & sécurité).**
|
||||||
- **Statut :** 🔵 en cours depuis 2026-09-26 — découpe par tranches à impact minimal (comportement inchangé, un domaine par commit). **T1 livrée (v2.27.2) :** `health` (`/api/health`, `/api/health/detailed` → `backend/routers/health.py`, `HealthResponse` → `schemas.py`). **T2 livrée (v2.27.3) :** `webhooks` (CRUD `/api/webhooks` → `backend/routers/webhooks.py`, logique déjà dans `backend/webhooks.py`). **T3 livrée (v2.27.4) :** `sharing` (`/api/share/*`, `/api/shares`, `/s/{token}*` → `backend/routers/sharing.py`, logique déjà dans `backend/share.py`). **T4 livrée (v2.27.5) :** `backups` (9 routes `/api/file/{vault}/backups|diff|restore` + `/api/backups*` → `backend/routers/backups.py`, `Diff/Restore*` → `schemas.py`, singleton SSE → `backend/sse.py`). **T5 livrée (v2.27.6) :** `search` (11 routes search/tags/suggest/graph/reload → `backend/routers/search.py`, modèles search → `schemas.py`, pool threads → `backend/search_executor.py`). **T6a livrée (v2.27.7) :** lecture fichiers (`/api/browse`, `/raw`, `/download`, `/backlinks`, `GET /api/file` → `backend/routers/files_read.py`, modèles + `EXT_TO_LANG` déplacés, helpers `_content_disposition`/`_media_max_inline_bytes` → `backend/routers/helpers.py`). **T6b livrée (v2.27.8) :** mutations fichiers/dossiers (save, xlsx/save, delete, create, rename, move, directories ×3, batch-upload → `backend/routers/files_write.py`, 15 modèles → `schemas.py`). **T6c livrée (v2.27.9) :** media/pdf/export/guide (file/pdf, exports ×3, guide, pdf/stream|info, image, media+thumb, attachments ×2, vault settings ×3, vault files → `backend/routers/files_media.py`, Range helper → `helpers.py`).
|
- **Statut :** 🔵 en cours depuis 2026-09-26 — découpe par tranches à impact minimal (comportement inchangé, un domaine par commit). **T1 livrée (v2.27.2) :** `health` (`/api/health`, `/api/health/detailed` → `backend/routers/health.py`, `HealthResponse` → `schemas.py`). **T2 livrée (v2.27.3) :** `webhooks` (CRUD `/api/webhooks` → `backend/routers/webhooks.py`, logique déjà dans `backend/webhooks.py`). **T3 livrée (v2.27.4) :** `sharing` (`/api/share/*`, `/api/shares`, `/s/{token}*` → `backend/routers/sharing.py`, logique déjà dans `backend/share.py`). **T4 livrée (v2.27.5) :** `backups` (9 routes `/api/file/{vault}/backups|diff|restore` + `/api/backups*` → `backend/routers/backups.py`, `Diff/Restore*` → `schemas.py`, singleton SSE → `backend/sse.py`). **T5 livrée (v2.27.6) :** `search` (11 routes search/tags/suggest/graph/reload → `backend/routers/search.py`, modèles search → `schemas.py`, pool threads → `backend/search_executor.py`). **T6a livrée (v2.27.7) :** lecture fichiers (`/api/browse`, `/raw`, `/download`, `/backlinks`, `GET /api/file` → `backend/routers/files_read.py`, modèles + `EXT_TO_LANG` déplacés, helpers `_content_disposition`/`_media_max_inline_bytes` → `backend/routers/helpers.py`). **T6b livrée (v2.27.8) :** mutations fichiers/dossiers (save, xlsx/save, delete, create, rename, move, directories ×3, batch-upload → `backend/routers/files_write.py`, 15 modèles → `schemas.py`). **T6c livrée (v2.27.9) :** media/pdf/export/guide (file/pdf, exports ×3, guide, pdf/stream|info, image, media+thumb, attachments ×2, vault settings ×3, vault files → `backend/routers/files_media.py`, Range helper → `helpers.py`). **T7 livrée (v2.27.10) :** config (app/ai-keys/tool-keys/ai-models/diagnostics/dashboard → `backend/routers/config.py`, `_FALLBACK_MODELS` + clés déplacés, `test_ai_models` réaligné).
|
||||||
- **Description :** extraire le monolithe `backend/main.py` (~4 827 lignes au 2026-09-26, ~17 % du backend) en routers FastAPI par domaine et rendre persistant l'état qui ne l'est pas (index de recherche, JTI révoqués, compteurs de rate-limit) pour préparer le multi-nœuds. L'état mémoire actuel (index, inverted index, vecteurs sémantiques, `SSEManager`, collab) rend le multi-workers unsafe.
|
- **Description :** extraire le monolithe `backend/main.py` (~4 827 lignes au 2026-09-26, ~17 % du backend) en routers FastAPI par domaine et rendre persistant l'état qui ne l'est pas (index de recherche, JTI révoqués, compteurs de rate-limit) pour préparer le multi-nœuds. L'état mémoire actuel (index, inverted index, vecteurs sémantiques, `SSEManager`, collab) rend le multi-workers unsafe.
|
||||||
- **Sous-tâches :**
|
- **Sous-tâches :**
|
||||||
- [ ] Routers par domaine : files, search, share, webhooks, plugins, collab, admin, ai — `main.py` conservé comme assemblage (< 500 lignes) ; dédupliquer les modèles Pydantic vers `schemas.py`. **Avancement :** `health` ✅ (T1, `backend/routers/health.py`), `webhooks` ✅ (T2, `backend/routers/webhooks.py`), `sharing` ✅ (T3, `backend/routers/sharing.py`), `backups` ✅ (T4, `backend/routers/backups.py` + `backend/sse.py`), `search` ✅ (T5, `backend/routers/search.py` + `backend/search_executor.py`), `files-read` ✅ (T6a, `backend/routers/files_read.py` + `helpers.py`), `files-write` ✅ (T6b, `backend/routers/files_write.py`), `files-media` ✅ (T6c, `backend/routers/files_media.py`) ; `tools/registry.py` existe déjà (permissions/quotas/redaction — à compléter, pas à créer)
|
- [ ] Routers par domaine : files, search, share, webhooks, plugins, collab, admin, ai — `main.py` conservé comme assemblage (< 500 lignes) ; dédupliquer les modèles Pydantic vers `schemas.py`. **Avancement :** `health` ✅ (T1, `backend/routers/health.py`), `webhooks` ✅ (T2, `backend/routers/webhooks.py`), `sharing` ✅ (T3, `backend/routers/sharing.py`), `backups` ✅ (T4, `backend/routers/backups.py` + `backend/sse.py`), `search` ✅ (T5, `backend/routers/search.py` + `backend/search_executor.py`), `files-read` ✅ (T6a, `backend/routers/files_read.py` + `helpers.py`), `files-write` ✅ (T6b, `backend/routers/files_write.py`), `files-media` ✅ (T6c, `backend/routers/files_media.py`), `config` ✅ (T7, `backend/routers/config.py`) ; `tools/registry.py` existe déjà (permissions/quotas/redaction — à compléter, pas à créer)
|
||||||
- [ ] Compléter `tools/registry.py` (existant : permissions/quotas/redaction) comme contrat central des outils IA si des manques sont constatés
|
- [ ] Compléter `tools/registry.py` (existant : permissions/quotas/redaction) comme contrat central des outils IA si des manques sont constatés
|
||||||
- [ ] Persister index, JTI révoqués et compteurs de rate-limit (SQLite par défaut, Redis en option multi-nœuds ; le rate-limit actuel est in-memory mono-process)
|
- [ ] Persister index, JTI révoqués et compteurs de rate-limit (SQLite par défaut, Redis en option multi-nœuds ; le rate-limit actuel est in-memory mono-process)
|
||||||
- [ ] Verrous asyncio autour de l'index global et des stores JSON ; auditer les `except Exception` larges (> 100 occurrences) : best-effort (backup/audit) vs masquage d'erreur (erreurs typées 4xx/5xx + test)
|
- [ ] Verrous asyncio autour de l'index global et des stores JSON ; auditer les `except Exception` larges (> 100 occurrences) : best-effort (backup/audit) vs masquage d'erreur (erreurs typées 4xx/5xx + test)
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "obsigate",
|
"name": "obsigate",
|
||||||
"version": "2.27.9",
|
"version": "2.27.10",
|
||||||
"description": "**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.",
|
"description": "**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.",
|
||||||
"main": "patch.js",
|
"main": "patch.js",
|
||||||
"directories": {
|
"directories": {
|
||||||
|
|||||||
+14
-3
@@ -12,7 +12,8 @@ from __future__ import annotations
|
|||||||
import pytest
|
import pytest
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
from backend.main import _FALLBACK_MODELS, app
|
from backend.main import app
|
||||||
|
from backend.routers.config import _FALLBACK_MODELS # ROADMAP #85 T7 — déménagé depuis backend.main
|
||||||
|
|
||||||
#: Trimmed-down copy of what api.mistral.ai/v1/models really returns (BUG-044).
|
#: Trimmed-down copy of what api.mistral.ai/v1/models really returns (BUG-044).
|
||||||
MISTRAL_LIVE_PAYLOAD = {
|
MISTRAL_LIVE_PAYLOAD = {
|
||||||
@@ -28,11 +29,12 @@ MISTRAL_LIVE_PAYLOAD = {
|
|||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def admin_client(tmp_path):
|
def admin_client(tmp_path):
|
||||||
"""Minimal admin client for the /api/config/ai-models endpoint."""
|
"""Minimal admin client for the /api/config/ai-models endpoint."""
|
||||||
from backend.auth.password import hash_password
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from backend.auth.password import hash_password
|
||||||
|
|
||||||
data_dir = tmp_path / "data"
|
data_dir = tmp_path / "data"
|
||||||
data_dir.mkdir()
|
data_dir.mkdir()
|
||||||
users = {
|
users = {
|
||||||
@@ -69,8 +71,9 @@ def admin_client(tmp_path):
|
|||||||
|
|
||||||
import backend.main
|
import backend.main
|
||||||
backend.main._load_config = lambda: {"watcher_enabled": False}
|
backend.main._load_config = lambda: {"watcher_enabled": False}
|
||||||
from backend.indexer import build_index, index
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
|
from backend.indexer import build_index, index
|
||||||
for key in list(index.keys()):
|
for key in list(index.keys()):
|
||||||
del index[key]
|
del index[key]
|
||||||
loop = asyncio.new_event_loop()
|
loop = asyncio.new_event_loop()
|
||||||
@@ -232,9 +235,11 @@ class TestListModelsEndpoint:
|
|||||||
# in backend.main (which does `from backend.ai import ... get_ai_key`).
|
# in backend.main (which does `from backend.ai import ... get_ai_key`).
|
||||||
import backend.ai as aimod
|
import backend.ai as aimod
|
||||||
import backend.main as bmain
|
import backend.main as bmain
|
||||||
|
import backend.routers.config as rconfig
|
||||||
monkeypatch.setattr(aimod, "get_ai_key", lambda name: "fake-xiaomi-key")
|
monkeypatch.setattr(aimod, "get_ai_key", lambda name: "fake-xiaomi-key")
|
||||||
if hasattr(bmain, "get_ai_key"):
|
if hasattr(bmain, "get_ai_key"):
|
||||||
monkeypatch.setattr(bmain, "get_ai_key", lambda name: "fake-xiaomi-key")
|
monkeypatch.setattr(bmain, "get_ai_key", lambda name: "fake-xiaomi-key")
|
||||||
|
monkeypatch.setattr(rconfig, "get_ai_key", lambda name: "fake-xiaomi-key")
|
||||||
|
|
||||||
captured = {}
|
captured = {}
|
||||||
|
|
||||||
@@ -316,9 +321,11 @@ class TestListModelsEndpoint:
|
|||||||
# Patch BOTH the source module AND the imported reference in backend.main
|
# Patch BOTH the source module AND the imported reference in backend.main
|
||||||
import backend.ai as aimod
|
import backend.ai as aimod
|
||||||
import backend.main as bmain
|
import backend.main as bmain
|
||||||
|
import backend.routers.config as rconfig
|
||||||
monkeypatch.setattr(aimod, "get_ai_key", lambda name: "fake-key")
|
monkeypatch.setattr(aimod, "get_ai_key", lambda name: "fake-key")
|
||||||
if hasattr(bmain, "get_ai_key"):
|
if hasattr(bmain, "get_ai_key"):
|
||||||
monkeypatch.setattr(bmain, "get_ai_key", lambda name: "fake-key")
|
monkeypatch.setattr(bmain, "get_ai_key", lambda name: "fake-key")
|
||||||
|
monkeypatch.setattr(rconfig, "get_ai_key", lambda name: "fake-key")
|
||||||
|
|
||||||
import urllib.request as global_urllib_mod
|
import urllib.request as global_urllib_mod
|
||||||
captured_urls = []
|
captured_urls = []
|
||||||
@@ -393,10 +400,12 @@ class TestDeclaredCapabilities:
|
|||||||
"""main.py binds get_ai_key at import: patch that binding too."""
|
"""main.py binds get_ai_key at import: patch that binding too."""
|
||||||
import backend.ai as aimod
|
import backend.ai as aimod
|
||||||
import backend.main as bmain
|
import backend.main as bmain
|
||||||
|
import backend.routers.config as rconfig
|
||||||
|
|
||||||
monkeypatch.setattr(aimod, "get_ai_key", lambda name: "fake-mistral-key")
|
monkeypatch.setattr(aimod, "get_ai_key", lambda name: "fake-mistral-key")
|
||||||
if hasattr(bmain, "get_ai_key"):
|
if hasattr(bmain, "get_ai_key"):
|
||||||
monkeypatch.setattr(bmain, "get_ai_key", lambda name: "fake-mistral-key")
|
monkeypatch.setattr(bmain, "get_ai_key", lambda name: "fake-mistral-key")
|
||||||
|
monkeypatch.setattr(rconfig, "get_ai_key", lambda name: "fake-mistral-key")
|
||||||
|
|
||||||
def test_declared_vision_reaches_both_endpoints(self, admin_client, monkeypatch):
|
def test_declared_vision_reaches_both_endpoints(self, admin_client, monkeypatch):
|
||||||
self._fake_key(monkeypatch)
|
self._fake_key(monkeypatch)
|
||||||
@@ -450,10 +459,12 @@ class TestDeclaredCapabilities:
|
|||||||
"""No API key: the curated list must still answer correctly (offline)."""
|
"""No API key: the curated list must still answer correctly (offline)."""
|
||||||
import backend.ai as aimod
|
import backend.ai as aimod
|
||||||
import backend.main as bmain
|
import backend.main as bmain
|
||||||
|
import backend.routers.config as rconfig
|
||||||
|
|
||||||
monkeypatch.setattr(aimod, "get_ai_key", lambda name: None)
|
monkeypatch.setattr(aimod, "get_ai_key", lambda name: None)
|
||||||
if hasattr(bmain, "get_ai_key"):
|
if hasattr(bmain, "get_ai_key"):
|
||||||
monkeypatch.setattr(bmain, "get_ai_key", lambda name: None)
|
monkeypatch.setattr(bmain, "get_ai_key", lambda name: None)
|
||||||
|
monkeypatch.setattr(rconfig, "get_ai_key", lambda name: None)
|
||||||
|
|
||||||
token = _login_admin(admin_client)
|
token = _login_admin(admin_client)
|
||||||
resp = admin_client.get(
|
resp = admin_client.get(
|
||||||
|
|||||||
Reference in New Issue
Block a user