Merge pull request 'Point Maven at the workstation model: a workstation block, and routing plus replies through llm.Pair' (#97) from task/485-run-the-big-model-on-the-workstation-wit into master
Reviewed-on: #97
This commit was merged in pull request #97.
This commit is contained in:
@@ -143,7 +143,15 @@ seed additions, both of which now score inside the classifier baseline. Qwen3-1.
|
|||||||
not a doubling, and the trade is worth re-arguing rather than assuming. **The ≈2.7s figure
|
not a doubling, and the trade is worth re-arguing rather than assuming. **The ≈2.7s figure
|
||||||
that stood here until 2026-08-02 was contention, not the model.** See `docs/evals/2026-07-31-routing.md` line 61, which measures the LLM router at
|
that stood here until 2026-08-02 was contention, not the model.** See `docs/evals/2026-07-31-routing.md` line 61, which measures the LLM router at
|
||||||
p50 825ms / p95 1.2s / max 3.0s and the full cascade at p50 0.80-1.04s. Do not plan latency
|
p50 825ms / p95 1.2s / max 3.0s and the full cascade at p50 0.80-1.04s. Do not plan latency
|
||||||
work off the bakeoff table. `Confidence: 1.0` used to be hardcoded in `llmrouter.go`, so the LLM
|
work off the bakeoff table.
|
||||||
|
|
||||||
|
**The numbers above are the homesrv floor, not the ceiling.** With the workstation up, routing
|
||||||
|
completes through `llm.Pair` against gemma-4-12b and scores **84.4% full / 93.5% intent-only at
|
||||||
|
p50 329ms** — better than the resident model and about 2.5× faster (`docs/evals/2026-08-02-workstation-gemma4-12b.md`,
|
||||||
|
Vikunja #485). The workstation is never assumed up, so both sets of numbers are live. Judge a
|
||||||
|
routing change against the classifier and the resident model, since those are what always answer.
|
||||||
|
|
||||||
|
`Confidence: 1.0` used to be hardcoded in `llmrouter.go`, so the LLM
|
||||||
path could never ask for clarification (6/6 refusal cases missed on the fixture) — Vikunja
|
path could never ask for clarification (6/6 refusal cases missed on the fixture) — Vikunja
|
||||||
#359. Fixed 31-07-2026 with structural signal (single-token utterance, keyless fact, act with
|
#359. Fixed 31-07-2026 with structural signal (single-token utterance, keyless fact, act with
|
||||||
no allowlisted fn) feeding the same stage-3 gate the classifier path already had — see
|
no allowlisted fn) feeding the same stage-3 gate the classifier path already had — see
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/config"
|
||||||
|
"github.com/kami/maven/internal/llm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// No `workstation` block is the shipping deploy. The seam must then be the
|
||||||
|
// resident client itself, with nothing probing anything.
|
||||||
|
func TestModelSeamUnconfiguredIsResidentOnly(t *testing.T) {
|
||||||
|
resident := llm.New("http://127.0.0.1:1", time.Second)
|
||||||
|
hot, pair := modelSeam(&config.Config{}, resident)
|
||||||
|
if pair != nil {
|
||||||
|
t.Error("built a pair with no workstation configured")
|
||||||
|
}
|
||||||
|
if hot == nil {
|
||||||
|
t.Fatal("no seam at all, so the cascade would route with the classifier")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A workstation with no resident model behind it has no floor, and a Pair with
|
||||||
|
// no floor is a configuration mistake rather than a degraded mode.
|
||||||
|
func TestModelSeamWithoutResidentIsNil(t *testing.T) {
|
||||||
|
cfg := &config.Config{Workstation: &config.WorkstationConfig{URL: "http://127.0.0.1:1"}}
|
||||||
|
cfg.Workstation.Health = strings.TrimRight(cfg.Workstation.URL, "/") + "/health"
|
||||||
|
hot, pair := modelSeam(cfg, nil)
|
||||||
|
if hot != nil || pair != nil {
|
||||||
|
t.Errorf("built a seam with no floor: hot=%v pair=%v", hot, pair)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The configured case: the seam is the pair, and the pair notices a workstation
|
||||||
|
// that answers /health.
|
||||||
|
func TestModelSeamPrefersAnAnsweringWorkstation(t *testing.T) {
|
||||||
|
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
defer up.Close()
|
||||||
|
|
||||||
|
cfg := &config.Config{Workstation: &config.WorkstationConfig{
|
||||||
|
URL: up.URL,
|
||||||
|
Probe: config.Duration(10 * time.Millisecond),
|
||||||
|
}}
|
||||||
|
cfg.Workstation.Health = strings.TrimRight(cfg.Workstation.URL, "/") + "/health"
|
||||||
|
|
||||||
|
hot, pair := modelSeam(cfg, llm.New("http://127.0.0.1:1", time.Second))
|
||||||
|
if pair == nil || hot == nil {
|
||||||
|
t.Fatal("no pair built for a configured workstation")
|
||||||
|
}
|
||||||
|
defer pair.Stop()
|
||||||
|
|
||||||
|
deadline := time.Now().Add(2 * time.Second)
|
||||||
|
for !pair.Available() && time.Now().Before(deadline) {
|
||||||
|
time.Sleep(5 * time.Millisecond)
|
||||||
|
}
|
||||||
|
if !pair.Available() {
|
||||||
|
t.Fatal("the pair never saw a workstation that answers /health")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A card held by a CPT run answers 503, and that must read as unavailable
|
||||||
|
// rather than as an error a turn has to handle.
|
||||||
|
func TestModelSeamHeldCardIsUnavailable(t *testing.T) {
|
||||||
|
busy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.Error(w, "model not loaded", http.StatusServiceUnavailable)
|
||||||
|
}))
|
||||||
|
defer busy.Close()
|
||||||
|
|
||||||
|
cfg := &config.Config{Workstation: &config.WorkstationConfig{
|
||||||
|
URL: busy.URL,
|
||||||
|
Probe: config.Duration(10 * time.Millisecond),
|
||||||
|
}}
|
||||||
|
cfg.Workstation.Health = strings.TrimRight(cfg.Workstation.URL, "/") + "/health"
|
||||||
|
|
||||||
|
_, pair := modelSeam(cfg, llm.New("http://127.0.0.1:1", time.Second))
|
||||||
|
if pair == nil {
|
||||||
|
t.Fatal("no pair built for a configured workstation")
|
||||||
|
}
|
||||||
|
defer pair.Stop()
|
||||||
|
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
if pair.Available() {
|
||||||
|
t.Error("a 503 from the supervisor read as available")
|
||||||
|
}
|
||||||
|
}
|
||||||
+52
-5
@@ -48,7 +48,11 @@ type voiceWiring struct {
|
|||||||
// mcp — the MCP client, nil unless the `mcp` block configures an enabled
|
// mcp — the MCP client, nil unless the `mcp` block configures an enabled
|
||||||
// server (Vikunja #251). Its tools land in the same allowlist as every
|
// server (Vikunja #251). Its tools land in the same allowlist as every
|
||||||
// other act, so nothing else here has to know about it.
|
// other act, so nothing else here has to know about it.
|
||||||
mcp *mcpWiring
|
// pair — the workstation model with the resident one as the floor, nil
|
||||||
|
// unless a `workstation` block names an address. Held here only so the
|
||||||
|
// prober is stopped on shutdown; callers were handed it at build time.
|
||||||
|
pair *llm.Pair
|
||||||
|
mcp *mcpWiring
|
||||||
// home — the Home Assistant client, nil unless the `smarthome` block is
|
// home — the Home Assistant client, nil unless the `smarthome` block is
|
||||||
// enabled (Vikunja #256). Its devices land in the same allowlist as every
|
// enabled (Vikunja #256). Its devices land in the same allowlist as every
|
||||||
// other act, so nothing else here has to know about it.
|
// other act, so nothing else here has to know about it.
|
||||||
@@ -76,6 +80,9 @@ func (w *voiceWiring) close() {
|
|||||||
if w.ttsClient != nil {
|
if w.ttsClient != nil {
|
||||||
_ = w.ttsClient.Close()
|
_ = w.ttsClient.Close()
|
||||||
}
|
}
|
||||||
|
if w.pair != nil {
|
||||||
|
w.pair.Stop()
|
||||||
|
}
|
||||||
w.mcp.close()
|
w.mcp.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -188,6 +195,11 @@ func wireVoice(cfg *config.Config, coreAPI ipc.CoreAPI, phr phraser.Phraser, mem
|
|||||||
// the new llama-server when the resident model is swapped (Vikunja #250).
|
// the new llama-server when the resident model is swapped (Vikunja #250).
|
||||||
llmClient = llmClientFor(lp, 60*time.Second)
|
llmClient = llmClientFor(lp, 60*time.Second)
|
||||||
}
|
}
|
||||||
|
// The workstation model sits above that one when it is configured and its
|
||||||
|
// card is free. hot is what the router and the replier complete through:
|
||||||
|
// either the pair, or the resident client alone, or nothing at all.
|
||||||
|
hot, pair := modelSeam(cfg, llmClient)
|
||||||
|
w.pair = pair
|
||||||
// ----- router (the cascade; floor examples seed the classifier) -----
|
// ----- router (the cascade; floor examples seed the classifier) -----
|
||||||
// The act matcher's allowlist is exactly the enabled tool names — the
|
// The act matcher's allowlist is exactly the enabled tool names — the
|
||||||
// router only matches acts the executor can run (one source of truth).
|
// router only matches acts the executor can run (one source of truth).
|
||||||
@@ -199,7 +211,7 @@ func wireVoice(cfg *config.Config, coreAPI ipc.CoreAPI, phr phraser.Phraser, mem
|
|||||||
// against the classifier's 50.0%, at about 1s a turn instead of 30ms (see
|
// against the classifier's 50.0%, at about 1s a turn instead of 30ms (see
|
||||||
// config.VoiceConfig.LLMRouter). The classifier always stays wired as the
|
// config.VoiceConfig.LLMRouter). The classifier always stays wired as the
|
||||||
// fallback, so a model error never breaks a turn.
|
// fallback, so a model error never breaks a turn.
|
||||||
rtr := buildRouter(emb, matcher, threshold, pickLLMRouter(cfg.Voice.UseLLMRouter(), llmClient))
|
rtr := buildRouter(emb, matcher, threshold, pickLLMRouter(cfg.Voice.UseLLMRouter(), hot))
|
||||||
|
|
||||||
// ----- sessions registry (shared with voicesink) -----
|
// ----- sessions registry (shared with voicesink) -----
|
||||||
sessions := voice.NewSessions()
|
sessions := voice.NewSessions()
|
||||||
@@ -233,8 +245,8 @@ func wireVoice(cfg *config.Config, coreAPI ipc.CoreAPI, phr phraser.Phraser, mem
|
|||||||
|
|
||||||
// ----- replier (LLM-backed when the engine is on, Stub floor otherwise) -----
|
// ----- replier (LLM-backed when the engine is on, Stub floor otherwise) -----
|
||||||
replier := voice.Replier(voice.NewStubReplier())
|
replier := voice.Replier(voice.NewStubReplier())
|
||||||
if llmClient != nil {
|
if hot != nil {
|
||||||
replier = newLLMReplier(llmClient, contextBlockFn(cfg, time.Now))
|
replier = newLLMReplier(hot, contextBlockFn(cfg, time.Now))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----- the handler (the reactive path; closes over stt / tts / router / coreAPI / memory) -----
|
// ----- the handler (the reactive path; closes over stt / tts / router / coreAPI / memory) -----
|
||||||
@@ -291,7 +303,42 @@ func wireVoice(cfg *config.Config, coreAPI ipc.CoreAPI, phr phraser.Phraser, mem
|
|||||||
// pickLLMRouter returns the LLM router when the operator asked for it and there
|
// pickLLMRouter returns the LLM router when the operator asked for it and there
|
||||||
// is a llama-server to talk to, and nil otherwise. nil is safe: the cascade then
|
// is a llama-server to talk to, and nil otherwise. nil is safe: the cascade then
|
||||||
// routes with the classifier, so an unusable setting costs accuracy, not turns.
|
// routes with the classifier, so an unusable setting costs accuracy, not turns.
|
||||||
func pickLLMRouter(enabled bool, c *llm.Client) *router.LLMRouter {
|
// modelSeam builds the completion seam the hot paths use: routing and replies.
|
||||||
|
//
|
||||||
|
// With no `workstation` block it is the resident client and nothing probes
|
||||||
|
// anything, which is today's deploy exactly. With one, it is an llm.Pair that
|
||||||
|
// prefers the workstation and falls back to the resident model silently — the
|
||||||
|
// silent half of the degradation rule (docs/offload.md), because the big model
|
||||||
|
// is only better here and the 1.7B is today's shipping quality. He is never
|
||||||
|
// told which of the two phrased his reply.
|
||||||
|
//
|
||||||
|
// A nil resident client means the phraser is not an LLM phraser. There is then
|
||||||
|
// no floor, and a Pair with no floor is a configuration mistake rather than a
|
||||||
|
// degraded mode, so the seam is nil and the cascade routes with the classifier.
|
||||||
|
func modelSeam(cfg *config.Config, resident *llm.Client) (router.Completer, *llm.Pair) {
|
||||||
|
if resident == nil {
|
||||||
|
if cfg.Workstation != nil {
|
||||||
|
log.Printf("voice: a workstation is configured but there is no resident model to floor it with — ignoring the block")
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if cfg.Workstation == nil {
|
||||||
|
return resident, nil
|
||||||
|
}
|
||||||
|
ws := cfg.Workstation
|
||||||
|
pair := llm.NewPair(
|
||||||
|
llm.New(ws.URL, time.Duration(ws.Timeout)),
|
||||||
|
resident,
|
||||||
|
ws.Health,
|
||||||
|
time.Duration(ws.Probe),
|
||||||
|
)
|
||||||
|
pair.Start(context.Background())
|
||||||
|
log.Printf("voice: workstation model at %s, probed every %s, resident model as the floor",
|
||||||
|
ws.URL, time.Duration(ws.Probe))
|
||||||
|
return pair, pair
|
||||||
|
}
|
||||||
|
|
||||||
|
func pickLLMRouter(enabled bool, c router.Completer) *router.LLMRouter {
|
||||||
if !enabled {
|
if !enabled {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,6 +39,23 @@
|
|||||||
"proxy": "socks5://192.168.240.1:10808"
|
"proxy": "socks5://192.168.240.1:10808"
|
||||||
},
|
},
|
||||||
|
|
||||||
|
"//workstation": [
|
||||||
|
"The big model on the desk PC (bugmachine, 7900 GRE 16GB), fronted by",
|
||||||
|
"mavgpud on port 8080. It runs gemma-4-12b and it is preferred over the",
|
||||||
|
"resident Qwen3-1.7B for routing and replies whenever the card is free.",
|
||||||
|
"The machine is never assumed up: it sleeps, and the card is often held by",
|
||||||
|
"a CPT run, in which case mavgpud answers 503 and Maven falls back to the",
|
||||||
|
"resident model without saying so. Deleting this block restores exactly",
|
||||||
|
"the behaviour homesrv had before it existed.",
|
||||||
|
"Addressed by LAN address, not container name: mavgpud runs on another",
|
||||||
|
"machine and there is no shared docker network to name it on."
|
||||||
|
],
|
||||||
|
"workstation": {
|
||||||
|
"url": "http://192.168.1.105:8080",
|
||||||
|
"probe": "15s",
|
||||||
|
"timeout": "90s"
|
||||||
|
},
|
||||||
|
|
||||||
"//search": [
|
"//search": [
|
||||||
"The live web, searched after his own notes and before Kiwix. Only the",
|
"The live web, searched after his own notes and before Kiwix. Only the",
|
||||||
"query string leaves the box — never a note, a fact, the persona block or",
|
"query string leaves the box — never a note, a fact, the persona block or",
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
# gemma-4-12b on the workstation, against the resident Qwen3-1.7B
|
||||||
|
|
||||||
|
Measured 2026-08-02 on the fixtures as they stand. Dated file: it is not edited
|
||||||
|
after today, and a newer number is a new file.
|
||||||
|
|
||||||
|
Vikunja #485's first assumption was that a 7-14B measurably beats Qwen3-1.7B on
|
||||||
|
the 77-case RU routing fixture and the 27-case talk fixture. It does, on both,
|
||||||
|
and it is also faster.
|
||||||
|
|
||||||
|
## The setup
|
||||||
|
|
||||||
|
`gemma-4-12B-it-qat-UD-Q4_K_XL` with the `mtp-gemma-4-12B-it-BF16` draft model,
|
||||||
|
served by `llama-server` b10220 on bugmachine (AMD 7900 GRE, 16GB), fronted by
|
||||||
|
`mavgpud` on `192.168.1.105:8080`. Thinking is off through
|
||||||
|
`--chat-template-kwargs '{"enable_thinking":false}'`, speculative decoding is
|
||||||
|
`--spec-type draft-mtp --spec-draft-n-max 2`, context 32768. The exact line is
|
||||||
|
`deploy/mavgpud.json`.
|
||||||
|
|
||||||
|
Every number below crossed the LAN from homesrv. Note the trap: homesrv's shell
|
||||||
|
exports `HTTP_PROXY`, Go honours it, and the runs need
|
||||||
|
`env -u HTTP_PROXY -u HTTPS_PROXY -u http_proxy -u https_proxy`.
|
||||||
|
|
||||||
|
## Routing, 77-case RU fixture
|
||||||
|
|
||||||
|
| | full | intent-only | p50 | p95 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| classifier alone (02-08) | 68.8% | — | 16.6µs | — |
|
||||||
|
| Qwen3-1.7B through the cascade (31-07, 02-08) | 72.7% | 77.9% | 0.80-1.04s | — |
|
||||||
|
| **gemma-4-12b through the cascade** | **84.4%** | **93.5%** | **329ms** | 429ms |
|
||||||
|
| gemma-4-12b alone, no cascade | 55.8% | 85.7% | 335ms | 436ms |
|
||||||
|
|
||||||
|
The workstation buys 11.7 points of full accuracy over the resident model. It
|
||||||
|
buys 15.6 points of intent-only, at a third of the latency. The router's p50 was
|
||||||
|
never the model's fault, which the 02-08 contention finding already said. A 12B
|
||||||
|
on a free 16GB card answers a routing turn in a third of a second.
|
||||||
|
|
||||||
|
Two things the table hides.
|
||||||
|
|
||||||
|
The alone-versus-cascade gap is slots, not intents. gemma reads the intent right
|
||||||
|
85.7% of the time on its own. It loses full accuracy on seven fact keys
|
||||||
|
(`вода` instead of `water`, `ужин` instead of `meal`) and on six reminder times
|
||||||
|
with no time slot. Stage 0 and the daemon's own extractor repair
|
||||||
|
both, which is why the cascade is 28 points higher. The lesson is that the
|
||||||
|
cascade earns its keep even under a much better model, not that it is scaffolding
|
||||||
|
to remove.
|
||||||
|
|
||||||
|
`errors: 6` in the alone row are declines on single-token and ambiguous
|
||||||
|
utterances, all of which the cascade caught. The remaining defects through the
|
||||||
|
cascade are three `query→fact` confusions, one `chat→query`, and one false
|
||||||
|
clarify.
|
||||||
|
|
||||||
|
## Talk, 27-case conversational fixture
|
||||||
|
|
||||||
|
| | pass | notes |
|
||||||
|
|---|---|---|
|
||||||
|
| Qwen3-1.7B (31-07) | 20/27 | 11-17/27 for the 0.8B before it |
|
||||||
|
| **gemma-4-12b** | **25/27 (92.6%)** | chat 8/9, knowledge 9/9, query 8/9 |
|
||||||
|
|
||||||
|
Knowledge is the interesting column: 9/9, in Russian, with real answers about
|
||||||
|
Rayleigh scattering, SSD versus HDD and thunder delay. That is the case the
|
||||||
|
1.7B cannot do at all and the reason the naming half of the degradation rule
|
||||||
|
exists.
|
||||||
|
|
||||||
|
Two failures, and one of them is the persona defect the CPT (#122) targets:
|
||||||
|
`query-notes-do-not-answer` wrote `заплатил` where Maven needs the feminine
|
||||||
|
form. The other is `chat-joke`, where the model told a joke without using any of
|
||||||
|
the words the check looks for. Run-to-run variance is about one case: a second
|
||||||
|
run scored 24/27 with `chat-followup-server` also off-topic.
|
||||||
|
|
||||||
|
## Nudge phrasing, 15-case fixture
|
||||||
|
|
||||||
|
15/15, every check, no errors. `mood`, `lang`, `length`, `feminine`,
|
||||||
|
`hisgender`, `address`, `cringe` and `ontopic` all clean.
|
||||||
|
|
||||||
|
## What this settles and what it does not
|
||||||
|
|
||||||
|
Settled: the size question. A 12B on the workstation beats the resident model on
|
||||||
|
every fixture we have, and it is faster. The offload argument holds.
|
||||||
|
|
||||||
|
Not settled: how often the card is free. That is #485's second assumption and
|
||||||
|
only the `mavgpud` log answers it, after a week of the owner's normal work. A
|
||||||
|
model that is better whenever it is up is worth little if it is never up.
|
||||||
+9
-1
@@ -125,7 +125,15 @@ Then the embedder above, **whisper.cpp** in `mavsttd`, and **piper** in `mavttsd
|
|||||||
`internal/netaddr` landed in PR #92. A seam address now carries its own scheme,
|
`internal/netaddr` landed in PR #92. A seam address now carries its own scheme,
|
||||||
and a scheme-less one is still unix. A tcp seam requires a shared token, because
|
and a scheme-less one is still unix. A tcp seam requires a shared token, because
|
||||||
the filesystem permission that authenticated the unix socket is gone.
|
the filesystem permission that authenticated the unix socket is gone.
|
||||||
2. **The resident model** (#485). Biggest quality delta. A 16GB card runs a 7-14B,
|
2. **The resident model** (#485). Half wired, 02-08-2026. A `workstation` block
|
||||||
|
builds an `llm.Pair` in `modelSeam` (`cmd/mavend/voicewire.go`), and routing
|
||||||
|
and replies complete through it. Both are the silent half of the rule. The
|
||||||
|
naming half is not wired. A world question still goes to the resident model
|
||||||
|
through `PhraseQuery`. That, and the four callers 485 did not reach, are #490.
|
||||||
|
Measured, `docs/evals/2026-08-02-workstation-gemma4-12b.md`: gemma-4-12b
|
||||||
|
through the cascade scores 84.4% full accuracy at p50 329ms. The resident
|
||||||
|
model scores 72.7% at p50 0.80-1.04s. On the talk fixture it is 25/27
|
||||||
|
against 20/27. Biggest quality delta. A 16GB card runs a 7-14B,
|
||||||
which fixes what the 1.7B gets wrong: world knowledge, and the persona the CPT
|
which fixes what the 1.7B gets wrong: world knowledge, and the persona the CPT
|
||||||
targets. The degradation path is already written and measured, since the
|
targets. The degradation path is already written and measured, since the
|
||||||
classifier scores 68.8% full accuracy at p50 16.6µs on its own.
|
classifier scores 68.8% full accuracy at p50 16.6µs on its own.
|
||||||
|
|||||||
@@ -224,6 +224,11 @@ type Config struct {
|
|||||||
// See SearchConfig.
|
// See SearchConfig.
|
||||||
Search *SearchConfig `json:"search,omitempty"`
|
Search *SearchConfig `json:"search,omitempty"`
|
||||||
|
|
||||||
|
// Workstation — the big model on the owner's desktop, preferred over the
|
||||||
|
// resident one when its GPU is free. nil / absent / url empty ⇒ homesrv
|
||||||
|
// behaves exactly as it does today. See WorkstationConfig.
|
||||||
|
Workstation *WorkstationConfig `json:"workstation,omitempty"`
|
||||||
|
|
||||||
// Praxis — the ecosystem attention-state service. When configured, maven
|
// Praxis — the ecosystem attention-state service. When configured, maven
|
||||||
// calls the Praxis HTTP tools API for attention listing and item lifecycle.
|
// calls the Praxis HTTP tools API for attention listing and item lifecycle.
|
||||||
// Maven never touches Praxis's database directly (ecosystem invariant: no
|
// Maven never touches Praxis's database directly (ecosystem invariant: no
|
||||||
@@ -1105,6 +1110,44 @@ const (
|
|||||||
DefaultKiwixSnippetRunes = 1500
|
DefaultKiwixSnippetRunes = 1500
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// WorkstationConfig — the big model on the owner's desktop (bugmachine, a
|
||||||
|
// 7900 GRE with 16GB), fronted by mavgpud.
|
||||||
|
//
|
||||||
|
// homesrv cannot grow a GPU, so the resident Qwen3-1.7B is the floor and this
|
||||||
|
// is the preferred model above it (owner's call, 2026-08-02, docs/offload.md).
|
||||||
|
// The workstation is never assumed up: its card is often held by a CPT run and
|
||||||
|
// the machine sleeps. No block, or an empty URL, and homesrv behaves exactly as
|
||||||
|
// it does today.
|
||||||
|
//
|
||||||
|
// Only the prompt crosses the LAN, and the workstation is not "the box". The
|
||||||
|
// rules in CLAUDE.md about what may leave still apply.
|
||||||
|
type WorkstationConfig struct {
|
||||||
|
// URL — where mavgpud listens, e.g. "http://192.168.1.105:8080". Empty ⇒
|
||||||
|
// the whole block is normalised to nil and nothing probes anything.
|
||||||
|
URL string `json:"url,omitempty"`
|
||||||
|
|
||||||
|
// Health — the admission endpoint. Empty ⇒ URL + "/health", which is what
|
||||||
|
// mavgpud serves. It answers 503 while the card is held, and that is the
|
||||||
|
// signal, so it must be the supervisor's endpoint and not llama-server's.
|
||||||
|
Health string `json:"health,omitempty"`
|
||||||
|
|
||||||
|
// Probe — how often admission is re-checked. 0 ⇒ DefaultWorkstationProbe.
|
||||||
|
// Nothing on the hot path waits for it: the answer is cached and read
|
||||||
|
// atomically, so this only sets how late Maven notices the card came back.
|
||||||
|
Probe Duration `json:"probe,omitempty"`
|
||||||
|
|
||||||
|
// Timeout — the per-request budget for a completion on the workstation.
|
||||||
|
// 0 ⇒ DefaultWorkstationTimeout. A big model on a LAN host is slower than
|
||||||
|
// the resident one, and a request that overruns falls back to the floor.
|
||||||
|
Timeout Duration `json:"timeout,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Workstation defaults, applied in Normalise.
|
||||||
|
const (
|
||||||
|
DefaultWorkstationProbe = 15 * time.Second
|
||||||
|
DefaultWorkstationTimeout = 90 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
// SearchConfig — the self-hosted SearXNG instance she searches with.
|
// SearchConfig — the self-hosted SearXNG instance she searches with.
|
||||||
//
|
//
|
||||||
// External search is allowed and off unless configured (CLAUDE.md). Configuring
|
// External search is allowed and off unless configured (CLAUDE.md). Configuring
|
||||||
@@ -1500,6 +1543,24 @@ func (c *Config) applyDefaults() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// No address, no preferred model. An unconfigured workstation is the
|
||||||
|
// default deploy and must be indistinguishable from today.
|
||||||
|
if c.Workstation != nil && strings.TrimSpace(c.Workstation.URL) == "" {
|
||||||
|
c.Workstation = nil
|
||||||
|
}
|
||||||
|
if c.Workstation != nil {
|
||||||
|
w := c.Workstation
|
||||||
|
if strings.TrimSpace(w.Health) == "" {
|
||||||
|
w.Health = strings.TrimRight(w.URL, "/") + "/health"
|
||||||
|
}
|
||||||
|
if w.Probe <= 0 {
|
||||||
|
w.Probe = Duration(DefaultWorkstationProbe)
|
||||||
|
}
|
||||||
|
if w.Timeout <= 0 {
|
||||||
|
w.Timeout = Duration(DefaultWorkstationTimeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if c.Voice != nil {
|
if c.Voice != nil {
|
||||||
if c.Voice.RouterThreshold <= 0 {
|
if c.Voice.RouterThreshold <= 0 {
|
||||||
c.Voice.RouterThreshold = DefaultRouterThreshold
|
c.Voice.RouterThreshold = DefaultRouterThreshold
|
||||||
|
|||||||
@@ -413,3 +413,56 @@ func TestNormaliseFillsKiwixDefaults(t *testing.T) {
|
|||||||
t.Error("rewrite: false was not honoured")
|
t.Error("rewrite: false was not honoured")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A workstation with no address is not a workstation. The unconfigured deploy
|
||||||
|
// must be indistinguishable from today, so the block is dropped rather than
|
||||||
|
// left to fail one probe at a time.
|
||||||
|
func TestNormaliseDropsAddresslessWorkstation(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
in *WorkstationConfig
|
||||||
|
}{
|
||||||
|
{"no url", &WorkstationConfig{Probe: Duration(time.Second)}},
|
||||||
|
{"blank url", &WorkstationConfig{URL: " "}},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
c := &Config{Workstation: tc.in}
|
||||||
|
c.applyDefaults()
|
||||||
|
if c.Workstation != nil {
|
||||||
|
t.Errorf("kept an unusable workstation block: %+v", c.Workstation)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The health endpoint defaults to the supervisor's, not llama-server's: mavgpud
|
||||||
|
// answers 503 while the card is held, and that refusal is the whole signal.
|
||||||
|
func TestNormaliseFillsWorkstationDefaults(t *testing.T) {
|
||||||
|
c := &Config{Workstation: &WorkstationConfig{URL: "http://192.168.1.105:8080/"}}
|
||||||
|
c.applyDefaults()
|
||||||
|
if c.Workstation == nil {
|
||||||
|
t.Fatal("dropped a usable workstation block")
|
||||||
|
}
|
||||||
|
if got, want := c.Workstation.Health, "http://192.168.1.105:8080/health"; got != want {
|
||||||
|
t.Errorf("Health = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
if time.Duration(c.Workstation.Probe) != DefaultWorkstationProbe {
|
||||||
|
t.Errorf("Probe = %s, want %s", time.Duration(c.Workstation.Probe), DefaultWorkstationProbe)
|
||||||
|
}
|
||||||
|
if time.Duration(c.Workstation.Timeout) != DefaultWorkstationTimeout {
|
||||||
|
t.Errorf("Timeout = %s, want %s", time.Duration(c.Workstation.Timeout), DefaultWorkstationTimeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An explicit health URL is left alone: the supervisor may sit behind something
|
||||||
|
// that does not put /health at the root.
|
||||||
|
func TestNormaliseKeepsExplicitWorkstationHealth(t *testing.T) {
|
||||||
|
c := &Config{Workstation: &WorkstationConfig{
|
||||||
|
URL: "http://192.168.1.105:8080",
|
||||||
|
Health: "http://192.168.1.105:9000/ready",
|
||||||
|
}}
|
||||||
|
c.applyDefaults()
|
||||||
|
if got, want := c.Workstation.Health, "http://192.168.1.105:9000/ready"; got != want {
|
||||||
|
t.Errorf("Health = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user