mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-12 01:37:06 +02:00
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>
184 lines
6.4 KiB
Go
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 ""
|
|
}
|