diff --git a/internal/loki/backend_models.go b/internal/loki/backend_models.go index e226007..601fd35 100644 --- a/internal/loki/backend_models.go +++ b/internal/loki/backend_models.go @@ -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 diff --git a/internal/loki/backend_models_dl_test.go b/internal/loki/backend_models_dl_test.go index 6f6a8c2..7de5107 100644 --- a/internal/loki/backend_models_dl_test.go +++ b/internal/loki/backend_models_dl_test.go @@ -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) + } +} diff --git a/internal/loki/facade_chat_test.go b/internal/loki/facade_chat_test.go index 184110c..31a692c 100644 --- a/internal/loki/facade_chat_test.go +++ b/internal/loki/facade_chat_test.go @@ -300,7 +300,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() diff --git a/internal/loki/facade_models.go b/internal/loki/facade_models.go index 8c0f776..ef6c54e 100644 --- a/internal/loki/facade_models.go +++ b/internal/loki/facade_models.go @@ -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, } diff --git a/internal/loki/ui/index.html b/internal/loki/ui/index.html index 872336f..ab81304 100644 --- a/internal/loki/ui/index.html +++ b/internal/loki/ui/index.html @@ -3500,6 +3500,7 @@ html[data-files="1"] #files-btn{color:var(--accent)} +
@@ -6754,6 +6755,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. @@ -6763,7 +6765,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 @@ -6880,6 +6882,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(); @@ -6890,7 +6908,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); @@ -6914,6 +6935,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'; @@ -6923,7 +6945,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)); diff --git a/internal/loki/ui/src/index.tmpl.html b/internal/loki/ui/src/index.tmpl.html index e405561..91dc1a7 100644 --- a/internal/loki/ui/src/index.tmpl.html +++ b/internal/loki/ui/src/index.tmpl.html @@ -1215,6 +1215,7 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
+
diff --git a/internal/loki/ui/src/js/07-models.js b/internal/loki/ui/src/js/07-models.js index 538a025..500d589 100644 --- a/internal/loki/ui/src/js/07-models.js +++ b/internal/loki/ui/src/js/07-models.js @@ -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)); diff --git a/internal/loki/web_server.go b/internal/loki/web_server.go index fd2a623..94aac50 100644 --- a/internal/loki/web_server.go +++ b/internal/loki/web_server.go @@ -259,6 +259,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 diff --git a/rust/loki-rs/src/downloads.rs b/rust/loki-rs/src/downloads.rs index 44fe294..4b728a8 100644 --- a/rust/loki-rs/src/downloads.rs +++ b/rust/loki-rs/src/downloads.rs @@ -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, cancel: watch::Sender, + /// `dlGate` : vrai pendant une pause. + pause: watch::Sender, } 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); + +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, 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, 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, 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, diff --git a/rust/loki-rs/tests/downloads.rs b/rust/loki-rs/tests/downloads.rs index bf1f7cd..09b6a91 100644 --- a/rust/loki-rs/tests/downloads.rs +++ b/rust/loki-rs/tests/downloads.rs @@ -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::::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(); diff --git a/rust/loki-rs/tests/parity_bench/bench.json b/rust/loki-rs/tests/parity_bench/bench.json index 107d753..fd87ee9 100644 --- a/rust/loki-rs/tests/parity_bench/bench.json +++ b/rust/loki-rs/tests/parity_bench/bench.json @@ -231,8 +231,8 @@ "predicted_ms": 6000, "predicted_n": 300, "predicted_per_second": 50, - "prompt_ms": 1014.5, - "prompt_n": 2029, + "prompt_ms": 1020, + "prompt_n": 2040, "prompt_per_second": 2000, "protocol": 2 }, @@ -304,7 +304,7 @@ "max_tokens": 300, "messages": [ { - "content": "<8102 octets f67301f37ff4>", + "content": "<8144 octets 5c98ee6b6194>", "role": "user" } ], @@ -335,8 +335,8 @@ "predicted_ms": 6000, "predicted_n": 300, "predicted_per_second": 50, - "prompt_ms": 1014.5, - "prompt_n": 2029, + "prompt_ms": 1020, + "prompt_n": 2040, "prompt_per_second": 2000, "protocol": 2 } @@ -358,8 +358,8 @@ "predicted_ms": 6000, "predicted_n": 300, "predicted_per_second": 50, - "prompt_ms": 1014.5, - "prompt_n": 2029, + "prompt_ms": 1020, + "prompt_n": 2040, "prompt_per_second": 2000, "protocol": 2 } @@ -387,8 +387,8 @@ "predicted_ms": 6000, "predicted_n": 300, "predicted_per_second": 50, - "prompt_ms": 1014.5, - "prompt_n": 2029, + "prompt_ms": 1020, + "prompt_n": 2040, "prompt_per_second": 2000, "protocol": 2 } @@ -410,8 +410,8 @@ "predicted_ms": 6000, "predicted_n": 300, "predicted_per_second": 50, - "prompt_ms": 1014.5, - "prompt_n": 2029, + "prompt_ms": 1020, + "prompt_n": 2040, "prompt_per_second": 2000, "protocol": 2 } @@ -433,8 +433,8 @@ "predicted_ms": 6000, "predicted_n": 300, "predicted_per_second": 50, - "prompt_ms": 1014.5, - "prompt_n": 2029, + "prompt_ms": 1020, + "prompt_n": 2040, "prompt_per_second": 2000, "protocol": 2 } @@ -542,8 +542,8 @@ "predicted_ms": 6000, "predicted_n": 300, "predicted_per_second": 50, - "prompt_ms": 1014.5, - "prompt_n": 2029, + "prompt_ms": 1020, + "prompt_n": 2040, "prompt_per_second": 2000, "protocol": 2 }, @@ -615,7 +615,7 @@ "max_tokens": 300, "messages": [ { - "content": "<8102 octets f67301f37ff4>", + "content": "<8144 octets 5c98ee6b6194>", "role": "user" } ], @@ -855,8 +855,8 @@ "predicted_ms": 6000, "predicted_n": 300, "predicted_per_second": 50, - "prompt_ms": 1014.5, - "prompt_n": 2029, + "prompt_ms": 1020, + "prompt_n": 2040, "prompt_per_second": 2000, "protocol": 2 } @@ -947,8 +947,8 @@ "predicted_ms": 6000, "predicted_n": 300, "predicted_per_second": 50, - "prompt_ms": 1014.5, - "prompt_n": 2029, + "prompt_ms": 1020, + "prompt_n": 2040, "prompt_per_second": 2000, "protocol": 2 } @@ -993,8 +993,8 @@ "predicted_ms": 6000, "predicted_n": 300, "predicted_per_second": 50, - "prompt_ms": 1014.5, - "prompt_n": 2029, + "prompt_ms": 1020, + "prompt_n": 2040, "prompt_per_second": 2000, "protocol": 2 }, @@ -1066,7 +1066,7 @@ "max_tokens": 300, "messages": [ { - "content": "<8102 octets f67301f37ff4>", + "content": "<8144 octets 5c98ee6b6194>", "role": "user" } ], @@ -1103,8 +1103,8 @@ "predicted_ms": 6000, "predicted_n": 300, "predicted_per_second": 50, - "prompt_ms": 1014.5, - "prompt_n": 2029, + "prompt_ms": 1020, + "prompt_n": 2040, "prompt_per_second": 2000, "protocol": 2 } @@ -1132,8 +1132,8 @@ "predicted_ms": 6000, "predicted_n": 300, "predicted_per_second": 50, - "prompt_ms": 1014.5, - "prompt_n": 2029, + "prompt_ms": 1020, + "prompt_n": 2040, "prompt_per_second": 2000, "protocol": 2 } @@ -1241,8 +1241,8 @@ "predicted_ms": 6000, "predicted_n": 300, "predicted_per_second": 50, - "prompt_ms": 1014.5, - "prompt_n": 2029, + "prompt_ms": 1020, + "prompt_n": 2040, "prompt_per_second": 2000, "protocol": 2 }, @@ -1314,7 +1314,7 @@ "max_tokens": 300, "messages": [ { - "content": "<8102 octets f67301f37ff4>", + "content": "<8144 octets 5c98ee6b6194>", "role": "user" } ], @@ -1554,8 +1554,8 @@ "predicted_ms": 6000, "predicted_n": 300, "predicted_per_second": 50, - "prompt_ms": 1014.5, - "prompt_n": 2029, + "prompt_ms": 1020, + "prompt_n": 2040, "prompt_per_second": 2000, "protocol": 2 } @@ -1646,8 +1646,8 @@ "predicted_ms": 6000, "predicted_n": 300, "predicted_per_second": 50, - "prompt_ms": 1014.5, - "prompt_n": 2029, + "prompt_ms": 1020, + "prompt_n": 2040, "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": 16402, + "prompt_n": 32804, "prompt_per_second": 2000, "protocol": 2 }, @@ -1759,7 +1759,7 @@ "max_tokens": 4096, "messages": [ { - "content": "<131211 octets 96987502f054>", + "content": "<131201 octets e3226a117903>", "role": "user" } ], @@ -1790,8 +1790,8 @@ "predicted_ms": 81920, "predicted_n": 4096, "predicted_per_second": 50, - "prompt_ms": 16403, - "prompt_n": 32806, + "prompt_ms": 16402, + "prompt_n": 32804, "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": 16402, + "prompt_n": 32804, "prompt_per_second": 2000, "protocol": 2 } @@ -2020,8 +2020,8 @@ "predicted_ms": 320, "predicted_n": 16, "predicted_per_second": 50, - "prompt_ms": 469.5, - "prompt_n": 939, + "prompt_ms": 470, + "prompt_n": 940, "prompt_per_second": 2000, "protocol": 2 }, @@ -2093,7 +2093,7 @@ "max_tokens": 16, "messages": [ { - "content": "<3743 octets 302346ae5a44>", + "content": "<3745 octets 55a9fdff5d02>", "role": "user" } ], @@ -2124,8 +2124,8 @@ "predicted_ms": 320, "predicted_n": 16, "predicted_per_second": 50, - "prompt_ms": 469.5, - "prompt_n": 939, + "prompt_ms": 470, + "prompt_n": 940, "prompt_per_second": 2000, "protocol": 2 } @@ -2147,8 +2147,8 @@ "predicted_ms": 320, "predicted_n": 16, "predicted_per_second": 50, - "prompt_ms": 469.5, - "prompt_n": 939, + "prompt_ms": 470, + "prompt_n": 940, "prompt_per_second": 2000, "protocol": 2 } @@ -2187,8 +2187,8 @@ "predicted_ms": 6000, "predicted_n": 300, "predicted_per_second": 50, - "prompt_ms": 1014.5, - "prompt_n": 2029, + "prompt_ms": 1020, + "prompt_n": 2040, "prompt_per_second": 2000, "protocol": 2 }, @@ -2260,7 +2260,7 @@ "max_tokens": 300, "messages": [ { - "content": "<8102 octets f67301f37ff4>", + "content": "<8144 octets 5c98ee6b6194>", "role": "user" } ], @@ -2291,8 +2291,8 @@ "predicted_ms": 6000, "predicted_n": 300, "predicted_per_second": 50, - "prompt_ms": 1014.5, - "prompt_n": 2029, + "prompt_ms": 1020, + "prompt_n": 2040, "prompt_per_second": 2000, "protocol": 2 } @@ -2314,8 +2314,8 @@ "predicted_ms": 6000, "predicted_n": 300, "predicted_per_second": 50, - "prompt_ms": 1014.5, - "prompt_n": 2029, + "prompt_ms": 1020, + "prompt_n": 2040, "prompt_per_second": 2000, "protocol": 2 } @@ -2420,8 +2420,8 @@ "predicted_ms": 6000, "predicted_n": 300, "predicted_per_second": 50, - "prompt_ms": 1014.5, - "prompt_n": 2029, + "prompt_ms": 1020, + "prompt_n": 2040, "prompt_per_second": 2000, "protocol": 2 } @@ -2443,8 +2443,8 @@ "predicted_ms": 6000, "predicted_n": 300, "predicted_per_second": 50, - "prompt_ms": 1014.5, - "prompt_n": 2029, + "prompt_ms": 1020, + "prompt_n": 2040, "prompt_per_second": 2000, "protocol": 2 } @@ -2541,7 +2541,7 @@ "max_tokens": 300, "messages": [ { - "content": "<8102 octets f67301f37ff4>", + "content": "<8144 octets 5c98ee6b6194>", "role": "user" } ], @@ -2572,8 +2572,8 @@ "predicted_ms": 6000, "predicted_n": 300, "predicted_per_second": 50, - "prompt_ms": 1014.5, - "prompt_n": 2029, + "prompt_ms": 1020, + "prompt_n": 2040, "prompt_per_second": 2000, "protocol": 2 } @@ -2595,8 +2595,8 @@ "predicted_ms": 6000, "predicted_n": 300, "predicted_per_second": 50, - "prompt_ms": 1014.5, - "prompt_n": 2029, + "prompt_ms": 1020, + "prompt_n": 2040, "prompt_per_second": 2000, "protocol": 2 } @@ -2693,7 +2693,7 @@ "max_tokens": 300, "messages": [ { - "content": "<8102 octets f67301f37ff4>", + "content": "<8144 octets 5c98ee6b6194>", "role": "user" } ], @@ -2747,8 +2747,8 @@ "predicted_ms": 6000, "predicted_n": 300, "predicted_per_second": 50, - "prompt_ms": 1014.5, - "prompt_n": 2029, + "prompt_ms": 1020, + "prompt_n": 2040, "prompt_per_second": 2000, "protocol": 2 } @@ -2770,8 +2770,8 @@ "predicted_ms": 6000, "predicted_n": 300, "predicted_per_second": 50, - "prompt_ms": 1014.5, - "prompt_n": 2029, + "prompt_ms": 1020, + "prompt_n": 2040, "prompt_per_second": 2000, "protocol": 2 } @@ -2868,7 +2868,7 @@ "max_tokens": 300, "messages": [ { - "content": "<8102 octets f67301f37ff4>", + "content": "<8144 octets 5c98ee6b6194>", "role": "user" } ], @@ -2992,8 +2992,8 @@ "predicted_ms": 6000, "predicted_n": 300, "predicted_per_second": 50, - "prompt_ms": 1014.5, - "prompt_n": 2029, + "prompt_ms": 1020, + "prompt_n": 2040, "prompt_per_second": 2000, "protocol": 2 } @@ -3015,8 +3015,8 @@ "predicted_ms": 6000, "predicted_n": 300, "predicted_per_second": 50, - "prompt_ms": 1014.5, - "prompt_n": 2029, + "prompt_ms": 1020, + "prompt_n": 2040, "prompt_per_second": 2000, "protocol": 2 } @@ -3121,8 +3121,8 @@ "predicted_ms": 6000, "predicted_n": 300, "predicted_per_second": 50, - "prompt_ms": 1014.5, - "prompt_n": 2029, + "prompt_ms": 1020, + "prompt_n": 2040, "prompt_per_second": 2000, "protocol": 2 } @@ -3144,8 +3144,8 @@ "predicted_ms": 6000, "predicted_n": 300, "predicted_per_second": 50, - "prompt_ms": 1014.5, - "prompt_n": 2029, + "prompt_ms": 1020, + "prompt_n": 2040, "prompt_per_second": 2000, "protocol": 2 } @@ -3189,8 +3189,8 @@ "predicted_ms": 6000, "predicted_n": 300, "predicted_per_second": 50, - "prompt_ms": 1014.5, - "prompt_n": 2029, + "prompt_ms": 1020, + "prompt_n": 2040, "prompt_per_second": 2000, "protocol": 2 }, @@ -3287,7 +3287,7 @@ "max_tokens": 300, "messages": [ { - "content": "<8102 octets f67301f37ff4>", + "content": "<8144 octets 5c98ee6b6194>", "role": "user" } ], @@ -3319,8 +3319,8 @@ "predicted_ms": 6000, "predicted_n": 300, "predicted_per_second": 50, - "prompt_ms": 1014.5, - "prompt_n": 2029, + "prompt_ms": 1020, + "prompt_n": 2040, "prompt_per_second": 2000, "protocol": 2 } @@ -3342,8 +3342,8 @@ "predicted_ms": 6000, "predicted_n": 300, "predicted_per_second": 50, - "prompt_ms": 1014.5, - "prompt_n": 2029, + "prompt_ms": 1020, + "prompt_n": 2040, "prompt_per_second": 2000, "protocol": 2 } @@ -3383,8 +3383,8 @@ "predicted_ms": 6000, "predicted_n": 300, "predicted_per_second": 50, - "prompt_ms": 1014.5, - "prompt_n": 2029, + "prompt_ms": 1020, + "prompt_n": 2040, "prompt_per_second": 2000, "protocol": 2 }, @@ -3457,7 +3457,7 @@ "max_tokens": 300, "messages": [ { - "content": "<8102 octets f67301f37ff4>", + "content": "<8144 octets 5c98ee6b6194>", "role": "user" } ], @@ -3489,8 +3489,8 @@ "predicted_ms": 6000, "predicted_n": 300, "predicted_per_second": 50, - "prompt_ms": 1014.5, - "prompt_n": 2029, + "prompt_ms": 1020, + "prompt_n": 2040, "prompt_per_second": 2000, "protocol": 2 } @@ -3512,8 +3512,8 @@ "predicted_ms": 6000, "predicted_n": 300, "predicted_per_second": 50, - "prompt_ms": 1014.5, - "prompt_n": 2029, + "prompt_ms": 1020, + "prompt_n": 2040, "prompt_per_second": 2000, "protocol": 2 } @@ -3579,8 +3579,8 @@ "predicted_ms": 6000, "predicted_n": 300, "predicted_per_second": 50, - "prompt_ms": 1014.5, - "prompt_n": 2029, + "prompt_ms": 1020, + "prompt_n": 2040, "prompt_per_second": 2000, "protocol": 2 }, @@ -3652,7 +3652,7 @@ "max_tokens": 300, "messages": [ { - "content": "<8102 octets f67301f37ff4>", + "content": "<8144 octets 5c98ee6b6194>", "role": "user" } ], @@ -3737,8 +3737,8 @@ "predicted_ms": 6000, "predicted_n": 300, "predicted_per_second": 50, - "prompt_ms": 1014.5, - "prompt_n": 2029, + "prompt_ms": 1020, + "prompt_n": 2040, "prompt_per_second": 2000, "protocol": 2 } @@ -3760,8 +3760,8 @@ "predicted_ms": 6000, "predicted_n": 300, "predicted_per_second": 50, - "prompt_ms": 1014.5, - "prompt_n": 2029, + "prompt_ms": 1020, + "prompt_n": 2040, "prompt_per_second": 2000, "protocol": 2 } @@ -3867,7 +3867,7 @@ "max_tokens": 300, "messages": [ { - "content": "<8102 octets f67301f37ff4>", + "content": "<8144 octets 5c98ee6b6194>", "role": "user" } ], @@ -3952,8 +3952,8 @@ "predicted_ms": 6000, "predicted_n": 300, "predicted_per_second": 50, - "prompt_ms": 1014.5, - "prompt_n": 2029, + "prompt_ms": 1020, + "prompt_n": 2040, "prompt_per_second": 2000, "protocol": 2 } @@ -3975,8 +3975,8 @@ "predicted_ms": 6000, "predicted_n": 300, "predicted_per_second": 50, - "prompt_ms": 1014.5, - "prompt_n": 2029, + "prompt_ms": 1020, + "prompt_n": 2040, "prompt_per_second": 2000, "protocol": 2 } @@ -4034,8 +4034,8 @@ "predicted_ms": 6000, "predicted_n": 300, "predicted_per_second": 50, - "prompt_ms": 1014.5, - "prompt_n": 2029, + "prompt_ms": 1020, + "prompt_n": 2040, "prompt_per_second": 2000, "protocol": 2 } @@ -4057,8 +4057,8 @@ "predicted_ms": 6000, "predicted_n": 300, "predicted_per_second": 50, - "prompt_ms": 1014.5, - "prompt_n": 2029, + "prompt_ms": 1020, + "prompt_n": 2040, "prompt_per_second": 2000, "protocol": 2 } @@ -4089,8 +4089,8 @@ "predicted_ms": 6000, "predicted_n": 300, "predicted_per_second": 50, - "prompt_ms": 1014.5, - "prompt_n": 2029, + "prompt_ms": 1020, + "prompt_n": 2040, "prompt_per_second": 2000, "protocol": 2 } @@ -4112,8 +4112,8 @@ "predicted_ms": 6000, "predicted_n": 300, "predicted_per_second": 50, - "prompt_ms": 1014.5, - "prompt_n": 2029, + "prompt_ms": 1020, + "prompt_n": 2040, "prompt_per_second": 2000, "protocol": 2 } @@ -4135,8 +4135,8 @@ "predicted_ms": 6000, "predicted_n": 300, "predicted_per_second": 50, - "prompt_ms": 1014.5, - "prompt_n": 2029, + "prompt_ms": 1020, + "prompt_n": 2040, "prompt_per_second": 2000, "protocol": 2 }