From 2db0ecd4312eaec1c7cd96d91e3852e07a9afa9d Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Fri, 2 Oct 2026 23:13:56 +0200 Subject: [PATCH] Presets externes : vision, compactage, bench et badge pour une API distante MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- internal/loki/backend_external.go | 25 ++++++++--- internal/loki/backend_external_test.go | 57 ++++++++++++++++++++++++-- internal/loki/chat_compact.go | 5 +++ internal/loki/chat_conversation.go | 6 ++- internal/loki/chat_screenshot.go | 5 +++ internal/loki/llm_bench.go | 6 +++ internal/loki/llm_client.go | 4 +- internal/loki/ui/index.html | 10 ++++- internal/loki/ui/src/index.tmpl.html | 3 ++ internal/loki/ui/src/js/11-external.js | 7 +++- internal/loki/web_external.go | 10 +++-- internal/loki/web_upload.go | 3 ++ 12 files changed, 124 insertions(+), 17 deletions(-) diff --git a/internal/loki/backend_external.go b/internal/loki/backend_external.go index 990abe1..2e1404e 100644 --- a/internal/loki/backend_external.go +++ b/internal/loki/backend_external.go @@ -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 } diff --git a/internal/loki/backend_external_test.go b/internal/loki/backend_external_test.go index a489281..8db84fc 100644 --- a/internal/loki/backend_external_test.go +++ b/internal/loki/backend_external_test.go @@ -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") + } +} diff --git a/internal/loki/chat_compact.go b/internal/loki/chat_compact.go index 2b96c01..0057264 100644 --- a/internal/loki/chat_compact.go +++ b/internal/loki/chat_compact.go @@ -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 { diff --git a/internal/loki/chat_conversation.go b/internal/loki/chat_conversation.go index 1333d7d..e4c4b4b 100644 --- a/internal/loki/chat_conversation.go +++ b/internal/loki/chat_conversation.go @@ -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 "" } diff --git a/internal/loki/chat_screenshot.go b/internal/loki/chat_screenshot.go index 785f730..bff9622 100644 --- a/internal/loki/chat_screenshot.go +++ b/internal/loki/chat_screenshot.go @@ -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 { diff --git a/internal/loki/llm_bench.go b/internal/loki/llm_bench.go index 5c51ad7..733072e 100644 --- a/internal/loki/llm_bench.go +++ b/internal/loki/llm_bench.go @@ -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) diff --git a/internal/loki/llm_client.go b/internal/loki/llm_client.go index 9bc789d..5349d43 100644 --- a/internal/loki/llm_client.go +++ b/internal/loki/llm_client.go @@ -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 { diff --git a/internal/loki/ui/index.html b/internal/loki/ui/index.html index a473aad..2368f9c 100644 --- a/internal/loki/ui/index.html +++ b/internal/loki/ui/index.html @@ -2502,6 +2502,9 @@ html[data-files="1"] #files-btn{color:var(--accent)} Contextepilote la jauge et le seuil de compactage tok +
+ +
@@ -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 diff --git a/internal/loki/ui/src/index.tmpl.html b/internal/loki/ui/src/index.tmpl.html index e1574b1..9dd5da6 100644 --- a/internal/loki/ui/src/index.tmpl.html +++ b/internal/loki/ui/src/index.tmpl.html @@ -280,6 +280,9 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid Contextepilote la jauge et le seuil de compactage tok
+
+ +
diff --git a/internal/loki/ui/src/js/11-external.js b/internal/loki/ui/src/js/11-external.js index 2b05264..d99b426 100644 --- a/internal/loki/ui/src/js/11-external.js +++ b/internal/loki/ui/src/js/11-external.js @@ -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 diff --git a/internal/loki/web_external.go b/internal/loki/web_external.go index 1b7b594..70470e1 100644 --- a/internal/loki/web_external.go +++ b/internal/loki/web_external.go @@ -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 diff --git a/internal/loki/web_upload.go b/internal/loki/web_upload.go index 60249d2..3e0004d 100644 --- a/internal/loki/web_upload.go +++ b/internal/loki/web_upload.go @@ -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"]) != "" }