mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-22 06:28:11 +02:00
Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a6911af27 | ||
|
|
965a4d6247 | ||
|
|
99c96a6c33 | ||
|
|
6a45f19831 | ||
|
|
d22a087d8e | ||
|
|
0ef2ed72f5 | ||
|
|
615e58dd2f | ||
|
|
9792953d62 | ||
|
|
4c2cf8b4e6 | ||
|
|
68dd65cd5a | ||
|
|
01c11be4ae | ||
|
|
00e09a71e3 |
No files matched your search
@@ -52,15 +52,32 @@ et le **chiffrement de la mémoire** (`mem_crypto.go`, `mem_vault.go`,
|
||||
**pas** reprise : elle vise ajean.link, le service de l'auteur amont. Loki
|
||||
expose à la place un export/import de paquet chiffré en fichier local.
|
||||
|
||||
Le **rendu des formules LaTeX** dans les réponses (`ui/src/js/00-math.js`,
|
||||
`web_katex.go`) est repris d'AJEAN (#123) : $…$, $$…$$, \\(…\\) et \\[…\\] rendus
|
||||
par KaTeX, chargé à la première formule seulement, sans toucher au code ni aux
|
||||
montants en $. Loki y ajoute deux garde-fous de détection (« 5 $, » et un $
|
||||
voisin d'un code en ligne restent du texte) et sert la route `/katex/` aussi
|
||||
depuis la façade Rust (`rust/loki-rs/src/ui.rs`).
|
||||
|
||||
## KaTeX
|
||||
|
||||
**[KaTeX](https://katex.org/)** 0.16.11 (licence MIT, © Khan Academy et
|
||||
contributeurs) est embarqué tel que publié — script, feuille de style et
|
||||
polices woff2 — sous `internal/loki/ui/katex/`, avec sa licence
|
||||
(`internal/loki/ui/katex/LICENSE`). Il est servi par Loki sous `/katex/`, sans
|
||||
appel à un CDN.
|
||||
|
||||
## Moteur Strata
|
||||
|
||||
Le second moteur de Loki (`internal/loki/backend_strata.go`) est
|
||||
**[Strata](https://github.com/Niko1221/Strata)** (MIT), spécialisé dans
|
||||
Qwen3.8-Flash-Next. Son intégration est reprise d'AJEAN (« AJEAN MoE 1.0 »,
|
||||
Qwen3.8-Flash-Next. Son intégration est reprise d'AJEAN (« AJEAN MoE 1.1 »,
|
||||
`backend_moe.go`) : Loki télécharge le paquet figé publié par AJEAN sur sa
|
||||
release `moe-v1.0` (sources de Strata et binaire Linux CUDA 12 compilé, avec
|
||||
les modifications d'AJEAN), vérifié par SHA-256. Ni Strata ni le modèle ne sont
|
||||
inclus dans l'image : ils sont téléchargés à la demande.
|
||||
release `moe-v1.1` (sources de Strata et binaire Linux CUDA 12 compilé, avec
|
||||
les modifications d'AJEAN), vérifié par SHA-256 avec les empreintes publiées
|
||||
par AJEAN. La reprise automatique d'une installation 1.0 (`strataMigrate`) est
|
||||
reprise d'AJEAN (`moeMigrate`). Ni Strata ni le modèle ne sont inclus dans
|
||||
l'image : ils sont téléchargés à la demande.
|
||||
|
||||
## Services externes
|
||||
|
||||
|
||||
@@ -111,7 +111,7 @@ Loki a deux moteurs, et le choix se fait **par preset** (clé `ENGINE`) :
|
||||
- **[Strata](https://github.com/Niko1221/Strata)** — spécialisé dans
|
||||
**Qwen3.8 Flash Next** (MoE de 125 B) : les experts sont répartis entre
|
||||
VRAM, RAM et disque pour faire tourner ce modèle sur une carte de joueur.
|
||||
Intégration reprise d'AJEAN (paquet figé « AJEAN MoE 1.0 », vérifié par
|
||||
Intégration reprise d'AJEAN (paquet figé « AJEAN MoE 1.1 », vérifié par
|
||||
SHA-256).
|
||||
|
||||
**Paramètres → Moteur → Strata** : la fenêtre lit la machine (cartes, RAM,
|
||||
@@ -120,7 +120,26 @@ puis installe et active le modèle en un clic (téléchargement de 66 à 84 Go d
|
||||
`/data/strata`). Le modèle installé devient un preset comme les autres :
|
||||
basculer dessus lance Strata, basculer sur un autre preset revient à llama.cpp.
|
||||
Son engrenage ouvre les réglages propres à Strata (contexte jusqu'à 262 144,
|
||||
cache KV fp16/8 bits, prédiction MTP, lecture des images, carte d'appoint).
|
||||
cache KV fp16/8 bits, prédiction MTP, lecture des images, seconde carte).
|
||||
|
||||
La seconde carte a trois réglages : **non utilisée**, **aide aux experts** (le
|
||||
défaut : elle garde et calcule une part des experts) ou **partage des couches**
|
||||
(les deux cartes se répartissent les couches du modèle). Le partage est souvent
|
||||
plus rapide avec deux cartes identiques (+50 % en génération mesuré par un
|
||||
utilisateur d'AJEAN sur deux RTX 3060) ; sur deux cartes très différentes,
|
||||
l'aide aux experts reste le meilleur choix. En partage des couches, le mode
|
||||
« hors RAM » n'est jamais choisi : la seconde carte n'y garde pas d'experts.
|
||||
|
||||
Depuis le paquet 1.1, la carte d'appoint calcule elle-même ses experts à la
|
||||
lecture des prompts (au lieu de les recopier vers la principale) : les ajouts
|
||||
de 100 à 200 jetons se lisent ~30 à 40 % plus vite (mesures d'AJEAN). Un modèle
|
||||
installé avec la 1.0 passe à la 1.1 **à son premier lancement**, sans
|
||||
réinstallation : Loki télécharge le nouveau paquet (~340 Mo), reprend
|
||||
l'environnement Python et la configuration de la 1.0 (le dossier
|
||||
`/data/strata/ajean-moe-1.0` doit donc rester en place) et réécrit le preset.
|
||||
En cas d'échec, la 1.0 continue de tourner et la raison est dans le journal du
|
||||
moteur. En mode « hors RAM », le contexte est plafonné à 131 072 jetons (au-delà,
|
||||
le cache KV verrouillé en RAM ne tient plus).
|
||||
|
||||
Pré-requis : Linux + NVIDIA RTX 20 ou plus récente (8 Go de VRAM minimum, une
|
||||
deuxième carte est utilisée comme carte d'appoint), environ 46 Go de RAM pour
|
||||
|
||||
@@ -282,10 +282,96 @@ type dlState struct {
|
||||
Part int `json:"part"` // tranche en cours (1-based)
|
||||
Finished bool `json:"finished"`
|
||||
Canceled bool `json:"canceled"`
|
||||
Paused bool `json:"paused"`
|
||||
Err string `json:"error"`
|
||||
StartedAt int64 `json:"started_at"`
|
||||
|
||||
cancel context.CancelFunc `json:"-"` // set while in flight, cleared on finish
|
||||
gate *dlGate `json:"-"`
|
||||
}
|
||||
|
||||
// dlGate met un téléchargement en pause (repris d'AJEAN v0.18.1) : les
|
||||
// connexions en cours sont coupées (rien ne reste ouvert vers le CDN pendant la
|
||||
// pause), et chaque morceau attend la reprise puis repart de l'octet où il
|
||||
// s'était arrêté, par une requête de plage.
|
||||
type dlGate struct {
|
||||
mu sync.Mutex
|
||||
paused bool
|
||||
wake chan struct{} // fermé à la reprise
|
||||
attempt map[int]context.CancelFunc // requêtes en cours, coupées par la pause
|
||||
next int
|
||||
}
|
||||
|
||||
func newDlGate() *dlGate { return &dlGate{attempt: map[int]context.CancelFunc{}} }
|
||||
|
||||
func (g *dlGate) setPaused(p bool) {
|
||||
if g == nil {
|
||||
return
|
||||
}
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
if p == g.paused {
|
||||
return
|
||||
}
|
||||
g.paused = p
|
||||
if p {
|
||||
g.wake = make(chan struct{})
|
||||
for _, c := range g.attempt {
|
||||
c()
|
||||
}
|
||||
return
|
||||
}
|
||||
close(g.wake)
|
||||
}
|
||||
|
||||
// wait bloque tant que le téléchargement est en pause. false = annulé entre-temps.
|
||||
func (g *dlGate) wait(ctx context.Context) bool {
|
||||
if g == nil {
|
||||
return ctx.Err() == nil
|
||||
}
|
||||
g.mu.Lock()
|
||||
paused, wake := g.paused, g.wake
|
||||
g.mu.Unlock()
|
||||
if !paused {
|
||||
return ctx.Err() == nil
|
||||
}
|
||||
select {
|
||||
case <-wake:
|
||||
return ctx.Err() == nil
|
||||
case <-ctx.Done():
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (g *dlGate) isPaused() bool {
|
||||
if g == nil {
|
||||
return false
|
||||
}
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
return g.paused
|
||||
}
|
||||
|
||||
// begin ouvre une requête que la pause pourra couper ; la fonction rendue la referme.
|
||||
func (g *dlGate) begin(ctx context.Context) (context.Context, func()) {
|
||||
if g == nil {
|
||||
return ctx, func() {}
|
||||
}
|
||||
actx, cancel := context.WithCancel(ctx)
|
||||
g.mu.Lock()
|
||||
id := g.next
|
||||
g.next++
|
||||
g.attempt[id] = cancel
|
||||
if g.paused {
|
||||
cancel() // pause tombée entre wait et begin
|
||||
}
|
||||
g.mu.Unlock()
|
||||
return actx, func() {
|
||||
g.mu.Lock()
|
||||
delete(g.attempt, id)
|
||||
g.mu.Unlock()
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -429,7 +515,7 @@ func handleModelDownload(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
st := &dlState{
|
||||
Filename: name, URL: urls[0], Dir: filepath.Dir(dests[0]),
|
||||
Parts: len(todoURLs), StartedAt: time.Now().Unix(), cancel: cancel,
|
||||
Parts: len(todoURLs), StartedAt: time.Now().Unix(), cancel: cancel, gate: newDlGate(),
|
||||
}
|
||||
dlDownloads[name] = st
|
||||
dlMu.Unlock()
|
||||
@@ -759,7 +845,9 @@ func runDownloadSet(ctx context.Context, st *dlState, urls, dests []string) {
|
||||
dt := now.Sub(lastAt).Seconds()
|
||||
dlMu.Lock()
|
||||
st.Done = cur
|
||||
if dt > 0 {
|
||||
if st.Paused {
|
||||
st.Speed = 0
|
||||
} else if dt > 0 {
|
||||
inst := int64(float64(cur-last) / dt)
|
||||
if st.Speed == 0 {
|
||||
st.Speed = inst
|
||||
@@ -827,7 +915,7 @@ func dlOnePart(ctx context.Context, st *dlState, dlURL, dest string, total int64
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := dlFetch(ctx, f, dlURL, total, conns, done); err != nil {
|
||||
if err := dlFetch(ctx, st.gate, f, dlURL, total, conns, done); err != nil {
|
||||
f.Close()
|
||||
_ = os.Remove(tmp)
|
||||
return err
|
||||
@@ -845,9 +933,9 @@ func dlOnePart(ctx context.Context, st *dlState, dlURL, dest string, total int64
|
||||
|
||||
// dlFetch writes the whole body into f, either as one stream or as `conns`
|
||||
// parallel byte ranges. done is incremented atomically as bytes land on disk.
|
||||
func dlFetch(ctx context.Context, f *os.File, dlURL string, total int64, conns int, done *int64) error {
|
||||
func dlFetch(ctx context.Context, g *dlGate, f *os.File, dlURL string, total int64, conns int, done *int64) error {
|
||||
if conns <= 1 {
|
||||
return dlChunk(ctx, f, dlURL, 0, total-1, total <= 0, done)
|
||||
return dlChunk(ctx, g, f, dlURL, 0, total-1, total <= 0, done)
|
||||
}
|
||||
size := total / int64(conns)
|
||||
var wg sync.WaitGroup
|
||||
@@ -861,7 +949,7 @@ func dlFetch(ctx context.Context, f *os.File, dlURL string, total int64, conns i
|
||||
wg.Add(1)
|
||||
go func(i int, start, end int64) {
|
||||
defer wg.Done()
|
||||
errs[i] = dlChunk(ctx, f, dlURL, start, end, false, done)
|
||||
errs[i] = dlChunk(ctx, g, f, dlURL, start, end, false, done)
|
||||
}(i, start, end)
|
||||
}
|
||||
wg.Wait()
|
||||
@@ -876,11 +964,15 @@ func dlFetch(ctx context.Context, f *os.File, dlURL string, total int64, conns i
|
||||
// dlChunk downloads [start,end] into f at the right offset, retrying from where
|
||||
// it stopped if the connection drops mid-chunk. With whole=true it streams the
|
||||
// entire body sequentially (server without range support, unknown size).
|
||||
func dlChunk(ctx context.Context, f *os.File, dlURL string, start, end int64, whole bool, done *int64) error {
|
||||
func dlChunk(ctx context.Context, g *dlGate, f *os.File, dlURL string, start, end int64, whole bool, done *int64) error {
|
||||
const attempts = 4
|
||||
pos := start
|
||||
var lastErr error
|
||||
for try := 0; try < attempts; try++ {
|
||||
// Une pause n'est pas un échec : on attend la reprise sans consommer d'essai.
|
||||
if !g.wait(ctx) {
|
||||
return ctx.Err()
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err // cancelled: never retry
|
||||
}
|
||||
@@ -900,18 +992,26 @@ func dlChunk(ctx context.Context, f *os.File, dlURL string, start, end int64, wh
|
||||
} else if pos > start {
|
||||
rng = fmt.Sprintf("bytes=%d-", pos) // best-effort resume
|
||||
}
|
||||
req, err := dlRequest(ctx, dlURL, rng)
|
||||
actx, release := g.begin(ctx)
|
||||
req, err := dlRequest(actx, dlURL, rng)
|
||||
if err != nil {
|
||||
release()
|
||||
return err
|
||||
}
|
||||
resp, err := dlClient.Do(req)
|
||||
if err != nil {
|
||||
release()
|
||||
if g.isPaused() && ctx.Err() == nil {
|
||||
try--
|
||||
continue
|
||||
}
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
if resp.StatusCode != 200 && resp.StatusCode != 206 {
|
||||
err := dlSourceError(resp, dlURL)
|
||||
resp.Body.Close()
|
||||
release()
|
||||
return err
|
||||
}
|
||||
if resp.StatusCode == 200 && pos > start {
|
||||
@@ -921,10 +1021,15 @@ func dlChunk(ctx context.Context, f *os.File, dlURL string, start, end int64, wh
|
||||
}
|
||||
n, cerr := dlCopy(f, resp.Body, pos, done)
|
||||
resp.Body.Close()
|
||||
release()
|
||||
pos += n
|
||||
if cerr == nil {
|
||||
return nil
|
||||
}
|
||||
if g.isPaused() && ctx.Err() == nil {
|
||||
try-- // coupé par la pause : la reprise repart de pos
|
||||
continue
|
||||
}
|
||||
lastErr = cerr
|
||||
}
|
||||
return lastErr
|
||||
@@ -981,6 +1086,35 @@ func handleModelDownloadCancel(w http.ResponseWriter, r *http.Request) {
|
||||
sendJSON(w, 200, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
// handleModelDownloadPause met en pause ({"pause":true}) ou reprend
|
||||
// ({"pause":false}) un téléchargement en cours. Le fichier partiel est gardé.
|
||||
func handleModelDownloadPause(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Filename string `json:"filename"`
|
||||
Pause bool `json:"pause"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
name := filepath.Base(strings.TrimSpace(req.Filename))
|
||||
dlMu.Lock()
|
||||
st, ok := dlDownloads[name]
|
||||
if !ok || st.Finished {
|
||||
dlMu.Unlock()
|
||||
sendJSON(w, 404, map[string]any{"ok": false, "error": "aucun téléchargement en cours pour " + name})
|
||||
return
|
||||
}
|
||||
st.Paused = req.Pause
|
||||
if req.Pause {
|
||||
st.Speed = 0
|
||||
}
|
||||
g := st.gate
|
||||
dlMu.Unlock()
|
||||
g.setPaused(req.Pause)
|
||||
sendJSON(w, 200, map[string]any{"ok": true, "paused": req.Pause})
|
||||
}
|
||||
|
||||
// cleanStalePartFiles removes leftover *.gguf.part files in every model dir at
|
||||
// startup. A download killed by a crash or a service restart can't be resumed
|
||||
// (its state lived in memory), so the partial file would otherwise sit there
|
||||
|
||||
@@ -231,3 +231,96 @@ func TestDLProbeSurfacesExplainedError(t *testing.T) {
|
||||
t.Errorf("message %q sans indication sur le jeton", err)
|
||||
}
|
||||
}
|
||||
|
||||
type slowWriter struct{ http.ResponseWriter }
|
||||
|
||||
func (s slowWriter) Write(b []byte) (int, error) {
|
||||
time.Sleep(2 * time.Millisecond)
|
||||
return s.ResponseWriter.Write(b)
|
||||
}
|
||||
|
||||
// Une pause coupe le flux, garde ce qui est écrit, et la reprise termine un
|
||||
// fichier identique à la source (repris d'AJEAN v0.18.1).
|
||||
func TestRunDownloadPauseResume(t *testing.T) {
|
||||
data := make([]byte, 48<<20)
|
||||
rand.New(rand.NewSource(2)).Read(data)
|
||||
t.Setenv("LOKI_DL_CONNS", "4")
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.ServeContent(slowWriter{w}, r, "m.gguf", time.Time{}, bytes.NewReader(data))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
dest := filepath.Join(t.TempDir(), "m.gguf")
|
||||
st := &dlState{Filename: "m.gguf", gate: newDlGate()}
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
runDownloadSet(context.Background(), st, []string{srv.URL + "/m.gguf"}, []string{dest})
|
||||
close(done)
|
||||
}()
|
||||
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
st.gate.setPaused(true)
|
||||
time.Sleep(1500 * time.Millisecond) // le compteur publié se fige
|
||||
dlMu.Lock()
|
||||
a := st.Done
|
||||
dlMu.Unlock()
|
||||
time.Sleep(1500 * time.Millisecond)
|
||||
dlMu.Lock()
|
||||
b, fin := st.Done, st.Finished
|
||||
dlMu.Unlock()
|
||||
if fin || a != b || a == 0 {
|
||||
t.Fatalf("pause inopérante : %d puis %d octets, terminé=%v", a, b, fin)
|
||||
}
|
||||
st.gate.setPaused(false)
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(30 * time.Second):
|
||||
t.Fatal("pas terminé après la reprise")
|
||||
}
|
||||
if st.Err != "" {
|
||||
t.Fatalf("erreur après reprise : %s", st.Err)
|
||||
}
|
||||
got, err := os.ReadFile(dest)
|
||||
if err != nil || !bytes.Equal(got, data) {
|
||||
t.Fatalf("fichier différent de la source après reprise (err=%v)", err)
|
||||
}
|
||||
}
|
||||
|
||||
// La route : pause puis reprise d'un téléchargement connu, refus pour
|
||||
// l'inconnu et pour un téléchargement terminé.
|
||||
func TestHandleModelDownloadPause(t *testing.T) {
|
||||
dlMu.Lock()
|
||||
saved := dlDownloads
|
||||
dlDownloads = map[string]*dlState{
|
||||
"a.gguf": {Filename: "a.gguf", Speed: 9, gate: newDlGate()},
|
||||
"b.gguf": {Filename: "b.gguf", Finished: true},
|
||||
}
|
||||
dlMu.Unlock()
|
||||
t.Cleanup(func() { dlMu.Lock(); dlDownloads = saved; dlMu.Unlock() })
|
||||
post := func(body string) (int, string) {
|
||||
w := httptest.NewRecorder()
|
||||
handleModelDownloadPause(w, httptest.NewRequest("POST", "/api/models/download/pause", strings.NewReader(body)))
|
||||
return w.Code, strings.TrimSpace(w.Body.String())
|
||||
}
|
||||
if code, out := post(`{"filename":"../a.gguf","pause":true}`); code != 200 || out != `{"ok":true,"paused":true}` {
|
||||
t.Fatalf("pause : %d %s", code, out)
|
||||
}
|
||||
dlMu.Lock()
|
||||
st := dlDownloads["a.gguf"]
|
||||
paused, speed := st.Paused, st.Speed
|
||||
dlMu.Unlock()
|
||||
if !paused || speed != 0 || !st.gate.isPaused() {
|
||||
t.Errorf("état après pause : paused=%v speed=%d porte=%v", paused, speed, st.gate.isPaused())
|
||||
}
|
||||
if code, out := post(`{"filename":"a.gguf","pause":false}`); code != 200 || out != `{"ok":true,"paused":false}` || st.gate.isPaused() {
|
||||
t.Fatalf("reprise : %d %s", code, out)
|
||||
}
|
||||
for _, body := range []string{`{"filename":"b.gguf","pause":true}`, `{"filename":"x.gguf","pause":true}`} {
|
||||
if code, _ := post(body); code != 404 {
|
||||
t.Errorf("%s : %d, attendu 404", body, code)
|
||||
}
|
||||
}
|
||||
if code, _ := post(`[`); code != 400 {
|
||||
t.Errorf("corps illisible : %d", code)
|
||||
}
|
||||
}
|
||||
+167
-16
@@ -5,11 +5,13 @@ package loki
|
||||
// répartit les experts entre VRAM, RAM et disque pour faire tenir le modèle sur
|
||||
// une carte de joueur.
|
||||
//
|
||||
// Repris d'AJEAN (backend_moe.go, « AJEAN MoE 1.0 ») : le moteur est le paquet
|
||||
// FIGÉ publié par AJEAN sur sa release GitHub moe-v1.0 — sources de Strata et
|
||||
// Repris d'AJEAN (backend_moe.go, « AJEAN MoE 1.1 ») : le moteur est le paquet
|
||||
// FIGÉ publié par AJEAN sur sa release GitHub moe-v1.1 — sources de Strata et
|
||||
// binaire Linux CUDA 12 compilé et validé par l'auteur d'AJEAN (avec ses
|
||||
// modifications : experts de la carte d'appoint gardés hors RAM). Les deux
|
||||
// fichiers sont vérifiés par SHA-256 avant usage.
|
||||
// modifications : experts de la carte d'appoint gardés hors RAM et, depuis la
|
||||
// 1.1, calculés par elle à la lecture des prompts). Les deux fichiers sont
|
||||
// vérifiés par SHA-256 avant usage. Un modèle installé avec la 1.0 passe à la
|
||||
// 1.1 à son premier lancement (strataMigrate).
|
||||
//
|
||||
// Le choix du moteur se fait par PRESET : ENGINE=strata. Sans la clé (ou
|
||||
// ENGINE=llamacpp), rien ne change, llama-server reste le moteur.
|
||||
@@ -47,11 +49,12 @@ import (
|
||||
"os/exec"
|
||||
)
|
||||
|
||||
// Paquet figé (release moe-v1.0 d'AJEAN). Le moteur est compilé en CUDA 12.8,
|
||||
// Paquet figé (release moe-v1.1 d'AJEAN). Le moteur est compilé en CUDA 12.8,
|
||||
// mode portable AVX2, sm_75/80/86/89/120 : RTX 20 à 50. Un moteur installé d'un
|
||||
// autre paquet est remplacé au lancement (strataEnsureEngine).
|
||||
// autre paquet est remplacé au lancement (strataEnsureEngine), une installation
|
||||
// d'une version précédente est reprise (strataMigrate).
|
||||
const (
|
||||
strataVersion = "1.0"
|
||||
strataVersion = "1.1"
|
||||
strataSrcAsset = "ajean-moe-src-" + strataVersion + ".tar.gz"
|
||||
strataEngineAsset = "ajean-moe-engine-" + strataVersion + "-linux-x64-cuda12.zip"
|
||||
// strataEngineLocal : le nom sous lequel l'installeur attend le moteur (CUDA12_ASSET).
|
||||
@@ -69,10 +72,11 @@ var (
|
||||
strataDiskFree = diskFree
|
||||
)
|
||||
|
||||
// strataAssetSHA : empreintes des fichiers de la release, vérifiées avant usage.
|
||||
// strataAssetSHA : empreintes des fichiers de la release, vérifiées avant usage
|
||||
// (celles qu'AJEAN publie dans son backend_moe.go pour moe-v1.1).
|
||||
var strataAssetSHA = map[string]string{
|
||||
strataSrcAsset: "5747f69426973d957e4f9894a8e3333c6ef0d6ac8565e26ddb8d36f52211cb3b",
|
||||
strataEngineAsset: "b5851bc138ab32ca3ffefc16bf91c8f557e4d503503473a22f2b6c9e9b8fd34c",
|
||||
strataSrcAsset: "5a72a90f2182ff0ccab2bdbd7cff15598a21ad94501aa19de2b1b18e2dbc8115",
|
||||
strataEngineAsset: "8c717206ee90120f967445169dc39bb11beffdad3d592c75ae08f75bec72f0d3",
|
||||
}
|
||||
|
||||
// Valeurs de la clé ENGINE d'un preset.
|
||||
@@ -132,8 +136,14 @@ const strataLowRAMHeadroomGB = 10
|
||||
|
||||
// strataDropHeadroomGB : RAM à laisser à côté des experts verrouillés quand la
|
||||
// carte d'aide garde les siens hors RAM (mesuré par AJEAN : 46 Go de RAM,
|
||||
// 34,7 Gio d'experts verrouillés, ~2,3 Go encore libres).
|
||||
const strataDropHeadroomGB = 7
|
||||
// 34,7 Gio d'experts verrouillés, ~2,3 Go encore libres ; le moteur 1.1 prend
|
||||
// ~1 Go de plus que la 1.0).
|
||||
const strataDropHeadroomGB = 8
|
||||
|
||||
// strataDropMaxCtx : contexte maximal en mode hors RAM. Le cache KV au-delà de
|
||||
// la part en VRAM est verrouillé en RAM : à 167 936 jetons, le serveur de test
|
||||
// d'AJEAN tombait à 1,3 Go libres dès la première requête (moteur 1.1).
|
||||
const strataDropMaxCtx = 131072
|
||||
|
||||
// strataHelperExpertsGB : ce que la carte d'aide garde d'experts (sa VRAM moins
|
||||
// le contexte CUDA, l'encodeur d'images et la marge : 5,3 Gio sur une 3070 8 Go).
|
||||
@@ -210,7 +220,9 @@ func strataPackHasExpertsBin(args []string) bool {
|
||||
// Pure : la décision se teste en table.
|
||||
func strataResolveExperts(cfg map[string]string, args []string, availGB, helperGB float64) string {
|
||||
binReady := strataPackHasExpertsBin(args) && strataEngineInstalled() == strataEngineAsset
|
||||
dropReady := binReady && strataHelperOn(cfg) && cfg["STRATA_DROP"] != "0"
|
||||
// (experts de la carte d'aide hors RAM : propre au mode d'aide, jamais en
|
||||
// partage des couches, où la seconde carte ne garde pas d'experts)
|
||||
dropReady := binReady && strataHelperOn(cfg) && !strataLayerSplit(cfg) && cfg["STRATA_DROP"] != "0"
|
||||
arena := strataArenaGB(cfg)
|
||||
switch strings.ToLower(strings.TrimSpace(cfg["STRATA_EXPERTS"])) {
|
||||
case strataExpertsDisk:
|
||||
@@ -494,6 +506,7 @@ type strataDetails struct {
|
||||
// pour les réglages : ce que la machine permet et l'état actuel
|
||||
HasHelper bool `json:"has_helper"` // une carte d'aide est disponible
|
||||
HelperOn bool `json:"helper_on"`
|
||||
Split bool `json:"split_layers"` // les deux cartes se partagent les couches
|
||||
Vision bool `json:"vision"`
|
||||
}
|
||||
|
||||
@@ -555,6 +568,7 @@ func strataDetailsFor(presetID string, env strataEnv) *strataDetails {
|
||||
if strataHelperOn(pc) {
|
||||
d.HelperOn = true
|
||||
d.HelperGPU = gpuName(h)
|
||||
d.Split = strataLayerSplit(pc)
|
||||
}
|
||||
}
|
||||
d.Vision = pc["STRATA_VISION"] != "0"
|
||||
@@ -870,7 +884,8 @@ func strataFindConfig(quant string) (string, error) {
|
||||
//
|
||||
// - --prefill 16384 : +15 % de lecture sur les longs prompts (40K) ;
|
||||
// - --short-read 150 : les petits ajouts (< 150 jetons) sont lus comme en
|
||||
// génération, sans le coût fixe du chemin par blocs : ~40 % plus rapides ;
|
||||
// génération, sans le coût fixe du chemin par blocs : ~40 % plus rapides
|
||||
// (48 avec une carte d'aide, voir strataBuildConfig) ;
|
||||
// - --prompt-cache-root 256 : point de reprise posé à la fin du prompt système
|
||||
// (défaut 2048) : une nouvelle conversation démarre en ~0,3 s au lieu de 4-5 s.
|
||||
var strataTuning = map[string]string{
|
||||
@@ -957,6 +972,16 @@ func strataBuildConfig(base map[string]any, cfg map[string]string, apiKey, mode
|
||||
}
|
||||
helper := strings.TrimSpace(cfg["STRATA_HELPER_GPU"])
|
||||
hasHelper := strataHelperOn(cfg)
|
||||
if strataLayerSplit(cfg) {
|
||||
// Partage des couches : le serveur de Strata le fait de lui-même quand sa
|
||||
// clé « gpu » liste plusieurs cartes (--layer-split auto, réparti selon la
|
||||
// VRAM libre), numérotées comme nvidia-smi. Pas de chemin « aide » : la
|
||||
// réserve de VRAM de l'installeur et l'encodeur d'images restent en place.
|
||||
main, _ := strconv.Atoi(strings.TrimSpace(cfg["STRATA_MAIN_GPU"]))
|
||||
second, _ := strconv.Atoi(helper)
|
||||
out["gpu"] = []any{main, second}
|
||||
hasHelper = false
|
||||
}
|
||||
// Deuxième carte en aide : elle garde et CALCULE sa part des experts (le
|
||||
// chemin « helper » du moteur), la carte principale reste CUDA0. Les cartes
|
||||
// sont posées par CUDA_VISIBLE_DEVICES (serveStrata), PAS par la clé « gpu » :
|
||||
@@ -1002,6 +1027,10 @@ func strataBuildConfig(base map[string]any, cfg map[string]string, apiKey, mode
|
||||
// de copie : 8 fils et 32 tampons (le moteur en prendrait 32 et 128)
|
||||
env["STRATA_STAGER_THREADS"] = "8"
|
||||
env["STRATA_STAGER_RING"] = "32"
|
||||
// sa VRAM libre réservée : 640 Mio (défaut 1024) = ~230 experts de plus
|
||||
// hors RAM, l'encodeur d'images y tourne encore ; le moteur refuse moins
|
||||
// de 512
|
||||
env["STRATA_REMOTE_DROP_RESERVE_MIB"] = "640"
|
||||
case "arena":
|
||||
// experts.bin mappé tel quel (variante mesurée par AJEAN), sans part PCIe :
|
||||
// le mappage n'en donne pas l'adresse GPU.
|
||||
@@ -1017,8 +1046,22 @@ func strataBuildConfig(base map[string]any, cfg map[string]string, apiKey, mode
|
||||
k, v, _ := strings.Cut(kv, "=")
|
||||
env[k] = v
|
||||
}
|
||||
if hasHelper {
|
||||
// Lecture d'un prompt (moteur 1.1) : les experts que la carte d'aide garde
|
||||
// sont calculés par elle au lieu d'être recopiés vers la principale par son
|
||||
// lien PCIe (le chemin passe par la source « helper ») ; le chemin par blocs
|
||||
// coûte alors moins cher, les ajouts courts n'en sont exclus que sous 48
|
||||
// jetons. Mesuré par AJEAN : 100 nouveaux jetons 1,19 -> 0,85 s, 200 :
|
||||
// 1,96 -> 1,12 s.
|
||||
env["STRATA_PREFILL_HELPER_SOURCE"] = "1"
|
||||
args = strataSetArg(args, "--short-read", "48")
|
||||
}
|
||||
out["env"] = env
|
||||
if ctx := strings.TrimSpace(cfg["CTX"]); ctx != "" {
|
||||
// hors RAM, le cache KV verrouillé au-delà de strataDropMaxCtx ne tient plus
|
||||
if n, err := strconv.Atoi(ctx); err == nil && n > strataDropMaxCtx && env["STRATA_REMOTE_DROP"] == "1" {
|
||||
ctx = strconv.Itoa(strataDropMaxCtx)
|
||||
}
|
||||
args = strataSetArg(args, "--max-context", ctx)
|
||||
}
|
||||
// réglages imposés par le preset (sinon : ceux de l'installeur)
|
||||
@@ -1162,6 +1205,16 @@ func strataHelperOn(cfg map[string]string) bool {
|
||||
return h != "" && h != "-1" && cfg["STRATA_HELPER"] != "0"
|
||||
}
|
||||
|
||||
// strataLayerSplit : les deux cartes se partagent les COUCHES du modèle (clé de
|
||||
// preset STRATA_SPLIT=layers) au lieu que la seconde n'aide que pour une part
|
||||
// des experts. Mesuré par un utilisateur d'AJEAN sur deux RTX 3060 identiques
|
||||
// (#121 amont) : la carte d'aide ne travaillait qu'à ~15 %, le partage des
|
||||
// couches donnait +50 % en génération. Sur deux cartes très différentes, le
|
||||
// mode d'aide reste le défaut mesuré : le partage est un choix, jamais imposé.
|
||||
func strataLayerSplit(cfg map[string]string) bool {
|
||||
return strataHelperOn(cfg) && cfg["STRATA_SPLIT"] == "layers"
|
||||
}
|
||||
|
||||
// strataCudaDevices : ordre des cartes vu par le moteur, la principale en CUDA0.
|
||||
func strataCudaDevices(cfg map[string]string) string {
|
||||
main := strings.TrimSpace(cfg["STRATA_MAIN_GPU"])
|
||||
@@ -1184,17 +1237,99 @@ func strataPreflight(cfg map[string]string) error {
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
return fmt.Errorf("configuration de Strata introuvable : %s — réinstalle le modèle", path)
|
||||
}
|
||||
py := filepath.Join(filepath.Dir(path), ".venv", "bin", "python")
|
||||
py := strataVenvPython(filepath.Dir(path))
|
||||
if _, err := os.Stat(py); err != nil {
|
||||
return fmt.Errorf("environnement Python de Strata absent (%s) : réinstalle le modèle", py)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// strataVenvPython : le Python de l'environnement de Strata, dans ses sources.
|
||||
func strataVenvPython(srcDir string) string { return filepath.Join(srcDir, ".venv", "bin", "python") }
|
||||
|
||||
// strataMigrate : un preset installé par une version précédente du paquet
|
||||
// (STRATA_CONFIG dans strataHome()/ajean-moe-<ancienne>) passe à la version
|
||||
// courante au lancement, sans réinstaller : les modèles et packs (data/)
|
||||
// servent tels quels. Les sources de la version courante sont posées (~340 Mo
|
||||
// avec le moteur), l'environnement Python de l'ancienne est repris par un lien
|
||||
// quand les dépendances n'ont pas changé, le moteur compilé est installé par
|
||||
// strataEnsureEngine, la config de l'installeur est recopiée avec ses chemins
|
||||
// réécrits, et les presets qui la citaient comme la config vivante pointent sur
|
||||
// la nouvelle. En cas d'échec rien n'est réécrit : l'ancienne version continue
|
||||
// de tourner. Repris d'AJEAN (moeMigrate, AJEAN MoE 1.1).
|
||||
func strataMigrate(cfg map[string]string) error {
|
||||
old := strings.TrimSpace(cfg["STRATA_CONFIG"])
|
||||
oldDir := filepath.Dir(old)
|
||||
newDir := strataSrcDir()
|
||||
if old == "" || oldDir == newDir || filepath.Dir(oldDir) != strataHome() || !strings.HasPrefix(filepath.Base(oldDir), "ajean-moe-") {
|
||||
return nil
|
||||
}
|
||||
newPath := filepath.Join(newDir, filepath.Base(old))
|
||||
if !isFile(newPath) {
|
||||
raw, err := os.ReadFile(old)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !isFile(filepath.Join(newDir, "setup.py")) {
|
||||
src, err := strataFetch(strataSrcAsset)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := extractArchive(src, strataHome()); err != nil {
|
||||
return fmt.Errorf("décompression : %w", err)
|
||||
}
|
||||
}
|
||||
venv := filepath.Join(newDir, ".venv")
|
||||
if _, err := os.Lstat(venv); err != nil {
|
||||
a, _ := os.ReadFile(filepath.Join(oldDir, "requirements.txt"))
|
||||
b, _ := os.ReadFile(filepath.Join(newDir, "requirements.txt"))
|
||||
if len(a) == 0 || string(a) != string(b) || !isFile(strataVenvPython(oldDir)) {
|
||||
return fmt.Errorf("dépendances Python changées : réinstalle le modèle depuis Paramètres → Moteur → Strata")
|
||||
}
|
||||
if err := os.Symlink(filepath.Join(oldDir, ".venv"), venv); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(newDir, "engine-cuda12"), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := strataEnsureEngine(); err != nil {
|
||||
return err
|
||||
}
|
||||
body := strings.ReplaceAll(string(raw), oldDir+string(os.PathSeparator), newDir+string(os.PathSeparator))
|
||||
if err := os.WriteFile(newPath+".new", []byte(body), 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(newPath+".new", newPath); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
// les presets qui citent l'ancienne config, puis la config vivante (les deux
|
||||
// changent ensemble : l'empreinte du preset actif reste celle de la config)
|
||||
if list, _ := filepath.Glob(filepath.Join(presetsDir(), "*.env")); len(list) > 0 {
|
||||
for _, p := range list {
|
||||
b, err := os.ReadFile(p)
|
||||
if err != nil || !strings.Contains(string(b), "STRATA_CONFIG="+old+"\n") {
|
||||
continue
|
||||
}
|
||||
_ = os.WriteFile(p, []byte(strings.ReplaceAll(string(b), "STRATA_CONFIG="+old+"\n", "STRATA_CONFIG="+newPath+"\n")), 0o644)
|
||||
}
|
||||
}
|
||||
if err := SetConfigKey("STRATA_CONFIG", newPath); err != nil {
|
||||
return err
|
||||
}
|
||||
cfg["STRATA_CONFIG"] = newPath
|
||||
fmt.Fprintf(os.Stderr, "[loki serve] paquet AJEAN MoE %s : installation reprise depuis %s\n", strataVersion, filepath.Base(oldDir))
|
||||
return nil
|
||||
}
|
||||
|
||||
// serveStrata : la branche Strata de `loki serve`. Écrit la config finale à
|
||||
// côté de celle de l'installeur puis remplace le process par le serveur de
|
||||
// Strata (comme pour llama-server : le superviseur suit directement le moteur).
|
||||
func serveStrata(cfg map[string]string) error {
|
||||
if err := strataMigrate(cfg); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "[loki serve] passage au paquet AJEAN MoE %s impossible (%v) : la version installée reste utilisée\n", strataVersion, err)
|
||||
}
|
||||
if err := strataPreflight(cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -1223,7 +1358,7 @@ func serveStrata(cfg map[string]string) error {
|
||||
return err
|
||||
}
|
||||
srcDir := filepath.Dir(path)
|
||||
py := filepath.Join(srcDir, ".venv", "bin", "python")
|
||||
py := strataVenvPython(srcDir)
|
||||
tag := strataConfigTag(path)
|
||||
final["log"] = filepath.Join(strataHome(), "strata-"+tag+".log")
|
||||
out := filepath.Join(strataHome(), "loki-strata-"+tag+".json")
|
||||
@@ -1326,6 +1461,9 @@ type strataSettingsReq struct {
|
||||
Vision bool `json:"vision"`
|
||||
Helper bool `json:"helper"`
|
||||
Experts string `json:"experts"` // auto, ram ou disk ; vide = inchangé
|
||||
// Split : rôle de la seconde carte, « helper » (aide aux experts) ou
|
||||
// « layers » (partage des couches). Vide = inchangé (anciens clients).
|
||||
Split string `json:"split"`
|
||||
}
|
||||
|
||||
// strataApplySettings réécrit les clés du preset (pure, testable).
|
||||
@@ -1340,6 +1478,8 @@ func strataApplySettings(content string, r strataSettingsReq) (string, error) {
|
||||
return "", fmt.Errorf("MTP hors limites : %d", r.Spec)
|
||||
case r.Experts != "" && r.Experts != strataExpertsAuto && r.Experts != strataExpertsRAM && r.Experts != strataExpertsDisk:
|
||||
return "", fmt.Errorf("placement des experts inconnu : %s", r.Experts)
|
||||
case r.Split != "" && r.Split != "helper" && r.Split != "layers":
|
||||
return "", fmt.Errorf("répartition inconnue : %s", r.Split)
|
||||
}
|
||||
set := map[string]string{
|
||||
"CTX": strconv.Itoa(r.Ctx), "STRATA_KV": r.KV, "STRATA_SPEC": strconv.Itoa(r.Spec),
|
||||
@@ -1350,6 +1490,14 @@ func strataApplySettings(content string, r strataSettingsReq) (string, error) {
|
||||
set["STRATA_EXPERTS"] = r.Experts
|
||||
keys = append(keys, "STRATA_EXPERTS")
|
||||
}
|
||||
if r.Split != "" {
|
||||
// « helper » est le défaut : la clé disparaît plutôt que d'être écrite
|
||||
set["STRATA_SPLIT"] = ""
|
||||
if r.Split == "layers" {
|
||||
set["STRATA_SPLIT"] = "layers"
|
||||
}
|
||||
keys = append(keys, "STRATA_SPLIT")
|
||||
}
|
||||
var lines []string
|
||||
for _, l := range strings.Split(strings.TrimRight(content, "\n"), "\n") {
|
||||
k, _, _ := strings.Cut(strings.TrimSpace(l), "=")
|
||||
@@ -1359,6 +1507,9 @@ func strataApplySettings(content string, r strataSettingsReq) (string, error) {
|
||||
lines = append(lines, l)
|
||||
}
|
||||
for _, k := range keys {
|
||||
if set[k] == "" && k == "STRATA_SPLIT" {
|
||||
continue
|
||||
}
|
||||
lines = append(lines, k+"="+set[k])
|
||||
}
|
||||
return strings.Join(lines, "\n") + "\n", nil
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Moteur 1.1 : avec une carte d'aide, la lecture d'un prompt passe par elle
|
||||
// (source « helper ») et le chemin direct ne garde que les ajouts sous 48 jetons.
|
||||
func TestStrataBuildConfigHelperPrompt(t *testing.T) {
|
||||
base := map[string]any{"args": []any{"--pack", "/p"}}
|
||||
out, _ := strataBuildConfig(base, map[string]string{"STRATA_HELPER_GPU": "0"}, "", "ram")
|
||||
if argAfter(strataArgs(t, out), "--short-read") != "48" || out["env"].(map[string]any)["STRATA_PREFILL_HELPER_SOURCE"] != "1" {
|
||||
t.Errorf("carte d'aide : lecture courte et source d'aide attendues : %v %v", strataArgs(t, out), out["env"])
|
||||
}
|
||||
// carte d'aide coupée ou absente : réglages d'avant
|
||||
for _, cfg := range []map[string]string{{}, {"STRATA_HELPER_GPU": "0", "STRATA_HELPER": "0"}} {
|
||||
out, _ = strataBuildConfig(base, cfg, "", "ram")
|
||||
if argAfter(strataArgs(t, out), "--short-read") != "150" || out["env"].(map[string]any)["STRATA_PREFILL_HELPER_SOURCE"] != nil {
|
||||
t.Errorf("une carte (%v) : %v %v", cfg, strataArgs(t, out), out["env"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mode hors RAM : réserve de VRAM de la carte d'aide abaissée, contexte plafonné.
|
||||
func TestStrataBuildConfigDropReserveAndCtx(t *testing.T) {
|
||||
base := map[string]any{"args": []any{"--pack", "/p"}, "env": map[string]any{}}
|
||||
cfg := map[string]string{"STRATA_HELPER_GPU": "0", "CTX": "167936"}
|
||||
out, _ := strataBuildConfig(base, cfg, "", "drop")
|
||||
if out["env"].(map[string]any)["STRATA_REMOTE_DROP_RESERVE_MIB"] != "640" {
|
||||
t.Errorf("réserve de la carte d'aide : %v", out["env"])
|
||||
}
|
||||
if got := argAfter(strataArgs(t, out), "--max-context"); got != "131072" {
|
||||
t.Errorf("contexte hors RAM non plafonné : %s", got)
|
||||
}
|
||||
// hors du mode hors RAM : le contexte demandé
|
||||
for _, mode := range []string{"ram", "arena", "disk"} {
|
||||
out, _ = strataBuildConfig(base, cfg, "", mode)
|
||||
if got := argAfter(strataArgs(t, out), "--max-context"); got != "167936" {
|
||||
t.Errorf("%s : contexte plafonné sans mode hors RAM : %s", mode, got)
|
||||
}
|
||||
if out["env"].(map[string]any)["STRATA_REMOTE_DROP_RESERVE_MIB"] != nil {
|
||||
t.Errorf("%s : réserve du mode hors RAM posée : %v", mode, out["env"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Un modèle installé avec le paquet 1.0 passe au paquet courant à son premier
|
||||
// lancement, sans réinstallation.
|
||||
func TestStrataMigrate(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("lien symbolique : Strata ne tourne que sous Linux")
|
||||
}
|
||||
home := t.TempDir()
|
||||
t.Setenv("LOKI_HOME", home)
|
||||
oldDir := filepath.Join(strataHome(), "ajean-moe-1.0")
|
||||
newDir := strataSrcDir()
|
||||
for _, d := range []string{filepath.Join(oldDir, ".venv", "bin"), filepath.Join(newDir, "engine-cuda12"), presetsDir()} {
|
||||
_ = os.MkdirAll(d, 0o755)
|
||||
}
|
||||
_ = os.WriteFile(strataVenvPython(oldDir), []byte("#!/bin/sh\n"), 0o755)
|
||||
for _, d := range []string{oldDir, newDir} {
|
||||
_ = os.WriteFile(filepath.Join(d, "requirements.txt"), []byte("numpy\n"), 0o644)
|
||||
}
|
||||
_ = os.WriteFile(filepath.Join(newDir, "setup.py"), []byte(""), 0o644)
|
||||
_ = os.WriteFile(strataEngineMarker(), []byte(strataEngineAsset+"\n"), 0o644)
|
||||
old := filepath.Join(oldDir, "strata-swift-iq3_xxs.json")
|
||||
_ = os.WriteFile(old, []byte(`{"exe": "`+filepath.Join(oldDir, "engine-cuda12", "strata")+`", "args": []}`), 0o644)
|
||||
preset := filepath.Join(presetsDir(), "P.env")
|
||||
_ = os.WriteFile(preset, []byte("ENGINE=strata\nSTRATA_CONFIG="+old+"\nCTX=1\n"), 0o644)
|
||||
other := filepath.Join(presetsDir(), "L.env")
|
||||
_ = os.WriteFile(other, []byte("MODEL=/m.gguf\n"), 0o644)
|
||||
if err := SetConfigKey("STRATA_CONFIG", old); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg := map[string]string{"ENGINE": "strata", "STRATA_CONFIG": old}
|
||||
if err := strataMigrate(cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := filepath.Join(newDir, "strata-swift-iq3_xxs.json")
|
||||
if cfg["STRATA_CONFIG"] != want || ReadConfig()["STRATA_CONFIG"] != want {
|
||||
t.Fatalf("config vivante : %q", cfg["STRATA_CONFIG"])
|
||||
}
|
||||
b, _ := os.ReadFile(want)
|
||||
if strings.Contains(string(b), oldDir) || !strings.Contains(string(b), filepath.Join(newDir, "engine-cuda12")) {
|
||||
t.Errorf("chemins non réécrits : %s", b)
|
||||
}
|
||||
if p, _ := os.ReadFile(preset); !strings.Contains(string(p), "STRATA_CONFIG="+want+"\n") {
|
||||
t.Errorf("preset non réécrit : %s", p)
|
||||
}
|
||||
if p, _ := os.ReadFile(other); string(p) != "MODEL=/m.gguf\n" {
|
||||
t.Errorf("preset d'un autre moteur touché : %s", p)
|
||||
}
|
||||
if !isFile(strataVenvPython(newDir)) {
|
||||
t.Error("environnement Python non repris")
|
||||
}
|
||||
// déjà à jour : rien à faire
|
||||
if err := strataMigrate(cfg); err != nil || cfg["STRATA_CONFIG"] != want {
|
||||
t.Errorf("seconde passe : %v %q", err, cfg["STRATA_CONFIG"])
|
||||
}
|
||||
}
|
||||
|
||||
// Dépendances Python changées : rien n'est réécrit, l'ancienne version tourne.
|
||||
func TestStrataMigrateRequirementsChanged(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("LOKI_HOME", home)
|
||||
oldDir := filepath.Join(strataHome(), "ajean-moe-1.0")
|
||||
newDir := strataSrcDir()
|
||||
for _, d := range []string{filepath.Join(oldDir, ".venv", "bin"), newDir} {
|
||||
_ = os.MkdirAll(d, 0o755)
|
||||
}
|
||||
_ = os.WriteFile(strataVenvPython(oldDir), []byte("#!/bin/sh\n"), 0o755)
|
||||
_ = os.WriteFile(filepath.Join(oldDir, "requirements.txt"), []byte("numpy\n"), 0o644)
|
||||
_ = os.WriteFile(filepath.Join(newDir, "requirements.txt"), []byte("numpy\ntorch\n"), 0o644)
|
||||
_ = os.WriteFile(filepath.Join(newDir, "setup.py"), []byte(""), 0o644)
|
||||
old := filepath.Join(oldDir, "strata-iq3_xxs.json")
|
||||
_ = os.WriteFile(old, []byte(`{"args": []}`), 0o644)
|
||||
cfg := map[string]string{"STRATA_CONFIG": old}
|
||||
if err := strataMigrate(cfg); err == nil || !strings.Contains(err.Error(), "dépendances") {
|
||||
t.Fatalf("erreur de dépendances attendue : %v", err)
|
||||
}
|
||||
if cfg["STRATA_CONFIG"] != old || isFile(filepath.Join(newDir, "strata-iq3_xxs.json")) {
|
||||
t.Errorf("config réécrite malgré l'échec : %q", cfg["STRATA_CONFIG"])
|
||||
}
|
||||
// un preset hors de strata/ (copié à la main) n'est pas touché
|
||||
cfg = map[string]string{"STRATA_CONFIG": filepath.Join(home, "ailleurs", "ajean-moe-1.0", "strata-x.json")}
|
||||
if err := strataMigrate(cfg); err != nil {
|
||||
t.Errorf("config hors de strata/ : %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Partage des couches (#121 amont) : la clé « gpu » liste les deux cartes (le
|
||||
// serveur de Strata en déduit --layer-split), et rien du mode d'aide ne subsiste.
|
||||
func TestStrataBuildConfigLayerSplit(t *testing.T) {
|
||||
base := map[string]any{
|
||||
"args": []any{"--pack", "/p", "--vram-reserve-mib", "900"},
|
||||
"vision": map[string]any{"exe": "/v"},
|
||||
}
|
||||
cfg := map[string]string{"STRATA_MAIN_GPU": "1", "STRATA_HELPER_GPU": "0", "STRATA_SPLIT": "layers"}
|
||||
out, err := strataBuildConfig(base, cfg, "", "drop")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
g, ok := out["gpu"].([]any)
|
||||
if !ok || len(g) != 2 || g[0] != 1 || g[1] != 0 {
|
||||
t.Fatalf("gpu = %v, attendu [1 0] (principale d'abord, numéros nvidia-smi)", out["gpu"])
|
||||
}
|
||||
a := strataArgs(t, out)
|
||||
if strataHasArg(a, "--remote-expert-opt") || strataHasArg(a, "--expert-cache-device1") {
|
||||
t.Errorf("options du mode d'aide en partage des couches : %v", a)
|
||||
}
|
||||
if argAfter(a, "--vram-reserve-mib") != "900" {
|
||||
t.Errorf("la réserve de VRAM de l'installeur doit rester : %v", a)
|
||||
}
|
||||
if argAfter(a, "--short-read") != "150" {
|
||||
t.Errorf("lecture des prompts par la carte d'aide en partage des couches : %v", a)
|
||||
}
|
||||
env := out["env"].(map[string]any)
|
||||
if env["STRATA_REMOTE_DROP"] != nil || env["STRATA_PREFILL_HELPER_SOURCE"] != nil {
|
||||
t.Errorf("experts hors RAM ou source d'aide (mode d'aide) en partage des couches : %v", env)
|
||||
}
|
||||
if _, has := out["vision"].(map[string]any)["cuda_device"]; has {
|
||||
t.Errorf("encodeur d'images déplacé sur la seconde carte en partage des couches")
|
||||
}
|
||||
|
||||
// Sans la clé, rien ne change : le mode d'aide reste le défaut.
|
||||
delete(cfg, "STRATA_SPLIT")
|
||||
out, _ = strataBuildConfig(base, cfg, "", "drop")
|
||||
if _, has := out["gpu"]; has {
|
||||
t.Errorf("clé gpu posée en mode d'aide : %v", out["gpu"])
|
||||
}
|
||||
if !strataHasArg(strataArgs(t, out), "--remote-expert-opt") {
|
||||
t.Errorf("mode d'aide perdu sans STRATA_SPLIT")
|
||||
}
|
||||
// Seconde carte coupée : pas de partage même si la clé traîne.
|
||||
cfg["STRATA_SPLIT"], cfg["STRATA_HELPER"] = "layers", "0"
|
||||
if strataLayerSplit(cfg) {
|
||||
t.Errorf("partage des couches avec la seconde carte coupée")
|
||||
}
|
||||
}
|
||||
|
||||
// En partage des couches, la seconde carte ne garde pas d'experts : le mode
|
||||
// hors RAM (qui compte sur elle pour en loger une part) n'est jamais choisi.
|
||||
func TestStrataResolveExpertsLayerSplit(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("LOKI_HOME", home)
|
||||
pack := filepath.Join(home, "pack")
|
||||
_ = os.MkdirAll(pack, 0o755)
|
||||
_ = os.WriteFile(filepath.Join(pack, "experts.bin"), []byte("x"), 0o644)
|
||||
_ = os.MkdirAll(filepath.Join(strataSrcDir(), "engine-cuda12"), 0o755)
|
||||
_ = os.WriteFile(strataEngineMarker(), []byte(strataEngineAsset+"\n"), 0o644)
|
||||
args := []string{"--pack", pack}
|
||||
cfg := map[string]string{"STRATA_CONFIG": "/x/strata-swift-iq3_xxs.json", "STRATA_HELPER_GPU": "1", "STRATA_DROP": "1"}
|
||||
if got := strataResolveExperts(cfg, args, 44, 12); got != "drop" {
|
||||
t.Fatalf("mode d'aide : %s, attendu drop", got)
|
||||
}
|
||||
cfg["STRATA_SPLIT"] = "layers"
|
||||
if got := strataResolveExperts(cfg, args, 44, 12); got != "arena" {
|
||||
t.Errorf("partage des couches : %s, attendu arena", got)
|
||||
}
|
||||
cfg["STRATA_EXPERTS"] = "ram"
|
||||
if got := strataResolveExperts(cfg, args, 44, 12); got != "ram" {
|
||||
t.Errorf("partage des couches, experts en RAM : %s, attendu ram", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrataApplySettingsSplit(t *testing.T) {
|
||||
in := "ENGINE=strata\nSTRATA_HELPER_GPU=0\n"
|
||||
req := strataSettingsReq{Ctx: 131072, KV: "fp16", Spec: 3, Vision: true, Helper: true, Split: "layers"}
|
||||
out, err := strataApplySettings(in, req)
|
||||
if err != nil || !strings.Contains(out, "STRATA_SPLIT=layers\n") {
|
||||
t.Fatalf("STRATA_SPLIT=layers attendu : %q (%v)", out, err)
|
||||
}
|
||||
req.Split = "helper"
|
||||
if out, _ = strataApplySettings(out, req); strings.Contains(out, "STRATA_SPLIT") {
|
||||
t.Errorf("retour au mode d'aide : la clé doit disparaître : %q", out)
|
||||
}
|
||||
// un ancien client n'envoie pas « split » : la clé est conservée
|
||||
req.Split = ""
|
||||
out, _ = strataApplySettings("STRATA_SPLIT=layers\n", req)
|
||||
if !strings.Contains(out, "STRATA_SPLIT=layers") {
|
||||
t.Errorf("clé effacée par un client qui ne la connaît pas : %q", out)
|
||||
}
|
||||
req.Split = "nimporte"
|
||||
if _, err := strataApplySettings(in, req); err == nil {
|
||||
t.Errorf("répartition inconnue acceptée")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrataDetailsForLayerSplit(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("LOKI_HOME", home)
|
||||
base := filepath.Join(home, "base.json")
|
||||
_ = os.WriteFile(base, []byte(`{"args":["--pack","/p"],"vision":{"exe":"v"}}`), 0o644)
|
||||
_ = os.MkdirAll(presetsDir(), 0o755)
|
||||
preset := "ENGINE=strata\nSTRATA_CONFIG=" + base + "\nSTRATA_MAIN_GPU=1\nSTRATA_HELPER_GPU=0\nSTRATA_SPLIT=layers\n"
|
||||
_ = os.WriteFile(filepath.Join(presetsDir(), "X.env"), []byte(preset), 0o644)
|
||||
env := strataEnv{GPUs: []strataGPU{{Index: 0, Name: "NVIDIA GeForce RTX 3060", VRAMGB: 12}, {Index: 1, Name: "NVIDIA GeForce RTX 3060", VRAMGB: 12}}}
|
||||
d := strataDetailsFor("X", env)
|
||||
if d == nil {
|
||||
t.Fatal("pas de détails")
|
||||
}
|
||||
if !d.Split || !d.HelperOn || d.HelperGPU == "" || d.VisionGPU != d.MainGPU || d.ShortRead != "150" {
|
||||
t.Errorf("%+v", *d)
|
||||
}
|
||||
}
|
||||
@@ -184,7 +184,7 @@ func TestStrataDetailsFor(t *testing.T) {
|
||||
}
|
||||
if d.KV != "fp16" || d.KVResident != "32768" || d.Spec != "3" || d.Ctx != "131072" || !d.Mmap ||
|
||||
d.MainGPU != "RTX 5060 Ti (16 Go)" || d.HelperGPU != "RTX 3070 (8 Go)" || d.VisionGPU != "RTX 3070 (8 Go)" ||
|
||||
d.ShortRead != "150" || d.CacheRoot != "256" || d.Prefill != "16384" {
|
||||
d.ShortRead != "48" || d.CacheRoot != "256" || d.Prefill != "16384" {
|
||||
t.Errorf("%+v", *d)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -242,6 +242,42 @@ func compactNeeded(msgs []Message, used, peakGen int) bool {
|
||||
return compactEnabled() && genHeadroomShort(used, peakGen, ctxWindow())
|
||||
}
|
||||
|
||||
// compactRetryDue : une compaction en cours de tour peut-elle être tentée ?
|
||||
// Après un essai qui n'a rien réduit (résumé raté, réduction sous 20 %),
|
||||
// chaque étape suivante relançait un résumé complet voué au même échec : plus
|
||||
// d'une heure de tentatives à vide, une par étape, sur une longue tâche (AJEAN
|
||||
// 68659e73). compactRefusedSkip ne couvre que la discussion principale avec
|
||||
// COMPACT_CONTINUATION ; ce garde-fou vaut pour tout tour, tâches et
|
||||
// sous-agents compris. Jamais à 90 % de la fenêtre ou plus : là, essayer
|
||||
// encore vaut mieux que déborder.
|
||||
func compactRetryDue(retryAt, used int, msgs []Message) bool {
|
||||
if retryAt <= 0 {
|
||||
return true
|
||||
}
|
||||
est := compactUsedOrEstimate(used, msgs)
|
||||
return est >= retryAt || est*10 >= ctxWindow()*9
|
||||
}
|
||||
|
||||
// compactNextRetry : seuil du prochain essai après une compaction en cours de
|
||||
// tour. 0 (aucun) si elle a réduit ; sinon le contexte d'aujourd'hui plus 5 %
|
||||
// de la fenêtre.
|
||||
func compactNextRetry(changed bool, used int, msgs []Message) int {
|
||||
if changed {
|
||||
return 0
|
||||
}
|
||||
est := compactUsedOrEstimate(used, msgs)
|
||||
next := est + ctxWindow()/20
|
||||
fmt.Fprintf(os.Stderr, "[compact] rien réduit en cours de tour : prochain essai à %d jetons (contexte à %d)\n", next, est)
|
||||
return next
|
||||
}
|
||||
|
||||
func compactUsedOrEstimate(used int, msgs []Message) int {
|
||||
if used > 0 {
|
||||
return used
|
||||
}
|
||||
return estimateTokens(msgs)
|
||||
}
|
||||
|
||||
// genHeadroomShort : la fenêtre ne laisse plus la place d'une génération de
|
||||
// 1,2 × la plus longue récente (plancher de 8 k jetons, ramené à 1/8 de la
|
||||
// fenêtre pour les petites), plus une petite marge. Fonction pure, testable.
|
||||
@@ -437,18 +473,24 @@ func compactMessagesCore(ctx context.Context, msgs []Message, caps Caps, opt com
|
||||
var index []recallEntry
|
||||
if caps.Agent && compactEnabled() {
|
||||
names := torsoToolNames(torso)
|
||||
var blocks []recallArchiveInput
|
||||
var at []int // position dans le torse de chaque bloc
|
||||
for i, m := range torso {
|
||||
if !recallEligible(m) {
|
||||
continue
|
||||
}
|
||||
content := msgText(m)
|
||||
label := recallLabel(m.Role, names[m.ToolCallID], content)
|
||||
id, aerr := archiveRecallBlock(label, m.Role, content)
|
||||
if aerr != nil {
|
||||
continue
|
||||
blocks = append(blocks, recallArchiveInput{Label: recallLabel(m.Role, names[m.ToolCallID], content), Role: m.Role, Content: content})
|
||||
at = append(at, i)
|
||||
}
|
||||
// Une seule transaction pour tous les blocs (AJEAN #130).
|
||||
if entries, aerr := archiveRecallBlocks(blocks); aerr == nil {
|
||||
for k, e := range entries {
|
||||
archived[at[k]] = &recallEntry{id: e.id, label: e.label}
|
||||
index = append(index, e)
|
||||
}
|
||||
archived[i] = &recallEntry{id: id, label: label}
|
||||
index = append(index, recallEntry{id: id, label: label})
|
||||
} else if len(blocks) > 0 {
|
||||
fmt.Fprintf(os.Stderr, "[compact] archives recall non écrites (%v) : repli sans rappel\n", aerr)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -528,6 +570,14 @@ func compactMessagesCore(ctx context.Context, msgs []Message, caps Caps, opt com
|
||||
} else {
|
||||
summary, err = summarizeTranscriptFor(ctx, renderTranscript(forSummary), caps.Code)
|
||||
}
|
||||
// La cause d'un résumé raté n'était écrite nulle part : on ne voyait que des
|
||||
// compactions « changé=false » à répétition, sans savoir pourquoi (AJEAN
|
||||
// 68659e73).
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "[compact] résumé raté : %v\n", err)
|
||||
} else if summaryLooksEmpty(summary) {
|
||||
fmt.Fprintf(os.Stderr, "[compact] résumé vide ou dégénéré (%d car.)\n", len([]rune(strings.TrimSpace(summary))))
|
||||
}
|
||||
var mid []Message
|
||||
if err != nil || summaryLooksEmpty(summary) {
|
||||
// Résumé raté (erreur, vide, ou juste une référence recall) → on garde le
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
package loki
|
||||
|
||||
import "testing"
|
||||
|
||||
// Après une compaction en cours de tour qui n'a rien réduit, l'étape suivante
|
||||
// ne relance pas un résumé tant que le contexte n'a pas grossi de 5 % de la
|
||||
// fenêtre — sauf à 90 % de la fenêtre ou plus.
|
||||
func TestCompactRetryBackoff(t *testing.T) {
|
||||
testHome(t)
|
||||
if err := putStr(bkConfig, "CTX", "100000"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !compactRetryDue(0, 70000, nil) {
|
||||
t.Fatal("sans échec précédent, l'essai doit être permis")
|
||||
}
|
||||
next := compactNextRetry(false, 70000, nil)
|
||||
if next != 75000 {
|
||||
t.Fatalf("prochain essai à %d, attendu 75000", next)
|
||||
}
|
||||
if compactRetryDue(next, 72000, nil) {
|
||||
t.Fatal("relancé alors que le contexte n'a presque pas grossi")
|
||||
}
|
||||
if !compactRetryDue(next, 75000, nil) {
|
||||
t.Fatal("pas relancé alors que le contexte a grossi de 5 % de la fenêtre")
|
||||
}
|
||||
if !compactRetryDue(200000, 90000, nil) {
|
||||
t.Fatal("à 90 % de la fenêtre, l'essai doit toujours être permis")
|
||||
}
|
||||
if compactNextRetry(true, 70000, nil) != 0 {
|
||||
t.Fatal("une compaction réussie doit lever le garde-fou")
|
||||
}
|
||||
}
|
||||
@@ -78,58 +78,88 @@ func recallIDToSeq(id string) (uint64, bool) {
|
||||
return n, true
|
||||
}
|
||||
|
||||
// archiveRecallBlock enregistre un bloc et renvoie son id ("r<seq>"). L'id est
|
||||
// alloué par NextSequence dans la transaction d'écriture : monotone, sans
|
||||
// collision, même si deux compactages se croisent. En cas d'échec d'écriture
|
||||
// (base indisponible, ex. tests sans LOKI_HOME) on renvoie une erreur et
|
||||
// l'appelant se rabat sur le compactage classique (troncature sans id).
|
||||
func archiveRecallBlock(label, role, content string) (string, error) {
|
||||
// recallArchiveInput : un bloc à archiver (voir archiveRecallBlocks).
|
||||
type recallArchiveInput struct {
|
||||
Label string
|
||||
Role string
|
||||
Content string
|
||||
}
|
||||
|
||||
// archiveRecallBlocks enregistre tous les blocs d'une compaction dans UNE
|
||||
// transaction et renvoie leurs entrées, dans l'ordre des blocs fournis. Les ids
|
||||
// sont alloués par NextSequence dans la transaction : monotones, sans
|
||||
// collision, même si deux compactages se croisent. Une transaction par bloc
|
||||
// payait une ouverture de la base et une synchronisation disque chacune, sous
|
||||
// le verrou global de la base, en plein tour (AJEAN #130). Tout ou rien : en
|
||||
// cas d'échec (base indisponible, ex. tests sans LOKI_HOME, mémoire
|
||||
// verrouillée) aucun id n'est rendu, et l'appelant se rabat sur le compactage
|
||||
// classique (troncature sans id).
|
||||
func archiveRecallBlocks(blocks []recallArchiveInput) ([]recallEntry, error) {
|
||||
if len(blocks) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
// Résolu AVANT d'ouvrir la base : chiffrer dans la transaction relirait la
|
||||
// configuration, donc la base, qui n'est pas rentrante (voir memEncoderNow).
|
||||
encode, err := memEncoderNow()
|
||||
if err != nil {
|
||||
return "", err
|
||||
return nil, err
|
||||
}
|
||||
var id string
|
||||
now := time.Now().Unix()
|
||||
var entries []recallEntry
|
||||
err = withDB(func(d *bolt.DB) error {
|
||||
return d.Update(func(tx *bolt.Tx) error {
|
||||
entries = entries[:0]
|
||||
b, err := tx.CreateBucketIfNotExists([]byte(bkRecall))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
seq, err := b.NextSequence()
|
||||
if err != nil {
|
||||
return err
|
||||
for _, in := range blocks {
|
||||
seq, err := b.NextSequence()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
blk := recallBlock{
|
||||
ID: "r" + strconv.FormatUint(seq, 10),
|
||||
Seq: seq,
|
||||
Label: in.Label,
|
||||
Role: in.Role,
|
||||
Content: in.Content,
|
||||
Created: now,
|
||||
}
|
||||
raw, err := json.Marshal(blk)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Un bloc archivé, c'est du verbatim de conversation : chiffré
|
||||
// comme le reste quand la mémoire l'est (l'encodeur rend le clair
|
||||
// inchangé si le chiffrement est inactif ; verrouillé,
|
||||
// memEncoderNow a déjà refusé plus haut).
|
||||
enc, err := encode(raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := b.Put(recallKey(seq), enc); err != nil {
|
||||
return err
|
||||
}
|
||||
entries = append(entries, recallEntry{id: blk.ID, label: blk.Label})
|
||||
}
|
||||
blk := recallBlock{
|
||||
ID: "r" + strconv.FormatUint(seq, 10),
|
||||
Seq: seq,
|
||||
Label: label,
|
||||
Role: role,
|
||||
Content: content,
|
||||
Created: time.Now().Unix(),
|
||||
}
|
||||
id = blk.ID
|
||||
raw, err := json.Marshal(blk)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Un bloc archivé, c'est du verbatim de conversation : chiffré comme
|
||||
// le reste quand la mémoire l'est (l'encodeur rend le clair inchangé
|
||||
// si le chiffrement est inactif ; verrouillé, memEncoderNow a déjà
|
||||
// refusé plus haut).
|
||||
enc, err := encode(raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return b.Put(recallKey(seq), enc)
|
||||
return nil
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
return nil, err
|
||||
}
|
||||
cacheBust(bkRecall)
|
||||
return id, nil
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
// archiveRecallBlock enregistre un seul bloc et renvoie son id ("r<seq>").
|
||||
func archiveRecallBlock(label, role, content string) (string, error) {
|
||||
e, err := archiveRecallBlocks([]recallArchiveInput{{Label: label, Role: role, Content: content}})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return e[0].id, nil
|
||||
}
|
||||
|
||||
// recallGet ramène un bloc par son id. false si l'id est mal formé ou absent.
|
||||
|
||||
@@ -221,3 +221,47 @@ func TestCompactSummaryUserMsg(t *testing.T) {
|
||||
t.Fatalf("l'index des blocs rappelables est absent :\n%s", avec)
|
||||
}
|
||||
}
|
||||
|
||||
// Les blocs d'une compaction s'écrivent en une seule transaction : ids dans
|
||||
// l'ordre des blocs, croissants, chacun relisible mot pour mot.
|
||||
func TestArchiveRecallBlocksBatch(t *testing.T) {
|
||||
testHome(t)
|
||||
if e, err := archiveRecallBlocks(nil); err != nil || e != nil {
|
||||
t.Fatalf("lot vide : %v, %v", e, err)
|
||||
}
|
||||
in := []recallArchiveInput{
|
||||
{Label: "un", Role: "tool", Content: bigBlock("PREMIER")},
|
||||
{Label: "deux", Role: "user", Content: bigBlock("SECOND")},
|
||||
{Label: "trois", Role: "assistant", Content: bigBlock("TROISIEME")},
|
||||
}
|
||||
entries, err := archiveRecallBlocks(in)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(entries) != len(in) {
|
||||
t.Fatalf("%d entrées pour %d blocs", len(entries), len(in))
|
||||
}
|
||||
var last uint64
|
||||
for i, e := range entries {
|
||||
seq, ok := recallIDToSeq(e.id)
|
||||
if !ok || seq <= last {
|
||||
t.Fatalf("id %q non croissant (précédent %d)", e.id, last)
|
||||
}
|
||||
last = seq
|
||||
if e.label != in[i].Label {
|
||||
t.Fatalf("entrée %d : libellé %q, attendu %q", i, e.label, in[i].Label)
|
||||
}
|
||||
blk, ok := recallGet(e.id)
|
||||
if !ok || blk.Content != in[i].Content || blk.Role != in[i].Role {
|
||||
t.Fatalf("bloc %s mal relu : %+v", e.id, blk)
|
||||
}
|
||||
}
|
||||
// Un archivage isolé suit la même séquence.
|
||||
id, err := archiveRecallBlock("quatre", "tool", bigBlock("QUATRE"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if seq, _ := recallIDToSeq(id); seq != last+1 {
|
||||
t.Fatalf("id isolé %q, attendu r%d", id, last+1)
|
||||
}
|
||||
}
|
||||
@@ -41,11 +41,15 @@ func TestRunShellNeBloquePasSurUnProcessDetache(t *testing.T) {
|
||||
}
|
||||
testHome(t)
|
||||
start := time.Now()
|
||||
out := runShell(context.Background(), "sleep 30 & echo lance", 2)
|
||||
if d := time.Since(start); d > 10*time.Second {
|
||||
// Délai de 10 s, bien au-delà de WaitDelay (2 s) : c'est le détachement
|
||||
// qui rend la main, pas le délai de la commande.
|
||||
out := runShell(context.Background(), "sleep 30 & echo lance", 10)
|
||||
if d := time.Since(start); d > 8*time.Second {
|
||||
t.Fatalf("runShell est resté accroché aux tubes du process détaché (%s)", d)
|
||||
}
|
||||
if !strings.Contains(out, "lance") && !strings.Contains(out, "timeout") {
|
||||
// La commande a fini (exit 0) : sa sortie est rendue, pas effacée par
|
||||
// « WaitDelay expired » (AJEAN 7403a8a0).
|
||||
if !strings.Contains(out, "exit: 0") || !strings.Contains(out, "lance") || !strings.Contains(out, "arrière-plan") {
|
||||
t.Errorf("sortie inattendue : %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -297,6 +297,14 @@ func runShell(parent context.Context, command string, timeoutSec int) string {
|
||||
// issue connue était de redémarrer loki-ui.
|
||||
cmd.WaitDelay = 2 * time.Second
|
||||
err := cmd.Run()
|
||||
// ErrWaitDelay : la commande s'est terminée normalement, mais un process
|
||||
// laissé en arrière-plan tenait encore les tubes. Ce qu'elle a écrit est
|
||||
// déjà capturé : le rendre, au lieu de « [erreur: exec: WaitDelay expired
|
||||
// before I/O complete] » qui l'effaçait (repris d'AJEAN 7403a8a0).
|
||||
detached := errors.Is(err, exec.ErrWaitDelay)
|
||||
if detached {
|
||||
err = nil
|
||||
}
|
||||
// Sortie nettoyée (couleurs ANSI retirées), réduite à la fin si la commande
|
||||
// demandait un tail.
|
||||
clean := func(s string) string { return tailOutput(stripANSI(s)) }
|
||||
@@ -337,6 +345,9 @@ func runShell(parent context.Context, command string, timeoutSec int) string {
|
||||
if errOut != "" {
|
||||
parts = append(parts, "stderr:\n"+errOut)
|
||||
}
|
||||
if detached {
|
||||
parts = append(parts, "(un process lancé en arrière-plan tourne toujours ; sa sortie n'est plus suivie — pour un serveur, utilise bash_bg)")
|
||||
}
|
||||
return strings.Join(parts, "\n\n")
|
||||
}
|
||||
|
||||
|
||||
+75
-19
@@ -16,6 +16,7 @@ import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
@@ -40,39 +41,94 @@ var defaultRetryPatterns = []string{
|
||||
// Compilés à la première utilisation, pas à l'init du package : la surcharge
|
||||
// vit dans la base bbolt, qu'on ne veut pas ouvrir avant que le process ait
|
||||
// choisi son rôle (CLI, serveur…).
|
||||
//
|
||||
// Relus quand la surcharge change (OpenFox c52c5bf7) : un sync.Once les
|
||||
// figeait jusqu'au redémarrage du process. retryPatterns tourne pendant le flux,
|
||||
// à chaque morceau : la base n'est rouverte que si une écriture de ce process a
|
||||
// touché le bucket d'état (retryStateBust, via cacheBust) ou, au plus une fois
|
||||
// par seconde, si date ou taille du fichier trahissent l'écriture d'un autre
|
||||
// process (`loki config set` pendant que le service tourne).
|
||||
var (
|
||||
retryOnce sync.Once
|
||||
retryRes []*regexp.Regexp
|
||||
retryMu sync.Mutex
|
||||
retryRes []*regexp.Regexp
|
||||
// retryCustom : la surcharge `retry_patterns` est active. Ses motifs sont
|
||||
// quelconques (\A, (?s).*? sur des Ko…) : le balayage fenêtré pendant le
|
||||
// flux (textualToolCallFrom) ne vaut que pour les motifs embarqués.
|
||||
retryCustom bool
|
||||
retryCustom bool
|
||||
retryLoaded bool
|
||||
retryStale bool // écriture locale du bucket d'état depuis la lecture
|
||||
retryRaw string
|
||||
retryPath string // la base lue (LOKI_HOME change d'un test à l'autre)
|
||||
retryChecked time.Time
|
||||
retryMtime time.Time
|
||||
retrySize int64
|
||||
)
|
||||
|
||||
func retryPatterns() []*regexp.Regexp {
|
||||
retryOnce.Do(func() { retryRes, retryCustom = compileRetryPatterns() })
|
||||
return retryRes
|
||||
// retryStateBust : appelé par cacheBust pour toute écriture du bucket d'état.
|
||||
func retryStateBust() {
|
||||
retryMu.Lock()
|
||||
retryStale = true
|
||||
retryMu.Unlock()
|
||||
}
|
||||
|
||||
func compileRetryPatterns() ([]*regexp.Regexp, bool) {
|
||||
pats := defaultRetryPatterns
|
||||
custom := false
|
||||
// Surcharge optionnelle : clé d'état `retry_patterns` = tableau JSON de
|
||||
// regex. Pas d'UI dédiée — c'est un réglage d'expert, posé via
|
||||
// `loki config` ou l'API ; l'embarqué couvre les cas connus.
|
||||
if raw := getStr(bkState, "retry_patterns"); raw != "" {
|
||||
var over []string
|
||||
if json.Unmarshal([]byte(raw), &over) == nil && len(over) > 0 {
|
||||
pats, custom = over, true
|
||||
func retryPatterns() []*regexp.Regexp {
|
||||
res, _ := retryState()
|
||||
return res
|
||||
}
|
||||
|
||||
// retryState renvoie les motifs compilés et si ce sont ceux de la surcharge.
|
||||
func retryState() ([]*regexp.Regexp, bool) {
|
||||
retryMu.Lock()
|
||||
defer retryMu.Unlock()
|
||||
reload := !retryLoaded || retryStale || retryPath != dbPath()
|
||||
if !reload && time.Since(retryChecked) >= time.Second {
|
||||
retryChecked = time.Now()
|
||||
if mtime, size := dbStamp(); size != retrySize || !mtime.Equal(retryMtime) {
|
||||
reload = true
|
||||
}
|
||||
}
|
||||
if reload {
|
||||
raw := getStr(bkState, "retry_patterns")
|
||||
retryMtime, retrySize = dbStamp()
|
||||
retryChecked, retryStale, retryPath = time.Now(), false, dbPath()
|
||||
if !retryLoaded || raw != retryRaw {
|
||||
retryRes, retryCustom = compileRetryPatterns(raw)
|
||||
retryRaw, retryLoaded = raw, true
|
||||
}
|
||||
}
|
||||
return retryRes, retryCustom
|
||||
}
|
||||
|
||||
// compileRetryPatterns compile la surcharge raw (tableau JSON de regex), ou
|
||||
// les motifs embarqués quand elle est absente, illisible, ou sans un seul motif
|
||||
// utilisable. Un motif vide est écarté : "(?mi)" correspond à TOUT, et chaque
|
||||
// tour était relancé une fois (OpenFox 8e9b4869).
|
||||
func compileRetryPatterns(raw string) ([]*regexp.Regexp, bool) {
|
||||
// Surcharge optionnelle : clé d'état `retry_patterns`. Pas d'UI dédiée —
|
||||
// c'est un réglage d'expert, posé via `loki config` ou l'API ; l'embarqué
|
||||
// couvre les cas connus.
|
||||
if raw != "" {
|
||||
var over []string
|
||||
if json.Unmarshal([]byte(raw), &over) == nil {
|
||||
if out := compileRetryList(over); len(out) > 0 {
|
||||
return out, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return compileRetryList(defaultRetryPatterns), false
|
||||
}
|
||||
|
||||
func compileRetryList(pats []string) []*regexp.Regexp {
|
||||
var out []*regexp.Regexp
|
||||
for _, p := range pats {
|
||||
if strings.TrimSpace(p) == "" {
|
||||
continue
|
||||
}
|
||||
if re, err := regexp.Compile("(?mi)" + p); err == nil {
|
||||
out = append(out, re)
|
||||
}
|
||||
}
|
||||
return out, custom
|
||||
return out
|
||||
}
|
||||
|
||||
// textualToolCall dit si le texte final du tour contient un appel d'outil
|
||||
@@ -100,9 +156,9 @@ func textualToolCallFrom(content string, scanned int) bool {
|
||||
if content == "" {
|
||||
return false
|
||||
}
|
||||
res := retryPatterns()
|
||||
res, custom := retryState()
|
||||
start := 0
|
||||
if !retryCustom {
|
||||
if !custom {
|
||||
start = retryScanStart(content, scanned)
|
||||
}
|
||||
w := content[start:]
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package loki
|
||||
|
||||
import "testing"
|
||||
|
||||
// Un motif vide dans la surcharge correspondait à tout : chaque tour était
|
||||
// relancé une fois. Il est écarté ; une surcharge sans motif utilisable retombe
|
||||
// sur les motifs embarqués.
|
||||
func TestCompileRetryPatternsSkipsEmpty(t *testing.T) {
|
||||
res, custom := compileRetryPatterns(`["", " ", "<oups>"]`)
|
||||
if !custom || len(res) != 1 {
|
||||
t.Fatalf("custom=%v, %d motifs ; attendu la surcharge avec un seul motif", custom, len(res))
|
||||
}
|
||||
if res[0].MatchString("une réponse tout à fait normale") {
|
||||
t.Fatal("un texte ordinaire déclenche une relance")
|
||||
}
|
||||
res, custom = compileRetryPatterns(`["", "([invalide"]`)
|
||||
if custom || len(res) != len(defaultRetryPatterns) {
|
||||
t.Fatalf("custom=%v, %d motifs ; attendu les motifs embarqués", custom, len(res))
|
||||
}
|
||||
if _, custom = compileRetryPatterns(`pas du json`); custom {
|
||||
t.Fatal("surcharge illisible prise en compte")
|
||||
}
|
||||
}
|
||||
|
||||
// Un changement de retry_patterns agit sans redémarrer le process.
|
||||
func TestRetryPatternsReloadOnChange(t *testing.T) {
|
||||
testHome(t)
|
||||
retryStateBust()
|
||||
if _, custom := retryState(); custom {
|
||||
t.Fatal("surcharge active sans clé")
|
||||
}
|
||||
if err := putStr(bkState, "retry_patterns", `["OUPS-OUTIL"]`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !textualToolCall("bon, OUPS-OUTIL, voilà") {
|
||||
t.Fatal("nouveau motif ignoré après l'écriture")
|
||||
}
|
||||
if err := putStr(bkState, "retry_patterns", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, custom := retryState(); custom {
|
||||
t.Fatal("surcharge retirée mais toujours active")
|
||||
}
|
||||
if textualToolCall("bon, OUPS-OUTIL, voilà") {
|
||||
t.Fatal("ancien motif encore appliqué")
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
@@ -300,7 +301,7 @@ func TestFacadeRelaieLesModeles(t *testing.T) {
|
||||
local := func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "go") }
|
||||
|
||||
routes := []string{"/api/models/download", "/api/models/download/probe",
|
||||
"/api/models/download/status", "/api/models/download/cancel", "/api/models",
|
||||
"/api/models/download/status", "/api/models/download/cancel", "/api/models/download/pause", "/api/models",
|
||||
"/api/models/delete", "/api/models/dirs", "/api/hf/search", "/api/hf/files", "/api/hf/token"}
|
||||
for _, p := range routes {
|
||||
w := httptest.NewRecorder()
|
||||
@@ -857,10 +858,10 @@ func TestFacadeMCP(t *testing.T) {
|
||||
if len(tools) != 1 || tools[0].Function.Name != "mcp__a__t" || mcpPromptLine(tools) == "" {
|
||||
t.Fatalf("outils : %+v", tools)
|
||||
}
|
||||
if got := mcpCall("mcp__a__t", map[string]any{"x": 1}); got != "fait" {
|
||||
if got := mcpCall(context.Background(), "mcp__a__t", map[string]any{"x": 1}); got != "fait" {
|
||||
t.Fatalf("appel : %q", got)
|
||||
}
|
||||
if got := mcpCall("mcp__a__t", nil); got != "fait" || !strings.HasSuffix(calls[len(calls)-1], `{"args":{},"name":"mcp__a__t"}`) {
|
||||
if got := mcpCall(context.Background(), "mcp__a__t", nil); got != "fait" || !strings.HasSuffix(calls[len(calls)-1], `{"args":{},"name":"mcp__a__t"}`) {
|
||||
t.Fatalf("appel sans arguments : %q (%v)", got, calls)
|
||||
}
|
||||
MCPPrewarm()
|
||||
@@ -872,7 +873,7 @@ func TestFacadeMCP(t *testing.T) {
|
||||
}
|
||||
// Façade injoignable : une erreur lisible, pas de panique.
|
||||
t.Setenv(facadeAddrEnv, "127.0.0.1:1")
|
||||
if got := mcpCall("mcp__a__t", nil); !strings.HasPrefix(got, "[erreur MCP] façade injoignable") {
|
||||
if got := mcpCall(context.Background(), "mcp__a__t", nil); !strings.HasPrefix(got, "[erreur MCP] façade injoignable") {
|
||||
t.Fatalf("façade injoignable : %q", got)
|
||||
}
|
||||
if tools := mcpTools(); tools != nil {
|
||||
|
||||
@@ -49,4 +49,5 @@ var facadeModelRoutes = map[string]bool{
|
||||
"/api/models/download/probe": true,
|
||||
"/api/models/download/status": true,
|
||||
"/api/models/download/cancel": true,
|
||||
"/api/models/download/pause": true,
|
||||
}
|
||||
@@ -1373,6 +1373,9 @@ func runChatLoop(ctx context.Context, kt *keepImages, messages []Message, tools
|
||||
// Contexte estimé pour la requête suivante (en-tour), comparé au compte
|
||||
// réel du moteur quand il arrive — journalisé s'il s'en écarte.
|
||||
lastEst := 0
|
||||
// Compaction en cours de tour qui n'a rien pu réduire : la retenter
|
||||
// seulement une fois le contexte nettement plus gros (compactRetryDue).
|
||||
compactRetryAt := 0
|
||||
// Garde-fou « appel d'outil écrit en texte » (code_retry.go) : relances
|
||||
// consécutives bornées, compteur remis à zéro après chaque outil exécuté.
|
||||
patternRetries := 0
|
||||
@@ -2468,7 +2471,8 @@ func runChatLoop(ctx context.Context, kt *keepImages, messages []Message, tools
|
||||
publishHistory()
|
||||
}
|
||||
}
|
||||
if compactNeeded(messages, used, peakGen) && !compactRefusedSkip(mainConv, used, messages) {
|
||||
if compactNeeded(messages, used, peakGen) && compactRetryDue(compactRetryAt, used, messages) &&
|
||||
!compactRefusedSkip(mainConv, used, messages) {
|
||||
opt := compactOptsMid(chatCfg, ep, mainConv, messages, used, tools, reasoningEffort, reasoningKwargs, pol,
|
||||
disableTools || toolChoiceNone)
|
||||
yes, no := true, false
|
||||
@@ -2476,6 +2480,7 @@ func runChatLoop(ctx context.Context, kt *keepImages, messages []Message, tools
|
||||
c, changed := compactMessagesNoted(ctx, messages, caps, opt)
|
||||
cb(StreamEvent{Compacting: &no})
|
||||
logCompact("en-tour", used, messages, c, changed)
|
||||
compactRetryAt = compactNextRetry(changed, used, messages)
|
||||
if changed {
|
||||
lastEst = 0 // estimation d'avant compaction : plus rien à comparer
|
||||
messages = c
|
||||
@@ -2810,7 +2815,7 @@ func execTool(ctx context.Context, name string, args map[string]any, label strin
|
||||
result, visionImg = toolCUScreenshot()
|
||||
default:
|
||||
if isMCPTool(name) {
|
||||
result = mcpCall(name, args)
|
||||
result = mcpCall(ctx, name, args)
|
||||
} else {
|
||||
result = "[erreur] outil inconnu: " + name
|
||||
}
|
||||
|
||||
+85
-23
@@ -25,6 +25,7 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -73,6 +74,14 @@ const (
|
||||
lspMaxDiags = 10 // diagnostics montrés au modèle
|
||||
)
|
||||
|
||||
// lspDiagQuiet : calme attendu après une publication avant de la croire
|
||||
// définitive. gopls, rust-analyzer et pyright publient souvent en plusieurs
|
||||
// fois pour une même version (liste vide d'abord, puis leur analyse, puis
|
||||
// celle d'un outil externe) : rendre la PREMIÈRE donnait « propre » à une
|
||||
// édition qui cassait la compilation. OpenFox attend 400 ms ; variable pour
|
||||
// les tests.
|
||||
var lspDiagQuiet = 350 * time.Millisecond
|
||||
|
||||
type lspServer struct {
|
||||
lang string
|
||||
root string
|
||||
@@ -82,11 +91,17 @@ type lspServer struct {
|
||||
nextID int
|
||||
version map[string]int // uri → version de document
|
||||
// diags : dernier publishDiagnostics reçu par uri, et signal d'arrivée.
|
||||
diagMu sync.Mutex
|
||||
diags map[string][]lspDiagnostic
|
||||
diagMu sync.Mutex
|
||||
diags map[string][]lspDiagnostic
|
||||
// diagVer : version de document annoncée par la dernière publication de
|
||||
// l'uri, -1 quand le serveur n'en donne pas (champ facultatif du protocole).
|
||||
diagVer map[string]int
|
||||
diagCh chan string // uri fraîchement publié
|
||||
lastUsed time.Time
|
||||
dead bool
|
||||
// dead : atomique, pas sous mu. writeMsg le pose sur une écriture ratée, et
|
||||
// writeMsg tourne DANS check, qui tient déjà mu : le prendre là bloquait le
|
||||
// serveur (et le tour) à vie. lspGet le lit aussi sans mu.
|
||||
dead atomic.Bool
|
||||
}
|
||||
|
||||
type lspDiagnostic struct {
|
||||
@@ -214,7 +229,7 @@ func lspGet(lang *lspLanguage, root string) (*lspServer, error) {
|
||||
key := lang.ID + "\x00" + root
|
||||
lspMu.Lock()
|
||||
defer lspMu.Unlock()
|
||||
if s := lspServers[key]; s != nil && !s.dead {
|
||||
if s := lspServers[key]; s != nil && !s.dead.Load() {
|
||||
return s, nil
|
||||
}
|
||||
s, err := lspStart(lang, root)
|
||||
@@ -242,7 +257,7 @@ func lspStart(lang *lspLanguage, root string) (*lspServer, error) {
|
||||
}
|
||||
s := &lspServer{
|
||||
lang: lang.ID, root: root, cmd: cmd, stdin: stdin,
|
||||
version: map[string]int{}, diags: map[string][]lspDiagnostic{},
|
||||
version: map[string]int{}, diags: map[string][]lspDiagnostic{}, diagVer: map[string]int{},
|
||||
diagCh: make(chan string, 16), lastUsed: time.Now(),
|
||||
}
|
||||
go s.readLoop(stdout)
|
||||
@@ -268,7 +283,7 @@ func lspStart(lang *lspLanguage, root string) (*lspServer, error) {
|
||||
func (s *lspServer) check(absPath string) ([]lspDiagnostic, bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.dead {
|
||||
if s.dead.Load() {
|
||||
return nil, false
|
||||
}
|
||||
s.lastUsed = time.Now()
|
||||
@@ -277,16 +292,29 @@ func (s *lspServer) check(absPath string) ([]lspDiagnostic, bool) {
|
||||
return nil, false
|
||||
}
|
||||
uri := fileURI(absPath)
|
||||
// Signaux en attente : tous PÉRIMÉS. Un serveur qui republie après le
|
||||
// retour du check précédent laisse son uri dans le tampon ; lu ici tout de
|
||||
// suite après didChange, il faisait rendre « aucun diagnostic » avant même
|
||||
// que le serveur ait vu la nouvelle version.
|
||||
for drained := false; !drained; {
|
||||
select {
|
||||
case <-s.diagCh:
|
||||
default:
|
||||
drained = true
|
||||
}
|
||||
}
|
||||
// Vide les publications en attente pour cet uri : on veut la PROCHAINE.
|
||||
s.diagMu.Lock()
|
||||
delete(s.diags, uri)
|
||||
delete(s.diagVer, uri)
|
||||
s.diagMu.Unlock()
|
||||
v, open := s.version[uri]
|
||||
if !open {
|
||||
s.version[uri] = 1
|
||||
v = 1
|
||||
s.version[uri] = v
|
||||
s.notify("textDocument/didOpen", map[string]any{
|
||||
"textDocument": map[string]any{
|
||||
"uri": uri, "languageId": s.lang, "version": 1, "text": string(content),
|
||||
"uri": uri, "languageId": s.lang, "version": v, "text": string(content),
|
||||
},
|
||||
})
|
||||
} else {
|
||||
@@ -297,24 +325,56 @@ func (s *lspServer) check(absPath string) ([]lspDiagnostic, bool) {
|
||||
"contentChanges": []map[string]any{{"text": string(content)}},
|
||||
})
|
||||
}
|
||||
// Attend la publication pour CET uri, bornée.
|
||||
deadline := time.After(lspDiagWait)
|
||||
if s.dead.Load() {
|
||||
return nil, false // écriture ratée : le serveur ne verra jamais cette version
|
||||
}
|
||||
// current : les diagnostics publiés pour CETTE version (ou sans version),
|
||||
// pas ceux d'une version antérieure arrivés en retard.
|
||||
current := func() ([]lspDiagnostic, bool) {
|
||||
s.diagMu.Lock()
|
||||
defer s.diagMu.Unlock()
|
||||
d, ok := s.diags[uri]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
if pv, has := s.diagVer[uri]; has && pv >= 0 && pv < v {
|
||||
return nil, false
|
||||
}
|
||||
return d, true
|
||||
}
|
||||
// Attend la publication pour CET uri, puis un calme de lspDiagQuiet sans
|
||||
// nouvelle publication ; le tout borné par lspDiagWait.
|
||||
deadline := time.NewTimer(lspDiagWait)
|
||||
defer deadline.Stop()
|
||||
var quiet <-chan time.Time
|
||||
var quietT *time.Timer
|
||||
defer func() {
|
||||
if quietT != nil {
|
||||
quietT.Stop()
|
||||
}
|
||||
}()
|
||||
for {
|
||||
select {
|
||||
case got := <-s.diagCh:
|
||||
if got != uri {
|
||||
continue
|
||||
}
|
||||
s.diagMu.Lock()
|
||||
d := s.diags[uri]
|
||||
s.diagMu.Unlock()
|
||||
if _, ok := current(); !ok {
|
||||
continue // publication d'une version antérieure
|
||||
}
|
||||
if quietT == nil {
|
||||
quietT = time.NewTimer(lspDiagQuiet)
|
||||
} else {
|
||||
quietT.Reset(lspDiagQuiet)
|
||||
}
|
||||
quiet = quietT.C
|
||||
case <-quiet:
|
||||
d, _ := current()
|
||||
return d, true
|
||||
case <-deadline:
|
||||
case <-deadline.C:
|
||||
// Pas de publication à temps : certains serveurs ne publient rien
|
||||
// quand il n'y a AUCUN diagnostic. On rend « rien », pas un échec.
|
||||
s.diagMu.Lock()
|
||||
d := s.diags[uri]
|
||||
s.diagMu.Unlock()
|
||||
d, _ := current()
|
||||
return d, true
|
||||
}
|
||||
}
|
||||
@@ -353,6 +413,7 @@ func (s *lspServer) readLoop(r io.Reader) {
|
||||
Method string `json:"method"`
|
||||
Params struct {
|
||||
URI string `json:"uri"`
|
||||
Version *int `json:"version"`
|
||||
Diagnostics []lspDiagnostic `json:"diagnostics"`
|
||||
} `json:"params"`
|
||||
}
|
||||
@@ -362,6 +423,11 @@ func (s *lspServer) readLoop(r io.Reader) {
|
||||
if msg.Method == "textDocument/publishDiagnostics" {
|
||||
s.diagMu.Lock()
|
||||
s.diags[msg.Params.URI] = msg.Params.Diagnostics
|
||||
ver := -1
|
||||
if msg.Params.Version != nil {
|
||||
ver = *msg.Params.Version
|
||||
}
|
||||
s.diagVer[msg.Params.URI] = ver
|
||||
s.diagMu.Unlock()
|
||||
select {
|
||||
case s.diagCh <- msg.Params.URI:
|
||||
@@ -377,7 +443,7 @@ func (s *lspServer) idleWatch() {
|
||||
for range t.C {
|
||||
s.mu.Lock()
|
||||
idle := time.Since(s.lastUsed) > lspIdleTimeout
|
||||
dead := s.dead
|
||||
dead := s.dead.Load()
|
||||
s.mu.Unlock()
|
||||
if dead {
|
||||
return
|
||||
@@ -401,11 +467,7 @@ func (s *lspServer) shutdown() {
|
||||
lspMu.Unlock()
|
||||
}
|
||||
|
||||
func (s *lspServer) markDead() {
|
||||
s.mu.Lock()
|
||||
s.dead = true
|
||||
s.mu.Unlock()
|
||||
}
|
||||
func (s *lspServer) markDead() { s.dead.Store(true) }
|
||||
|
||||
func (s *lspServer) send(method string, params any, isRequest bool) {
|
||||
s.nextID++
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// fakeLSP : un serveur LSP réduit à ce que check utilise. Pour chaque
|
||||
// didOpen/didChange reçu, react(version, publish) décide des publications.
|
||||
type fakeLSP struct {
|
||||
s *lspServer
|
||||
out *io.PipeWriter
|
||||
}
|
||||
|
||||
func lspFrame(w io.Writer, v any) {
|
||||
b, _ := json.Marshal(v)
|
||||
fmt.Fprintf(w, "Content-Length: %d\r\n\r\n%s", len(b), b)
|
||||
}
|
||||
|
||||
func newFakeLSP(t *testing.T, react func(ver int, publish func(ver *int, diags []lspDiagnostic))) *fakeLSP {
|
||||
t.Helper()
|
||||
inR, inW := io.Pipe()
|
||||
outR, outW := io.Pipe()
|
||||
s := &lspServer{
|
||||
lang: "go", root: t.TempDir(), stdin: inW,
|
||||
version: map[string]int{}, diags: map[string][]lspDiagnostic{}, diagVer: map[string]int{},
|
||||
diagCh: make(chan string, 16), lastUsed: time.Now(),
|
||||
}
|
||||
go s.readLoop(outR)
|
||||
go func() {
|
||||
br := bufio.NewReader(inR)
|
||||
for {
|
||||
n := 0
|
||||
for {
|
||||
line, err := br.ReadString('\n')
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
break
|
||||
}
|
||||
if v, ok := strings.CutPrefix(line, "Content-Length:"); ok {
|
||||
fmt.Sscanf(strings.TrimSpace(v), "%d", &n)
|
||||
}
|
||||
}
|
||||
buf := make([]byte, n)
|
||||
if _, err := io.ReadFull(br, buf); err != nil {
|
||||
return
|
||||
}
|
||||
var msg struct {
|
||||
Method string `json:"method"`
|
||||
Params struct {
|
||||
TextDocument struct {
|
||||
URI string `json:"uri"`
|
||||
Version int `json:"version"`
|
||||
} `json:"textDocument"`
|
||||
} `json:"params"`
|
||||
}
|
||||
if json.Unmarshal(buf, &msg) != nil {
|
||||
continue
|
||||
}
|
||||
if msg.Method != "textDocument/didOpen" && msg.Method != "textDocument/didChange" {
|
||||
continue
|
||||
}
|
||||
uri := msg.Params.TextDocument.URI
|
||||
go react(msg.Params.TextDocument.Version, func(ver *int, diags []lspDiagnostic) {
|
||||
params := map[string]any{"uri": uri, "diagnostics": diags}
|
||||
if ver != nil {
|
||||
params["version"] = *ver
|
||||
}
|
||||
lspFrame(outW, map[string]any{"jsonrpc": "2.0", "method": "textDocument/publishDiagnostics", "params": params})
|
||||
})
|
||||
}
|
||||
}()
|
||||
t.Cleanup(func() { inW.Close(); outW.Close() })
|
||||
return &fakeLSP{s: s, out: outW}
|
||||
}
|
||||
|
||||
func lspErr(msg string) []lspDiagnostic {
|
||||
d := lspDiagnostic{Severity: 1, Message: msg}
|
||||
return []lspDiagnostic{d}
|
||||
}
|
||||
|
||||
func lspTempFile(t *testing.T) string {
|
||||
t.Helper()
|
||||
p := filepath.Join(t.TempDir(), "main.go")
|
||||
if err := os.WriteFile(p, []byte("package main\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func withLSPQuiet(t *testing.T, d time.Duration) {
|
||||
old := lspDiagQuiet
|
||||
lspDiagQuiet = d
|
||||
t.Cleanup(func() { lspDiagQuiet = old })
|
||||
}
|
||||
|
||||
// Un signal laissé dans le tampon par une republication tardive ne doit pas
|
||||
// faire rendre « aucun diagnostic » à l'édition suivante.
|
||||
func TestLSPCheckIgnoresStaleSignal(t *testing.T) {
|
||||
withLSPQuiet(t, 80*time.Millisecond)
|
||||
p := lspTempFile(t)
|
||||
f := newFakeLSP(t, func(ver int, publish func(*int, []lspDiagnostic)) {
|
||||
time.Sleep(60 * time.Millisecond)
|
||||
publish(&ver, lspErr("undefined: x"))
|
||||
})
|
||||
uri := fileURI(p)
|
||||
f.s.version[uri] = 1 // document déjà ouvert
|
||||
f.s.diagCh <- uri // republication tardive du check précédent
|
||||
f.s.diags[uri] = nil // …dont la liste était vide
|
||||
d, ok := f.s.check(p)
|
||||
if !ok || len(d) != 1 || d[0].Message != "undefined: x" {
|
||||
t.Fatalf("diagnostics = %+v, %v ; attendu l'erreur de la nouvelle version", d, ok)
|
||||
}
|
||||
}
|
||||
|
||||
// Une liste vide publiée d'abord (« clear ») puis l'analyse : c'est l'analyse
|
||||
// qui compte.
|
||||
func TestLSPCheckWaitsPastInitialClear(t *testing.T) {
|
||||
withLSPQuiet(t, 150*time.Millisecond)
|
||||
p := lspTempFile(t)
|
||||
f := newFakeLSP(t, func(ver int, publish func(*int, []lspDiagnostic)) {
|
||||
publish(&ver, nil)
|
||||
time.Sleep(60 * time.Millisecond)
|
||||
publish(&ver, lspErr("missing return"))
|
||||
})
|
||||
d, ok := f.s.check(p)
|
||||
if !ok || len(d) != 1 || d[0].Message != "missing return" {
|
||||
t.Fatalf("diagnostics = %+v, %v ; attendu l'erreur publiée après la liste vide", d, ok)
|
||||
}
|
||||
}
|
||||
|
||||
// Une publication d'une version antérieure, arrivée en retard, est ignorée.
|
||||
func TestLSPCheckIgnoresOlderVersion(t *testing.T) {
|
||||
withLSPQuiet(t, 80*time.Millisecond)
|
||||
p := lspTempFile(t)
|
||||
f := newFakeLSP(t, func(ver int, publish func(*int, []lspDiagnostic)) {
|
||||
old := ver - 1
|
||||
publish(&old, lspErr("ancienne erreur"))
|
||||
time.Sleep(40 * time.Millisecond)
|
||||
publish(&ver, nil)
|
||||
})
|
||||
f.s.version[fileURI(p)] = 3
|
||||
d, ok := f.s.check(p)
|
||||
if !ok || len(d) != 0 {
|
||||
t.Fatalf("diagnostics = %+v, %v ; attendu aucun (seule la version 4 compte)", d, ok)
|
||||
}
|
||||
}
|
||||
|
||||
// Sans version dans la publication (champ facultatif), elle est prise.
|
||||
func TestLSPCheckAcceptsUnversionedPublish(t *testing.T) {
|
||||
withLSPQuiet(t, 50*time.Millisecond)
|
||||
p := lspTempFile(t)
|
||||
f := newFakeLSP(t, func(ver int, publish func(*int, []lspDiagnostic)) {
|
||||
publish(nil, lspErr("syntax error"))
|
||||
})
|
||||
start := time.Now()
|
||||
d, ok := f.s.check(p)
|
||||
if !ok || len(d) != 1 {
|
||||
t.Fatalf("diagnostics = %+v, %v", d, ok)
|
||||
}
|
||||
if time.Since(start) > lspDiagWait {
|
||||
t.Fatalf("check a attendu la borne au lieu du calme")
|
||||
}
|
||||
}
|
||||
|
||||
// Une écriture ratée vers le serveur ne bloque plus check (markDead prenait
|
||||
// mu, déjà tenu par check).
|
||||
func TestLSPCheckWriteFailureDoesNotDeadlock(t *testing.T) {
|
||||
p := lspTempFile(t)
|
||||
r, w := io.Pipe()
|
||||
r.Close() // toute écriture échoue
|
||||
s := &lspServer{
|
||||
lang: "go", stdin: w,
|
||||
version: map[string]int{}, diags: map[string][]lspDiagnostic{}, diagVer: map[string]int{},
|
||||
diagCh: make(chan string, 16),
|
||||
}
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
s.check(p)
|
||||
close(done)
|
||||
}()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(lspDiagWait + 2*time.Second):
|
||||
t.Fatal("check bloqué après une écriture ratée")
|
||||
}
|
||||
if !s.dead.Load() {
|
||||
t.Fatal("serveur pas marqué mort après une écriture ratée")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
)
|
||||
|
||||
// fakeMCPSlow branche un serveur MCP en mémoire dont l'outil « slow » ne rend
|
||||
// la main qu'à l'annulation de sa requête (ou au bout de 30 s).
|
||||
func fakeMCPSlow(t *testing.T) string {
|
||||
t.Helper()
|
||||
srv := mcpsdk.NewServer(&mcpsdk.Implementation{Name: "lent", Version: "1"}, nil)
|
||||
srv.AddTool(&mcpsdk.Tool{Name: "slow", InputSchema: map[string]any{"type": "object"}},
|
||||
func(ctx context.Context, _ *mcpsdk.CallToolRequest) (*mcpsdk.CallToolResult, error) {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-time.After(30 * time.Second):
|
||||
return &mcpsdk.CallToolResult{Content: []mcpsdk.Content{&mcpsdk.TextContent{Text: "fini"}}}, nil
|
||||
}
|
||||
})
|
||||
st, ct := mcpsdk.NewInMemoryTransports()
|
||||
ctx := context.Background()
|
||||
ss, err := srv.Connect(ctx, st, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cs, err := mcpsdk.NewClient(&mcpsdk.Implementation{Name: "loki-test", Version: "1"}, nil).Connect(ctx, ct, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ready := make(chan struct{})
|
||||
close(ready)
|
||||
name := mcpExposedName("lent", "slow")
|
||||
mcpMgr.mu.Lock()
|
||||
if mcpMgr.sessions == nil {
|
||||
mcpMgr.sessions = map[string]*mcpSession{}
|
||||
}
|
||||
if mcpMgr.registry == nil {
|
||||
mcpMgr.registry = map[string]mcpToolRef{}
|
||||
}
|
||||
mcpMgr.sessions["lent"] = &mcpSession{name: "lent", sess: cs, ready: ready}
|
||||
mcpMgr.registry[name] = mcpToolRef{server: "lent", tool: "slow"}
|
||||
mcpMgr.mu.Unlock()
|
||||
t.Cleanup(func() {
|
||||
mcpMgr.mu.Lock()
|
||||
delete(mcpMgr.sessions, "lent")
|
||||
delete(mcpMgr.registry, name)
|
||||
mcpMgr.mu.Unlock()
|
||||
cs.Close()
|
||||
ss.Close()
|
||||
})
|
||||
return name
|
||||
}
|
||||
|
||||
// Le bouton stop annule l'appel MCP en cours au lieu d'attendre ses 120 s.
|
||||
func TestMCPCallHonorsTurnCancel(t *testing.T) {
|
||||
testHome(t)
|
||||
name := fakeMCPSlow(t)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
time.AfterFunc(150*time.Millisecond, cancel)
|
||||
start := time.Now()
|
||||
out := mcpCall(ctx, name, map[string]any{})
|
||||
if d := time.Since(start); d > 5*time.Second {
|
||||
t.Fatalf("l'appel a ignoré l'annulation du tour (%s)", d)
|
||||
}
|
||||
if !strings.Contains(out, "interrompu") {
|
||||
t.Fatalf("sortie = %q ; attendu un appel interrompu", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCPCallTimeoutFor(t *testing.T) {
|
||||
cases := []struct {
|
||||
cfg MCPServerConfig
|
||||
args map[string]any
|
||||
want time.Duration
|
||||
}{
|
||||
{MCPServerConfig{}, nil, mcpCallTimeout},
|
||||
{MCPServerConfig{CallTimeoutSec: 600}, nil, 10 * time.Minute},
|
||||
{MCPServerConfig{}, map[string]any{"timeout": 300.0}, 330 * time.Second}, // secondes
|
||||
{MCPServerConfig{}, map[string]any{"timeout": 200000.0}, 230 * time.Second}, // millisecondes
|
||||
{MCPServerConfig{}, map[string]any{"timeout": 5000.0}, mcpCallTimeout}, // 5 s : le défaut reste
|
||||
{MCPServerConfig{}, map[string]any{"timeout": "400"}, 430 * time.Second}, // chaîne
|
||||
{MCPServerConfig{}, map[string]any{"timeout": 86400.0 * 1000}, mcpCallTimeoutMax}, // plafond
|
||||
{MCPServerConfig{CallTimeoutSec: 30}, nil, 30 * time.Second}, // plus court que le défaut
|
||||
}
|
||||
for i, c := range cases {
|
||||
if got := mcpCallTimeoutFor(c.cfg, c.args); got != c.want {
|
||||
t.Errorf("cas %d : %s, attendu %s", i, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -374,7 +375,11 @@ func isMCPTool(name string) bool {
|
||||
|
||||
// mcpCall exécute un outil MCP à partir de son nom exposé et renvoie une chaîne
|
||||
// résultat (texte aplati), cappée. Reconnecte une fois si la session est morte.
|
||||
func mcpCall(name string, args map[string]any) string {
|
||||
//
|
||||
// parent est le contexte DU TOUR, comme pour runShell : l'appel naissait d'un
|
||||
// context.Background(), donc le bouton stop n'arrêtait rien et le tour restait
|
||||
// bloqué jusqu'au bout des 120 s (OpenFox 2ef584b8).
|
||||
func mcpCall(parent context.Context, name string, args map[string]any) string {
|
||||
if facadeMCP() { // le pool est celui de la façade (facade_mcp.go)
|
||||
return facadeMCPCall(name, args)
|
||||
}
|
||||
@@ -384,35 +389,92 @@ func mcpCall(name string, args map[string]any) string {
|
||||
if !ok {
|
||||
return "[erreur] outil MCP inconnu ou serveur non connecté: " + name
|
||||
}
|
||||
servers, _ := LoadMCPConfig()
|
||||
cfg, exists := servers[ref.server]
|
||||
timeout := mcpCallTimeoutFor(cfg, args)
|
||||
|
||||
result, err := mcpCallOnce(ref, args)
|
||||
result, err := mcpCallOnce(parent, ref, args, timeout)
|
||||
if err != nil {
|
||||
if parent.Err() != nil {
|
||||
return "[appel MCP interrompu]"
|
||||
}
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
// Le serveur est vivant mais l'outil est lent : le relancer
|
||||
// doublerait l'attente (et l'effet de l'outil).
|
||||
return fmt.Sprintf("[erreur MCP] pas de réponse après %s (délai de l'appel ; un argument timeout plus grand l'allonge)", timeout.Round(time.Second))
|
||||
}
|
||||
// Session peut-être morte (process stdio tombé) : on l'invalide et on
|
||||
// retente une fois avec une reconnexion fraîche.
|
||||
servers, _ := LoadMCPConfig()
|
||||
cfg, exists := servers[ref.server]
|
||||
if exists && cfg.Enabled {
|
||||
mcpInvalidate(ref.server)
|
||||
mcpMgr.ensure(ref.server, cfg)
|
||||
if result2, err2 := mcpCallOnce(ref, args); err2 == nil {
|
||||
if result2, err2 := mcpCallOnce(parent, ref, args, timeout); err2 == nil {
|
||||
return result2
|
||||
} else {
|
||||
err = err2
|
||||
}
|
||||
if parent.Err() != nil {
|
||||
return "[appel MCP interrompu]"
|
||||
}
|
||||
}
|
||||
return "[erreur MCP] " + err.Error()
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func mcpCallOnce(ref mcpToolRef, args map[string]any) (string, error) {
|
||||
// mcpCallTimeoutMax plafonne le délai d'un appel, quoi que demandent l'outil
|
||||
// ou la config : au-delà, c'est un job d'arrière-plan, pas un appel d'outil.
|
||||
const mcpCallTimeoutMax = 30 * time.Minute
|
||||
|
||||
// mcpCallTimeoutFor : délai d'un appel. Défaut mcpCallTimeout, remplacé par
|
||||
// CallTimeoutSec du serveur ; un argument « timeout » de l'outil lui-même
|
||||
// (beaucoup en ont un : navigateur, build, requête) l'allonge de 30 s pour que
|
||||
// l'outil ait le temps de rendre SA propre erreur de délai.
|
||||
func mcpCallTimeoutFor(cfg MCPServerConfig, args map[string]any) time.Duration {
|
||||
d := mcpCallTimeout
|
||||
if cfg.CallTimeoutSec > 0 {
|
||||
d = time.Duration(cfg.CallTimeoutSec) * time.Second
|
||||
}
|
||||
if t := mcpArgTimeout(args); t > 0 && t+30*time.Second > d {
|
||||
d = t + 30*time.Second
|
||||
}
|
||||
return min(d, mcpCallTimeoutMax)
|
||||
}
|
||||
|
||||
// mcpArgTimeout lit l'argument « timeout » d'un appel. Les serveurs MCP ne
|
||||
// s'accordent pas sur l'unité : au-delà de 3600 on le lit en millisecondes
|
||||
// (5000 = 5 s), en deçà en secondes (600 = 10 min).
|
||||
func mcpArgTimeout(args map[string]any) time.Duration {
|
||||
var v float64
|
||||
switch x := args["timeout"].(type) {
|
||||
case float64:
|
||||
v = x
|
||||
case int:
|
||||
v = float64(x)
|
||||
case int64:
|
||||
v = float64(x)
|
||||
case json.Number:
|
||||
v, _ = x.Float64()
|
||||
case string:
|
||||
v, _ = strconv.ParseFloat(strings.TrimSpace(x), 64)
|
||||
}
|
||||
if v <= 0 {
|
||||
return 0
|
||||
}
|
||||
if v > 3600 {
|
||||
return time.Duration(v) * time.Millisecond
|
||||
}
|
||||
return time.Duration(v * float64(time.Second))
|
||||
}
|
||||
|
||||
func mcpCallOnce(parent context.Context, ref mcpToolRef, args map[string]any, timeout time.Duration) (string, error) {
|
||||
mcpMgr.mu.Lock()
|
||||
s := mcpMgr.sessions[ref.server]
|
||||
mcpMgr.mu.Unlock()
|
||||
if s == nil || s.sess == nil {
|
||||
return "", fmt.Errorf("serveur %s non connecté", ref.server)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), mcpCallTimeout)
|
||||
ctx, cancel := context.WithTimeout(parent, timeout)
|
||||
defer cancel()
|
||||
res, err := s.sess.CallTool(ctx, &mcpsdk.CallToolParams{Name: ref.tool, Arguments: args})
|
||||
if err != nil {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -83,7 +84,7 @@ func TestMCPEndToEnd(t *testing.T) {
|
||||
t.Fatalf("outil %s introuvable dans %d outils", echoName, len(tools))
|
||||
}
|
||||
|
||||
out := mcpCall(echoName, map[string]any{"message": "bonjour-loki"})
|
||||
out := mcpCall(context.Background(), echoName, map[string]any{"message": "bonjour-loki"})
|
||||
if !strings.Contains(out, "bonjour-loki") {
|
||||
t.Fatalf("echo n'a pas renvoyé le message ; got: %q", out)
|
||||
}
|
||||
|
||||
@@ -50,6 +50,11 @@ type MCPServerConfig struct {
|
||||
// non namespacé). Permet de garder un serveur connecté tout en masquant
|
||||
// certains de ses outils. Vide = tous les outils exposés.
|
||||
DisabledTools []string `json:"disabledTools,omitempty"`
|
||||
|
||||
// CallTimeoutSec : délai d'un appel d'outil de ce serveur, en secondes.
|
||||
// 0 = défaut (mcpCallTimeout). Pour un serveur dont les outils sont longs
|
||||
// par nature (build, scraping, requête lourde).
|
||||
CallTimeoutSec int `json:"callTimeoutSec,omitempty"`
|
||||
}
|
||||
|
||||
// ToolDisabled indique si un outil (nom réel) est masqué pour ce serveur.
|
||||
|
||||
@@ -299,9 +299,9 @@ func TestPariteMCP(t *testing.T) {
|
||||
results := map[string]string{}
|
||||
for _, n := range []string{"echo", "multi", "media", "link", "struct", "fail", "empty", "big", "titled", "secret", "dot_tool", "nope"} {
|
||||
name := mcpExposedName("alpha", n)
|
||||
results[name] = mcpCall(name, callArgs[n])
|
||||
results[name] = mcpCall(context.Background(), name, callArgs[n])
|
||||
}
|
||||
results["mcp__zeta__x"] = mcpCall("mcp__zeta__x", nil)
|
||||
results["mcp__zeta__x"] = mcpCall(context.Background(), "mcp__zeta__x", nil)
|
||||
fx["calls"] = results
|
||||
fx["prompt_line_live"] = mcpPromptLine(mcpTools())
|
||||
|
||||
|
||||
@@ -249,13 +249,14 @@ func TestPariteStrata(t *testing.T) {
|
||||
return out
|
||||
}
|
||||
|
||||
srcDir := filepath.Base(strataSrcDir())
|
||||
src := []parityArchEntry{
|
||||
{Name: "ajean-moe-1.0/", Type: "dir", Mode: 0o755},
|
||||
{Name: "ajean-moe-1.0/setup.sh", Type: "file", Body: parityStrataSetup, Mode: 0o755},
|
||||
{Name: "ajean-moe-1.0/setup.py", Type: "file", Body: "# installeur\n", Mode: 0o644},
|
||||
{Name: "ajean-moe-1.0/serve/server.py", Type: "file", Body: "# serveur\n", Mode: 0o644},
|
||||
{Name: srcDir + "/", Type: "dir", Mode: 0o755},
|
||||
{Name: srcDir + "/setup.sh", Type: "file", Body: parityStrataSetup, Mode: 0o755},
|
||||
{Name: srcDir + "/setup.py", Type: "file", Body: "# installeur\n", Mode: 0o644},
|
||||
{Name: srcDir + "/serve/server.py", Type: "file", Body: "# serveur\n", Mode: 0o644},
|
||||
}
|
||||
goodRel := parityStrataRel{Src: src, Engine: "moteur strata 1.0"}
|
||||
goodRel := parityStrataRel{Src: src, Engine: "moteur strata " + strataVersion}
|
||||
mem47 := "MemTotal: 49283072 kB\nMemFree: 1000000 kB\nMemAvailable: 41943040 kB\n"
|
||||
mem64 := "MemTotal: 67108864 kB\nMemAvailable: 65011712 kB\n"
|
||||
mem16 := "MemTotal: 16777216 kB\nMemAvailable: 12582912 kB\n"
|
||||
@@ -332,7 +333,7 @@ func TestPariteStrata(t *testing.T) {
|
||||
mu.Unlock()
|
||||
st.After = map[string]string{}
|
||||
cfg := ReadConfig()
|
||||
for _, k := range []string{"ENGINE", "STRATA_CONFIG", "CTX", "STRATA_KV", "STRATA_HELPER"} {
|
||||
for _, k := range []string{"ENGINE", "STRATA_CONFIG", "CTX", "STRATA_KV", "STRATA_HELPER", "STRATA_SPLIT"} {
|
||||
if v := cfg[k]; v != "" {
|
||||
st.After["config/"+k] = mask(v)
|
||||
}
|
||||
@@ -381,6 +382,10 @@ func TestPariteStrata(t *testing.T) {
|
||||
Method: "POST", Path: "/api/strata/settings", Body: settings("S", 98304, "")},
|
||||
{Name: "réglages du preset actif pendant une optimisation", Tune: true,
|
||||
Method: "POST", Path: "/api/strata/settings", Body: settings("S", 98304, "")},
|
||||
{Name: "réglages, répartition inconnue", Method: "POST", Path: "/api/strata/settings", Body: settings("S", 98304, `,"split":"nimporte"`)},
|
||||
{Name: "réglages, partage des couches", Method: "POST", Path: "/api/strata/settings", Body: settings("S", 98304, `,"split":"layers"`)},
|
||||
{Name: "réglages d'un ancien client, partage gardé", Method: "POST", Path: "/api/strata/settings", Body: settings("S", 98304, "")},
|
||||
{Name: "réglages, retour à l'aide aux experts", Method: "POST", Path: "/api/strata/settings", Body: settings("S", 98304, `,"split":"helper"`)},
|
||||
|
||||
// Installation.
|
||||
{Name: "installation en GET", Method: "GET", Path: "/api/strata/install"},
|
||||
@@ -393,42 +398,52 @@ func TestPariteStrata(t *testing.T) {
|
||||
Body: `{"family":"qwen","quant":"IQ3_S"}`, Wait: true},
|
||||
{Name: "release introuvable", Meminfo: mem47, Release: &parityStrataRel{Status: 404, Src: src, Engine: "x"},
|
||||
Method: "POST", Path: "/api/strata/install", Body: `{"family":"swift","quant":"IQ3_XXS"}`, Wait: true},
|
||||
{Name: "moteur altéré", Release: &parityStrataRel{Src: src, Engine: "moteur strata 1.0", BadSHA: true},
|
||||
{Name: "moteur altéré", Release: &parityStrataRel{Src: src, Engine: "moteur strata " + strataVersion, BadSHA: true},
|
||||
Method: "POST", Path: "/api/strata/install", Body: `{"family":"swift","quant":"IQ3_XXS"}`, Wait: true},
|
||||
{Name: "installation", Release: &goodRel, Method: "POST", Path: "/api/strata/install",
|
||||
Body: `{"family":"swift","quant":"IQ3_XXS"}`, Wait: true},
|
||||
{Name: "après l'installation", Method: "GET", Path: "/api/strata"},
|
||||
{Name: "mode lancé connu, modèles inactifs", State: map[string]string{"strata_experts_mode": "drop"}, Files: map[string]string{
|
||||
"presets/STRATA FLASH NEXT CLASSIQUE IQ2_XS.env": "# NAME=Classique IQ2_XS\nENGINE=strata\nSTRATA_CONFIG={{HOME}}/strata/ajean-moe-1.0/strata-swift-iq3_xxs.json\n" +
|
||||
"presets/STRATA FLASH NEXT CLASSIQUE IQ2_XS.env": "# NAME=Classique IQ2_XS\nENGINE=strata\nSTRATA_CONFIG={{HOME}}/strata/" + srcDir + "/strata-swift-iq3_xxs.json\n" +
|
||||
"STRATA_MAIN_GPU=1\nSTRATA_HELPER_GPU=-1\nSTRATA_EXPERTS=disk\n",
|
||||
"presets/STRATA FLASH NEXT CLASSIQUE Q2_0.env": "ENGINE=moe\nSTRATA_CONFIG={{HOME}}/strata/ajean-moe-1.0/strata-swift-iq3_xxs.json\n" +
|
||||
"presets/STRATA FLASH NEXT CLASSIQUE Q2_0.env": "ENGINE=moe\nSTRATA_CONFIG={{HOME}}/strata/" + srcDir + "/strata-swift-iq3_xxs.json\n" +
|
||||
"STRATA_MAIN_GPU=0\nSTRATA_HELPER_GPU=1\nSTRATA_HELPER=0\nSTRATA_VISION=0\nSTRATA_EXPERTS=ram\n",
|
||||
}, Method: "GET", Path: "/api/strata"},
|
||||
{Name: "experts.bin et moteur du paquet", Files: map[string]string{
|
||||
"strata/data/packs/swift-iq3_xxs/experts.bin": "x",
|
||||
}, State: map[string]string{"strata_experts_mode": ""}, Method: "GET", Path: "/api/strata"},
|
||||
{Name: "RAM juste sous le seuil du mode hors RAM", Meminfo: "MemTotal: 49283072 kB\nMemAvailable: 43164063 kB\n",
|
||||
{Name: "RAM juste sous le seuil du mode hors RAM", Meminfo: "MemTotal: 49283072 kB\nMemAvailable: 44140625 kB\n",
|
||||
Method: "GET", Path: "/api/strata"},
|
||||
{Name: "RAM au-dessus du seuil du mode hors RAM", Meminfo: "MemTotal: 49283072 kB\nMemAvailable: 43457032 kB\n",
|
||||
{Name: "RAM au-dessus du seuil du mode hors RAM", Meminfo: "MemTotal: 49283072 kB\nMemAvailable: 44433594 kB\n",
|
||||
Method: "GET", Path: "/api/strata"},
|
||||
{Name: "contexte plafonné en mode hors RAM", Files: map[string]string{
|
||||
"presets/STRATA FLASH NEXT SWIFT 1.5 IQ3_XXS.env": "# NAME=Qwen3.8 Flash Next Swift 1.5 IQ3_XXS (Strata)\nENGINE=strata\n" +
|
||||
"STRATA_CONFIG={{HOME}}/strata/" + srcDir + "/strata-swift-iq3_xxs.json\nSTRATA_MAIN_GPU=1\nSTRATA_HELPER_GPU=0\n" +
|
||||
"STRATA_VISION=1\nSTRATA_EXPERTS=auto\nSTRATA_DROP=1\nSTRATA_KV=fp16\nSTRATA_KV_RESIDENT=32768\nSTRATA_SPEC=3\nCTX=196608\nREASONING=off\n",
|
||||
}, Method: "GET", Path: "/api/strata"},
|
||||
{Name: "seconde carte en partage des couches", Files: map[string]string{
|
||||
"presets/STRATA FLASH NEXT SWIFT 1.5 IQ3_XXS.env": "# NAME=Qwen3.8 Flash Next Swift 1.5 IQ3_XXS (Strata)\nENGINE=strata\n" +
|
||||
"STRATA_CONFIG={{HOME}}/strata/" + srcDir + "/strata-swift-iq3_xxs.json\nSTRATA_MAIN_GPU=1\nSTRATA_HELPER_GPU=0\n" +
|
||||
"STRATA_VISION=1\nSTRATA_EXPERTS=auto\nSTRATA_DROP=1\nSTRATA_KV=fp16\nSTRATA_KV_RESIDENT=32768\nSTRATA_SPEC=3\nCTX=196608\nREASONING=off\nSTRATA_SPLIT=layers\n",
|
||||
}, Method: "GET", Path: "/api/strata"},
|
||||
{Name: "mode hors RAM coupé dans le preset", Files: map[string]string{
|
||||
"presets/STRATA FLASH NEXT SWIFT 1.5 IQ3_XXS.env": "# NAME=Qwen3.8 Flash Next Swift 1.5 IQ3_XXS (Strata)\nENGINE=strata\n" +
|
||||
"STRATA_CONFIG={{HOME}}/strata/ajean-moe-1.0/strata-swift-iq3_xxs.json\nSTRATA_MAIN_GPU=1\nSTRATA_HELPER_GPU=0\n" +
|
||||
"STRATA_CONFIG={{HOME}}/strata/" + srcDir + "/strata-swift-iq3_xxs.json\nSTRATA_MAIN_GPU=1\nSTRATA_HELPER_GPU=0\n" +
|
||||
"STRATA_VISION=1\nSTRATA_EXPERTS=auto\nSTRATA_DROP=0\nSTRATA_KV=fp16\nSTRATA_KV_RESIDENT=32768\nSTRATA_SPEC=3\nCTX=131072\nREASONING=off\n",
|
||||
}, Method: "GET", Path: "/api/strata"},
|
||||
{Name: "moteur d'un autre paquet", Meminfo: mem47, Files: map[string]string{
|
||||
"strata/ajean-moe-1.0/engine-cuda12/.loki-engine": "ajean-moe-engine-0.9-linux-x64-cuda12.zip\n",
|
||||
"strata/" + srcDir + "/engine-cuda12/.loki-engine": "ajean-moe-engine-0.9-linux-x64-cuda12.zip\n",
|
||||
}, Method: "GET", Path: "/api/strata"},
|
||||
{Name: "réinstallation, moteur téléchargé périmé", Files: map[string]string{
|
||||
"strata/dl/ajean-moe-engine-1.0-linux-x64-cuda12.zip": "ancien moteur",
|
||||
"strata/dl/" + strataEngineAsset: "ancien moteur",
|
||||
}, Method: "POST", Path: "/api/strata/install", Body: `{"family":"swift","quant":"IQ3_XXS"}`, Wait: true},
|
||||
{Name: "installeur en échec", Method: "POST", Path: "/api/strata/install", Body: `{"family":"qwen","quant":"Q2_0"}`, Wait: true},
|
||||
{Name: "installeur sans configuration", Method: "POST", Path: "/api/strata/install", Body: `{"family":"qwen","quant":"IQ2_XS"}`, Wait: true},
|
||||
{Name: "configuration de lancement disparue", Files: map[string]string{
|
||||
"strata/ajean-moe-1.0/strata-swift-iq3_xxs.json": "-",
|
||||
"strata/" + srcDir + "/strata-swift-iq3_xxs.json": "-",
|
||||
}, Method: "GET", Path: "/api/strata"},
|
||||
{Name: "configuration de lancement illisible", Files: map[string]string{
|
||||
"strata/ajean-moe-1.0/strata-swift-iq3_xxs.json": "[1]",
|
||||
"strata/" + srcDir + "/strata-swift-iq3_xxs.json": "[1]",
|
||||
}, Method: "GET", Path: "/api/strata"},
|
||||
} {
|
||||
run(st)
|
||||
|
||||
@@ -222,6 +222,10 @@ func cacheBust(bucket string) {
|
||||
cacheMu.Lock()
|
||||
delete(caches, bucket)
|
||||
cacheMu.Unlock()
|
||||
if bucket == bkState {
|
||||
webKeyCacheBust()
|
||||
retryStateBust()
|
||||
}
|
||||
}
|
||||
|
||||
// dbStamp renvoie la date et la taille du fichier de base — de quoi repérer
|
||||
|
||||
@@ -340,15 +340,24 @@ func TestRetryScanStartWindows(t *testing.T) {
|
||||
// Surcharge retry_patterns : ses motifs ne sont pas bornés, donc pas de fenêtre.
|
||||
func TestTextualToolCallFromCustomFullScan(t *testing.T) {
|
||||
retryPatterns()
|
||||
retryMu.Lock()
|
||||
saved := retryRes
|
||||
savedCustom := retryCustom
|
||||
defer func() { retryRes, retryCustom = saved, savedCustom }()
|
||||
retryRes, retryCustom = []*regexp.Regexp{regexp.MustCompile(`(?mi)\Aoups`)}, true
|
||||
retryChecked = time.Now() // pas de relecture pendant le test
|
||||
retryMu.Unlock()
|
||||
defer func() {
|
||||
retryMu.Lock()
|
||||
retryRes, retryCustom = saved, savedCustom
|
||||
retryMu.Unlock()
|
||||
}()
|
||||
s := "oups.\n" + strings.Repeat("Une phrase, puis une autre.\n", 400)
|
||||
if !textualToolCallFrom(s, len(s)-10) {
|
||||
t.Fatal("motif ancré en tête manqué")
|
||||
}
|
||||
retryMu.Lock()
|
||||
retryCustom = false
|
||||
retryMu.Unlock()
|
||||
if textualToolCallFrom(s, len(s)-10) {
|
||||
t.Fatal("la fenêtre aurait dû le manquer (le test ne prouve rien sinon)")
|
||||
}
|
||||
|
||||
+121
-8
@@ -1418,6 +1418,10 @@ button:hover{border-color:var(--dim);color:var(--text)}
|
||||
border:1px solid var(--border);border-radius:999px;background:var(--panel);
|
||||
color:var(--dim);font-size:12px;line-height:1.5}
|
||||
.stopnote::before{content:"";flex:none;width:6px;height:6px;border-radius:50%;background:var(--dim)}
|
||||
/* Formules LaTeX (KaTeX, 00-math.js) : une longue formule défile au lieu de déborder. */
|
||||
.katex{font-size:1.08em}
|
||||
.katex-display{margin:.6em 0;overflow-x:auto;overflow-y:hidden;padding:2px 0}
|
||||
.math-pending{font-family:var(--mono);font-size:.92em;color:var(--dim)}
|
||||
|
||||
/* ===== Fenêtres et sélecteurs =====================================
|
||||
Coins arrondis et ombre douce plutôt qu'un cadre marqué. */
|
||||
@@ -3500,6 +3504,7 @@ html[data-files="1"] #files-btn{color:var(--accent)}
|
||||
</div>
|
||||
<div id="m-hf-bar" class="pe-bar" style="display:none"><i></i></div>
|
||||
<div id="m-hf-progress" class="pe-note" style="display:none"></div>
|
||||
<button id="m-hf-pause" class="pe-link" onclick="togglePauseDownload()" style="display:none">Mettre en pause</button>
|
||||
<button id="m-hf-cancel" class="pe-link" onclick="cancelDownload()" style="display:none">Annuler le téléchargement</button>
|
||||
</div>
|
||||
<div class="pe-row">
|
||||
@@ -3819,6 +3824,76 @@ html[data-files="1"] #files-btn{color:var(--accent)}
|
||||
</div>
|
||||
<script src="marked.min.js"></script>
|
||||
<script>
|
||||
// --- Formules LaTeX (KaTeX) ---------------------------------------------------
|
||||
// Repris d'AJEAN (#123). md() (00-md.js) met les formules de côté AVANT marked,
|
||||
// qui abîmerait \\, _ et * : un repère (caractères à usage privé U+E000/U+E001
|
||||
// autour du numéro) prend leur place, puis reçoit le rendu. Le code (blocs et
|
||||
// `en ligne`) n'est jamais touché. Formes reconnues : $$…$$ et \[…\] (bloc),
|
||||
// \(…\) et $…$ (en ligne). $…$ n'est une formule que s'il ne commence ni ne finit
|
||||
// par une espace, n'est pas collé à un mot ni échappé (\$) avant, et n'est pas
|
||||
// suivi d'un chiffre : « 5 $ et 10 $ » ou « $5 et $10 » restent du texte.
|
||||
// Ajouts de Loki : un $ ouvrant précédé d'un chiffre et d'une espace (« 5 $, »)
|
||||
// est un montant, et $…$ ne contient pas de ` — sans quoi « 5 $, code `$x$` »
|
||||
// s'apparierait par-dessus le code en ligne.
|
||||
// KaTeX (embarqué, servi sous /katex/ par web_katex.go et la façade) n'est chargé
|
||||
// qu'à la première formule ; d'ici là la source s'affiche, puis est rendue sur
|
||||
// place. (Pas de lookbehind dans l'expression : les iOS d'avant 16.4 refusent de
|
||||
// charger le script entier. Ces contrôles-là sont faits dans le remplacement.)
|
||||
//
|
||||
// MATH_RE et mathExtract sont purs (ni DOM ni réseau) : web_katex_math_test.go les
|
||||
// exécute sous node sur les cas limites (montants, code, échappements).
|
||||
const MATH_RE = /(```[\s\S]*?(?:```|$)|~~~[\s\S]*?(?:~~~|$)|`[^`\n]*`)|\$\$([\s\S]+?)\$\$|\\\[([\s\S]+?)\\\]|\\\(([\s\S]+?)\\\)|\$(?![\s$])((?:\\.|[^$\\\n`])+?)\$(?![\d$])/g;
|
||||
const MATH_MARK_RE = /\uE000(\d+)\uE001/g;
|
||||
function mathExtract(s){
|
||||
const list = [];
|
||||
const text = s.replace(MATH_RE, (all, code, dd, br, pa, d, at)=>{
|
||||
if(code !== undefined) return all;
|
||||
// $…$ : pas collé à un mot ni échappé (\$) avant, pas un montant (« 5 $ »),
|
||||
// pas d'espace avant le $ final.
|
||||
if(d !== undefined && (/[\\$\w]/.test(s[at-1] || '') || /\d\s$/.test(s.slice(Math.max(0, at-2), at)) || /\s$/.test(d))) return all;
|
||||
const display = dd !== undefined || br !== undefined;
|
||||
list.push({tex: (dd ?? br ?? pa ?? d).trim(), display});
|
||||
return '\uE000' + (list.length-1) + '\uE001';
|
||||
});
|
||||
return {text, list};
|
||||
}
|
||||
// mathRestore remplace chaque repère du HTML produit par marked par le rendu de
|
||||
// sa formule (render : formule → HTML).
|
||||
function mathRestore(html, list, render){
|
||||
return list.length ? html.replace(MATH_MARK_RE, (all, i)=>list[+i] ? render(list[+i]) : all) : html;
|
||||
}
|
||||
const mathEsc = (s)=>s.replace(/[&<>"]/g, c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));
|
||||
const MATH_OPTS = {throwOnError: false, output: 'html'};
|
||||
function mathHtml(f){
|
||||
if(window.katex){
|
||||
try{ return katex.renderToString(f.tex, {...MATH_OPTS, displayMode: f.display}); }
|
||||
catch(_){ return '<code>'+mathEsc(f.tex)+'</code>'; }
|
||||
}
|
||||
mathLoad();
|
||||
return '<span class="math-pending" data-d="'+(f.display?1:0)+'">'+mathEsc(f.tex)+'</span>';
|
||||
}
|
||||
let mathLoading = null;
|
||||
function mathLoad(){
|
||||
if(mathLoading) return mathLoading;
|
||||
if(!document.querySelector('link[data-katex]')){
|
||||
const css = document.createElement('link');
|
||||
css.rel = 'stylesheet'; css.href = 'katex/katex.min.css'; css.dataset.katex = '1';
|
||||
document.head.appendChild(css);
|
||||
}
|
||||
mathLoading = new Promise((ok, ko)=>{
|
||||
const s = document.createElement('script');
|
||||
s.src = 'katex/katex.min.js'; s.onload = ok;
|
||||
// Échec (hors ligne, 404) : script retiré, nouvel essai à la prochaine formule.
|
||||
s.onerror = ()=>{ s.remove(); ko(); };
|
||||
document.head.appendChild(s);
|
||||
}).then(()=>{
|
||||
document.querySelectorAll('.math-pending').forEach(el=>{
|
||||
try{ katex.render(el.textContent, el, {...MATH_OPTS, displayMode: el.dataset.d === '1'}); }catch(_){}
|
||||
el.classList.remove('math-pending');
|
||||
});
|
||||
}).catch(()=>{ mathLoading = null; });
|
||||
return mathLoading;
|
||||
}
|
||||
// marked: GFM on, no auto-breaks (single newlines stay inline).
|
||||
marked.setOptions({ gfm: true, breaks: false });
|
||||
// Pre-process to fix common LLM markdown mistakes before handing to marked:
|
||||
@@ -3835,7 +3910,13 @@ function fixMd(s){
|
||||
s = s.replace(/\n{3,}/g, '\n\n');
|
||||
return s;
|
||||
}
|
||||
function md(src){ return src ? marked.parse(fixMd(src)) : ''; }
|
||||
// Les formules LaTeX sont mises de côté avant marked puis rendues par KaTeX
|
||||
// (00-math.js) ; le code et les montants en $ ne sont pas touchés.
|
||||
function md(src){
|
||||
if(!src) return '';
|
||||
const m = mathExtract(fixMd(src));
|
||||
return mathRestore(marked.parse(m.text), m.list, mathHtml);
|
||||
}
|
||||
// Les garde-fous du serveur ("[stop: trop d'appels d'outils]") sont concaténés
|
||||
// au texte de la réponse : ils arrivent donc en clair, au milieu du markdown.
|
||||
// markNotices les sort du fil pour qu'on ne les prenne pas pour une phrase du
|
||||
@@ -4521,6 +4602,7 @@ async function loadCfg(){
|
||||
rows.push(row('CTX', d.ctx));
|
||||
rows.push(row('KV', d.kv+(d.kv_resident ? ' · '+k(d.kv_resident)+' VRAM' : '')));
|
||||
rows.push(row('MTP', d.spec));
|
||||
if(d.helper_gpu) rows.push(row('GPU 2', d.split_layers ? 'couches' : 'aide', d.helper_gpu));
|
||||
rows.push(row('EXPERTS', ({drop:'mlock (hors appoint)', ram:'RAM', arena:'experts.bin', disk:'mmap (disque)'})[d.mode] || (d.mmap ? 'mmap' : 'RAM'), d.mode_label||''));
|
||||
if(d.vision_gpu) rows.push(row('VISION', 'on', d.vision_gpu));
|
||||
}else if(c.CTX) rows.push(row('CTX', c.CTX));
|
||||
@@ -6753,6 +6835,7 @@ function dlEls(){
|
||||
prog: document.getElementById('m-hf-progress'),
|
||||
bar: document.getElementById('m-hf-bar'),
|
||||
cancel: document.getElementById('m-hf-cancel'),
|
||||
pause: document.getElementById('m-hf-pause'),
|
||||
};
|
||||
}
|
||||
// Arrête le poll local sans toucher au téléchargement serveur.
|
||||
@@ -6762,7 +6845,7 @@ function resetDlUI(){
|
||||
const e = dlEls();
|
||||
stopDlPoll(); dlName='';
|
||||
e.prog.style.display='none'; e.bar.style.display='none';
|
||||
e.cancel.style.display='none'; e.btn.disabled=false;
|
||||
e.cancel.style.display='none'; e.pause.style.display='none'; e.btn.disabled=false;
|
||||
}
|
||||
// Remplit le sélecteur de destination avec les dossiers de modèles connus.
|
||||
// L'espace libre est dans le libellé de l'option : c'est l'info utile au moment
|
||||
@@ -6879,6 +6962,22 @@ async function cancelDownload(){
|
||||
await jpost('/api/models/download/cancel', {filename: dlName});
|
||||
// L'état « annulé » est confirmé par le prochain tick de poll.
|
||||
}
|
||||
// Met en pause ou reprend : le fichier partiel est gardé, la reprise repart
|
||||
// de là où le transfert s'était arrêté (repris d'AJEAN v0.18.1).
|
||||
let dlPaused = false;
|
||||
async function togglePauseDownload(){
|
||||
if(!dlName) return;
|
||||
const e = dlEls();
|
||||
e.pause.disabled = true;
|
||||
try{
|
||||
const r = await jpost('/api/models/download/pause', {filename: dlName, pause: !dlPaused});
|
||||
if(r.ok) setDlPaused(r.paused);
|
||||
}finally{ e.pause.disabled = false; }
|
||||
}
|
||||
function setDlPaused(p){
|
||||
dlPaused = !!p;
|
||||
dlEls().pause.textContent = dlPaused ? 'Reprendre' : 'Mettre en pause';
|
||||
}
|
||||
// Suit un téléchargement (nouveau ou déjà en cours) et pilote barre + texte.
|
||||
function watchDownload(fname){
|
||||
const e = dlEls();
|
||||
@@ -6889,7 +6988,10 @@ function watchDownload(fname){
|
||||
e.bar.style.display = 'block';
|
||||
e.cancel.style.display = 'inline';
|
||||
e.cancel.disabled = false;
|
||||
const stop = ()=>{ stopDlPoll(); dlName=''; e.btn.disabled=false; e.cancel.style.display='none'; };
|
||||
e.pause.style.display = 'inline';
|
||||
e.pause.disabled = false;
|
||||
setDlPaused(false);
|
||||
const stop = ()=>{ stopDlPoll(); dlName=''; e.btn.disabled=false; e.cancel.style.display='none'; e.pause.style.display='none'; };
|
||||
const tick = async ()=>{
|
||||
const list = await jget('/api/models/download/status');
|
||||
const st = (list||[]).find(d=>d.filename===fname);
|
||||
@@ -6913,6 +7015,7 @@ function watchDownload(fname){
|
||||
if(dlQueue.length) startDownloadURL(dlQueue.shift());
|
||||
return;
|
||||
}
|
||||
if(!!st.paused !== dlPaused) setDlPaused(st.paused);
|
||||
const pct = st.total>0 ? st.done*100/st.total : 0;
|
||||
if(st.total>0){
|
||||
e.bar.className = 'pe-bar';
|
||||
@@ -6922,7 +7025,8 @@ function watchDownload(fname){
|
||||
}
|
||||
const tot = st.total>0 ? ' / '+fmtSize(st.total)+' ('+Math.round(pct)+'%)' : '';
|
||||
let extra = '';
|
||||
if(st.speed>0){
|
||||
if(st.paused) extra += ' — en pause';
|
||||
else if(st.speed>0){
|
||||
extra += ' — '+fmtSize(st.speed)+'/s';
|
||||
if(st.total>0){
|
||||
const eta = Math.max(0, Math.round((st.total-st.done)/st.speed));
|
||||
@@ -9281,7 +9385,11 @@ function strataRenderSettings(fit){
|
||||
['Experts', sel('strata-s-experts', [['auto','auto (selon la RAM libre)'],['ram','en RAM (le plus rapide)'],['disk','depuis le disque (mmap, économe)']], d.experts||'auto')],
|
||||
['Lire les images', sw('strata-s-vision', d.vision)],
|
||||
];
|
||||
if(d.has_helper) rows.push(["Carte d'appoint", sw('strata-s-helper', d.helper_on)]);
|
||||
// Seconde carte : aide aux experts (défaut mesuré) ou partage des couches,
|
||||
// souvent plus rapide avec deux cartes identiques (#121 d'AJEAN).
|
||||
if(d.has_helper) rows.push(['Seconde carte', sel('strata-s-helper',
|
||||
[['off', 'non utilisée'], ['helper', 'aide aux experts'], ['layers', 'partage des couches']],
|
||||
!d.helper_on ? 'off' : d.split_layers ? 'layers' : 'helper')]);
|
||||
document.getElementById('strata-settings').innerHTML = rows.map(([a,b])=>
|
||||
'<div class="moe-kv"><span>'+escHtml(a)+'</span><span>'+b+'</span></div>').join('');
|
||||
strataCtxLbl();
|
||||
@@ -9301,8 +9409,11 @@ async function strataSaveSettings(){
|
||||
if(!fit || !fit.preset) return;
|
||||
const v = id => document.getElementById(id);
|
||||
const body = {preset: fit.preset, ctx: +v('strata-s-ctx').value, kv: v('strata-s-kv').value, spec: +v('strata-s-spec').value,
|
||||
vision: v('strata-s-vision').checked, helper: v('strata-s-helper') ? v('strata-s-helper').checked : false,
|
||||
vision: v('strata-s-vision').checked,
|
||||
experts: v('strata-s-experts') ? v('strata-s-experts').value : ''};
|
||||
const second = v('strata-s-helper') ? v('strata-s-helper').value : 'off';
|
||||
body.helper = second !== 'off';
|
||||
if(body.helper) body.split = second;
|
||||
if(fit.active && !await askConfirm('Le modèle va redémarrer avec ces réglages (quelques minutes).', {title:'Strata — Qwen3.8 Flash Next', okText:'Enregistrer'})) return;
|
||||
const r = await jpost('/api/strata/settings', body);
|
||||
if(!r.ok){ toast('Erreur : '+(r.error||'')); return; }
|
||||
@@ -9321,14 +9432,16 @@ function strataRenderDetails(fit){
|
||||
const k = n => (+n >= 1024 ? Math.round(+n/1024)+'K' : n);
|
||||
const rows = [
|
||||
['Carte principale', d.main_gpu],
|
||||
["Carte d'appoint", d.helper_gpu || 'aucune'],
|
||||
['Seconde carte', d.helper_gpu ? d.helper_gpu + (d.split_layers ? ' · partage des couches' : ' · aide aux experts') : 'aucune'],
|
||||
['Lecture des images', d.vision_gpu || 'aucune'],
|
||||
['Contexte', (+d.ctx).toLocaleString('fr-FR')+' jetons'],
|
||||
['Cache KV', d.kv + (d.kv_resident ? ' · '+k(d.kv_resident)+' en VRAM, le reste en RAM' : '')],
|
||||
['Prédiction (MTP)', d.spec+' jetons'],
|
||||
['Experts hors GPU', (d.mode_label || (d.mmap ? 'lus depuis le disque (mmap)' : 'chargés en RAM'))
|
||||
+ (fit.active ? ' — dernier lancement' : ' — selon la RAM libre maintenant')],
|
||||
['Lecture des prompts', 'blocs de '+k(d.prefill)+', lecture directe sous '+d.short_read+' jetons'],
|
||||
// paquet 1.1 : la carte d'appoint calcule ses experts à la lecture d'un prompt
|
||||
['Lecture des prompts', 'blocs de '+k(d.prefill)+', lecture directe sous '+d.short_read+' jetons'
|
||||
+ (d.helper_gpu && !d.split_layers ? ", experts de la carte d'appoint calculés par elle" : '')],
|
||||
["Démarrage d'une conversation", 'reprise après le prompt système (dès '+d.cache_root+' jetons)'],
|
||||
['Version du moteur', 'paquet AJEAN MoE '+d.version],
|
||||
];
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2013-2020 Khan Academy and other contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Vendored
+1
@@ -0,0 +1 @@
|
||||
@font-face{font-family:KaTeX_AMS;font-style:normal;font-weight:400;src:url(fonts/KaTeX_AMS-Regular.woff2) format("woff2"),url(fonts/KaTeX_AMS-Regular.woff) format("woff"),url(fonts/KaTeX_AMS-Regular.ttf) format("truetype")}@font-face{font-family:KaTeX_Caligraphic;font-style:normal;font-weight:700;src:url(fonts/KaTeX_Caligraphic-Bold.woff2) format("woff2"),url(fonts/KaTeX_Caligraphic-Bold.woff) format("woff"),url(fonts/KaTeX_Caligraphic-Bold.ttf) format("truetype")}@font-face{font-family:KaTeX_Caligraphic;font-style:normal;font-weight:400;src:url(fonts/KaTeX_Caligraphic-Regular.woff2) format("woff2"),url(fonts/KaTeX_Caligraphic-Regular.woff) format("woff"),url(fonts/KaTeX_Caligraphic-Regular.ttf) format("truetype")}@font-face{font-family:KaTeX_Fraktur;font-style:normal;font-weight:700;src:url(fonts/KaTeX_Fraktur-Bold.woff2) format("woff2"),url(fonts/KaTeX_Fraktur-Bold.woff) format("woff"),url(fonts/KaTeX_Fraktur-Bold.ttf) format("truetype")}@font-face{font-family:KaTeX_Fraktur;font-style:normal;font-weight:400;src:url(fonts/KaTeX_Fraktur-Regular.woff2) format("woff2"),url(fonts/KaTeX_Fraktur-Regular.woff) format("woff"),url(fonts/KaTeX_Fraktur-Regular.ttf) format("truetype")}@font-face{font-family:KaTeX_Main;font-style:normal;font-weight:700;src:url(fonts/KaTeX_Main-Bold.woff2) format("woff2"),url(fonts/KaTeX_Main-Bold.woff) format("woff"),url(fonts/KaTeX_Main-Bold.ttf) format("truetype")}@font-face{font-family:KaTeX_Main;font-style:italic;font-weight:700;src:url(fonts/KaTeX_Main-BoldItalic.woff2) format("woff2"),url(fonts/KaTeX_Main-BoldItalic.woff) format("woff"),url(fonts/KaTeX_Main-BoldItalic.ttf) format("truetype")}@font-face{font-family:KaTeX_Main;font-style:italic;font-weight:400;src:url(fonts/KaTeX_Main-Italic.woff2) format("woff2"),url(fonts/KaTeX_Main-Italic.woff) format("woff"),url(fonts/KaTeX_Main-Italic.ttf) format("truetype")}@font-face{font-family:KaTeX_Main;font-style:normal;font-weight:400;src:url(fonts/KaTeX_Main-Regular.woff2) format("woff2"),url(fonts/KaTeX_Main-Regular.woff) format("woff"),url(fonts/KaTeX_Main-Regular.ttf) format("truetype")}@font-face{font-family:KaTeX_Math;font-style:italic;font-weight:700;src:url(fonts/KaTeX_Math-BoldItalic.woff2) format("woff2"),url(fonts/KaTeX_Math-BoldItalic.woff) format("woff"),url(fonts/KaTeX_Math-BoldItalic.ttf) format("truetype")}@font-face{font-family:KaTeX_Math;font-style:italic;font-weight:400;src:url(fonts/KaTeX_Math-Italic.woff2) format("woff2"),url(fonts/KaTeX_Math-Italic.woff) format("woff"),url(fonts/KaTeX_Math-Italic.ttf) format("truetype")}@font-face{font-family:"KaTeX_SansSerif";font-style:normal;font-weight:700;src:url(fonts/KaTeX_SansSerif-Bold.woff2) format("woff2"),url(fonts/KaTeX_SansSerif-Bold.woff) format("woff"),url(fonts/KaTeX_SansSerif-Bold.ttf) format("truetype")}@font-face{font-family:"KaTeX_SansSerif";font-style:italic;font-weight:400;src:url(fonts/KaTeX_SansSerif-Italic.woff2) format("woff2"),url(fonts/KaTeX_SansSerif-Italic.woff) format("woff"),url(fonts/KaTeX_SansSerif-Italic.ttf) format("truetype")}@font-face{font-family:"KaTeX_SansSerif";font-style:normal;font-weight:400;src:url(fonts/KaTeX_SansSerif-Regular.woff2) format("woff2"),url(fonts/KaTeX_SansSerif-Regular.woff) format("woff"),url(fonts/KaTeX_SansSerif-Regular.ttf) format("truetype")}@font-face{font-family:KaTeX_Script;font-style:normal;font-weight:400;src:url(fonts/KaTeX_Script-Regular.woff2) format("woff2"),url(fonts/KaTeX_Script-Regular.woff) format("woff"),url(fonts/KaTeX_Script-Regular.ttf) format("truetype")}@font-face{font-family:KaTeX_Size1;font-style:normal;font-weight:400;src:url(fonts/KaTeX_Size1-Regular.woff2) format("woff2"),url(fonts/KaTeX_Size1-Regular.woff) format("woff"),url(fonts/KaTeX_Size1-Regular.ttf) format("truetype")}@font-face{font-family:KaTeX_Size2;font-style:normal;font-weight:400;src:url(fonts/KaTeX_Size2-Regular.woff2) format("woff2"),url(fonts/KaTeX_Size2-Regular.woff) format("woff"),url(fonts/KaTeX_Size2-Regular.ttf) format("truetype")}@font-face{font-family:KaTeX_Size3;font-style:normal;font-weight:400;src:url(fonts/KaTeX_Size3-Regular.woff2) format("woff2"),url(fonts/KaTeX_Size3-Regular.woff) format("woff"),url(fonts/KaTeX_Size3-Regular.ttf) format("truetype")}@font-face{font-family:KaTeX_Size4;font-style:normal;font-weight:400;src:url(fonts/KaTeX_Size4-Regular.woff2) format("woff2"),url(fonts/KaTeX_Size4-Regular.woff) format("woff"),url(fonts/KaTeX_Size4-Regular.ttf) format("truetype")}@font-face{font-family:KaTeX_Typewriter;font-style:normal;font-weight:400;src:url(fonts/KaTeX_Typewriter-Regular.woff2) format("woff2"),url(fonts/KaTeX_Typewriter-Regular.woff) format("woff"),url(fonts/KaTeX_Typewriter-Regular.ttf) format("truetype")}.katex{font:normal 1.21em KaTeX_Main,Times New Roman,serif;line-height:1.2;text-indent:0;text-rendering:auto}.katex *{-ms-high-contrast-adjust:none!important;border-color:currentColor}.katex .katex-version:after{content:"0.16.11"}.katex .katex-mathml{clip:rect(1px,1px,1px,1px);border:0;height:1pLine truncated
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.katex=t():e.katex=t()}("undefined"!=typeof self?self:this,(function(){return function(){"use strict";var e={d:function(t,r){for(var n in r)e.o(r,n)&&!e.o(t,n)&&Object.defineProperty(t,n,{enumerable:!0,get:r[n]})},o:function(e,t){return Object.prototype.hasOwnProperty.call(e,t)}},t={};e.d(t,{default:function(){return Yn}});class r{constructor(e,t){this.name=void 0,this.position=void 0,this.length=void 0,this.rawMessage=void 0;let n,o,s="KaTeX parse error: "+e;const i=t&&t.loc;if(i&&i.start<=i.end){const e=i.lexer.input;n=i.start,o=i.end,n===e.length?s+=" at end of input: ":s+=" at position "+(n+1)+": ";const t=e.slice(n,o).replace(/[^]/g,"$&\u0332");let r,a;r=n>15?"\u2026"+e.slice(n-15,n):e.slice(0,n),a=o+15<e.length?e.slice(o,o+15)+"\u2026":e.slice(o),s+=r+t+a}const a=new Error(s);return a.name="ParseError",a.__proto__=r.prototype,a.position=n,null!=n&&null!=o&&(a.length=o-n),a.rawMessage=e,a}}r.prototype.__proto__=Error.prototype;var n=r;const o=/([A-Z])/g,s={"&":"&",">":">","<":"<",'"':""","'":"'"},i=/[&><"']/g;const a=function(e){return"ordgroup"===e.type||"color"===e.type?1===e.body.length?a(e.body[0]):e:"font"===e.type?a(e.body):e};var l={contains:function(e,t){return-1!==e.indexOf(t)},deflt:function(e,t){return void 0===e?t:e},escape:function(e){return String(e).replace(i,(e=>s[e]))},hyphenate:function(e){return e.replace(o,"-$1").toLowerCase()},getBaseElem:a,isCharacterBox:function(e){const t=a(e);return"mathord"===t.type||"textord"===t.type||"atom"===t.type},protocolFromUrl:function(e){const t=/^[\x00-\x20]*([^\\/#?]*?)(:|�*58|�*3a|&colon)/i.exec(e);return t?":"!==t[2]?null:/^[a-zA-Z][a-zA-Z0-9+\-.]*$/.test(t[1])?t[1].toLowerCase():null:"_relative"}};const h={displayMode:{type:"boolean",description:"Render math in display mode, which puts the math in display style (so \\int and \\sum are large, for example), and centers the math on the page on its own line.",cli:"-d, --display-mode"},output:{type:{enum:["htmlAndMathml","html","mathml"]},description:"Determines the markup language of the output.",cli:"-F, --format <type>"},leqno:{type:"boolean",description:"Render display math in leqno style (left-justified tags)."},fleqn:{type:"boolean",description:"Render display math flush left."},throwOnError:{type:"boolean",default:!0,cli:"-t, --no-throw-on-error",cliDescription:"Render errors (in the color given by --error-color) instead of throwing a ParseError exception when encountering an error."},errorColor:{type:"string",default:"#cc0000",cli:"-c, --error-color <color>",cliDescription:"A color string given in the format 'rgb' or 'rrggbb' (no #). This option determines the color of errors rendered by the -t option.",cliProcessor:e=>"#"+e},macros:{type:"object",cli:"-m, --macro <def>",cliDescription:"Define custom macro of the form '\\foo:expansion' (use multiple -m arguments for multiple macros).",cliDefault:[],cliProcessor:(e,t)=>(t.push(e),t)},minRuleThickness:{type:"number",description:"Specifies a minimum thickness, in ems, for fraction lines, `\\sqrt` top lines, `{array}` vertical lines, `\\hline`, `\\hdashline`, `\\underline`, `\\overline`, and the borders of `\\fbox`, `\\boxed`, and `\\fcolorbox`.",processor:e=>Math.max(0,e),cli:"--min-rule-thickness <size>",cliProcessor:parseFloat},colorIsTextColor:{type:"boolean",description:"Makes \\color behave like LaTeX's 2-argument \\textcolor, instead of LaTeX's one-argument \\color mode change.",cli:"-b, --color-is-text-color"},strict:{type:[{enum:["warn","ignore","error"]},"boolean","function"],description:"Turn on strict / LaTeX faithfulness mode, which throws an error if the input uses features that are not supported by LaTeX.",cli:"-S, --strict",cliDefault:!1},trust:{type:["boolean","function"],description:"Trust the input, enabling all HTML features such as \\url.",cli:"-T, --trust"},maxSize:{type:"number",default:1/0,description:"If non-zero, all user-specified sizes, e.g. in \\rule{500em}{500em}, will be capped to maxSize ems. Otherwise, elements and spaces can be arbitrarily large",processor:e=>Math.max(0,e),cli:"-s, --max-size <n>",cliProcessor:parseInt},maxExpand:{type:"number",default:1e3,description:"Limit the number of macro expansions to the specified number, to prevent e.g. infinite macro loops. If set to Infinity, the macro expander will try to fully expand as in LaTeX.",processor:e=>Math.max(0,e),cli:"-e, --max-expand <n>",cliProcessor:e=>"Infinity"===e?1/0:parseInt(e)},globalGroup:{type:"boolean",cli:!1}};function c(e){if(e.default)return e.default;const t=e.type,r=Array.isArray(t)?t[0]:t;if("string"!=typeof r)return r.enum[0];switch(r){case"boolean":return!1;case"string":return"";case"number":return 0;case"object":return{}}}class m{constructor(e){this.displayMode=void 0,this.output=void 0,this.leqno=void 0,this.fleqn=void 0,this.throwOnError=void 0Line truncated
|
||||
@@ -1215,6 +1215,7 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
|
||||
</div>
|
||||
<div id="m-hf-bar" class="pe-bar" style="display:none"><i></i></div>
|
||||
<div id="m-hf-progress" class="pe-note" style="display:none"></div>
|
||||
<button id="m-hf-pause" class="pe-link" onclick="togglePauseDownload()" style="display:none">Mettre en pause</button>
|
||||
<button id="m-hf-cancel" class="pe-link" onclick="cancelDownload()" style="display:none">Annuler le téléchargement</button>
|
||||
</div>
|
||||
<div class="pe-row">
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
// --- Formules LaTeX (KaTeX) ---------------------------------------------------
|
||||
// Repris d'AJEAN (#123). md() (00-md.js) met les formules de côté AVANT marked,
|
||||
// qui abîmerait \\, _ et * : un repère (caractères à usage privé U+E000/U+E001
|
||||
// autour du numéro) prend leur place, puis reçoit le rendu. Le code (blocs et
|
||||
// `en ligne`) n'est jamais touché. Formes reconnues : $$…$$ et \[…\] (bloc),
|
||||
// \(…\) et $…$ (en ligne). $…$ n'est une formule que s'il ne commence ni ne finit
|
||||
// par une espace, n'est pas collé à un mot ni échappé (\$) avant, et n'est pas
|
||||
// suivi d'un chiffre : « 5 $ et 10 $ » ou « $5 et $10 » restent du texte.
|
||||
// Ajouts de Loki : un $ ouvrant précédé d'un chiffre et d'une espace (« 5 $, »)
|
||||
// est un montant, et $…$ ne contient pas de ` — sans quoi « 5 $, code `$x$` »
|
||||
// s'apparierait par-dessus le code en ligne.
|
||||
// KaTeX (embarqué, servi sous /katex/ par web_katex.go et la façade) n'est chargé
|
||||
// qu'à la première formule ; d'ici là la source s'affiche, puis est rendue sur
|
||||
// place. (Pas de lookbehind dans l'expression : les iOS d'avant 16.4 refusent de
|
||||
// charger le script entier. Ces contrôles-là sont faits dans le remplacement.)
|
||||
//
|
||||
// MATH_RE et mathExtract sont purs (ni DOM ni réseau) : web_katex_math_test.go les
|
||||
// exécute sous node sur les cas limites (montants, code, échappements).
|
||||
const MATH_RE = /(```[\s\S]*?(?:```|$)|~~~[\s\S]*?(?:~~~|$)|`[^`\n]*`)|\$\$([\s\S]+?)\$\$|\\\[([\s\S]+?)\\\]|\\\(([\s\S]+?)\\\)|\$(?![\s$])((?:\\.|[^$\\\n`])+?)\$(?![\d$])/g;
|
||||
const MATH_MARK_RE = /\uE000(\d+)\uE001/g;
|
||||
function mathExtract(s){
|
||||
const list = [];
|
||||
const text = s.replace(MATH_RE, (all, code, dd, br, pa, d, at)=>{
|
||||
if(code !== undefined) return all;
|
||||
// $…$ : pas collé à un mot ni échappé (\$) avant, pas un montant (« 5 $ »),
|
||||
// pas d'espace avant le $ final.
|
||||
if(d !== undefined && (/[\\$\w]/.test(s[at-1] || '') || /\d\s$/.test(s.slice(Math.max(0, at-2), at)) || /\s$/.test(d))) return all;
|
||||
const display = dd !== undefined || br !== undefined;
|
||||
list.push({tex: (dd ?? br ?? pa ?? d).trim(), display});
|
||||
return '\uE000' + (list.length-1) + '\uE001';
|
||||
});
|
||||
return {text, list};
|
||||
}
|
||||
// mathRestore remplace chaque repère du HTML produit par marked par le rendu de
|
||||
// sa formule (render : formule → HTML).
|
||||
function mathRestore(html, list, render){
|
||||
return list.length ? html.replace(MATH_MARK_RE, (all, i)=>list[+i] ? render(list[+i]) : all) : html;
|
||||
}
|
||||
const mathEsc = (s)=>s.replace(/[&<>"]/g, c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));
|
||||
const MATH_OPTS = {throwOnError: false, output: 'html'};
|
||||
function mathHtml(f){
|
||||
if(window.katex){
|
||||
try{ return katex.renderToString(f.tex, {...MATH_OPTS, displayMode: f.display}); }
|
||||
catch(_){ return '<code>'+mathEsc(f.tex)+'</code>'; }
|
||||
}
|
||||
mathLoad();
|
||||
return '<span class="math-pending" data-d="'+(f.display?1:0)+'">'+mathEsc(f.tex)+'</span>';
|
||||
}
|
||||
let mathLoading = null;
|
||||
function mathLoad(){
|
||||
if(mathLoading) return mathLoading;
|
||||
if(!document.querySelector('link[data-katex]')){
|
||||
const css = document.createElement('link');
|
||||
css.rel = 'stylesheet'; css.href = 'katex/katex.min.css'; css.dataset.katex = '1';
|
||||
document.head.appendChild(css);
|
||||
}
|
||||
mathLoading = new Promise((ok, ko)=>{
|
||||
const s = document.createElement('script');
|
||||
s.src = 'katex/katex.min.js'; s.onload = ok;
|
||||
// Échec (hors ligne, 404) : script retiré, nouvel essai à la prochaine formule.
|
||||
s.onerror = ()=>{ s.remove(); ko(); };
|
||||
document.head.appendChild(s);
|
||||
}).then(()=>{
|
||||
document.querySelectorAll('.math-pending').forEach(el=>{
|
||||
try{ katex.render(el.textContent, el, {...MATH_OPTS, displayMode: el.dataset.d === '1'}); }catch(_){}
|
||||
el.classList.remove('math-pending');
|
||||
});
|
||||
}).catch(()=>{ mathLoading = null; });
|
||||
return mathLoading;
|
||||
}
|
||||
@@ -14,7 +14,13 @@ function fixMd(s){
|
||||
s = s.replace(/\n{3,}/g, '\n\n');
|
||||
return s;
|
||||
}
|
||||
function md(src){ return src ? marked.parse(fixMd(src)) : ''; }
|
||||
// Les formules LaTeX sont mises de côté avant marked puis rendues par KaTeX
|
||||
// (00-math.js) ; le code et les montants en $ ne sont pas touchés.
|
||||
function md(src){
|
||||
if(!src) return '';
|
||||
const m = mathExtract(fixMd(src));
|
||||
return mathRestore(marked.parse(m.text), m.list, mathHtml);
|
||||
}
|
||||
// Les garde-fous du serveur ("[stop: trop d'appels d'outils]") sont concaténés
|
||||
// au texte de la réponse : ils arrivent donc en clair, au milieu du markdown.
|
||||
// markNotices les sort du fil pour qu'on ne les prenne pas pour une phrase du
|
||||
|
||||
@@ -330,6 +330,7 @@ async function loadCfg(){
|
||||
rows.push(row('CTX', d.ctx));
|
||||
rows.push(row('KV', d.kv+(d.kv_resident ? ' · '+k(d.kv_resident)+' VRAM' : '')));
|
||||
rows.push(row('MTP', d.spec));
|
||||
if(d.helper_gpu) rows.push(row('GPU 2', d.split_layers ? 'couches' : 'aide', d.helper_gpu));
|
||||
rows.push(row('EXPERTS', ({drop:'mlock (hors appoint)', ram:'RAM', arena:'experts.bin', disk:'mmap (disque)'})[d.mode] || (d.mmap ? 'mmap' : 'RAM'), d.mode_label||''));
|
||||
if(d.vision_gpu) rows.push(row('VISION', 'on', d.vision_gpu));
|
||||
}else if(c.CTX) rows.push(row('CTX', c.CTX));
|
||||
|
||||
@@ -1501,6 +1501,7 @@ function dlEls(){
|
||||
prog: document.getElementById('m-hf-progress'),
|
||||
bar: document.getElementById('m-hf-bar'),
|
||||
cancel: document.getElementById('m-hf-cancel'),
|
||||
pause: document.getElementById('m-hf-pause'),
|
||||
};
|
||||
}
|
||||
// Arrête le poll local sans toucher au téléchargement serveur.
|
||||
@@ -1510,7 +1511,7 @@ function resetDlUI(){
|
||||
const e = dlEls();
|
||||
stopDlPoll(); dlName='';
|
||||
e.prog.style.display='none'; e.bar.style.display='none';
|
||||
e.cancel.style.display='none'; e.btn.disabled=false;
|
||||
e.cancel.style.display='none'; e.pause.style.display='none'; e.btn.disabled=false;
|
||||
}
|
||||
// Remplit le sélecteur de destination avec les dossiers de modèles connus.
|
||||
// L'espace libre est dans le libellé de l'option : c'est l'info utile au moment
|
||||
@@ -1627,6 +1628,22 @@ async function cancelDownload(){
|
||||
await jpost('/api/models/download/cancel', {filename: dlName});
|
||||
// L'état « annulé » est confirmé par le prochain tick de poll.
|
||||
}
|
||||
// Met en pause ou reprend : le fichier partiel est gardé, la reprise repart
|
||||
// de là où le transfert s'était arrêté (repris d'AJEAN v0.18.1).
|
||||
let dlPaused = false;
|
||||
async function togglePauseDownload(){
|
||||
if(!dlName) return;
|
||||
const e = dlEls();
|
||||
e.pause.disabled = true;
|
||||
try{
|
||||
const r = await jpost('/api/models/download/pause', {filename: dlName, pause: !dlPaused});
|
||||
if(r.ok) setDlPaused(r.paused);
|
||||
}finally{ e.pause.disabled = false; }
|
||||
}
|
||||
function setDlPaused(p){
|
||||
dlPaused = !!p;
|
||||
dlEls().pause.textContent = dlPaused ? 'Reprendre' : 'Mettre en pause';
|
||||
}
|
||||
// Suit un téléchargement (nouveau ou déjà en cours) et pilote barre + texte.
|
||||
function watchDownload(fname){
|
||||
const e = dlEls();
|
||||
@@ -1637,7 +1654,10 @@ function watchDownload(fname){
|
||||
e.bar.style.display = 'block';
|
||||
e.cancel.style.display = 'inline';
|
||||
e.cancel.disabled = false;
|
||||
const stop = ()=>{ stopDlPoll(); dlName=''; e.btn.disabled=false; e.cancel.style.display='none'; };
|
||||
e.pause.style.display = 'inline';
|
||||
e.pause.disabled = false;
|
||||
setDlPaused(false);
|
||||
const stop = ()=>{ stopDlPoll(); dlName=''; e.btn.disabled=false; e.cancel.style.display='none'; e.pause.style.display='none'; };
|
||||
const tick = async ()=>{
|
||||
const list = await jget('/api/models/download/status');
|
||||
const st = (list||[]).find(d=>d.filename===fname);
|
||||
@@ -1661,6 +1681,7 @@ function watchDownload(fname){
|
||||
if(dlQueue.length) startDownloadURL(dlQueue.shift());
|
||||
return;
|
||||
}
|
||||
if(!!st.paused !== dlPaused) setDlPaused(st.paused);
|
||||
const pct = st.total>0 ? st.done*100/st.total : 0;
|
||||
if(st.total>0){
|
||||
e.bar.className = 'pe-bar';
|
||||
@@ -1670,7 +1691,8 @@ function watchDownload(fname){
|
||||
}
|
||||
const tot = st.total>0 ? ' / '+fmtSize(st.total)+' ('+Math.round(pct)+'%)' : '';
|
||||
let extra = '';
|
||||
if(st.speed>0){
|
||||
if(st.paused) extra += ' — en pause';
|
||||
else if(st.speed>0){
|
||||
extra += ' — '+fmtSize(st.speed)+'/s';
|
||||
if(st.total>0){
|
||||
const eta = Math.max(0, Math.round((st.total-st.done)/st.speed));
|
||||
|
||||
@@ -128,7 +128,11 @@ function strataRenderSettings(fit){
|
||||
['Experts', sel('strata-s-experts', [['auto','auto (selon la RAM libre)'],['ram','en RAM (le plus rapide)'],['disk','depuis le disque (mmap, économe)']], d.experts||'auto')],
|
||||
['Lire les images', sw('strata-s-vision', d.vision)],
|
||||
];
|
||||
if(d.has_helper) rows.push(["Carte d'appoint", sw('strata-s-helper', d.helper_on)]);
|
||||
// Seconde carte : aide aux experts (défaut mesuré) ou partage des couches,
|
||||
// souvent plus rapide avec deux cartes identiques (#121 d'AJEAN).
|
||||
if(d.has_helper) rows.push(['Seconde carte', sel('strata-s-helper',
|
||||
[['off', 'non utilisée'], ['helper', 'aide aux experts'], ['layers', 'partage des couches']],
|
||||
!d.helper_on ? 'off' : d.split_layers ? 'layers' : 'helper')]);
|
||||
document.getElementById('strata-settings').innerHTML = rows.map(([a,b])=>
|
||||
'<div class="moe-kv"><span>'+escHtml(a)+'</span><span>'+b+'</span></div>').join('');
|
||||
strataCtxLbl();
|
||||
@@ -148,8 +152,11 @@ async function strataSaveSettings(){
|
||||
if(!fit || !fit.preset) return;
|
||||
const v = id => document.getElementById(id);
|
||||
const body = {preset: fit.preset, ctx: +v('strata-s-ctx').value, kv: v('strata-s-kv').value, spec: +v('strata-s-spec').value,
|
||||
vision: v('strata-s-vision').checked, helper: v('strata-s-helper') ? v('strata-s-helper').checked : false,
|
||||
vision: v('strata-s-vision').checked,
|
||||
experts: v('strata-s-experts') ? v('strata-s-experts').value : ''};
|
||||
const second = v('strata-s-helper') ? v('strata-s-helper').value : 'off';
|
||||
body.helper = second !== 'off';
|
||||
if(body.helper) body.split = second;
|
||||
if(fit.active && !await askConfirm('Le modèle va redémarrer avec ces réglages (quelques minutes).', {title:'Strata — Qwen3.8 Flash Next', okText:'Enregistrer'})) return;
|
||||
const r = await jpost('/api/strata/settings', body);
|
||||
if(!r.ok){ toast('Erreur : '+(r.error||'')); return; }
|
||||
@@ -168,14 +175,16 @@ function strataRenderDetails(fit){
|
||||
const k = n => (+n >= 1024 ? Math.round(+n/1024)+'K' : n);
|
||||
const rows = [
|
||||
['Carte principale', d.main_gpu],
|
||||
["Carte d'appoint", d.helper_gpu || 'aucune'],
|
||||
['Seconde carte', d.helper_gpu ? d.helper_gpu + (d.split_layers ? ' · partage des couches' : ' · aide aux experts') : 'aucune'],
|
||||
['Lecture des images', d.vision_gpu || 'aucune'],
|
||||
['Contexte', (+d.ctx).toLocaleString('fr-FR')+' jetons'],
|
||||
['Cache KV', d.kv + (d.kv_resident ? ' · '+k(d.kv_resident)+' en VRAM, le reste en RAM' : '')],
|
||||
['Prédiction (MTP)', d.spec+' jetons'],
|
||||
['Experts hors GPU', (d.mode_label || (d.mmap ? 'lus depuis le disque (mmap)' : 'chargés en RAM'))
|
||||
+ (fit.active ? ' — dernier lancement' : ' — selon la RAM libre maintenant')],
|
||||
['Lecture des prompts', 'blocs de '+k(d.prefill)+', lecture directe sous '+d.short_read+' jetons'],
|
||||
// paquet 1.1 : la carte d'appoint calcule ses experts à la lecture d'un prompt
|
||||
['Lecture des prompts', 'blocs de '+k(d.prefill)+', lecture directe sous '+d.short_read+' jetons'
|
||||
+ (d.helper_gpu && !d.split_layers ? ", experts de la carte d'appoint calculés par elle" : '')],
|
||||
["Démarrage d'une conversation", 'reprise après le prompt système (dès '+d.cache_root+' jetons)'],
|
||||
['Version du moteur', 'paquet AJEAN MoE '+d.version],
|
||||
];
|
||||
|
||||
@@ -1383,6 +1383,10 @@ button:hover{border-color:var(--dim);color:var(--text)}
|
||||
border:1px solid var(--border);border-radius:999px;background:var(--panel);
|
||||
color:var(--dim);font-size:12px;line-height:1.5}
|
||||
.stopnote::before{content:"";flex:none;width:6px;height:6px;border-radius:50%;background:var(--dim)}
|
||||
/* Formules LaTeX (KaTeX, 00-math.js) : une longue formule défile au lieu de déborder. */
|
||||
.katex{font-size:1.08em}
|
||||
.katex-display{margin:.6em 0;overflow-x:auto;overflow-y:hidden;padding:2px 0}
|
||||
.math-pending{font-family:var(--mono);font-size:.92em;color:var(--dim)}
|
||||
|
||||
/* ===== Fenêtres et sélecteurs =====================================
|
||||
Coins arrondis et ombre douce plutôt qu'un cadre marqué. */
|
||||
|
||||
@@ -10,6 +10,8 @@ import (
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// web_auth.go protège l'API de pilotage (loki web) quand elle est exposée sur
|
||||
@@ -19,7 +21,8 @@ import (
|
||||
// La clé de pilotage est volontairement DISTINCTE de .api_key (qui, elle,
|
||||
// protège llama-server / les complétions). On veut pouvoir donner à un client
|
||||
// un accès aux complétions sans lui donner le droit de redémarrer la machine,
|
||||
// et inversement. Elle est relue à chaque requête (pas de cache) pour qu'un
|
||||
// et inversement. Elle est relue à chaque requête,
|
||||
// depuis un cache invalidé à chaque écriture (voir readWebKeyErr), pour qu'un
|
||||
// changement de clé prenne effet sans redémarrer le serveur web.
|
||||
|
||||
// readWebKeyErr renvoie la clé de pilotage en distinguant « aucune clé » d'une
|
||||
@@ -27,9 +30,62 @@ import (
|
||||
// ouverte. Confondre les deux, c'est ouvrir l'API parce que la base était
|
||||
// momentanément verrouillée par une commande CLI — une panne d'E/S qui désarme
|
||||
// l'authentification. requireWebAuth refuse donc plutôt que d'ouvrir.
|
||||
//
|
||||
// requireWebAuth la consulte à CHAQUE requête : sans cache, chacune payait une
|
||||
// ouverture complète de la base sous dbMu (vignettes chargées deux par deux,
|
||||
// SSE, statut…), en concurrence avec l'écrivain des discussions (AJEAN #120).
|
||||
// On garde la dernière lecture RÉUSSIE, invalidée comme cachedKV : écriture de
|
||||
// ce process (cacheBust du bucket d'état), date/taille du fichier pour les
|
||||
// autres process (la CLI), une seconde au plus. Une lecture ratée n'est jamais
|
||||
// mise en cache : elle remonte, et l'API reste fermée.
|
||||
func readWebKeyErr() (string, error) {
|
||||
path := dbPath()
|
||||
mtime, size := dbStamp()
|
||||
c := &webKeyCache
|
||||
c.Lock()
|
||||
if c.ok && c.path == path && c.size == size && c.mtime.Equal(mtime) && time.Since(c.when) < cacheMaxAge {
|
||||
k := c.key
|
||||
c.Unlock()
|
||||
return k, nil
|
||||
}
|
||||
gen := c.gen
|
||||
c.Unlock()
|
||||
b, err := getBytesErr(bkState, "web_key")
|
||||
return string(b), err
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
// Date et taille relevées APRÈS la lecture : ouvrir la base peut à lui seul
|
||||
// toucher le fichier (Windows), et un relevé d'avant ne correspondait alors
|
||||
// jamais. Une écriture externe glissée entre la lecture et le relevé reste
|
||||
// couverte par l'âge maximal.
|
||||
mtime, size = dbStamp()
|
||||
c.Lock()
|
||||
// Une invalidation pendant la lecture : la valeur lue est peut-être déjà
|
||||
// l'ancienne — ne pas la garder.
|
||||
if c.gen == gen {
|
||||
c.ok, c.path, c.key, c.when, c.mtime, c.size = true, path, string(b), time.Now(), mtime, size
|
||||
}
|
||||
c.Unlock()
|
||||
return string(b), nil
|
||||
}
|
||||
|
||||
var webKeyCache struct {
|
||||
sync.Mutex
|
||||
ok bool
|
||||
gen uint64 // incrémenté à chaque invalidation
|
||||
path string // la base lue (LOKI_HOME change d'un test à l'autre)
|
||||
key string
|
||||
when time.Time
|
||||
mtime time.Time
|
||||
size int64
|
||||
}
|
||||
|
||||
// webKeyCacheBust : appelé par cacheBust pour toute écriture du bucket d'état.
|
||||
func webKeyCacheBust() {
|
||||
webKeyCache.Lock()
|
||||
webKeyCache.ok = false
|
||||
webKeyCache.gen++
|
||||
webKeyCache.Unlock()
|
||||
}
|
||||
|
||||
// readWebKey renvoie la clé de pilotage, ou "" si aucune n'est définie (ou
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
bolt "go.etcd.io/bbolt"
|
||||
)
|
||||
|
||||
// Une écriture de ce process est vue tout de suite : le cache ne retarde pas un
|
||||
// changement de clé.
|
||||
func TestWebKeyCacheInvalidatedByLocalWrite(t *testing.T) {
|
||||
testHome(t)
|
||||
if err := putStr(bkState, "web_key", "ancienne"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if k, err := readWebKeyErr(); err != nil || k != "ancienne" {
|
||||
t.Fatalf("clé = %q, %v", k, err)
|
||||
}
|
||||
if err := putStr(bkState, "web_key", "nouvelle"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if k, err := readWebKeyErr(); err != nil || k != "nouvelle" {
|
||||
t.Fatalf("clé après écriture = %q, %v ; attendu la nouvelle", k, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Une écriture d'un AUTRE process (sans cacheBust ici) finit vue : au plus
|
||||
// cacheMaxAge plus tard.
|
||||
func TestWebKeyCacheSeesExternalWrite(t *testing.T) {
|
||||
testHome(t)
|
||||
if err := putStr(bkState, "web_key", "a"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if k, _ := readWebKeyErr(); k != "a" {
|
||||
t.Fatalf("clé = %q", k)
|
||||
}
|
||||
// Écriture directe, comme le ferait la CLI : pas de cacheBust.
|
||||
err := withDB(func(d *bolt.DB) error {
|
||||
return d.Update(func(tx *bolt.Tx) error {
|
||||
return tx.Bucket([]byte(bkState)).Put([]byte("web_key"), []byte("b"))
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
deadline := time.Now().Add(cacheMaxAge + 500*time.Millisecond)
|
||||
for {
|
||||
if k, _ := readWebKeyErr(); k == "b" {
|
||||
return
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("écriture externe jamais vue")
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
// Sans changement, les lectures suivantes n'ouvrent plus la base.
|
||||
func TestWebKeyCacheServesRepeatedReads(t *testing.T) {
|
||||
testHome(t)
|
||||
if err := putStr(bkState, "web_key", "k"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if k, _ := readWebKeyErr(); k != "k" {
|
||||
t.Fatalf("clé = %q", k)
|
||||
}
|
||||
// Base verrouillée par « un autre process » : une lecture qui l'ouvrirait
|
||||
// attendrait la borne de 5 s puis échouerait.
|
||||
dbMu.Lock()
|
||||
type res struct {
|
||||
k string
|
||||
err error
|
||||
}
|
||||
ch := make(chan res, 1)
|
||||
go func() { k, err := readWebKeyErr(); ch <- res{k, err} }()
|
||||
select {
|
||||
case r := <-ch:
|
||||
dbMu.Unlock()
|
||||
if r.err != nil || r.k != "k" {
|
||||
t.Fatalf("clé = %q, %v", r.k, r.err)
|
||||
}
|
||||
case <-time.After(300 * time.Millisecond):
|
||||
dbMu.Unlock()
|
||||
<-ch
|
||||
t.Fatal("lecture servie par la base, pas par le cache")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// web_katex.go — KaTeX 0.16.11 (formules LaTeX des réponses), repris d'AJEAN.
|
||||
//
|
||||
// Embarqué sous ui/katex/ et servi sous /katex/ : l'UI ne le charge qu'à la
|
||||
// première formule d'une réponse (00-md.js), et il doit marcher hors ligne,
|
||||
// sans appeler un CDN. Seuls le script, la feuille de style et les polices
|
||||
// woff2 sont servis — pas d'index de dossier — avec un Content-Type fixe : le
|
||||
// registre MIME de l'OS (Windows notamment) ne doit pas le décider, et la
|
||||
// façade Rust (rust/loki-rs/src/ui.rs) rend exactement les mêmes en-têtes.
|
||||
|
||||
// katexTypes : extensions servies et leur Content-Type.
|
||||
var katexTypes = map[string]string{
|
||||
".js": "application/javascript",
|
||||
".css": "text/css; charset=utf-8",
|
||||
".woff2": "font/woff2",
|
||||
}
|
||||
|
||||
// katexFile : chemin relatif à ui/katex/ d'une ressource servable, ou "" si la
|
||||
// demande n'en est pas une (racine, dossier, autre extension, remontée).
|
||||
func katexFile(urlPath string) (rel, ctype string) {
|
||||
rel = strings.TrimPrefix(urlPath, "/katex/")
|
||||
if rel == urlPath || rel == "" || strings.Contains(rel, "..") || path.Clean(rel) != rel {
|
||||
return "", ""
|
||||
}
|
||||
ctype, ok := katexTypes[path.Ext(rel)]
|
||||
if !ok {
|
||||
return "", ""
|
||||
}
|
||||
return rel, ctype
|
||||
}
|
||||
|
||||
func handleKatex(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
rel, ctype := katexFile(r.URL.Path)
|
||||
if rel == "" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
b, err := uiFS.ReadFile("ui/katex/" + rel)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", ctype)
|
||||
w.Header().Set("Cache-Control", "public, max-age=86400")
|
||||
w.Header().Set("Content-Length", strconv.Itoa(len(b)))
|
||||
w.Write(b)
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Détection des formules (ui/src/js/00-math.js), exécutée sous node : le dépôt
|
||||
// n'a pas de banc de tests JS, et MATH_RE/mathExtract sont purs. Ignoré si node
|
||||
// n'est pas installé (il l'est sur les runners de la CI).
|
||||
|
||||
type formule struct {
|
||||
Tex string `json:"tex"`
|
||||
Display bool `json:"display"`
|
||||
}
|
||||
|
||||
// harnaisMath : charge 00-math.js et marked.min.js, puis pour chaque entrée
|
||||
// (JSON sur stdin) rend {text, list} de mathExtract et, pour le bout en bout, le
|
||||
// HTML de marked après mathRestore (chaque formule rendue « [tex] »).
|
||||
const harnaisMath = `
|
||||
const fs = require('fs');
|
||||
const [mathPath, markedPath] = process.argv.slice(2);
|
||||
const m = new Function(fs.readFileSync(mathPath, 'utf8') + '; return {mathExtract, mathRestore};')();
|
||||
const {marked} = require(markedPath);
|
||||
marked.setOptions({ gfm: true, breaks: false });
|
||||
const cases = JSON.parse(fs.readFileSync(0, 'utf8'));
|
||||
console.log(JSON.stringify(cases.map(s => {
|
||||
const r = m.mathExtract(s);
|
||||
const html = m.mathRestore(marked.parse(r.text), r.list, f => '[' + (f.display ? 'D:' : '') + f.tex + ']');
|
||||
return {text: r.text, list: r.list, html};
|
||||
})));
|
||||
`
|
||||
|
||||
type résultatMath struct {
|
||||
Text string `json:"text"`
|
||||
List []formule `json:"list"`
|
||||
HTML string `json:"html"`
|
||||
}
|
||||
|
||||
func lanceMath(t *testing.T, cas []string) []résultatMath {
|
||||
t.Helper()
|
||||
node, err := exec.LookPath("node")
|
||||
if err != nil {
|
||||
t.Skip("node absent : détection des formules non testée")
|
||||
}
|
||||
dir := t.TempDir()
|
||||
h := filepath.Join(dir, "harnais.js")
|
||||
if err := os.WriteFile(h, []byte(harnaisMath), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
math, _ := filepath.Abs("ui/src/js/00-math.js")
|
||||
marked, _ := filepath.Abs("ui/marked.min.js")
|
||||
in, _ := json.Marshal(cas)
|
||||
cmd := exec.Command(node, h, math, marked)
|
||||
cmd.Stdin = strings.NewReader(string(in))
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("node : %v\n%s", err, out)
|
||||
}
|
||||
var res []résultatMath
|
||||
if err := json.Unmarshal(out, &res); err != nil {
|
||||
t.Fatalf("sortie illisible : %v\n%s", err, out)
|
||||
}
|
||||
if len(res) != len(cas) {
|
||||
t.Fatalf("%d résultats pour %d cas", len(res), len(cas))
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
func TestMathDetection(t *testing.T) {
|
||||
in := func(tex string) formule { return formule{Tex: tex} }
|
||||
bloc := func(tex string) formule { return formule{Tex: tex, Display: true} }
|
||||
cas := []struct {
|
||||
src string
|
||||
want []formule
|
||||
}{
|
||||
// Montants : jamais des formules.
|
||||
{"Ça coûte 5 $ et 10 $.", nil},
|
||||
{"Entre $5 et $10 par mois.", nil},
|
||||
{"Prix : 5$ ou 6$, voire 7 $.", nil},
|
||||
{`Échappés : \$5 et \$6.`, nil},
|
||||
{"Collé à un mot : x$y$z.", nil},
|
||||
{"Espace finale : $x $.", nil},
|
||||
{"De 5 $, à 10$.", nil},
|
||||
{"Montant : 5 $, code `$x_1$`.", nil},
|
||||
// Code : jamais touché.
|
||||
{"En ligne `$x$` et `$$y$$`.", nil},
|
||||
{"```\n$x$ et $$y$$\n```", nil},
|
||||
{"~~~tex\n\\[a\\]\n~~~", nil},
|
||||
{"```\nbloc non fermé $x$", nil},
|
||||
// Formules.
|
||||
{"Soit $x^2 + y^2 = z^2$ ici.", []formule{in("x^2 + y^2 = z^2")}},
|
||||
{"Deux : $a$, $b$.", []formule{in("a"), in("b")}},
|
||||
{"$$\n\\int_0^1 x\\,dx\n$$", []formule{bloc(`\int_0^1 x\,dx`)}},
|
||||
{`En ligne \(a+b\) et en bloc \[c\].`, []formule{in("a+b"), bloc("c")}},
|
||||
{"Code `$x$` puis $y$.", []formule{in("y")}},
|
||||
{`Échappement dedans : $a\$b$.`, []formule{in(`a\$b`)}},
|
||||
}
|
||||
srcs := make([]string, len(cas))
|
||||
for i, c := range cas {
|
||||
srcs[i] = c.src
|
||||
}
|
||||
res := lanceMath(t, srcs)
|
||||
for i, c := range cas {
|
||||
got := res[i].List
|
||||
if len(got) == 0 {
|
||||
got = nil
|
||||
}
|
||||
if !reflect.DeepEqual(got, c.want) {
|
||||
t.Errorf("%q : %+v, attendu %+v", c.src, got, c.want)
|
||||
}
|
||||
if c.want == nil && res[i].Text != c.src {
|
||||
t.Errorf("%q : texte modifié en %q", c.src, res[i].Text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Bout en bout avec marked : \\, _ et * d'une formule arrivent intacts au rendu,
|
||||
// le markdown autour reste du markdown, et aucun repère ne survit.
|
||||
func TestMathAvecMarked(t *testing.T) {
|
||||
res := lanceMath(t, []string{
|
||||
"*Gras* : $a_1 * b_2$ et $$\\begin{matrix}a & b \\\\ c & d\\end{matrix}$$",
|
||||
"Montant : 5 $, code `$x_1$`.",
|
||||
})
|
||||
for _, want := range []string{"<em>Gras</em>", "[a_1 * b_2]", `[D:\begin{matrix}a & b \\ c & d\end{matrix}]`} {
|
||||
if !strings.Contains(res[0].HTML, want) {
|
||||
t.Errorf("HTML sans %q :\n%s", want, res[0].HTML)
|
||||
}
|
||||
}
|
||||
for _, r := range res {
|
||||
if strings.ContainsAny(r.HTML, "\uE000\uE001") {
|
||||
t.Errorf("repère resté dans le HTML : %q", r.HTML)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(res[1].HTML, "5 $") || !strings.Contains(res[1].HTML, "<code>$x_1$</code>") {
|
||||
t.Errorf("montant ou code abîmé : %s", res[1].HTML)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func getKatex(t *testing.T, méthode, chemin string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
rec := httptest.NewRecorder()
|
||||
handleKatex(rec, httptest.NewRequest(méthode, chemin, nil))
|
||||
return rec
|
||||
}
|
||||
|
||||
// Le script, embarqué, est servi sous /katex/ avec les en-têtes que rend aussi
|
||||
// la façade Rust (ui.rs).
|
||||
func TestKatexScriptServi(t *testing.T) {
|
||||
rec := getKatex(t, http.MethodGet, "/katex/katex.min.js")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("code %d", rec.Code)
|
||||
}
|
||||
h := rec.Header()
|
||||
if got := h.Get("Content-Type"); got != "application/javascript" {
|
||||
t.Errorf("Content-Type = %q", got)
|
||||
}
|
||||
if got := h.Get("Cache-Control"); got != "public, max-age=86400" {
|
||||
t.Errorf("Cache-Control = %q", got)
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), `version:"0.16.11"`) {
|
||||
t.Error("katex.min.js n'est pas KaTeX 0.16.11")
|
||||
}
|
||||
if h.Get("Content-Length") != strconv.Itoa(rec.Body.Len()) {
|
||||
t.Errorf("Content-Length = %q pour %d octets", h.Get("Content-Length"), rec.Body.Len())
|
||||
}
|
||||
}
|
||||
|
||||
// Chaque police woff2 que la feuille de style demande est embarquée et servie.
|
||||
func TestKatexFeuilleEtPolices(t *testing.T) {
|
||||
rec := getKatex(t, http.MethodGet, "/katex/katex.min.css")
|
||||
if rec.Code != http.StatusOK || rec.Header().Get("Content-Type") != "text/css; charset=utf-8" {
|
||||
t.Fatalf("css : code %d, type %q", rec.Code, rec.Header().Get("Content-Type"))
|
||||
}
|
||||
polices := regexp.MustCompile(`url\((fonts/[^)]+\.woff2)\)`).FindAllStringSubmatch(rec.Body.String(), -1)
|
||||
if len(polices) < 10 {
|
||||
t.Fatalf("%d polices woff2 dans la feuille", len(polices))
|
||||
}
|
||||
for _, m := range polices {
|
||||
r := getKatex(t, http.MethodGet, "/katex/"+m[1])
|
||||
if r.Code != http.StatusOK || r.Header().Get("Content-Type") != "font/woff2" || r.Body.Len() == 0 {
|
||||
t.Errorf("%s : code %d, type %q", m[1], r.Code, r.Header().Get("Content-Type"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Rien d'autre : ni index de dossier, ni licence, ni remontée, ni POST.
|
||||
func TestKatexRefus(t *testing.T) {
|
||||
for _, c := range []struct{ méthode, chemin string }{
|
||||
{http.MethodGet, "/katex/"},
|
||||
{http.MethodGet, "/katex/fonts/"},
|
||||
{http.MethodGet, "/katex/LICENSE"},
|
||||
{http.MethodGet, "/katex/fonts/absente.woff2"},
|
||||
{http.MethodGet, "/katex/../index.html"},
|
||||
{http.MethodGet, "/katex/fonts/../katex.min.js"},
|
||||
{http.MethodGet, "/katexkatex.min.js"},
|
||||
{http.MethodPost, "/katex/katex.min.js"},
|
||||
} {
|
||||
if rec := getKatex(t, c.méthode, c.chemin); rec.Code != http.StatusNotFound {
|
||||
t.Errorf("%s %s : code %d, attendu 404", c.méthode, c.chemin, rec.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Derrière gzipHandler (comme en production) : script et feuille compressés,
|
||||
// polices non — comme la façade.
|
||||
func TestKatexCompression(t *testing.T) {
|
||||
h := gzipHandler(http.HandlerFunc(handleKatex))
|
||||
for chemin, gz := range map[string]bool{
|
||||
"/katex/katex.min.js": true,
|
||||
"/katex/katex.min.css": true,
|
||||
"/katex/fonts/KaTeX_Main-Regular.woff2": false,
|
||||
} {
|
||||
req := httptest.NewRequest(http.MethodGet, chemin, nil)
|
||||
req.Header.Set("Accept-Encoding", "gzip")
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if got := rec.Header().Get("Content-Encoding") == "gzip"; got != gz {
|
||||
t.Errorf("%s : gzip %v, attendu %v", chemin, got, gz)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,7 @@ import (
|
||||
)
|
||||
|
||||
//go:generate go run ../../tools/assemble-ui ui
|
||||
//go:embed ui/index.html ui/marked.min.js ui/sw.js ui/manifest.webmanifest ui/fonts/*.woff2
|
||||
//go:embed ui/index.html ui/marked.min.js ui/sw.js ui/manifest.webmanifest ui/fonts/*.woff2 ui/katex
|
||||
var uiFS embed.FS
|
||||
|
||||
// cmdWeb starts the HTTP server on the given port (default 8090).
|
||||
@@ -217,6 +217,9 @@ func newWebMux() *http.ServeMux {
|
||||
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
||||
w.Write(b)
|
||||
})
|
||||
// KaTeX (formules LaTeX des réponses, web_katex.go) : chargé par l'UI
|
||||
// seulement quand une réponse contient des maths. Embarqué, comme les polices.
|
||||
mux.HandleFunc("/katex/", handleKatex)
|
||||
// Endpoint compatible OpenAI, servi par Loki SUR SON PORT et relayé vers
|
||||
// llama-server (voir mountOAI). C'est ce qui le rend joignable par le nom de
|
||||
// domaine ou l'IP de l'interface, sans publier de second port ni ouvrir le
|
||||
@@ -259,6 +262,7 @@ func newWebMux() *http.ServeMux {
|
||||
api("/api/models/download/probe", handleModelDownloadProbe) // taille + espace libre avant de lancer
|
||||
api("/api/models/download/status", handleModelDownloadStatus)
|
||||
api("/api/models/download/cancel", handleModelDownloadCancel)
|
||||
api("/api/models/download/pause", handleModelDownloadPause)
|
||||
api("/api/hf/search", handleHFSearch) // chercher un modèle GGUF sur Hugging Face
|
||||
api("/api/hf/files", handleHFFiles) // quants et projecteurs d'un dépôt
|
||||
api("/api/hf/token", handleHFToken) // jeton pour les dépôts à accès restreint
|
||||
|
||||
@@ -42,6 +42,32 @@ fn main() {
|
||||
}
|
||||
out.push_str("];\n");
|
||||
|
||||
// KaTeX (formules LaTeX, repris d'AJEAN) : script, feuille et polices
|
||||
// woff2 sous ui/katex/, les polices nommées par leur chemin relatif
|
||||
// (« fonts/x.woff2 »), comme les sert web_katex.go.
|
||||
let katex = ui.join("katex");
|
||||
println!("cargo:rerun-if-changed={}", katex.display());
|
||||
for (ident, name) in [("KATEX_JS", "katex.min.js"), ("KATEX_CSS", "katex.min.css")] {
|
||||
let p = katex.join(name);
|
||||
assert!(p.is_file(), "fichier KaTeX manquant : {}", p.display());
|
||||
println!("cargo:rerun-if-changed={}", p.display());
|
||||
writeln!(out, "pub static {ident}: &[u8] = include_bytes!({p:?});").unwrap();
|
||||
}
|
||||
let katex_fonts = katex.join("fonts");
|
||||
println!("cargo:rerun-if-changed={}", katex_fonts.display());
|
||||
let mut kfonts: Vec<PathBuf> = std::fs::read_dir(&katex_fonts)
|
||||
.unwrap_or_else(|e| panic!("{} : {e}", katex_fonts.display()))
|
||||
.filter_map(|e| e.ok().map(|e| e.path()))
|
||||
.filter(|p| p.extension().is_some_and(|x| x == "woff2"))
|
||||
.collect();
|
||||
kfonts.sort();
|
||||
out.push_str("pub static KATEX_FONTS: &[(&str, &[u8])] = &[\n");
|
||||
for p in &kfonts {
|
||||
let name = format!("fonts/{}", p.file_name().unwrap().to_str().unwrap());
|
||||
writeln!(out, " ({name:?}, include_bytes!({p:?})),").unwrap();
|
||||
}
|
||||
out.push_str("];\n");
|
||||
|
||||
let dst = PathBuf::from(std::env::var("OUT_DIR").unwrap()).join("ui_assets.rs");
|
||||
std::fs::write(dst, out).unwrap();
|
||||
}
|
||||
@@ -922,11 +922,19 @@ impl RetryPatterns {
|
||||
if over.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let pats = over
|
||||
// Un motif vide (ou blanc) est écarté : `(?mi)` correspond à TOUT, et
|
||||
// chaque tour était relancé une fois (OpenFox 8e9b4869). Sans un seul
|
||||
// motif utilisable, les motifs embarqués, comme le Go
|
||||
// (compileRetryPatterns).
|
||||
let pats: Vec<Box<dyn RetryPattern>> = over
|
||||
.iter()
|
||||
.filter(|p| !p.trim().is_empty())
|
||||
.filter_map(|p| compile_go_regex(&format!("(?mi){p}")))
|
||||
.map(|r| Box::new(GoRegex(r)) as Box<dyn RetryPattern>)
|
||||
.collect();
|
||||
if pats.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(RetryPatterns::custom(pats))
|
||||
}
|
||||
|
||||
@@ -2907,10 +2915,19 @@ mod tests {
|
||||
/// en `(?mi)`, sémantique du Go (classes et limites de mot ASCII).
|
||||
#[test]
|
||||
fn surcharge_retry_patterns() {
|
||||
for raw in ["", "null", "[]", "[\"a\", 1]", "\"x\"", "[\"a\"] x"] {
|
||||
for raw in [
|
||||
"",
|
||||
"null",
|
||||
"[]",
|
||||
"[\"a\", 1]",
|
||||
"\"x\"",
|
||||
"[\"a\"] x",
|
||||
"[\"\"]",
|
||||
"[\" \", \"(\"]",
|
||||
] {
|
||||
assert!(RetryPatterns::from_override(raw).is_none(), "{raw}");
|
||||
}
|
||||
let p = RetryPatterns::from_override(r#"["^oups", "[", "\\bdo_\\w+\\("]"#).unwrap();
|
||||
let p = RetryPatterns::from_override(r#"["^oups", "[", "", "\\bdo_\\w+\\("]"#).unwrap();
|
||||
assert!(p.is_custom());
|
||||
assert_eq!(p.len(), 2, "la regex illisible est sautée");
|
||||
assert!(p.textual_tool_call("Bonjour\nOUPS !"));
|
||||
|
||||
+117
-10
@@ -3,7 +3,7 @@
|
||||
//! `backend_model_dirs.go` et `backend_hf.go` (lot 8 de la migration).
|
||||
//!
|
||||
//! Tenus par la façade quand `LOKI_MODELS=rust` (expérimental ; le Go
|
||||
//! par défaut) : `POST /api/models/download`, `/probe`, `/cancel`,
|
||||
//! par défaut) : `POST /api/models/download`, `/probe`, `/cancel`, `/pause`,
|
||||
//! `GET /api/models/download/status`. Le Go remet alors à la façade ces
|
||||
//! routes quand elles lui arrivent par le tunnel du relais, et ne vide plus
|
||||
//! les `.part` au démarrage (c'est la façade qui le fait).
|
||||
@@ -71,6 +71,7 @@ pub fn handles(path: &str) -> bool {
|
||||
| "/api/models/download/probe"
|
||||
| "/api/models/download/status"
|
||||
| "/api/models/download/cancel"
|
||||
| "/api/models/download/pause"
|
||||
)
|
||||
}
|
||||
|
||||
@@ -519,6 +520,7 @@ pub struct DlState {
|
||||
pub part: i64,
|
||||
pub finished: bool,
|
||||
pub canceled: bool,
|
||||
pub paused: bool,
|
||||
#[serde(rename = "error")]
|
||||
pub err: String,
|
||||
pub started_at: i64,
|
||||
@@ -527,6 +529,8 @@ pub struct DlState {
|
||||
struct Entry {
|
||||
state: Mutex<DlState>,
|
||||
cancel: watch::Sender<bool>,
|
||||
/// `dlGate` : vrai pendant une pause.
|
||||
pause: watch::Sender<bool>,
|
||||
}
|
||||
|
||||
impl Entry {
|
||||
@@ -549,6 +553,37 @@ impl Stop {
|
||||
}
|
||||
}
|
||||
|
||||
/// `dlGate` : la pause d'un téléchargement. Les requêtes en cours sont
|
||||
/// coupées (rien ne reste ouvert vers le CDN pendant la pause), et chaque
|
||||
/// morceau attend la reprise puis repart de l'octet où il s'était arrêté, par
|
||||
/// une requête de plage.
|
||||
#[derive(Clone)]
|
||||
struct Gate(watch::Receiver<bool>);
|
||||
|
||||
impl Gate {
|
||||
fn is_paused(&self) -> bool {
|
||||
*self.0.borrow()
|
||||
}
|
||||
/// Attend la reprise ; `false` : annulé entre-temps.
|
||||
async fn wait(&self, stop: &Stop) -> bool {
|
||||
if self.is_paused() {
|
||||
let mut rx = self.0.clone();
|
||||
tokio::select! {
|
||||
_ = rx.wait_for(|p| !*p) => {}
|
||||
_ = stop.wait() => return false,
|
||||
}
|
||||
}
|
||||
!stop.is_set()
|
||||
}
|
||||
/// Se résout quand la pause tombe (jamais si l'émetteur a disparu).
|
||||
async fn paused(&self) {
|
||||
let mut rx = self.0.clone();
|
||||
if rx.wait_for(|p| *p).await.is_err() {
|
||||
std::future::pending::<()>().await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Ce qu'un téléchargement doit savoir de la machine.
|
||||
#[derive(Clone)]
|
||||
pub struct DlEnv {
|
||||
@@ -615,6 +650,28 @@ impl Downloads {
|
||||
}
|
||||
}
|
||||
|
||||
/// `handleModelDownloadPause` : met en pause ou reprend ; `false` :
|
||||
/// inconnu ou terminé. Le fichier partiel est gardé.
|
||||
pub fn pause(&self, name: &str, paused: bool) -> bool {
|
||||
let Some(e) = self.map().get(name).cloned() else {
|
||||
return false;
|
||||
};
|
||||
let running = e.with(|s| {
|
||||
if s.finished {
|
||||
return false;
|
||||
}
|
||||
s.paused = paused;
|
||||
if paused {
|
||||
s.speed = 0;
|
||||
}
|
||||
true
|
||||
});
|
||||
if running {
|
||||
let _ = e.pause.send_replace(paused);
|
||||
}
|
||||
running
|
||||
}
|
||||
|
||||
fn request(&self, env: &DlEnv, url: &str, range: Option<&str>) -> reqwest::RequestBuilder {
|
||||
let mut rb = self
|
||||
.client
|
||||
@@ -722,6 +779,7 @@ impl Downloads {
|
||||
);
|
||||
}
|
||||
let (tx, rx) = watch::channel(false);
|
||||
let (pause_tx, pause_rx) = watch::channel(false);
|
||||
let entry = Arc::new(Entry {
|
||||
state: Mutex::new(DlState {
|
||||
filename: name.clone(),
|
||||
@@ -732,12 +790,14 @@ impl Downloads {
|
||||
..Default::default()
|
||||
}),
|
||||
cancel: tx,
|
||||
pause: pause_tx,
|
||||
});
|
||||
map.insert(name.clone(), entry.clone());
|
||||
drop(map);
|
||||
let parts = todo.len();
|
||||
let me = self.clone();
|
||||
tokio::spawn(async move { me.run_set(env, entry, Stop(rx), todo).await });
|
||||
let gate = Gate(pause_rx);
|
||||
tokio::spawn(async move { me.run_set(env, entry, Stop(rx), gate, todo).await });
|
||||
(
|
||||
StatusCode::OK,
|
||||
json!({ "ok": true, "filename": name, "parts": parts }),
|
||||
@@ -752,6 +812,7 @@ impl Downloads {
|
||||
env: DlEnv,
|
||||
entry: Arc<Entry>,
|
||||
stop: Stop,
|
||||
gate: Gate,
|
||||
todo: Vec<(String, String)>,
|
||||
) {
|
||||
let done = Arc::new(AtomicI64::new(0));
|
||||
@@ -812,7 +873,9 @@ impl Downloads {
|
||||
let dt = now.duration_since(last_at).as_secs_f64();
|
||||
entry.with(|s| {
|
||||
s.done = cur;
|
||||
if dt > 0.0 {
|
||||
if s.paused {
|
||||
s.speed = 0;
|
||||
} else if dt > 0.0 {
|
||||
let inst = ((cur - last) as f64 / dt) as i64;
|
||||
s.speed = if s.speed == 0 {
|
||||
inst
|
||||
@@ -829,7 +892,7 @@ impl Downloads {
|
||||
for (i, ((u, dest), (total, ranged))) in todo.iter().zip(sizes).enumerate() {
|
||||
entry.with(|s| s.part = i as i64 + 1);
|
||||
if let Err(h) = self
|
||||
.one_part(&env, &entry, &stop, u, dest, total, ranged, &done)
|
||||
.one_part(&env, &entry, &stop, &gate, u, dest, total, ranged, &done)
|
||||
.await
|
||||
{
|
||||
ticker.abort();
|
||||
@@ -851,6 +914,7 @@ impl Downloads {
|
||||
env: &DlEnv,
|
||||
entry: &Entry,
|
||||
stop: &Stop,
|
||||
gate: &Gate,
|
||||
url: &str,
|
||||
dest: &str,
|
||||
total: i64,
|
||||
@@ -877,7 +941,7 @@ impl Downloads {
|
||||
path: tmp.clone(),
|
||||
});
|
||||
let res = if conns <= 1 {
|
||||
self.chunk(env, stop, &file, url, 0, total - 1, total <= 0, done)
|
||||
self.chunk(env, stop, gate, &file, url, 0, total - 1, total <= 0, done)
|
||||
.await
|
||||
} else {
|
||||
let size = total / conns as i64;
|
||||
@@ -888,7 +952,7 @@ impl Downloads {
|
||||
} else {
|
||||
start + size - 1
|
||||
};
|
||||
self.chunk(env, stop, &file, url, start, end, false, done)
|
||||
self.chunk(env, stop, gate, &file, url, start, end, false, done)
|
||||
});
|
||||
futures_util::future::join_all(parts)
|
||||
.await
|
||||
@@ -918,6 +982,7 @@ impl Downloads {
|
||||
&self,
|
||||
env: &DlEnv,
|
||||
stop: &Stop,
|
||||
gate: &Gate,
|
||||
file: &Arc<PartFile>,
|
||||
url: &str,
|
||||
start: i64,
|
||||
@@ -927,7 +992,13 @@ impl Downloads {
|
||||
) -> Result<(), Halt> {
|
||||
let mut pos = start;
|
||||
let mut last_err = String::new();
|
||||
for attempt in 0..DL_ATTEMPTS {
|
||||
let mut attempt: u64 = 0;
|
||||
while attempt < DL_ATTEMPTS {
|
||||
// Une pause n'est pas un échec : on attend la reprise sans
|
||||
// consommer d'essai.
|
||||
if !gate.wait(stop).await {
|
||||
return Err(Halt::Canceled);
|
||||
}
|
||||
if stop.is_set() {
|
||||
return Err(Halt::Canceled);
|
||||
}
|
||||
@@ -948,13 +1019,16 @@ impl Downloads {
|
||||
None
|
||||
};
|
||||
let resp = tokio::select! {
|
||||
r = self.request(env, url, range.as_deref()).send() => r,
|
||||
r = self.request(env, url, range.as_deref()).send() => r.map_err(|e| e.to_string()),
|
||||
_ = stop.wait() => return Err(Halt::Canceled),
|
||||
_ = gate.paused() => Err("en pause".to_string()),
|
||||
};
|
||||
let resp = match resp {
|
||||
Ok(r) => r,
|
||||
Err(_) if gate.is_paused() && !stop.is_set() => continue,
|
||||
Err(e) => {
|
||||
last_err = e.to_string();
|
||||
last_err = e;
|
||||
attempt += 1;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
@@ -967,7 +1041,7 @@ impl Downloads {
|
||||
done.fetch_add(start - pos, Ordering::Relaxed);
|
||||
pos = start;
|
||||
}
|
||||
match copy_at(stop, file, resp, pos, done).await {
|
||||
match copy_at(stop, gate, file, resp, pos, done).await {
|
||||
(n, Ok(())) => {
|
||||
let _ = n;
|
||||
return Ok(());
|
||||
@@ -975,7 +1049,12 @@ impl Downloads {
|
||||
(_, Err(Halt::Canceled)) => return Err(Halt::Canceled),
|
||||
(n, Err(Halt::Err(e))) => {
|
||||
pos += n;
|
||||
// coupé par la pause : la reprise repart de pos
|
||||
if gate.is_paused() && !stop.is_set() {
|
||||
continue;
|
||||
}
|
||||
last_err = e;
|
||||
attempt += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -987,6 +1066,7 @@ impl Downloads {
|
||||
/// même en cas d'erreur (pour reprendre).
|
||||
async fn copy_at(
|
||||
stop: &Stop,
|
||||
gate: &Gate,
|
||||
file: &Arc<PartFile>,
|
||||
resp: reqwest::Response,
|
||||
off: i64,
|
||||
@@ -999,6 +1079,17 @@ async fn copy_at(
|
||||
let next = tokio::select! {
|
||||
n = stream.next() => n,
|
||||
_ = stop.wait() => return (written, Err(Halt::Canceled)),
|
||||
_ = gate.paused() => {
|
||||
// pause : ce qui est reçu est écrit, le flux est coupé
|
||||
return match flush(file, &mut buf, off + written).await {
|
||||
Ok(k) => {
|
||||
written += k;
|
||||
done.fetch_add(k, Ordering::Relaxed);
|
||||
(written, Err(Halt::Err("en pause".into())))
|
||||
}
|
||||
Err(we) => (written, Err(Halt::Err(we))),
|
||||
};
|
||||
}
|
||||
};
|
||||
let eof = next.is_none();
|
||||
match next {
|
||||
@@ -1186,6 +1277,22 @@ pub async fn serve(
|
||||
)
|
||||
}
|
||||
}
|
||||
"/api/models/download/pause" => {
|
||||
let v = match body_json(body).await {
|
||||
Ok(v) => v,
|
||||
Err(e) => return bad(e),
|
||||
};
|
||||
let name = filepath_base(str_of(&v, "filename").trim());
|
||||
let paused = v.get("pause").and_then(Value::as_bool).unwrap_or(false);
|
||||
if dl.pause(&name, paused) {
|
||||
send_json(StatusCode::OK, &json!({ "ok": true, "paused": paused }))
|
||||
} else {
|
||||
send_json(
|
||||
StatusCode::NOT_FOUND,
|
||||
&json!({ "ok": false, "error": format!("aucun téléchargement en cours pour {name}") }),
|
||||
)
|
||||
}
|
||||
}
|
||||
"/api/models/download" => {
|
||||
let v = match body_json(body).await {
|
||||
Ok(v) => v,
|
||||
|
||||
@@ -25,6 +25,14 @@ pub struct ServerConfig {
|
||||
pub enabled: bool,
|
||||
#[serde(rename = "disabledTools", skip_serializing_if = "Vec::is_empty")]
|
||||
pub disabled_tools: Vec<String>,
|
||||
/// `CallTimeoutSec` : délai d'un appel d'outil de ce serveur, en
|
||||
/// secondes ; 0 = défaut ([`CALL_TIMEOUT`]).
|
||||
#[serde(rename = "callTimeoutSec", skip_serializing_if = "is_zero")]
|
||||
pub call_timeout_sec: i64,
|
||||
}
|
||||
|
||||
fn is_zero(n: &i64) -> bool {
|
||||
*n == 0
|
||||
}
|
||||
|
||||
impl Default for ServerConfig {
|
||||
@@ -37,6 +45,7 @@ impl Default for ServerConfig {
|
||||
headers: BTreeMap::new(),
|
||||
enabled: true,
|
||||
disabled_tools: Vec::new(),
|
||||
call_timeout_sec: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -78,6 +87,7 @@ impl<'de> Deserialize<'de> for ServerConfig {
|
||||
Some(x) => x.as_bool().unwrap_or(false),
|
||||
},
|
||||
disabled_tools: list("disabledTools"),
|
||||
call_timeout_sec: v.get("callTimeoutSec").and_then(Value::as_i64).unwrap_or(0),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -282,6 +292,53 @@ pub const CONNECT_TIMEOUT: Duration = Duration::from_secs(20);
|
||||
pub const CONNECT_TIMEOUT_COLD: Duration = Duration::from_secs(180);
|
||||
/// `mcpCallTimeout`.
|
||||
pub const CALL_TIMEOUT: Duration = Duration::from_secs(120);
|
||||
/// `mcpCallTimeoutMax` : plafond d'un appel, quoi que demandent l'outil ou la
|
||||
/// config.
|
||||
pub const CALL_TIMEOUT_MAX: Duration = Duration::from_secs(30 * 60);
|
||||
|
||||
/// `mcpCallTimeoutFor` : [`CALL_TIMEOUT`], remplacé par `callTimeoutSec` du
|
||||
/// serveur ; un argument `timeout` de l'outil l'allonge de 30 s.
|
||||
pub fn call_timeout_for(cfg: &ServerConfig, args: &Map<String, Value>) -> Duration {
|
||||
let mut d = CALL_TIMEOUT;
|
||||
if cfg.call_timeout_sec > 0 {
|
||||
d = Duration::from_secs(cfg.call_timeout_sec as u64);
|
||||
}
|
||||
if let Some(t) = arg_timeout(args) {
|
||||
let t = t + Duration::from_secs(30);
|
||||
if t > d {
|
||||
d = t;
|
||||
}
|
||||
}
|
||||
d.min(CALL_TIMEOUT_MAX)
|
||||
}
|
||||
|
||||
/// `mcpArgTimeout` : l'argument `timeout` d'un appel. Au-delà de 3600, lu en
|
||||
/// millisecondes (5000 = 5 s) ; en deçà, en secondes (600 = 10 min).
|
||||
fn arg_timeout(args: &Map<String, Value>) -> Option<Duration> {
|
||||
let v = match args.get("timeout")? {
|
||||
Value::Number(n) => n.as_f64()?,
|
||||
Value::String(s) => s.trim().parse::<f64>().ok()?,
|
||||
_ => return None,
|
||||
};
|
||||
if !v.is_finite() || v <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
if v > 3600.0 {
|
||||
// Plafonné bien avant tout débordement (CALL_TIMEOUT_MAX suit).
|
||||
return Some(Duration::from_millis(v.min(1e12) as u64));
|
||||
}
|
||||
Some(Duration::from_secs_f64(v))
|
||||
}
|
||||
|
||||
/// `mcpCall` pour un délai dépassé : le serveur est vivant mais l'outil est
|
||||
/// lent, pas de nouvel essai (il doublerait l'attente, et l'effet de l'outil).
|
||||
pub fn call_deadline_error(timeout: Duration) -> String {
|
||||
let secs = (timeout.as_millis() + 500) / 1000;
|
||||
format!(
|
||||
"[erreur MCP] pas de réponse après {} (délai de l'appel ; un argument timeout plus grand l'allonge)",
|
||||
crate::tools::shell::go_duration(Duration::from_secs(secs as u64))
|
||||
)
|
||||
}
|
||||
|
||||
/// Nom du lanceur (`filepath.Base`), en minuscules, sans `.exe` puis sans
|
||||
/// `.cmd`.
|
||||
@@ -463,6 +520,51 @@ fn resource_contents_json(v: &Value) -> String {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Les cas de `TestMCPCallTimeoutFor` côté Go.
|
||||
#[test]
|
||||
fn delai_d_appel() {
|
||||
let cfg = |n: i64| ServerConfig {
|
||||
call_timeout_sec: n,
|
||||
..Default::default()
|
||||
};
|
||||
let args = |v: Value| {
|
||||
let mut m = Map::new();
|
||||
m.insert("timeout".into(), v);
|
||||
m
|
||||
};
|
||||
let s = Duration::from_secs;
|
||||
let none = Map::new();
|
||||
assert_eq!(call_timeout_for(&cfg(0), &none), CALL_TIMEOUT);
|
||||
assert_eq!(call_timeout_for(&cfg(600), &none), s(600));
|
||||
assert_eq!(call_timeout_for(&cfg(0), &args(json!(300.0))), s(330));
|
||||
assert_eq!(call_timeout_for(&cfg(0), &args(json!(200000.0))), s(230));
|
||||
assert_eq!(
|
||||
call_timeout_for(&cfg(0), &args(json!(5000.0))),
|
||||
CALL_TIMEOUT
|
||||
);
|
||||
assert_eq!(call_timeout_for(&cfg(0), &args(json!("400"))), s(430));
|
||||
assert_eq!(
|
||||
call_timeout_for(&cfg(0), &args(json!(86400.0 * 1000.0))),
|
||||
CALL_TIMEOUT_MAX
|
||||
);
|
||||
assert_eq!(call_timeout_for(&cfg(30), &none), s(30));
|
||||
assert_eq!(
|
||||
call_deadline_error(s(330)),
|
||||
"[erreur MCP] pas de réponse après 5m30s (délai de l'appel ; un argument timeout plus grand l'allonge)"
|
||||
);
|
||||
// Le champ survit à un aller-retour JSON, absent quand il vaut 0.
|
||||
let c: ServerConfig =
|
||||
serde_json::from_value(json!({"command": "x", "callTimeoutSec": 45})).unwrap();
|
||||
assert_eq!(c.call_timeout_sec, 45);
|
||||
assert_eq!(serde_json::to_value(&c).unwrap()["callTimeoutSec"], 45);
|
||||
assert!(
|
||||
serde_json::to_value(cfg(0))
|
||||
.unwrap()
|
||||
.get("callTimeoutSec")
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn noms() {
|
||||
assert_eq!(sanitize("mon serveur!"), "mon_serveur");
|
||||
|
||||
@@ -39,6 +39,9 @@ pub const INTERNAL_TOOLS: &str = "/internal/facade/v1/mcp/tools";
|
||||
pub const INTERNAL_CALL: &str = "/internal/facade/v1/mcp/call";
|
||||
pub const INTERNAL_PREWARM: &str = "/internal/facade/v1/mcp/prewarm";
|
||||
|
||||
/// Erreur d'un appel au délai dépassé (`context.DeadlineExceeded` du Go).
|
||||
const CALL_DEADLINE: &str = "context deadline exceeded";
|
||||
|
||||
/// Issue d'une tentative de connexion.
|
||||
#[derive(Default)]
|
||||
struct SlotState {
|
||||
@@ -251,15 +254,20 @@ impl Mcp {
|
||||
return format!("[erreur] outil MCP inconnu ou serveur non connecté: {name}");
|
||||
};
|
||||
let cap = config::output_cap(config::ctx_window(&store.config().await));
|
||||
let mut err = match self.call_once(&server, &tool, args, cap).await {
|
||||
let servers = Self::load(store).await;
|
||||
let timeout =
|
||||
config::call_timeout_for(&servers.get(&server).cloned().unwrap_or_default(), args);
|
||||
let mut err = match self.call_once(&server, &tool, args, cap, timeout).await {
|
||||
Ok(r) => return r,
|
||||
Err(e) => e,
|
||||
};
|
||||
let servers = Self::load(store).await;
|
||||
if err == CALL_DEADLINE {
|
||||
return config::call_deadline_error(timeout);
|
||||
}
|
||||
if let Some(cfg) = servers.get(&server).filter(|c| c.enabled) {
|
||||
self.invalidate(&server).await;
|
||||
self.ensure(&server, cfg, version).await;
|
||||
match self.call_once(&server, &tool, args, cap).await {
|
||||
match self.call_once(&server, &tool, args, cap, timeout).await {
|
||||
Ok(r) => return r,
|
||||
Err(e) => err = e,
|
||||
}
|
||||
@@ -273,6 +281,7 @@ impl Mcp {
|
||||
tool: &str,
|
||||
args: &Map<String, Value>,
|
||||
cap: usize,
|
||||
timeout: std::time::Duration,
|
||||
) -> Result<String, String> {
|
||||
let slot = lock(&self.slots).get(server).cloned();
|
||||
let sess = slot
|
||||
@@ -281,9 +290,9 @@ impl Mcp {
|
||||
.and_then(|st| st.sess.clone())
|
||||
.filter(|s| !s.is_closed())
|
||||
.ok_or_else(|| format!("serveur {server} non connecté"))?;
|
||||
let res = tokio::time::timeout(config::CALL_TIMEOUT, sess.call_tool(tool, args))
|
||||
let res = tokio::time::timeout(timeout, sess.call_tool(tool, args))
|
||||
.await
|
||||
.map_err(|_| "context deadline exceeded".to_string())??;
|
||||
.map_err(|_| CALL_DEADLINE.to_string())??;
|
||||
let out = config::cap_output(config::flatten_content(&res), cap);
|
||||
if res.get("isError").and_then(Value::as_bool) == Some(true) {
|
||||
return Ok(format!("[l'outil a renvoyé une erreur]\n{out}"));
|
||||
@@ -570,6 +579,7 @@ pub async fn serve(
|
||||
.collect(),
|
||||
enabled: d.opt_bool("enabled").unwrap_or(true),
|
||||
disabled_tools: Vec::new(),
|
||||
call_timeout_sec: 0,
|
||||
};
|
||||
let name = d.str("name");
|
||||
let res = mcp
|
||||
|
||||
@@ -345,7 +345,7 @@ impl Strata {
|
||||
"mmap": mode == "disk" || mode == "arena", "drop": mode == "drop",
|
||||
"experts": experts, "mode_label": experts_label(&mode), "mode": mode,
|
||||
"main_gpu": main_gpu,
|
||||
"has_helper": false, "helper_on": false,
|
||||
"has_helper": false, "helper_on": false, "split_layers": false,
|
||||
});
|
||||
let kvr = val("--kv-resident");
|
||||
if !kvr.is_empty() {
|
||||
@@ -356,6 +356,7 @@ impl Strata {
|
||||
d["has_helper"] = json!(true);
|
||||
if helper_on(&pc) {
|
||||
d["helper_on"] = json!(true);
|
||||
d["split_layers"] = json!(layer_split(&pc));
|
||||
let hg = gpu_name(h);
|
||||
if !hg.is_empty() {
|
||||
d["helper_gpu"] = json!(hg);
|
||||
@@ -825,6 +826,7 @@ pub async fn serve(
|
||||
("vision", Kind::Bool),
|
||||
("helper", Kind::Bool),
|
||||
("experts", Kind::Str),
|
||||
("split", Kind::Str),
|
||||
],
|
||||
);
|
||||
if let Some(e) = d.err.clone() {
|
||||
@@ -838,6 +840,7 @@ pub async fn serve(
|
||||
vision: d.bool("vision"),
|
||||
helper: d.bool("helper"),
|
||||
experts: d.str("experts"),
|
||||
split: d.str("split"),
|
||||
};
|
||||
if r.preset.contains(['/', '\\']) || r.preset.is_empty() {
|
||||
return fail(StatusCode::BAD_REQUEST, "preset invalide");
|
||||
|
||||
@@ -19,24 +19,27 @@ use std::path::{Path, PathBuf};
|
||||
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
/// Paquet figé (release moe-v1.0 d'AJEAN).
|
||||
pub const VERSION: &str = "1.0";
|
||||
pub const RELEASE_BASE: &str = "https://github.com/nathaninline/ajean/releases/download/moe-v1.0/";
|
||||
pub const SRC_ASSET: &str = "ajean-moe-src-1.0.tar.gz";
|
||||
pub const ENGINE_ASSET: &str = "ajean-moe-engine-1.0-linux-x64-cuda12.zip";
|
||||
/// Paquet figé (release moe-v1.1 d'AJEAN : experts de la carte d'aide
|
||||
/// calculés par elle à la lecture des prompts). Un modèle installé avec la
|
||||
/// 1.0 passe à la 1.1 à son premier lancement (`strataMigrate`, côté Go :
|
||||
/// `loki serve` d'un preset Strata reste au Go).
|
||||
pub const VERSION: &str = "1.1";
|
||||
pub const RELEASE_BASE: &str = "https://github.com/nathaninline/ajean/releases/download/moe-v1.1/";
|
||||
pub const SRC_ASSET: &str = "ajean-moe-src-1.1.tar.gz";
|
||||
pub const ENGINE_ASSET: &str = "ajean-moe-engine-1.1-linux-x64-cuda12.zip";
|
||||
/// Le nom sous lequel l'installeur attend le moteur (CUDA12_ASSET).
|
||||
pub const ENGINE_LOCAL: &str = "strata-linux-x64-cuda12.zip";
|
||||
|
||||
/// `strataAssetSHA`.
|
||||
/// `strataAssetSHA` (celles qu'AJEAN publie pour moe-v1.1).
|
||||
pub fn default_asset_sha() -> HashMap<String, String> {
|
||||
HashMap::from([
|
||||
(
|
||||
SRC_ASSET.to_string(),
|
||||
"5747f69426973d957e4f9894a8e3333c6ef0d6ac8565e26ddb8d36f52211cb3b".to_string(),
|
||||
"5a72a90f2182ff0ccab2bdbd7cff15598a21ad94501aa19de2b1b18e2dbc8115".to_string(),
|
||||
),
|
||||
(
|
||||
ENGINE_ASSET.to_string(),
|
||||
"b5851bc138ab32ca3ffefc16bf91c8f557e4d503503473a22f2b6c9e9b8fd34c".to_string(),
|
||||
"8c717206ee90120f967445169dc39bb11beffdad3d592c75ae08f75bec72f0d3".to_string(),
|
||||
),
|
||||
])
|
||||
}
|
||||
@@ -137,7 +140,11 @@ pub fn quant(id: &str) -> Option<Quant> {
|
||||
}
|
||||
|
||||
pub const LOW_RAM_HEADROOM_GB: f64 = 10.0;
|
||||
pub const DROP_HEADROOM_GB: f64 = 7.0;
|
||||
/// `strataDropHeadroomGB` (le moteur 1.1 prend ~1 Go de plus que la 1.0).
|
||||
pub const DROP_HEADROOM_GB: f64 = 8.0;
|
||||
/// `strataDropMaxCtx` : contexte maximal en mode hors RAM (le cache KV
|
||||
/// au-delà de la part en VRAM est verrouillé en RAM).
|
||||
pub const DROP_MAX_CTX: i64 = 131072;
|
||||
|
||||
/// `strataHelperExpertsGB`.
|
||||
pub fn helper_experts_gb(vram_gb: f64) -> f64 {
|
||||
@@ -280,6 +287,13 @@ pub fn helper_on(cfg: &Cfg) -> bool {
|
||||
!h.is_empty() && h != "-1" && cfg_get(cfg, "STRATA_HELPER") != "0"
|
||||
}
|
||||
|
||||
/// `strataLayerSplit` : les deux cartes se partagent les couches
|
||||
/// (`STRATA_SPLIT=layers`) au lieu que la seconde n'aide que pour une part
|
||||
/// des experts. Un choix, jamais imposé : le mode d'aide reste le défaut.
|
||||
pub fn layer_split(cfg: &Cfg) -> bool {
|
||||
helper_on(cfg) && cfg_get(cfg, "STRATA_SPLIT") == "layers"
|
||||
}
|
||||
|
||||
/// `strataResolveExperts` : le mode lancé (drop, ram, arena, disk), d'après
|
||||
/// le choix du preset et la RAM disponible.
|
||||
pub fn resolve_experts(
|
||||
@@ -290,7 +304,9 @@ pub fn resolve_experts(
|
||||
helper_gb: f64,
|
||||
) -> &'static str {
|
||||
let bin_ready = pack_has_experts_bin(args) && paths.engine_installed() == ENGINE_ASSET;
|
||||
let drop_ready = bin_ready && helper_on(cfg) && cfg_get(cfg, "STRATA_DROP") != "0";
|
||||
// (jamais en partage des couches : la seconde carte n'y garde pas d'experts)
|
||||
let drop_ready =
|
||||
bin_ready && helper_on(cfg) && !layer_split(cfg) && cfg_get(cfg, "STRATA_DROP") != "0";
|
||||
let arena = arena_gb(cfg);
|
||||
match cfg_get(cfg, "STRATA_EXPERTS")
|
||||
.trim()
|
||||
@@ -404,7 +420,18 @@ pub fn build_config(base: &Value, cfg: &Cfg, api_key: &str, mode: &str) -> Resul
|
||||
args = set_arg(args, flag, v);
|
||||
}
|
||||
let helper = cfg_get(cfg, "STRATA_HELPER_GPU").trim();
|
||||
let has_helper = helper_on(cfg);
|
||||
let mut has_helper = helper_on(cfg);
|
||||
if layer_split(cfg) {
|
||||
// Partage des couches : la clé « gpu » liste les deux cartes (numéros
|
||||
// nvidia-smi), le serveur de Strata en déduit --layer-split.
|
||||
let main = cfg_get(cfg, "STRATA_MAIN_GPU")
|
||||
.trim()
|
||||
.parse::<i64>()
|
||||
.unwrap_or(0);
|
||||
let second = helper.parse::<i64>().unwrap_or(0);
|
||||
out.insert("gpu".into(), json!([main, second]));
|
||||
has_helper = false;
|
||||
}
|
||||
if has_helper {
|
||||
args = set_arg(args, "--expert-cache-device1", "auto");
|
||||
if !has_arg(&args, "--remote-expert-opt") {
|
||||
@@ -431,6 +458,8 @@ pub fn build_config(base: &Value, cfg: &Cfg, api_key: &str, mode: &str) -> Resul
|
||||
env.insert("STRATA_REMOTE_DROP".into(), json!("1"));
|
||||
env.insert("STRATA_STAGER_THREADS".into(), json!("8"));
|
||||
env.insert("STRATA_STAGER_RING".into(), json!("32"));
|
||||
// VRAM libre réservée de la carte d'aide : 640 Mio (défaut 1024)
|
||||
env.insert("STRATA_REMOTE_DROP_RESERVE_MIB".into(), json!("640"));
|
||||
}
|
||||
"arena" => {
|
||||
args = set_arg(args, "--pcie-frac", "0");
|
||||
@@ -445,10 +474,23 @@ pub fn build_config(base: &Value, cfg: &Cfg, api_key: &str, mode: &str) -> Resul
|
||||
for (k, v) in ENV_TUNING {
|
||||
env.insert(k.into(), json!(v));
|
||||
}
|
||||
if has_helper {
|
||||
// Lecture d'un prompt (moteur 1.1) : les experts de la carte d'aide
|
||||
// sont calculés par elle (source « helper ») ; le chemin direct ne
|
||||
// garde que les ajouts sous 48 jetons.
|
||||
env.insert("STRATA_PREFILL_HELPER_SOURCE".into(), json!("1"));
|
||||
args = set_arg(args, "--short-read", "48");
|
||||
}
|
||||
let drop_on = env.get("STRATA_REMOTE_DROP").and_then(Value::as_str) == Some("1");
|
||||
out.insert("env".into(), Value::Object(env));
|
||||
let ctx = cfg_get(cfg, "CTX").trim();
|
||||
if !ctx.is_empty() {
|
||||
args = set_arg(args, "--max-context", ctx);
|
||||
// hors RAM, le cache KV verrouillé au-delà de DROP_MAX_CTX ne tient plus
|
||||
let capped = match ctx.parse::<i64>() {
|
||||
Ok(n) if n > DROP_MAX_CTX && drop_on => DROP_MAX_CTX.to_string(),
|
||||
_ => ctx.to_string(),
|
||||
};
|
||||
args = set_arg(args, "--max-context", &capped);
|
||||
}
|
||||
for (key, flag) in [
|
||||
("STRATA_KV", "--kv"),
|
||||
@@ -766,6 +808,8 @@ pub struct SettingsReq {
|
||||
pub vision: bool,
|
||||
pub helper: bool,
|
||||
pub experts: String,
|
||||
/// « helper » ou « layers » ; vide = inchangé (anciens clients).
|
||||
pub split: String,
|
||||
}
|
||||
|
||||
/// `strataApplySettings` : réécrit les clés du preset. Pure.
|
||||
@@ -786,6 +830,9 @@ pub fn apply_settings(content: &str, r: &SettingsReq) -> Result<String, String>
|
||||
{
|
||||
return Err(format!("placement des experts inconnu : {}", r.experts));
|
||||
}
|
||||
if !r.split.is_empty() && r.split != "helper" && r.split != "layers" {
|
||||
return Err(format!("répartition inconnue : {}", r.split));
|
||||
}
|
||||
let mut set: Vec<(&str, String)> = vec![
|
||||
("CTX", r.ctx.to_string()),
|
||||
("STRATA_KV", r.kv.clone()),
|
||||
@@ -796,6 +843,11 @@ pub fn apply_settings(content: &str, r: &SettingsReq) -> Result<String, String>
|
||||
if !r.experts.is_empty() {
|
||||
set.push(("STRATA_EXPERTS", r.experts.clone()));
|
||||
}
|
||||
if !r.split.is_empty() {
|
||||
// « helper » est le défaut : la clé disparaît plutôt que d'être écrite
|
||||
let v = if r.split == "layers" { "layers" } else { "" };
|
||||
set.push(("STRATA_SPLIT", v.into()));
|
||||
}
|
||||
let mut lines: Vec<String> = Vec::new();
|
||||
for l in content.trim_end_matches('\n').split('\n') {
|
||||
let k = l.trim().split_once('=').map_or(l.trim(), |(k, _)| k);
|
||||
@@ -805,6 +857,9 @@ pub fn apply_settings(content: &str, r: &SettingsReq) -> Result<String, String>
|
||||
lines.push(l.to_string());
|
||||
}
|
||||
for (k, v) in set {
|
||||
if k == "STRATA_SPLIT" && v.is_empty() {
|
||||
continue;
|
||||
}
|
||||
lines.push(format!("{k}={v}"));
|
||||
}
|
||||
Ok(lines.join("\n") + "\n")
|
||||
|
||||
@@ -369,6 +369,125 @@ fn modes_des_experts() {
|
||||
assert!(out["env"].get("STRATA_REMOTE_DROP").is_none());
|
||||
}
|
||||
|
||||
// Moteur 1.1 (`TestStrataBuildConfigHelperPrompt`).
|
||||
#[test]
|
||||
fn lecture_des_prompts_par_la_carte_d_aide() {
|
||||
let base = json!({ "args": ["--pack", "/p"] });
|
||||
let out = build_config(&base, &cfg(&[("STRATA_HELPER_GPU", "0")]), "", "ram").unwrap();
|
||||
assert_eq!(arg_after(&args_of(&out), "--short-read"), "48");
|
||||
assert_eq!(out["env"]["STRATA_PREFILL_HELPER_SOURCE"], "1");
|
||||
for c in [
|
||||
Cfg::new(),
|
||||
cfg(&[("STRATA_HELPER_GPU", "0"), ("STRATA_HELPER", "0")]),
|
||||
] {
|
||||
let out = build_config(&base, &c, "", "ram").unwrap();
|
||||
assert_eq!(arg_after(&args_of(&out), "--short-read"), "150", "{c:?}");
|
||||
assert!(out["env"].get("STRATA_PREFILL_HELPER_SOURCE").is_none());
|
||||
}
|
||||
}
|
||||
|
||||
// `TestStrataBuildConfigDropReserveAndCtx`.
|
||||
#[test]
|
||||
fn hors_ram_reserve_et_contexte() {
|
||||
let base = json!({ "args": ["--pack", "/p"], "env": {} });
|
||||
let c = cfg(&[("STRATA_HELPER_GPU", "0"), ("CTX", "167936")]);
|
||||
let out = build_config(&base, &c, "", "drop").unwrap();
|
||||
assert_eq!(out["env"]["STRATA_REMOTE_DROP_RESERVE_MIB"], "640");
|
||||
assert_eq!(arg_after(&args_of(&out), "--max-context"), "131072");
|
||||
for mode in ["ram", "arena", "disk"] {
|
||||
let out = build_config(&base, &c, "", mode).unwrap();
|
||||
assert_eq!(
|
||||
arg_after(&args_of(&out), "--max-context"),
|
||||
"167936",
|
||||
"{mode}"
|
||||
);
|
||||
assert!(out["env"].get("STRATA_REMOTE_DROP_RESERVE_MIB").is_none());
|
||||
}
|
||||
}
|
||||
|
||||
// `TestStrataBuildConfigLayerSplit`.
|
||||
#[test]
|
||||
fn partage_des_couches() {
|
||||
let base = json!({
|
||||
"args": ["--pack", "/p", "--vram-reserve-mib", "900"],
|
||||
"vision": { "exe": "/v" },
|
||||
});
|
||||
let mut c = cfg(&[
|
||||
("STRATA_MAIN_GPU", "1"),
|
||||
("STRATA_HELPER_GPU", "0"),
|
||||
("STRATA_SPLIT", "layers"),
|
||||
]);
|
||||
let out = build_config(&base, &c, "", "drop").unwrap();
|
||||
assert_eq!(out["gpu"], json!([1, 0]));
|
||||
let a = args_of(&out);
|
||||
assert!(!has_arg(&a, "--remote-expert-opt") && !has_arg(&a, "--expert-cache-device1"));
|
||||
assert_eq!(arg_after(&a, "--vram-reserve-mib"), "900");
|
||||
assert_eq!(arg_after(&a, "--short-read"), "150");
|
||||
assert!(out["env"].get("STRATA_REMOTE_DROP").is_none());
|
||||
assert!(out["env"].get("STRATA_PREFILL_HELPER_SOURCE").is_none());
|
||||
assert!(out["vision"].get("cuda_device").is_none());
|
||||
c.remove("STRATA_SPLIT");
|
||||
let out = build_config(&base, &c, "", "drop").unwrap();
|
||||
assert!(out.get("gpu").is_none());
|
||||
assert!(has_arg(&args_of(&out), "--remote-expert-opt"));
|
||||
c.insert("STRATA_SPLIT".into(), "layers".into());
|
||||
c.insert("STRATA_HELPER".into(), "0".into());
|
||||
assert!(!layer_split(&c));
|
||||
}
|
||||
|
||||
// `TestStrataResolveExpertsLayerSplit`.
|
||||
#[test]
|
||||
fn partage_des_couches_sans_hors_ram() {
|
||||
let d = tempfile::tempdir().unwrap();
|
||||
let p = Paths {
|
||||
home: d.path().to_path_buf(),
|
||||
};
|
||||
let pack = d.path().join("pack");
|
||||
std::fs::create_dir_all(&pack).unwrap();
|
||||
std::fs::write(pack.join("experts.bin"), "x").unwrap();
|
||||
std::fs::create_dir_all(p.src_dir().join("engine-cuda12")).unwrap();
|
||||
std::fs::write(p.engine_marker(), format!("{ENGINE_ASSET}\n")).unwrap();
|
||||
let args = vec!["--pack".to_string(), pack.to_string_lossy().into_owned()];
|
||||
let mut c = cfg(&[
|
||||
("STRATA_CONFIG", "/x/strata-swift-iq3_xxs.json"),
|
||||
("STRATA_HELPER_GPU", "1"),
|
||||
("STRATA_DROP", "1"),
|
||||
]);
|
||||
assert_eq!(resolve_experts(&p, &c, &args, 44.0, 12.0), "drop");
|
||||
c.insert("STRATA_SPLIT".into(), "layers".into());
|
||||
assert_eq!(resolve_experts(&p, &c, &args, 44.0, 12.0), "arena");
|
||||
c.insert("STRATA_EXPERTS".into(), "ram".into());
|
||||
assert_eq!(resolve_experts(&p, &c, &args, 44.0, 12.0), "ram");
|
||||
}
|
||||
|
||||
// `TestStrataApplySettingsSplit`.
|
||||
#[test]
|
||||
fn reglages_de_la_seconde_carte() {
|
||||
let input = "ENGINE=strata\nSTRATA_HELPER_GPU=0\n";
|
||||
let mut r = SettingsReq {
|
||||
ctx: 131072,
|
||||
kv: "fp16".into(),
|
||||
spec: 3,
|
||||
vision: true,
|
||||
helper: true,
|
||||
split: "layers".into(),
|
||||
..SettingsReq::default()
|
||||
};
|
||||
let out = apply_settings(input, &r).unwrap();
|
||||
assert!(out.contains("STRATA_SPLIT=layers\n"), "{out:?}");
|
||||
r.split = "helper".into();
|
||||
let out = apply_settings(&out, &r).unwrap();
|
||||
assert!(!out.contains("STRATA_SPLIT"), "{out:?}");
|
||||
r.split = String::new();
|
||||
let out = apply_settings("STRATA_SPLIT=layers\n", &r).unwrap();
|
||||
assert!(out.contains("STRATA_SPLIT=layers"), "{out:?}");
|
||||
r.split = "nimporte".into();
|
||||
assert_eq!(
|
||||
apply_settings(input, &r).unwrap_err(),
|
||||
"répartition inconnue : nimporte"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reglages_des_experts() {
|
||||
let input = "ENGINE=strata\nSTRATA_EXPERTS=auto\n";
|
||||
|
||||
@@ -48,6 +48,10 @@ static MANIFEST: Asset = Asset::new(
|
||||
"application/manifest+json",
|
||||
"public, max-age=3600",
|
||||
);
|
||||
/// KaTeX (formules LaTeX des réponses) : mêmes en-têtes que `web_katex.go`.
|
||||
const KATEX_CACHE: &str = "public, max-age=86400";
|
||||
static KATEX_JS: Asset = Asset::new(assets::KATEX_JS, "application/javascript", KATEX_CACHE);
|
||||
static KATEX_CSS: Asset = Asset::new(assets::KATEX_CSS, "text/css; charset=utf-8", KATEX_CACHE);
|
||||
|
||||
/// Réponse statique pour cette requête, ou `None` si la route n'est pas une
|
||||
/// ressource de l'UI (elle part alors au Go, qui rendra son 404 habituel).
|
||||
@@ -76,6 +80,9 @@ pub fn serve(method: &Method, path: &str, headers: &HeaderMap) -> Option<Respons
|
||||
}
|
||||
"/manifest.webmanifest" => asset(&MANIFEST, accepts_gzip),
|
||||
p if p.starts_with("/fonts/") => font(p)?,
|
||||
"/katex/katex.min.js" => asset(&KATEX_JS, accepts_gzip),
|
||||
"/katex/katex.min.css" => asset(&KATEX_CSS, accepts_gzip),
|
||||
p if p.starts_with("/katex/") => katex_font(p)?,
|
||||
_ => return None,
|
||||
};
|
||||
if accepts_gzip {
|
||||
@@ -130,6 +137,20 @@ fn font(path: &str) -> Option<Response<Body>> {
|
||||
Some(r)
|
||||
}
|
||||
|
||||
/// `/katex/fonts/<nom>.woff2` — chemin exact (pas le dernier segment seul,
|
||||
/// contrairement à `/fonts/`), comme `katexFile` dans `web_katex.go`. Le
|
||||
/// reste de `/katex/` part au Go, qui rend son 404.
|
||||
fn katex_font(path: &str) -> Option<Response<Body>> {
|
||||
let rel = path.strip_prefix("/katex/")?;
|
||||
let (_, body) = assets::KATEX_FONTS.iter().find(|(n, _)| *n == rel)?;
|
||||
let mut r = Response::new(full(Bytes::from_static(body)));
|
||||
let h = r.headers_mut();
|
||||
h.insert(header::CONTENT_TYPE, HeaderValue::from_static("font/woff2"));
|
||||
h.insert(header::CACHE_CONTROL, HeaderValue::from_static(KATEX_CACHE));
|
||||
h.insert(header::CONTENT_LENGTH, HeaderValue::from(body.len()));
|
||||
Some(r)
|
||||
}
|
||||
|
||||
fn compress(b: &[u8]) -> Vec<u8> {
|
||||
let mut enc = GzEncoder::new(Vec::with_capacity(b.len() / 3), Compression::default());
|
||||
enc.write_all(b).expect("gzip en mémoire");
|
||||
@@ -193,6 +214,46 @@ mod tests {
|
||||
assert!(get("/fonts/inter.ttf", false).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn katex() {
|
||||
let r = get("/katex/katex.min.js", true).unwrap();
|
||||
assert_eq!(hv(&r, "content-type"), "application/javascript");
|
||||
assert_eq!(hv(&r, "cache-control"), "public, max-age=86400");
|
||||
assert_eq!(hv(&r, "content-encoding"), "gzip");
|
||||
assert!(
|
||||
String::from_utf8_lossy(assets::KATEX_JS).contains("version:\"0.16.11\""),
|
||||
"KaTeX 0.16.11 attendu"
|
||||
);
|
||||
let r = get("/katex/katex.min.css", false).unwrap();
|
||||
assert_eq!(hv(&r, "content-type"), "text/css; charset=utf-8");
|
||||
// Chaque police woff2 demandée par la feuille est servie, non compressée.
|
||||
let css = String::from_utf8_lossy(assets::KATEX_CSS);
|
||||
let polices: Vec<&str> = css
|
||||
.split("url(")
|
||||
.skip(1)
|
||||
.filter_map(|s| s.split(')').next())
|
||||
.filter(|u| u.ends_with(".woff2"))
|
||||
.collect();
|
||||
assert!(polices.len() >= 10, "{} polices", polices.len());
|
||||
for p in polices {
|
||||
let r = get(&format!("/katex/{p}"), true).unwrap_or_else(|| panic!("{p}"));
|
||||
assert_eq!(hv(&r, "content-type"), "font/woff2");
|
||||
assert_eq!(hv(&r, "content-encoding"), "");
|
||||
assert_eq!(hv(&r, "cache-control"), "public, max-age=86400");
|
||||
}
|
||||
// Le reste part au Go (404) : ni index, ni licence, ni dernier segment seul.
|
||||
for p in [
|
||||
"/katex/",
|
||||
"/katex/LICENSE",
|
||||
"/katex/fonts/",
|
||||
"/katex/fonts/absente.woff2",
|
||||
"/katex/KaTeX_Main-Regular.woff2",
|
||||
"/katex/x/fonts/KaTeX_Main-Regular.woff2",
|
||||
] {
|
||||
assert!(get(p, false).is_none(), "{p}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gzip_si_accepte() {
|
||||
let r = get("/", true).unwrap();
|
||||
|
||||
@@ -469,12 +469,19 @@ async fn bail_travail_annexe_et_trace() {
|
||||
*bn.go.side_jobs.lock().unwrap(),
|
||||
vec!["begin 1".to_string(), "end 1 true".to_string()]
|
||||
);
|
||||
// La longueur du prompt suit le corpus de code, c'est-à-dire l'UI
|
||||
// embarquée : la vérifier en dur cassait le test à chaque retouche de
|
||||
// l'interface. On vérifie la forme de la trace.
|
||||
let perfs = bn.go.bench_perfs.lock().unwrap().clone();
|
||||
assert_eq!(perfs.len(), 1, "{perfs:?}");
|
||||
let n = perfs[0]["prompt_n"].as_i64().unwrap_or(0);
|
||||
assert!(n > 0, "{perfs:?}");
|
||||
assert_eq!(
|
||||
*bn.go.bench_perfs.lock().unwrap(),
|
||||
vec![json!({
|
||||
"prompt_n": 2036, "prompt_ms": 1018.0, "prompt_per_second": 2000.0,
|
||||
perfs[0],
|
||||
json!({
|
||||
"prompt_n": n, "prompt_ms": n as f64 / 2.0, "prompt_per_second": 2000.0,
|
||||
"predicted_n": 300, "predicted_per_second": 50.0,
|
||||
})]
|
||||
})
|
||||
);
|
||||
// La clé de l'API locale accompagne les requêtes du bench.
|
||||
let auth = bn.fake.lock().unwrap().auth.clone();
|
||||
|
||||
@@ -260,6 +260,13 @@ async fn wait_finished(dl: &Downloads, name: &str) -> DlState {
|
||||
panic!("téléchargement {name} jamais terminé : {:?}", dl.status());
|
||||
}
|
||||
|
||||
fn state_of(dl: &Downloads, name: &str) -> DlState {
|
||||
dl.status()
|
||||
.into_iter()
|
||||
.find(|s| s.filename == name)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn models(home: &Path) -> PathBuf {
|
||||
home.join("models")
|
||||
}
|
||||
@@ -537,6 +544,58 @@ async fn annulation_efface_le_part() {
|
||||
assert_eq!(entries(&models(tmp.path())), Vec::<String>::new());
|
||||
}
|
||||
|
||||
// `TestRunDownloadPauseResume` : la pause coupe le flux et garde ce qui est
|
||||
// écrit ; la reprise repart de là par une plage et termine un fichier
|
||||
// identique à la source.
|
||||
#[tokio::test]
|
||||
async fn pause_puis_reprise() {
|
||||
let (addr, hf) = fake_hf().await;
|
||||
let data = payload(8 << 20, 5);
|
||||
hf.files
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert("/m.gguf".into(), Arc::new(data.clone()));
|
||||
*hf.slow.lock().unwrap() = true;
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let dl = Arc::new(Downloads::new());
|
||||
let (code, _) = dl.start(
|
||||
env(),
|
||||
&dirs_at(tmp.path()),
|
||||
&format!("http://{addr}/m.gguf"),
|
||||
"",
|
||||
);
|
||||
assert_eq!(code, StatusCode::OK);
|
||||
assert!(!dl.pause("autre.gguf", true));
|
||||
tokio::time::sleep(Duration::from_millis(300)).await;
|
||||
assert!(dl.pause("m.gguf", true));
|
||||
tokio::time::sleep(Duration::from_millis(1500)).await;
|
||||
let a = state_of(&dl, "m.gguf");
|
||||
tokio::time::sleep(Duration::from_millis(1500)).await;
|
||||
let b = state_of(&dl, "m.gguf");
|
||||
assert!(
|
||||
a.paused && !b.finished && a.done == b.done && a.done > 0 && b.speed == 0,
|
||||
"pause inopérante : {a:?} puis {b:?}"
|
||||
);
|
||||
let before = hf.log.lock().unwrap().len();
|
||||
assert!(dl.pause("m.gguf", false));
|
||||
let end = wait_finished(&dl, "m.gguf").await;
|
||||
assert!(
|
||||
end.err.is_empty() && !end.canceled && !end.paused,
|
||||
"{end:?}"
|
||||
);
|
||||
let got = std::fs::read(models(tmp.path()).join("m.gguf")).unwrap();
|
||||
assert!(got == data, "fichier différent de la source après reprise");
|
||||
// La reprise demande la suite, pas le début.
|
||||
let log = hf.log.lock().unwrap().clone();
|
||||
let resumed = log[before..]
|
||||
.iter()
|
||||
.filter_map(|l| l.strip_prefix("/m.gguf bytes="))
|
||||
.any(|r| !r.starts_with("0-"));
|
||||
assert!(resumed, "{log:?}");
|
||||
// Terminé : plus rien à mettre en pause.
|
||||
assert!(!dl.pause("m.gguf", true));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn refus_avant_de_lancer() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -231,8 +231,8 @@
|
||||
"predicted_ms": 6000,
|
||||
"predicted_n": 300,
|
||||
"predicted_per_second": 50,
|
||||
"prompt_ms": 1018,
|
||||
"prompt_n": 2036,
|
||||
"prompt_ms": 1014.5,
|
||||
"prompt_n": 2029,
|
||||
"prompt_per_second": 2000,
|
||||
"protocol": 2
|
||||
},
|
||||
@@ -260,7 +260,7 @@
|
||||
},
|
||||
{
|
||||
"body": {
|
||||
"content": "<163664 octets b7452851885a>"
|
||||
"content": "<163781 octets 608b16075e1c>"
|
||||
},
|
||||
"method": "POST",
|
||||
"path": "/tokenize"
|
||||
@@ -304,7 +304,7 @@
|
||||
"max_tokens": 300,
|
||||
"messages": [
|
||||
{
|
||||
"content": "<8129 octets ae9cc838495d>",
|
||||
"content": "<8103 octets e6352865574a>",
|
||||
"role": "user"
|
||||
}
|
||||
],
|
||||
@@ -335,8 +335,8 @@
|
||||
"predicted_ms": 6000,
|
||||
"predicted_n": 300,
|
||||
"predicted_per_second": 50,
|
||||
"prompt_ms": 1018,
|
||||
"prompt_n": 2036,
|
||||
"prompt_ms": 1014.5,
|
||||
"prompt_n": 2029,
|
||||
"prompt_per_second": 2000,
|
||||
"protocol": 2
|
||||
}
|
||||
@@ -358,8 +358,8 @@
|
||||
"predicted_ms": 6000,
|
||||
"predicted_n": 300,
|
||||
"predicted_per_second": 50,
|
||||
"prompt_ms": 1018,
|
||||
"prompt_n": 2036,
|
||||
"prompt_ms": 1014.5,
|
||||
"prompt_n": 2029,
|
||||
"prompt_per_second": 2000,
|
||||
"protocol": 2
|
||||
}
|
||||
@@ -387,8 +387,8 @@
|
||||
"predicted_ms": 6000,
|
||||
"predicted_n": 300,
|
||||
"predicted_per_second": 50,
|
||||
"prompt_ms": 1018,
|
||||
"prompt_n": 2036,
|
||||
"prompt_ms": 1014.5,
|
||||
"prompt_n": 2029,
|
||||
"prompt_per_second": 2000,
|
||||
"protocol": 2
|
||||
}
|
||||
@@ -410,8 +410,8 @@
|
||||
"predicted_ms": 6000,
|
||||
"predicted_n": 300,
|
||||
"predicted_per_second": 50,
|
||||
"prompt_ms": 1018,
|
||||
"prompt_n": 2036,
|
||||
"prompt_ms": 1014.5,
|
||||
"prompt_n": 2029,
|
||||
"prompt_per_second": 2000,
|
||||
"protocol": 2
|
||||
}
|
||||
@@ -433,8 +433,8 @@
|
||||
"predicted_ms": 6000,
|
||||
"predicted_n": 300,
|
||||
"predicted_per_second": 50,
|
||||
"prompt_ms": 1018,
|
||||
"prompt_n": 2036,
|
||||
"prompt_ms": 1014.5,
|
||||
"prompt_n": 2029,
|
||||
"prompt_per_second": 2000,
|
||||
"protocol": 2
|
||||
}
|
||||
@@ -542,8 +542,8 @@
|
||||
"predicted_ms": 6000,
|
||||
"predicted_n": 300,
|
||||
"predicted_per_second": 50,
|
||||
"prompt_ms": 1018,
|
||||
"prompt_n": 2036,
|
||||
"prompt_ms": 1014.5,
|
||||
"prompt_n": 2029,
|
||||
"prompt_per_second": 2000,
|
||||
"protocol": 2
|
||||
},
|
||||
@@ -571,7 +571,7 @@
|
||||
},
|
||||
{
|
||||
"body": {
|
||||
"content": "<163664 octets b7452851885a>"
|
||||
"content": "<163781 octets 608b16075e1c>"
|
||||
},
|
||||
"method": "POST",
|
||||
"path": "/tokenize"
|
||||
@@ -615,7 +615,7 @@
|
||||
"max_tokens": 300,
|
||||
"messages": [
|
||||
{
|
||||
"content": "<8129 octets ae9cc838495d>",
|
||||
"content": "<8103 octets e6352865574a>",
|
||||
"role": "user"
|
||||
}
|
||||
],
|
||||
@@ -855,8 +855,8 @@
|
||||
"predicted_ms": 6000,
|
||||
"predicted_n": 300,
|
||||
"predicted_per_second": 50,
|
||||
"prompt_ms": 1018,
|
||||
"prompt_n": 2036,
|
||||
"prompt_ms": 1014.5,
|
||||
"prompt_n": 2029,
|
||||
"prompt_per_second": 2000,
|
||||
"protocol": 2
|
||||
}
|
||||
@@ -947,8 +947,8 @@
|
||||
"predicted_ms": 6000,
|
||||
"predicted_n": 300,
|
||||
"predicted_per_second": 50,
|
||||
"prompt_ms": 1018,
|
||||
"prompt_n": 2036,
|
||||
"prompt_ms": 1014.5,
|
||||
"prompt_n": 2029,
|
||||
"prompt_per_second": 2000,
|
||||
"protocol": 2
|
||||
}
|
||||
@@ -993,8 +993,8 @@
|
||||
"predicted_ms": 6000,
|
||||
"predicted_n": 300,
|
||||
"predicted_per_second": 50,
|
||||
"prompt_ms": 1018,
|
||||
"prompt_n": 2036,
|
||||
"prompt_ms": 1014.5,
|
||||
"prompt_n": 2029,
|
||||
"prompt_per_second": 2000,
|
||||
"protocol": 2
|
||||
},
|
||||
@@ -1022,7 +1022,7 @@
|
||||
},
|
||||
{
|
||||
"body": {
|
||||
"content": "<163664 octets b7452851885a>"
|
||||
"content": "<163781 octets 608b16075e1c>"
|
||||
},
|
||||
"method": "POST",
|
||||
"path": "/tokenize"
|
||||
@@ -1066,7 +1066,7 @@
|
||||
"max_tokens": 300,
|
||||
"messages": [
|
||||
{
|
||||
"content": "<8129 octets ae9cc838495d>",
|
||||
"content": "<8103 octets e6352865574a>",
|
||||
"role": "user"
|
||||
}
|
||||
],
|
||||
@@ -1103,8 +1103,8 @@
|
||||
"predicted_ms": 6000,
|
||||
"predicted_n": 300,
|
||||
"predicted_per_second": 50,
|
||||
"prompt_ms": 1018,
|
||||
"prompt_n": 2036,
|
||||
"prompt_ms": 1014.5,
|
||||
"prompt_n": 2029,
|
||||
"prompt_per_second": 2000,
|
||||
"protocol": 2
|
||||
}
|
||||
@@ -1132,8 +1132,8 @@
|
||||
"predicted_ms": 6000,
|
||||
"predicted_n": 300,
|
||||
"predicted_per_second": 50,
|
||||
"prompt_ms": 1018,
|
||||
"prompt_n": 2036,
|
||||
"prompt_ms": 1014.5,
|
||||
"prompt_n": 2029,
|
||||
"prompt_per_second": 2000,
|
||||
"protocol": 2
|
||||
}
|
||||
@@ -1241,8 +1241,8 @@
|
||||
"predicted_ms": 6000,
|
||||
"predicted_n": 300,
|
||||
"predicted_per_second": 50,
|
||||
"prompt_ms": 1018,
|
||||
"prompt_n": 2036,
|
||||
"prompt_ms": 1014.5,
|
||||
"prompt_n": 2029,
|
||||
"prompt_per_second": 2000,
|
||||
"protocol": 2
|
||||
},
|
||||
@@ -1270,7 +1270,7 @@
|
||||
},
|
||||
{
|
||||
"body": {
|
||||
"content": "<163664 octets b7452851885a>"
|
||||
"content": "<163781 octets 608b16075e1c>"
|
||||
},
|
||||
"method": "POST",
|
||||
"path": "/tokenize"
|
||||
@@ -1314,7 +1314,7 @@
|
||||
"max_tokens": 300,
|
||||
"messages": [
|
||||
{
|
||||
"content": "<8129 octets ae9cc838495d>",
|
||||
"content": "<8103 octets e6352865574a>",
|
||||
"role": "user"
|
||||
}
|
||||
],
|
||||
@@ -1554,8 +1554,8 @@
|
||||
"predicted_ms": 6000,
|
||||
"predicted_n": 300,
|
||||
"predicted_per_second": 50,
|
||||
"prompt_ms": 1018,
|
||||
"prompt_n": 2036,
|
||||
"prompt_ms": 1014.5,
|
||||
"prompt_n": 2029,
|
||||
"prompt_per_second": 2000,
|
||||
"protocol": 2
|
||||
}
|
||||
@@ -1646,8 +1646,8 @@
|
||||
"predicted_ms": 6000,
|
||||
"predicted_n": 300,
|
||||
"predicted_per_second": 50,
|
||||
"prompt_ms": 1018,
|
||||
"prompt_n": 2036,
|
||||
"prompt_ms": 1014.5,
|
||||
"prompt_n": 2029,
|
||||
"prompt_per_second": 2000,
|
||||
"protocol": 2
|
||||
}
|
||||
@@ -1686,8 +1686,8 @@
|
||||
"predicted_ms": 81920,
|
||||
"predicted_n": 4096,
|
||||
"predicted_per_second": 50,
|
||||
"prompt_ms": 16403,
|
||||
"prompt_n": 32806,
|
||||
"prompt_ms": 16400.5,
|
||||
"prompt_n": 32801,
|
||||
"prompt_per_second": 2000,
|
||||
"protocol": 2
|
||||
},
|
||||
@@ -1715,7 +1715,7 @@
|
||||
},
|
||||
{
|
||||
"body": {
|
||||
"content": "<163664 octets b7452851885a>"
|
||||
"content": "<163781 octets 608b16075e1c>"
|
||||
},
|
||||
"method": "POST",
|
||||
"path": "/tokenize"
|
||||
@@ -1759,7 +1759,7 @@
|
||||
"max_tokens": 4096,
|
||||
"messages": [
|
||||
{
|
||||
"content": "<131209 octets a079a2b6a1db>",
|
||||
"content": "<131191 octets 64f00ccc3d63>",
|
||||
"role": "user"
|
||||
}
|
||||
],
|
||||
@@ -1790,8 +1790,8 @@
|
||||
"predicted_ms": 81920,
|
||||
"predicted_n": 4096,
|
||||
"predicted_per_second": 50,
|
||||
"prompt_ms": 16403,
|
||||
"prompt_n": 32806,
|
||||
"prompt_ms": 16400.5,
|
||||
"prompt_n": 32801,
|
||||
"prompt_per_second": 2000,
|
||||
"protocol": 2
|
||||
}
|
||||
@@ -1813,8 +1813,8 @@
|
||||
"predicted_ms": 81920,
|
||||
"predicted_n": 4096,
|
||||
"predicted_per_second": 50,
|
||||
"prompt_ms": 16403,
|
||||
"prompt_n": 32806,
|
||||
"prompt_ms": 16400.5,
|
||||
"prompt_n": 32801,
|
||||
"prompt_per_second": 2000,
|
||||
"protocol": 2
|
||||
}
|
||||
@@ -1882,7 +1882,7 @@
|
||||
},
|
||||
{
|
||||
"body": {
|
||||
"content": "<163664 octets b7452851885a>"
|
||||
"content": "<163781 octets 608b16075e1c>"
|
||||
},
|
||||
"method": "POST",
|
||||
"path": "/tokenize"
|
||||
@@ -2049,7 +2049,7 @@
|
||||
},
|
||||
{
|
||||
"body": {
|
||||
"content": "<163664 octets b7452851885a>"
|
||||
"content": "<163781 octets 608b16075e1c>"
|
||||
},
|
||||
"method": "POST",
|
||||
"path": "/tokenize"
|
||||
@@ -2093,7 +2093,7 @@
|
||||
"max_tokens": 16,
|
||||
"messages": [
|
||||
{
|
||||
"content": "<3745 octets 5578a8f2779c>",
|
||||
"content": "<3744 octets dff015dced82>",
|
||||
"role": "user"
|
||||
}
|
||||
],
|
||||
@@ -2187,8 +2187,8 @@
|
||||
"predicted_ms": 6000,
|
||||
"predicted_n": 300,
|
||||
"predicted_per_second": 50,
|
||||
"prompt_ms": 1018,
|
||||
"prompt_n": 2036,
|
||||
"prompt_ms": 1014.5,
|
||||
"prompt_n": 2029,
|
||||
"prompt_per_second": 2000,
|
||||
"protocol": 2
|
||||
},
|
||||
@@ -2216,7 +2216,7 @@
|
||||
},
|
||||
{
|
||||
"body": {
|
||||
"content": "<163664 octets b7452851885a>"
|
||||
"content": "<163781 octets 608b16075e1c>"
|
||||
},
|
||||
"method": "POST",
|
||||
"path": "/tokenize"
|
||||
@@ -2260,7 +2260,7 @@
|
||||
"max_tokens": 300,
|
||||
"messages": [
|
||||
{
|
||||
"content": "<8129 octets ae9cc838495d>",
|
||||
"content": "<8103 octets e6352865574a>",
|
||||
"role": "user"
|
||||
}
|
||||
],
|
||||
@@ -2291,8 +2291,8 @@
|
||||
"predicted_ms": 6000,
|
||||
"predicted_n": 300,
|
||||
"predicted_per_second": 50,
|
||||
"prompt_ms": 1018,
|
||||
"prompt_n": 2036,
|
||||
"prompt_ms": 1014.5,
|
||||
"prompt_n": 2029,
|
||||
"prompt_per_second": 2000,
|
||||
"protocol": 2
|
||||
}
|
||||
@@ -2314,8 +2314,8 @@
|
||||
"predicted_ms": 6000,
|
||||
"predicted_n": 300,
|
||||
"predicted_per_second": 50,
|
||||
"prompt_ms": 1018,
|
||||
"prompt_n": 2036,
|
||||
"prompt_ms": 1014.5,
|
||||
"prompt_n": 2029,
|
||||
"prompt_per_second": 2000,
|
||||
"protocol": 2
|
||||
}
|
||||
@@ -2368,7 +2368,7 @@
|
||||
},
|
||||
{
|
||||
"body": {
|
||||
"content": "<163664 octets b7452851885a>"
|
||||
"content": "<163781 octets 608b16075e1c>"
|
||||
},
|
||||
"method": "POST",
|
||||
"path": "/tokenize"
|
||||
@@ -2420,8 +2420,8 @@
|
||||
"predicted_ms": 6000,
|
||||
"predicted_n": 300,
|
||||
"predicted_per_second": 50,
|
||||
"prompt_ms": 1018,
|
||||
"prompt_n": 2036,
|
||||
"prompt_ms": 1014.5,
|
||||
"prompt_n": 2029,
|
||||
"prompt_per_second": 2000,
|
||||
"protocol": 2
|
||||
}
|
||||
@@ -2443,8 +2443,8 @@
|
||||
"predicted_ms": 6000,
|
||||
"predicted_n": 300,
|
||||
"predicted_per_second": 50,
|
||||
"prompt_ms": 1018,
|
||||
"prompt_n": 2036,
|
||||
"prompt_ms": 1014.5,
|
||||
"prompt_n": 2029,
|
||||
"prompt_per_second": 2000,
|
||||
"protocol": 2
|
||||
}
|
||||
@@ -2497,7 +2497,7 @@
|
||||
},
|
||||
{
|
||||
"body": {
|
||||
"content": "<163664 octets b7452851885a>"
|
||||
"content": "<163781 octets 608b16075e1c>"
|
||||
},
|
||||
"method": "POST",
|
||||
"path": "/tokenize"
|
||||
@@ -2541,7 +2541,7 @@
|
||||
"max_tokens": 300,
|
||||
"messages": [
|
||||
{
|
||||
"content": "<8129 octets ae9cc838495d>",
|
||||
"content": "<8103 octets e6352865574a>",
|
||||
"role": "user"
|
||||
}
|
||||
],
|
||||
@@ -2572,8 +2572,8 @@
|
||||
"predicted_ms": 6000,
|
||||
"predicted_n": 300,
|
||||
"predicted_per_second": 50,
|
||||
"prompt_ms": 1018,
|
||||
"prompt_n": 2036,
|
||||
"prompt_ms": 1014.5,
|
||||
"prompt_n": 2029,
|
||||
"prompt_per_second": 2000,
|
||||
"protocol": 2
|
||||
}
|
||||
@@ -2595,8 +2595,8 @@
|
||||
"predicted_ms": 6000,
|
||||
"predicted_n": 300,
|
||||
"predicted_per_second": 50,
|
||||
"prompt_ms": 1018,
|
||||
"prompt_n": 2036,
|
||||
"prompt_ms": 1014.5,
|
||||
"prompt_n": 2029,
|
||||
"prompt_per_second": 2000,
|
||||
"protocol": 2
|
||||
}
|
||||
@@ -2649,7 +2649,7 @@
|
||||
},
|
||||
{
|
||||
"body": {
|
||||
"content": "<163664 octets b7452851885a>"
|
||||
"content": "<163781 octets 608b16075e1c>"
|
||||
},
|
||||
"method": "POST",
|
||||
"path": "/tokenize"
|
||||
@@ -2693,7 +2693,7 @@
|
||||
"max_tokens": 300,
|
||||
"messages": [
|
||||
{
|
||||
"content": "<8129 octets ae9cc838495d>",
|
||||
"content": "<8103 octets e6352865574a>",
|
||||
"role": "user"
|
||||
}
|
||||
],
|
||||
@@ -2747,8 +2747,8 @@
|
||||
"predicted_ms": 6000,
|
||||
"predicted_n": 300,
|
||||
"predicted_per_second": 50,
|
||||
"prompt_ms": 1018,
|
||||
"prompt_n": 2036,
|
||||
"prompt_ms": 1014.5,
|
||||
"prompt_n": 2029,
|
||||
"prompt_per_second": 2000,
|
||||
"protocol": 2
|
||||
}
|
||||
@@ -2770,8 +2770,8 @@
|
||||
"predicted_ms": 6000,
|
||||
"predicted_n": 300,
|
||||
"predicted_per_second": 50,
|
||||
"prompt_ms": 1018,
|
||||
"prompt_n": 2036,
|
||||
"prompt_ms": 1014.5,
|
||||
"prompt_n": 2029,
|
||||
"prompt_per_second": 2000,
|
||||
"protocol": 2
|
||||
}
|
||||
@@ -2824,7 +2824,7 @@
|
||||
},
|
||||
{
|
||||
"body": {
|
||||
"content": "<163664 octets b7452851885a>"
|
||||
"content": "<163781 octets 608b16075e1c>"
|
||||
},
|
||||
"method": "POST",
|
||||
"path": "/tokenize"
|
||||
@@ -2868,7 +2868,7 @@
|
||||
"max_tokens": 300,
|
||||
"messages": [
|
||||
{
|
||||
"content": "<8129 octets ae9cc838495d>",
|
||||
"content": "<8103 octets e6352865574a>",
|
||||
"role": "user"
|
||||
}
|
||||
],
|
||||
@@ -2992,8 +2992,8 @@
|
||||
"predicted_ms": 6000,
|
||||
"predicted_n": 300,
|
||||
"predicted_per_second": 50,
|
||||
"prompt_ms": 1018,
|
||||
"prompt_n": 2036,
|
||||
"prompt_ms": 1014.5,
|
||||
"prompt_n": 2029,
|
||||
"prompt_per_second": 2000,
|
||||
"protocol": 2
|
||||
}
|
||||
@@ -3015,8 +3015,8 @@
|
||||
"predicted_ms": 6000,
|
||||
"predicted_n": 300,
|
||||
"predicted_per_second": 50,
|
||||
"prompt_ms": 1018,
|
||||
"prompt_n": 2036,
|
||||
"prompt_ms": 1014.5,
|
||||
"prompt_n": 2029,
|
||||
"prompt_per_second": 2000,
|
||||
"protocol": 2
|
||||
}
|
||||
@@ -3069,7 +3069,7 @@
|
||||
},
|
||||
{
|
||||
"body": {
|
||||
"content": "<163664 octets b7452851885a>"
|
||||
"content": "<163781 octets 608b16075e1c>"
|
||||
},
|
||||
"method": "POST",
|
||||
"path": "/tokenize"
|
||||
@@ -3121,8 +3121,8 @@
|
||||
"predicted_ms": 6000,
|
||||
"predicted_n": 300,
|
||||
"predicted_per_second": 50,
|
||||
"prompt_ms": 1018,
|
||||
"prompt_n": 2036,
|
||||
"prompt_ms": 1014.5,
|
||||
"prompt_n": 2029,
|
||||
"prompt_per_second": 2000,
|
||||
"protocol": 2
|
||||
}
|
||||
@@ -3144,8 +3144,8 @@
|
||||
"predicted_ms": 6000,
|
||||
"predicted_n": 300,
|
||||
"predicted_per_second": 50,
|
||||
"prompt_ms": 1018,
|
||||
"prompt_n": 2036,
|
||||
"prompt_ms": 1014.5,
|
||||
"prompt_n": 2029,
|
||||
"prompt_per_second": 2000,
|
||||
"protocol": 2
|
||||
}
|
||||
@@ -3189,8 +3189,8 @@
|
||||
"predicted_ms": 6000,
|
||||
"predicted_n": 300,
|
||||
"predicted_per_second": 50,
|
||||
"prompt_ms": 1018,
|
||||
"prompt_n": 2036,
|
||||
"prompt_ms": 1014.5,
|
||||
"prompt_n": 2029,
|
||||
"prompt_per_second": 2000,
|
||||
"protocol": 2
|
||||
},
|
||||
@@ -3218,7 +3218,7 @@
|
||||
},
|
||||
{
|
||||
"body": {
|
||||
"content": "<163664 octets b7452851885a>"
|
||||
"content": "<163781 octets 608b16075e1c>"
|
||||
},
|
||||
"method": "POST",
|
||||
"path": "/tokenize"
|
||||
@@ -3287,7 +3287,7 @@
|
||||
"max_tokens": 300,
|
||||
"messages": [
|
||||
{
|
||||
"content": "<8129 octets ae9cc838495d>",
|
||||
"content": "<8103 octets e6352865574a>",
|
||||
"role": "user"
|
||||
}
|
||||
],
|
||||
@@ -3319,8 +3319,8 @@
|
||||
"predicted_ms": 6000,
|
||||
"predicted_n": 300,
|
||||
"predicted_per_second": 50,
|
||||
"prompt_ms": 1018,
|
||||
"prompt_n": 2036,
|
||||
"prompt_ms": 1014.5,
|
||||
"prompt_n": 2029,
|
||||
"prompt_per_second": 2000,
|
||||
"protocol": 2
|
||||
}
|
||||
@@ -3342,8 +3342,8 @@
|
||||
"predicted_ms": 6000,
|
||||
"predicted_n": 300,
|
||||
"predicted_per_second": 50,
|
||||
"prompt_ms": 1018,
|
||||
"prompt_n": 2036,
|
||||
"prompt_ms": 1014.5,
|
||||
"prompt_n": 2029,
|
||||
"prompt_per_second": 2000,
|
||||
"protocol": 2
|
||||
}
|
||||
@@ -3383,8 +3383,8 @@
|
||||
"predicted_ms": 6000,
|
||||
"predicted_n": 300,
|
||||
"predicted_per_second": 50,
|
||||
"prompt_ms": 1018,
|
||||
"prompt_n": 2036,
|
||||
"prompt_ms": 1014.5,
|
||||
"prompt_n": 2029,
|
||||
"prompt_per_second": 2000,
|
||||
"protocol": 2
|
||||
},
|
||||
@@ -3412,7 +3412,7 @@
|
||||
},
|
||||
{
|
||||
"body": {
|
||||
"content": "<163664 octets b7452851885a>"
|
||||
"content": "<163781 octets 608b16075e1c>"
|
||||
},
|
||||
"method": "POST",
|
||||
"path": "/tokenize"
|
||||
@@ -3457,7 +3457,7 @@
|
||||
"max_tokens": 300,
|
||||
"messages": [
|
||||
{
|
||||
"content": "<8129 octets ae9cc838495d>",
|
||||
"content": "<8103 octets e6352865574a>",
|
||||
"role": "user"
|
||||
}
|
||||
],
|
||||
@@ -3489,8 +3489,8 @@
|
||||
"predicted_ms": 6000,
|
||||
"predicted_n": 300,
|
||||
"predicted_per_second": 50,
|
||||
"prompt_ms": 1018,
|
||||
"prompt_n": 2036,
|
||||
"prompt_ms": 1014.5,
|
||||
"prompt_n": 2029,
|
||||
"prompt_per_second": 2000,
|
||||
"protocol": 2
|
||||
}
|
||||
@@ -3512,8 +3512,8 @@
|
||||
"predicted_ms": 6000,
|
||||
"predicted_n": 300,
|
||||
"predicted_per_second": 50,
|
||||
"prompt_ms": 1018,
|
||||
"prompt_n": 2036,
|
||||
"prompt_ms": 1014.5,
|
||||
"prompt_n": 2029,
|
||||
"prompt_per_second": 2000,
|
||||
"protocol": 2
|
||||
}
|
||||
@@ -3579,8 +3579,8 @@
|
||||
"predicted_ms": 6000,
|
||||
"predicted_n": 300,
|
||||
"predicted_per_second": 50,
|
||||
"prompt_ms": 1018,
|
||||
"prompt_n": 2036,
|
||||
"prompt_ms": 1014.5,
|
||||
"prompt_n": 2029,
|
||||
"prompt_per_second": 2000,
|
||||
"protocol": 2
|
||||
},
|
||||
@@ -3608,7 +3608,7 @@
|
||||
},
|
||||
{
|
||||
"body": {
|
||||
"content": "<163664 octets b7452851885a>"
|
||||
"content": "<163781 octets 608b16075e1c>"
|
||||
},
|
||||
"method": "POST",
|
||||
"path": "/tokenize"
|
||||
@@ -3652,7 +3652,7 @@
|
||||
"max_tokens": 300,
|
||||
"messages": [
|
||||
{
|
||||
"content": "<8129 octets ae9cc838495d>",
|
||||
"content": "<8103 octets e6352865574a>",
|
||||
"role": "user"
|
||||
}
|
||||
],
|
||||
@@ -3737,8 +3737,8 @@
|
||||
"predicted_ms": 6000,
|
||||
"predicted_n": 300,
|
||||
"predicted_per_second": 50,
|
||||
"prompt_ms": 1018,
|
||||
"prompt_n": 2036,
|
||||
"prompt_ms": 1014.5,
|
||||
"prompt_n": 2029,
|
||||
"prompt_per_second": 2000,
|
||||
"protocol": 2
|
||||
}
|
||||
@@ -3760,8 +3760,8 @@
|
||||
"predicted_ms": 6000,
|
||||
"predicted_n": 300,
|
||||
"predicted_per_second": 50,
|
||||
"prompt_ms": 1018,
|
||||
"prompt_n": 2036,
|
||||
"prompt_ms": 1014.5,
|
||||
"prompt_n": 2029,
|
||||
"prompt_per_second": 2000,
|
||||
"protocol": 2
|
||||
}
|
||||
@@ -3823,7 +3823,7 @@
|
||||
},
|
||||
{
|
||||
"body": {
|
||||
"content": "<163664 octets b7452851885a>"
|
||||
"content": "<163781 octets 608b16075e1c>"
|
||||
},
|
||||
"method": "POST",
|
||||
"path": "/tokenize"
|
||||
@@ -3867,7 +3867,7 @@
|
||||
"max_tokens": 300,
|
||||
"messages": [
|
||||
{
|
||||
"content": "<8129 octets ae9cc838495d>",
|
||||
"content": "<8103 octets e6352865574a>",
|
||||
"role": "user"
|
||||
}
|
||||
],
|
||||
@@ -3952,8 +3952,8 @@
|
||||
"predicted_ms": 6000,
|
||||
"predicted_n": 300,
|
||||
"predicted_per_second": 50,
|
||||
"prompt_ms": 1018,
|
||||
"prompt_n": 2036,
|
||||
"prompt_ms": 1014.5,
|
||||
"prompt_n": 2029,
|
||||
"prompt_per_second": 2000,
|
||||
"protocol": 2
|
||||
}
|
||||
@@ -3975,8 +3975,8 @@
|
||||
"predicted_ms": 6000,
|
||||
"predicted_n": 300,
|
||||
"predicted_per_second": 50,
|
||||
"prompt_ms": 1018,
|
||||
"prompt_n": 2036,
|
||||
"prompt_ms": 1014.5,
|
||||
"prompt_n": 2029,
|
||||
"prompt_per_second": 2000,
|
||||
"protocol": 2
|
||||
}
|
||||
@@ -4034,8 +4034,8 @@
|
||||
"predicted_ms": 6000,
|
||||
"predicted_n": 300,
|
||||
"predicted_per_second": 50,
|
||||
"prompt_ms": 1018,
|
||||
"prompt_n": 2036,
|
||||
"prompt_ms": 1014.5,
|
||||
"prompt_n": 2029,
|
||||
"prompt_per_second": 2000,
|
||||
"protocol": 2
|
||||
}
|
||||
@@ -4057,8 +4057,8 @@
|
||||
"predicted_ms": 6000,
|
||||
"predicted_n": 300,
|
||||
"predicted_per_second": 50,
|
||||
"prompt_ms": 1018,
|
||||
"prompt_n": 2036,
|
||||
"prompt_ms": 1014.5,
|
||||
"prompt_n": 2029,
|
||||
"prompt_per_second": 2000,
|
||||
"protocol": 2
|
||||
}
|
||||
@@ -4089,8 +4089,8 @@
|
||||
"predicted_ms": 6000,
|
||||
"predicted_n": 300,
|
||||
"predicted_per_second": 50,
|
||||
"prompt_ms": 1018,
|
||||
"prompt_n": 2036,
|
||||
"prompt_ms": 1014.5,
|
||||
"prompt_n": 2029,
|
||||
"prompt_per_second": 2000,
|
||||
"protocol": 2
|
||||
}
|
||||
@@ -4112,8 +4112,8 @@
|
||||
"predicted_ms": 6000,
|
||||
"predicted_n": 300,
|
||||
"predicted_per_second": 50,
|
||||
"prompt_ms": 1018,
|
||||
"prompt_n": 2036,
|
||||
"prompt_ms": 1014.5,
|
||||
"prompt_n": 2029,
|
||||
"prompt_per_second": 2000,
|
||||
"protocol": 2
|
||||
}
|
||||
@@ -4135,8 +4135,8 @@
|
||||
"predicted_ms": 6000,
|
||||
"predicted_n": 300,
|
||||
"predicted_per_second": 50,
|
||||
"prompt_ms": 1018,
|
||||
"prompt_n": 2036,
|
||||
"prompt_ms": 1014.5,
|
||||
"prompt_n": 2029,
|
||||
"prompt_per_second": 2000,
|
||||
"protocol": 2
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -318,6 +318,7 @@ async fn parite_de_strata() {
|
||||
"CTX",
|
||||
"STRATA_KV",
|
||||
"STRATA_HELPER",
|
||||
"STRATA_SPLIT",
|
||||
] {
|
||||
if let Some(v) = bn.go.get("config", k).filter(|v| !v.is_empty()) {
|
||||
after.insert(format!("config/{k}"), Value::String(bn.mask(&v)));
|
||||
|
||||
Reference in new issue
Block a user