ad074cea31
Loading a different gguf was a one-line edit to phraser.model_path plus a
restart. It is now an owner-triggered IPC call, off unless configured.
internal/phraser/swap.go holds the safety properties as code:
- Never two models resident. The old llama-server is killed and reaped
before the new one is launched. One 1.7B fits the Vega iGPU; a
blue/green overlap would OOM the box, so it is not offered.
- Atomic from a turn's point of view. Swap drains the in-flight turns
(they finish on the old model), then refuses arrivals with ErrSwapping
until the new server has answered /v1/models. No turn ever sees half a
swap; refused turns fall back to the classifier cascade.
- A failed load rolls back. If the new model does not start or does not
probe, the previous one is reloaded and the call returns RolledBack
with the error. If the rollback also fails the daemon says so and
degrades to the classifier rather than pretending to serve.
Holders of the completion client are re-pointed, not rebuilt: llm.Client
guards its base URL and LLMPhraser.OnSwap re-points it, so the router, the
replier, the mail extractor and the memory evaluator follow the new port
without knowing a swap happened.
Reach is deliberately narrow. phraser.swap_models is an exact-match
allowlist of absolute paths a human wrote, rejected at startup otherwise,
so "swap the model" can never mean "load any file on my disk"; the running
model is always swappable back to. MethodSwapModel is AuthStepUp, the same
rung as mutating the tool allowlist, and /models gates POST through the
same stepUpOK the tools page uses. Nothing calls Swap on a timer and no
act, intent or utterance reaches it.
Vikunja #250
328 lines
9.9 KiB
Go
328 lines
9.9 KiB
Go
package config
|
|
|
|
import (
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func writeConfig(t *testing.T, body string) string {
|
|
t.Helper()
|
|
p := filepath.Join(t.TempDir(), "mavend.json")
|
|
if err := writeFile(t, p, body); err != nil {
|
|
t.Fatalf("write config: %v", err)
|
|
}
|
|
return p
|
|
}
|
|
|
|
func TestLoadDefaults(t *testing.T) {
|
|
p := writeConfig(t, `{}`)
|
|
c, err := Load(p)
|
|
if err != nil {
|
|
t.Fatalf("Load: %v", err)
|
|
}
|
|
if time.Duration(c.TickInterval) != DefaultTickInterval {
|
|
t.Errorf("TickInterval default = %v, want %v", c.TickInterval, DefaultTickInterval)
|
|
}
|
|
if time.Duration(c.RepeatInterval) != DefaultRepeatInterval {
|
|
t.Errorf("RepeatInterval default = %v, want %v", c.RepeatInterval, DefaultRepeatInterval)
|
|
}
|
|
if c.DBPath == "" {
|
|
t.Error("DBPath default not applied")
|
|
}
|
|
if c.SocketPath == "" {
|
|
t.Error("SocketPath default not applied")
|
|
}
|
|
}
|
|
|
|
// Nudges come from templates unless the config says otherwise.
|
|
func TestPhraserLLMNudgesDefaultsOff(t *testing.T) {
|
|
p := writeConfig(t, `{"phraser":{"model_path":"/tmp/m.gguf"}}`)
|
|
c, err := Load(p)
|
|
if err != nil {
|
|
t.Fatalf("Load: %v", err)
|
|
}
|
|
if c.Phraser.LLMNudges {
|
|
t.Error("llm_nudges defaults on; templates must be the default")
|
|
}
|
|
|
|
p = writeConfig(t, `{"phraser":{"model_path":"/tmp/m.gguf","llm_nudges":true}}`)
|
|
c, err = Load(p)
|
|
if err != nil {
|
|
t.Fatalf("Load: %v", err)
|
|
}
|
|
if !c.Phraser.LLMNudges {
|
|
t.Error("llm_nudges:true did not parse")
|
|
}
|
|
}
|
|
|
|
func TestLoadDurationsParse(t *testing.T) {
|
|
p := writeConfig(t, `{"tick_interval":"90s","repeat_interval":"10m"}`)
|
|
c, err := Load(p)
|
|
if err != nil {
|
|
t.Fatalf("Load: %v", err)
|
|
}
|
|
if time.Duration(c.TickInterval) != 90*time.Second {
|
|
t.Errorf("TickInterval = %v, want 90s", c.TickInterval)
|
|
}
|
|
if time.Duration(c.RepeatInterval) != 10*time.Minute {
|
|
t.Errorf("RepeatInterval = %v, want 10m", c.RepeatInterval)
|
|
}
|
|
}
|
|
|
|
func TestLoadBadDurationRejected(t *testing.T) {
|
|
p := writeConfig(t, `{"tick_interval":"not-a-duration"}`)
|
|
if _, err := Load(p); err == nil {
|
|
t.Fatal("Load succeeded for a bad duration; want error")
|
|
}
|
|
}
|
|
|
|
func TestLoadMissingFile(t *testing.T) {
|
|
p := filepath.Join(t.TempDir(), "nonexistent.json")
|
|
if _, err := Load(p); err == nil {
|
|
t.Fatal("Load succeeded for a missing file; want error")
|
|
}
|
|
}
|
|
|
|
func TestVoiceEnabledRequiresBind(t *testing.T) {
|
|
// enabled=true without bind is refused — the voice surface can't
|
|
// default a bind (127.0.0.1 too relaxed for production, a wg addr is
|
|
// the user's). surfacing the gap explicitly beats an idle listener.
|
|
p := writeConfig(t, `{"voice":{"enabled":true}}`)
|
|
if _, err := Load(p); err == nil {
|
|
t.Fatal("Load succeeded for voice.enabled=true with no bind; want error")
|
|
}
|
|
}
|
|
|
|
func TestVoiceEnabledWithBindOK(t *testing.T) {
|
|
// voice surface fully configured — accepted (the daemon wires Stub tts +
|
|
// voicesink; no models on disk required).
|
|
p := writeConfig(t, `{"voice":{"enabled":true,"bind":"127.0.0.1:9100"}}`)
|
|
if _, err := Load(p); err != nil {
|
|
t.Fatalf("Load: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestEmbedderAllPathsSetOK(t *testing.T) {
|
|
p := writeConfig(t, `{
|
|
"voice": {
|
|
"enabled": true, "bind": "127.0.0.1:9100",
|
|
"embedder": {
|
|
"model_path": "m.onnx",
|
|
"tokenizer_path": "t.json",
|
|
"lib_path": "l.so"
|
|
}
|
|
}
|
|
}`)
|
|
if _, err := Load(p); err != nil {
|
|
t.Fatalf("Load: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestEmbedderPartialConfigRejected(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
json string
|
|
}{
|
|
{"missing model_path", `{"voice":{"enabled":true,"bind":"127.0.0.1:9100","embedder":{"tokenizer_path":"t.json","lib_path":"l.so"}}}`},
|
|
{"missing tokenizer_path", `{"voice":{"enabled":true,"bind":"127.0.0.1:9100","embedder":{"model_path":"m.onnx","lib_path":"l.so"}}}`},
|
|
{"missing lib_path", `{"voice":{"enabled":true,"bind":"127.0.0.1:9100","embedder":{"model_path":"m.onnx","tokenizer_path":"t.json"}}}`},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
p := writeConfig(t, tt.json)
|
|
if _, err := Load(p); err == nil {
|
|
t.Fatal("Load succeeded for partial embedder; want error")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestEmbedderNilOK(t *testing.T) {
|
|
// voice block without embedder → ok (HashEmbedder floor)
|
|
p := writeConfig(t, `{"voice":{"enabled":true,"bind":"127.0.0.1:9100"}}`)
|
|
if _, err := Load(p); err != nil {
|
|
t.Fatalf("Load: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestVoicePresentDisabledOK(t *testing.T) {
|
|
// voice block present but not enabled — acceptable (the listener stays
|
|
// down; the routing table's ChannelVoice selections drop).
|
|
p := writeConfig(t, `{"voice":{"enabled":false}}`)
|
|
if _, err := Load(p); err != nil {
|
|
t.Fatalf("Load: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestVoiceSttTtsConfigParsed(t *testing.T) {
|
|
// the stt/tts worker sub-blocks parse + remember socket/lang.
|
|
p := writeConfig(t, `{
|
|
"voice": {
|
|
"enabled": true, "bind": "127.0.0.1:9100",
|
|
"stt": {"socket": "/tmp/stt.sock", "lang": "ru"},
|
|
"tts": {"socket": "/tmp/tts.sock", "lang": "ru", "voice": "natasha"}
|
|
}
|
|
}`)
|
|
c, err := Load(p)
|
|
if err != nil {
|
|
t.Fatalf("Load: %v", err)
|
|
}
|
|
if c.Voice.Stt == nil || c.Voice.Stt.Socket != "/tmp/stt.sock" {
|
|
t.Fatalf("Stt config not parsed: %+v", c.Voice)
|
|
}
|
|
if c.Voice.Tts == nil || c.Voice.Tts.Socket != "/tmp/tts.sock" || c.Voice.Tts.Voice != "natasha" {
|
|
t.Fatalf("Tts config not parsed: %+v", c.Voice)
|
|
}
|
|
}
|
|
|
|
func TestWeatherConfig(t *testing.T) {
|
|
// Weather block with provider + default location → OK
|
|
p := writeConfig(t, `{"voice":{"enabled":true,"bind":"127.0.0.1:9100","weather":{"provider":"open-meteo","default_location":"Moscow"}}}`)
|
|
if _, err := Load(p); err != nil {
|
|
t.Fatalf("Load with weather config: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestWeatherConfigNilOK(t *testing.T) {
|
|
// No weather block → OK (stub)
|
|
p := writeConfig(t, `{"voice":{"enabled":true,"bind":"127.0.0.1:9100"}}`)
|
|
if _, err := Load(p); err != nil {
|
|
t.Fatalf("Load without weather config: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestLLMRouterDefaultsOn(t *testing.T) {
|
|
p := writeConfig(t, `{"voice":{"enabled":true,"bind":"127.0.0.1:9100"}}`)
|
|
c, err := Load(p)
|
|
if err != nil {
|
|
t.Fatalf("Load: %v", err)
|
|
}
|
|
if !c.Voice.UseLLMRouter() {
|
|
t.Error("voice.llm_router absent should mean on")
|
|
}
|
|
}
|
|
|
|
// Missing and explicitly false must not mean the same thing.
|
|
func TestLLMRouterExplicitFalseTurnsItOff(t *testing.T) {
|
|
p := writeConfig(t, `{"voice":{"enabled":true,"bind":"127.0.0.1:9100","llm_router":false}}`)
|
|
c, err := Load(p)
|
|
if err != nil {
|
|
t.Fatalf("Load: %v", err)
|
|
}
|
|
if c.Voice.UseLLMRouter() {
|
|
t.Error("voice.llm_router false should turn it off")
|
|
}
|
|
}
|
|
|
|
func TestLLMRouterRead(t *testing.T) {
|
|
p := writeConfig(t, `{"voice":{"enabled":true,"bind":"127.0.0.1:9100","llm_router":true}}`)
|
|
c, err := Load(p)
|
|
if err != nil {
|
|
t.Fatalf("Load: %v", err)
|
|
}
|
|
if !c.Voice.UseLLMRouter() {
|
|
t.Error("voice.llm_router true was not read")
|
|
}
|
|
}
|
|
|
|
func TestDurationRoundTrip(t *testing.T) {
|
|
d := Duration(15 * time.Minute)
|
|
b, err := d.MarshalJSON()
|
|
if err != nil {
|
|
t.Fatalf("MarshalJSON: %v", err)
|
|
}
|
|
if got, want := string(b), `"15m0s"`; got != want {
|
|
t.Errorf("MarshalJSON = %s, want %s", got, want)
|
|
}
|
|
var d2 Duration
|
|
if err := d2.UnmarshalJSON(b); err != nil {
|
|
t.Fatalf("UnmarshalJSON: %v", err)
|
|
}
|
|
if d2 != d {
|
|
t.Errorf("round-trip = %v, want %v", d2, d)
|
|
}
|
|
}
|
|
|
|
// Both new opt-in capabilities follow the same rule: absent block ⇒ nil ⇒ the
|
|
// behaviour does not exist. Presence is the enable act, so a bare `{}` block is
|
|
// valid and gets the defaults filled in.
|
|
func TestOptInBlocksAbsentStayNil(t *testing.T) {
|
|
c, err := Load(writeConfig(t, `{}`))
|
|
if err != nil {
|
|
t.Fatalf("Load: %v", err)
|
|
}
|
|
if c.PatternProposals != nil {
|
|
t.Errorf("pattern_proposals absent but got %+v", c.PatternProposals)
|
|
}
|
|
if c.PatternProposals.AnnounceProposals() {
|
|
t.Error("AnnounceProposals() true with no config block")
|
|
}
|
|
if c.MemoryEval != nil {
|
|
t.Errorf("memory_eval absent but got %+v", c.MemoryEval)
|
|
}
|
|
}
|
|
|
|
func TestOptInBlocksGetDefaultsWhenPresent(t *testing.T) {
|
|
c, err := Load(writeConfig(t, `{"pattern_proposals":{"notify":true},"memory_eval":{}}`))
|
|
if err != nil {
|
|
t.Fatalf("Load: %v", err)
|
|
}
|
|
if !c.PatternProposals.AnnounceProposals() {
|
|
t.Error("notify:true did not enable announcements")
|
|
}
|
|
if time.Duration(c.PatternProposals.Cooldown) != DefaultProposalCooldown {
|
|
t.Errorf("proposal cooldown = %v, want %v", c.PatternProposals.Cooldown, DefaultProposalCooldown)
|
|
}
|
|
if time.Duration(c.MemoryEval.Interval) != DefaultMemoryEvalInterval {
|
|
t.Errorf("memory eval interval = %v, want %v", c.MemoryEval.Interval, DefaultMemoryEvalInterval)
|
|
}
|
|
}
|
|
|
|
// Notify is off even when the block exists — the block is where you tune it,
|
|
// notify:true is the act that lets her speak.
|
|
func TestPatternProposalNotifyDefaultsOff(t *testing.T) {
|
|
c, err := Load(writeConfig(t, `{"pattern_proposals":{"cooldown":"6h"}}`))
|
|
if err != nil {
|
|
t.Fatalf("Load: %v", err)
|
|
}
|
|
if c.PatternProposals.AnnounceProposals() {
|
|
t.Error("notify defaulted to on")
|
|
}
|
|
if time.Duration(c.PatternProposals.Cooldown) != 6*time.Hour {
|
|
t.Errorf("cooldown = %v, want 6h", c.PatternProposals.Cooldown)
|
|
}
|
|
}
|
|
|
|
// TestSwapModelsAbsentMeansOff — the swap capability does not exist unless the
|
|
// operator lists the models he allows (Vikunja #250).
|
|
func TestSwapModelsAbsentMeansOff(t *testing.T) {
|
|
c, err := Load(writeConfig(t, `{"phraser": {"model_path": "/m/qwen.gguf"}}`))
|
|
if err != nil {
|
|
t.Fatalf("Load: %v", err)
|
|
}
|
|
if len(c.Phraser.SwapModels) != 0 {
|
|
t.Errorf("swap_models = %v; want empty when unconfigured", c.Phraser.SwapModels)
|
|
}
|
|
}
|
|
|
|
func TestSwapModelsParsedAndMustBeAbsolute(t *testing.T) {
|
|
c, err := Load(writeConfig(t, `{"phraser": {
|
|
"model_path": "/m/qwen.gguf",
|
|
"swap_models": ["/m/qwen.gguf", "/m/qwen-cpt.gguf"]
|
|
}}`))
|
|
if err != nil {
|
|
t.Fatalf("Load: %v", err)
|
|
}
|
|
if len(c.Phraser.SwapModels) != 2 {
|
|
t.Fatalf("swap_models = %v; want 2 entries", c.Phraser.SwapModels)
|
|
}
|
|
// A relative entry would resolve against the daemon's cwd, not the operator's.
|
|
if _, err := Load(writeConfig(t, `{"phraser": {
|
|
"model_path": "/m/qwen.gguf",
|
|
"swap_models": ["models/llm/qwen.gguf"]
|
|
}}`)); err == nil {
|
|
t.Error("Load accepted a relative swap_models entry; want a startup failure")
|
|
}
|
|
}
|