mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-11 17:26:57 +02:00
Le fork est parti de la v0.9.4 ; l'amont en est à la v0.10.7. Reprise de ce qui manque VRAIMENT ici, en laissant de côté ce que loki a déjà résolu à sa façon (contexte MTP via --parallel 1, jauge de contexte, chrono de tour, vignettes d'images, ligne d'état de génération). Rendu du chat cadencé puis lissé (amont v0.9.5 issue #24, v0.10.5). Chaque token re-parsait le Markdown du bloc ENTIER : du O(n²) qui faisait ramer l'interface sur un long raisonnement — le moteur débitait toujours autant, mais les tokens semblaient arriver au ralenti et un simple rafraîchissement « réparait » tout. Le texte s'accumule désormais et n'est re-rendu qu'à intervalle adaptatif (16 ms sur un petit bloc, jusqu'à 500 ms sur un énorme), soldé à chaque frontière (outil, bascule de rôle, fin de tour, erreur, rejeu). Par-dessus, un lissage d'apparition découple l'arrivée de l'affichage : le décodage spéculatif rend les tokens par rafales, le texte sautait par paquets ; il s'écoule maintenant à cadence régulière. Rejeu exclu — relire un fil ne doit pas être une lente réécriture. Échantillonnage réglable par preset (amont v0.9.5/v0.9.6) : TEMP, TOP_P, TOP_K, MIN_P, PRESENCE_PENALTY, REPEAT_PENALTY, injectés dans chaque requête (donc sans redémarrage du moteur), vide = défaut du serveur. Sans ça seule la température voyageait et le reste retombait sur les défauts de llama.cpp, rarement ceux que recommande le modèle. Différence avec l'amont : REASONING_EFFORT n'est PAS traité là — loki lui réserve un chemin plus riche, et l'écrire ici écraserait `chat_template_kwargs`, donc la consigne « aucune ». Un seul message système, en tête, à l'envoi (amont v0.9.8, issue #26). steerSystem ne couvrait que les consignes de loki ; un historique venu d'ailleurs peut encore en porter deux, et Qwen3.x en --jinja répond alors « System message must be at the beginning ». Copie normalisée : l'historique affiché et persisté garde sa forme. Détection Vulkan multi-distro (amont issues #28, #29) : le chemin Debian codé en dur est invisible sur Fedora/RHEL/Atomic, où le plan de build retombait sur le CPU. ldconfig d'abord, puis les chemins connus. Dossier de travail (amont v0.10.2) : la consigne dit maintenant ce que le dossier EST — l'endroit par défaut de tout ce que le modèle produit — et nomme les dossiers système à ne pas toucher, au lieu d'interdire vaguement d'en sortir. Non repris : les tâches planifiées (~1200 lignes + interface, à décider), et le quoting cmd.exe par .bat temporaire (loki tourne en conteneur Linux).
305 lines
10 KiB
Go
305 lines
10 KiB
Go
package loki
|
|
|
|
import (
|
|
"bufio"
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"net/http"
|
|
"os"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
)
|
|
|
|
// readAPIKeyErr renvoie la clé Bearer des complétions en distinguant « aucune
|
|
// clé » d'une LECTURE RATÉE — même nuance que readWebKeyErr, et pour la même
|
|
// raison : sans clé, l'endpoint est OUVERT. Confondre les deux, c'est ouvrir
|
|
// l'accès aux complétions parce que la base était momentanément verrouillée par
|
|
// une commande CLI. requireCompletionKey refuse donc plutôt que d'ouvrir.
|
|
func readAPIKeyErr() (string, error) {
|
|
b, err := getBytesErr(bkState, "api_key")
|
|
return string(b), err
|
|
}
|
|
|
|
// readAPIKey renvoie la clé Bearer de llama-server, ou "" si aucune n'est
|
|
// définie (ou illisible). Elle est rangée hors de la configuration pour survivre
|
|
// aux changements de preset, qui remplacent la configuration en bloc. Réservé à
|
|
// l'affichage et aux appels internes ; toute décision d'accès passe par
|
|
// effectiveAPIKeyErr.
|
|
func readAPIKey() string { k, _ := readAPIKeyErr(); return k }
|
|
|
|
// effectiveAPIKeyErr renvoie la clé que llama-server exige RÉELLEMENT : celle
|
|
// rangée en base, sinon la clé résiduelle de config.env (repli rétro-compatible,
|
|
// cf. backend_serve.go).
|
|
//
|
|
// Une seule source pour les deux côtés du relais. Quand la garde de Loki ne
|
|
// regardait que la clé en base, un API_KEY oublié dans config.env donnait un
|
|
// endpoint « ouvert » côté Loki et un 401 côté moteur — le client voyait un
|
|
// refus sans jamais comprendre quelle clé fournir.
|
|
func effectiveAPIKeyErr() (string, error) {
|
|
k, err := readAPIKeyErr()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if k != "" {
|
|
return k, nil
|
|
}
|
|
return strings.TrimSpace(ReadConfig()["API_KEY"]), nil
|
|
}
|
|
|
|
// authHeader sets the Authorization: Bearer header on req when an API key is
|
|
// configured, so Loki's own internal calls (chat/web/bench/test) authenticate
|
|
// against a protected llama-server. No-op when no key is set.
|
|
func authHeader(req *http.Request) {
|
|
if k := readAPIKey(); k != "" {
|
|
req.Header.Set("Authorization", "Bearer "+k)
|
|
}
|
|
}
|
|
|
|
// genAPIKey returns a fresh random OpenAI-style completion key.
|
|
func genAPIKey() string {
|
|
buf := make([]byte, 24)
|
|
_, _ = rand.Read(buf)
|
|
return "sk-loki-" + hex.EncodeToString(buf)
|
|
}
|
|
|
|
// writeAPIKey enregistre (key != "") ou supprime (key == "") la clé Bearer.
|
|
// Ne redémarre PAS le service : llama-server ne lit --api-key qu'au lancement,
|
|
// c'est à l'appelant de choisir quand appliquer.
|
|
func writeAPIKey(key string) error {
|
|
_ = SetConfigKey("API_KEY", "") // aucune ambiguïté avec une valeur résiduelle
|
|
return putStr(bkState, "api_key", key)
|
|
}
|
|
|
|
// maskAPIKey renders a key for display: keep the "sk-loki-" prefix and last 4
|
|
// chars, elide the middle. Empty in → empty out.
|
|
func maskAPIKey(k string) string {
|
|
if k == "" {
|
|
return ""
|
|
}
|
|
if len(k) <= 13 {
|
|
return "…" + k[len(k)-2:]
|
|
}
|
|
return k[:9] + "…" + k[len(k)-4:]
|
|
}
|
|
|
|
// cmdSetAPIKey définit (ou supprime) la clé Bearer de llama-server. Exposé sur
|
|
// internet, le serveur exige alors « Authorization: Bearer <clé> » à chaque
|
|
// appel.
|
|
//
|
|
// loki set-api-key <clé> définit la clé
|
|
// loki set-api-key génère une clé aléatoire
|
|
// loki set-api-key "" supprime la protection
|
|
func cmdSetAPIKey(args []string) error {
|
|
var key string
|
|
switch {
|
|
case len(args) == 0:
|
|
key = genAPIKey()
|
|
fmt.Printf("%s clé générée : %s\n", green("[ok]"), bold(key))
|
|
case args[0] == "" || args[0] == "off" || args[0] == "none":
|
|
key = ""
|
|
default:
|
|
key = strings.TrimSpace(args[0])
|
|
}
|
|
if err := writeAPIKey(key); err != nil {
|
|
return err
|
|
}
|
|
if key == "" {
|
|
fmt.Printf("%s API_KEY supprimée — serveur ouvert (pas d'authentification)\n", yellow("[info]"))
|
|
} else {
|
|
fmt.Printf("%s API_KEY enregistrée\n", green("[ok]"))
|
|
fmt.Printf(" les clients doivent envoyer : %s\n", dim("Authorization: Bearer "+key))
|
|
}
|
|
fmt.Print(dim("[info] redémarrer le service pour appliquer ? [Y/n] "))
|
|
sc := bufio.NewScanner(os.Stdin)
|
|
if sc.Scan() && strings.HasPrefix(strings.ToLower(strings.TrimSpace(sc.Text())), "n") {
|
|
fmt.Println(dim("[info] pense à lancer 'loki restart'"))
|
|
return nil
|
|
}
|
|
return serviceAction("restart")
|
|
}
|
|
|
|
// ReadConfig renvoie la configuration active de llama-server. Passe par le cache
|
|
// de lecture (store.go) : elle est relue à chaque itération de la boucle
|
|
// d'inférence et après chaque appel d'outil.
|
|
func ReadConfig() map[string]string { return cachedKV(bkConfig) }
|
|
|
|
// SetConfigKey définit une clé de configuration. Une valeur vide la supprime.
|
|
func SetConfigKey(key, value string) error { return putStr(bkConfig, key, value) }
|
|
|
|
// WriteConfig remplace TOUTE la configuration en une transaction. C'est ce
|
|
// qu'exige l'application d'un preset : jamais un état mi-ancien mi-nouveau.
|
|
func WriteConfig(m map[string]string) error { return replaceKV(bkConfig, m) }
|
|
|
|
// unquoteValue retire les guillemets ENTOURANTS d'une valeur, et seulement
|
|
// eux : une paire ouvrante/fermante du même caractère.
|
|
//
|
|
// Un simple Trim(v, `"`) mangeait le guillemet d'un argument interne — par
|
|
// exemple EXTRA_ARGS=--chat-template-file "/etc/loki/tpl.jinja" perdait son
|
|
// guillemet final et repartait déséquilibré dans le preset (issue #17 : contenu
|
|
// tronqué / guillemets non appariés à la création d'un preset).
|
|
func unquoteValue(v string) string {
|
|
if len(v) >= 2 {
|
|
if q := v[0]; (q == '"' || q == '\'') && v[len(v)-1] == q {
|
|
return v[1 : len(v)-1]
|
|
}
|
|
}
|
|
return v
|
|
}
|
|
|
|
// parseEnv lit un fichier au format clé=valeur (les presets). Les lignes vides
|
|
// et les commentaires sont ignorés, les guillemets entourants retirés.
|
|
func parseEnv(text string) map[string]string {
|
|
m := map[string]string{}
|
|
for _, line := range strings.Split(text, "\n") {
|
|
s := strings.TrimSpace(line)
|
|
if s == "" || strings.HasPrefix(s, "#") {
|
|
continue
|
|
}
|
|
k, v, ok := strings.Cut(strings.TrimPrefix(s, "export "), "=")
|
|
if !ok {
|
|
continue
|
|
}
|
|
m[strings.TrimSpace(k)] = unquoteValue(strings.TrimSpace(v))
|
|
}
|
|
return m
|
|
}
|
|
|
|
// quoteValue rend une valeur telle que parseEnv la relise à l'identique. On
|
|
// n'entoure de guillemets que ce qui en a besoin (espaces), et jamais une valeur
|
|
// qui contient déjà un guillemet : elle est écrite telle quelle, unquoteValue ne
|
|
// touchant qu'à une paire entourante.
|
|
func quoteValue(v string) string {
|
|
if v == "" || strings.ContainsRune(v, '"') {
|
|
return v
|
|
}
|
|
if strings.ContainsAny(v, " \t") || unquoteValue(v) != v {
|
|
return `"` + v + `"`
|
|
}
|
|
return v
|
|
}
|
|
|
|
// formatEnv rend une configuration au format des presets, clés triées pour que
|
|
// deux écritures du même contenu donnent le même fichier.
|
|
func formatEnv(m map[string]string) string {
|
|
keys := make([]string, 0, len(m))
|
|
for k := range m {
|
|
keys = append(keys, k)
|
|
}
|
|
sort.Strings(keys)
|
|
var b strings.Builder
|
|
for _, k := range keys {
|
|
fmt.Fprintf(&b, "%s=%s\n", k, quoteValue(m[k]))
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// splitArgs découpe EXTRA_ARGS comme le ferait un shell : sur les espaces, mais
|
|
// en respectant les guillemets, pour qu'un chemin qui en contient reste UN seul
|
|
// argument (--chat-template-file "/mes modèles/tpl.jinja"). Le découpage
|
|
// précédent, sur le seul caractère espace, le coupait en deux et llama-server
|
|
// refusait de démarrer.
|
|
func splitArgs(s string) []string {
|
|
out := []string{}
|
|
var cur strings.Builder
|
|
var quote rune
|
|
flush := func() {
|
|
if cur.Len() > 0 {
|
|
out = append(out, cur.String())
|
|
cur.Reset()
|
|
}
|
|
}
|
|
for _, r := range s {
|
|
switch {
|
|
case quote != 0:
|
|
if r == quote {
|
|
quote = 0
|
|
// Guillemets vides ("" ) : on garde l'argument vide explicite.
|
|
if cur.Len() == 0 {
|
|
out = append(out, "")
|
|
}
|
|
} else {
|
|
cur.WriteRune(r)
|
|
}
|
|
case r == '"' || r == '\'':
|
|
quote = r
|
|
case r == ' ' || r == '\t' || r == '\n' || r == '\r':
|
|
flush()
|
|
default:
|
|
cur.WriteRune(r)
|
|
}
|
|
}
|
|
flush()
|
|
return out
|
|
}
|
|
|
|
// samplingKeys : les paramètres d'échantillonnage réglables par preset. Repris
|
|
// de l'amont AJEAN (v0.9.5/v0.9.6).
|
|
//
|
|
// Ils sont injectés dans CHAQUE requête de complétion (applySampling) et non
|
|
// passés à llama-server au lancement : une valeur présente dans la requête
|
|
// l'emporte de toute façon sur le défaut du serveur, donc c'est le seul endroit
|
|
// où le réglage prend vraiment effet. Sans ça, seule la température voyageait, et
|
|
// top_k/min_p/… retombaient sur les défauts de llama.cpp — rarement ceux que
|
|
// recommande le modèle (Qwen3.8 veut top_k 20 / min_p 0, pas 40 / 0.05).
|
|
//
|
|
// Correspondance clé de config → champ du corps OpenAI. Une valeur vide vaut
|
|
// « clé absente » : llama-server garde alors son propre défaut pour ce champ.
|
|
var samplingKeys = []struct {
|
|
cfg, field string
|
|
integer bool
|
|
}{
|
|
{"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},
|
|
}
|
|
|
|
// applySampling superpose les paramètres d'échantillonnage du preset au corps
|
|
// d'une requête de complétion. Les clés vides sont ignorées (le serveur garde son
|
|
// défaut) ; TEMP, s'il est défini, l'emporte sur la température déjà posée dans le
|
|
// payload — c'est le réglage recommandé du modèle, et l'interface n'a pas de
|
|
// curseur de température qui pourrait le contredire.
|
|
//
|
|
// Contrairement à l'amont, REASONING_EFFORT n'est PAS traité ici : loki lui
|
|
// réserve un chemin plus riche (llm_client.go + llm_effort.go — champ de haut
|
|
// niveau, kwargs du gabarit, repli quand le gabarit refuse le niveau). L'écrire
|
|
// depuis ici écraserait `chat_template_kwargs`, donc la consigne « aucune » avec.
|
|
func applySampling(payload map[string]any) { applySamplingFrom(payload, ReadConfig()) }
|
|
|
|
// applySamplingFrom est la partie pure d'applySampling : testable sans base.
|
|
func applySamplingFrom(payload map[string]any, cfg map[string]string) {
|
|
for _, s := range samplingKeys {
|
|
v := strings.TrimSpace(cfg[s.cfg])
|
|
if v == "" {
|
|
continue
|
|
}
|
|
if s.integer {
|
|
if n, err := strconv.Atoi(v); err == nil {
|
|
payload[s.field] = n
|
|
}
|
|
continue
|
|
}
|
|
if f, err := strconv.ParseFloat(v, 64); err == nil {
|
|
payload[s.field] = f
|
|
}
|
|
}
|
|
}
|
|
|
|
// Le plafond d'appels d'outils par tour (TOOL_LIMIT) et l'anti-boucle ont été
|
|
// retirés : ils coupaient surtout des tours légitimes. Le bouton stop est le
|
|
// seul frein.
|
|
|
|
// LLMPort renvoie le port du serveur (clé PORT), 8080 par défaut.
|
|
func LLMPort() int {
|
|
if p, ok := ReadConfig()["PORT"]; ok {
|
|
if n, err := strconv.Atoi(p); err == nil && n > 0 {
|
|
return n
|
|
}
|
|
}
|
|
return 8080
|
|
}
|