mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-14 18:58:43 +02:00
Trois apports repérés chez AJEAN (v0.13.8) et OpenFox (2.0.118), portés et adaptés à Loki. ## Reprise réseau du tour (llm_retry_net.go) 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 le disaient eux-mêmes — « réessaie dans quelques secondes » — autrement dit on demandait à l'utilisateur de refaire à la main ce que le code pouvait faire seul. Une tâche planifiée tombée pendant un redémarrage du moteur échouait pour de bon, sans personne pour recliquer. Trois reprises consécutives, 0,8 → 1,6 → 3,2 s, plafonnées, interruptibles par un /stop. La règle de sûreté ne souffre pas d'exception : on ne rejoue que TANT QU'AUCUN OCTET N'A ÉTÉ DIFFUSÉ, sinon la moitié de la réponse déjà chez l'utilisateur serait dupliquée. Le compteur repart à zéro dès qu'une réponse arrive. 500 n'est pas un statut de reprise : c'est ce que llama.cpp rend pour un appel d'outil malformé ou un prompt trop long, deux échecs déterministes que les filets sémantiques traitent déjà. Restent les codes qui disent « pas maintenant » : 429, 502, 503, 504. ## Presets externes (backend_external.go, web_external.go) Un preset avec EXTERNAL=1 route le chat vers une API OpenAI-compatible distante (OpenAI, Groq, OpenRouter, un vLLM sur une autre machine) au lieu du llama-server local. C'est un preset COMME UN AUTRE : même liste, même bascule, même prompt système par preset. La différence ne vit qu'à deux endroits — l'inférence (resolveChatEndpoint) et la bascule, qui arrête le moteur local au lieu de le redémarrer. La clé du serveur local ne part jamais chez un tiers : chaque endpoint porte la sienne. La clé du preset n'est jamais renvoyée en clair à l'interface, et un champ vide ne l'efface pas — il faut y avoir touché. Une fenêtre dédiée plutôt que l'éditeur habituel : un modèle distant n'a ni quantification, ni couches GPU, ni moteur. Un bouton teste la connexion avant d'enregistrer, et rend le message de l'API plutôt que le JSON brut. Le résumé de compactage part au même endroit que le chat : le laisser taper le moteur local aurait cassé toute compaction sur un preset externe. ## see_image (chat_vision_tool.go) Loki savait voir une pièce jointe et une capture qu'il venait de prendre, mais pas un fichier qui dort sur le disque : « regarde ~/photos/bug.png » n'avait aucune réponse, `read` rendant des octets binaires. L'outil charge l'image et la réinjecte dans un message utilisateur multimodal — même chemin que les pièces jointes. Même règle ÉPHÉMÈRE que les captures (et non celle de l'amont, qui persiste l'image) : l'image va dans le tour en cours, pas dans l'historique. Un base64 persisté repartirait à chaque tour et finirait par dépasser la fenêtre pour de bon. Le marqueur de perte à la compaction existait déjà mais n'offrait qu'un recours, « reprends la capture » — ce qui enverrait photographier une page web alors que l'image perdue est un PNG du disque. Formulation généralisée. ## Au passage toolCallLabel est extrait de runChat. Cette table nom d'outil → argument a une double fonction — libellé affiché ET argument principal — donc un outil absent s'exécute sur une chaîne vide : see_image répondait « chemin de fichier manquant » quoi qu'on lui passe, sans que rien d'autre ne bronche. Une table pareille se teste. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0129sffVC43rezAXUMQuzUog
200 lines
7.1 KiB
Go
200 lines
7.1 KiB
Go
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)
|
|
}
|
|
}
|