refactor: #85 T7 extrait le domaine config vers backend/routers (comportement inchange)

This commit is contained in:
2026-09-26 14:33:40 -04:00
parent 6cccdc1f34
commit 6e527c371d
13 changed files with 589 additions and 529 deletions
+11 -1
View File
@@ -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
View File
@@ -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.
[![Version](https://img.shields.io/badge/Version-2.27.9-blue.svg)]() [![Version](https://img.shields.io/badge/Version-2.27.10-blue.svg)]()
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/) [![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/)
[![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/) [![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](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*
+3 -3
View File
@@ -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.
[![Version](https://img.shields.io/badge/Version-2.27.9-blue.svg)]() [![Version](https://img.shields.io/badge/Version-2.27.10-blue.svg)]()
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/) [![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/)
[![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/) [![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](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*
+1 -1
View File
@@ -1 +1 @@
2.27.9 2.27.10
+18 -510
View File
@@ -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)
+531
View File
@@ -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,
}
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
View File
@@ -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 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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(