5596cdddbc
llm.Client carries a bearer credential and sets it on the completion, and Pair signs the /health probe with it too. An unsigned probe would answer 401, Pair would read that as a card that is busy, and every workstation turn would fall back to the resident model with nothing naming why. The token comes from workstation.token, expanded from MAVEN_GPU_TOKEN like every other secret in that file. Missing, and voicewire says so at startup: the fallback is silent by design and this failure would otherwise be invisible. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ESv8hqNPseYt1CnotZpqDz
149 lines
5.7 KiB
Go
149 lines
5.7 KiB
Go
package config
|
|
|
|
import (
|
|
"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 {
|
|
// 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.
|
|
// Wrong or missing reads as a workstation that is down, and Maven falls
|
|
// back to the resident model.
|
|
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 {
|
|
// 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 && strings.TrimSpace(c.Workstation.URL) == "" {
|
|
c.Workstation = nil
|
|
}
|
|
if c.Workstation == nil {
|
|
return
|
|
}
|
|
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)
|
|
}
|
|
normaliseWorkstationStt(w)
|
|
}
|
|
|
|
// 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 && 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"
|
|
}
|