diff --git a/internal/loki/backend_external.go b/internal/loki/backend_external.go new file mode 100644 index 0000000..990abe1 --- /dev/null +++ b/internal/loki/backend_external.go @@ -0,0 +1,119 @@ +package loki + +// backend_external.go — presets « externes » : au lieu de lancer un llama-server +// local, un tel preset route le chat vers une API OpenAI-compatible distante +// (OpenAI, Groq, OpenRouter, un autre Loki, un vLLM sur une autre machine…). +// Un preset externe se reconnaît à sa clé EXTERNAL=1 ; il porte l'URL, le nom du +// modèle et la clé d'accès. +// +// Choix d'archi, repris d'AJEAN : l'externe est un PRESET COMME UN AUTRE, un +// fichier .env dans presetsDir. Toute la mécanique existante s'applique sans +// rien changer — liste, nom d'affichage, empreinte d'activité, bascule, prompt +// système par preset, réglages d'échantillonnage. La différence ne vit qu'à +// DEUX endroits : au moment de l'inférence (resolveChatEndpoint) et au moment +// de la bascule (aucun moteur à redémarrer). +// +// Ce qui ne s'applique pas, et c'est voulu : un preset externe n'a ni MODEL, ni +// NGL, ni moteur. La machine distante décide de tout ça. CTX, en revanche, +// reste utile — c'est lui qui pilote la jauge de contexte et le seuil de +// compaction côté Loki. + +import ( + "fmt" + "strings" +) + +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 +) + +// chatEndpoint : où partent les appels /v1/chat/completions de ce tour. +type chatEndpoint struct { + URL string // URL complète des complétions + Model string // nom de modèle envoyé dans le payload + Key string // Bearer ("" = aucun) + External bool // true = API distante (pas le llama-server local) +} + +// isExternalConfig indique si une configuration décrit un endpoint externe. +func isExternalConfig(cfg map[string]string) bool { + return strings.TrimSpace(cfg[extKeyFlag]) == "1" +} + +// externalActive : le preset actif est-il un endpoint externe ? +func externalActive() bool { return isExternalConfig(ReadConfig()) } + +// completionsURL normalise l'URL saisie en une URL de complétions complète. +// Accepte les trois formes qu'on colle en pratique : +// +// - une URL déjà complète (…/chat/completions) — laissée telle quelle ; +// - une base OpenAI (…/v1, …/openai/v1, …/api/v1) — on ajoute /chat/completions ; +// - autre chose — on suppose une racine et on ajoute /v1/chat/completions. +// +// Exiger la forme exacte serait une source d'échecs muets : la page d'un +// fournisseur donne tantôt « https://api.groq.com/openai/v1 », tantôt l'URL +// complète, et se tromper ne produit qu'un 404 illisible. +func completionsURL(raw string) string { + u := strings.TrimRight(strings.TrimSpace(raw), "/") + if u == "" { + return "" + } + if strings.HasSuffix(u, "/chat/completions") { + return u + } + if strings.HasSuffix(u, "/v1") { + return u + "/chat/completions" + } + return u + "/v1/chat/completions" +} + +// resolveChatEndpoint décide où envoyer les complétions : l'API externe si le +// preset actif en est un, sinon le llama-server local. +func resolveChatEndpoint() chatEndpoint { + cfg := ReadConfig() + if isExternalConfig(cfg) { + return chatEndpoint{ + URL: completionsURL(cfg[extKeyURL]), + Model: strings.TrimSpace(cfg[extKeyModel]), + Key: strings.TrimSpace(cfg[extKeyToken]), + External: true, + } + } + return chatEndpoint{ + URL: fmt.Sprintf("http://localhost:%d/v1/chat/completions", LLMPort()), + Model: "loki", + Key: readAPIKey(), + } +} + +// auth pose l'en-tête d'autorisation de CET endpoint. Indispensable de le tenir +// ici plutôt que d'appeler authHeader : celui-ci envoie la clé du serveur LOCAL, +// et l'expédier à api.openai.com serait fuiter un secret chez un tiers en même +// temps qu'un 401 garanti. +func (e chatEndpoint) auth(set func(k, v string)) { + if e.Key != "" { + set("Authorization", "Bearer "+e.Key) + } +} + +// 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 { + m := map[string]string{ + extKeyFlag: "1", + extKeyURL: strings.TrimSpace(url), + extKeyModel: strings.TrimSpace(model), + } + if k := strings.TrimSpace(key); k != "" { + m[extKeyToken] = k + } + if c := strings.TrimSpace(ctx); c != "" { + m["CTX"] = c + } + return formatEnv(m) +} diff --git a/internal/loki/backend_external_test.go b/internal/loki/backend_external_test.go new file mode 100644 index 0000000..a489281 --- /dev/null +++ b/internal/loki/backend_external_test.go @@ -0,0 +1,196 @@ +package loki + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// Les trois formes d'URL qu'on colle en pratique. Exiger la forme exacte serait +// une source d'échecs muets : la page d'un fournisseur donne tantôt la base, +// tantôt l'URL complète, et se tromper ne produit qu'un 404 illisible. +func TestNormalisationDeLURLDeComplétions(t *testing.T) { + cas := map[string]string{ + "https://api.openai.com/v1": "https://api.openai.com/v1/chat/completions", + "https://api.groq.com/openai/v1": "https://api.groq.com/openai/v1/chat/completions", + "https://api.openai.com/v1/chat/completions": "https://api.openai.com/v1/chat/completions", + "https://api.openai.com/v1/": "https://api.openai.com/v1/chat/completions", + "http://192.168.1.20:8080": "http://192.168.1.20:8080/v1/chat/completions", + "": "", + } + for in, want := range cas { + if got := completionsURL(in); got != want { + t.Errorf("completionsURL(%q) = %q, attendu %q", in, got, want) + } + } +} + +// Un preset externe actif détourne les complétions ; sans lui, on reste sur le +// llama-server local avec la clé LOCALE. +func TestEndpointSuitLePresetActif(t *testing.T) { + testHome(t) + if err := SetConfigKey("PORT", "9999"); err != nil { + t.Fatal(err) + } + ep := resolveChatEndpoint() + if ep.External { + t.Fatal("aucun preset externe : l'endpoint doit rester local") + } + if ep.URL != "http://localhost:9999/v1/chat/completions" { + t.Fatalf("URL locale = %q", ep.URL) + } + if ep.Model != "loki" { + t.Fatalf("modèle local = %q, attendu loki", ep.Model) + } + + if err := WriteConfig(parseEnv(externalPresetContent( + "https://api.groq.com/openai/v1", "llama-3.3-70b", "sk-secret", "65536"))); err != nil { + t.Fatal(err) + } + ep = resolveChatEndpoint() + if !ep.External { + t.Fatal("EXTERNAL=1 non reconnu") + } + if ep.URL != "https://api.groq.com/openai/v1/chat/completions" { + t.Fatalf("URL externe = %q", ep.URL) + } + if ep.Model != "llama-3.3-70b" || ep.Key != "sk-secret" { + t.Fatalf("modèle/clé externes = %q / %q", ep.Model, ep.Key) + } + // CTX passe par la clé standard : c'est elle qui pilote la jauge de contexte + // et le seuil de compaction, exactement comme pour un preset local. + if got := ReadConfig()["CTX"]; got != "65536" { + t.Fatalf("CTX = %q, attendu 65536", got) + } +} + +// La clé du serveur LOCAL ne doit jamais partir chez un tiers : ce serait fuiter +// un secret en même temps qu'un 401 garanti. +func TestLaCleLocaleNePartJamaisChezUnTiers(t *testing.T) { + testHome(t) + if err := WriteConfig(parseEnv(externalPresetContent( + "https://api.openai.com/v1", "gpt-4o-mini", "", ""))); err != nil { + t.Fatal(err) + } + // Une clé d'API locale existe et n'a rien à faire dans la requête sortante. + if err := SetConfigKey("API_KEY", "sk-loki-locale"); err != nil { + t.Fatal(err) + } + ep := resolveChatEndpoint() + if ep.Key != "" { + t.Fatalf("clé envoyée à l'API externe = %q — le preset n'en déclare aucune", ep.Key) + } + got := map[string]string{} + ep.auth(func(k, v string) { got[k] = v }) + if len(got) != 0 { + t.Fatalf("en-tête posé sans clé de preset : %v", got) + } +} + +// Un preset externe se reconnaît, un preset local ne se prend pas pour un externe. +func TestReconnaissanceDunPresetExterne(t *testing.T) { + if !isExternalConfig(map[string]string{extKeyFlag: "1"}) { + t.Error("EXTERNAL=1 non reconnu") + } + for _, v := range []string{"", "0", "off", "false"} { + if isExternalConfig(map[string]string{extKeyFlag: v}) { + t.Errorf("EXTERNAL=%q pris pour un preset externe", v) + } + } + if isExternalConfig(parseEnv("MODEL=\"m.gguf\"\nCTX=4096\n")) { + t.Error("un preset local est pris pour un externe") + } +} + +// La route de test rend un message LISIBLE plutôt que le JSON brut : c'est tout +// l'intérêt de tester avant d'enregistrer. +func TestRouteDeTestRemonteLErreurDeLAPI(t *testing.T) { + testHome(t) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bearer sk-bonne" { + w.WriteHeader(401) + _, _ = w.Write([]byte(`{"error":{"message":"Incorrect API key provided"}}`)) + return + } + sendJSON(w, 200, map[string]any{"choices": []any{}}) + })) + t.Cleanup(srv.Close) + + post := func(body string) map[string]any { + w := httptest.NewRecorder() + handlePresetExternalTest(w, httptest.NewRequest("POST", "/api/preset/external/test", strings.NewReader(body))) + var out map[string]any + _ = json.Unmarshal(w.Body.Bytes(), &out) + return out + } + out := post(`{"url":"` + srv.URL + `","model":"m","key":"sk-mauvaise","keyTouched":true}`) + if out["ok"] == true { + t.Fatal("clé refusée annoncée comme valide") + } + if msg, _ := out["error"].(string); msg != "Incorrect API key provided" { + t.Fatalf("message d'erreur = %q, attendu celui de l'API et non le JSON brut", msg) + } + if out = post(`{"url":"` + srv.URL + `","model":"m","key":"sk-bonne","keyTouched":true}`); out["ok"] != true { + t.Fatalf("clé valide refusée : %v", out) + } +} + +// Rééditer un preset sans toucher au champ clé ne doit PAS l'effacer : la modale +// ne la reçoit jamais en clair, donc un champ vide n'est pas une demande +// d'effacement. +func TestEditionSansToucherALaCleLaConserve(t *testing.T) { + testHome(t) + id, err := SavePreset("", "Groq", externalPresetContent("https://api.groq.com/openai/v1", "llama", "sk-gardee", "")) + if err != nil { + t.Fatal(err) + } + w := httptest.NewRecorder() + handlePresetExternalSave(w, httptest.NewRequest("POST", "/api/preset/external/save", + strings.NewReader(`{"id":"`+id+`","name":"Groq","url":"https://api.groq.com/openai/v1","model":"llama-3.3","key":""}`))) + if w.Code != 200 { + t.Fatalf("code %d : %s", w.Code, w.Body.String()) + } + content, err := ReadPreset(id) + if err != nil { + t.Fatal(err) + } + cfg := parseEnv(content) + if cfg[extKeyToken] != "sk-gardee" { + t.Fatalf("clé = %q — effacée par une édition qui n'y touchait pas", cfg[extKeyToken]) + } + if cfg[extKeyModel] != "llama-3.3" { + t.Fatalf("modèle non mis à jour : %q", cfg[extKeyModel]) + } + // Et la clé ne ressort jamais en clair par la route de lecture. + w = httptest.NewRecorder() + handlePresetExternal(w, httptest.NewRequest("GET", "/api/preset/external?id="+id, nil)) + if strings.Contains(w.Body.String(), "sk-gardee") { + t.Fatalf("la clé est renvoyée en clair : %s", w.Body.String()) + } + var out map[string]any + _ = json.Unmarshal(w.Body.Bytes(), &out) + if out["hasKey"] != true { + t.Fatal("hasKey doit signaler la présence d'une clé") + } +} + +// 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", "")) + if err != nil { + t.Fatal(err) + } + w := httptest.NewRecorder() + handlePresetExternalSave(w, httptest.NewRequest("POST", "/api/preset/external/save", + strings.NewReader(`{"id":"`+id+`","name":"Local distant","url":"http://192.168.1.20:8080","model":"qwen","key":"","keyTouched":true}`))) + if w.Code != 200 { + t.Fatalf("code %d : %s", w.Code, w.Body.String()) + } + content, _ := ReadPreset(id) + if k := parseEnv(content)[extKeyToken]; k != "" { + t.Fatalf("clé = %q, attendu effacée (keyTouched avec champ vide)", k) + } +} diff --git a/internal/loki/backend_presets.go b/internal/loki/backend_presets.go index 97fdfcb..15670ee 100644 --- a/internal/loki/backend_presets.go +++ b/internal/loki/backend_presets.go @@ -264,6 +264,17 @@ func SwitchToPreset(target string) error { return err } fmt.Printf("%s configuration <- %s\n", green("[ok]"), filepath.Base(target)) + // Preset externe (backend_external.go) : rien à redémarrer, il n'a pas de + // MODEL et llama-server partirait en boucle de crash. On arrête au contraire + // le moteur encore en vie — le chat part vers l'API distante, garder le + // modèle en VRAM ne sert plus à rien. + if isExternalConfig(ReadConfig()) { + fmt.Println(dim("[info] preset externe — arrêt du moteur local")) + if !serviceIsActive() { + return nil + } + return serviceAction("stop") + } fmt.Println(dim("[info] redémarrage du service...")) return serviceAction("restart") } diff --git a/internal/loki/chat_compact.go b/internal/loki/chat_compact.go index f2050c6..ef6ceef 100644 --- a/internal/loki/chat_compact.go +++ b/internal/loki/chat_compact.go @@ -111,8 +111,15 @@ func compactSummaryBudget() int { // // Ce marqueur ne rend pas l'image récupérable (le résumé est du texte), il rend // sa PERTE visible : le résumeur peut la mentionner, et le modèle sait qu'il -// doit reprendre une capture plutôt que deviner. -const imageLostMarker = " [image — not kept in the conversation past this point; take the screenshot again if you still need to see it]" +// doit REVOIR l'image plutôt que deviner. +// +// Formulation volontairement générale depuis l'arrivée de see_image : une image +// peut désormais venir d'une capture (web_screenshot), d'une pièce jointe ou +// d'un fichier du disque. Dire « reprends la capture » enverrait le modèle +// photographier une page web alors que l'image perdue était un PNG sur le +// disque — le geste juste dépend de la provenance, que la légende conservée +// juste avant ce marqueur indique. +const imageLostMarker = " [image — not kept in the conversation past this point; look at it again (see_image on the file, or a new screenshot) if you still need to see it]" // msgText extrait le texte d'un message (Content est `any`, en pratique string // ou nil quand l'assistant n'a que des tool_calls). Un message multimodal @@ -577,8 +584,13 @@ Summarize densely and faithfully, keeping ONLY the essentials: Strict rules: no preamble or conclusion, no verbatim or long quotes, no throwaway detail. Use short bullet points. Be as concise as you can WHILE keeping every fact, decision and still-open task: a detail you drop here is lost for good, so when in doubt keep it. This is a dense compression summary, not a report. Always write ACTUAL prose sentences/bullets — never answer with just an id or a reference. Write the summary in the SAME language as the conversation.` + // Le résumé part au MÊME endroit que le chat : sur un preset externe, il + // s'appuie sur l'API distante (backend_external.go). Le laisser taper le + // llama-server local ferait échouer toute compaction dès qu'aucun moteur + // local ne tourne — et la compaction, c'est ce qui empêche le fil de mourir. + ep := resolveChatEndpoint() payload := map[string]any{ - "model": "loki", + "model": ep.Model, "messages": []Message{ {Role: "system", Content: sys}, {Role: "user", Content: transcript}, @@ -594,13 +606,12 @@ Write the summary in the SAME language as the conversation.` "chat_template_kwargs": map[string]any{"enable_thinking": false}, } body, _ := json.Marshal(payload) - url := fmt.Sprintf("http://localhost:%d/v1/chat/completions", LLMPort()) - req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body)) + req, err := http.NewRequestWithContext(ctx, "POST", ep.URL, bytes.NewReader(body)) if err != nil { return "", err } req.Header.Set("Content-Type", "application/json") - authHeader(req) + ep.auth(req.Header.Set) resp, err := http.DefaultClient.Do(req) if err != nil { return "", friendlyLLMError(err) @@ -608,7 +619,11 @@ Write the summary in the SAME language as the conversation.` defer resp.Body.Close() if resp.StatusCode != http.StatusOK { b, _ := io.ReadAll(io.LimitReader(resp.Body, 500)) - return "", fmt.Errorf("résumé: llama-server %d: %s", resp.StatusCode, strings.TrimSpace(string(b))) + who := "llama-server" + if ep.External { + who = "API externe" + } + return "", fmt.Errorf("résumé: %s %d: %s", who, resp.StatusCode, strings.TrimSpace(string(b))) } var out summarizeResp if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { diff --git a/internal/loki/chat_screenshot.go b/internal/loki/chat_screenshot.go index f3e963e..2159b35 100644 --- a/internal/loki/chat_screenshot.go +++ b/internal/loki/chat_screenshot.go @@ -156,9 +156,10 @@ func engineSeesImages() bool { // - garantir l'invariant à l'avenir, quel que soit le chemin d'écriture. // // Le texte restant reçoit imageLostMarker : sans lui, une conversation rouverte -// ne garde que la légende de la capture (« Capture … ») et le modèle n'a plus -// aucun indice qu'il A VU une image — il la redécrit de mémoire au lieu d'en -// reprendre une. Même raison que dans msgText, autre bout de la chaîne. +// ne garde que la légende (« Capture … », « Image demandée (x.png) : ») et le +// modèle n'a plus aucun indice qu'il A VU une image — il la redécrit de mémoire +// au lieu d'y revenir. Même raison que dans msgText, autre bout de la chaîne. +// Vaut pour toutes les provenances : capture, pièce jointe, see_image. func stripImageParts(msgs []Message) []Message { for i, m := range msgs { parts, ok := m.Content.([]any) diff --git a/internal/loki/chat_vision_tool.go b/internal/loki/chat_vision_tool.go new file mode 100644 index 0000000..3fe2c8d --- /dev/null +++ b/internal/loki/chat_vision_tool.go @@ -0,0 +1,117 @@ +package loki + +// chat_vision_tool.go — l'outil see_image : le modèle charge lui-même une image +// du disque dans sa VISION, sans que l'utilisateur ait à la joindre au message. +// +// Loki savait déjà voir deux choses : une pièce jointe (web_upload.go) et une +// capture d'écran qu'il venait de prendre (chat_screenshot.go). Pas un fichier +// qui dort sur le disque. « Regarde la capture dans ~/photos/bug.png » n'avait +// donc aucune réponse : `read` rend des octets binaires, et le modèle finissait +// par décrire ce qu'il croyait deviner du nom de fichier. +// +// Mécanique : le résultat de l'outil reste un simple texte (un accusé). L'image, +// elle, est réinjectée juste après dans un message utilisateur multimodal +// (image_url) — le SEUL format que llama-server comprenne une fois --mmproj +// chargé, et exactement le chemin déjà emprunté par les pièces jointes et les +// captures. + +import ( + "encoding/base64" + "fmt" + "os" + "path/filepath" +) + +// maxVisionBytes borne la taille d'une image chargée dans la vision. Au-delà, +// le base64 gonfle le contexte pour rien : le moteur redimensionne de toute +// façon avant l'encodeur d'images. +const maxVisionBytes = 12 << 20 // 12 Mio + +// toolSeeImage lit un fichier image et renvoie (accusé texte, partie image_url). +// La partie image vaut nil en cas d'erreur : l'appelant n'injecte alors rien. +// +// Chaque refus dit CE QUI manque plutôt que « impossible » : sans projecteur, la +// réponse n'est pas la même que sur un poste distant ou un fichier trop lourd, +// et un modèle qui reçoit un motif clair peut corriger son geste lui-même. +func toolSeeImage(path string) (string, map[string]any) { + if !visionEnabled() { + return "[erreur] la vision n'est pas active sur ce preset (aucun projecteur MMPROJ configuré) — impossible de voir une image", nil + } + // Cible = un poste distant : le fichier est LÀ-BAS, pas lisible d'ici. + if agentTargetSlug() != "" { + return "[erreur] voir une image n'est pas possible sur un poste distant (le fichier est sur l'autre machine)", nil + } + if path == "" { + return "[erreur] chemin de fichier manquant", nil + } + abs := resolveAgentPath(path) + mime := imageMime(abs) + if mime == "" { + return "[erreur] format non reconnu comme image (attendu : png, jpg, gif, webp, bmp) : " + path, nil + } + st, err := os.Stat(abs) + if err != nil { + return "[erreur] fichier introuvable : " + path, nil + } + if st.IsDir() { + return "[erreur] c'est un dossier, pas une image : " + path, nil + } + if st.Size() > maxVisionBytes { + return fmt.Sprintf("[erreur] image trop lourde (%s, max %s) : %s", + humanBytes(st.Size()), humanBytes(maxVisionBytes), path), nil + } + // Sonde du moteur EN DERNIER, juste avant le travail coûteux. Elle fait un + // appel réseau (/props, mis en cache 10 s) : la poser d'entrée, c'était payer + // un aller-retour pour répondre « chemin manquant », et surtout masquer les + // motifs précis ci-dessus derrière un « le moteur ne voit pas » qui n'apprend + // rien quand le vrai problème est une faute de frappe dans le chemin. + // + // Elle reste indispensable : un projecteur déclaré ne garantit pas que le + // moteur en service sache traiter une image (mauvais couple modèle/mmproj, + // moteur trop ancien). Autant le dire que d'envoyer 12 Mio de base64 que + // personne ne regardera. + if !engineSeesImages() { + return "[erreur] le moteur en service ne traite pas les images (projecteur non chargé ?) — impossible de voir " + path, nil + } + b, err := os.ReadFile(abs) + if err != nil { + return "[erreur] lecture impossible : " + err.Error(), nil + } + if len(b) == 0 { + return "[erreur] fichier vide : " + path, nil + } + return "[ok] image chargée : " + filepath.Base(abs), map[string]any{ + "type": "image_url", + "image_url": map[string]any{ + "url": "data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(b), + }, + } +} + +// seeImageMessage emballe la partie image dans le message utilisateur qui la +// porte jusqu'au modèle. La légende nomme le fichier : après compaction il ne +// restera qu'elle et imageLostMarker, et « Image demandée : » tout court ne +// dirait pas LAQUELLE rouvrir. +func seeImageMessage(label string, img map[string]any) Message { + return Message{Role: "user", Content: []map[string]any{ + {"type": "text", "text": "Image demandée (" + label + ") :"}, + img, + }} +} + +// seeImageTool — schéma envoyé au modèle. Description tenue au plus court : les +// schémas partent dans CHAQUE requête et le préambule a un budget +// (TestSystemPromptStaysLean). +func seeImageTool() Tool { + return Tool{Type: "function", Function: ToolFunction{ + Name: "see_image", + Description: "Ouvre un fichier image du disque pour le VOIR (png, jpg, gif, webp, bmp).", + Parameters: map[string]any{ + "type": "object", + "properties": map[string]any{ + "file": map[string]any{"type": "string", "description": "Chemin de l'image (relatif au dossier de travail, ou absolu)"}, + }, + "required": []string{"file"}, + }, + }} +} diff --git a/internal/loki/chat_vision_tool_test.go b/internal/loki/chat_vision_tool_test.go new file mode 100644 index 0000000..cdb0664 --- /dev/null +++ b/internal/loki/chat_vision_tool_test.go @@ -0,0 +1,186 @@ +package loki + +import ( + "encoding/base64" + "os" + "path/filepath" + "strings" + "testing" +) + +// pngPixel : un PNG valide d'un pixel, assez pour qu'imageMime le reconnaisse. +var pngPixel, _ = base64.StdEncoding.DecodeString( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==") + +// visionOn arme la vision (clé MMPROJ) et renvoie le dossier de travail. +func visionOn(t *testing.T) string { + t.Helper() + home := testHome(t) + if err := SetConfigKey("MMPROJ", "mmproj-F16.gguf"); err != nil { + t.Fatal(err) + } + return home +} + +// Sans projecteur, l'outil DIT ce qui manque au lieu d'échouer vaguement — et +// il ne renvoie surtout aucune image. +func TestSeeImageSansVisionRefuseEtExplique(t *testing.T) { + testHome(t) + txt, img := toolSeeImage("photo.png") + if img != nil { + t.Fatal("une image a été chargée alors que la vision est inactive") + } + if !strings.Contains(txt, "MMPROJ") { + t.Fatalf("le refus ne dit pas ce qui manque : %q", txt) + } +} + +// Les refus qui ne dépendent pas du moteur : format, absence, dossier, poids. +// Chacun doit nommer son motif — un modèle qui reçoit « impossible » ne peut +// pas corriger son geste, alors qu'un « ce n'est pas une image » se rattrape. +func TestSeeImageMotifsDeRefus(t *testing.T) { + home := visionOn(t) + write := func(name string, b []byte) string { + p := filepath.Join(home, name) + if err := os.WriteFile(p, b, 0o644); err != nil { + t.Fatal(err) + } + return p + } + write("notes.txt", []byte("bonjour")) + if err := os.MkdirAll(filepath.Join(home, "dossier.png"), 0o755); err != nil { + t.Fatal(err) + } + gros := write("gros.png", append(append([]byte(nil), pngPixel...), make([]byte, maxVisionBytes)...)) + + cas := []struct{ path, attendu string }{ + {"", "chemin de fichier manquant"}, + {filepath.Join(home, "notes.txt"), "format non reconnu"}, + {filepath.Join(home, "absente.png"), "introuvable"}, + {filepath.Join(home, "dossier.png"), "dossier"}, + {gros, "trop lourde"}, + } + for _, c := range cas { + txt, img := toolSeeImage(c.path) + if img != nil { + t.Errorf("%q : une image a été chargée malgré le refus", c.path) + } + if !strings.Contains(txt, c.attendu) { + t.Errorf("%q : motif = %q, attendu contenir %q", c.path, txt, c.attendu) + } + } +} + +// Le message porteur nomme le fichier : après compactage il ne restera que sa +// légende et imageLostMarker, et « Image demandée : » tout court ne dirait pas +// LAQUELLE rouvrir. +func TestMessagePorteurNommeLeFichier(t *testing.T) { + img := map[string]any{"type": "image_url", "image_url": map[string]any{"url": "data:image/png;base64,AAAA"}} + m := seeImageMessage("captures/bug.png", img) + if m.Role != "user" { + t.Fatalf("rôle = %q, attendu user (un message tool ne porte que du texte)", m.Role) + } + parts, ok := m.Content.([]map[string]any) + if !ok || len(parts) != 2 { + t.Fatalf("contenu multimodal attendu, obtenu %#v", m.Content) + } + txt, _ := parts[0]["text"].(string) + if !strings.Contains(txt, "captures/bug.png") { + t.Fatalf("la légende ne nomme pas le fichier : %q", txt) + } + if parts[1]["type"] != "image_url" { + t.Fatalf("seconde partie = %v, attendu image_url", parts[1]["type"]) + } + // Et cette légende survit au compactage, suivie du marqueur de perte. + got := msgText(m) + if !strings.Contains(got, "captures/bug.png") || !strings.Contains(got, imageLostMarker) { + t.Fatalf("après compaction : %q", got) + } +} + +// Le marqueur ne doit plus parler QUE de captures d'écran : une image perdue +// peut venir du disque, et « reprends la capture » enverrait photographier une +// page web à la place. +func TestMarqueurDePerteNestPlusSpecifiqueAuxCaptures(t *testing.T) { + if strings.Contains(imageLostMarker, "take the screenshot again") { + t.Fatal("le marqueur impose encore la capture d'écran comme seul recours") + } + if !strings.Contains(imageLostMarker, "see_image") { + t.Fatal("le marqueur ne mentionne pas see_image : le modèle ne saura pas rouvrir un fichier") + } +} + +// L'outil n'est proposé QUE si la vision est active : l'annoncer sans projecteur +// ferait promettre au modèle de regarder, puis se contredire. +func TestOutilProposeSeulementAvecLaVision(t *testing.T) { + testHome(t) + has := func() bool { + for _, tl := range EnabledTools(Caps{Agent: true}) { + if tl.Function.Name == "see_image" { + return true + } + } + return false + } + if has() { + t.Fatal("see_image proposé sans projecteur configuré") + } + if err := SetConfigKey("MMPROJ", "mmproj-F16.gguf"); err != nil { + t.Fatal(err) + } + if !has() { + t.Fatal("see_image absent alors que la vision est active") + } + // Hors mode agent, jamais : l'outil lit un fichier du disque. + if err := SetConfigKey("MMPROJ", "mmproj-F16.gguf"); err != nil { + t.Fatal(err) + } + for _, tl := range EnabledTools(Caps{}) { + if tl.Function.Name == "see_image" { + t.Fatal("see_image proposé hors mode agent") + } + } +} + +// Le chemin doit remonter du JSON d'appel jusqu'à l'outil. Le libellé d'un appel +// est dérivé par une table nom d'outil → argument ; see_image n'y figurait pas, +// donc il recevait une chaîne vide et répondait invariablement « chemin de +// fichier manquant » — l'outil aurait été inutilisable sans rien casser ailleurs. +func TestLeCheminDeLImageRemonteJusquALOutil(t *testing.T) { + if got := toolCallLabel("see_image", map[string]any{"file": "captures/bug.png"}); got != "captures/bug.png" { + t.Fatalf("libellé de see_image = %q, attendu le chemin du fichier", got) + } +} + +// Contre-épreuve de l'extraction : les libellés des autres outils n'ont pas +// bougé. Déplacer une table de 40 lignes hors d'une fonction de 700, c'est +// exactement le genre de geste qui casse un cas au passage sans bruit. +func TestLibellesDesAutresOutilsInchanges(t *testing.T) { + cas := []struct{ tool, key, val, want string }{ + {"bash", "command", "ls -la", "ls -la"}, + {"read", "file", "main.go", "main.go"}, + {"web_search", "query", "météo", "météo"}, + {"grep", "pattern", "TODO", "TODO"}, + {"recall", "id", "b12", "b12"}, + {"git_clone", "url", "https://x/y", "https://x/y"}, + {"outil_inconnu", "file", "x", ""}, + } + for _, c := range cas { + if got := toolCallLabel(c.tool, map[string]any{c.key: c.val}); got != c.want { + t.Errorf("toolCallLabel(%q) = %q, attendu %q", c.tool, got, c.want) + } + } + if got := toolCallLabel("web_grep", map[string]any{"url": "https://x", "pattern": "p"}); got != "p @ https://x" { + t.Errorf("web_grep : %q", got) + } + if got := toolCallLabel("tracker", map[string]any{"action": "add", "name": "poids"}); got != "add poids" { + t.Errorf("tracker : %q", got) + } + // Argument absent ou du mauvais type : chaîne vide, jamais de panique. + if got := toolCallLabel("read", map[string]any{"file": 42}); got != "" { + t.Errorf("argument non-chaîne : %q", got) + } + if got := toolCallLabel("bash", nil); got != "" { + t.Errorf("arguments nil : %q", got) + } +} diff --git a/internal/loki/llm_client.go b/internal/loki/llm_client.go index 3524cf7..ac8277b 100644 --- a/internal/loki/llm_client.go +++ b/internal/loki/llm_client.go @@ -359,6 +359,13 @@ func EnabledTools(caps Caps) []Tool { // une sortie sur le web — et sans elle, le modèle croyait n'avoir aucun // navigateur et perdait des minutes à installer puppeteer. La borne aux // adresses locales vit dans toolWebScreenshot. + // Voir une image du DISQUE : seulement quand la vision est réellement active + // (projecteur MMPROJ déclaré). Sinon l'outil ne saurait que renvoyer une + // erreur, et l'annoncer ferait croire au modèle qu'il a des yeux qu'il n'a + // pas — il promettrait alors de regarder, puis se contredirait. + if caps.Agent && visionEnabled() { + tools = append(tools, seeImageTool()) + } if caps.Agent && (caps.Internet || caps.Code) && screenshotAvailable() { tools = append(tools, webScreenshotTool()) } @@ -658,6 +665,55 @@ func isNetTimeout(err error) bool { return errors.As(err, &ne) && ne.Timeout() } +// toolCallLabel dérive le libellé HUMAIN d'un appel d'outil : la commande, le +// chemin, la requête… Il est annoncé à l'interface AVANT l'exécution — sans ça, +// une commande shell lente laisse l'écran figé sans rien dire — et il sert +// ensuite d'argument principal à plusieurs outils. +// +// Cette double fonction est un piège : un outil absent de cette table reçoit un +// libellé VIDE, et si son exécution lit ce libellé (see_image, read, bash…), il +// s'exécute sur une chaîne vide. Vécu à l'ajout de see_image : l'outil répondait +// « chemin de fichier manquant » quoi qu'on lui passe, sans que rien d'autre ne +// bronche. D'où l'extraction : une table pareille se teste. +func toolCallLabel(name string, args map[string]any) string { + label := "" + switch name { + case "mem_search", "web_search", "recall_search": + label, _ = args["query"].(string) + case "mem_read", "mem_add", "mem_edit", "edit", "write", "read", "git_diff", "see_image": + label, _ = args["file"].(string) + case "recall": + label, _ = args["id"].(string) + case "tracker": + // Libellé lisible : « nom » ou « action nom », pas un dump d'arguments. + label = strings.TrimSpace(str(args["action"]) + " " + str(args["name"])) + case "bash", "bash_bg": + label, _ = args["command"].(string) + case "grep", "glob": + label, _ = args["pattern"].(string) + case "ask": + label, _ = args["question"].(string) + case "criteria": + label, _ = args["action"].(string) + case "bash_tail": + label, _ = args["id"].(string) + case "git_clone": + label, _ = args["url"].(string) + case "web_open", "web_read": + label, _ = args["url"].(string) + case "web_grep": + u, _ := args["url"].(string) + p, _ := args["pattern"].(string) + label = p + " @ " + u + default: + // Outils MCP : libellé = un aperçu compact des arguments. + if isMCPTool(name) { + label = mcpArgLabel(args) + } + } + return label +} + func runChat(ctx context.Context, messages []Message, temperature float64, caps Caps, cb ChatCallback) ([]Message, error) { var extra []Message tools := EnabledTools(caps) @@ -715,6 +771,15 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps // Garde-fou « appel d'outil écrit en texte » (code_retry.go) : une seule // relance par tour, comme le nudge. patternRetried := false + // Reprises RÉSEAU consécutives (llm_retry_net.go). Remis à zéro dès qu'une + // réponse arrive : une boucle d'outils longue retrouve son budget à chaque + // itération réussie, un moteur durablement mort finit par rendre la main. + netRetries := 0 + // Destination des complétions : llama-server local, ou une API OpenAI-compatible + // externe si le preset actif en est un (backend_external.go). Résolu UNE FOIS + // par tour — une bascule de preset en plein tour est rare, et se rejoue de + // toute façon au message suivant. + ep := resolveChatEndpoint() // Budget SOUPLE d'appels d'outils (llm_budget.go). Toujours pas de plafond // d'itérations : couper un tour cassait des recherches légitimes. Mais au-delà // d'un palier on RAPPELLE au modèle combien d'appels il a déjà faits et on lui @@ -730,7 +795,7 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps messages = append(messages, Message{Role: "user", Content: msg}) } payload := map[string]any{ - "model": "loki", + "model": ep.Model, // Normalisé juste avant l'envoi : un seul système, en tête. Les gabarits // stricts (Qwen3.x) refusent un système ailleurs qu'en position 0. "messages": normalizeSystemMessages(messages), @@ -762,15 +827,28 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps payload["parallel_tool_calls"] = false } body, _ := json.Marshal(payload) - url := fmt.Sprintf("http://localhost:%d/v1/chat/completions", LLMPort()) - req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body)) + req, err := http.NewRequestWithContext(ctx, "POST", ep.URL, bytes.NewReader(body)) if err != nil { return extra, err } req.Header.Set("Content-Type", "application/json") - authHeader(req) + // ⚠️ Surtout pas authHeader : celui-ci pose la clé du serveur LOCAL, et + // l'envoyer à api.openai.com serait fuiter un secret chez un tiers en même + // temps qu'un 401 garanti. Chaque endpoint porte la sienne. + ep.auth(req.Header.Set) resp, err := http.DefaultClient.Do(req) if err != nil { + // Rien n'est encore parti à l'écran : le tour est rejouable tel quel. + // C'est le cas du moteur qui redémarre (bascule de preset, rechargement + // de modèle) — quelques secondes de connexion refusée qui faisaient + // échouer pour de bon une tâche planifiée tombée pile là. + if netRetries < llmNetRetries && llmRetryableErr(ctx, err) { + logLLMRetry(netRetries+1, friendlyLLMError(err).Error()) + if werr := llmNetBackoff(ctx, netRetries); werr == nil { + netRetries++ + continue + } + } err = friendlyLLMError(err) cb(StreamEvent{Err: err}) return extra, err @@ -832,10 +910,30 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps messages = steerSystem(messages, "N'appelle plus d'outil. Réponds maintenant directement en français à partir des informations déjà obtenues.") continue } - err := fmt.Errorf("llama-server a renvoyé %d : %s", resp.StatusCode, msg) + // Dernier recours, APRÈS les filets sémantiques ci-dessus : un statut + // qui dit « pas maintenant » (passerelle, service indisponible, trop de + // requêtes) et non « ta requête est fautive ». Le corps n'a pas été + // diffusé, le tour est rejouable. + if netRetries < llmNetRetries && llmRetryableStatus(resp.StatusCode) && ctx.Err() == nil { + logLLMRetry(netRetries+1, fmt.Sprintf("le moteur a renvoyé %d", resp.StatusCode)) + if werr := llmNetBackoff(ctx, netRetries); werr == nil { + netRetries++ + continue + } + } + // Nommer la BONNE machine : « llama-server a renvoyé 401 » sur un preset + // externe envoie chercher la panne du mauvais côté. + who := "llama-server" + if ep.External { + who = "l'API externe" + } + err := fmt.Errorf("%s a renvoyé %d : %s", who, resp.StatusCode, msg) cb(StreamEvent{Err: err}) return extra, err } + // Une réponse est arrivée : le budget de reprise réseau repart à neuf pour + // la suite de la boucle d'outils. + netRetries = 0 toolCalls := map[int]*ToolCall{} assistantContent := strings.Builder{} finishReason := "" @@ -1112,50 +1210,19 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps toolRuns++ // alimente le budget souple (voir budgetNudge) var args map[string]any _ = json.Unmarshal([]byte(tc.Function.Arguments), &args) - // Derive the human label (command / skill name) up front so we can - // announce the call BEFORE running it — otherwise the UI shows - // nothing while a slow shell command runs and looks frozen. - label := "" - switch tc.Function.Name { - case "mem_search", "web_search", "recall_search": - label, _ = args["query"].(string) - case "mem_read", "mem_add", "mem_edit", "edit", "write", "read", "git_diff": - label, _ = args["file"].(string) - case "recall": - label, _ = args["id"].(string) - case "tracker": - // Libellé lisible : « nom » ou « action nom », pas un dump d'arguments. - label = strings.TrimSpace(str(args["action"]) + " " + str(args["name"])) - case "bash", "bash_bg": - label, _ = args["command"].(string) - case "grep", "glob": - label, _ = args["pattern"].(string) - case "ask": - label, _ = args["question"].(string) - case "criteria": - label, _ = args["action"].(string) - case "bash_tail": - label, _ = args["id"].(string) - case "git_clone": - label, _ = args["url"].(string) - case "web_open", "web_read": - label, _ = args["url"].(string) - case "web_grep": - u, _ := args["url"].(string) - p, _ := args["pattern"].(string) - label = p + " @ " + u - default: - // Outils MCP : libellé = un aperçu compact des arguments. - if isMCPTool(tc.Function.Name) { - label = mcpArgLabel(args) - } - } + // Libellé humain, annoncé AVANT l'exécution (voir toolCallLabel) : sans + // ça l'interface reste muette pendant une commande lente. Il sert + // aussi d'argument principal à plusieurs outils. + label := toolCallLabel(tc.Function.Name, args) cb(StreamEvent{ToolUsed: &ToolUsedEvent{Name: tc.Function.Name, Label: label}}) result := "" // diff : rempli par les outils d'écriture (edit / mémoire) pour que // l'UI montre les lignes ajoutées et retirées. var diff []DiffLine + // visionImg : partie image_url rendue par see_image, réinjectée après + // le résultat de l'outil (un message `tool` ne porte que du texte). + var visionImg map[string]any // Appel rigoureusement identique déjà exécuté dans ce tour : on ne le // rejoue pas. Les petits modèles réémettent volontiers deux fois la // même écriture ; la rejouer produisait une fausse erreur (« old @@ -1171,6 +1238,8 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps continue } switch tc.Function.Name { + case "see_image": + result, visionImg = toolSeeImage(label) case "recall": result = toolRecall(args) case "recall_search": @@ -1352,6 +1421,15 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps } } } + // see_image a réussi : même relais, et surtout même règle ÉPHÉMÈRE + // que la capture — l'image va dans `messages` (le tour en cours) + // mais PAS dans `extra` (l'historique persistant). Un base64 + // persisté repartirait à CHAQUE tour suivant et finirait par + // dépasser la fenêtre pour de bon. Le modèle regarde l'image + // maintenant ; ce qu'il en dit, lui, reste. + if visionImg != nil { + messages = append(messages, seeImageMessage(label, visionImg)) + } } // Compaction EN COURS DE TOUR. Le seuil n'était testé qu'AU DÉBUT du tour : // une boucle d'outils peut à elle seule remplir la fenêtre (résultats @@ -1426,7 +1504,16 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps var healthClient = &http.Client{Timeout: 3 * time.Second} // healthCheck pings llama.cpp's /health endpoint. +// +// Preset externe : il n'y a pas de llama-server local à sonder. On répond +// « prêt » sans latence — la vraie joignabilité de l'API distante se révèle à +// l'appel de complétion, avec un message d'erreur explicite si elle échoue. +// Sans ce court-circuit, la saisie resterait bloquée sur un moteur éteint que +// personne n'allumera jamais. func healthCheck() bool { + if externalActive() { + return true + } resp, err := healthClient.Get(fmt.Sprintf("http://localhost:%d/health", LLMPort())) if err != nil { return false diff --git a/internal/loki/llm_retry_net.go b/internal/loki/llm_retry_net.go new file mode 100644 index 0000000..47c90fc --- /dev/null +++ b/internal/loki/llm_retry_net.go @@ -0,0 +1,124 @@ +package loki + +// llm_retry_net.go — réessayer un tour que RIEN n'a encore diffusé. +// +// Jusqu'ici, la requête de complétion partait une fois : un `Do()` qui échoue +// ou un statut d'erreur tuait le tour. Les messages d'erreur eux-mêmes le +// disaient — « réessaie dans quelques secondes », « réessaie dans un +// instant » — c'est-à-dire qu'on demandait à l'utilisateur de refaire à la main +// ce que le code pouvait faire seul. +// +// Le cas courant chez Loki n'a rien d'exotique : le moteur redémarre (bascule de +// preset, rechargement de modèle, redémarrage du conteneur) et la connexion est +// refusée pendant quelques secondes. Une tâche planifiée qui tombe pile là +// échouait pour de bon, sans personne pour cliquer. +// +// LA règle de sûreté, et elle ne souffre pas d'exception : on ne rejoue que +// TANT QU'AUCUN OCTET N'A ÉTÉ DIFFUSÉ. Une fois le flux commencé, la moitié de +// la réponse est déjà chez l'utilisateur ; la rejouer la dupliquerait. Les deux +// points de reprise (échec de `Do`, statut d'erreur avant lecture du corps) +// sont tous deux situés avant la première ligne SSE. + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "os" + "syscall" + "time" +) + +const ( + // llmNetRetries : tentatives CONSÉCUTIVES par tour. Le compteur repart à zéro + // dès qu'une réponse arrive, donc une boucle d'outils longue retrouve son + // budget à chaque itération réussie — mais un moteur durablement mort rend la + // main au lieu de tourner sans fin. + llmNetRetries = 3 + // Attente de la 1re reprise, doublée ensuite (0,8 s → 1,6 s → 3,2 s). Un + // llama-server qui redémarre met quelques secondes à rouvrir son port : plus + // court ne sert à rien, plus long fait passer une panne franche pour un gel. + llmNetWait = 800 * time.Millisecond + llmNetMaxWait = 5 * time.Second +) + +// llmRetryableErr : cette erreur de transport vaut-elle une nouvelle tentative ? +// +// Un ctx déjà fini n'en vaut JAMAIS une : c'est le /stop de l'utilisateur ou la +// limite de l'appelant, et réessayer serait passer outre. On le vérifie donc +// avant de regarder l'erreur elle-même — une échéance dépassée remonte sinon +// comme un simple timeout réseau, indiscernable d'une surcharge du moteur. +func llmRetryableErr(ctx context.Context, err error) bool { + if err == nil || ctx.Err() != nil { + return false + } + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + return false + } + switch { + case errors.Is(err, syscall.ECONNREFUSED): // moteur en cours de démarrage + return true + case errors.Is(err, syscall.ECONNRESET), errors.Is(err, io.EOF), errors.Is(err, io.ErrUnexpectedEOF): + return true + case isNetTimeout(err): // moteur surchargé ou en plein chargement du modèle + return true + } + return false +} + +// llmRetryableStatus : ce code HTTP vaut-il une nouvelle tentative ? +// +// Volontairement étroit. 500 en est ABSENT : c'est le code que llama.cpp rend +// pour un appel d'outil malformé ou un prompt qui dépasse le contexte, deux +// échecs déterministes que les filets au-dessus traitent déjà (retrait des +// outils, compaction en vol). Le rejouer trois fois ne ferait que retarder +// l'erreur de plusieurs secondes. Restent les codes qui disent « pas +// maintenant » : passerelle, service indisponible, trop de requêtes. +func llmRetryableStatus(code int) bool { + switch code { + case http.StatusTooManyRequests, http.StatusBadGateway, + http.StatusServiceUnavailable, http.StatusGatewayTimeout: + return true + } + return false +} + +// llmNetDelay : combien attendre avant la tentative n (0 = la première reprise). +// Doublement puis plafond. Séparé de l'attente pour être vérifiable sans dormir +// cinq secondes dans la suite de tests — et parce qu'un décalage de bits sur un +// n non borné déborderait en silence (0,8 s << 64 revient à zéro). +func llmNetDelay(n int) time.Duration { + if n < 0 { + n = 0 + } + if n > 16 { // au-delà, le plafond s'applique de toute façon + n = 16 + } + if d := llmNetWait << n; d < llmNetMaxWait { + return d + } + return llmNetMaxWait +} + +// llmNetBackoff attend avant la tentative n (0 = la première reprise). +// Interruptible : un /stop pendant l'attente rend la main tout de suite au lieu +// de faire patienter l'utilisateur pour une requête qu'il vient d'annuler. +func llmNetBackoff(ctx context.Context, n int) error { + t := time.NewTimer(llmNetDelay(n)) + defer t.Stop() + select { + case <-ctx.Done(): + return ctx.Err() + case <-t.C: + return nil + } +} + +// logLLMRetry trace la reprise. Elle n'est PAS envoyée à l'interface : le tour +// n'a encore rien produit, et faire clignoter une erreur qu'on est en train de +// rattraper inquiéterait pour rien. Le journal du conteneur, lui, doit la voir — +// c'est là qu'on cherche quand « ça a mis huit secondes ». +func logLLMRetry(attempt int, cause string) { + fmt.Fprintf(os.Stderr, "[llm] %s — nouvelle tentative %d/%d\n", cause, attempt, llmNetRetries) +} diff --git a/internal/loki/llm_retry_net_test.go b/internal/loki/llm_retry_net_test.go new file mode 100644 index 0000000..22d90db --- /dev/null +++ b/internal/loki/llm_retry_net_test.go @@ -0,0 +1,199 @@ +package loki + +import ( + "context" + "errors" + "io" + "net" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "sync/atomic" + "syscall" + "testing" + "time" +) + +// flakyServer répond `code` aux `fails` premières requêtes, puis sert un flux +// SSE normal. Renvoie le port (à mettre dans PORT) et le compteur d'appels. +func flakyServer(t *testing.T, fails int, code int, body string) (string, *atomic.Int32) { + t.Helper() + var calls atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if int(calls.Add(1)) <= fails { + http.Error(w, "moteur occupé", code) + return + } + w.Header().Set("Content-Type", "text/event-stream") + w.WriteHeader(200) + _, _ = w.Write([]byte(body)) + w.(http.Flusher).Flush() + })) + t.Cleanup(srv.Close) + u, err := url.Parse(srv.URL) + if err != nil { + t.Fatal(err) + } + return u.Port(), &calls +} + +// Le cas qui motive tout le fichier : le moteur redémarre, la connexion est +// refusée quelques secondes, et le tour mourait pour de bon — une tâche +// planifiée tombée pile là échouait sans personne pour recliquer. +func TestConnexionRefuseeEstRejouee(t *testing.T) { + testHome(t) + // Un port fermé : on ouvre puis on referme aussitôt pour en obtenir un dont on + // est sûr que personne n'écoute. + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + port := ln.Addr().(*net.TCPAddr).Port + _ = ln.Close() + if err := SetConfigKey("PORT", itoa(port)); err != nil { + t.Fatal(err) + } + + start := time.Now() + _, err = runChat(context.Background(), []Message{{Role: "user", Content: "bonjour"}}, 0.7, Caps{}, func(ev StreamEvent) bool { return true }) + elapsed := time.Since(start) + if err == nil { + t.Fatal("moteur injoignable : aucune erreur remontée") + } + // 3 reprises espacées de 0,8 / 1,6 / 3,2 s : le tour ne peut pas avoir rendu + // la main tout de suite. Sans ce délai, aucune reprise n'a eu lieu. + if min := llmNetWait + 2*llmNetWait; elapsed < min { + t.Fatalf("rendu la main en %v : aucune reprise n'a eu lieu (attendu au moins %v)", elapsed, min) + } +} + +// Un statut « pas maintenant » (503) est rejoué, et le tour aboutit. +func TestStatutTransitoireEstRejoueEtAboutit(t *testing.T) { + testHome(t) + port, calls := flakyServer(t, 2, http.StatusServiceUnavailable, + sseChunk("ça a fini par passer")+"data: [DONE]\n\n") + if err := SetConfigKey("PORT", port); err != nil { + t.Fatal(err) + } + var content strings.Builder + _, err := runChat(context.Background(), []Message{{Role: "user", Content: "bonjour"}}, 0.7, Caps{}, func(ev StreamEvent) bool { + if ev.Content != "" { + content.WriteString(ev.Content) + } + return true + }) + if err != nil { + t.Fatalf("503 transitoire non rattrapé : %v", err) + } + if n := calls.Load(); n != 3 { + t.Fatalf("%d appel(s) au moteur, attendu 3 (deux échecs puis la bonne)", n) + } + if !strings.Contains(content.String(), "ça a fini par passer") { + t.Fatalf("réponse perdue : %q", content.String()) + } +} + +// Contre-épreuve : un 500 n'est PAS un statut de reprise RÉSEAU. C'est ce que +// llama.cpp rend pour un appel d'outil malformé ou un prompt trop long — deux +// échecs déterministes ; attendre puis rejouer à l'identique ne ferait que +// retarder l'erreur de plusieurs secondes. +// +// Le tour peut malgré tout repartir UNE fois : le filet de compaction en vol, +// antérieur à ce fichier, retente le tour avec un historique résumé (le 500 est +// souvent un dépassement de contexte). C'est un rejeu SÉMANTIQUE — il change la +// requête et n'attend pas. La preuve qu'aucune reprise réseau n'a eu lieu est +// donc le CHRONO : trois reprises coûteraient au bas mot 0,8 + 1,6 s d'attente. +func TestErreur500NestPasRejoueeParLeReseau(t *testing.T) { + testHome(t) + port, calls := flakyServer(t, 99, http.StatusInternalServerError, "") + if err := SetConfigKey("PORT", port); err != nil { + t.Fatal(err) + } + start := time.Now() + _, err := runChat(context.Background(), []Message{{Role: "user", Content: "bonjour"}}, 0.7, Caps{}, func(ev StreamEvent) bool { return true }) + elapsed := time.Since(start) + if err == nil { + t.Fatal("500 : aucune erreur remontée") + } + if elapsed >= llmNetWait { + t.Fatalf("rendu la main en %v : une attente de reprise réseau a eu lieu sur un 500", elapsed) + } + // Borne haute large : ce qui compte est qu'on ne soit pas allé au bout du + // budget réseau (1 + 3 = 4 appels). + if n := calls.Load(); n > 2 { + t.Fatalf("%d appels au moteur sur un 500 : le budget de reprise réseau a été consommé", n) + } +} + +// Un /stop pendant le tour ne doit surtout pas être rattrapé : réessayer, c'est +// passer outre l'annulation de l'utilisateur. +func TestAnnulationNestJamaisRejouee(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if llmRetryableErr(ctx, syscall.ECONNREFUSED) { + t.Fatal("ctx annulé : la reprise doit être refusée") + } + if llmRetryableErr(context.Background(), context.Canceled) { + t.Fatal("context.Canceled : la reprise doit être refusée") + } + if llmRetryableErr(context.Background(), context.DeadlineExceeded) { + t.Fatal("échéance dépassée : c'est la limite de l'appelant, pas une panne réseau") + } + // L'attente elle-même rend la main tout de suite sur un ctx fini. + if err := llmNetBackoff(ctx, 2); err == nil { + t.Fatal("l'attente doit être interrompue par le ctx") + } +} + +// Les erreurs de transport qui méritent une reprise, et celles qui n'en méritent +// pas. Une faute de frappe dans l'URL ne se répare pas en attendant. +func TestErreursDeTransportRejouables(t *testing.T) { + ctx := context.Background() + for _, e := range []error{syscall.ECONNREFUSED, syscall.ECONNRESET, io.EOF, io.ErrUnexpectedEOF} { + if !llmRetryableErr(ctx, e) { + t.Errorf("%v devrait être rejouable (moteur qui redémarre ou connexion perdue)", e) + } + } + if llmRetryableErr(ctx, errors.New("unsupported protocol scheme")) { + t.Error("une erreur de configuration ne se répare pas en réessayant") + } + if llmRetryableErr(ctx, nil) { + t.Error("pas d'erreur, pas de reprise") + } +} + +// Les codes de reprise, et ceux qui n'en sont pas. +func TestStatutsRejouables(t *testing.T) { + for _, c := range []int{http.StatusTooManyRequests, http.StatusBadGateway, + http.StatusServiceUnavailable, http.StatusGatewayTimeout} { + if !llmRetryableStatus(c) { + t.Errorf("%d dit « pas maintenant » : rejouable", c) + } + } + for _, c := range []int{200, 400, 401, 404, 500} { + if llmRetryableStatus(c) { + t.Errorf("%d ne doit pas déclencher de reprise", c) + } + } +} + +// L'attente double à chaque tentative, se plafonne, et ne déborde pas : un +// décalage de bits sur un n non borné repasserait à zéro en silence — la reprise +// deviendrait une rafale. +func TestAttenteCroissantePuisPlafonnee(t *testing.T) { + if got := llmNetDelay(0); got != llmNetWait { + t.Errorf("1re attente = %v, attendu %v", got, llmNetWait) + } + if got := llmNetDelay(1); got != 2*llmNetWait { + t.Errorf("2e attente = %v, attendu le double", got) + } + for _, n := range []int{10, 20, 64, 1000} { + if got := llmNetDelay(n); got != llmNetMaxWait { + t.Errorf("llmNetDelay(%d) = %v, attendu le plafond %v", n, got, llmNetMaxWait) + } + } + if got := llmNetDelay(-1); got != llmNetWait { + t.Errorf("llmNetDelay(-1) = %v, attendu la 1re attente", got) + } +} diff --git a/internal/loki/ui/index.html b/internal/loki/ui/index.html index 2e94a85..48dfed4 100644 --- a/internal/loki/ui/index.html +++ b/internal/loki/ui/index.html @@ -201,8 +201,8 @@ option,optgroup{background:var(--panel);color:var(--text)} /* Desktop : menu plus large et bulles (badges) un peu plus grosses. */ @media (min-width:721px){ .side{width:380px} - .preset-meta .qtag,.preset-meta .rtag,.preset-meta .btag{font-size:11px;padding:2px 9px;border-radius:4px} - .preset-meta .qtag svg,.preset-meta .rtag svg,.preset-meta .btag svg{width:11px;height:11px} + .preset-meta .qtag,.preset-meta .rtag,.preset-meta .btag,.preset-meta .xtag{font-size:11px;padding:2px 9px;border-radius:4px} + .preset-meta .qtag svg,.preset-meta .rtag svg,.preset-meta .btag svg,.preset-meta .xtag svg{width:11px;height:11px} } h1{margin:0;font-size:18px;color:var(--accent)} /* Logo : « Loki » dessiné en pixels (SVG 4x5 par lettre, trait d'un module). Le « A » @@ -394,6 +394,15 @@ button:disabled{opacity:.5;cursor:not-allowed} .preset-meta{display:flex;align-items:center;gap:6px;margin-top:3px;flex-wrap:wrap} .qtag{flex-shrink:0;font-size:9px;padding:1px 6px;border:1px solid var(--border);color:var(--dim);border-radius:4px;letter-spacing:.03em;text-transform:uppercase} .rtag{flex-shrink:0;display:inline-flex;align-items:center;gap:3px;font-size:9px;padding:1px 6px;border:1px solid var(--border);color:var(--dim);border-radius:4px;letter-spacing:.03em} +/* Preset externe : le seul tag qui prend la couleur d'accent. Il ne décrit pas + un réglage mais une DESTINATION — la réponse part sur internet, pas sur la + carte graphique d'à côté, et ça mérite de se voir du premier coup d'œil. + min-width:0 + ellipsis : un nom de modèle distant peut être très long + (« llama-3.3-70b-versatile ») et ne doit pas pousser le reste hors de la ligne. */ +.xtag{flex-shrink:1;min-width:0;display:inline-flex;align-items:center;gap:3px;font-size:9px; + padding:1px 6px;border:1px solid var(--accent);color:var(--accent);border-radius:4px;letter-spacing:.03em; + overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.xtag svg{flex:none} .btag{flex-shrink:0;display:inline-flex;align-items:center;gap:3px;font-size:9px;padding:1px 6px;border:1px solid var(--border);color:var(--dim);border-radius:4px;letter-spacing:.03em} /* Petit « ? » d'aide : bulle au survol/clic au lieu d'un pavé de texte permanent. */ .help{display:inline-flex;align-items:center;justify-content:center;width:15px;height:15px;margin-left:6px;flex-shrink:0;font-size:10px;font-weight:600;line-height:1;color:var(--dim);border:1px solid var(--border);border-radius:50%;cursor:help;user-select:none;vertical-align:middle} @@ -2417,6 +2426,54 @@ html[data-files="1"] #files-btn{color:var(--accent)} + +
+