feat(ai): selection fournisseur/modele par defaut + guide architecture
- Persistance ai_default_provider / ai_default_models dans data/config.json - Lecture + rechargement a chaud dans backend/ai.py (get_default_provider, reload_ai_config) - UI: selecteurs Fournisseur/Modele par defaut dans la section Cles API IA (i18n FR/EN) - docs/AI_ARCHITECTURE_GUIDE.md: architecture cible, catalogue d'outils, decisions MCP
This commit is contained in:
+43
-3
@@ -18,6 +18,7 @@ ProviderName = Literal["deepseek", "openrouter", "gemini", "ollama", "nvidia", "
|
||||
|
||||
# Provider configurations — keys loaded from file or .env
|
||||
AI_KEYS_FILE = Path("data/api_keys.json")
|
||||
APP_CONFIG_FILE = Path(__file__).resolve().parent.parent / "data" / "config.json"
|
||||
|
||||
def _read_ai_keys() -> dict:
|
||||
if not AI_KEYS_FILE.exists():
|
||||
@@ -27,6 +28,16 @@ def _read_ai_keys() -> dict:
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def _read_app_config() -> dict:
|
||||
"""Read the persisted application config (``data/config.json``)."""
|
||||
if not APP_CONFIG_FILE.exists():
|
||||
return {}
|
||||
try:
|
||||
return json.loads(APP_CONFIG_FILE.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
def get_ai_key(env_name: str) -> str:
|
||||
"""Get AI key: stored file first, then .env fallback."""
|
||||
keys = _read_ai_keys()
|
||||
@@ -36,7 +47,7 @@ def get_ai_key(env_name: str) -> str:
|
||||
|
||||
def _load_provider_keys():
|
||||
"""Load AI keys from stored file, falling back to .env."""
|
||||
return {
|
||||
providers = {
|
||||
"deepseek": {
|
||||
"api_key": get_ai_key("DEEPSEEK_API_KEY"),
|
||||
"base_url": "https://api.deepseek.com/v1",
|
||||
@@ -90,17 +101,46 @@ def _load_provider_keys():
|
||||
"auth_header": "Bearer {api_key}",
|
||||
},
|
||||
}
|
||||
# Apply persisted per-provider model overrides (data/config.json).
|
||||
overrides = _read_app_config().get("ai_default_models") or {}
|
||||
if isinstance(overrides, dict):
|
||||
for name, model in overrides.items():
|
||||
if name in providers and isinstance(model, str) and model:
|
||||
providers[name]["model"] = model
|
||||
return providers
|
||||
|
||||
|
||||
PROVIDERS = _load_provider_keys()
|
||||
|
||||
DEFAULT_PROVIDER: ProviderName = os.getenv("AI_DEFAULT_PROVIDER", "deepseek") # type: ignore
|
||||
|
||||
|
||||
def get_default_provider() -> str:
|
||||
"""Resolve the default provider: ``data/config.json`` > env > ``deepseek``."""
|
||||
provider = _read_app_config().get("ai_default_provider") or os.getenv("AI_DEFAULT_PROVIDER", "deepseek")
|
||||
return provider if provider in PROVIDERS else "deepseek"
|
||||
|
||||
|
||||
def reload_ai_config() -> str:
|
||||
"""Reload provider keys and model overrides from disk into ``PROVIDERS`` in place.
|
||||
|
||||
Mutating ``PROVIDERS`` (rather than rebinding it) keeps references held by
|
||||
other modules valid. Returns the resolved default provider.
|
||||
"""
|
||||
global DEFAULT_PROVIDER
|
||||
PROVIDERS.clear()
|
||||
PROVIDERS.update(_load_provider_keys())
|
||||
DEFAULT_PROVIDER = get_default_provider() # type: ignore[assignment]
|
||||
logger.info(f"AI config reloaded (default provider: {DEFAULT_PROVIDER})")
|
||||
return DEFAULT_PROVIDER
|
||||
|
||||
|
||||
def _get_provider_config(provider: ProviderName | None = None) -> dict:
|
||||
"""Get provider config, falling back to default if requested provider unavailable."""
|
||||
p = provider or DEFAULT_PROVIDER
|
||||
default = get_default_provider()
|
||||
p = provider or default
|
||||
if p not in PROVIDERS:
|
||||
p = DEFAULT_PROVIDER
|
||||
p = default
|
||||
cfg = PROVIDERS[p]
|
||||
if not cfg["api_key"]:
|
||||
# Try next available provider
|
||||
|
||||
@@ -6,7 +6,6 @@ from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from backend.ai import (
|
||||
DEFAULT_PROVIDER,
|
||||
PROVIDERS,
|
||||
ai_change_tone,
|
||||
ai_continue_writing,
|
||||
@@ -24,6 +23,7 @@ from backend.ai import (
|
||||
ai_simplify,
|
||||
ai_summarize,
|
||||
ai_translate,
|
||||
get_default_provider,
|
||||
)
|
||||
from backend.schemas import AIStatusResponse
|
||||
|
||||
@@ -91,7 +91,7 @@ async def api_status():
|
||||
|
||||
return {
|
||||
"configured": any(p["available"] for p in providers.values()),
|
||||
"default_provider": DEFAULT_PROVIDER,
|
||||
"default_provider": get_default_provider(),
|
||||
"providers": providers,
|
||||
"autocomplete": autocomplete,
|
||||
}
|
||||
|
||||
@@ -4286,6 +4286,8 @@ _DEFAULT_CONFIG = {
|
||||
"prefix_max_expansions": 50,
|
||||
"recent_files_limit": 20,
|
||||
"max_backups_per_file": 10,
|
||||
"ai_default_provider": "deepseek",
|
||||
"ai_default_models": {},
|
||||
}
|
||||
|
||||
|
||||
@@ -4341,6 +4343,12 @@ async def api_set_config(body: dict = Body(...), current_user=Depends(require_ad
|
||||
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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user