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
250 lines
8.1 KiB
Go
250 lines
8.1 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
|
|
}
|
|
|
|
// 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
|
|
}
|