vojo/apps/ai-bot/failover.go

234 lines
9.4 KiB
Go

package main
import (
"context"
"errors"
"io"
"log/slog"
"net/http"
"strings"
"sync"
"time"
)
// failover.go is the local-first routing decorator over the LLMClient seam: prefer
// the (free) home backend while it is reachable, fall back to the cloud transparently
// when it is not. The cascade keeps calling b.llm.Complete and never learns which leg
// answered — LLMResponse.Model carries that to billing/telemetry.
//
// Health is decided by an ACTIVE background probe plus a request-path circuit
// breaker, both feeding one atomic healthy flag:
//
// - unhealthy → requests go straight to the cloud (no per-request timeout tax on a
// dead tunnel);
// - healthy → try local under its own short deadline (LOCAL_LLM_TIMEOUT_SECONDS —
// a half-alive tunnel must fail fast enough to leave the shared RequestBudget
// room for the cloud leg), and on a transport/5xx/timeout failure trip the
// breaker and retry the SAME request on the cloud;
// - recovery is probe-driven ONLY (anti-flapping): a tripped breaker needs
// tripProbesToClose consecutive probe successes to close, so a tunnel that
// accepts TCP but drops requests can't flap traffic between legs.
//
// A terminal 4xx from the local leg (except 429) does NOT fall back: that is a
// config/request error (a wrong LOCAL_LLM_MODEL tag, a malformed body) and the cloud
// would only mask it. 429 (a busy single-slot GPU) falls back like a timeout.
// Per-request fallback logs at DEBUG; only state transitions and a both-legs failure
// are louder.
const (
// localProbeTimeout bounds one health probe. The probe is a bare GET /models —
// answered from ollama's process memory, no inference — so anything slower than
// this means the tunnel, not the model, is struggling.
localProbeTimeout = 3 * time.Second
// tripProbesToClose is how many consecutive probe successes re-close a breaker
// tripped by a REQUEST failure. The probe can pass while real requests fail (a
// cold model being loaded, a half-alive tunnel), so one success is not proof;
// two spaced an interval apart also rate-limits the cold-load abort loop (an
// aborted request cancels ollama's model load, keeping every next request cold).
tripProbesToClose = 2
// neverOnlineWarnAfter: consecutive probe failures — without the local leg EVER
// having come online since boot — that trigger a one-time WARN. A wrong-valued
// LOCAL_LLM_BASE_URL (say, missing the /v1 suffix) passes the boot presence
// check but 404s forever, and per-request fallbacks log only at DEBUG — so
// without this the enabled feature would be silently inoperative, serving 100%
// of traffic from the paid cloud (the exact outcome the config fail-fast exists
// to prevent).
neverOnlineWarnAfter = 3
)
type failoverLLMClient struct {
primary LLMClient // local backend (free, preferred)
fallback LLMClient // cloud (paid, always available)
log *slog.Logger
legTimeout time.Duration // per-request deadline for the local leg
probeURL string // <LOCAL_LLM_BASE_URL>/models
probeKey string // optional bearer for the probe (usually empty)
interval time.Duration // background probe period
httpc *http.Client
// Breaker state. healthy starts false: until the first probe passes, traffic is
// served by the cloud, so a boot with the home box offline never eats the probe
// timeout on user requests.
mu sync.Mutex
healthy bool
consecOK int // consecutive probe successes so far
needOK int // successes required to close (1 normally, tripProbesToClose after a trip)
consecFail int // consecutive probe failures (the never-online WARN counter)
everHealthy bool // the leg has been online at least once since boot
warnedDown bool // the one-time never-online WARN fired
}
// NewFailoverLLMClient wraps primary (local) and fallback (cloud) into one LLMClient
// and starts the background health probe, which lives until ctx is cancelled (the
// process signal context — same lifetime as the bot).
func NewFailoverLLMClient(ctx context.Context, primary, fallback LLMClient, cfg *Config, logger *slog.Logger) LLMClient {
c := &failoverLLMClient{
primary: primary,
fallback: fallback,
log: logger,
legTimeout: cfg.LocalLLMTimeout,
probeURL: cfg.LocalLLMBaseURL + "/models",
probeKey: cfg.LocalLLMAPIKey,
interval: cfg.LocalLLMHealthInterval,
httpc: &http.Client{},
needOK: 1,
}
// One INFO at construction, so the absence of the follow-up "online" line is a
// documented signal (see the never-online WARN in noteProbe).
logger.Info("local llm failover enabled; cloud serves until the first successful probe", "probe_url", c.probeURL)
go c.probeLoop(ctx)
return c
}
func (c *failoverLLMClient) Complete(ctx context.Context, req LLMRequest) (*LLMResponse, error) {
if !c.isHealthy() {
c.log.DebugContext(ctx, "local llm unhealthy; serving from cloud")
return c.fallback.Complete(ctx, req)
}
lctx, cancel := context.WithTimeout(ctx, c.legTimeout)
resp, err := c.primary.Complete(lctx, req)
cancel()
if err == nil {
if strings.TrimSpace(resp.Text) != "" {
return resp, nil
}
// A 2xx with EMPTY content from the local leg (thinking ate the whole token
// budget, a content quirk of the abliterated model). On the plain cloud path
// an empty completion is kept — it was billed — but the local one is free, so
// retry the turn on the cloud instead of shipping the ⚠️-react empty path.
// The backend is up and answering: not a breaker trip.
c.log.DebugContext(ctx, "local llm returned empty content; retrying on cloud")
return c.fallback.Complete(ctx, req)
}
if ctx.Err() != nil {
// The WHOLE request budget is gone, not just the local leg — a cloud call
// would die the same way, so don't burn a paid attempt; and don't trip the
// breaker over our own cancellation.
return nil, err
}
var se *httpStatusError
if errors.As(err, &se) && se.status >= 400 && se.status < 500 && se.status != http.StatusTooManyRequests {
return nil, err // terminal 4xx: a local config error — fail loud, never mask with the cloud
}
c.trip()
c.log.DebugContext(ctx, "local llm failed; falling back to cloud", "err", err)
resp, ferr := c.fallback.Complete(ctx, req)
if ferr != nil {
// Both legs down — this is the only per-request failure loud enough for WARN
// (the caller will still ERROR+react; this line preserves the local cause).
c.log.WarnContext(ctx, "local llm and cloud fallback both failed", "local_err", err, "cloud_err", ferr)
return nil, ferr
}
return resp, nil
}
func (c *failoverLLMClient) isHealthy() bool {
c.mu.Lock()
defer c.mu.Unlock()
return c.healthy
}
// trip opens the breaker after an in-request local failure. Recovery is probe-driven
// only: requests never re-try the local leg until the prober has re-closed it.
func (c *failoverLLMClient) trip() {
c.mu.Lock()
defer c.mu.Unlock()
if c.healthy {
c.log.Info("local llm tripped offline by a request failure; serving from cloud until probes recover")
}
c.healthy = false
c.consecOK = 0
c.needOK = tripProbesToClose
}
// noteProbe folds one probe result into the breaker. A failed probe opens it (the
// tunnel is observably down — don't wait for a user request to find out) but does
// not raise needOK: plain down/up recovers on the next success, only a request-path
// trip demands the longer streak.
func (c *failoverLLMClient) noteProbe(ok bool) {
c.mu.Lock()
defer c.mu.Unlock()
if !ok {
if c.healthy {
c.log.Info("local llm probe failed; serving from cloud")
}
c.healthy = false
c.consecOK = 0
c.consecFail++
// Never came online since boot → the feature is silently inoperative (every
// request quietly bills the cloud, visible only at DEBUG). Say so ONCE, loud.
if !c.everHealthy && !c.warnedDown && c.consecFail >= neverOnlineWarnAfter {
c.warnedDown = true
c.log.Warn("local llm has never come online since boot; ALL traffic is served by the paid cloud — check LOCAL_LLM_BASE_URL and the tunnel", "probe_url", c.probeURL)
}
return
}
c.consecFail = 0
c.consecOK++
if !c.healthy && c.consecOK >= c.needOK {
c.healthy = true
c.everHealthy = true
c.needOK = 1
c.log.Info("local llm online; serving from local backend")
}
}
func (c *failoverLLMClient) probeLoop(ctx context.Context) {
t := time.NewTicker(c.interval)
defer t.Stop()
for {
c.noteProbe(c.probe(ctx)) // first probe runs immediately, not an interval late
select {
case <-ctx.Done():
return
case <-t.C:
}
}
}
// probe GETs the OpenAI models listing on the local base URL — cheap (no inference)
// and it proves the whole path (SSH tunnel → relay → server) end to end, which is
// exactly what dies when the home box goes away. It does NOT prove the model is
// resident in VRAM; keeping it warm is OLLAMA_KEEP_ALIVE=-1's job (a cold model
// answers slowly, which the request-path breaker handles).
func (c *failoverLLMClient) probe(ctx context.Context) bool {
pctx, cancel := context.WithTimeout(ctx, localProbeTimeout)
defer cancel()
req, err := http.NewRequestWithContext(pctx, http.MethodGet, c.probeURL, nil)
if err != nil {
return false
}
if c.probeKey != "" {
req.Header.Set("Authorization", "Bearer "+c.probeKey)
}
resp, err := c.httpc.Do(req)
if err != nil {
return false
}
defer resp.Body.Close()
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4<<10)) // drain for connection reuse
return resp.StatusCode >= 200 && resp.StatusCode < 300
}