Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9bb342569b | |||
| 5596cdddbc | |||
| 1c13d2265b | |||
| 95e7427153 | |||
| a1d018dc47 | |||
| 9f714b7ae8 | |||
| ef3ee1e00a | |||
| 99e73ea653 | |||
| ab1784f5e1 | |||
| 2c73493bf8 | |||
| ff202c0c35 |
@@ -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),
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"crypto/subtle"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The boundary in front of the card.
|
||||||
|
//
|
||||||
|
// mavgpud has to listen on the LAN, because homesrv is the client and a
|
||||||
|
// loopback default takes the model arm down. That makes this the one hop on the
|
||||||
|
// workstation anything on the network could reach, and until 2026-08-11 it
|
||||||
|
// reverse-proxied every path to llama-server unauthenticated: any client could
|
||||||
|
// spend the card, hold the model resident by touching the idle clock, and read
|
||||||
|
// /slots, which carries the prompts of whoever else was using it.
|
||||||
|
//
|
||||||
|
// So: a bearer token every request must carry, read from a file, and a path
|
||||||
|
// allowlist so a token that leaks buys the model API and not the admin one. The
|
||||||
|
// CW2 transcriber beside this daemon has worked this way since it shipped; this
|
||||||
|
// is the same arrangement, not a new one.
|
||||||
|
|
||||||
|
// readToken loads the bearer token. The file holds the token and nothing else,
|
||||||
|
// trailing newline allowed. A path that is set and unreadable is fatal to the
|
||||||
|
// caller: a supervisor that silently ran without its boundary is the failure
|
||||||
|
// this exists to prevent.
|
||||||
|
func readToken(path string) (string, error) {
|
||||||
|
b, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("token_file: %w", err)
|
||||||
|
}
|
||||||
|
tok := strings.TrimSpace(string(b))
|
||||||
|
if tok == "" {
|
||||||
|
return "", fmt.Errorf("token_file %s is empty", path)
|
||||||
|
}
|
||||||
|
return tok, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// loopbackListen reports whether addr can only be reached from this machine.
|
||||||
|
// An empty or wildcard host is not loopback, which is the case that matters:
|
||||||
|
// ":8080" is the shipped default and it answers the whole LAN.
|
||||||
|
func loopbackListen(addr string) bool {
|
||||||
|
host, _, err := net.SplitHostPort(addr)
|
||||||
|
if err != nil {
|
||||||
|
host = addr
|
||||||
|
}
|
||||||
|
host = strings.Trim(host, "[]")
|
||||||
|
if host == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if host == "localhost" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
ip := net.ParseIP(host)
|
||||||
|
return ip != nil && ip.IsLoopback()
|
||||||
|
}
|
||||||
|
|
||||||
|
// allowed is what a token buys. Everything llama-server exposes beyond this is
|
||||||
|
// refused, because the endpoints Maven does not call are the expensive ones to
|
||||||
|
// hand out: /slots returns other callers' prompts, and its save/restore actions
|
||||||
|
// write files chosen by the request.
|
||||||
|
//
|
||||||
|
// Adding a caller means adding its path here. That is deliberate — the list is
|
||||||
|
// short because Maven's use of the workstation is.
|
||||||
|
var allowed = map[string]string{
|
||||||
|
"/v1/chat/completions": http.MethodPost,
|
||||||
|
"/v1/completions": http.MethodPost,
|
||||||
|
"/v1/embeddings": http.MethodPost,
|
||||||
|
"/v1/models": http.MethodGet,
|
||||||
|
"/props": http.MethodGet,
|
||||||
|
}
|
||||||
|
|
||||||
|
// requireToken authenticates, then bounds. Order matters: an unauthenticated
|
||||||
|
// client must not be able to make this daemon allocate a body buffer.
|
||||||
|
//
|
||||||
|
// /health is not exempt. It reports whether the card is loaded and free, which
|
||||||
|
// is exactly what someone deciding whether to take it from him would ask.
|
||||||
|
func requireToken(token string, maxBody int64, next http.Handler) http.Handler {
|
||||||
|
want := sha256.Sum256([]byte(token))
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
got := sha256.Sum256([]byte(bearer(r)))
|
||||||
|
if subtle.ConstantTimeCompare(got[:], want[:]) != 1 {
|
||||||
|
w.Header().Set("WWW-Authenticate", "Bearer")
|
||||||
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.Body = http.MaxBytesReader(w, r.Body, maxBody)
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// bearer pulls the credential out of the header. A malformed header yields the
|
||||||
|
// empty string, which fails the comparison like any other wrong token — there
|
||||||
|
// is no separate error for it, because telling a caller *how* it was wrong is
|
||||||
|
// the only thing a probe learns from a 401.
|
||||||
|
func bearer(r *http.Request) string {
|
||||||
|
h := r.Header.Get("Authorization")
|
||||||
|
const prefix = "Bearer "
|
||||||
|
if len(h) <= len(prefix) || !strings.EqualFold(h[:len(prefix)], prefix) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(h[len(prefix):])
|
||||||
|
}
|
||||||
|
|
||||||
|
// allowlist refuses a path the model arm does not use. It answers 404 rather
|
||||||
|
// than 403 so a scan cannot map llama-server's surface through this hop.
|
||||||
|
func allowlist(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
method, ok := allowed[r.URL.Path]
|
||||||
|
if !ok {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if r.Method != method {
|
||||||
|
w.Header().Set("Allow", method)
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// limitInflight caps concurrent proxied requests. A waiter leaves when its own
|
||||||
|
// context ends, so a client that gave up does not keep a slot: llama-server
|
||||||
|
// runs with -np 1 and queueing here is cheaper than queueing inside the child
|
||||||
|
// with a body held in memory on both sides.
|
||||||
|
func limitInflight(n int, next http.Handler) http.Handler {
|
||||||
|
if n <= 0 {
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
slots := make(chan struct{}, n)
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
select {
|
||||||
|
case slots <- struct{}{}:
|
||||||
|
defer func() { <-slots }()
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
case <-r.Context().Done():
|
||||||
|
http.Error(w, "client went away", http.StatusServiceUnavailable)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ok is what the boundary is protecting: anything that reaches it has spent
|
||||||
|
// the card.
|
||||||
|
func ok(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusTeapot) }
|
||||||
|
|
||||||
|
func TestRequireTokenRefusesEveryWrongCredential(t *testing.T) {
|
||||||
|
h := requireToken("s3cret", 1<<20, http.HandlerFunc(ok))
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
auth string
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{"no header", "", http.StatusUnauthorized},
|
||||||
|
{"wrong token", "Bearer wrong", http.StatusUnauthorized},
|
||||||
|
{"prefix of the token", "Bearer s3cre", http.StatusUnauthorized},
|
||||||
|
{"token with no scheme", "s3cret", http.StatusUnauthorized},
|
||||||
|
{"basic auth", "Basic czNjcmV0", http.StatusUnauthorized},
|
||||||
|
{"right token", "Bearer s3cret", http.StatusTeapot},
|
||||||
|
{"scheme is case-insensitive", "bearer s3cret", http.StatusTeapot},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
r := httptest.NewRequest(http.MethodGet, "/health", nil)
|
||||||
|
if tc.auth != "" {
|
||||||
|
r.Header.Set("Authorization", tc.auth)
|
||||||
|
}
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(w, r)
|
||||||
|
if w.Code != tc.want {
|
||||||
|
t.Errorf("status %d, want %d", w.Code, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The 401 must not say which part was wrong. A probe that can tell a malformed
|
||||||
|
// header from a wrong token learns the header shape for free.
|
||||||
|
func TestUnauthorizedSaysNothingUseful(t *testing.T) {
|
||||||
|
h := requireToken("s3cret", 1<<20, http.HandlerFunc(ok))
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/health", nil))
|
||||||
|
if got := strings.TrimSpace(w.Body.String()); got != "unauthorized" {
|
||||||
|
t.Errorf("body %q, want %q", got, "unauthorized")
|
||||||
|
}
|
||||||
|
if got := w.Header().Get("WWW-Authenticate"); got != "Bearer" {
|
||||||
|
t.Errorf("WWW-Authenticate %q, want Bearer", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The body cap applies to an authenticated request. An unauthenticated one
|
||||||
|
// never gets far enough to allocate anything.
|
||||||
|
func TestRequireTokenCapsTheBody(t *testing.T) {
|
||||||
|
var read error
|
||||||
|
h := requireToken("t", 8, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
buf := make([]byte, 64)
|
||||||
|
for read == nil {
|
||||||
|
if _, read = r.Body.Read(buf); read != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
r := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(strings.Repeat("x", 4096)))
|
||||||
|
r.Header.Set("Authorization", "Bearer t")
|
||||||
|
h.ServeHTTP(httptest.NewRecorder(), r)
|
||||||
|
if read == nil || !strings.Contains(read.Error(), "too large") {
|
||||||
|
t.Errorf("read error %v, want the body cap", read)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAllowlistRefusesWhatMavenDoesNotCall(t *testing.T) {
|
||||||
|
h := allowlist(http.HandlerFunc(ok))
|
||||||
|
cases := []struct {
|
||||||
|
method, path string
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{http.MethodPost, "/v1/chat/completions", http.StatusTeapot},
|
||||||
|
{http.MethodGet, "/v1/models", http.StatusTeapot},
|
||||||
|
// /slots returns the prompts of whoever else is using the card, and
|
||||||
|
// its actions write files the request names.
|
||||||
|
{http.MethodGet, "/slots", http.StatusNotFound},
|
||||||
|
{http.MethodPost, "/slots/0?action=save", http.StatusNotFound},
|
||||||
|
{http.MethodGet, "/", http.StatusNotFound},
|
||||||
|
{http.MethodGet, "/v1/chat/completions", http.StatusMethodNotAllowed},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.method+" "+tc.path, func(t *testing.T) {
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(w, httptest.NewRequest(tc.method, tc.path, nil))
|
||||||
|
if w.Code != tc.want {
|
||||||
|
t.Errorf("status %d, want %d", w.Code, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLimitInflightCapsConcurrency(t *testing.T) {
|
||||||
|
const cap = 2
|
||||||
|
var mu sync.Mutex
|
||||||
|
now, peak := 0, 0
|
||||||
|
release := make(chan struct{})
|
||||||
|
h := limitInflight(cap, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
mu.Lock()
|
||||||
|
now++
|
||||||
|
if now > peak {
|
||||||
|
peak = now
|
||||||
|
}
|
||||||
|
mu.Unlock()
|
||||||
|
<-release
|
||||||
|
mu.Lock()
|
||||||
|
now--
|
||||||
|
mu.Unlock()
|
||||||
|
}))
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for i := 0; i < 8; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil))
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
// Let the first wave arrive, then drain. The assertion is the peak, and a
|
||||||
|
// peak that never reached the cap still cannot exceed it.
|
||||||
|
close(release)
|
||||||
|
wg.Wait()
|
||||||
|
if peak > cap {
|
||||||
|
t.Errorf("%d requests in flight at once, cap is %d", peak, cap)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoopbackListen(t *testing.T) {
|
||||||
|
cases := map[string]bool{
|
||||||
|
":8080": false, // the shipped default, and the whole LAN
|
||||||
|
"0.0.0.0:8080": false,
|
||||||
|
"[::]:8080": false,
|
||||||
|
"192.168.1.105:8080": false,
|
||||||
|
"127.0.0.1:8080": true,
|
||||||
|
"[::1]:8080": true,
|
||||||
|
"localhost:8080": true,
|
||||||
|
}
|
||||||
|
for addr, want := range cases {
|
||||||
|
if got := loopbackListen(addr); got != want {
|
||||||
|
t.Errorf("loopbackListen(%q) = %v, want %v", addr, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadToken(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
good := filepath.Join(dir, "tok")
|
||||||
|
if err := os.WriteFile(good, []byte(" abc123\n"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, err := readToken(good)
|
||||||
|
if err != nil || got != "abc123" {
|
||||||
|
t.Errorf("readToken = %q, %v; want abc123", got, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
blank := filepath.Join(dir, "blank")
|
||||||
|
if err := os.WriteFile(blank, []byte("\n\n"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := readToken(blank); err == nil {
|
||||||
|
t.Error("an empty token file is not a token")
|
||||||
|
}
|
||||||
|
if _, err := readToken(filepath.Join(dir, "absent")); err == nil {
|
||||||
|
t.Error("a missing token file is not a token")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -104,9 +104,11 @@ func TestHealthAndProxyRefuseWhenNotReady(t *testing.T) {
|
|||||||
s := &supervisor{run: newRunner("fake", "/bin/true", nil, "")}
|
s := &supervisor{run: newRunner("fake", "/bin/true", nil, "")}
|
||||||
h := s.handler(mustURL(t, "http://127.0.0.1:1"))
|
h := s.handler(mustURL(t, "http://127.0.0.1:1"))
|
||||||
|
|
||||||
for _, path := range []string{"/health", "/v1/chat/completions"} {
|
// The completion is a POST because the allowlist is in front of the
|
||||||
|
// readiness check now, and it answers 405 to a method it never serves.
|
||||||
|
for path, method := range map[string]string{"/health": http.MethodGet, "/v1/chat/completions": http.MethodPost} {
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, path, nil))
|
h.ServeHTTP(w, httptest.NewRequest(method, path, nil))
|
||||||
if w.Code != http.StatusServiceUnavailable {
|
if w.Code != http.StatusServiceUnavailable {
|
||||||
t.Errorf("%s with no model: got %d, want 503", path, w.Code)
|
t.Errorf("%s with no model: got %d, want 503", path, w.Code)
|
||||||
}
|
}
|
||||||
|
|||||||
+47
-3
@@ -36,6 +36,21 @@ type config struct {
|
|||||||
Listen string `json:"listen"` // what Maven talks to
|
Listen string `json:"listen"` // what Maven talks to
|
||||||
LlamaAddr string `json:"llama_addr"` // where llama-server binds
|
LlamaAddr string `json:"llama_addr"` // where llama-server binds
|
||||||
LlamaBin string `json:"llama_bin"`
|
LlamaBin string `json:"llama_bin"`
|
||||||
|
|
||||||
|
// TokenFile holds the bearer token every request must carry. It is a path
|
||||||
|
// and never the token itself, the rule mavpoll, mavmaild and the CW2
|
||||||
|
// transcriber already follow: a secret in a committed config is a secret
|
||||||
|
// in the history. Empty is allowed only on a loopback Listen, and
|
||||||
|
// requireToken is where that is decided.
|
||||||
|
TokenFile string `json:"token_file,omitempty"`
|
||||||
|
|
||||||
|
// MaxBody bounds a proxied request body. A completion is a prompt, and a
|
||||||
|
// prompt that does not fit here would not fit the context window either.
|
||||||
|
MaxBody int64 `json:"max_body_bytes,omitempty"`
|
||||||
|
// MaxInflight bounds how many proxied requests reach llama-server at once.
|
||||||
|
// It runs with -np 1, so anything above a handful only queues inside the
|
||||||
|
// child while holding a connection and a body in memory here.
|
||||||
|
MaxInflight int `json:"max_inflight,omitempty"`
|
||||||
// LlamaArgs must include the flags that bind LlamaAddr. They are passed
|
// LlamaArgs must include the flags that bind LlamaAddr. They are passed
|
||||||
// through untouched so the model, context size and layer count stay the
|
// through untouched so the model, context size and layer count stay the
|
||||||
// owner's business and not this daemon's schema.
|
// owner's business and not this daemon's schema.
|
||||||
@@ -88,6 +103,8 @@ func defaults() config {
|
|||||||
MinFreeVRAM: 15 << 30,
|
MinFreeVRAM: 15 << 30,
|
||||||
EvictAfter: 2,
|
EvictAfter: 2,
|
||||||
StartAfter: 5,
|
StartAfter: 5,
|
||||||
|
MaxBody: 8 << 20,
|
||||||
|
MaxInflight: 4,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,6 +140,20 @@ func main() {
|
|||||||
log.Fatal("mavgpud: llama_bin is required")
|
log.Fatal("mavgpud: llama_bin is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A LAN listener with no token is refused rather than downgraded to
|
||||||
|
// loopback. Downgrading would look like a safe default and would take the
|
||||||
|
// model arm down instead: homesrv is the client and it is on the LAN.
|
||||||
|
var token string
|
||||||
|
if cfg.TokenFile != "" {
|
||||||
|
var err error
|
||||||
|
if token, err = readToken(cfg.TokenFile); err != nil {
|
||||||
|
log.Fatalf("mavgpud: %v", err)
|
||||||
|
}
|
||||||
|
} else if !loopbackListen(cfg.Listen) {
|
||||||
|
log.Fatalf("mavgpud: listen %s is reachable from the network and token_file is unset — "+
|
||||||
|
"set token_file, or listen on 127.0.0.1 and accept that Maven cannot reach it", cfg.Listen)
|
||||||
|
}
|
||||||
|
|
||||||
base := "http://" + cfg.LlamaAddr
|
base := "http://" + cfg.LlamaAddr
|
||||||
run := newRunner("llama-server", cfg.LlamaBin, cfg.LlamaArgs, base+"/health")
|
run := newRunner("llama-server", cfg.LlamaBin, cfg.LlamaArgs, base+"/health")
|
||||||
sup := &supervisor{
|
sup := &supervisor{
|
||||||
@@ -145,7 +176,20 @@ func main() {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("mavgpud: llama_addr: %v", err)
|
log.Fatalf("mavgpud: llama_addr: %v", err)
|
||||||
}
|
}
|
||||||
srv := &http.Server{Addr: cfg.Listen, Handler: sup.handler(target)}
|
var h http.Handler = sup.handler(target)
|
||||||
|
if token != "" {
|
||||||
|
h = requireToken(token, cfg.MaxBody, h)
|
||||||
|
}
|
||||||
|
srv := &http.Server{
|
||||||
|
Addr: cfg.Listen,
|
||||||
|
Handler: h,
|
||||||
|
// A slow-loris client holds a connection and a header buffer for free
|
||||||
|
// otherwise. No ReadTimeout or WriteTimeout: a completion legitimately
|
||||||
|
// takes minutes on this card, and either one would cut it off.
|
||||||
|
ReadHeaderTimeout: 10 * time.Second,
|
||||||
|
IdleTimeout: 60 * time.Second,
|
||||||
|
MaxHeaderBytes: 1 << 16,
|
||||||
|
}
|
||||||
go func() {
|
go func() {
|
||||||
log.Printf("mavgpud: listening on %s, model %s", cfg.Listen, cfg.LlamaBin)
|
log.Printf("mavgpud: listening on %s, model %s", cfg.Listen, cfg.LlamaBin)
|
||||||
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||||
@@ -203,14 +247,14 @@ func (s *supervisor) handler(target *url.URL) http.Handler {
|
|||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
_, _ = w.Write([]byte(`{"status":"ok"}`))
|
_, _ = w.Write([]byte(`{"status":"ok"}`))
|
||||||
})
|
})
|
||||||
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
mux.Handle("/", allowlist(limitInflight(s.cfg.MaxInflight, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
if !s.run.isReady() {
|
if !s.run.isReady() {
|
||||||
http.Error(w, "model not loaded", http.StatusServiceUnavailable)
|
http.Error(w, "model not loaded", http.StatusServiceUnavailable)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.touch()
|
s.touch()
|
||||||
proxy.ServeHTTP(w, r)
|
proxy.ServeHTTP(w, r)
|
||||||
})
|
}))))
|
||||||
return mux
|
return mux
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -71,15 +71,30 @@ func newWakeModels(melPath, embedPath, headPath, libPath string) (*wakeModels, e
|
|||||||
return nil, fmt.Errorf("wake word: onnx runtime: %w", err)
|
return nil, fmt.Errorf("wake word: onnx runtime: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// One thread per session, not the default of every core. Measured on
|
||||||
|
// workpc: the default took mavwaked from 68% of one core to 335% of
|
||||||
|
// three, for three graphs that each run in well under 80ms single
|
||||||
|
// threaded. An always-on gate that eats a quarter of the workstation is
|
||||||
|
// not a gate he will leave running.
|
||||||
|
opts, err := ort.NewSessionOptions()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("wake word: session options: %w", err)
|
||||||
|
}
|
||||||
|
defer opts.Destroy()
|
||||||
|
if err := opts.SetIntraOpNumThreads(1); err != nil {
|
||||||
|
return nil, fmt.Errorf("wake word: intra-op threads: %w", err)
|
||||||
|
}
|
||||||
|
if err := opts.SetInterOpNumThreads(1); err != nil {
|
||||||
|
return nil, fmt.Errorf("wake word: inter-op threads: %w", err)
|
||||||
|
}
|
||||||
open := func(p string, in, out []string) (*ort.DynamicAdvancedSession, error) {
|
open := func(p string, in, out []string) (*ort.DynamicAdvancedSession, error) {
|
||||||
s, err := ort.NewDynamicAdvancedSession(p, in, out, nil)
|
s, err := ort.NewDynamicAdvancedSession(p, in, out, opts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("wake word: load %s: %w", p, err)
|
return nil, fmt.Errorf("wake word: load %s: %w", p, err)
|
||||||
}
|
}
|
||||||
return s, nil
|
return s, nil
|
||||||
}
|
}
|
||||||
m := &wakeModels{}
|
m := &wakeModels{}
|
||||||
var err error
|
|
||||||
if m.mel, err = open(melPath, []string{"input"}, []string{"output"}); err != nil {
|
if m.mel, err = open(melPath, []string{"input"}, []string{"output"}); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -1,5 +1,14 @@
|
|||||||
{
|
{
|
||||||
"listen": ":8080",
|
"listen": ":8080",
|
||||||
|
"//token_file": [
|
||||||
|
"The bearer token every request must carry. homesrv is the client and it",
|
||||||
|
"is on the LAN, so this port cannot be loopback and the token is what",
|
||||||
|
"stops anything else on the network spending the card or reading /slots.",
|
||||||
|
"A path, never the token: mavgpud refuses to start when listen is",
|
||||||
|
"reachable from the network and this is unset.",
|
||||||
|
"Same value as MAVEN_GPU_TOKEN in homesrv's deploy/telegram.env."
|
||||||
|
],
|
||||||
|
"token_file": "/home/kami/.config/mavgpud.token",
|
||||||
"llama_addr": "127.0.0.1:10000",
|
"llama_addr": "127.0.0.1:10000",
|
||||||
"llama_bin": "llama-server",
|
"llama_bin": "llama-server",
|
||||||
"//llama_args": [
|
"//llama_args": [
|
||||||
|
|||||||
+37
-14
@@ -4,11 +4,22 @@
|
|||||||
# user unit because it needs his ALSA session and his ssh agent, and because
|
# user unit because it needs his ALSA session and his ssh agent, and because
|
||||||
# it should stop when he logs out.
|
# it should stop when he logs out.
|
||||||
#
|
#
|
||||||
# THERE IS NO WAKE WORD YET (V-487 stage two). Anything spoken near the fifine
|
# The keyword is "Мэйвен" and the three -wake- flags are what require it
|
||||||
# becomes a turn. What makes that safe rather than expensive is voiceSender:
|
# (V-487 stage two). Without them anything spoken near the fifine becomes a
|
||||||
# it sends Surface=SurfaceVoice, which caps every command at L0, so no
|
# turn, which voiceSender makes safe rather than expensive: it sends
|
||||||
# accidental trigger runs a destructive act. It does not stop her answering
|
# Surface=SurfaceVoice, capping every command at L0. That does not stop her
|
||||||
# out loud, so this unit is his to stop when the room is not his alone.
|
# answering out loud, which is the whole reason the keyword exists.
|
||||||
|
#
|
||||||
|
# The threshold is 0.999 and it is the binary's default, so it is not passed.
|
||||||
|
# It came from 65 minutes of held-out Russian speech through this same binary:
|
||||||
|
# 0.9 false wakes an hour against 2.8 at 0.99, for one lost render out of 126
|
||||||
|
# (docs/evals/2026-08-09-wake-word.md). If the room proves noisier than the
|
||||||
|
# corpus, read the scores out of this unit's journal and pass -wake-threshold.
|
||||||
|
# Do not lower it by guessing.
|
||||||
|
#
|
||||||
|
# A keyword shorter than 1.32s can be heard too late to be used, because the
|
||||||
|
# head scores 1.28s of audio and the VAD has closed the utterance by then.
|
||||||
|
# "Мэйвен, <request>" is unaffected. A bare "Мэйвен" is the case that fails.
|
||||||
#
|
#
|
||||||
# -vad-model is passed on purpose. Silero answers "is this frame speech" where
|
# -vad-model is passed on purpose. Silero answers "is this frame speech" where
|
||||||
# the energy floor answers "is this frame loud". It declines white noise at
|
# the energy floor answers "is this frame loud". It declines white noise at
|
||||||
@@ -24,27 +35,39 @@
|
|||||||
# systemctl --user enable --now mavwaked.service
|
# systemctl --user enable --now mavwaked.service
|
||||||
|
|
||||||
[Unit]
|
[Unit]
|
||||||
Description=Maven always-on listening (VAD, no wake word yet)
|
Description=Maven always-on listening (silero VAD, "Мэйвен" keyword)
|
||||||
# The tunnel is the only path to mavend and the only thing authenticating it.
|
# The tunnel is the only path to mavend and the only thing authenticating it.
|
||||||
Requires=maven-voice-tunnel.service
|
Requires=maven-voice-tunnel.service
|
||||||
After=maven-voice-tunnel.service
|
After=maven-voice-tunnel.service
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
# card 0 is the fifine USB microphone. Named, and not "default", because the
|
# The Scarlett Solo 4th Gen, and not the fifine. The fifine was the device
|
||||||
# default device follows whatever pipewire last decided and this daemon should
|
# here for three days and mavwaked never logged one utterance in them, because
|
||||||
# not change ears when he plugs in a headset.
|
# it returns RMS 0.00004 with its capture switch on and its ALSA volume at the
|
||||||
|
# full 496 of 496. That silence is in the hardware, so no flag reaches it.
|
||||||
|
#
|
||||||
|
# Named CARD=Gen and not card 4, because a USB card number moves when
|
||||||
|
# something else is replugged and this daemon must not change ears quietly.
|
||||||
|
# Not "default" either: that follows whatever pipewire last decided.
|
||||||
#
|
#
|
||||||
# plughw and not hw. mavwaked asks arecord for 16kHz mono, which is what the
|
# plughw and not hw. mavwaked asks arecord for 16kHz mono, which is what the
|
||||||
# whole pipeline is canonical in. The fifine offers 2 channels at 44100 or
|
# whole pipeline is canonical in. Neither microphone offers it, so bare hw
|
||||||
# 48000 and nothing else, so bare hw:0,0 dies on "Channels count non
|
# dies on "Channels count non available" before a frame is read. plughw puts
|
||||||
# available" before a frame is read. plughw puts ALSA's downmix and resampler
|
# ALSA's downmix and resampler in front. Any replacement wants the same.
|
||||||
# in front. Any replacement microphone wants the same treatment.
|
#
|
||||||
|
# The Scarlett measured RMS 0.003 against 0.14 on the onboard input, so its
|
||||||
|
# front-panel gain is the thing to raise if she mishears. That is a knob, not
|
||||||
|
# a control ALSA exposes. The two loud devices, the onboard ALC897 and the
|
||||||
|
# camera, both clip at peak 1.0 and are worse candidates, not better ones.
|
||||||
Environment=LD_LIBRARY_PATH=%h/.local/lib
|
Environment=LD_LIBRARY_PATH=%h/.local/lib
|
||||||
ExecStart=%h/.local/bin/mavwaked \
|
ExecStart=%h/.local/bin/mavwaked \
|
||||||
-device plughw:0,0 \
|
-device plughw:CARD=Gen,DEV=0 \
|
||||||
-addr 127.0.0.1:9100 \
|
-addr 127.0.0.1:9100 \
|
||||||
-lang ru \
|
-lang ru \
|
||||||
-vad-model %h/.local/share/maven/models/silero_vad.onnx \
|
-vad-model %h/.local/share/maven/models/silero_vad.onnx \
|
||||||
|
-wake-model %h/.local/share/maven/models/maven_wakeword.onnx \
|
||||||
|
-wake-mel %h/.local/share/maven/models/melspectrogram.onnx \
|
||||||
|
-wake-embed %h/.local/share/maven/models/embedding_model.onnx \
|
||||||
-onnx-lib %h/.local/lib/libonnxruntime.so
|
-onnx-lib %h/.local/lib/libonnxruntime.so
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
RestartSec=5
|
RestartSec=5
|
||||||
|
|||||||
@@ -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=
|
||||||
|
|||||||
+29
-5
@@ -102,7 +102,7 @@ Text-to-speech has not moved. piper on homesrv is the only synthesizer.
|
|||||||
| `mavpoll` | Environment poller: netdata alarms, uptime-kuma, zenmoney, wireguard presence. Writes facts, sends nothing. Telegram is `internal/delivery/telegramsink`. |
|
| `mavpoll` | Environment poller: netdata alarms, uptime-kuma, zenmoney, wireguard presence. Writes facts, sends nothing. Telegram is `internal/delivery/telegramsink`. |
|
||||||
| `mavcaldav` | CalDAV calendar sync. |
|
| `mavcaldav` | CalDAV calendar sync. |
|
||||||
| `mavmaild` | Mail reader (IMAP, read-only). Holds the IMAP password, core never sees it. |
|
| `mavmaild` | Mail reader (IMAP, read-only). Holds the IMAP password, core never sees it. |
|
||||||
| `mavgpud` | GPU supervisor. **Runs on workpc**, own unit `deploy/mavgpud.service`. Keeps llama-server loaded while the card is free (V-488). Maven never asks it for anything and reads `/health` through `llm.Pair`. |
|
| `mavgpud` | GPU supervisor. **Runs on workpc**, own unit `deploy/mavgpud.service`. Keeps llama-server loaded while the card is free (V-488). Maven never asks it for anything and reads `/health` through `llm.Pair`. Its LAN port needs a bearer token in the file named by `token_file`, matching `MAVEN_GPU_TOKEN` on homesrv, or it refuses to start (V-673). |
|
||||||
| `mavupdate` | Not a daemon. Operator CLI a human runs on the box to deploy a new build. |
|
| `mavupdate` | Not a daemon. Operator CLI a human runs on the box to deploy a new build. |
|
||||||
|
|
||||||
Two binaries have no Makefile target and neither is deployed. `mavseal` encrypts
|
Two binaries have no Makefile target and neither is deployed. `mavseal` encrypts
|
||||||
@@ -166,11 +166,35 @@ the GPU. A model that will not load is logged and not fatal.
|
|||||||
`-barge-in` is not passed. The threshold is room-specific and this room has no
|
`-barge-in` is not passed. The threshold is room-specific and this room has no
|
||||||
number yet. Read the "suppressed while speaking" means out of the journal first.
|
number yet. Read the "suppressed while speaking" means out of the journal first.
|
||||||
|
|
||||||
The device is `plughw:0,0` and not `hw:0,0`. The fifine offers 2 channels at
|
The device is `plughw:CARD=Gen,DEV=0`, the Scarlett Solo. It is `plughw` and
|
||||||
44100 or 48000 and nothing else, and mavwaked asks arecord for 16kHz mono. Bare
|
not `hw` because mavwaked asks arecord for 16kHz mono. No microphone here
|
||||||
`hw` dies on "Channels count non available" before a frame is read.
|
offers that, so bare `hw` dies on "Channels count non available" before a
|
||||||
|
frame is read. It is named `CARD=Gen` and not card 4 because a USB card number
|
||||||
|
moves when something else is replugged.
|
||||||
|
|
||||||
There is no wake word yet (V-487 stage two), so the loop runs open.
|
It used to be the fifine on card 0, and that cost three days. mavwaked logged
|
||||||
|
zero completed utterances across them, before the wake word existed and after.
|
||||||
|
The fifine returns RMS 0.00004 with its capture switch on and its ALSA volume
|
||||||
|
at the full 496 of 496. That silence is in the hardware and no flag reaches
|
||||||
|
it. Over the same eight seconds of speech the onboard ALC897 read 0.142 and
|
||||||
|
the camera 0.289, both clipping at peak 1.0. The Scarlett read 0.003 clean.
|
||||||
|
|
||||||
|
Check the level before blaming the gate. Stop the unit, run `arecord` against
|
||||||
|
the device for five seconds, and measure. A live room floor reads near 0.001.
|
||||||
|
|
||||||
|
The three `-wake-` flags require the keyword "Мэйвен" (V-487 stage two). Drop
|
||||||
|
them and the loop runs open, which is what it did before. The threshold is the
|
||||||
|
binary's default of 0.999 and is not passed. Over 65 minutes of held-out
|
||||||
|
Russian speech it woke her 0.9 times an hour against 2.8 at 0.99. That cost one
|
||||||
|
lost render out of 126 (`docs/evals/2026-08-09-wake-word.md`).
|
||||||
|
|
||||||
|
The three sessions are pinned to one thread each. onnxruntime otherwise sizes
|
||||||
|
its pool to every core and spins between runs, which took mavwaked from 68% of
|
||||||
|
one core to 335%. With the cap it sits at 81%, so the gate costs about 13%.
|
||||||
|
|
||||||
|
A keyword shorter than 1.32s can be heard too late to be used. The head scores
|
||||||
|
1.28s of audio, and the VAD has closed the utterance by then.
|
||||||
|
"Мэйвен, <request>" is unaffected. A bare "Мэйвен" is the case that fails.
|
||||||
|
|
||||||
mavwaked connects at startup and holds the conn, so a nudge routed to voice
|
mavwaked connects at startup and holds the conn, so a nudge routed to voice
|
||||||
reaches the speaker before he has said anything (V-671). It used to connect
|
reaches the speaker before he has said anything (V-671). It used to connect
|
||||||
|
|||||||
@@ -94,6 +94,14 @@ the head the whole request to peak during. A bare "Мэйвен" with nothing af
|
|||||||
it is the case that fails. One fix would hold an ignored utterance for a grace
|
it is the case that fails. One fix would hold an ignored utterance for a grace
|
||||||
period and ship it if the keyword lands late. It is not built.
|
period and ship it if the keyword lands late. It is not built.
|
||||||
|
|
||||||
|
## What it costs the workstation
|
||||||
|
|
||||||
|
Under systemd on workpc, mavwaked sat at 335% of a core with the gate on and
|
||||||
|
68% with only silero. onnxruntime sizes its thread pool to every core and spins
|
||||||
|
between runs, and this gate runs three graphs twelve times a second. Pinning
|
||||||
|
all three sessions to one thread brought it to 81%, so the keyword costs about
|
||||||
|
13% of one core. The three graphs each finish in well under 80ms that way.
|
||||||
|
|
||||||
## What was not measured
|
## What was not measured
|
||||||
|
|
||||||
No room recordings. Every negative above is a clean corpus clip. This gate
|
No room recordings. Every negative above is a clean corpus clip. This gate
|
||||||
|
|||||||
@@ -105,6 +105,19 @@ how we find out whether the blind spot is real.
|
|||||||
untouched. The model, the context size, the layer count and the MTP flags are the
|
untouched. The model, the context size, the layer count and the MTP flags are the
|
||||||
owner's business and not this daemon's schema.
|
owner's business and not this daemon's schema.
|
||||||
|
|
||||||
|
**The port carries a bearer token and cannot be loopback** (V-673). homesrv is
|
||||||
|
the client, so this hop is on the LAN. Until 2026-08-11 anything on the network
|
||||||
|
could spend the card, hold the model resident by touching the idle clock, and
|
||||||
|
read `/slots`, which returns other callers' prompts. mavgpud now reads
|
||||||
|
`token_file` and refuses to start when the listen address is reachable from the
|
||||||
|
network without one. Downgrading to loopback instead would look safe and take
|
||||||
|
the model arm down. Maven sends the same token from `workstation.token`, on the
|
||||||
|
completion and on the `/health` probe alike. An unsigned probe answers 401,
|
||||||
|
which Pair reads as a busy card, so a missing token degrades to the resident
|
||||||
|
model rather than breaking a turn. The proxy also
|
||||||
|
allowlists the five paths Maven calls, so a leaked token buys the model API and
|
||||||
|
not llama-server's admin surface.
|
||||||
|
|
||||||
**Every GPU service on that box belongs under this supervisor**, added to
|
**Every GPU service on that box belongs under this supervisor**, added to
|
||||||
`cmd/mavgpud` rather than to systemd beside it. The rule was learned on
|
`cmd/mavgpud` rather than to systemd beside it. The rule was learned on
|
||||||
2026-08-09. The CW2 transcriber ran as its own user unit and registered on the
|
2026-08-09. The CW2 transcriber ran as its own user unit and registered on the
|
||||||
|
|||||||
@@ -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