Merge pull request #12 from R0m1k3/claude/slot-timing-performance-jqwjgx

Raisonnement : un gabarit qui refuse le niveau ne tue plus le tour
This commit is contained in:
LogiFlow authored and GitHub committed 2026-08-20 10:52:12 +02:00
commit 40f34170b9
9 files changed
+370 -12

No files matched your search

+9 -4
View File
@@ -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/<id>/`) : les pièces jointes
+12 -5
View File
@@ -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":
+28 -2
View File
@@ -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.
+195
View File
@@ -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)
}
+119
View File
@@ -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)
}
}
+1 -1
View File
@@ -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"},
+3
View File
@@ -2459,6 +2459,7 @@ html[data-files="1"] #files-btn{color:var(--accent)}
<option value="low">basse</option>
<option value="medium">moyenne</option>
<option value="high">haute</option>
<option value="xhigh">maximale</option>
</select>
<!-- Joindre un fichier. Icône dessinée en SVG : pas de dépendance à une
police d'emoji, dont le rendu varie énormément d'un système à l'autre. -->
@@ -2899,6 +2900,7 @@ html[data-files="1"] #files-btn{color:var(--accent)}
<option value="low">basse</option>
<option value="medium">moyenne</option>
<option value="high">haute</option>
<option value="xhigh">maximale</option>
</select>
</span>
</div>
@@ -6830,6 +6832,7 @@ const THINK_EFFORT_LABELS = {
low: 'basse',
medium: 'moyenne',
high: 'haute',
xhigh: 'maximale',
};
async function loadThinkEffort(){
+2
View File
@@ -429,6 +429,7 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
<option value="low">basse</option>
<option value="medium">moyenne</option>
<option value="high">haute</option>
<option value="xhigh">maximale</option>
</select>
<!-- Joindre un fichier. Icône dessinée en SVG : pas de dépendance à une
police d'emoji, dont le rendu varie énormément d'un système à l'autre. -->
@@ -869,6 +870,7 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
<option value="low">basse</option>
<option value="medium">moyenne</option>
<option value="high">haute</option>
<option value="xhigh">maximale</option>
</select>
</span>
</div>
+1
View File
@@ -14,6 +14,7 @@ const THINK_EFFORT_LABELS = {
low: 'basse',
medium: 'moyenne',
high: 'haute',
xhigh: 'maximale',
};
async function loadThinkEffort(){