Files
Loki/internal/loki/web_external.go
T
MichaelandClaude Opus 5.5 2db0ecd431 Presets externes : vision, compactage, bench et badge pour une API distante
La reprise faite en parallèle sur l'autre branche (b667fd4, jamais poussée)
couvrait des trous de celle-ci. Ses ajouts, posés sur la version en place :

- chat_template_kwargs, propre à llama.cpp, ne part plus vers une API
  distante — ni au chat ni au résumé de compactage. Une API stricte
  (OpenAI) répond 400 à un argument inconnu : toute compaction échouait.
- Vision : case « le modèle accepte les images » (EXTERNAL_VISION) dans la
  fenêtre API externe. Elle remplace, en externe, MMPROJ et la sonde
  /props : un modèle distant multimodal ne pouvait jamais recevoir d'image.
- Le benchmark refuse de tourner sur un preset externe : healthCheck le dit
  prêt sans moteur, et la mesure tapait un port arrêté ou un moteur resté
  en vie, attribuée à tort à ce preset.
- Le badge de modèle des réponses porte le nom du modèle distant.
- Éditer le preset en service l'applique aussitôt (SavePresetApplying).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:13:56 +02:00

184 lines
6.4 KiB
Go

package loki
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"strings"
"time"
)
// web_external.go — endpoints /api/preset/external/* : lire, enregistrer et
// tester un preset « externe » (API OpenAI-compatible distante).
//
// GET /api/preset/external?id=… champs du preset (jamais la clé en clair)
// POST /api/preset/external/save enregistre (création si id vide)
// POST /api/preset/external/test appel de complétion minimal, avant d'enregistrer
//
// Le test AVANT enregistrement n'est pas un confort : une URL mal collée ou une
// clé expirée ne se voit sinon qu'au premier message, sous la forme d'un code
// HTTP nu au milieu d'une conversation.
// handlePresetExternal renvoie les champs d'un preset externe pour préremplir la
// modale d'édition. id vide = nouveau preset (champs vides).
func handlePresetExternal(w http.ResponseWriter, r *http.Request) {
id := strings.TrimSpace(r.URL.Query().Get("id"))
if id == "" {
sendJSON(w, 200, map[string]any{"id": "", "name": "", "url": "", "model": "", "hasKey": false})
return
}
content, err := ReadPreset(id)
if err != nil {
sendJSON(w, 404, map[string]any{"error": "not found"})
return
}
cfg := parseEnv(content)
if !isExternalConfig(cfg) {
sendJSON(w, 400, map[string]any{"error": "pas un preset externe"})
return
}
// La clé n'est PAS renvoyée en clair : on signale seulement sa présence, pour
// que la modale montre « clé enregistrée » sans l'exposer. Un ré-enregistrement
// sans toucher au champ clé la conserve (voir handlePresetExternalSave).
sendJSON(w, 200, map[string]any{
"id": id,
"name": presetDisplayName(content, id),
"url": strings.TrimSpace(cfg[extKeyURL]),
"model": strings.TrimSpace(cfg[extKeyModel]),
"ctx": strings.TrimSpace(cfg["CTX"]),
"hasKey": strings.TrimSpace(cfg[extKeyToken]) != "",
"vision": strings.TrimSpace(cfg[extKeyVision]) == "1",
})
}
// externalSaveReq : payload de la modale externe. `key` vide sur une ÉDITION
// conserve la clé existante (voir keyTouched) ; sur une création, absence de clé
// = pas d'authentification.
type externalSaveReq struct {
ID string `json:"id"`
Name string `json:"name"`
URL string `json:"url"`
Model string `json:"model"`
Key string `json:"key"`
Ctx string `json:"ctx"` // taille de contexte (clé CTX), optionnelle
KeyTouched bool `json:"keyTouched"` // l'utilisateur a modifié le champ clé
Vision bool `json:"vision"` // le modèle distant accepte les images
}
func handlePresetExternalSave(w http.ResponseWriter, r *http.Request) {
var req externalSaveReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
return
}
req.Name = strings.TrimSpace(req.Name)
req.URL = strings.TrimSpace(req.URL)
req.Model = strings.TrimSpace(req.Model)
if req.Name == "" {
sendJSON(w, 400, map[string]any{"ok": false, "error": "nom requis"})
return
}
if req.URL == "" {
sendJSON(w, 400, map[string]any{"ok": false, "error": "URL requise"})
return
}
if req.Model == "" {
sendJSON(w, 400, map[string]any{"ok": false, "error": "modèle requis"})
return
}
// Clé : sur une édition sans toucher au champ, on relit la clé déjà stockée
// pour ne pas l'effacer (la modale ne la reçoit jamais en clair).
key := req.Key
if req.ID != "" && !req.KeyTouched {
if content, err := ReadPreset(req.ID); err == nil {
key = strings.TrimSpace(parseEnv(content)[extKeyToken])
}
}
content := externalPresetContent(req.URL, req.Model, key, req.Ctx, req.Vision)
// Éditer le preset EN SERVICE l'applique tout de suite : sans ça, une URL ou
// une clé corrigée ne servait qu'après une bascule aller-retour.
newID, applied, err := SavePresetApplying(req.ID, req.Name, content)
if err != nil {
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
return
}
sendJSON(w, 200, map[string]any{"ok": true, "id": newID, "name": req.Name, "applied": applied})
}
// handlePresetExternalTest tente un appel de complétion minimal vers l'endpoint
// saisi, pour valider URL + modèle + clé avant d'enregistrer. Sur une édition
// sans clé fournie, on réutilise la clé stockée.
func handlePresetExternalTest(w http.ResponseWriter, r *http.Request) {
var req externalSaveReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
return
}
url := completionsURL(req.URL)
model := strings.TrimSpace(req.Model)
if url == "" || model == "" {
sendJSON(w, 200, map[string]any{"ok": false, "error": "URL et modèle requis"})
return
}
key := strings.TrimSpace(req.Key)
if key == "" && req.ID != "" && !req.KeyTouched {
if content, err := ReadPreset(req.ID); err == nil {
key = strings.TrimSpace(parseEnv(content)[extKeyToken])
}
}
payload := map[string]any{
"model": model,
"messages": []map[string]any{{"role": "user", "content": "ping"}},
"max_tokens": 1,
"stream": false,
}
body, _ := json.Marshal(payload)
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
defer cancel()
hreq, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body))
if err != nil {
sendJSON(w, 200, map[string]any{"ok": false, "error": err.Error()})
return
}
hreq.Header.Set("Content-Type", "application/json")
if key != "" {
hreq.Header.Set("Authorization", "Bearer "+key)
}
resp, err := http.DefaultClient.Do(hreq)
if err != nil {
sendJSON(w, 200, map[string]any{"ok": false, "error": "injoignable : " + err.Error()})
return
}
defer resp.Body.Close()
rb, _ := io.ReadAll(io.LimitReader(resp.Body, 2000))
if resp.StatusCode != http.StatusOK {
msg := strings.TrimSpace(string(rb))
if m := extractAPIError(rb); m != "" {
msg = m
}
if msg == "" {
msg = resp.Status
}
sendJSON(w, 200, map[string]any{"ok": false, "status": resp.StatusCode, "error": msg})
return
}
sendJSON(w, 200, map[string]any{"ok": true})
}
// extractAPIError sort le champ error.message d'une réponse d'erreur au format
// OpenAI ({"error":{"message":"…"}}), pour un message lisible plutôt que le JSON
// brut. Renvoie "" si le format ne correspond pas.
func extractAPIError(b []byte) string {
var e struct {
Error struct {
Message string `json:"message"`
} `json:"error"`
}
if json.Unmarshal(b, &e) == nil {
return strings.TrimSpace(e.Error.Message)
}
return ""
}