package config import ( "fmt" "net" "net/url" "strings" "time" ) // WorkstationConfig — the big model on the owner's desktop (workpc, 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 { // Disabled keeps both written workpc arms explicitly dark. ModelDisabled is // the narrower switch: CW2 STT may remain live while the large-model // supervisor has no provisioned client token. Disabled bool `json:"disabled,omitempty"` ModelDisabled bool `json:"model_disabled,omitempty"` // 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"` // Token — the bearer credential mavgpud requires, expanded from the // environment like every other secret here. It is what stops anything on // the LAN spending the card, so a URL that is not loopback needs one. // A missing token on a LAN URL fails config validation. Loopback development // endpoints may omit it. Token string `json:"token,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"` // Stt — CrisperWhisper 2.0 on the same machine, a separate service on its // own port. Absent ⇒ every utterance goes to mavsttd, which is today. Stt *WorkstationSttConfig `json:"stt,omitempty"` } // WorkstationSttConfig — speech-to-text on the workstation. // // It is a second service and not a second endpoint on mavgpud: whisper.cpp // cannot load CrisperWhisper 2.0 at all, because it derives its language count // from the vocabulary size and CW2's 51897 tokens shift seven special token // ids. So CW2 runs under transformers, and this block addresses it. // // Worth the trouble: CW2 turbo scores 10.4% WER in Russian against 27.5% for // the ggml-small.bin homesrv loads // (docs/evals/2026-08-09-crisperwhisper2-russian-wer.md). type WorkstationSttConfig struct { // Disabled keeps the written CW2 endpoint dark without also disabling the // independently authenticated model supervisor. Disabled bool `json:"disabled,omitempty"` // URL — the transcribe endpoint, e.g. // "http://192.168.1.105:8081/transcribe". Empty ⇒ the block is normalised // to nil and mavsttd takes every turn. URL string `json:"url,omitempty"` // Health — the admission endpoint. Empty ⇒ the URL's origin + "/health". // It answers 503 while the card is held, and that is the signal. Health string `json:"health,omitempty"` // Token — the bearer token the service checks. Audio is the most sensitive // thing that crosses this seam, so a LAN deployment should set one. Write // it as ${MAVEN_STT_TOKEN} and keep the value in deploy/telegram.env, the // way every other secret in this file is written. Token string `json:"token,omitempty"` // Probe — how often admission is re-checked. 0 ⇒ DefaultWorkstationProbe. Probe Duration `json:"probe,omitempty"` // Timeout — the per-request budget for one utterance. 0 ⇒ // DefaultWorkstationSttTimeout. A request that overruns falls back to // mavsttd, which costs a worse transcript and not the turn. Timeout Duration `json:"timeout,omitempty"` } // Workstation defaults, applied in normaliseWorkstation. const ( DefaultWorkstationProbe = 15 * time.Second DefaultWorkstationTimeout = 90 * time.Second // One utterance, not one completion. A voice turn waits on this, so the // budget is a few seconds and not a minute and a half. DefaultWorkstationSttTimeout = 10 * time.Second ) // normaliseWorkstation applies the block's defaults. No address, no preferred // model: an unconfigured workstation is the default deploy and must be // indistinguishable from today. func (c *Config) normaliseWorkstation() { if c.Workstation != nil && c.Workstation.Disabled { c.Workstation = nil } if c.Workstation == nil { return } w := c.Workstation // Preserve the historical empty-block meaning. A live STT sub-block makes // the parent non-empty; in that case omitting the model URL is an error // unless model_disabled states the operator's intent. if !w.ModelDisabled && strings.TrimSpace(w.URL) == "" && (w.Stt == nil || w.Stt.Disabled || strings.TrimSpace(w.Stt.URL) == "") { c.Workstation = nil return } if !w.ModelDisabled { 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) } } normaliseWorkstationStt(w) if w.ModelDisabled && w.Stt == nil { c.Workstation = nil } } // validateWorkstation rejects a live LAN endpoint without its bearer secret. // Loopback remains useful for local development without manufacturing a secret; // malformed or non-HTTP endpoints are rejected before any probe starts. func (c *Config) validateWorkstation() error { if c.Workstation == nil { return nil } w := c.Workstation if !w.ModelDisabled { if err := validateWorkstationEndpoint("workstation.url", w.URL, w.Token); err != nil { return err } if err := validateWorkstationEndpoint("workstation.health", w.Health, w.Token); err != nil { return err } } if w.Stt != nil { if err := validateWorkstationEndpoint("workstation.stt.url", w.Stt.URL, w.Stt.Token); err != nil { return err } if err := validateWorkstationEndpoint("workstation.stt.health", w.Stt.Health, w.Stt.Token); err != nil { return err } } return nil } func validateWorkstationEndpoint(name, raw, token string) error { u, err := url.Parse(raw) if err != nil || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") { return fmt.Errorf("%s must be an absolute http(s) URL", name) } host := strings.TrimSpace(u.Hostname()) loopback := strings.EqualFold(host, "localhost") if ip := net.ParseIP(host); ip != nil { loopback = ip.IsLoopback() } if !loopback && strings.TrimSpace(token) == "" { return fmt.Errorf("%s is not loopback, so its token is required while enabled", name) } return nil } // normaliseWorkstationStt applies the speech-to-text block's defaults. No // address, no remote: mavsttd then takes every utterance, which is today. func normaliseWorkstationStt(w *WorkstationConfig) { if w.Stt != nil && (w.Stt.Disabled || strings.TrimSpace(w.Stt.URL) == "") { w.Stt = nil } if w.Stt == nil { return } s := w.Stt if strings.TrimSpace(s.Health) == "" { s.Health = healthOrigin(s.URL) } if s.Probe <= 0 { s.Probe = Duration(DefaultWorkstationProbe) } if s.Timeout <= 0 { s.Timeout = Duration(DefaultWorkstationSttTimeout) } } // healthOrigin derives the admission endpoint from the transcribe endpoint. // The URL names a path, so appending to it would ask for /transcribe/health. func healthOrigin(raw string) string { u, err := url.Parse(raw) if err != nil || u.Host == "" { return strings.TrimRight(raw, "/") + "/health" } return u.Scheme + "://" + u.Host + "/health" }