Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions internal/ajean/backend_cloud.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
}

Expand Down
101 changes: 91 additions & 10 deletions internal/ajean/backend_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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é
Expand Down
53 changes: 48 additions & 5 deletions internal/ajean/backend_external.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package ajean

import (
"fmt"
"net/url"
"strings"
)

Expand All @@ -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.
Expand All @@ -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.
Expand Down Expand Up @@ -83,14 +97,43 @@ 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{
URL: fmt.Sprintf("http://localhost:%d/v1/chat/completions", LLMPort()),
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
Expand Down
Loading
Loading