diff --git a/cmd/mavend/voicewire.go b/cmd/mavend/voicewire.go index f3a2f7f..f476128 100644 --- a/cmd/mavend/voicewire.go +++ b/cmd/mavend/voicewire.go @@ -386,8 +386,13 @@ func modelSeam(cfg *config.Config, resident *llm.Client) (router.Completer, *llm return resident, nil } ws := cfg.Workstation + remote := llm.New(ws.URL, time.Duration(ws.Timeout)) + remote.SetToken(ws.Token) + if ws.Token == "" { + log.Printf("voice: no workstation.token — mavgpud refuses an unauthenticated request, so this reads as a card that is always busy") + } pair := llm.NewPair( - llm.New(ws.URL, time.Duration(ws.Timeout)), + remote, resident, ws.Health, time.Duration(ws.Probe), diff --git a/deploy/mavend.json b/deploy/mavend.json index aa0d95b..349d163 100644 --- a/deploy/mavend.json +++ b/deploy/mavend.json @@ -94,6 +94,7 @@ ], "workstation": { "url": "http://192.168.1.105:8080", + "token": "${MAVEN_GPU_TOKEN}", "probe": "15s", "timeout": "90s", "stt": { diff --git a/deploy/telegram.env.example b/deploy/telegram.env.example index 8542bcf..3717da7 100644 --- a/deploy/telegram.env.example +++ b/deploy/telegram.env.example @@ -10,3 +10,12 @@ TELEGRAM_CHAT_ID= # ntfy token add --expires=never maven # Read access is not needed — mavend publishes and never subscribes. NTFY_TOKEN= + +# Bearer token for mavgpud, the workstation's GPU supervisor (V-673). It fronts +# the big model on a LAN port, so the token is the whole boundary in front of +# the card. Any long random string; mint one with: +# openssl rand -hex 32 +# The same value goes in a file on workpc, named by token_file in +# deploy/mavgpud.json. Unset here and every workstation turn falls back to the +# resident model, because mavgpud answers 401 and Maven reads that as down. +MAVEN_GPU_TOKEN= diff --git a/internal/config/workstation.go b/internal/config/workstation.go index 6fab688..6b34d2a 100644 --- a/internal/config/workstation.go +++ b/internal/config/workstation.go @@ -27,6 +27,13 @@ type WorkstationConfig struct { // 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. diff --git a/internal/llm/client.go b/internal/llm/client.go index 4d77ae6..bc93b36 100644 --- a/internal/llm/client.go +++ b/internal/llm/client.go @@ -51,6 +51,10 @@ type Client struct { base string swap SwapGate http *http.Client + // token — the bearer credential for a server that asks for one. Empty for + // the resident model, which is reached over loopback on the same box. + // mavgpud on the workstation requires it: that hop is on the LAN. + token string // gate / background — priority on the single llama-server slot. Set once // at wiring time (SetGate), read on every request. nil gate ⇒ no gating, @@ -101,6 +105,27 @@ func New(baseURL string, timeout time.Duration) *Client { return &Client{base: baseURL, http: &http.Client{Timeout: timeout}} } +// SetToken installs the bearer credential this client sends. Wiring-time, like +// SetGate: an empty token means the server is not asking for one. +func (c *Client) SetToken(t string) { + c.mu.Lock() + c.token = t + c.mu.Unlock() +} + +// authorize adds the credential when there is one. Exported to the package so +// the Pair prober signs /health with the same token as the completion — a +// probe that answers 401 would otherwise read as a workstation that is down, +// and Maven would fall back forever without saying why. +func (c *Client) authorize(req *http.Request) { + c.mu.RLock() + t := c.token + c.mu.RUnlock() + if t != "" { + req.Header.Set("Authorization", "Bearer "+t) + } +} + // SetBaseURL re-points the client at another llama-server. Safe to call while // requests are in flight: a request that already read the old base finishes // against the old base (or fails, and every caller of Complete has a fallback), @@ -196,6 +221,7 @@ func (c *Client) Complete(ctx context.Context, r Req) (string, error) { return "", err } req.Header.Set("Content-Type", "application/json") + c.authorize(req) httpResp, err := c.http.Do(req) if err != nil { return "", err diff --git a/internal/llm/remote.go b/internal/llm/remote.go index 30b1143..69b19ba 100644 --- a/internal/llm/remote.go +++ b/internal/llm/remote.go @@ -132,6 +132,9 @@ func (p *Pair) probe(ctx context.Context) { p.set(false) return } + if p.remote != nil { + p.remote.authorize(req) + } resp, err := p.http.Do(req) if err != nil { p.set(false) diff --git a/internal/llm/remote_test.go b/internal/llm/remote_test.go index 3169b42..787b627 100644 --- a/internal/llm/remote_test.go +++ b/internal/llm/remote_test.go @@ -250,3 +250,35 @@ func TestNoFloorIsAnError(t *testing.T) { t.Fatalf("err = %v, want ErrNoFloor", err) } } + +// The workstation is behind mavgpud, which requires a bearer token on the +// completion and on /health alike. A probe that did not carry it would answer +// 401, Pair would read that as a card that is busy, and every turn would fall +// back to the resident model with nothing in the log naming why. +func TestPairSignsTheProbeAndTheCompletion(t *testing.T) { + seen := make(chan string, 2) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + seen <- r.Header.Get("Authorization") + if r.URL.Path == "/health" { + return + } + _, _ = w.Write([]byte(`{"choices":[{"message":{"content":"ok"}}]}`)) + })) + defer srv.Close() + + remote := New(srv.URL, time.Second) + remote.SetToken("s3cret") + p := NewPair(remote, New(srv.URL, time.Second), srv.URL+"/health", time.Hour) + p.probe(context.Background()) + if !p.Available() { + t.Fatal("the probe did not admit an answering workstation") + } + if _, err := p.Complete(context.Background(), Req{User: "привет"}); err != nil { + t.Fatalf("complete: %v", err) + } + for i := 0; i < 2; i++ { + if got := <-seen; got != "Bearer s3cret" { + t.Errorf("request %d carried %q, want the bearer token", i, got) + } + } +}