mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-12 09:47:20 +02:00
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>
This commit is contained in:
1 parent
48b8193dca
commit
2db0ecd431
12 files changed
+124
-17
No files matched your search
@@ -24,10 +24,11 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
extKeyFlag = "EXTERNAL" // marqueur : "1" = preset externe
|
||||
extKeyURL = "EXTERNAL_URL" // base ou URL complète des complétions
|
||||
extKeyModel = "EXTERNAL_MODEL" // nom du modèle envoyé dans le payload
|
||||
extKeyToken = "EXTERNAL_KEY" // clé API (Bearer), peut être vide
|
||||
extKeyFlag = "EXTERNAL" // marqueur : "1" = preset externe
|
||||
extKeyURL = "EXTERNAL_URL" // base ou URL complète des complétions
|
||||
extKeyModel = "EXTERNAL_MODEL" // nom du modèle envoyé dans le payload
|
||||
extKeyToken = "EXTERNAL_KEY" // clé API (Bearer), peut être vide
|
||||
extKeyVision = "EXTERNAL_VISION" // "1" = le modèle distant accepte les images
|
||||
)
|
||||
|
||||
// chatEndpoint : où partent les appels /v1/chat/completions de ce tour.
|
||||
@@ -46,6 +47,14 @@ func isExternalConfig(cfg map[string]string) bool {
|
||||
// externalActive : le preset actif est-il un endpoint externe ?
|
||||
func externalActive() bool { return isExternalConfig(ReadConfig()) }
|
||||
|
||||
// externalVisionActive : le preset actif est externe ET déclaré multimodal. Pas
|
||||
// de MMPROJ ni de sonde /props à consulter côté distant : seule la case du
|
||||
// preset dit si le modèle accepte les images.
|
||||
func externalVisionActive() bool {
|
||||
cfg := ReadConfig()
|
||||
return isExternalConfig(cfg) && strings.TrimSpace(cfg[extKeyVision]) == "1"
|
||||
}
|
||||
|
||||
// completionsURL normalise l'URL saisie en une URL de complétions complète.
|
||||
// Accepte les trois formes qu'on colle en pratique :
|
||||
//
|
||||
@@ -102,8 +111,9 @@ func (e chatEndpoint) auth(set func(k, v string)) {
|
||||
// externalPresetContent construit le corps .env d'un preset externe (hors ligne
|
||||
// « # NAME= », ajoutée par SavePreset). Une clé vide n'est pas écrite. `ctx` va
|
||||
// dans la clé CTX standard — elle pilote la jauge de contexte et le seuil de
|
||||
// compaction, exactement comme pour un preset local.
|
||||
func externalPresetContent(url, model, key, ctx string) string {
|
||||
// compaction, exactement comme pour un preset local. vision = le modèle distant
|
||||
// accepte les images (EXTERNAL_VISION=1).
|
||||
func externalPresetContent(url, model, key, ctx string, vision bool) string {
|
||||
m := map[string]string{
|
||||
extKeyFlag: "1",
|
||||
extKeyURL: strings.TrimSpace(url),
|
||||
@@ -112,6 +122,9 @@ func externalPresetContent(url, model, key, ctx string) string {
|
||||
if k := strings.TrimSpace(key); k != "" {
|
||||
m[extKeyToken] = k
|
||||
}
|
||||
if vision {
|
||||
m[extKeyVision] = "1"
|
||||
}
|
||||
if c := strings.TrimSpace(ctx); c != "" {
|
||||
m["CTX"] = c
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -46,7 +47,7 @@ func TestEndpointSuitLePresetActif(t *testing.T) {
|
||||
}
|
||||
|
||||
if err := WriteConfig(parseEnv(externalPresetContent(
|
||||
"https://api.groq.com/openai/v1", "llama-3.3-70b", "sk-secret", "65536"))); err != nil {
|
||||
"https://api.groq.com/openai/v1", "llama-3.3-70b", "sk-secret", "65536", false))); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ep = resolveChatEndpoint()
|
||||
@@ -71,7 +72,7 @@ func TestEndpointSuitLePresetActif(t *testing.T) {
|
||||
func TestLaCleLocaleNePartJamaisChezUnTiers(t *testing.T) {
|
||||
testHome(t)
|
||||
if err := WriteConfig(parseEnv(externalPresetContent(
|
||||
"https://api.openai.com/v1", "gpt-4o-mini", "", ""))); err != nil {
|
||||
"https://api.openai.com/v1", "gpt-4o-mini", "", "", false))); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Une clé d'API locale existe et n'a rien à faire dans la requête sortante.
|
||||
@@ -142,7 +143,7 @@ func TestRouteDeTestRemonteLErreurDeLAPI(t *testing.T) {
|
||||
// d'effacement.
|
||||
func TestEditionSansToucherALaCleLaConserve(t *testing.T) {
|
||||
testHome(t)
|
||||
id, err := SavePreset("", "Groq", externalPresetContent("https://api.groq.com/openai/v1", "llama", "sk-gardee", ""))
|
||||
id, err := SavePreset("", "Groq", externalPresetContent("https://api.groq.com/openai/v1", "llama", "sk-gardee", "", false))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -179,7 +180,7 @@ func TestEditionSansToucherALaCleLaConserve(t *testing.T) {
|
||||
// Un preset externe demande explicitement d'écraser la clé par une vide.
|
||||
func TestEffacementExpliciteDeLaCle(t *testing.T) {
|
||||
testHome(t)
|
||||
id, err := SavePreset("", "Local distant", externalPresetContent("http://192.168.1.20:8080", "qwen", "sk-a-jeter", ""))
|
||||
id, err := SavePreset("", "Local distant", externalPresetContent("http://192.168.1.20:8080", "qwen", "sk-a-jeter", "", false))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -194,3 +195,51 @@ func TestEffacementExpliciteDeLaCle(t *testing.T) {
|
||||
t.Fatalf("clé = %q, attendu effacée (keyTouched avec champ vide)", k)
|
||||
}
|
||||
}
|
||||
|
||||
// Le résumé de compactage n'envoie pas chat_template_kwargs (propre à
|
||||
// llama.cpp) à une API distante : une API stricte répond 400 et la compaction
|
||||
// échouait à chaque fois.
|
||||
func TestCompactageExterneSansKwargsLlamaCpp(t *testing.T) {
|
||||
testHome(t)
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var body map[string]any
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
if _, ok := body["chat_template_kwargs"]; ok {
|
||||
w.WriteHeader(400)
|
||||
_, _ = w.Write([]byte(`{"error":{"message":"Unrecognized request argument supplied: chat_template_kwargs"}}`))
|
||||
return
|
||||
}
|
||||
sendJSON(w, 200, map[string]any{"choices": []any{map[string]any{"message": map[string]any{"content": "- résumé"}}}})
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
if err := WriteConfig(parseEnv(externalPresetContent(srv.URL, "gpt-4o-mini", "", "", false))); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := summarizeTranscript(context.Background(), "user: bonjour\nassistant: salut"); err != nil {
|
||||
t.Fatalf("compactage refusé par l'API distante : %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Vision et benchmark d'un preset externe : la case du preset décide, et le
|
||||
// bench (qui mesure le moteur local) refuse de tourner.
|
||||
func TestPresetExterneVisionEtBench(t *testing.T) {
|
||||
testHome(t)
|
||||
if err := WriteConfig(parseEnv(externalPresetContent("https://api.openai.com/v1", "gpt-4o", "", "", true))); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !visionEnabled() || !engineSeesImages() {
|
||||
t.Error("preset externe déclaré multimodal : la vision devrait être active")
|
||||
}
|
||||
if _, err := runBench(10, 10); err == nil {
|
||||
t.Error("benchmark lancé sur un preset externe")
|
||||
}
|
||||
if got := chatModelName(); got != "gpt-4o" {
|
||||
t.Errorf("badge de modèle = %q, attendu le modèle distant", got)
|
||||
}
|
||||
if err := WriteConfig(parseEnv(externalPresetContent("https://api.openai.com/v1", "gpt-4o", "", "", false))); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if visionEnabled() {
|
||||
t.Error("preset externe texte seul : la vision devrait être coupée")
|
||||
}
|
||||
}
|
||||
@@ -605,6 +605,11 @@ Write the summary in the SAME language as the conversation.`
|
||||
// passe ces kwargs au gabarit Jinja (--jinja).
|
||||
"chat_template_kwargs": map[string]any{"enable_thinking": false},
|
||||
}
|
||||
if ep.External {
|
||||
// Propre à llama.cpp : une API distante stricte (OpenAI) refuse un
|
||||
// argument inconnu par un 400, et la compaction échouait à chaque fois.
|
||||
delete(payload, "chat_template_kwargs")
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", ep.URL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
|
||||
@@ -656,7 +656,11 @@ func (c *Conversation) dropQueued(epoch int) {
|
||||
// chatModelName renvoie le nom lisible du modèle configuré : fichier sans
|
||||
// dossier ni extension .gguf (même règle que modelLabel côté UI), "" si aucun.
|
||||
func chatModelName() string {
|
||||
m := strings.TrimSpace(ReadConfig()["MODEL"])
|
||||
cfg := ReadConfig()
|
||||
if isExternalConfig(cfg) {
|
||||
return strings.TrimSpace(cfg[extKeyModel]) // modèle distant, tel quel
|
||||
}
|
||||
m := strings.TrimSpace(cfg["MODEL"])
|
||||
if m == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -117,6 +117,11 @@ var (
|
||||
)
|
||||
|
||||
func engineSeesImages() bool {
|
||||
// Preset externe : aucun /props à sonder, on s'en tient à la déclaration
|
||||
// du preset (case « le modèle accepte les images »).
|
||||
if externalActive() {
|
||||
return externalVisionActive()
|
||||
}
|
||||
visionProbeMu.Lock()
|
||||
defer visionProbeMu.Unlock()
|
||||
if time.Since(visionProbeAt) < 10*time.Second {
|
||||
|
||||
@@ -44,6 +44,12 @@ Le cuisinier ferme les yeux pour goûter la sauce. Trop salée. Il ajoute une po
|
||||
// inflating decode numbers — what you measure here is close to what you'll
|
||||
// see in real chat at the same context length.
|
||||
func runBench(nPrompt, nPredict int) (*benchResult, error) {
|
||||
// Un bench mesure le moteur LOCAL : sur un preset externe, healthCheck dit
|
||||
// « prêt » sans moteur, et la mesure taperait un port arrêté ou un moteur
|
||||
// resté en vie — attribuée à tort à ce preset.
|
||||
if externalActive() {
|
||||
return nil, fmt.Errorf("benchmark indisponible : le preset actif est une API externe")
|
||||
}
|
||||
port := LLMPort()
|
||||
if !healthCheck() {
|
||||
return nil, fmt.Errorf("serveur injoignable sur :%d", port)
|
||||
|
||||
@@ -994,7 +994,9 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
|
||||
if reasoningEffort != "" {
|
||||
payload["reasoning_effort"] = reasoningEffort
|
||||
}
|
||||
if reasoningKwargs != nil {
|
||||
// chat_template_kwargs est propre à llama.cpp (--jinja) : une API
|
||||
// distante stricte (OpenAI) refuse un argument inconnu (400).
|
||||
if reasoningKwargs != nil && !ep.External {
|
||||
payload["chat_template_kwargs"] = reasoningKwargs
|
||||
}
|
||||
if len(tools) > 0 && !disableTools {
|
||||
|
||||
@@ -2502,6 +2502,9 @@ html[data-files="1"] #files-btn{color:var(--accent)}
|
||||
<span class="pe-row-l">Contexte<span class="pe-sub">pilote la jauge et le seuil de compactage</span></span>
|
||||
<span class="pe-row-c"><input id="ext-ctx" class="pe-val" type="number" min="0" step="1024" placeholder="32768"><span class="pe-unit">tok</span></span>
|
||||
</div>
|
||||
<div class="pe-row plain">
|
||||
<label class="switchrow" style="margin:0"><span class="switch"><input type="checkbox" id="ext-vision"><span class="slider"></span></span> le modèle accepte les images<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">coche si le modèle distant est <b>multimodal</b> (vision) : les images jointes lui sont alors envoyées. À laisser décoché pour un modèle texte seul.</span></span></label>
|
||||
</div>
|
||||
<div class="pe-row stack">
|
||||
<button id="ext-test" class="pe-link" onclick="testExternal()">Tester la connexion</button>
|
||||
<div id="ext-test-out" class="pe-note"></div>
|
||||
@@ -8106,6 +8109,7 @@ async function openExternal(id){
|
||||
document.getElementById('ext-test-out').textContent = '';
|
||||
document.getElementById('ext-key-sub').textContent = 'vide = aucune authentification';
|
||||
set('ext-name',''); set('ext-url',''); set('ext-model',''); set('ext-key',''); set('ext-ctx','');
|
||||
document.getElementById('ext-vision').checked = false;
|
||||
showModal('ext-modal');
|
||||
if(!id) return;
|
||||
let d = {};
|
||||
@@ -8113,6 +8117,7 @@ async function openExternal(id){
|
||||
if(extEditing !== id) return; // une autre ouverture a pris la main
|
||||
set('ext-name', d.name || ''); set('ext-url', d.url || '');
|
||||
set('ext-model', d.model || ''); set('ext-ctx', d.ctx || '');
|
||||
document.getElementById('ext-vision').checked = !!d.vision;
|
||||
if(d.hasKey){
|
||||
document.getElementById('ext-key-sub').textContent =
|
||||
'une clé est enregistrée — laisse vide pour la conserver';
|
||||
@@ -8129,6 +8134,7 @@ function extPayload(){
|
||||
key: document.getElementById('ext-key').value || '',
|
||||
ctx: (document.getElementById('ext-ctx').value || '').trim(),
|
||||
keyTouched: extKeyTouched,
|
||||
vision: document.getElementById('ext-vision').checked,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -8158,9 +8164,11 @@ async function saveExternal(){
|
||||
if(!p.model){ toast('modèle requis'); return; }
|
||||
const r = await jpost('/api/preset/external/save', p);
|
||||
if(!r.ok){ toast('erreur : ' + (r.error||'')); return; }
|
||||
toast('enregistré');
|
||||
// Preset en service : le serveur l'a appliqué aussitôt (applied).
|
||||
toast(r.applied ? 'enregistré et appliqué' : 'enregistré');
|
||||
closeExternal();
|
||||
loadPresets();
|
||||
if(r.applied && typeof loadStatus === 'function') loadStatus();
|
||||
}
|
||||
|
||||
// Même route de suppression que n'importe quel preset — et même refus si c'est
|
||||
|
||||
@@ -280,6 +280,9 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
|
||||
<span class="pe-row-l">Contexte<span class="pe-sub">pilote la jauge et le seuil de compactage</span></span>
|
||||
<span class="pe-row-c"><input id="ext-ctx" class="pe-val" type="number" min="0" step="1024" placeholder="32768"><span class="pe-unit">tok</span></span>
|
||||
</div>
|
||||
<div class="pe-row plain">
|
||||
<label class="switchrow" style="margin:0"><span class="switch"><input type="checkbox" id="ext-vision"><span class="slider"></span></span> le modèle accepte les images<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">coche si le modèle distant est <b>multimodal</b> (vision) : les images jointes lui sont alors envoyées. À laisser décoché pour un modèle texte seul.</span></span></label>
|
||||
</div>
|
||||
<div class="pe-row stack">
|
||||
<button id="ext-test" class="pe-link" onclick="testExternal()">Tester la connexion</button>
|
||||
<div id="ext-test-out" class="pe-note"></div>
|
||||
|
||||
@@ -27,6 +27,7 @@ async function openExternal(id){
|
||||
document.getElementById('ext-test-out').textContent = '';
|
||||
document.getElementById('ext-key-sub').textContent = 'vide = aucune authentification';
|
||||
set('ext-name',''); set('ext-url',''); set('ext-model',''); set('ext-key',''); set('ext-ctx','');
|
||||
document.getElementById('ext-vision').checked = false;
|
||||
showModal('ext-modal');
|
||||
if(!id) return;
|
||||
let d = {};
|
||||
@@ -34,6 +35,7 @@ async function openExternal(id){
|
||||
if(extEditing !== id) return; // une autre ouverture a pris la main
|
||||
set('ext-name', d.name || ''); set('ext-url', d.url || '');
|
||||
set('ext-model', d.model || ''); set('ext-ctx', d.ctx || '');
|
||||
document.getElementById('ext-vision').checked = !!d.vision;
|
||||
if(d.hasKey){
|
||||
document.getElementById('ext-key-sub').textContent =
|
||||
'une clé est enregistrée — laisse vide pour la conserver';
|
||||
@@ -50,6 +52,7 @@ function extPayload(){
|
||||
key: document.getElementById('ext-key').value || '',
|
||||
ctx: (document.getElementById('ext-ctx').value || '').trim(),
|
||||
keyTouched: extKeyTouched,
|
||||
vision: document.getElementById('ext-vision').checked,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -79,9 +82,11 @@ async function saveExternal(){
|
||||
if(!p.model){ toast('modèle requis'); return; }
|
||||
const r = await jpost('/api/preset/external/save', p);
|
||||
if(!r.ok){ toast('erreur : ' + (r.error||'')); return; }
|
||||
toast('enregistré');
|
||||
// Preset en service : le serveur l'a appliqué aussitôt (applied).
|
||||
toast(r.applied ? 'enregistré et appliqué' : 'enregistré');
|
||||
closeExternal();
|
||||
loadPresets();
|
||||
if(r.applied && typeof loadStatus === 'function') loadStatus();
|
||||
}
|
||||
|
||||
// Même route de suppression que n'importe quel preset — et même refus si c'est
|
||||
|
||||
@@ -49,6 +49,7 @@ func handlePresetExternal(w http.ResponseWriter, r *http.Request) {
|
||||
"model": strings.TrimSpace(cfg[extKeyModel]),
|
||||
"ctx": strings.TrimSpace(cfg["CTX"]),
|
||||
"hasKey": strings.TrimSpace(cfg[extKeyToken]) != "",
|
||||
"vision": strings.TrimSpace(cfg[extKeyVision]) == "1",
|
||||
})
|
||||
}
|
||||
|
||||
@@ -63,6 +64,7 @@ type externalSaveReq struct {
|
||||
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) {
|
||||
@@ -94,13 +96,15 @@ func handlePresetExternalSave(w http.ResponseWriter, r *http.Request) {
|
||||
key = strings.TrimSpace(parseEnv(content)[extKeyToken])
|
||||
}
|
||||
}
|
||||
content := externalPresetContent(req.URL, req.Model, key, req.Ctx)
|
||||
newID, err := SavePreset(req.ID, req.Name, content)
|
||||
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})
|
||||
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
|
||||
|
||||
@@ -177,6 +177,9 @@ func imageMime(name string) string {
|
||||
// seule où envoyer une image AU MODÈLE a un sens. Sans projecteur, llama-server
|
||||
// rejetterait un contenu image ; on s'en tient alors au dépôt-fichier.
|
||||
func visionEnabled() bool {
|
||||
if externalActive() {
|
||||
return externalVisionActive() // modèle distant déclaré multimodal
|
||||
}
|
||||
return strings.TrimSpace(ReadConfig()["MMPROJ"]) != ""
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user