mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-12 01:37:06 +02:00
- module github.com/R0m1k3/Loki, cmd/loki, internal/loki (package loki) - LOKI_HOME, LOKI_MODEL_DIRS, LOKI_SERVICE, LOKI_DL_CONNS ; /etc/loki ; units loki-engine / loki-ui ; binaire et aide CLI - updateRepo pointe sur R0m1k3/Loki (l'auto-update ne tirera plus les binaires AJEAN amont) Conservé à l'identique : le domaine ajean.link (service de tunnel amont), les littéraux de migration 0.7.x (migrate_07.go), RELEASE_NOTES.md et LICENSE (historique et licence de l'amont). go build/vet/test : verts.
140 lines
4.3 KiB
Go
140 lines
4.3 KiB
Go
package loki
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// sseServer sert un flux de complétion puis le COUPE brutalement (fermeture de
|
|
// la connexion sans terminer la réponse), comme le ferait un moteur qui perd sa
|
|
// connexion en plein milieu. Renvoie le port à mettre dans PORT.
|
|
func sseCuttingServer(t *testing.T, body string, cut bool) string {
|
|
t.Helper()
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "text/event-stream")
|
|
w.WriteHeader(200)
|
|
_, _ = w.Write([]byte(body))
|
|
w.(http.Flusher).Flush()
|
|
if !cut {
|
|
return
|
|
}
|
|
// Coupe la connexion sans fin de réponse propre : côté client, la lecture
|
|
// du corps échoue (« unexpected EOF ») au lieu de se terminer.
|
|
hj, ok := w.(http.Hijacker)
|
|
if !ok {
|
|
t.Error("serveur de test non détournable")
|
|
return
|
|
}
|
|
conn, _, err := hj.Hijack()
|
|
if err != nil {
|
|
t.Error(err)
|
|
return
|
|
}
|
|
_ = conn.Close()
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
u, err := url.Parse(srv.URL)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return u.Port()
|
|
}
|
|
|
|
// chunk fabrique une ligne SSE de contenu.
|
|
func sseChunk(text string) string {
|
|
return `data: {"choices":[{"delta":{"content":"` + text + `"}}]}` + "\n\n"
|
|
}
|
|
|
|
// Le cœur de l'issue #19 : llama-server termine normalement de son côté (« stop
|
|
// processing » dans son journal), mais la lecture du flux casse. Avant, le tour
|
|
// s'arrêtait EN SILENCE — l'agent « rendait la main sans avoir terminé, ni même
|
|
// commenté ». Il doit maintenant remonter une erreur explicite.
|
|
func TestFluxCoupeRemonteUneErreur(t *testing.T) {
|
|
testHome(t)
|
|
// Réponse annoncée sur 3 chunks mais coupée après le premier, sans [DONE].
|
|
port := sseCuttingServer(t, sseChunk("début de r"), true)
|
|
if err := SetConfigKey("PORT", port); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
var gotErr error
|
|
var content strings.Builder
|
|
_, err := runChat(context.Background(), []Message{{Role: "user", Content: "bonjour"}}, 0.7, Caps{}, func(ev StreamEvent) bool {
|
|
switch {
|
|
case ev.Err != nil:
|
|
gotErr = ev.Err
|
|
case ev.Content != "":
|
|
content.WriteString(ev.Content)
|
|
}
|
|
return true
|
|
})
|
|
if err == nil {
|
|
t.Fatal("flux coupé : runChat a rendu la main SANS erreur (le tour s'arrêtait en silence)")
|
|
}
|
|
if gotErr == nil {
|
|
t.Fatal("flux coupé : aucune erreur poussée vers l'interface")
|
|
}
|
|
if !strings.Contains(err.Error(), "coupé") {
|
|
t.Errorf("message peu clair pour l'utilisateur : %v", err)
|
|
}
|
|
// Le texte déjà reçu n'est pas jeté : on a bien affiché ce qui était arrivé.
|
|
if !strings.Contains(content.String(), "début de r") {
|
|
t.Errorf("le texte reçu avant la coupure a été perdu : %q", content.String())
|
|
}
|
|
}
|
|
|
|
// Contre-épreuve : un flux qui se termine PROPREMENT ne doit évidemment pas
|
|
// déclencher l'erreur, sans quoi chaque réponse normale se plaindrait.
|
|
func TestFluxCompletNeRemonteRien(t *testing.T) {
|
|
testHome(t)
|
|
port := sseCuttingServer(t, sseChunk("réponse complète")+"data: [DONE]\n\n", false)
|
|
if err := SetConfigKey("PORT", port); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var gotErr error
|
|
var content strings.Builder
|
|
_, err := runChat(context.Background(), []Message{{Role: "user", Content: "bonjour"}}, 0.7, Caps{}, func(ev StreamEvent) bool {
|
|
if ev.Err != nil {
|
|
gotErr = ev.Err
|
|
}
|
|
if ev.Content != "" {
|
|
content.WriteString(ev.Content)
|
|
}
|
|
return true
|
|
})
|
|
if err != nil || gotErr != nil {
|
|
t.Fatalf("flux normal signalé en erreur : %v / %v", err, gotErr)
|
|
}
|
|
if content.String() != "réponse complète" {
|
|
t.Fatalf("contenu = %q", content.String())
|
|
}
|
|
}
|
|
|
|
// Un arrêt demandé par l'utilisateur (/stop) coupe aussi la lecture : il ne doit
|
|
// PAS se déguiser en panne du moteur.
|
|
func TestFluxAnnuleResteSilencieux(t *testing.T) {
|
|
testHome(t)
|
|
port := sseCuttingServer(t, sseChunk("a"), true)
|
|
if err := SetConfigKey("PORT", port); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
var gotErr error
|
|
_, _ = runChat(ctx, []Message{{Role: "user", Content: "bonjour"}}, 0.7, Caps{}, func(ev StreamEvent) bool {
|
|
if ev.Content != "" {
|
|
cancel() // l'utilisateur clique sur stop dès le premier morceau
|
|
}
|
|
if ev.Err != nil {
|
|
gotErr = ev.Err
|
|
}
|
|
return true
|
|
})
|
|
if gotErr != nil && strings.Contains(gotErr.Error(), "coupé") {
|
|
t.Fatalf("un stop volontaire a été présenté comme une panne : %v", gotErr)
|
|
}
|
|
}
|