mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-11 17:26:57 +02:00
L'endpoint compatible OpenAI n'était pas servi par Loki : le panneau annonçait l'adresse de llama-server lui-même, http://<ip>:8080/v1. Dans le déploiement de référence de ce fork, cette adresse ne peut joindre personne — le port 8080 n'est pas publié par le conteneur, l'entrypoint sème HOST=127.0.0.1, et l'IP annoncée est celle du bridge Docker. L'autre voie proposée, « exposer en public (ajean.link) », exigeait un jeton de relais que ce fork ne permet plus d'obtenir : l'interrupteur ne pouvait que renvoyer vers un panneau supprimé. Désormais, Loki sert /v1/* SUR SON PROPRE PORT et relaie vers le moteur. L'API est donc joignable partout où l'interface l'est — IP du réseau local, nom de domaine, reverse proxy — sans publier de second port ni ouvrir le moteur. Serveur - mountOAI (llm_oai.go) monte /v1/ sur le mux, et RIEN d'autre : ni /metrics, ni /props, ni /slots, qui divulgueraient le modèle chargé et l'état des slots. Le filtre interne d'oaiHandler reste en seconde barrière. - requireCompletionKey (web_auth.go) garde cette surface avec la clé des COMPLÉTIONS, pas celle de pilotage : un client OpenAI n'a qu'un en-tête Authorization, et on veut pouvoir lui donner l'accès au modèle sans le droit de redémarrer la machine. Erreurs au format d'OpenAI (body.error.message), que les SDK savent présenter. Le préflight CORS passe sans clé — il n'en porte jamais, et le refuser casserait tout client tiers de navigateur. - effectiveAPIKeyErr (backend_config.go) devient la source unique de la clé exigée : base d'abord, config.env en repli, exactement comme le moteur. Sans ce miroir, un API_KEY résiduel donnait un endpoint « ouvert » côté Loki et un 401 côté moteur, sans rien pour l'expliquer. Lecture ratée = refus, jamais ouverture (même raisonnement que readWebKeyErr). - oaiHandler passe à ReverseProxy.Rewrite : le port du moteur est relu à chaque requête au lieu d'être figé à la construction — il visait l'ancien port dès qu'on changeait PORT, jusqu'au redémarrage de Loki. - withLocalAuth (relay_link.go) n'injecte plus la clé de pilotage sur /v1 : elle aurait été refusée par la garde, et surtout relayée au moteur. Le trafic du tunnel est marqué (en-tête effacé avant d'être posé, sinon un client le forge) et la surface y reste fermée tant que oai_public est faux — la promesse du tunnel est tenue. Adresse affichée - web_public_url.go : normalisation d'une adresse publique saisie à la main (schéma ajouté, /v1 recopié toléré, chemin refusé), origine de la requête via Host + X-Forwarded-Proto, et la règle de priorité entre les deux. - Le calcul quitte le navigateur pour le serveur : c'est la concaténation côté client qui produisait l'adresse fantôme. Interface - Le panneau perd l'interrupteur ajean.link et l'interrupteur d'écoute LAN — ce dernier n'a plus d'objet, et deux interrupteurs pour « rendre l'IA joignable » était la confusion à lever. La route /api/network et `loki network` restent pour qui veut exposer le moteur en direct. - Il gagne un champ « adresse publique » (facultatif, pour le reverse proxy) et un avertissement rouge tant qu'aucune clé n'est définie — l'endpoint est maintenant ouvert PARTOUT où l'interface l'est, ça ne se dit pas à voix basse. Le démarrage de `loki web` le crie aussi. Vérifié bout en bout sur le serveur réel : liste des modèles à travers Loki avec la clé (200), sans la clé (401), et complétion en streaming dont les tokens arrivent espacés de 120 ms — le flux traverse bien le double proxy. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LPyFxVHNAN9u5pVzSYMwjd
197 lines
8.7 KiB
Go
197 lines
8.7 KiB
Go
package loki
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// binSupportsReasoningFlag dit si ce llama-server accepte « --reasoning ».
|
|
//
|
|
// Le drapeau est récent : les moteurs plus anciens, et certains forks, ne le
|
|
// connaissent pas et REFUSENT de démarrer sur un argument inconnu. Comme on ne
|
|
// l'ajoute que pour interdire le raisonnement, mieux vaut demander au binaire
|
|
// que parier : on lit son aide, une fois, au lancement du moteur.
|
|
//
|
|
// L'aide se lit avec le même chemin de bibliothèques que le vrai lancement
|
|
// (setLibraryPath a déjà été appelé) : sans ça un moteur parfaitement valide
|
|
// échoue à s'exécuter (« libllama-common.so introuvable ») et on conclurait à
|
|
// tort qu'il ne gère pas le drapeau.
|
|
func binSupportsReasoningFlag(bin string) bool {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
cmd := exec.CommandContext(ctx, bin, "--help")
|
|
var out bytes.Buffer
|
|
cmd.Stdout, cmd.Stderr = &out, &out
|
|
if err := cmd.Run(); err != nil && out.Len() == 0 {
|
|
return false // aide illisible : on ne prend pas le risque
|
|
}
|
|
return strings.Contains(out.String(), "--reasoning ")
|
|
}
|
|
|
|
// cmdServe replaces the historic start.sh: read config.env, build the
|
|
// llama-server invocation, and exec it (replacing this process so systemd
|
|
// supervises llama-server directly).
|
|
func cmdServe(args []string) error {
|
|
cfg := ReadConfig()
|
|
bin := cfg["BIN"]
|
|
if bin == "" {
|
|
return fmt.Errorf("BIN non défini — lance « loki edit »")
|
|
}
|
|
model := cfg["MODEL"]
|
|
if model == "" {
|
|
return fmt.Errorf("MODEL non défini — lance « loki edit »")
|
|
}
|
|
// MODEL vaut soit un simple nom de fichier (le .gguf vit dans LOKI_HOME ou
|
|
// dans un dossier déclaré — disque externe…), soit un chemin absolu. Sous
|
|
// systemd/launchd le WorkingDirectory vaut LOKI_HOME, donc le relatif tombait
|
|
// juste ; lancé depuis une app de bureau, le répertoire courant est « / » et
|
|
// llama-server ne trouvait rien. On résout donc explicitement, quel que soit
|
|
// le contexte de lancement.
|
|
resolved, err := resolveServeModelPath(model)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
model = resolved
|
|
if _, err := os.Stat(model); err != nil {
|
|
return fmt.Errorf("modèle introuvable : %s", model)
|
|
}
|
|
// Modèle découpé en tranches : llama-server ouvre les suivantes tout seul, mais
|
|
// s'il en manque une il démarre puis meurt sur un tenseur introuvable — message
|
|
// incompréhensible, et systemd relance en boucle. On le dit ici, en clair.
|
|
if missing := shardFamilyMissing(filepath.Dir(model), filepath.Base(model)); len(missing) > 0 {
|
|
return fmt.Errorf("modèle incomplet : il manque %s dans %s — ce modèle tient en %d fichiers, télécharge-les tous",
|
|
strings.Join(missing, ", "), filepath.Dir(model), len(shardFamily(filepath.Base(model))))
|
|
}
|
|
if !filepath.IsAbs(bin) {
|
|
bin = filepath.Join(LokiHome(), bin)
|
|
}
|
|
// Le moteur précompilé s'installe dans un dossier versionné : un preset écrit
|
|
// avant une mise à jour pointe sur une release qui n'existe plus. On le fait
|
|
// suivre au moteur courant plutôt que d'échouer en 127.
|
|
bin = prebuiltResolveBin(bin)
|
|
|
|
// Make sure llama-server can find its bundled shared libraries (the .so/.dll
|
|
// neighbours of the binary). This is platform-specific: LD_LIBRARY_PATH on
|
|
// Linux, PATH on Windows — handled inside execServer.
|
|
setLibraryPath(filepath.Dir(bin))
|
|
|
|
// Sélection GPU (loki gpu) : on filtre les devices visibles par llama-server.
|
|
// CUDA_DEVICE_ORDER=PCI_BUS_ID garantit que les index correspondent à ceux
|
|
// affichés par nvidia-smi (sinon CUDA réordonne par "device le plus rapide").
|
|
if v := cfg["CUDA_VISIBLE_DEVICES"]; v != "" {
|
|
_ = os.Setenv("CUDA_VISIBLE_DEVICES", v)
|
|
_ = os.Setenv("CUDA_DEVICE_ORDER", "PCI_BUS_ID")
|
|
}
|
|
|
|
get := func(key, fallback string) string {
|
|
if v, ok := cfg[key]; ok && v != "" {
|
|
return v
|
|
}
|
|
return fallback
|
|
}
|
|
kv := get("KV_TYPE", "")
|
|
ktv := get("KV_TYPE_K", kv)
|
|
vtv := get("KV_TYPE_V", kv)
|
|
|
|
llmArgs := []string{bin,
|
|
"-m", model,
|
|
"-c", get("CTX", "32768"),
|
|
"-t", get("THREADS", "0"),
|
|
"-tb", get("THREADS_BATCH", "0"),
|
|
"-b", get("BATCH", "2048"),
|
|
"-ub", get("UBATCH", "512"),
|
|
"--host", get("HOST", "0.0.0.0"),
|
|
"--port", get("PORT", "8080"),
|
|
}
|
|
// NGL=auto : on n'envoie PAS -ngl, et llama.cpp choisit lui-même combien de
|
|
// couches tiennent dans la VRAM libre (common_fit_params). Il s'en abstient
|
|
// dès que la valeur est imposée — « n_gpu_layers already set by user to 999,
|
|
// abort » — d'où des cudaMalloc en échec, un repli partiel sur le CPU et un
|
|
// débit effondré alors que le GPU tourne à 100 %.
|
|
// Le défaut reste 999 (tout sur le GPU) : omettre -ngl sur un llama.cpp
|
|
// antérieur à cet ajustement automatique le ferait tourner 100 % CPU.
|
|
if ngl := get("NGL", "999"); !strings.EqualFold(ngl, "auto") {
|
|
llmArgs = append(llmArgs, "-ngl", ngl)
|
|
}
|
|
if ktv != "" {
|
|
llmArgs = append(llmArgs, "-ctk", ktv)
|
|
}
|
|
if vtv != "" {
|
|
llmArgs = append(llmArgs, "-ctv", vtv)
|
|
}
|
|
// Vision : le projecteur multimodal (mmproj-*.gguf) donne des yeux au modèle.
|
|
// C'est un fichier .gguf À PART du modèle, chargé via --mmproj. On le résout
|
|
// comme le modèle (nom simple cherché dans les dossiers déclarés, ou chemin
|
|
// absolu), pour qu'un preset écrit sous Windows reste lançable ailleurs et que
|
|
// le champ « Vision » de l'interface n'ait qu'à écrire le nom du fichier.
|
|
// Introuvable = on préfère le dire clairement plutôt que laisser llama-server
|
|
// mourir en boucle sur un « failed to load mmproj » cryptique.
|
|
if mm := strings.TrimSpace(cfg["MMPROJ"]); mm != "" {
|
|
mmPath, err := resolveServeModelPath(mm)
|
|
if err != nil {
|
|
return fmt.Errorf("projecteur vision introuvable : %s (%v)", mm, err)
|
|
}
|
|
if _, err := os.Stat(mmPath); err != nil {
|
|
return fmt.Errorf("projecteur vision introuvable : %s", mmPath)
|
|
}
|
|
llmArgs = append(llmArgs, "--mmproj", mmPath)
|
|
}
|
|
// Raisonnement. Trois cas, et la nuance compte :
|
|
//
|
|
// REASONING=on|auto|deepseek → --reasoning <valeur>
|
|
// REASONING=off → --reasoning off (interdiction EXPLICITE)
|
|
// clé absente → aucun drapeau, le moteur fait son défaut
|
|
//
|
|
// L'interface écrivait « off » en EFFAÇANT la ligne, ce qui n'est pas du tout
|
|
// la même chose : sans drapeau, llama-server suit le gabarit du modèle, et un
|
|
// modèle à raisonnement raisonne. L'interrupteur affichait donc « désactivé »
|
|
// pendant que le modèle réfléchissait quand même. Il faut le dire au moteur.
|
|
if r := strings.TrimSpace(cfg["REASONING"]); r != "" {
|
|
if reasoningActive(r) {
|
|
// budget illimité par défaut (-1) : on laisse le modèle réfléchir jusqu'au
|
|
// bout au lieu de le couper à 2048, ce qui tronquait la vraie réponse (la
|
|
// réflexion atteignait le plafond et il ne restait plus de marge pour le
|
|
// contenu). L'anti-boucle côté llm_client.go reste le garde-fou. NE PAS forcer 0 :
|
|
// sur llama.cpp vanilla, 0 = "immediate end" → coupe tout le raisonnement
|
|
// (le fork ik_llama.cpp l'ignore). Configurable via REASONING_BUDGET.
|
|
llmArgs = append(llmArgs, "--reasoning", r, "--reasoning-budget", get("REASONING_BUDGET", "-1"))
|
|
} else if binSupportsReasoningFlag(bin) {
|
|
// Pas de budget ici : « off » suffit, et un budget sur un moteur qui
|
|
// n'attend rien d'autre ne ferait qu'ajouter une occasion d'échouer.
|
|
llmArgs = append(llmArgs, "--reasoning", "off")
|
|
} else {
|
|
// Vieux moteur (ou fork) qui ne connaît pas le drapeau : le lui passer
|
|
// le ferait sortir en erreur au démarrage, donc boucler. On le dit et on
|
|
// continue sans — mieux vaut un modèle qui réfléchit qu'un moteur mort.
|
|
fmt.Fprintf(os.Stderr, "[loki serve] ce moteur ne connaît pas --reasoning : impossible de désactiver le raisonnement\n")
|
|
}
|
|
}
|
|
// API_KEY protège le serveur quand il est exposé sur internet : llama-server
|
|
// exige alors l'en-tête "Authorization: Bearer <clé>". La clé est lue depuis
|
|
// $LOKI_HOME/.api_key en priorité (elle survit ainsi aux changements de preset
|
|
// qui réécrivent config.env), avec config.env comme repli rétro-compatible.
|
|
if k, _ := effectiveAPIKeyErr(); k != "" {
|
|
llmArgs = append(llmArgs, "--api-key", k)
|
|
}
|
|
// EXTRA_ARGS is appended verbatim, split like the shell would — quotes kept
|
|
// together so a path with spaces stays one argument.
|
|
llmArgs = append(llmArgs, splitArgs(cfg["EXTRA_ARGS"])...)
|
|
|
|
// Working dir = LOKI_HOME so relative paths in EXTRA_ARGS (e.g. --mmproj
|
|
// mmproj-F16.gguf) still resolve.
|
|
_ = os.Chdir(LokiHome())
|
|
|
|
fmt.Fprintf(os.Stderr, "[loki serve] %s model=%s port=%s\n",
|
|
bin, filepath.Base(model), get("PORT", "8080"))
|
|
|
|
// Hand off to the llama-server process. On Unix this replaces the current
|
|
// process (exec); on Windows it runs as a child and waits. See sys_platform_*.go.
|
|
return execServer(bin, llmArgs)
|
|
}
|