Files
2026-09-11 06:14:38 +02:00

230 lines
5.9 KiB
Go

package server
import (
"bufio"
"bytes"
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"sort"
"sync"
"time"
)
type adminOperation struct {
ID string `json:"id"`
Type string `json:"type"`
Worker string `json:"worker"`
Model string `json:"model"`
Status string `json:"status"`
Message string `json:"message,omitempty"`
Completed int64 `json:"completed,omitempty"`
Total int64 `json:"total,omitempty"`
Progress float64 `json:"progress"`
StartedAt time.Time `json:"started_at"`
UpdatedAt time.Time `json:"updated_at"`
Error string `json:"error,omitempty"`
}
type operationManager struct {
mu sync.RWMutex
ops map[string]adminOperation
cancel map[string]context.CancelFunc
maxKeep int
client *http.Client
}
func newOperationManager() *operationManager {
return &operationManager{ops: map[string]adminOperation{}, cancel: map[string]context.CancelFunc{}, maxKeep: 100, client: &http.Client{Timeout: 0}}
}
func (m *operationManager) list() []adminOperation {
m.mu.RLock()
defer m.mu.RUnlock()
out := make([]adminOperation, 0, len(m.ops))
for _, op := range m.ops {
out = append(out, op)
}
sort.Slice(out, func(i, j int) bool { return out[i].StartedAt.After(out[j].StartedAt) })
return out
}
func (m *operationManager) update(id string, fn func(*adminOperation)) {
m.mu.Lock()
defer m.mu.Unlock()
op, ok := m.ops[id]
if !ok {
return
}
fn(&op)
op.UpdatedAt = time.Now().UTC()
m.ops[id] = op
}
func (m *operationManager) add(op adminOperation) {
m.mu.Lock()
defer m.mu.Unlock()
m.ops[op.ID] = op
if len(m.ops) <= m.maxKeep {
return
}
var oldest string
var oldestTime time.Time
for id, x := range m.ops {
if x.Status == "running" || x.Status == "queued" {
continue
}
if oldest == "" || x.StartedAt.Before(oldestTime) {
oldest, oldestTime = id, x.StartedAt
}
}
if oldest != "" {
delete(m.ops, oldest)
}
}
func (m *operationManager) startPull(base *url.URL, worker, model string) adminOperation {
now := time.Now().UTC()
op := adminOperation{ID: operationID(), Type: "pull", Worker: worker, Model: model, Status: "queued", StartedAt: now, UpdatedAt: now}
m.add(op)
ctx, cancel := context.WithCancel(context.Background())
m.mu.Lock()
m.cancel[op.ID] = cancel
m.mu.Unlock()
go m.runPull(ctx, op.ID, base, model)
return op
}
func (m *operationManager) runPull(ctx context.Context, id string, base *url.URL, model string) {
defer func() {
m.mu.Lock()
delete(m.cancel, id)
m.mu.Unlock()
}()
m.update(id, func(op *adminOperation) { op.Status = "running"; op.Message = "starting pull" })
body, _ := json.Marshal(map[string]any{"model": model, "stream": true})
req, err := http.NewRequestWithContext(ctx, http.MethodPost, base.String()+"/api/pull", bytes.NewReader(body))
if err != nil {
m.fail(id, err)
return
}
req.Header.Set("Content-Type", "application/json")
resp, err := m.client.Do(req)
if err != nil {
if ctx.Err() != nil {
m.update(id, func(op *adminOperation) { op.Status = "cancelled"; op.Message = "cancelled" })
return
}
m.fail(id, err)
return
}
defer resp.Body.Close()
if resp.StatusCode/100 != 2 {
b, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
m.fail(id, fmt.Errorf("Ollama HTTP %d: %s", resp.StatusCode, string(b)))
return
}
sc := bufio.NewScanner(resp.Body)
buf := make([]byte, 0, 64<<10)
sc.Buffer(buf, 2<<20)
for sc.Scan() {
var x struct {
Status string `json:"status"`
Digest string `json:"digest"`
Total int64 `json:"total"`
Completed int64 `json:"completed"`
Error string `json:"error"`
}
if json.Unmarshal(sc.Bytes(), &x) != nil {
continue
}
if x.Error != "" {
m.fail(id, fmt.Errorf("%s", x.Error))
return
}
m.update(id, func(op *adminOperation) {
op.Message = x.Status
if x.Total > 0 {
op.Total = x.Total
}
if x.Completed > 0 {
op.Completed = x.Completed
}
if op.Total > 0 {
op.Progress = float64(op.Completed) / float64(op.Total)
if op.Progress > 1 {
op.Progress = 1
}
}
})
}
if err := sc.Err(); err != nil {
if ctx.Err() != nil {
m.update(id, func(op *adminOperation) { op.Status = "cancelled"; op.Message = "cancelled" })
return
}
m.fail(id, err)
return
}
m.update(id, func(op *adminOperation) { op.Status = "completed"; op.Message = "success"; op.Progress = 1 })
}
func (m *operationManager) fail(id string, err error) {
m.update(id, func(op *adminOperation) { op.Status = "failed"; op.Error = err.Error(); op.Message = "failed" })
}
func (m *operationManager) cancelOperation(id string) bool {
m.mu.RLock()
cancel := m.cancel[id]
m.mu.RUnlock()
if cancel == nil {
return false
}
cancel()
return true
}
func (m *operationManager) modelAction(ctx context.Context, base *url.URL, action, model string) error {
var method, path string
var payload any
switch action {
case "delete":
method, path = http.MethodDelete, "/api/delete"
payload = map[string]string{"model": model}
case "stop":
// Ollama unloads a resident model by issuing a generate request with
// keep_alive=0. There is no native /api/stop endpoint.
method, path = http.MethodPost, "/api/generate"
payload = map[string]any{"model": model, "keep_alive": 0, "stream": false}
default:
return fmt.Errorf("unsupported model action %q", action)
}
body, _ := json.Marshal(payload)
req, err := http.NewRequestWithContext(ctx, method, base.String()+path, bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
resp, err := m.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode/100 != 2 {
b, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
return fmt.Errorf("Ollama HTTP %d: %s", resp.StatusCode, string(b))
}
return nil
}
func operationID() string {
b := make([]byte, 12)
_, _ = rand.Read(b)
return hex.EncodeToString(b)
}