Sign the completion and the probe with the same token (V-673)
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
This commit is contained in:
@@ -386,8 +386,13 @@ func modelSeam(cfg *config.Config, resident *llm.Client) (router.Completer, *llm
|
|||||||
return resident, nil
|
return resident, nil
|
||||||
}
|
}
|
||||||
ws := cfg.Workstation
|
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(
|
pair := llm.NewPair(
|
||||||
llm.New(ws.URL, time.Duration(ws.Timeout)),
|
remote,
|
||||||
resident,
|
resident,
|
||||||
ws.Health,
|
ws.Health,
|
||||||
time.Duration(ws.Probe),
|
time.Duration(ws.Probe),
|
||||||
|
|||||||
@@ -94,6 +94,7 @@
|
|||||||
],
|
],
|
||||||
"workstation": {
|
"workstation": {
|
||||||
"url": "http://192.168.1.105:8080",
|
"url": "http://192.168.1.105:8080",
|
||||||
|
"token": "${MAVEN_GPU_TOKEN}",
|
||||||
"probe": "15s",
|
"probe": "15s",
|
||||||
"timeout": "90s",
|
"timeout": "90s",
|
||||||
"stt": {
|
"stt": {
|
||||||
|
|||||||
@@ -10,3 +10,12 @@ TELEGRAM_CHAT_ID=
|
|||||||
# ntfy token add --expires=never maven
|
# ntfy token add --expires=never maven
|
||||||
# Read access is not needed — mavend publishes and never subscribes.
|
# Read access is not needed — mavend publishes and never subscribes.
|
||||||
NTFY_TOKEN=
|
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=
|
||||||
|
|||||||
@@ -27,6 +27,13 @@ type WorkstationConfig struct {
|
|||||||
// signal, so it must be the supervisor's endpoint and not llama-server's.
|
// signal, so it must be the supervisor's endpoint and not llama-server's.
|
||||||
Health string `json:"health,omitempty"`
|
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.
|
// Probe — how often admission is re-checked. 0 ⇒ DefaultWorkstationProbe.
|
||||||
// Nothing on the hot path waits for it: the answer is cached and read
|
// 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.
|
// atomically, so this only sets how late Maven notices the card came back.
|
||||||
|
|||||||
@@ -51,6 +51,10 @@ type Client struct {
|
|||||||
base string
|
base string
|
||||||
swap SwapGate
|
swap SwapGate
|
||||||
http *http.Client
|
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
|
// gate / background — priority on the single llama-server slot. Set once
|
||||||
// at wiring time (SetGate), read on every request. nil gate ⇒ no gating,
|
// 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}}
|
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
|
// 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
|
// 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),
|
// 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
|
return "", err
|
||||||
}
|
}
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
c.authorize(req)
|
||||||
httpResp, err := c.http.Do(req)
|
httpResp, err := c.http.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
|
|||||||
@@ -132,6 +132,9 @@ func (p *Pair) probe(ctx context.Context) {
|
|||||||
p.set(false)
|
p.set(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if p.remote != nil {
|
||||||
|
p.remote.authorize(req)
|
||||||
|
}
|
||||||
resp, err := p.http.Do(req)
|
resp, err := p.http.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
p.set(false)
|
p.set(false)
|
||||||
|
|||||||
@@ -250,3 +250,35 @@ func TestNoFloorIsAnError(t *testing.T) {
|
|||||||
t.Fatalf("err = %v, want ErrNoFloor", err)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user