Skip to content

Commit 5f8d3ec

Browse files
committed
bug fixed for api key save
1 parent 9821122 commit 5f8d3ec

8 files changed

Lines changed: 392 additions & 27 deletions

File tree

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,3 +189,7 @@ config_exo_*.example.xml
189189
# depuis config.xml.example)
190190
config.xml
191191
!config.xml.example
192+
193+
# ── v2.0.5 — Config IA persistée (contient clé API en clair, NE JAMAIS COMMITTER) ──
194+
instance/ia_config.json
195+
instance/

CHANGELOG_v2.0.5.md

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
# SCRIBE v2.0.5 — 27 avril 2026
2+
3+
## 🛠 Hotfix critique : sauvegarde de la clé API IA depuis l'admin
4+
5+
### Le bug
6+
7+
Dans toutes les versions précédentes (v2.0.0 à v2.0.4), il était **impossible
8+
d'enregistrer une clé API IA depuis le panneau d'administration**. Le panneau
9+
ne proposait qu'un bouton "Tester" — aucun bouton "Enregistrer" — et la
10+
documentation affichée demandait de définir des variables d'environnement
11+
côté serveur avant de redémarrer SCRIBE.
12+
13+
Conséquence pour les nouveaux utilisateurs (CHU, RSSI, ESN qui clonent le
14+
repo) : impossible d'activer Albert ou tout autre fournisseur IA sans
15+
toucher au shell du serveur, ce qui rend la démo OVH publique inutilisable
16+
sur l'IA et bloque l'évaluation par les profils non-techniques.
17+
18+
### Le fix
19+
20+
Le panneau **Admin → APIs & IA** propose désormais 3 boutons distincts :
21+
22+
- **🧪 Tester la clé** — valide la connexion au fournisseur sans rien
23+
enregistrer (comportement de l'ancien bouton "Tester")
24+
- **💾 Enregistrer & activer** — persiste la configuration dans
25+
`instance/ia_config.json` (permissions `0600`, fichier ignoré par Git)
26+
et recharge l'IA à chaud, sans redémarrage serveur
27+
- **🗑 Supprimer la config sauvegardée** — supprime le fichier persisté et
28+
revient aux variables d'environnement (ou aux valeurs par défaut)
29+
30+
Le panneau permet aussi de saisir un **modèle** spécifique (optionnel) et,
31+
pour les fournisseurs locaux (Ollama, OpenAI Compat), une **URL de serveur
32+
local**. Les placeholders sont pré-remplis avec les modèles et URLs par
33+
défaut de chaque fournisseur.
34+
35+
### Détails techniques
36+
37+
**Backend** :
38+
- Nouvelles routes `POST /api/v1/admin/config/ia` et
39+
`DELETE /api/v1/admin/config/ia` dans `core/admin_plugins.py` (admin only)
40+
- Persistance JSON dans `instance/ia_config.json` (créé à la demande,
41+
permissions `0600`)
42+
- Fonctions `_load_persisted_ia()` et `save_persisted_ia()` dans `config.py`
43+
- Fix critique dans `app/api/ai_router.py` : `AIConfig.__init__` lit
44+
désormais `config.IA` en priorité (qui a appliqué le fichier persisté),
45+
au lieu de relire directement `os.getenv()` à chaque création. Sans ce
46+
fix, la sauvegarde ne prenait pas effet sans redémarrage.
47+
- Validation : providers cloud (`albert`, `openai`, `anthropic`, `gemini`,
48+
`mistral`) exigent une clé API ; providers locaux (`ollama`,
49+
`openai_compat`) acceptent une URL seule.
50+
51+
**Frontend** :
52+
- `app/static/js/scribe.js` : refonte complète de `adminShowIaConfig()`,
53+
ajout des fonctions `adminTestIaKey()`, `adminSaveIaConfig()`, et
54+
`adminResetIaConfig()`. L'ancienne fonction `adminSaveIaKey()` est
55+
conservée comme stub de compatibilité.
56+
- Le panneau expose maintenant clairement la persistance, sans message
57+
trompeur "(sauvegardée en variable d'env temporaire)".
58+
59+
**Sécurité** :
60+
- Fichier `instance/ia_config.json` ajouté au `.gitignore` pour éviter
61+
toute fuite accidentelle de clé API en clair.
62+
- Permissions `0600` posées au moment de l'écriture (Linux/macOS).
63+
64+
### Workflow utilisateur
65+
66+
1. **Admin** se connecte, clique sur le bouton ⚙ Administration
67+
2. Choisit la section **APIs & IA** dans le menu latéral
68+
3. Clique sur le fournisseur souhaité (ex : Albert)
69+
4. Saisit sa clé API + un modèle (optionnel) + une URL si fournisseur local
70+
5. **🧪 Tester la clé** pour valider la connexion → message vert
71+
6. **💾 Enregistrer & activer** → la config est persistée et l'IA est
72+
utilisable immédiatement, sans redémarrer SCRIBE
73+
7. Toutes les fonctions IA (Analyse incident, Situation globale,
74+
Génération scénario...) sont opérationnelles
75+
76+
### Compatibilité
77+
78+
- **Variables d'environnement** : si `SCRIBE_IA_PROVIDER`, `SCRIBE_IA_KEY`,
79+
etc. sont définies au lancement, elles servent de valeurs par défaut.
80+
Le fichier `instance/ia_config.json` (s'il existe) les surcharge.
81+
Pour revenir aux variables d'env : `🗑 Supprimer la config sauvegardée`.
82+
- **Anciennes installations Docker** : compatibilité totale, le mode
83+
`docker run -e SCRIBE_IA_KEY=xxx` continue de fonctionner.
84+
85+
### Validation
86+
87+
- 118 fichiers Python validés (ast.parse)
88+
- `node --check` OK sur scribe.js
89+
- 10 tests fonctionnels TestClient :
90+
- GET initial avec/sans clé
91+
- POST avec validation provider/cloud-key/local-url
92+
- DELETE remise à zéro
93+
- Permissions 0600 du fichier persisté
94+
- Persistance survit au reload du process
95+
- Non-admin rejeté en 403
96+
- Le 400 `ia_not_configured` disparaît bien après save
97+
- Bench `tests/bench/bench.py` : 4/5 OK (échec connu sur
98+
`04_transfert_federe`, non lié à ce fix)
99+
100+
### Mise à jour
101+
102+
```bash
103+
cd votre-instance-scribe
104+
git pull origin main
105+
# Aucune migration de DB requise
106+
# Aucune modification des configs existantes
107+
# Le fichier instance/ia_config.json sera créé à la première sauvegarde admin
108+
```

app/api/ai_router.py

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -70,10 +70,23 @@ class AIConfig:
7070
"""Configuration du fournisseur IA, chargée une fois au démarrage."""
7171

7272
def __init__(self):
73-
self.provider = os.getenv("SCRIBE_IA_PROVIDER", "albert").lower()
74-
self.api_key = os.getenv("SCRIBE_IA_KEY", "")
75-
self.model = os.getenv("SCRIBE_IA_MODEL", "")
76-
self.base_url = os.getenv("SCRIBE_IA_URL", "")
73+
# v2.0.5 — Priorité de lecture :
74+
# 1. config.IA (qui a déjà appliqué instance/ia_config.json + env vars)
75+
# 2. fallback config.js IA section (legacy)
76+
# Ainsi l'admin qui sauvegarde via UI voit son changement pris en compte
77+
# immédiatement après reload_ai_config().
78+
try:
79+
from config import IA
80+
self.provider = (IA.get("provider") or "albert").lower()
81+
self.api_key = IA.get("api_key", "") or ""
82+
self.model = IA.get("model", "") or ""
83+
self.base_url = IA.get("base_url", "") or ""
84+
except Exception:
85+
# Fallback strict env si import config échoue (cas exotique)
86+
self.provider = os.getenv("SCRIBE_IA_PROVIDER", "albert").lower()
87+
self.api_key = os.getenv("SCRIBE_IA_KEY", "")
88+
self.model = os.getenv("SCRIBE_IA_MODEL", "")
89+
self.base_url = os.getenv("SCRIBE_IA_URL", "")
7790

7891
self._load_from_config_js()
7992

app/static/js/scribe.js

Lines changed: 107 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -5829,6 +5829,8 @@ async function loadAdminIA() {
58295829
const r2 = await apiFetch('/api/v1/admin/config/ia');
58305830
if (!r2 || !r2.ok) return;
58315831
const d2 = await r2.json();
5832+
// v2.0.5 — cache global pour que adminShowIaConfig puisse récupérer default_model
5833+
window._iaProvidersCache = d2.all_providers || [];
58325834
const provBox = document.getElementById('admin-ia-providers');
58335835
if (!provBox || !d2.all_providers) return;
58345836
provBox.innerHTML = d2.all_providers.map(p => {
@@ -5851,30 +5853,58 @@ function adminShowIaConfig(providerId, providerLabel) {
58515853
if (!panel) return;
58525854
panel.style.display = 'block';
58535855
panel.scrollIntoView({behavior:'smooth', block:'nearest'});
5856+
5857+
// v2.0.5 — Récupérer le modèle par défaut du fournisseur depuis la config exposée
5858+
let defaultModel = '';
5859+
try {
5860+
const all = window._iaProvidersCache || [];
5861+
const meta = all.find(p => p.id === providerId);
5862+
if (meta && meta.default_model) defaultModel = meta.default_model;
5863+
} catch(e) {}
5864+
5865+
const isLocal = providerId === 'ollama' || providerId === 'openai_compat';
58545866
panel.innerHTML =
58555867
'<div style="font-family:var(--mono);font-size:11px;font-weight:700;margin-bottom:10px">⚙ Configurer : ' + providerLabel + '</div>' +
5856-
'<p style="font-family:var(--mono);font-size:10px;color:var(--muted);margin-bottom:10px">' +
5857-
'Pour activer ce fournisseur, définir les variables d\'environnement suivantes avant de démarrer SCRIBE :' +
5858-
'</p>' +
5859-
'<div style="background:var(--surface3);border-radius:4px;padding:10px;font-family:var(--mono);font-size:10px;line-height:2">' +
5860-
'<div>export <strong>SCRIBE_IA_PROVIDER</strong>=' + providerId + '</div>' +
5861-
'<div>export <strong>SCRIBE_IA_KEY</strong>=<em>votre-clé-api</em></div>' +
5862-
'<div>export <strong>SCRIBE_IA_MODEL</strong>=<em>nom-du-modèle (optionnel)</em></div>' +
5863-
'</div>' +
5864-
'<div id="ia-config-key-section" style="margin-top:12px">' +
5865-
'<label style="font-family:var(--mono);font-size:9px;color:var(--muted);letter-spacing:1px;display:block;margin-bottom:4px">CLÉ API (sauvegardée en variable d\'env temporaire)</label>' +
5866-
'<div style="display:flex;gap:8px">' +
5867-
'<input type="password" id="ia-config-key-input" placeholder="sk-... ou clé API" style="flex:1;font-family:var(--mono);font-size:10px;padding:6px 8px;background:var(--surface2);border:1px solid var(--border2);border-radius:4px;color:var(--text)">' +
5868-
'<button onclick="adminSaveIaKey(&quot;' + providerId + '&quot;)" style="font-family:var(--mono);font-size:10px;padding:6px 14px;background:#003189;color:#fff;border:none;border-radius:4px;cursor:pointer">Tester</button>' +
5868+
'<div id="ia-config-key-section">' +
5869+
'<label style="font-family:var(--mono);font-size:9px;color:var(--muted);letter-spacing:1px;display:block;margin-bottom:4px">' +
5870+
'CLÉ API' + (isLocal ? ' (optionnelle pour fournisseur local)' : ' (obligatoire)') + '</label>' +
5871+
'<input type="password" id="ia-config-key-input" placeholder="sk-... ou clé API" autocomplete="off" ' +
5872+
'style="width:100%;font-family:var(--mono);font-size:10px;padding:6px 8px;background:var(--surface2);' +
5873+
'border:1px solid var(--border2);border-radius:4px;color:var(--text);box-sizing:border-box;margin-bottom:10px">' +
5874+
'<label style="font-family:var(--mono);font-size:9px;color:var(--muted);letter-spacing:1px;display:block;margin-bottom:4px">' +
5875+
'MODÈLE (optionnel — laisser vide pour le défaut)</label>' +
5876+
'<input type="text" id="ia-config-model-input" placeholder="' + (defaultModel || 'défaut') + '" ' +
5877+
'style="width:100%;font-family:var(--mono);font-size:10px;padding:6px 8px;background:var(--surface2);' +
5878+
'border:1px solid var(--border2);border-radius:4px;color:var(--text);box-sizing:border-box;margin-bottom:10px">' +
5879+
(isLocal ?
5880+
'<label style="font-family:var(--mono);font-size:9px;color:var(--muted);letter-spacing:1px;display:block;margin-bottom:4px">' +
5881+
'URL DU SERVEUR LOCAL (optionnel)</label>' +
5882+
'<input type="text" id="ia-config-url-input" placeholder="http://localhost:11434/v1/chat/completions" ' +
5883+
'style="width:100%;font-family:var(--mono);font-size:10px;padding:6px 8px;background:var(--surface2);' +
5884+
'border:1px solid var(--border2);border-radius:4px;color:var(--text);box-sizing:border-box;margin-bottom:10px">'
5885+
: '') +
5886+
'<div style="display:flex;gap:8px;margin-top:6px;flex-wrap:wrap">' +
5887+
'<button onclick="adminTestIaKey(&quot;' + providerId + '&quot;)" ' +
5888+
'style="font-family:var(--mono);font-size:10px;padding:8px 14px;background:transparent;color:var(--text);' +
5889+
'border:1px solid var(--border2);border-radius:4px;cursor:pointer">🧪 Tester la clé</button>' +
5890+
'<button onclick="adminSaveIaConfig(&quot;' + providerId + '&quot;)" ' +
5891+
'style="font-family:var(--mono);font-size:10px;padding:8px 18px;background:#003189;color:#fff;' +
5892+
'border:none;border-radius:4px;cursor:pointer;font-weight:700">💾 Enregistrer & activer</button>' +
5893+
'<button onclick="adminResetIaConfig()" ' +
5894+
'style="font-family:var(--mono);font-size:10px;padding:8px 14px;background:transparent;color:#f87171;' +
5895+
'border:1px solid #f87171;border-radius:4px;cursor:pointer;margin-left:auto" ' +
5896+
'title="Supprime la clé sauvegardée et revient aux variables d\'environnement">' +
5897+
'🗑 Supprimer la config sauvegardée</button>' +
58695898
'</div>' +
5870-
'<div id="ia-config-result" style="font-family:var(--mono);font-size:10px;margin-top:6px"></div>' +
5899+
'<div id="ia-config-result" style="font-family:var(--mono);font-size:10px;margin-top:10px"></div>' +
58715900
'</div>';
58725901
}
58735902

5874-
async function adminSaveIaKey(providerId) {
5903+
// v2.0.5 — Tester sans enregistrer (renommé pour clarté)
5904+
async function adminTestIaKey(providerId) {
58755905
const key = document.getElementById('ia-config-key-input')?.value?.trim();
58765906
const res = document.getElementById('ia-config-result');
5877-
if (!key) { if(res) { res.style.color='#fbbf24'; res.textContent='⚠ Saisir une clé API'; } return; }
5907+
if (!key) { if(res) { res.style.color='#fbbf24'; res.textContent='⚠ Saisir une clé API à tester'; } return; }
58785908
if(res) { res.style.color='var(--muted)'; res.textContent='⏳ Test de connexion…'; }
58795909
try {
58805910
const r = await apiFetch('/api/v1/admin/config/ia/test', {
@@ -5884,13 +5914,73 @@ async function adminSaveIaKey(providerId) {
58845914
});
58855915
const d = await r.json();
58865916
if (d.ok) {
5887-
if(res) { res.style.color='#4ade80'; res.textContent='✓ Connexion OK — ' + (d.message||''); }
5917+
if(res) { res.style.color='#4ade80'; res.textContent='✓ Connexion OK — ' + (d.message||'') + ' · La clé n\'est PAS encore enregistrée, cliquez sur 💾 Enregistrer pour l\'activer'; }
5918+
} else {
5919+
if(res) { res.style.color='#f87171'; res.textContent='✗ ' + (d.detail||d.message||'Erreur'); }
5920+
}
5921+
} catch(e) { if(res) { res.style.color='#f87171'; res.textContent='Erreur réseau : ' + e.message; } }
5922+
}
5923+
5924+
// v2.0.5 — Enregistrer la config IA (avec persistance + reload à chaud)
5925+
async function adminSaveIaConfig(providerId) {
5926+
const key = document.getElementById('ia-config-key-input')?.value?.trim() || '';
5927+
const model = document.getElementById('ia-config-model-input')?.value?.trim() || '';
5928+
const url = document.getElementById('ia-config-url-input')?.value?.trim() || '';
5929+
const res = document.getElementById('ia-config-result');
5930+
5931+
const isLocal = providerId === 'ollama' || providerId === 'openai_compat';
5932+
if (!isLocal && !key) {
5933+
if(res) { res.style.color='#fbbf24'; res.textContent='⚠ Clé API obligatoire pour ce fournisseur'; }
5934+
return;
5935+
}
5936+
5937+
if(res) { res.style.color='var(--muted)'; res.textContent='⏳ Enregistrement…'; }
5938+
try {
5939+
const r = await apiFetch('/api/v1/admin/config/ia', {
5940+
method: 'POST',
5941+
headers: {'Content-Type':'application/json'},
5942+
body: JSON.stringify({provider: providerId, api_key: key, model: model, base_url: url})
5943+
});
5944+
const d = await r.json();
5945+
if (d.ok) {
5946+
if(res) {
5947+
res.style.color='#4ade80';
5948+
res.textContent='✓ ' + (d.message || 'Config IA enregistrée et activée');
5949+
}
5950+
// Vider le champ clé pour pas qu'elle traîne en clair dans le DOM
5951+
const keyInput = document.getElementById('ia-config-key-input');
5952+
if (keyInput) keyInput.value = '';
5953+
// Refresh du panneau actif après 800ms
5954+
setTimeout(() => loadAdminIA(), 800);
5955+
} else {
5956+
if(res) { res.style.color='#f87171'; res.textContent='✗ ' + (d.detail||d.message||'Erreur enregistrement'); }
5957+
}
5958+
} catch(e) { if(res) { res.style.color='#f87171'; res.textContent='Erreur réseau : ' + e.message; } }
5959+
}
5960+
5961+
// v2.0.5 — Supprimer la config IA persistée
5962+
async function adminResetIaConfig() {
5963+
if (!confirm('Supprimer la configuration IA enregistrée ?\n\nL\'instance reviendra aux valeurs des variables d\'environnement (ou aux valeurs par défaut).')) return;
5964+
const res = document.getElementById('ia-config-result');
5965+
if(res) { res.style.color='var(--muted)'; res.textContent='⏳ Suppression…'; }
5966+
try {
5967+
const r = await apiFetch('/api/v1/admin/config/ia', {method: 'DELETE'});
5968+
const d = await r.json();
5969+
if (d.ok) {
5970+
if(res) { res.style.color='#4ade80'; res.textContent='✓ ' + (d.message || 'Config supprimée'); }
5971+
setTimeout(() => loadAdminIA(), 800);
58885972
} else {
58895973
if(res) { res.style.color='#f87171'; res.textContent='✗ ' + (d.detail||d.message||'Erreur'); }
58905974
}
58915975
} catch(e) { if(res) { res.style.color='#f87171'; res.textContent='Erreur réseau : ' + e.message; } }
58925976
}
58935977

5978+
// v2.0.5 — Stub conservé pour compatibilité descendante (au cas où des handlers
5979+
// onclick="adminSaveIaKey(...)" subsistent dans du DOM ancien). Redirige vers test.
5980+
async function adminSaveIaKey(providerId) {
5981+
return adminTestIaKey(providerId);
5982+
}
5983+
58945984
async function loadAdminRouting() {
58955985
const box = document.getElementById('admin-routing-info');
58965986
if (!box) return;

config.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,3 +222,68 @@ def get_ia_provider_config() -> dict:
222222
if IA["base_url"]: base["url"] = IA["base_url"]
223223
base["provider"] = provider
224224
return base
225+
226+
227+
# ── v2.0.5 — Persistance config IA via fichier JSON ──────────────────────────
228+
# Permet à l'admin de sauvegarder provider/api_key/model/base_url depuis l'UI
229+
# sans redémarrer le process. Le fichier prime sur les variables d'env au boot,
230+
# et reload_ai_config() le relit à chaud après écriture.
231+
232+
import json as _json
233+
234+
_IA_CONFIG_PATH = os.path.join(os.path.dirname(__file__), "instance", "ia_config.json")
235+
236+
237+
def _load_persisted_ia() -> None:
238+
"""
239+
Lit instance/ia_config.json si présent et applique provider/api_key/model/
240+
base_url sur le dict IA. Silencieux si le fichier est absent ou corrompu —
241+
on retombe sur les variables d'env / valeurs par défaut.
242+
"""
243+
try:
244+
if not os.path.exists(_IA_CONFIG_PATH):
245+
return
246+
with open(_IA_CONFIG_PATH, "r", encoding="utf-8") as f:
247+
data = _json.load(f)
248+
if not isinstance(data, dict):
249+
return
250+
for key in ("provider", "api_key", "model", "base_url"):
251+
v = data.get(key)
252+
if v is not None and isinstance(v, str):
253+
IA[key] = v
254+
except Exception:
255+
# Échec silencieux — on n'empêche pas le boot si le JSON est cassé
256+
pass
257+
258+
259+
def save_persisted_ia(provider: str, api_key: str = "",
260+
model: str = "", base_url: str = "") -> str:
261+
"""
262+
Écrit instance/ia_config.json avec provider/api_key/model/base_url.
263+
Met aussi à jour le dict IA en mémoire (pour ne pas avoir à reload).
264+
Permissions 0600 sur le fichier (clé API = sensible).
265+
266+
Retourne le chemin du fichier écrit.
267+
"""
268+
os.makedirs(os.path.dirname(_IA_CONFIG_PATH), exist_ok=True)
269+
data = {
270+
"provider": (provider or "").strip(),
271+
"api_key": (api_key or "").strip(),
272+
"model": (model or "").strip(),
273+
"base_url": (base_url or "").strip(),
274+
}
275+
with open(_IA_CONFIG_PATH, "w", encoding="utf-8") as f:
276+
_json.dump(data, f, indent=2, ensure_ascii=False)
277+
try:
278+
os.chmod(_IA_CONFIG_PATH, 0o600)
279+
except Exception:
280+
pass # Windows ou FS sans support de chmod
281+
282+
# Met à jour le dict IA en mémoire immédiatement
283+
for k, v in data.items():
284+
IA[k] = v
285+
return _IA_CONFIG_PATH
286+
287+
288+
# Appliquer la persistance au boot (une seule fois)
289+
_load_persisted_ia()

0 commit comments

Comments
 (0)