mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-16 03:39:29 +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
125 lines
4.9 KiB
Go
125 lines
4.9 KiB
Go
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)
|
|
}
|