Make consumers optional: a URL enables its mode, empty disables it
OLLAMA_URL and COMFY_URL no longer have defaults; each consumer (listener, client, startup probe, lock participation) is enabled by setting its URL and disabled by leaving it empty. At least one must be set. Ollama-only mode is a pure pass-through; ComfyUI-only mode skips the unload and warm-reload steps. /metrics is now served on both listeners. This is the extension pattern for future consumers such as local game detection.
This commit is contained in:
@@ -51,13 +51,12 @@ type Config struct {
|
||||
}
|
||||
|
||||
// Defaults returns the configuration used when neither the environment nor
|
||||
// a config file sets a value.
|
||||
// a config file sets a value. The upstream URLs default to empty: a
|
||||
// consumer is enabled by setting its URL, disabled by leaving it empty.
|
||||
func Defaults() Config {
|
||||
return Config{
|
||||
ListenOllama: ":11434",
|
||||
ListenComfy: ":8188",
|
||||
OllamaURL: "http://127.0.0.1:11435",
|
||||
ComfyURL: "http://127.0.0.1:8189",
|
||||
UnloadTimeout: time.Minute,
|
||||
JobTimeout: 15 * time.Minute,
|
||||
LLMWaitTimeout: 10 * time.Minute,
|
||||
@@ -219,5 +218,8 @@ func Load(getenv func(string) string) (Config, error) {
|
||||
default:
|
||||
return cfg, fmt.Errorf("LOG_FORMAT: must be \"text\" or \"json\"")
|
||||
}
|
||||
if cfg.OllamaURL == "" && cfg.ComfyURL == "" {
|
||||
return cfg, fmt.Errorf("at least one of OLLAMA_URL or COMFY_URL must be set (each URL enables its consumer)")
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
@@ -8,13 +8,21 @@ import (
|
||||
)
|
||||
|
||||
func TestDefaults(t *testing.T) {
|
||||
cfg, err := Load(func(string) string { return "" })
|
||||
cfg, err := Load(func(k string) string {
|
||||
if k == "OLLAMA_URL" {
|
||||
return "http://127.0.0.1:11435"
|
||||
}
|
||||
return ""
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.ListenOllama != ":11434" || cfg.ListenComfy != ":8188" {
|
||||
t.Fatalf("listen addrs = %s %s", cfg.ListenOllama, cfg.ListenComfy)
|
||||
}
|
||||
if cfg.ComfyURL != "" {
|
||||
t.Fatalf("ComfyURL default = %q, want empty (disabled)", cfg.ComfyURL)
|
||||
}
|
||||
if cfg.UnloadTimeout != time.Minute || cfg.JobTimeout != 15*time.Minute {
|
||||
t.Fatalf("timeouts = %v %v", cfg.UnloadTimeout, cfg.JobTimeout)
|
||||
}
|
||||
@@ -26,6 +34,13 @@ func TestDefaults(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRequiresConsumer(t *testing.T) {
|
||||
_, err := Load(func(string) string { return "" })
|
||||
if err == nil || !strings.Contains(err.Error(), "OLLAMA_URL") {
|
||||
t.Fatalf("err = %v, want missing-consumer error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseEnvFile(t *testing.T) {
|
||||
input := `# comment
|
||||
OLLAMA_URL=http://host:11435
|
||||
@@ -91,6 +106,9 @@ func TestLoadErrors(t *testing.T) {
|
||||
if k == tc.key {
|
||||
return tc.value
|
||||
}
|
||||
if k == "OLLAMA_URL" {
|
||||
return "http://127.0.0.1:11435"
|
||||
}
|
||||
return ""
|
||||
})
|
||||
if err == nil {
|
||||
|
||||
+52
-28
@@ -102,24 +102,37 @@ type Server struct {
|
||||
comfyProxy *httputil.ReverseProxy
|
||||
}
|
||||
|
||||
// New builds a Server, validating the upstream URLs.
|
||||
// New builds a Server, validating the upstream URLs. At least one of
|
||||
// OllamaURL / ComfyURL must be set; an empty URL disables that consumer —
|
||||
// its handler is then never served, its client may be nil, and the image
|
||||
// job flow skips the Ollama unload/warm steps.
|
||||
func New(cfg Config) (*Server, error) {
|
||||
ollamaURL, err := url.Parse(cfg.OllamaURL)
|
||||
if err != nil || ollamaURL.Scheme == "" || ollamaURL.Host == "" {
|
||||
return nil, fmt.Errorf("invalid OLLAMA_URL %q", cfg.OllamaURL)
|
||||
if cfg.OllamaURL == "" && cfg.ComfyURL == "" {
|
||||
return nil, fmt.Errorf("at least one of OllamaURL or ComfyURL is required")
|
||||
}
|
||||
comfyURL, err := url.Parse(cfg.ComfyURL)
|
||||
if err != nil || comfyURL.Scheme == "" || comfyURL.Host == "" {
|
||||
return nil, fmt.Errorf("invalid COMFY_URL %q", cfg.ComfyURL)
|
||||
var ollamaURL, comfyURL *url.URL
|
||||
if cfg.OllamaURL != "" {
|
||||
u, err := url.Parse(cfg.OllamaURL)
|
||||
if err != nil || u.Scheme == "" || u.Host == "" {
|
||||
return nil, fmt.Errorf("invalid OLLAMA_URL %q", cfg.OllamaURL)
|
||||
}
|
||||
ollamaURL = u
|
||||
}
|
||||
if cfg.ComfyURL != "" {
|
||||
u, err := url.Parse(cfg.ComfyURL)
|
||||
if err != nil || u.Scheme == "" || u.Host == "" {
|
||||
return nil, fmt.Errorf("invalid COMFY_URL %q", cfg.ComfyURL)
|
||||
}
|
||||
comfyURL = u
|
||||
}
|
||||
log := cfg.Log
|
||||
if log == nil {
|
||||
log = slog.Default()
|
||||
}
|
||||
if cfg.UnloadPollInterval > 0 {
|
||||
if cfg.UnloadPollInterval > 0 && cfg.Ollama != nil {
|
||||
cfg.Ollama.PollInterval = cfg.UnloadPollInterval
|
||||
}
|
||||
if cfg.HistoryPollInterval > 0 {
|
||||
if cfg.HistoryPollInterval > 0 && cfg.Comfy != nil {
|
||||
cfg.Comfy.PollInterval = cfg.HistoryPollInterval
|
||||
}
|
||||
freeTimeout := cfg.FreeTimeout
|
||||
@@ -160,7 +173,7 @@ func New(cfg Config) (*Server, error) {
|
||||
max: backoffMax,
|
||||
log: log,
|
||||
}
|
||||
return &Server{
|
||||
s := &Server{
|
||||
cfg: cfg,
|
||||
log: log,
|
||||
logWriter: cfg.LogWriter,
|
||||
@@ -172,9 +185,14 @@ func New(cfg Config) (*Server, error) {
|
||||
busyMode: busyMode,
|
||||
busyStatus: busyStatus,
|
||||
busyRetryAfter: busyRetryAfter,
|
||||
ollamaProxy: newReverseProxy(ollamaURL, retry, log.With("upstream", "ollama")),
|
||||
comfyProxy: newReverseProxy(comfyURL, retry, log.With("upstream", "comfy")),
|
||||
}, nil
|
||||
}
|
||||
if ollamaURL != nil {
|
||||
s.ollamaProxy = newReverseProxy(ollamaURL, retry, log.With("upstream", "ollama"))
|
||||
}
|
||||
if comfyURL != nil {
|
||||
s.comfyProxy = newReverseProxy(comfyURL, retry, log.With("upstream", "comfy"))
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// retryTransport retries requests whose failure means the upstream never
|
||||
@@ -470,9 +488,13 @@ func (s *Server) OllamaHandler() http.Handler {
|
||||
// ComfyHandler serves the ComfyUI-facing listener.
|
||||
func (s *Server) ComfyHandler() http.Handler {
|
||||
return s.logRequests("comfy", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/healthz" {
|
||||
switch r.URL.Path {
|
||||
case "/healthz":
|
||||
s.writeHealthz(w)
|
||||
return
|
||||
case "/metrics":
|
||||
s.writeMetrics(w)
|
||||
return
|
||||
}
|
||||
if r.Method == http.MethodPost && r.URL.Path == "/prompt" {
|
||||
s.handlePrompt(w, r)
|
||||
@@ -527,19 +549,21 @@ func (s *Server) handlePrompt(w http.ResponseWriter, r *http.Request) {
|
||||
s.cfg.Metrics.ObserveLockWait("image", time.Since(start).Seconds())
|
||||
log.Info("image lock acquired")
|
||||
|
||||
uctx, ucancel := context.WithTimeout(r.Context(), s.cfg.UnloadTimeout)
|
||||
elapsed, uerr := s.cfg.Ollama.UnloadAll(uctx)
|
||||
ucancel()
|
||||
s.cfg.Metrics.ObserveUnload(elapsed.Seconds())
|
||||
switch {
|
||||
case r.Context().Err() != nil:
|
||||
s.cfg.Lock.ReleaseImage()
|
||||
return
|
||||
case uerr != nil:
|
||||
// Degrade, don't fail the user's request on a misbehaving neighbour.
|
||||
log.Warn("ollama unload incomplete; continuing", "err", uerr)
|
||||
default:
|
||||
log.Info("ollama models unloaded", "seconds", elapsed.Seconds())
|
||||
if s.cfg.Ollama != nil {
|
||||
uctx, ucancel := context.WithTimeout(r.Context(), s.cfg.UnloadTimeout)
|
||||
elapsed, uerr := s.cfg.Ollama.UnloadAll(uctx)
|
||||
ucancel()
|
||||
s.cfg.Metrics.ObserveUnload(elapsed.Seconds())
|
||||
switch {
|
||||
case r.Context().Err() != nil:
|
||||
s.cfg.Lock.ReleaseImage()
|
||||
return
|
||||
case uerr != nil:
|
||||
// Degrade, don't fail the user's request on a misbehaving neighbour.
|
||||
log.Warn("ollama unload incomplete; continuing", "err", uerr)
|
||||
default:
|
||||
log.Info("ollama models unloaded", "seconds", elapsed.Seconds())
|
||||
}
|
||||
}
|
||||
|
||||
cw := &captureWriter{ResponseWriter: w, status: http.StatusOK, limit: s.captureLimit}
|
||||
@@ -585,7 +609,7 @@ func (s *Server) finishImageJob(promptID string) {
|
||||
s.cfg.Lock.ReleaseImage()
|
||||
log.Info("image lock released")
|
||||
|
||||
if s.cfg.WarmModel != "" {
|
||||
if s.cfg.Ollama != nil && s.cfg.WarmModel != "" {
|
||||
if state, _, _ := s.cfg.Lock.Snapshot(); state == lock.StateIdle {
|
||||
wctx, wcancel := context.WithTimeout(context.Background(), s.warmTimeout)
|
||||
if err := s.cfg.Ollama.Warm(wctx, s.cfg.WarmModel); err != nil {
|
||||
|
||||
@@ -451,3 +451,82 @@ func TestLLMBusyWaitTimeoutRetryAfter(t *testing.T) {
|
||||
t.Fatalf("Retry-After = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComfyOnlyModeSkipsOllama(t *testing.T) {
|
||||
f := newFakes(t)
|
||||
|
||||
comfyClient, err := comfy.New(f.comfy.URL, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
comfyClient.PollInterval = 5 * time.Millisecond
|
||||
srv, err := New(Config{
|
||||
ComfyURL: f.comfy.URL,
|
||||
Lock: lock.New(nil),
|
||||
Comfy: comfyClient,
|
||||
Metrics: metrics.New(),
|
||||
JobTimeout: 2 * time.Second,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
front := httptest.NewServer(srv.ComfyHandler())
|
||||
defer front.Close()
|
||||
|
||||
resp, err := http.Post(front.URL+"/prompt", "application/json", strings.NewReader(`{}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("prompt status = %d", resp.StatusCode)
|
||||
}
|
||||
f.completeJob()
|
||||
select {
|
||||
case <-f.freeCh:
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("/free never called")
|
||||
}
|
||||
// With Ollama disabled the unload steps must not happen.
|
||||
if i := f.rec.index("unload"); i >= 0 {
|
||||
f.rec.mu.Lock()
|
||||
t.Fatalf("unload called with ollama disabled; events: %v", f.rec.events)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOllamaOnlyMode(t *testing.T) {
|
||||
f := newFakes(t)
|
||||
|
||||
ollamaClient, err := ollama.New(f.ollama.URL, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
srv, err := New(Config{
|
||||
OllamaURL: f.ollama.URL,
|
||||
Lock: lock.New(nil),
|
||||
Ollama: ollamaClient,
|
||||
Metrics: metrics.New(),
|
||||
LLMWaitTimeout: time.Second,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
front := httptest.NewServer(srv.OllamaHandler())
|
||||
defer front.Close()
|
||||
|
||||
resp, err := http.Post(front.URL+"/api/chat", "application/json", strings.NewReader(`{}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("chat status = %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRequiresConsumer(t *testing.T) {
|
||||
_, err := New(Config{Lock: lock.New(nil), Metrics: metrics.New()})
|
||||
if err == nil {
|
||||
t.Fatal("New with no upstream URLs should fail")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user