diff --git a/README.md b/README.md index 135a034..44fad51 100644 --- a/README.md +++ b/README.md @@ -206,16 +206,21 @@ Ajoutées par ce fork : runtime manque (`npx`/`uvx` absent) le signale au lieu d'échouer plus tard, et celles qui réclament une clé d'API la rappellent avant l'enregistrement. - **Intensité du raisonnement** : un niveau — auto / aucune / basse / moyenne / - haute — envoyé à `llama-server` comme `reasoning_effort`. Réglable **dans la - barre de saisie**, parce que ça se décide en écrivant le message : le + haute / maximale — envoyé à `llama-server` comme `reasoning_effort`. Réglable + **dans la barre de saisie**, parce que ça se décide en écrivant le message : le changement s'applique au message suivant, sans redémarrer le moteur. L'éditeur de preset garde le même réglage comme **défaut du modèle** ; appliquer un preset reprend donc la main sur le choix fait à la volée. `none` coupe le raisonnement ; les autres valeurs sont passées au gabarit jinja du modèle, ce qui ne change le comportement que des modèles qui les lisent (gpt-oss et apparentés) — ailleurs c'est ignoré sans erreur, et l'interface le dit plutôt - que de promettre un effet. La liste est grisée quand le raisonnement est coupé - pour ce modèle. + que de promettre un effet. Aucun gabarit ne les connaît toutes (gpt-oss : + basse/moyenne/haute ; Qwen3.8 : basse/moyenne/maximale) et certains **refusent** + celles qu'ils ne connaissent pas, avec une erreur 500 qui tuait le tour : loki + lit alors les niveaux annoncés par le refus, **repli sur le plus proche** (haute + → maximale) et rejoue le message sans rien perdre de l'historique — une fois, + puis la traduction est retenue pour ce modèle. La liste est grisée quand le + raisonnement est coupé pour ce modèle. - **Discussions multiples** : historique complet dans la barre latérale, titre repris du premier message (renommable), suppression. **Chaque discussion a son dossier de fichiers** (`workspace/discussions//`) : les pièces jointes diff --git a/internal/loki/backend_models.go b/internal/loki/backend_models.go index a85faf3..8b7ac6a 100644 --- a/internal/loki/backend_models.go +++ b/internal/loki/backend_models.go @@ -72,17 +72,24 @@ func reasoningActive(v string) bool { // ReasoningEfforts liste les valeurs proposées par l'UI pour REASONING_EFFORT. // "" (auto) n'y figure pas : c'est l'absence de réglage. -var ReasoningEfforts = []string{"none", "low", "medium", "high"} +// +// Aucun gabarit ne les connaît toutes — gpt-oss lit low/medium/high, Qwen3.8 lit +// low/medium/xhigh — et certains REFUSENT celles qu'ils ne connaissent pas (voir +// llm_effort.go, qui traduit alors vers le niveau accepté le plus proche). La +// liste reste donc l'union des niveaux courants plutôt que le plus petit +// dénominateur commun, sinon le maximum d'un modèle est hors d'atteinte. +var ReasoningEfforts = []string{"none", "low", "medium", "high", "xhigh"} // reasoningEffortValue normalise REASONING_EFFORT en une valeur envoyable telle // quelle à llama-server, ou "" pour ne rien envoyer du tout. // // llama-server accepte `reasoning_effort` dans /v1/chat/completions : la valeur // `none` coupe le raisonnement, toute autre valeur est simplement passée au -// gabarit jinja du modèle. Un gabarit qui ne la lit pas l'ignore sans erreur — -// c'est pourquoi il n'y a pas de repli à prévoir ici, contrairement à un backend -// qui rejetterait la requête. En pratique seuls les modèles dont le gabarit -// gère `reasoning_effort` (gpt-oss et apparentés) changent de comportement. +// gabarit jinja du modèle. Un gabarit qui ne la lit pas l'ignore sans erreur ; +// un gabarit qui la VALIDE, lui, peut lever une exception jinja et faire +// répondre 500 à llama-server (Qwen3.8 sur « high »). Le repli n'est pas ici — +// on ne sait pas d'avance ce que le gabarit accepte — mais dans llm_effort.go, +// qui apprend la réponse du refus lui-même et rejoue le tour. func reasoningEffortValue(v string) string { switch s := strings.ToLower(strings.TrimSpace(v)); s { case "", "auto", "default": diff --git a/internal/loki/llm_client.go b/internal/loki/llm_client.go index 484bc6f..0bb10a5 100644 --- a/internal/loki/llm_client.go +++ b/internal/loki/llm_client.go @@ -616,12 +616,19 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps reasoningOn := reasoningActive(chatCfg["REASONING"]) // Intensité du raisonnement, passée telle quelle au gabarit du modèle. Vide // = on n'envoie rien. Réglage par preset, donc de fait par modèle. - reasoningEffort := reasoningEffortValue(chatCfg["REASONING_EFFORT"]) + effortWanted := reasoningEffortValue(chatCfg["REASONING_EFFORT"]) + // …sauf si le gabarit de CE modèle a déjà refusé ce niveau (llm_effort.go) : + // on part alors directement sur la traduction apprise, sans repayer le 500. + reasoningEffort := effortResolve(effortWanted) + // Le raisonnement est-il interdit pour ce tour ? « aucune » compte comme une + // interdiction, y compris quand le repli ci-dessus a retiré le niveau : c'est + // `enable_thinking` qui porte alors la consigne, seul. + thinkOff := reasoningExplicitlyOff(chatCfg["REASONING"]) || effortWanted == "none" // Ce que le gabarit du modèle doit savoir, dans SA langue : `reasoning_effort` // ne parle qu'aux gabarits qui le lisent, `enable_thinking` parle aux modèles // hybrides (Qwen3 & co). Sans ça, « aucune » n'avait aucun effet sur eux : le // modèle réfléchissait pendant que l'interface annonçait le contraire. - reasoningKwargs := reasoningTemplateKwargs(reasoningExplicitlyOff(chatCfg["REASONING"]), reasoningEffort) + reasoningKwargs := reasoningTemplateKwargs(thinkOff, reasoningEffort) // When llama.cpp fails to parse a model-generated tool call (HTTP 500), we // retry the same turn once with tools removed so the model answers in plain // text from the tool results already gathered, instead of dying mid-chat. @@ -630,6 +637,9 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps // dépassement de la fenêtre de contexte après de gros résultats d'outils), on // compacte l'historique en vol et on rejoue le tour — une seule fois. compactedRetry := false + // Repli d'intensité de raisonnement (llm_effort.go) : une seule tentative par + // tour, comme les autres filets. + effortRetried := false // Appels d'outil déjà exécutés (clé = nom + arguments bruts) : sert à ne pas // rejouer deux fois exactement la même écriture dans un même échange. doneCalls := map[string]string{} @@ -709,6 +719,22 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps if msg == "" { msg = resp.Status } + // Refus du niveau de raisonnement par le gabarit du modèle (Qwen3.8 ne + // connaît pas « high », gpt-oss ne connaît pas « xhigh »…). C'est un + // refus DÉFINITIF, pas une question de taille de prompt : à traiter + // avant la compaction, qui sinon taillait l'historique pour rien puis + // échouait quand même. On rejoue le tour avec le niveau accepté (ou + // sans le champ), l'historique intact. + if !effortRetried && effortWanted != "" { + if fixed, ok := effortFromRejection(msg, effortWanted); ok { + effortRetried = true + effortRemember(effortWanted, fixed) + logEffortFallback(effortWanted, fixed) + reasoningEffort = fixed + reasoningKwargs = reasoningTemplateKwargs(thinkOff, fixed) + continue + } + } // Le prompt a peut-être dépassé la fenêtre de contexte : on tente une // compaction en vol et on rejoue le tour (une seule fois) avant tout le // reste. C'est le filet de secours à la Hermes. diff --git a/internal/loki/llm_effort.go b/internal/loki/llm_effort.go new file mode 100644 index 0000000..167fad6 --- /dev/null +++ b/internal/loki/llm_effort.go @@ -0,0 +1,195 @@ +package loki + +// llm_effort.go — repli quand le gabarit du modèle REFUSE la valeur de +// `reasoning_effort`. +// +// L'hypothèse d'origine (backend_models.go) était : un gabarit jinja qui ne lit +// pas `reasoning_effort` l'ignore sans erreur, donc rien à prévoir côté loki. +// Elle tombe dès qu'un gabarit VALIDE la valeur au lieu de la subir. Vu en +// production sur Qwen3.8-27B : +// +// {"error":{"code":500,"message":"... raise_exception('Unexpected reasoning +// effort ' ~ reasoning_effort) ... Unexpected reasoning effort high. Supported +// types are xhigh (default), medium, and low."}} +// +// Ce gabarit connaît xhigh/medium/low mais PAS « high » — le niveau que +// l'interface propose et enregistre par défaut. Résultat : chaque message part +// en 500, le tour meurt, et le message d'erreur affiché est une trace jinja. +// Pire, le 500 arrivait dans la branche « prompt trop long » de runChat, donc +// loki compactait l'historique pour rien avant d'abandonner. +// +// D'où ce fichier : reconnaître ce refus, traduire le niveau demandé vers celui +// que le gabarit accepte (le plus proche sur l'échelle), rejouer le tour SANS +// toucher à l'historique, et retenir la traduction pour ce modèle afin de ne +// pas repayer l'aller-retour à chaque message. + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + "sync" +) + +// effortLadder : les niveaux connus, du plus faible au plus fort. Sert à choisir +// un remplaçant PROCHE quand le gabarit refuse la valeur demandée — « high » +// devient « xhigh » (voisin immédiat) plutôt que « low ». +// +// La liste vaut pour tous les gabarits croisés jusqu'ici : gpt-oss lit +// low/medium/high, Qwen3.8 lit low/medium/xhigh, certains ajoutent « minimal ». +// Une valeur hors échelle n'est pas classable : on retire alors le champ plutôt +// que d'inventer un niveau. +var effortLadder = []string{"none", "minimal", "low", "medium", "high", "xhigh"} + +// effortRank renvoie la position d'un niveau sur l'échelle, ou -1 s'il est +// inconnu. +func effortRank(v string) int { + v = strings.ToLower(strings.TrimSpace(v)) + for i, s := range effortLadder { + if s == v { + return i + } + } + return -1 +} + +// effortWords capte les niveaux cités dans un message d'erreur. Les bornes de +// mot évitent de lire « high » à l'intérieur de « xhigh ». +var effortWords = regexp.MustCompile(`\b(none|minimal|low|medium|high|xhigh)\b`) + +// effortRejection dit si le corps d'erreur renvoyé par llama-server est un refus +// de la valeur de `reasoning_effort` par le gabarit, et renvoie les niveaux que +// ce gabarit déclare accepter (éventuellement vide : tous ne les listent pas). +// +// La détection reste volontairement large — le texte vient du gabarit du modèle, +// pas de llama.cpp, donc sa formulation change d'un modèle à l'autre. Le seul +// invariant : il parle de reasoning effort et dit que la valeur ne va pas. +func effortRejection(body string) (supported []string, ok bool) { + low := strings.ToLower(body) + if !strings.Contains(low, "reasoning effort") && !strings.Contains(low, "reasoning_effort") { + return nil, false + } + refus := false + for _, w := range []string{"unexpected", "unsupported", "not supported", "invalid", "unknown"} { + if strings.Contains(low, w) { + refus = true + break + } + } + if !refus { + return nil, false + } + // Les niveaux acceptés sont annoncés APRÈS « supported » (« Supported types + // are xhigh (default), medium, and low. »). Ne lire que cette fin de message + // évite de prendre pour une liste le niveau refusé, cité juste avant. + if i := strings.LastIndex(low, "supported"); i >= 0 { + for _, m := range effortWords.FindAllString(low[i:], -1) { + if effortRank(m) >= 0 && !slicesHas(supported, m) { + supported = append(supported, m) + } + } + } + return supported, true +} + +func slicesHas(list []string, v string) bool { + for _, s := range list { + if s == v { + return true + } + } + return false +} + +// nearestEffort traduit le niveau demandé vers le plus proche parmi ceux que le +// gabarit accepte. Renvoie "" quand il n'y a rien de sensé à envoyer : le champ +// est alors simplement retiré de la requête, et le gabarit reprend son défaut. +// +// À égalité de distance on prend le niveau le PLUS FORT : demander « haute » et +// se retrouver avec « moyenne » dégrade la réponse en silence, alors qu'un cran +// au-dessus ne coûte que du temps de génération. +// +// « none » n'est jamais traduit : c'est une consigne de couper le raisonnement, +// pas une intensité. Un gabarit qui la refuse reçoit déjà `enable_thinking: +// false` par chat_template_kwargs (backend_models.go), qui dit la même chose +// dans une langue que tous comprennent. +func nearestEffort(want string, supported []string) string { + want = strings.ToLower(strings.TrimSpace(want)) + if want == "" || want == "none" { + return "" + } + rank := effortRank(want) + if rank < 0 { + return "" + } + best, bestRank := "", -1 + for _, s := range supported { + r := effortRank(s) + if r < 0 || s == want { + continue // s == want : le gabarit ne peut pas à la fois refuser et accepter + } + if best == "" || abs(r-rank) < abs(bestRank-rank) || (abs(r-rank) == abs(bestRank-rank) && r > bestRank) { + best, bestRank = s, r + } + } + return best +} + +func abs(n int) int { + if n < 0 { + return -n + } + return n +} + +// effortFromRejection combine les deux : à partir du corps d'erreur et du niveau +// demandé, renvoie le niveau à réessayer ("" = ne rien envoyer) et true si le +// refus a bien été reconnu. +func effortFromRejection(body, want string) (string, bool) { + supported, ok := effortRejection(body) + if !ok { + return "", false + } + return nearestEffort(want, supported), true +} + +// effortFallbacks retient les traductions apprises, par modèle : sans ça chaque +// message repaierait un aller-retour 500 avant de tomber sur la bonne valeur. +// Clé = fichier de modèle + niveau demandé ; la valeur "" est significative +// (= ne rien envoyer), d'où sync.Map plutôt qu'une map avec test de zéro. +var effortFallbacks sync.Map + +func effortKey(want string) string { + return filepath.Base(strings.TrimSpace(ReadConfig()["MODEL"])) + "\x00" + want +} + +// effortResolve renvoie le niveau réellement envoyable pour la valeur demandée, +// c'est-à-dire la traduction déjà apprise pour ce modèle s'il y en a une. +func effortResolve(want string) string { + if want == "" { + return "" + } + if v, ok := effortFallbacks.Load(effortKey(want)); ok { + return v.(string) + } + return want +} + +// effortRemember enregistre la traduction pour les messages suivants. +func effortRemember(want, got string) { + if want == "" { + return + } + effortFallbacks.Store(effortKey(want), got) +} + +// logEffortFallback trace le repli sur stderr : sinon l'intensité choisie dans +// l'interface n'est pas celle qui part au moteur, sans que rien ne le dise. +func logEffortFallback(want, got string) { + if got == "" { + fmt.Fprintf(os.Stderr, "[reasoning] gabarit du modèle : intensité %q refusée — champ retiré pour ce modèle\n", want) + return + } + fmt.Fprintf(os.Stderr, "[reasoning] gabarit du modèle : intensité %q refusée — repli sur %q pour ce modèle\n", want, got) +} diff --git a/internal/loki/llm_effort_test.go b/internal/loki/llm_effort_test.go new file mode 100644 index 0000000..fbf1ad9 --- /dev/null +++ b/internal/loki/llm_effort_test.go @@ -0,0 +1,119 @@ +package loki + +import "testing" + +// Le message qui a motivé tout ce fichier : Qwen3.8-27B refuse « high », le +// niveau que l'interface enregistre par défaut, et llama-server répond 500. +const qwenRefus = `{"error":{"code":500,"message":"\n------------\nWhile executing CallExpression at line 49, column 28 in source:\n...', 'low') %}\n {{- raise_exception('Unexpected reasoning effort ' ~ reason...\n ^\nError: Jinja Exception: Unexpected reasoning effort high. Supported types are xhigh (default), medium, and low.","type":"server_error"}}` + +func TestEffortRejection(t *testing.T) { + sup, ok := effortRejection(qwenRefus) + if !ok { + t.Fatal("refus de reasoning_effort non reconnu") + } + // « high » est cité AVANT la liste (c'est la valeur refusée) : le lire comme + // une valeur acceptée renverrait loki sur la même erreur en boucle. + want := map[string]bool{"xhigh": true, "medium": true, "low": true} + if len(sup) != len(want) { + t.Fatalf("niveaux acceptés = %v, attendu %v", sup, want) + } + for _, s := range sup { + if !want[s] { + t.Errorf("niveau %q lu à tort comme accepté (%v)", s, sup) + } + } + // Les autres 500 (prompt trop long, appel d'outil illisible) ne doivent pas + // passer par ce filet : ils ont le leur. + for _, body := range []string{ + "", + `{"error":{"code":500,"message":"the request exceeds the available context size"}}`, + `{"error":{"code":500,"message":"Failed to parse tool call"}}`, + // Parle bien de reasoning_effort mais ne le refuse pas. + `{"error":{"code":500,"message":"reasoning_effort applied"}}`, + } { + if _, ok := effortRejection(body); ok { + t.Errorf("corps %q pris à tort pour un refus d'intensité", body) + } + } +} + +// Sans liste de niveaux acceptés, il n'y a rien à deviner : on retire le champ. +func TestEffortRejectionSansListe(t *testing.T) { + sup, ok := effortRejection("Error: Unknown reasoning effort 'xhigh'") + if !ok { + t.Fatal("refus non reconnu") + } + if len(sup) != 0 { + t.Fatalf("niveaux acceptés = %v, attendu aucun", sup) + } + if got := nearestEffort("xhigh", sup); got != "" { + t.Errorf("nearestEffort sans liste = %q, attendu \"\"", got) + } +} + +func TestNearestEffort(t *testing.T) { + qwen := []string{"xhigh", "medium", "low"} + oss := []string{"low", "medium", "high"} + cases := []struct { + want string + supported []string + expect string + }{ + {"high", qwen, "xhigh"}, // voisin immédiat vers le haut + {"xhigh", oss, "high"}, // voisin immédiat vers le bas + {"medium", qwen, "low"}, // « medium » refusé quand même : on ne le renvoie pas + {"low", []string{"medium", "high"}, "medium"}, + {"high", nil, ""}, // rien d'annoncé : on retire le champ + {"", qwen, ""}, // auto : il n'y avait rien à envoyer + {"none", qwen, ""}, // consigne de coupure, pas une intensité + {"maximum", qwen, ""}, // hors échelle : on n'invente pas + } + for _, c := range cases { + if got := nearestEffort(c.want, c.supported); got != c.expect { + t.Errorf("nearestEffort(%q, %v) = %q, attendu %q", c.want, c.supported, got, c.expect) + } + } + // À égalité de distance, le niveau le plus FORT : dégrader en silence est + // pire que générer un peu plus longtemps. + if got := nearestEffort("medium", []string{"low", "high"}); got != "high" { + t.Errorf("égalité de distance = %q, attendu high", got) + } +} + +func TestEffortFromRejection(t *testing.T) { + got, ok := effortFromRejection(qwenRefus, "high") + if !ok || got != "xhigh" { + t.Fatalf("effortFromRejection = %q, %v ; attendu xhigh, true", got, ok) + } + if _, ok := effortFromRejection("boom", "high"); ok { + t.Error("un 500 quelconque ne doit pas déclencher le repli d'intensité") + } +} + +// La traduction apprise doit valoir pour les messages SUIVANTS (sinon chaque +// message repaie un aller-retour 500) et rester attachée au modèle : un autre +// modèle a un autre gabarit, donc d'autres niveaux. +func TestEffortMemoire(t *testing.T) { + testHome(t) + if err := WriteConfig(map[string]string{"MODEL": "/data/models/qwen.gguf"}); err != nil { + t.Fatal(err) + } + if got := effortResolve("high"); got != "high" { + t.Fatalf("sans refus connu : %q, attendu high", got) + } + effortRemember("high", "xhigh") + if got := effortResolve("high"); got != "xhigh" { + t.Errorf("après apprentissage : %q, attendu xhigh", got) + } + if err := WriteConfig(map[string]string{"MODEL": "/data/models/gpt-oss.gguf"}); err != nil { + t.Fatal(err) + } + if got := effortResolve("high"); got != "high" { + t.Errorf("autre modèle : %q, attendu high (gabarit inconnu)", got) + } + // Champ retiré ("") : c'est une réponse apprise, pas une absence de réponse. + effortRemember("high", "") + if got := effortResolve("high"); got != "" { + t.Errorf("repli « champ retiré » : %q, attendu \"\"", got) + } +} diff --git a/internal/loki/sys_service.go b/internal/loki/sys_service.go index 0eeb884..07674b0 100644 --- a/internal/loki/sys_service.go +++ b/internal/loki/sys_service.go @@ -75,7 +75,7 @@ var configTemplate = []struct{ key, help string }{ {"KV_TYPE", "quantization du cache KV (q8_0, q4_0…) ; KV_TYPE_K / KV_TYPE_V pour les séparer"}, {"REASONING", "passthrough du mode raisonnement (on/auto/deepseek)"}, {"REASONING_BUDGET", "plafond de tokens de réflexion ; -1 = illimité"}, - {"REASONING_EFFORT", "intensité du raisonnement : vide (auto) / none / low / medium / high"}, + {"REASONING_EFFORT", "intensité du raisonnement : vide (auto) / none / low / medium / high / xhigh"}, {"COMPACT", "compactage automatique du contexte (off pour couper)"}, {"MEM_MODE", "mémoire de l'IA : off / ondemand / always"}, {"EXTRA_ARGS", "ajouté tel quel à la ligne de commande de llama-server"}, diff --git a/internal/loki/ui/index.html b/internal/loki/ui/index.html index 11cf2ef..2a0c6b1 100644 --- a/internal/loki/ui/index.html +++ b/internal/loki/ui/index.html @@ -2459,6 +2459,7 @@ html[data-files="1"] #files-btn{color:var(--accent)} + @@ -2899,6 +2900,7 @@ html[data-files="1"] #files-btn{color:var(--accent)} + @@ -6830,6 +6832,7 @@ const THINK_EFFORT_LABELS = { low: 'basse', medium: 'moyenne', high: 'haute', + xhigh: 'maximale', }; async function loadThinkEffort(){ diff --git a/internal/loki/ui/src/index.tmpl.html b/internal/loki/ui/src/index.tmpl.html index ce6a924..869426c 100644 --- a/internal/loki/ui/src/index.tmpl.html +++ b/internal/loki/ui/src/index.tmpl.html @@ -429,6 +429,7 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid + @@ -869,6 +870,7 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid + diff --git a/internal/loki/ui/src/js/13-reasoning.js b/internal/loki/ui/src/js/13-reasoning.js index 70be8dc..426d43c 100644 --- a/internal/loki/ui/src/js/13-reasoning.js +++ b/internal/loki/ui/src/js/13-reasoning.js @@ -14,6 +14,7 @@ const THINK_EFFORT_LABELS = { low: 'basse', medium: 'moyenne', high: 'haute', + xhigh: 'maximale', }; async function loadThinkEffort(){