Go daemon listening on :8770 that fronts mvoice (8766), whisper-server
(8178), ollama (11434), comfyui (8188) behind a single /v1 façade.
What this MVP does:
- Loads config/consumers.yaml: routing table, per-consumer URL + health +
paths + vram_resident_mib + can_coexist_with + load/unload routes.
- Background health probe (5s) on every consumer; refuses fast with a
structured 503 if the last probe failed (no Felix-Banholzer-style
silent fallback).
- POST /v1/{tts,stt,llm,image} proxies the request body + Content-Type
to the routed consumer's path and streams the response back.
- GET /audio/* proxies to audio_proxy consumer (wa.sh fetches its WAV
this way).
- GET /v1/status exposes live GPU sample (nvidia-smi every 2s),
per-consumer health/loaded/gpu_resident_mib/active/total_requests,
scheduler stats.
- GET /healthz, GET / — broker liveness.
The Scheduler interface is in place but the implementation is
'Passthrough' — every job runs immediately, no lock, no queue. Schritt 4
replaces it with a serialising mutex; Schritt 5 adds VRAM-pressure
eviction. The interface boundary means server.go stays unchanged.
Out of scope here:
- Schritt 3: wa.sh migration (parallel work in mAi).
- Schritt 4: queue + global GPU lock.
- Schritt 5: nvidia-smi-driven LRU eviction.
Tests: config validation (good/bad), proxy forwards body, audio proxy
streams bytes, unhealthy consumer returns 503, /v1/status JSON shape.
Refs: m/mGPUmanager#1
131 lines
2.7 KiB
Go
131 lines
2.7 KiB
Go
package config
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
)
|
|
|
|
func TestLoadValidConfig(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "consumers.yaml")
|
|
body := `
|
|
listen: 127.0.0.1:8770
|
|
gpu:
|
|
total_mib: 16376
|
|
reserved_mib: 1024
|
|
poll_interval_seconds: 2
|
|
|
|
routing:
|
|
tts: mvoice
|
|
llm: ollama
|
|
|
|
audio_proxy: mvoice
|
|
|
|
consumers:
|
|
mvoice:
|
|
url: http://localhost:8766
|
|
health:
|
|
method: GET
|
|
path: /api/health
|
|
paths:
|
|
tts:
|
|
method: POST
|
|
path: /api/synthesize
|
|
vram_resident_mib: 2800
|
|
unload:
|
|
method: POST
|
|
path: /api/admin/unload
|
|
load:
|
|
method: POST
|
|
path: /api/admin/load
|
|
can_coexist_with: [ollama]
|
|
priority: 3
|
|
max_concurrency: 1
|
|
ollama:
|
|
url: http://localhost:11434
|
|
health:
|
|
method: GET
|
|
path: /api/tags
|
|
paths:
|
|
llm:
|
|
method: POST
|
|
path: /api/generate
|
|
vram_managed: true
|
|
can_coexist_with: [mvoice]
|
|
priority: 2
|
|
`
|
|
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
cfg, err := Load(path)
|
|
if err != nil {
|
|
t.Fatalf("Load: %v", err)
|
|
}
|
|
if cfg.Listen != "127.0.0.1:8770" {
|
|
t.Errorf("Listen = %q", cfg.Listen)
|
|
}
|
|
if cfg.GPU.AvailableMiB() != 15352 {
|
|
t.Errorf("AvailableMiB = %d, want 15352", cfg.GPU.AvailableMiB())
|
|
}
|
|
if cfg.GPU.PollInterval().Seconds() != 2 {
|
|
t.Errorf("PollInterval = %s", cfg.GPU.PollInterval())
|
|
}
|
|
|
|
name, cons := cfg.ConsumerForKind(KindTTS)
|
|
if name != "mvoice" || cons == nil {
|
|
t.Fatalf("ConsumerForKind(tts) = %q, %v", name, cons)
|
|
}
|
|
if cons.Paths[KindTTS].Method != "POST" {
|
|
t.Errorf("default method not preserved")
|
|
}
|
|
if cons.MaxConcurrency != 1 {
|
|
t.Errorf("MaxConcurrency = %d", cons.MaxConcurrency)
|
|
}
|
|
|
|
if _, ok := cfg.Consumers["ollama"]; !ok {
|
|
t.Fatal("ollama not loaded")
|
|
}
|
|
if cfg.Consumers["ollama"].MaxConcurrency != 1 {
|
|
t.Errorf("ollama MaxConcurrency default = %d, want 1",
|
|
cfg.Consumers["ollama"].MaxConcurrency)
|
|
}
|
|
}
|
|
|
|
func TestLoadRejectsUnknownRouting(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "consumers.yaml")
|
|
body := `
|
|
routing:
|
|
tts: nonexistent
|
|
consumers:
|
|
mvoice:
|
|
url: http://localhost:8766
|
|
health: { path: /api/health }
|
|
paths: {}
|
|
`
|
|
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := Load(path); err == nil {
|
|
t.Fatal("expected error for unknown routing target, got nil")
|
|
}
|
|
}
|
|
|
|
func TestLoadRejectsMissingURL(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "consumers.yaml")
|
|
body := `
|
|
consumers:
|
|
broken:
|
|
health: { path: /h }
|
|
`
|
|
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := Load(path); err == nil {
|
|
t.Fatal("expected error for missing URL, got nil")
|
|
}
|
|
}
|