From fd8864854ef332bc45e7aaddf4c3a46684e7791b Mon Sep 17 00:00:00 2001 From: Olivi Date: Wed, 28 Oct 2026 11:59:54 +0100 Subject: [PATCH] Preset API externe : le raisonnement passe par les champs natifs du fournisseur MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit REASONING=off n'était jamais transmis à l'API distante, et REASONING_EFFORT partait dans chat_template_kwargs, un champ propre à llama.cpp que les fournisseurs tolérants ignorent (DeepSeek) et que les stricts rejettent. Les champs llama.cpp (chat_template_kwargs, top_k, min_p, repeat_penalty) ne partent plus que vers un endpoint llama.cpp (local, Modal, EXTERNAL_SERVICE). Une vraie API distante reçoit à la place ses champs natifs : thinking.type + reasoning_effort pour DeepSeek, reasoning_effort pour OpenAI. DeepSeek est reconnu à son hôte ou à son modèle ; la clé EXTERNAL_REASONING permet de forcer le dialecte ou de le couper. Côté interface, l'interrupteur Raisonnement sort de #m-settings-row (il disparaissait pour un preset externe), un effort défini rallume le raisonnement, et --jinja n'est plus écrit dans EXTRA_ARGS d'un preset externe. Fixes #178 Co-Authored-By: Claude Code --- internal/ajean/backend_cloud.go | 2 + internal/ajean/backend_config.go | 101 ++++++++++-- internal/ajean/backend_external.go | 53 ++++++- internal/ajean/backend_reasoning_test.go | 178 ++++++++++++++++++++++ internal/ajean/backend_sampling_test.go | 4 +- internal/ajean/llm_client.go | 5 +- internal/ajean/ui/index.html | 92 +++++++++-- internal/ajean/ui/src/index.tmpl.html | 33 +++- internal/ajean/ui/src/js/00a-i18n-data.js | 18 +++ internal/ajean/ui/src/js/05-status.js | 15 +- internal/ajean/ui/src/js/07-models.js | 60 ++++++-- internal/ajean/ui/src/styles.css | 2 +- internal/ajean/web_external.go | 33 +++- 13 files changed, 540 insertions(+), 56 deletions(-) create mode 100644 internal/ajean/backend_reasoning_test.go diff --git a/internal/ajean/backend_cloud.go b/internal/ajean/backend_cloud.go index 35b1cc4d..732f8de8 100644 --- a/internal/ajean/backend_cloud.go +++ b/internal/ajean/backend_cloud.go @@ -465,6 +465,8 @@ func cloudEndpoint(cfg map[string]string) chatEndpoint { Key: cloudAPIKey(), External: true, Cloud: true, + // Le GPU loué fait tourner llama-server : les kwargs de gabarit y sont valides. + Template: true, } } diff --git a/internal/ajean/backend_config.go b/internal/ajean/backend_config.go index 7a549427..208fb8a5 100644 --- a/internal/ajean/backend_config.go +++ b/internal/ajean/backend_config.go @@ -216,13 +216,14 @@ func splitArgs(s string) []string { var samplingKeys = []struct { cfg, field string integer bool + llamaOnly bool // champ propre à llama.cpp : jamais envoyé à une API distante }{ - {"TEMP", "temperature", false}, - {"TOP_P", "top_p", false}, - {"TOP_K", "top_k", true}, - {"MIN_P", "min_p", false}, - {"PRESENCE_PENALTY", "presence_penalty", false}, - {"REPEAT_PENALTY", "repeat_penalty", false}, + {"TEMP", "temperature", false, false}, + {"TOP_P", "top_p", false, false}, + {"TOP_K", "top_k", true, true}, + {"MIN_P", "min_p", false, true}, + {"PRESENCE_PENALTY", "presence_penalty", false, false}, + {"REPEAT_PENALTY", "repeat_penalty", false, true}, } // applySampling superpose les paramètres d'échantillonnage du preset au corps @@ -235,11 +236,23 @@ var samplingKeys = []struct { // gabarits jinja de llama.cpp (chat_template_kwargs) : il n'a d'effet que si le // serveur tourne avec --jinja, mais on ne l'envoie que lorsqu'un preset le // définit explicitement, donc jamais à l'aveugle. -func applySampling(payload map[string]any) { applySamplingFrom(payload, ReadConfig()) } +// +// ⚠️ Un preset EXTERNAL=1 parle à une API distante, pas à llama.cpp : elle +// rejette silencieusement (DeepSeek) ou en 400 (OpenAI, Azure, Groq, Mistral) les +// champs qui n'existent pas chez elle. chat_template_kwargs, top_k, min_p et +// repeat_penalty ne partent donc QUE vers un endpoint llama.cpp (local, GPU +// Modal, moteur EXTERNAL_SERVICE) ; vers une API distante, on émet à la place les +// champs natifs du fournisseur (voir applyReasoningFrom). +func applySampling(payload map[string]any, ep chatEndpoint) { + applySamplingFrom(payload, ReadConfig(), ep) +} // applySamplingFrom est la partie pure d'applySampling : testable sans base. -func applySamplingFrom(payload map[string]any, cfg map[string]string) { +func applySamplingFrom(payload map[string]any, cfg map[string]string, ep chatEndpoint) { for _, s := range samplingKeys { + if s.llamaOnly && !ep.Template { + continue + } v := strings.TrimSpace(cfg[s.cfg]) if v == "" { continue @@ -254,9 +267,77 @@ func applySamplingFrom(payload map[string]any, cfg map[string]string) { payload[s.field] = f } } - if eff := strings.TrimSpace(cfg["REASONING_EFFORT"]); eff != "" { - payload["chat_template_kwargs"] = map[string]any{"reasoning_effort": eff} + applyReasoningFrom(payload, cfg, ep) +} + +// applyReasoningFrom décide comment le niveau de raisonnement part sur le fil. +// +// Endpoint llama.cpp : chat_template_kwargs.reasoning_effort (gabarit jinja). +// API distante : champs natifs, et seulement si le preset (ou l'URL/le modèle) +// déclare le fournisseur — sinon on n'envoie rien plutôt que de risquer un 400. +// Pour DeepSeek, un effort défini implique le raisonnement ACTIVÉ : c'est ce qui +// répare les presets où l'interrupteur masqué avait laissé REASONING=off à côté +// d'un REASONING_EFFORT. +func applyReasoningFrom(payload map[string]any, cfg map[string]string, ep chatEndpoint) { + eff := strings.TrimSpace(cfg["REASONING_EFFORT"]) + if ep.Template { + if eff != "" { + payload["chat_template_kwargs"] = map[string]any{"reasoning_effort": eff} + } + return + } + raw := strings.TrimSpace(cfg["REASONING"]) + off := raw != "" && !reasoningActive(raw) + switch externalReasoningMode(cfg) { + case extReasoningDeepSeek: + if eff != "" { + payload["thinking"] = map[string]any{"type": "enabled"} + if v := deepseekEffort(eff); v != "" { + payload["reasoning_effort"] = v + } + return + } + if off { + payload["thinking"] = map[string]any{"type": "disabled"} + } + case extReasoningOpenAI: + if eff == "" { + return // off : pas de champ universel côté OpenAI, on garde son défaut + } + if v := openAIEffort(eff); v != "" { + payload["reasoning_effort"] = v + } + } +} + +// deepseekEffort traduit le niveau AJeAN vers les trois paliers réels de DeepSeek +// (low/high/max). L'API ramène elle-même medium et xhigh à high, mais l'UI +// d'AJeAN libelle xhigh « (max) » : on honore ce libellé plutôt que de laisser +// l'alias officiel raboter le niveau. +func deepseekEffort(eff string) string { + switch strings.ToLower(eff) { + case "low": + return "low" + case "medium", "high": + return "high" + case "xhigh": + return "max" + } + return "" +} + +// openAIEffort : l'API OpenAI connaît minimal/low/medium/high. AJeAN n'expose pas +// minimal, et xhigh retombe sur high (pas de palier au-dessus). +func openAIEffort(eff string) string { + switch strings.ToLower(eff) { + case "low": + return "low" + case "medium": + return "medium" + case "high", "xhigh": + return "high" } + return "" } // Le plafond d'appels d'outils par tour (TOOL_LIMIT) et l'anti-boucle ont été diff --git a/internal/ajean/backend_external.go b/internal/ajean/backend_external.go index 373d893c..89666001 100644 --- a/internal/ajean/backend_external.go +++ b/internal/ajean/backend_external.go @@ -2,6 +2,7 @@ package ajean import ( "fmt" + "net/url" "strings" ) @@ -16,11 +17,20 @@ import ( // l'inférence (resolveChatEndpoint) et de la bascule (pas de redémarrage moteur). const ( - extKeyFlag = "EXTERNAL" // marqueur : "1" = preset externe - extKeyURL = "EXTERNAL_URL" // base ou URL complète des complétions - extKeyModel = "EXTERNAL_MODEL" // nom du modèle envoyé dans le payload - extKeyToken = "EXTERNAL_KEY" // clé API (Bearer), peut être vide - extKeyVision = "EXTERNAL_VISION" // "1" = le modèle distant accepte les images + extKeyFlag = "EXTERNAL" // marqueur : "1" = preset externe + extKeyURL = "EXTERNAL_URL" // base ou URL complète des complétions + extKeyModel = "EXTERNAL_MODEL" // nom du modèle envoyé dans le payload + extKeyToken = "EXTERNAL_KEY" // clé API (Bearer), peut être vide + extKeyVision = "EXTERNAL_VISION" // "1" = le modèle distant accepte les images + extKeyReasoning = "EXTERNAL_REASONING" // "deepseek" / "openai" / "none" / vide = auto +) + +// Valeurs de EXTERNAL_REASONING. Une API distante n'accepte pas les champs +// llama.cpp (chat_template_kwargs…) : il faut lui parler sa langue. « none » +// désactive explicitement l'auto-détection ; une valeur vide la laisse faire. +const ( + extReasoningDeepSeek = "deepseek" + extReasoningOpenAI = "openai" ) // chatEndpoint : où partent les appels /v1/chat/completions de ce tour. @@ -30,6 +40,10 @@ type chatEndpoint struct { Key string // Bearer ("" = aucun) External bool // true = API distante (pas le llama-server local) Cloud bool // true = GPU cloud Modal (attente du réveil sur 503) + // Template : l'endpoint parle llama.cpp (local, Modal, EXTERNAL_SERVICE) et + // accepte donc chat_template_kwargs / top_k / min_p / repeat_penalty. Faux + // pour une vraie API distante, qui reçoit à la place ses champs natifs. + Template bool } // isExternalConfig indique si une configuration décrit un endpoint externe. @@ -83,6 +97,10 @@ func resolveChatEndpoint() chatEndpoint { Model: strings.TrimSpace(cfg[extKeyModel]), Key: strings.TrimSpace(cfg[extKeyToken]), External: true, + // Un moteur tiers lancé par ajean (EXTERNAL_SERVICE, ex. MoE) est un + // serveur llama.cpp : il comprend les kwargs de gabarit. Une vraie API + // distante, non. + Template: externalServiceOf(cfg) != "", } } return chatEndpoint{ @@ -90,7 +108,32 @@ func resolveChatEndpoint() chatEndpoint { Model: "ajean", Key: readAPIKey(), External: false, + Template: true, + } +} + +// externalReasoningMode : quel dialecte natif de raisonnement parler à cette API +// distante. la clé EXTERNAL_REASONING l'emporte ; « none » coupe l'auto-détection. +// Sans clé, DeepSeek est reconnu à son hôte ou au préfixe de son modèle — sinon +// on n'envoie rien, une API inconnue pouvant rejeter le champ en 400. +func externalReasoningMode(cfg map[string]string) string { + if m := strings.ToLower(strings.TrimSpace(cfg[extKeyReasoning])); m != "" { + switch m { + case extReasoningDeepSeek, extReasoningOpenAI: + return m + } + return "" // none / valeur inconnue : aucune émission + } + if u, err := url.Parse(strings.TrimSpace(cfg[extKeyURL])); err == nil { + h := strings.ToLower(u.Hostname()) + if h == "deepseek.com" || strings.HasSuffix(h, ".deepseek.com") { + return extReasoningDeepSeek + } + } + if strings.HasPrefix(strings.ToLower(strings.TrimSpace(cfg[extKeyModel])), "deepseek") { + return extReasoningDeepSeek } + return "" } // externalPresetContent construit le corps .env d'un preset externe (hors ligne diff --git a/internal/ajean/backend_reasoning_test.go b/internal/ajean/backend_reasoning_test.go new file mode 100644 index 00000000..bbcb2549 --- /dev/null +++ b/internal/ajean/backend_reasoning_test.go @@ -0,0 +1,178 @@ +package ajean + +import "testing" + +// Un endpoint réellement distant ne doit recevoir AUCUN champ llama.cpp : les +// fournisseurs tolérants les ignorent (le réglage ne marche alors pas) et les +// stricts les rejettent (400). Mesuré chez DeepSeek le 2026-10-28 : le corps +// contenait chat_template_kwargs et le raisonnement restait actif. +func TestApplySamplingExterneOmetLesChampsLlamaCpp(t *testing.T) { + payload := map[string]any{} + cfg := map[string]string{ + "TEMP": "0.7", + "TOP_P": "0.95", + "TOP_K": "20", + "MIN_P": "0", + "PRESENCE_PENALTY": "0.1", + "REPEAT_PENALTY": "1.1", + "REASONING_EFFORT": "medium", + } + applySamplingFrom(payload, cfg, chatEndpoint{External: true}) + for _, k := range []string{"top_k", "min_p", "repeat_penalty", "chat_template_kwargs"} { + if _, ok := payload[k]; ok { + t.Errorf("%s ne doit pas partir vers une API distante : %v", k, payload) + } + } + if payload["temperature"] != 0.7 || payload["top_p"] != 0.95 || payload["presence_penalty"] != 0.1 { + t.Errorf("les champs OpenAI standard doivent rester : %v", payload) + } +} + +// Modal est External=true mais fait tourner llama.cpp : les kwargs de gabarit et +// les champs d'échantillonnage llama.cpp y restent valides. +func TestApplySamplingCloudGardeLesChampsLlamaCpp(t *testing.T) { + payload := map[string]any{} + applySamplingFrom(payload, + map[string]string{"TOP_K": "20", "REASONING_EFFORT": "medium"}, + chatEndpoint{External: true, Cloud: true, Template: true}) + if payload["top_k"] != 20 { + t.Errorf("top_k manquant : %v", payload) + } + if _, ok := payload["chat_template_kwargs"].(map[string]any); !ok { + t.Errorf("chat_template_kwargs manquant : %v", payload) + } +} + +// DeepSeek : un effort défini active le raisonnement, même si REASONING=off est +// resté dans le preset. C'est le cas mesuré (REASONING=off + REASONING_EFFORT=high) +// où l'API raisonnait quand même à son défaut. +func TestApplyReasoningDeepSeekEffortGagneSurOff(t *testing.T) { + payload := map[string]any{} + cfg := map[string]string{ + "EXTERNAL": "1", + "EXTERNAL_URL": "https://api.deepseek.com/v1", + "EXTERNAL_MODEL": "deepseek-flash", + "REASONING": "off", + "REASONING_EFFORT": "medium", + } + applySamplingFrom(payload, cfg, chatEndpoint{External: true}) + th, _ := payload["thinking"].(map[string]any) + if th["type"] != "enabled" || payload["reasoning_effort"] != "high" { + t.Errorf("attendu thinking enabled + reasoning_effort high : %v", payload) + } + if _, ok := payload["chat_template_kwargs"]; ok { + t.Error("chat_template_kwargs ne doit pas partir chez DeepSeek") + } +} + +// DeepSeek : off explicite et aucun effort → thinking désactivé, rien d'autre. +func TestApplyReasoningDeepSeekOff(t *testing.T) { + payload := map[string]any{} + cfg := map[string]string{ + "EXTERNAL": "1", + "EXTERNAL_URL": "https://api.deepseek.com/v1", + "REASONING": "off", + } + applySamplingFrom(payload, cfg, chatEndpoint{External: true}) + th, _ := payload["thinking"].(map[string]any) + if th["type"] != "disabled" { + t.Errorf("attendu thinking disabled : %v", payload) + } + if _, ok := payload["reasoning_effort"]; ok { + t.Errorf("aucun reasoning_effort attendu : %v", payload) + } +} + +// OpenAI : champ natif de premier niveau, jamais de champ llama.cpp. +func TestApplyReasoningOpenAI(t *testing.T) { + payload := map[string]any{} + cfg := map[string]string{ + "EXTERNAL": "1", + "EXTERNAL_URL": "https://api.openai.com/v1", + "EXTERNAL_MODEL": "gpt-5", + "EXTERNAL_REASONING": extReasoningOpenAI, + "REASONING_EFFORT": "xhigh", + } + applySamplingFrom(payload, cfg, chatEndpoint{External: true}) + if payload["reasoning_effort"] != "high" { + t.Errorf("reasoning_effort high attendu : %v", payload) + } + for _, k := range []string{"thinking", "chat_template_kwargs"} { + if _, ok := payload[k]; ok { + t.Errorf("%s ne doit pas partir chez OpenAI : %v", k, payload) + } + } +} + +// Un fournisseur inconnu ne reçoit rien : mieux vaut un réglage inopérant qu'un +// 400 sur chaque requête. +func TestApplyReasoningFournisseurInconnuNEnvoieRien(t *testing.T) { + payload := map[string]any{} + cfg := map[string]string{ + "EXTERNAL": "1", + "EXTERNAL_URL": "https://api.exemple.com/v1", + "EXTERNAL_MODEL": "mon-modele", + "REASONING_EFFORT": "high", + "REASONING": "off", + } + applySamplingFrom(payload, cfg, chatEndpoint{External: true}) + if _, ok := payload["chat_template_kwargs"]; ok { + t.Errorf("chat_template_kwargs envoyé à un fournisseur inconnu : %v", payload) + } + for _, k := range []string{"thinking", "reasoning_effort"} { + if _, ok := payload[k]; ok { + t.Errorf("%s envoyé à un fournisseur inconnu : %v", k, payload) + } + } +} + +func TestDeepseekEffortMapping(t *testing.T) { + cases := map[string]string{ + "low": "low", "medium": "high", "high": "high", "xhigh": "max", + "inconnu": "", + } + for in, want := range cases { + if got := deepseekEffort(in); got != want { + t.Errorf("deepseekEffort(%q) = %q, attendu %q", in, got, want) + } + } +} + +func TestOpenAIEffortMapping(t *testing.T) { + cases := map[string]string{ + "low": "low", "medium": "medium", "high": "high", "xhigh": "high", + "inconnu": "", + } + for in, want := range cases { + if got := openAIEffort(in); got != want { + t.Errorf("openAIEffort(%q) = %q, attendu %q", in, got, want) + } + } +} + +func TestExternalReasoningMode(t *testing.T) { + cases := []struct { + name string + cfg map[string]string + want string + }{ + {"clé explicite", map[string]string{extKeyReasoning: extReasoningOpenAI}, extReasoningOpenAI}, + {"none coupe l'auto-détection", map[string]string{ + extKeyReasoning: "none", extKeyURL: "https://api.deepseek.com/v1", + }, ""}, + {"hôte deepseek", map[string]string{ + extKeyURL: "https://api.deepseek.com/v1", + }, extReasoningDeepSeek}, + {"modèle deepseek derrière un proxy", map[string]string{ + extKeyURL: "https://proxy.exemple.com/v1", extKeyModel: "deepseek-chat", + }, extReasoningDeepSeek}, + {"fournisseur inconnu", map[string]string{ + extKeyURL: "https://api.openai.com/v1", extKeyModel: "gpt-5", + }, ""}, + } + for _, c := range cases { + if got := externalReasoningMode(c.cfg); got != c.want { + t.Errorf("%s : externalReasoningMode = %q, attendu %q", c.name, got, c.want) + } + } +} diff --git a/internal/ajean/backend_sampling_test.go b/internal/ajean/backend_sampling_test.go index 12f256d5..8fe7951a 100644 --- a/internal/ajean/backend_sampling_test.go +++ b/internal/ajean/backend_sampling_test.go @@ -14,7 +14,7 @@ func TestApplySamplingFrom(t *testing.T) { "MIN_P": "0", "PRESENCE_PENALTY": "", // vide → ignoré "REASONING_EFFORT": "medium", - }) + }, chatEndpoint{Template: true}) if payload["temperature"] != 1.0 { t.Errorf("TEMP doit écraser la température : %v", payload["temperature"]) } @@ -37,7 +37,7 @@ func TestApplySamplingFrom(t *testing.T) { // valeurs qui contrediraient le défaut du modèle. func TestApplySamplingEmpty(t *testing.T) { payload := map[string]any{"temperature": 0.7} - applySamplingFrom(payload, map[string]string{}) + applySamplingFrom(payload, map[string]string{}, chatEndpoint{Template: true}) if len(payload) != 1 || payload["temperature"] != 0.7 { t.Errorf("payload modifié à tort : %v", payload) } diff --git a/internal/ajean/llm_client.go b/internal/ajean/llm_client.go index a2b7ad0f..37a0fdba 100644 --- a/internal/ajean/llm_client.go +++ b/internal/ajean/llm_client.go @@ -1055,8 +1055,9 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps // Paramètres d'échantillonnage du preset (top_p, top_k, min_p, présence…, // et TEMP qui écrase la température par défaut si le preset la fixe). Appliqués // à chaque itération : une bascule de preset en cours de tour est ainsi prise - // en compte, comme le reste de la config relue par ReadConfig(). - applySampling(payload) + // en compte, comme le reste de la config relue par ReadConfig(). Le choix des + // champs dépend de l'endpoint (llama.cpp vs API distante) : voir applySampling. + applySampling(payload, ep) if len(tools) > 0 && !disableTools { payload["tools"] = tools // The model sometimes emits parallel tool calls, which this llama.cpp diff --git a/internal/ajean/ui/index.html b/internal/ajean/ui/index.html index 4868b183..a44f8ac5 100644 --- a/internal/ajean/ui/index.html +++ b/internal/ajean/ui/index.html @@ -1165,7 +1165,7 @@ #m-exl3-row,#m-exl3-set,#m-exl3-gpu{display:none} #modal.eng-exl3 #m-exl3-row,#modal.eng-exl3 #m-exl3-set{display:flex} #modal.eng-exl3 #m-exl3-gpu:not([hidden]){display:flex} -#modal.eng-exl3 #m-model-row,#modal.eng-exl3 #m-settings-row,#modal.eng-exl3 #m-gpu-group, +#modal.eng-exl3 #m-model-row,#modal.eng-exl3 #m-settings-row,#modal.eng-exl3 #m-reasoning-row,#modal.eng-exl3 #m-gpu-group, #modal.eng-exl3 #m-backend-custom,#modal.eng-exl3 #be-avail-note{display:none!important} #modal.run-cloud #m-exl3-row,#modal.run-cloud #m-exl3-set,#modal.run-cloud #m-exl3-gpu, #modal.run-external #m-exl3-row,#modal.run-external #m-exl3-set,#modal.run-external #m-exl3-gpu{display:none!important} @@ -2875,6 +2875,15 @@

Visionle modèle accepte les images +
+ Raisonnementchamps natifs du fournisseur + +
tester la connexion → @@ -3238,10 +3247,6 @@

Jetons anticipésdéfaut 3

-
- Raisonnementréflexion étape par étape - -
Flash attentionplus rapide, moins de VRAM @@ -3252,6 +3257,15 @@

+