Merge branch 'fix/g07' into fix/integrated
# Conflicts: # internal/ipc/api.go # internal/ipc/client.go # internal/llm/client.go
This commit is contained in:
+22
-20
@@ -93,10 +93,12 @@ var querySources = []querySource{
|
||||
{"memory", (*reactiveHandler).queryMemory},
|
||||
{"notes", (*reactiveHandler).queryNotes},
|
||||
// LAST before the model answers from memory, and that position is the whole
|
||||
// design (Vikunja #259): local sources first. The model, his own notes and
|
||||
// facts, and — once internal/kiwix is wired into this chain — the offline
|
||||
// ZIMs all get their turn before anything touches the network. This source
|
||||
// only claims a turn where he named a URL out loud, so it never competes
|
||||
// design (Vikunja #259): local sources first. His memory, his notes and —
|
||||
// once internal/kiwix is wired into this chain — the offline ZIMs all get
|
||||
// their turn before anything touches the network. The model does NOT: it
|
||||
// answers after this, because a URL he said out loud is an instruction and
|
||||
// a 1.7B guessing at a page it cannot read is how contents get invented.
|
||||
// This source only claims a turn where he named a URL, so it never competes
|
||||
// with a local answer.
|
||||
{"web", (*reactiveHandler).queryWeb},
|
||||
{"general-knowledge", (*reactiveHandler).queryGeneral},
|
||||
@@ -218,7 +220,7 @@ func (h *reactiveHandler) queryHabits(ctx context.Context, t *queryTurn) (string
|
||||
return profile.FormatOverallRU(), true
|
||||
}
|
||||
|
||||
// feedNoteWindow — how many recent notes are scanned for feed items, and
|
||||
// feedNoteWindow — how many recent FEED notes are scanned, and
|
||||
// feedReadOut — how many headlines she actually reads back. She summarises the
|
||||
// top of the pile, she does not recite a river.
|
||||
const (
|
||||
@@ -243,25 +245,23 @@ func (h *reactiveHandler) queryFeeds(ctx context.Context, t *queryTurn) (string,
|
||||
// news bulletin.
|
||||
return "я пока не читаю ленты — они не настроены.", true
|
||||
}
|
||||
notes, err := h.api.RecentNotes(ctx, feedNoteWindow)
|
||||
// By source, not the last 200 notes of any kind: a busy day of voice notes
|
||||
// used to push the newest headline out of the window, and she answered "в
|
||||
// лентах пока ничего нового" while the poller was working fine.
|
||||
notes, err := h.api.RecentNotesFromSource(ctx, rss.SourcePrefix, feedNoteWindow)
|
||||
if err != nil {
|
||||
log.Printf("voice: feeds: recent notes: %v", err)
|
||||
return "не получилось посмотреть ленты.", true
|
||||
}
|
||||
var picked []string
|
||||
for _, n := range notes {
|
||||
if !strings.HasPrefix(n.Source, rss.SourcePrefix) {
|
||||
if !router.CategoryMatches(rss.NoteCategory(n.Text), q.Category) {
|
||||
continue
|
||||
}
|
||||
if !router.CategoryMatches(n.Text, q.Category) {
|
||||
continue
|
||||
}
|
||||
// The note carries title, summary and link; she reads the title.
|
||||
title := n.Text
|
||||
if i := strings.IndexByte(title, '\n'); i > 0 {
|
||||
title = title[:i]
|
||||
}
|
||||
picked = append(picked, strings.TrimSpace(title))
|
||||
// The note carries title, summary, category tag and link; she reads the
|
||||
// title alone. The tag is for the match above, and piper reads brackets
|
||||
// out loud.
|
||||
picked = append(picked, rss.NoteHeadline(n.Text))
|
||||
if len(picked) == feedReadOut {
|
||||
break
|
||||
}
|
||||
@@ -462,10 +462,12 @@ func (h *reactiveHandler) queryWeb(ctx context.Context, t *queryTurn) (string, b
|
||||
return "", false
|
||||
}
|
||||
if h.crawler == nil {
|
||||
// Claim rather than fall through: he asked about a specific page, and
|
||||
// letting the model answer from the URL's spelling alone is how a small
|
||||
// model invents a page's contents.
|
||||
return "я не читаю страницы — это не настроено.", true
|
||||
// Fall through. Reading pages is off unless configured, and on a daemon
|
||||
// where it was never turned on the older behaviour is right: the model
|
||||
// answers the question as if the URL had not been said. Announcing a
|
||||
// configuration status is for a capability that exists and failed, not
|
||||
// for one he never asked for.
|
||||
return "", false
|
||||
}
|
||||
ctxFetch, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
+44
-9
@@ -20,6 +20,8 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/url"
|
||||
"time"
|
||||
@@ -39,21 +41,36 @@ func newCrawler(cfg *config.Config) *crawl.Crawler {
|
||||
return nil
|
||||
}
|
||||
cc := cfg.Crawl
|
||||
// The WATCH crawler, and only it, reaches the watched hosts. webfetch reads
|
||||
// a non-empty allow list as "these and nothing else", so folding the watch
|
||||
// hosts in turned a single watch into an allowlist for everything: a config
|
||||
// with one watch and on_demand true silently refused every other page he
|
||||
// pasted, with "не получилось прочитать страницу." and no clue why.
|
||||
return crawlerWithHosts(cc, crawlHosts(cc, true))
|
||||
}
|
||||
|
||||
// crawlHosts — the allowlist for one of the two crawlers. forWatches adds the
|
||||
// watched pages' own hosts, so a watch does not have to be allowlisted by hand.
|
||||
//
|
||||
// The on-demand crawler gets his allow_hosts and nothing else. webfetch reads a
|
||||
// non-empty list as "these and nothing else", so adding the watch hosts there
|
||||
// would silently narrow on-demand reading to the watched sites.
|
||||
func crawlHosts(cc *config.CrawlConfig, forWatches bool) []string {
|
||||
hosts := append([]string(nil), cc.AllowHosts...)
|
||||
// A watched page's own host is always reachable; otherwise an allowlist and
|
||||
// a watch list would have to be kept in sync by hand.
|
||||
if !forWatches {
|
||||
return hosts
|
||||
}
|
||||
for _, w := range cc.Watches {
|
||||
if u, err := url.Parse(w.URL); err == nil && u.Hostname() != "" {
|
||||
hosts = append(hosts, u.Hostname())
|
||||
}
|
||||
}
|
||||
// An allowlist plus on-demand is a contradiction worth logging rather than
|
||||
// silently resolving: he asked for arbitrary pages AND for a fixed list.
|
||||
// The allowlist wins, because it is the narrower instruction.
|
||||
if len(hosts) > 0 && cc.OnDemand && len(cc.AllowHosts) > 0 {
|
||||
log.Printf("crawl: allow_hosts is set, so on-demand reading is limited to those hosts")
|
||||
}
|
||||
return hosts
|
||||
}
|
||||
|
||||
// crawlerWithHosts builds a crawler over one allowlist. Two callers, two lists:
|
||||
// see newCrawler and onDemandCrawler.
|
||||
func crawlerWithHosts(cc *config.CrawlConfig, hosts []string) *crawl.Crawler {
|
||||
ua := cc.UserAgent
|
||||
if ua == "" {
|
||||
ua = webfetch.DefaultUserAgent
|
||||
@@ -81,7 +98,14 @@ func onDemandCrawler(cfg *config.Config) *crawl.Crawler {
|
||||
if cfg.Crawl == nil || !cfg.Crawl.OnDemand {
|
||||
return nil
|
||||
}
|
||||
return newCrawler(cfg)
|
||||
cc := cfg.Crawl
|
||||
// His own allow_hosts, and nothing added behind his back. Empty means "any
|
||||
// host that is not denied and not private", which is what on-demand reading
|
||||
// of a URL he just said out loud has to mean.
|
||||
if len(cc.AllowHosts) > 0 {
|
||||
log.Printf("crawl: allow_hosts is set, so on-demand reading is limited to those %d host(s)", len(cc.AllowHosts))
|
||||
}
|
||||
return crawlerWithHosts(cc, crawlHosts(cc, false))
|
||||
}
|
||||
|
||||
// crawlWorker — ticker + watcher for the scheduled half.
|
||||
@@ -137,9 +161,20 @@ func (w *crawlWorker) run(ctx context.Context) {
|
||||
// net/http out of the crawler package.
|
||||
type crawlFetcher struct{ f *webfetch.Fetcher }
|
||||
|
||||
// Get maps webfetch's sentinels onto crawl's. This adapter is the one place
|
||||
// that imports both packages, so the mapping belongs here; the crawler used to
|
||||
// match on three substrings of a message it could not see the definition of,
|
||||
// and a reworded error would have quietly turned a blocked host into "there is
|
||||
// no robots.txt here".
|
||||
func (a *crawlFetcher) Get(ctx context.Context, u string) (*crawl.Response, error) {
|
||||
resp, err := a.f.Get(ctx, u)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, webfetch.ErrBlocked), errors.Is(err, webfetch.ErrPrivate), errors.Is(err, webfetch.ErrScheme):
|
||||
return nil, fmt.Errorf("%w: %v", crawl.ErrFetchRefused, err)
|
||||
case errors.Is(err, webfetch.ErrStatus):
|
||||
return nil, fmt.Errorf("%w: %v", crawl.ErrFetchStatus, err)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &crawl.Response{URL: resp.URL, ContentType: resp.ContentType, Body: resp.Body}, nil
|
||||
|
||||
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
@@ -13,6 +14,7 @@ import (
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
"github.com/kami/maven/internal/router"
|
||||
"github.com/kami/maven/internal/voice"
|
||||
"github.com/kami/maven/internal/webfetch"
|
||||
)
|
||||
|
||||
// The default config reads nothing. This is the whole "off unless configured"
|
||||
@@ -123,16 +125,13 @@ func TestQueryWebPassesWithoutAURL(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Not configured is said out loud rather than falling through, so a small model
|
||||
// never invents a page's contents from its URL.
|
||||
func TestQueryWebSaysWhenNotConfigured(t *testing.T) {
|
||||
// A daemon where page reading was never turned on — the default — answers the
|
||||
// question the way it did before the capability existed. Claiming the turn to
|
||||
// report a configuration status is for something that exists and failed.
|
||||
func TestQueryWebPassesWhenNotConfigured(t *testing.T) {
|
||||
h := buildWebHandler(nil)
|
||||
reply, ok := askWeb(h, "посмотри https://example.org/page")
|
||||
if !ok {
|
||||
t.Fatal("the web source did not claim a question with a URL")
|
||||
}
|
||||
if !strings.Contains(reply, "не настроено") {
|
||||
t.Errorf("reply = %q, want the not-configured answer", reply)
|
||||
if reply, ok := askWeb(h, "посмотри https://example.org/page"); ok {
|
||||
t.Fatalf("an unconfigured crawler claimed the turn with %q", reply)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -184,3 +183,58 @@ func (robotsDenyFetcher) Get(_ context.Context, u string) (*crawl.Response, erro
|
||||
}
|
||||
return &crawl.Response{URL: u, ContentType: "text/html", Body: []byte("<html>nope</html>")}, nil
|
||||
}
|
||||
|
||||
// TestCrawlHostsKeepsAWatchOutOfTheOnDemandAllowlist — the on-demand crawler
|
||||
// used to be built over allow_hosts PLUS every watched host. webfetch reads a
|
||||
// non-empty allow list as "these and nothing else", so one watch on a config
|
||||
// with no allow_hosts at all turned unrestricted on-demand reading into
|
||||
// "the watched site only", and every other URL he pasted came back as
|
||||
// "не получилось прочитать страницу." with nothing in the log to explain it.
|
||||
func TestCrawlHostsKeepsAWatchOutOfTheOnDemandAllowlist(t *testing.T) {
|
||||
cc := &config.CrawlConfig{
|
||||
OnDemand: true,
|
||||
Watches: []config.CrawlWatchConfig{{Name: "p", URL: "https://watched.example/p"}},
|
||||
}
|
||||
if got := crawlHosts(cc, false); len(got) != 0 {
|
||||
t.Errorf("on-demand allowlist = %v; a watch is not an allowlist entry, and an empty list is what means \"anything public\"", got)
|
||||
}
|
||||
if got := crawlHosts(cc, true); len(got) != 1 || got[0] != "watched.example" {
|
||||
t.Errorf("watch allowlist = %v; want the watched host so a watch needs no hand-written entry", got)
|
||||
}
|
||||
|
||||
// With allow_hosts set, his list is what on-demand gets, unchanged.
|
||||
cc.AllowHosts = []string{"wiki.example"}
|
||||
on := crawlHosts(cc, false)
|
||||
if len(on) != 1 || on[0] != "wiki.example" {
|
||||
t.Errorf("on-demand allowlist = %v; want exactly his allow_hosts", on)
|
||||
}
|
||||
if got := crawlHosts(cc, true); len(got) != 2 {
|
||||
t.Errorf("watch allowlist = %v; want his hosts plus the watched one", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCrawlFetcherReportsARefusalAsARefusal — internal/crawl cannot import
|
||||
// webfetch, so it used to recognise a guard refusal by matching substrings of
|
||||
// webfetch's message text. This adapter owns both packages and is where the
|
||||
// translation belongs.
|
||||
func TestCrawlFetcherReportsARefusalAsARefusal(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "boom", http.StatusBadGateway)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
blocked := &crawlFetcher{f: webfetch.New(webfetch.Config{AllowHosts: []string{"wiki.example"}})}
|
||||
if _, err := blocked.Get(context.Background(), "https://other.example/a"); !errors.Is(err, crawl.ErrFetchRefused) {
|
||||
t.Errorf("a host outside allow_hosts = %v; want crawl.ErrFetchRefused", err)
|
||||
}
|
||||
if _, err := blocked.Get(context.Background(), "file:///etc/passwd"); !errors.Is(err, crawl.ErrFetchRefused) {
|
||||
t.Errorf("a non-http scheme = %v; want crawl.ErrFetchRefused", err)
|
||||
}
|
||||
|
||||
// A 5xx is a different thing: the server answered, badly. robots.txt over
|
||||
// this must refuse the crawl rather than read it as "no rules".
|
||||
open := &crawlFetcher{f: webfetch.New(webfetch.Config{AllowHosts: []string{"127.0.0.1"}, AllowPrivate: true})}
|
||||
if _, err := open.Get(context.Background(), srv.URL+"/robots.txt"); !errors.Is(err, crawl.ErrFetchStatus) {
|
||||
t.Errorf("a 502 = %v; want crawl.ErrFetchStatus", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,12 +88,12 @@ func TestQueryFeedsByCategory(t *testing.T) {
|
||||
// answered by the model inventing a bulletin.
|
||||
func TestQueryFeedsOffAndEmptyDiffer(t *testing.T) {
|
||||
off := buildFeedHandler(t, false)
|
||||
reply, ok := askFeeds(t, off, "что нового?")
|
||||
reply, ok := askFeeds(t, off, "что нового в лентах?")
|
||||
if !ok || !strings.Contains(reply, "не настроены") {
|
||||
t.Fatalf("feeds off: reply = %q, ok = %v", reply, ok)
|
||||
}
|
||||
on := buildFeedHandler(t, true)
|
||||
reply, ok = askFeeds(t, on, "что нового?")
|
||||
reply, ok = askFeeds(t, on, "что нового в лентах?")
|
||||
if !ok || !strings.Contains(reply, "ничего нового") {
|
||||
t.Fatalf("feeds on but empty: reply = %q, ok = %v", reply, ok)
|
||||
}
|
||||
@@ -104,6 +104,25 @@ func TestQueryFeedsPassesOnANonFeedQuestion(t *testing.T) {
|
||||
if reply, ok := askFeeds(t, h, "напомни полить цветы"); ok {
|
||||
t.Fatalf("claimed an unrelated question with %q", reply)
|
||||
}
|
||||
// The bare greeting is not a request for headlines. It used to be answered
|
||||
// with a configuration status.
|
||||
if reply, ok := askFeeds(t, h, "что нового?"); ok {
|
||||
t.Fatalf("claimed a greeting with %q", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// A busy day of his own notes must not push the newest headline out of the
|
||||
// window the feed answer scans.
|
||||
func TestQueryFeedsIsNotCrowdedOutByHisOwnNotes(t *testing.T) {
|
||||
notes := []ipc.Note{{Text: "Релиз ядра [технологии]", Source: "rss:habr"}}
|
||||
for i := 0; i < feedNoteWindow+10; i++ {
|
||||
notes = append(notes, ipc.Note{Text: "мысль вслух", Source: "tap:voice"})
|
||||
}
|
||||
h := buildFeedHandler(t, true, notes...)
|
||||
reply, ok := askFeeds(t, h, "что нового в лентах?")
|
||||
if !ok || !strings.Contains(reply, "ядра") {
|
||||
t.Fatalf("reply = %q, ok = %v; the headline fell out of the window", reply, ok)
|
||||
}
|
||||
}
|
||||
|
||||
// The mark is what stops a restart from re-noting yesterday's headlines, so the
|
||||
|
||||
+6
-1
@@ -377,7 +377,11 @@ func run(args []string) error {
|
||||
if locked {
|
||||
srv.Check = func(ctx context.Context, m ipc.Method, _ json.RawMessage) error {
|
||||
switch m {
|
||||
case ipc.MethodAssertStepUp, ipc.MethodUnlock:
|
||||
case ipc.MethodAssertStepUp, ipc.MethodUnlock, ipc.MethodPing:
|
||||
// Ping is allowed for the same reason the two unlock methods
|
||||
// are: it never reaches CoreAPI. It answers "she is up and
|
||||
// locked", which is what mavupdate needs to tell a daemon
|
||||
// waiting for a passkey apart from one that failed to start.
|
||||
return nil // allowed in locked mode
|
||||
default:
|
||||
return errLocked
|
||||
@@ -388,6 +392,7 @@ func run(args []string) error {
|
||||
}
|
||||
|
||||
srv.StepUp = func(ctx context.Context) error { return passkeySess.Assert(ctx, auth.Scope{}) }
|
||||
srv.LockedFn = dl.isLocked
|
||||
|
||||
// Mail ingestion (Vikunja #246): the hook stays nil unless an email block is
|
||||
// configured and there is a llama-server to extract with, in which case
|
||||
|
||||
@@ -58,6 +58,7 @@ func wireModelSwap(srv *ipc.Server, phr phraser.Phraser, cfg *config.Config) {
|
||||
ModelPath: res.ModelPath,
|
||||
BaseURL: res.BaseURL,
|
||||
RolledBack: res.RolledBack,
|
||||
NoBackend: res.NoBackend,
|
||||
TookMs: res.Took.Milliseconds(),
|
||||
}
|
||||
if err != nil {
|
||||
@@ -106,9 +107,15 @@ func wireModelSwap(srv *ipc.Server, phr phraser.Phraser, cfg *config.Config) {
|
||||
// the port of a server that no longer exists, and the daemon would degrade to
|
||||
// the classifier permanently after the first swap. The client is re-pointed, not
|
||||
// rebuilt, so nothing that holds it has to know a swap happened.
|
||||
// SetSwapGate is the other half, and on the deploy shape it is the load-bearing
|
||||
// one:
|
||||
// llama-server is relaunched on the same fixed port, so SetBaseURL is usually a
|
||||
// no-op, while the gate is what makes the swap's drain count these callers at
|
||||
// all. Without it a swap can kill the server mid-routing-decision.
|
||||
func llmClientFor(lp *phraser.LLMPhraser, timeout time.Duration) *llm.Client {
|
||||
c := llm.New(lp.BaseURL(), timeout)
|
||||
c.SetGate(residentGate, false)
|
||||
c.SetSwapGate(lp)
|
||||
lp.OnSwap(func(base string) { c.SetBaseURL(base) })
|
||||
return c
|
||||
}
|
||||
@@ -136,6 +143,7 @@ var residentGate = llm.NewGate(backgroundQuiet)
|
||||
func llmBackgroundClientFor(lp *phraser.LLMPhraser, timeout time.Duration) *llm.Client {
|
||||
c := llm.New(lp.BaseURL(), timeout)
|
||||
c.SetGate(residentGate, true)
|
||||
c.SetSwapGate(lp)
|
||||
lp.OnSwap(func(base string) { c.SetBaseURL(base) })
|
||||
return c
|
||||
}
|
||||
|
||||
+12
-3
@@ -12,9 +12,12 @@
|
||||
// the gate that guards the tool allowlist. That is deliberate and it is the
|
||||
// reason there is no MethodApplyUpdate anywhere in internal/ipc.
|
||||
//
|
||||
// Consequently: mavend does not import internal/update, nothing runs on a timer,
|
||||
// nothing checks a release server, and no act, intent, tool or LLM output can
|
||||
// reach any of this. She cannot update herself. She can be updated, by him.
|
||||
// Consequently: mavend never constructs an update.Updater and nothing in the
|
||||
// daemon can call Apply, nothing runs on a timer, nothing checks a release
|
||||
// server, and no act, intent, tool or LLM output can reach any of this. The
|
||||
// package is linked into mavend through internal/config, which validates the
|
||||
// update block at startup; the guarantee is the absent caller, not an absent
|
||||
// import. She cannot update herself. She can be updated, by him.
|
||||
//
|
||||
// mavupdate -config deploy/mavend.json list # snapshots available to roll back to
|
||||
// mavupdate -config deploy/mavend.json verify # make build + make test, deploys nothing
|
||||
@@ -158,6 +161,12 @@ func cmdRollback(ctx context.Context, u *update.Updater, id string) {
|
||||
res, err := u.Rollback(ctx, id)
|
||||
report(res.Steps)
|
||||
summarize(res)
|
||||
// The standalone rollback is what he reaches for when something is already
|
||||
// wrong, so a failed one needs the loud paragraph more than apply does, not
|
||||
// less.
|
||||
if errors.Is(err, update.ErrRollbackFailed) {
|
||||
die("\n%v\n\nSHE IS PROBABLY DOWN. The previous artifacts are in the snapshot dir; copy them\nover the install dir and restart by hand.", err)
|
||||
}
|
||||
if err != nil && !errors.Is(err, update.ErrRolledBack) {
|
||||
die("\n%v", err)
|
||||
}
|
||||
|
||||
+13
-1
@@ -364,6 +364,11 @@ func main() {
|
||||
flag.Parse()
|
||||
|
||||
var core ipc.CoreAPI
|
||||
// swapConn — a second connection, for /models and nothing else. A model swap
|
||||
// is a multi-minute IPC call and ipc.Client serialises everything on one
|
||||
// mutex, so sharing the connection would freeze every other page for the
|
||||
// length of the load. See handleModels.
|
||||
var swapConn modelController
|
||||
if *coreSock != "" {
|
||||
c, err := ipc.DialWait(*coreSock, 60*time.Second)
|
||||
if err != nil {
|
||||
@@ -371,6 +376,12 @@ func main() {
|
||||
}
|
||||
defer c.Close()
|
||||
core = c
|
||||
if sc, err := ipc.Dial(*coreSock); err != nil {
|
||||
log.Printf("models: second core connection failed (%v) — /models will share the main one and a swap will block the other pages", err)
|
||||
} else {
|
||||
defer sc.Close()
|
||||
swapConn = sc
|
||||
}
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
@@ -516,13 +527,14 @@ func main() {
|
||||
// /tools, and for a comparable reason: which model is loaded decides how every
|
||||
// utterance is routed and how every reply is worded. GET is read-only.
|
||||
mux.HandleFunc("/models", func(w http.ResponseWriter, r *http.Request) {
|
||||
handleModels(w, r, core, stepUpSession, *requireStepUp)
|
||||
handleModels(w, r, core, swapConn, stepUpSession, *requireStepUp)
|
||||
})
|
||||
|
||||
// State-changing routes on this server, and their gate (Vikunja #317):
|
||||
//
|
||||
// POST /tools step-up — defines argv that internal/tool executes
|
||||
// POST /routines step-up — accepting schedules recurring firing
|
||||
// POST /models step-up — replaces the model that routes and phrases
|
||||
// POST /api/revert step-up — voids the latest fact for a key
|
||||
// POST /api/chat step-up — reaches the router, LLM and the act path
|
||||
// POST /api/ptt step-up — audio into runTurn, so the same router,
|
||||
|
||||
+23
-7
@@ -36,6 +36,7 @@ var modelsTmpl = template.Must(template.New("models").Funcs(shellFuncs()).Parse(
|
||||
const modelsHTML = `{{template "shellTop" "models"}}
|
||||
<h1>Resident model</h1>
|
||||
<p class=hint>swapping requires step-up — <a href=/auth/passkey>assert a passkey</a> first. The old model is unloaded before the new one is loaded (one model fits the iGPU at a time), so turns during the load are refused and fall back to the classifier.</p>
|
||||
<p class=hint>a swap is not remembered. Nothing writes it down, so the next restart of the daemon — including the one <code>mavupdate</code> does — comes back on <code>phraser.model_path</code> from the config. Make it stick by editing that.</p>
|
||||
{{if .Msg}}<div class="msg msg-ok">{{.Msg}}</div>{{end}}
|
||||
{{if .Err}}<div class="msg msg-err">{{.Err}}</div>{{end}}
|
||||
{{if .Off}}
|
||||
@@ -80,12 +81,22 @@ type modelsPage struct {
|
||||
// A failed swap is reported as a failure with the model that is still serving
|
||||
// named, because that is the state the operator needs: the daemon rolled back
|
||||
// and is answering turns, it just is not answering them with what he asked for.
|
||||
func handleModels(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, session *webauthn.PasskeySession, requireStepUp bool) {
|
||||
// swapConn, when non-nil, is a SECOND connection to the same core, used for
|
||||
// nothing but this page. ipc.Client holds its mutex for a whole roundtrip and
|
||||
// neither side sets a read deadline, so a swap on the shared connection blocks
|
||||
// /dash, /history, /notifications and everything else for as long as the load
|
||||
// takes: a 90s drain plus a 60s launch plus a 30s probe, doubled if it rolls
|
||||
// back. No browser timeout frees them, because the server side keeps reading
|
||||
// the reply. On its own connection the swap only blocks the swap.
|
||||
func handleModels(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, swapConn modelController, session *webauthn.PasskeySession, requireStepUp bool) {
|
||||
if core == nil {
|
||||
http.Error(w, "models disabled (no -core)", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
mc, ok := core.(modelController)
|
||||
mc, ok := swapConn, swapConn != nil
|
||||
if !ok {
|
||||
mc, ok = core.(modelController)
|
||||
}
|
||||
if !ok {
|
||||
http.Error(w, "models unavailable: core connection does not support model swap", http.StatusServiceUnavailable)
|
||||
return
|
||||
@@ -103,11 +114,13 @@ func handleModels(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, sess
|
||||
http.Error(w, "model_path required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
req := ipc.SwapModelReq{ModelPath: path}
|
||||
if v, err := strconv.Atoi(r.FormValue("n_ctx")); err == nil {
|
||||
req.NCtx = v
|
||||
}
|
||||
res, err := mc.SwapModel(ctx, req)
|
||||
// Only the path comes off the form. n_ctx and n_gpu_layers are load
|
||||
// settings the daemon keeps from what is live, and the resident model is
|
||||
// a Thinking variant whose 4096-token window is sized for reasoning
|
||||
// tokens (CLAUDE.md). A field nothing renders, that a hand-crafted POST
|
||||
// could use to shrink the window under the router, is not worth having.
|
||||
// Changing them is a config edit and a restart.
|
||||
res, err := mc.SwapModel(ctx, ipc.SwapModelReq{ModelPath: path})
|
||||
switch {
|
||||
case err == nil:
|
||||
page.Msg = "loaded " + res.Model + " (" + strconv.FormatInt(res.TookMs, 10) + "ms)"
|
||||
@@ -118,6 +131,9 @@ func handleModels(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, sess
|
||||
case errors.Is(err, ipc.ErrUnknownMethod):
|
||||
http.Error(w, "swap not configured on this core", http.StatusServiceUnavailable)
|
||||
return
|
||||
case res.NoBackend:
|
||||
page.Err = "swap failed AND the rollback failed — no model is loaded. She is answering from templates and routing on the classifier. Try loading a model again; a restart is not needed."
|
||||
log.Printf("models: swap to %s failed and the rollback failed, no model loaded: %v", path, err)
|
||||
case res.RolledBack:
|
||||
page.Err = "swap failed, rolled back to " + res.Model + " — she is still answering, with the old model"
|
||||
log.Printf("models: swap to %s failed, rolled back: %v", path, err)
|
||||
|
||||
@@ -36,7 +36,7 @@ func (f *fakeModelCore) SwapModel(ctx context.Context, req ipc.SwapModelReq) (ip
|
||||
func modelsGET(t *testing.T, core ipc.CoreAPI) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
w := httptest.NewRecorder()
|
||||
handleModels(w, httptest.NewRequest(http.MethodGet, "/models", nil), core, nil, false)
|
||||
handleModels(w, httptest.NewRequest(http.MethodGet, "/models", nil), core, nil, nil, false)
|
||||
return w
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ func modelsPOST(t *testing.T, core ipc.CoreAPI, session *webauthn.PasskeySession
|
||||
r := httptest.NewRequest(http.MethodPost, "/models", strings.NewReader("model_path="+path))
|
||||
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
w := httptest.NewRecorder()
|
||||
handleModels(w, r, core, session, requireStepUp)
|
||||
handleModels(w, r, core, nil, session, requireStepUp)
|
||||
return w
|
||||
}
|
||||
|
||||
@@ -148,3 +148,59 @@ func TestModels_CoreWithoutTheMethodsIs503(t *testing.T) {
|
||||
type errBrokenModel struct{}
|
||||
|
||||
func (errBrokenModel) Error() string { return "llm: server did not start" }
|
||||
|
||||
func TestModels_TotalFailureDoesNotSaySheIsStillAnswering(t *testing.T) {
|
||||
// The load failed and so did the rollback: nothing is loaded. The page used
|
||||
// to branch on RolledBack first and render "rolled back to — she is still
|
||||
// answering, with the old model" over an empty model name.
|
||||
core := &fakeModelCore{
|
||||
swapResp: ipc.SwapModelResp{NoBackend: true},
|
||||
swapErr: errBrokenModel{},
|
||||
status: ipc.ModelStatusResp{Model: "unknown"},
|
||||
}
|
||||
w := modelsPOST(t, core, nil, false, "/m/cpt.gguf")
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("POST /models after a total failure = %d; want 200 with the failure rendered", w.Code)
|
||||
}
|
||||
body := w.Body.String()
|
||||
if strings.Contains(body, "still answering") {
|
||||
t.Errorf("the page claims she is still answering while no model is loaded:\n%s", body)
|
||||
}
|
||||
if !strings.Contains(body, "no model is loaded") {
|
||||
t.Errorf("the page does not name the state the operator is in:\n%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestModels_POSTIgnoresLoadSettingsOffTheForm(t *testing.T) {
|
||||
// n_ctx was read off a form that renders no such input, so only a
|
||||
// hand-crafted POST could set it. The resident model is a Thinking variant
|
||||
// whose window is sized for reasoning tokens; shrinking it from the wire is
|
||||
// not a capability this page offers.
|
||||
core := &fakeModelCore{swapResp: ipc.SwapModelResp{Model: "qwen3-cpt"}}
|
||||
r := httptest.NewRequest(http.MethodPost, "/models", strings.NewReader("model_path=/m/cpt.gguf&n_ctx=512&n_gpu_layers=0"))
|
||||
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
w := httptest.NewRecorder()
|
||||
handleModels(w, r, core, nil, nil, false)
|
||||
if len(core.swapped) != 1 {
|
||||
t.Fatalf("SwapModel calls = %v; want one", core.swapped)
|
||||
}
|
||||
if got := core.swapped[0]; got.NCtx != 0 || got.NGpuLayers != 0 {
|
||||
t.Errorf("swap request = %+v; want the load settings left to the daemon", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestModels_SwapUsesItsOwnConnection(t *testing.T) {
|
||||
// A swap is a multi-minute IPC call and ipc.Client serialises everything on
|
||||
// one mutex, so it must not run on the connection every other page shares.
|
||||
shared := &fakeModelCore{status: ipc.ModelStatusResp{Model: "qwen3"}}
|
||||
swapConn := &fakeModelCore{swapResp: ipc.SwapModelResp{Model: "qwen3-cpt"}}
|
||||
r := httptest.NewRequest(http.MethodPost, "/models", strings.NewReader("model_path=/m/cpt.gguf"))
|
||||
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
handleModels(httptest.NewRecorder(), r, shared, swapConn, nil, false)
|
||||
if len(shared.swapped) != 0 {
|
||||
t.Errorf("the swap went out on the shared connection: %v", shared.swapped)
|
||||
}
|
||||
if len(swapConn.swapped) != 1 {
|
||||
t.Errorf("the swap did not use the dedicated connection: %v", swapConn.swapped)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user