Swap the resident model without restarting mavend (#250)
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
This commit is contained in:
@@ -579,6 +579,21 @@ type PhraserConfig struct {
|
||||
// persona and invented units). Chat, query and reminder phrasing always go
|
||||
// through the model regardless. See phraser.Config.LLMNudges.
|
||||
LLMNudges bool `json:"llm_nudges,omitempty"`
|
||||
|
||||
// SwapModels — the gguf files the running daemon is allowed to swap to
|
||||
// without a restart (Vikunja #250). Empty (the default) means the swap
|
||||
// capability does not exist: ipc.MethodSwapModel answers ErrUnknownMethod,
|
||||
// exactly like an unconfigured weather or telegram block.
|
||||
//
|
||||
// It is an allowlist and not a directory on purpose. The request carries a
|
||||
// path, and llama-server is started with it as `-m`; anything short of an
|
||||
// exact match against a list a human wrote in this file would make "swap the
|
||||
// model" mean "load a file of your choosing off my disk". ModelPath is
|
||||
// always swappable back to whether or not it is listed.
|
||||
//
|
||||
// Paths must be absolute — the daemon's working directory is not the
|
||||
// operator's, and a relative path here would resolve somewhere surprising.
|
||||
SwapModels []string `json:"swap_models,omitempty"`
|
||||
}
|
||||
|
||||
// EmbedderConfig — paths for the ONNX multilingual embedder. The daemon
|
||||
@@ -820,6 +835,14 @@ func (c *Config) validate() error {
|
||||
if c.Phraser.ModelPath == "" {
|
||||
return errors.New("phraser.model_path is required")
|
||||
}
|
||||
// A relative entry in the swap allowlist would resolve against the
|
||||
// daemon's working directory, so the path a human reads in this file
|
||||
// would not be the path llama-server is handed. Fail at startup.
|
||||
for _, m := range c.Phraser.SwapModels {
|
||||
if !filepath.IsAbs(m) {
|
||||
return fmt.Errorf("phraser.swap_models: %q must be an absolute path", m)
|
||||
}
|
||||
}
|
||||
}
|
||||
if c.Voice != nil && c.Voice.Enabled {
|
||||
if c.Voice.Bind == "" {
|
||||
|
||||
@@ -293,3 +293,35 @@ func TestPatternProposalNotifyDefaultsOff(t *testing.T) {
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user