feat: ollama-proxy — multi-account Ollama Cloud reverse proxy

Go reverse proxy for ollama.com that balances requests across multiple
API keys with round-robin and failover on 429/5xx. Exposes both native
Ollama API (/api/*) and OpenAI-compatible (/v1/*) passthrough.

- Round-robin balancer with per-account cooldown (60s default)
- Pre-stream failover: 429 → cooldown + next account; 5xx → next account
- Streaming invariant: once 2xx starts streaming, no account switch
- SSE (text/event-stream) and NDJSON passthrough with http.Flusher
- CLI: accounts add/list/remove/set-base-url, serve, version
- Accounts stored in ~/.config/ollama-proxy/accounts.json (chmod 0600)
- systemd unit (User=dueattendant149, 127.0.0.1:11435, Restart=always)
- 43 tests (unit + integration with httptest upstream)
- opencode integration: custom provider 'ocp' with explicit model list
- Requires NO_PROXY=127.0.0.1,localhost when HTTP_PROXY is set
This commit is contained in:
Atte149 2026-06-19 13:58:13 +03:00
commit 3963eede70
23 changed files with 2534 additions and 0 deletions

158
internal/proxy/balancer.go Normal file
View file

@ -0,0 +1,158 @@
// Package proxy implements the Ollama Cloud reverse proxy: account selection
// (round-robin with per-account cooldown), the HTTP reverse-proxy handler,
// and the pre-stream failover logic.
package proxy
import (
"sync"
"sync/atomic"
"time"
"github.com/Atte149/ollama-proxy/internal/config"
)
// Balancer selects the next upstream account using round-robin and skips
// accounts currently in cooldown. It is safe for concurrent use.
type Balancer struct {
accounts []config.Account
rr atomic.Uint64 // round-robin counter
mu sync.RWMutex
cooldown map[string]time.Time // account ID -> until
last429 map[string]time.Time // account ID -> last 429 time (for display)
cooldownDur time.Duration
}
// NewBalancer builds a Balancer from a list of accounts. The cooldown window
// is applied uniformly to all accounts. At least one account is required;
// otherwise Next returns ErrNoAccounts on every call.
func NewBalancer(accounts []config.Account, cooldown time.Duration) *Balancer {
// copy to avoid external mutation
accts := make([]config.Account, len(accounts))
copy(accts, accounts)
return &Balancer{
accounts: accts,
cooldown: make(map[string]time.Time),
last429: make(map[string]time.Time),
cooldownDur: cooldown,
}
}
// Len returns the number of accounts the balancer knows about.
func (b *Balancer) Len() int { return len(b.accounts) }
// Account returns the i-th account (mostly for tests / display).
func (b *Balancer) Account(i int) config.Account { return b.accounts[i] }
// Accounts returns a shallow copy of the account list.
func (b *Balancer) Accounts() []config.Account {
out := make([]config.Account, len(b.accounts))
copy(out, b.accounts)
return out
}
// ErrNoAccounts is returned when the balancer has no accounts configured.
type ErrNoAccounts struct{}
func (ErrNoAccounts) Error() string { return "no accounts configured" }
// ErrAllCooldown is returned when every account is currently in cooldown.
type ErrAllCooldown struct {
// Until is the earliest time at which any account becomes available again.
Until time.Time
}
func (e ErrAllCooldown) Error() string {
return "all accounts are in cooldown until " + e.Until.Format(time.RFC3339)
}
// Next picks the next available account, skipping accounts in cooldown. It
// rotates starting from the round-robin counter so consecutive calls land on
// different accounts when possible. Returns ErrNoAccounts or ErrAllCooldown
// when nothing is available.
func (b *Balancer) Next() (config.Account, error) {
if len(b.accounts) == 0 {
return config.Account{}, ErrNoAccounts{}
}
now := time.Now()
var earliest time.Time
for i := 0; i < len(b.accounts); i++ {
idx := int(b.rr.Add(1)) % len(b.accounts)
// idx is computed from the *new* counter value; we Add-then-mod so each
// call advances even if this loop iteration rejects the candidate.
if !b.isCooldown(b.accounts[idx].ID, now) {
return b.accounts[idx], nil
}
if earliest.IsZero() || b.cooldownUntil(b.accounts[idx].ID).Before(earliest) {
earliest = b.cooldownUntil(b.accounts[idx].ID)
}
}
return config.Account{}, ErrAllCooldown{Until: earliest}
}
// isCooldown reports whether the account is currently rate-limited.
func (b *Balancer) isCooldown(id string, now time.Time) bool {
b.mu.RLock()
defer b.mu.RUnlock()
until, ok := b.cooldown[id]
if !ok {
return false
}
return now.Before(until)
}
// cooldownUntil returns the cooldown expiry for an account (zero if none).
func (b *Balancer) cooldownUntil(id string) time.Time {
b.mu.RLock()
defer b.mu.RUnlock()
return b.cooldown[id]
}
// MarkCooldown puts the given account into cooldown for the configured window
// starting from now. Safe to call concurrently; idempotent (extends the window).
func (b *Balancer) MarkCooldown(id string) {
b.MarkCooldownFor(id, b.cooldownDur)
}
// MarkCooldownFor puts the account into cooldown for an explicit duration.
func (b *Balancer) MarkCooldownFor(id string, d time.Duration) {
b.mu.Lock()
defer b.mu.Unlock()
until := time.Now().Add(d)
b.cooldown[id] = until
b.last429[id] = time.Now()
}
// ClearCooldown removes the cooldown for an account (e.g. on a successful
// request after earlier failures).
func (b *Balancer) ClearCooldown(id string) {
b.mu.Lock()
defer b.mu.Unlock()
delete(b.cooldown, id)
}
// Status returns a snapshot of per-account cooldown state, suitable for
// display in `accounts list`. The map key is the account ID.
func (b *Balancer) Status() map[string]AccountStatus {
b.mu.RLock()
defer b.mu.RUnlock()
out := make(map[string]AccountStatus, len(b.accounts))
now := time.Now()
for _, a := range b.accounts {
until := b.cooldown[a.ID]
st := AccountStatus{
InCooldown: !until.IsZero() && now.Before(until),
Until: until,
Last429: b.last429[a.ID],
}
out[a.ID] = st
}
return out
}
// AccountStatus is the per-account cooldown snapshot returned by Status.
type AccountStatus struct {
InCooldown bool
Until time.Time
Last429 time.Time
}

View file

@ -0,0 +1,160 @@
package proxy
import (
"sync"
"testing"
"time"
"github.com/Atte149/ollama-proxy/internal/config"
)
func mkAccts(n int) []config.Account {
out := make([]config.Account, n)
for i := range out {
out[i] = config.Account{ID: "id" + string(rune('a'+i)), Name: "a" + string(rune('a'+i)), APIKey: "k" + string(rune('a'+i))}
}
return out
}
func TestBalancer_Next_RoundRobin(t *testing.T) {
b := NewBalancer(mkAccts(3), 60*time.Second)
seen := make(map[string]int)
for i := 0; i < 9; i++ {
a, err := b.Next()
if err != nil {
t.Fatalf("Next %d: %v", i, err)
}
seen[a.ID]++
}
// 9 calls across 3 accounts should hit each exactly 3 times (round-robin).
for id, n := range seen {
if n != 3 {
t.Errorf("account %s got %d hits, want 3", id, n)
}
}
}
func TestBalancer_Next_NoAccounts(t *testing.T) {
b := NewBalancer(nil, 60*time.Second)
_, err := b.Next()
if _, ok := err.(ErrNoAccounts); !ok {
t.Errorf("Next on empty balancer: %v, want ErrNoAccounts", err)
}
}
func TestBalancer_Next_SkipsCooldown(t *testing.T) {
accts := mkAccts(3)
b := NewBalancer(accts, 60*time.Second)
// Put the first account in cooldown; the next 3 calls should never pick it.
b.MarkCooldown(accts[0].ID)
for i := 0; i < 3; i++ {
a, err := b.Next()
if err != nil {
t.Fatalf("Next %d: %v", i, err)
}
if a.ID == accts[0].ID {
t.Errorf("Next %d returned cooldown account %s", i, a.ID)
}
}
}
func TestBalancer_Next_AllCooldown(t *testing.T) {
accts := mkAccts(2)
b := NewBalancer(accts, 60*time.Second)
b.MarkCooldown(accts[0].ID)
b.MarkCooldown(accts[1].ID)
_, err := b.Next()
if _, ok := err.(ErrAllCooldown); !ok {
t.Errorf("Next with all in cooldown: %v, want ErrAllCooldown", err)
}
}
func TestBalancer_ClearCooldown(t *testing.T) {
accts := mkAccts(2)
b := NewBalancer(accts, 60*time.Second)
b.MarkCooldown(accts[0].ID)
b.ClearCooldown(accts[0].ID)
// After clearing, the first account must be selectable again. Spin until
// we see it — round-robin visits each account within len(accts) calls.
seen := false
for i := 0; i < len(accts)*3; i++ {
a, err := b.Next()
if err != nil {
t.Fatalf("Next %d: %v", i, err)
}
if a.ID == accts[0].ID {
seen = true
break
}
}
if !seen {
t.Error("cleared account was never returned by Next")
}
}
func TestBalancer_Next_Concurrent(t *testing.T) {
b := NewBalancer(mkAccts(3), 60*time.Second)
var wg sync.WaitGroup
const goroutines = 16
const perG = 100
results := make(chan string, goroutines*perG)
for g := 0; g < goroutines; g++ {
wg.Add(1)
go func() {
defer wg.Done()
for i := 0; i < perG; i++ {
a, err := b.Next()
if err != nil {
t.Errorf("Next: %v", err)
return
}
results <- a.ID
}
}()
}
wg.Wait()
close(results)
// Sanity: every result is one of the known account IDs. We don't assert
// strict fairness under contention (atomic counter gives best-effort RR).
known := map[string]bool{"ida": true, "idb": true, "idc": true}
count := 0
for id := range results {
if !known[id] {
t.Errorf("unknown account id returned: %s", id)
}
count++
}
if count != goroutines*perG {
t.Errorf("got %d results, want %d", count, goroutines*perG)
}
}
func TestBalancer_Status(t *testing.T) {
accts := mkAccts(2)
b := NewBalancer(accts, 60*time.Second)
b.MarkCooldown(accts[0].ID)
st := b.Status()
if !st[accts[0].ID].InCooldown {
t.Error("account 0 should be in cooldown")
}
if st[accts[1].ID].InCooldown {
t.Error("account 1 should not be in cooldown")
}
if st[accts[0].ID].Last429.IsZero() {
t.Error("account 0 should have Last429 set")
}
}
func TestBalancer_CooldownExpires(t *testing.T) {
accts := mkAccts(1)
b := NewBalancer(accts, 50*time.Millisecond)
b.MarkCooldown(accts[0].ID)
// Immediately: account is in cooldown.
if _, err := b.Next(); err == nil {
t.Fatal("Next succeeded immediately after MarkCooldown")
}
time.Sleep(60 * time.Millisecond)
if _, err := b.Next(); err != nil {
t.Errorf("Next after cooldown expiry: %v", err)
}
}

294
internal/proxy/handler.go Normal file
View file

@ -0,0 +1,294 @@
package proxy
import (
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"strings"
"time"
)
// Handler is the http.Handler that proxies incoming requests to Ollama Cloud
// through the balancer, applying per-account Authorization and failover logic.
type Handler struct {
Balancer *Balancer
BaseURL string // upstream root, e.g. "https://ollama.com"
Client *http.Client
Log *slog.Logger
Retries int // max attempts per request (= number of accounts to try)
}
// NewHandler builds a Handler with sensible HTTP client defaults (no timeout
// on the overall request — streaming responses can be long; per-read timeout
// is governed by the caller's context).
func NewHandler(b *Balancer, baseURL string, retries int, log *slog.Logger) *Handler {
return &Handler{
Balancer: b,
BaseURL: strings.TrimRight(baseURL, "/"),
Client: &http.Client{
// No overall timeout: streaming chat may take minutes. The caller's
// request context (cancelled when the client disconnects) still
// propagates through to upstream via NewWithContext.
Timeout: 0,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse // don't follow redirects automatically
},
},
Log: log,
Retries: retries,
}
}
// allowedPaths is the allowlist of upstream paths the proxy will forward.
// Anything else returns 404 — we never proxy arbitrary paths.
var allowedPaths = map[string]bool{
"/api/chat": true,
"/api/generate": true,
"/api/tags": true,
"/api/show": true,
"/api/ps": true,
"/api/version": true,
"/api/delete": true,
"/v1/chat/completions": true,
"/v1/completions": true,
"/v1/models": true,
"/v1/embeddings": true,
"/v1/files": true,
}
// isAllowed reports whether a path (possibly with a trailing slash or query)
// matches one of the allowed upstream endpoints.
func isAllowed(path string) bool {
if allowedPaths[path] {
return true
}
// allow sub-paths like /v1/files/<id> under an allowed prefix.
for prefix := range allowedPaths {
if strings.HasPrefix(path, prefix+"/") {
return true
}
}
return false
}
// ServeHTTP proxies a single client request to Ollama Cloud with failover.
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if !isAllowed(r.URL.Path) {
http.NotFound(w, r)
return
}
if h.Balancer.Len() == 0 {
writeError(w, http.StatusServiceUnavailable, "no accounts configured")
return
}
maxAttempts := h.Retries
if maxAttempts < 1 {
maxAttempts = 1
}
if maxAttempts > h.Balancer.Len() {
maxAttempts = h.Balancer.Len()
}
h.proxyWithFailover(w, r, maxAttempts)
}
// proxyWithFailover tries up to maxAttempts accounts. The first attempt that
// begins streaming (returns headers + a 2xx) commits: we copy the rest of the
// response to the client verbatim and stop retrying. Pre-stream 429/5xx move
// on to the next account.
func (h *Handler) proxyWithFailover(w http.ResponseWriter, r *http.Request, maxAttempts int) {
ctx := r.Context()
var lastErr error
for attempt := 0; attempt < maxAttempts; attempt++ {
acct, err := h.Balancer.Next()
if err != nil {
// No accounts available.
switch err.(type) {
case ErrAllCooldown:
// All accounts rate-limited upstream → propagate 429 to client.
msg := err.Error()
if lastErr != nil {
msg = msg + "; last upstream error: " + lastErr.Error()
}
writeError(w, http.StatusTooManyRequests, msg)
return
case ErrNoAccounts:
writeError(w, http.StatusServiceUnavailable, err.Error())
return
default:
writeError(w, http.StatusServiceUnavailable, err.Error())
return
}
}
// Build the upstream request, substituting the account's key.
upstreamURL := h.BaseURL + r.URL.RequestURI()
// Per-account BaseURL override wins when set.
if acct.BaseURL != "" {
upstreamURL = strings.TrimRight(acct.BaseURL, "/") + r.URL.RequestURI()
}
req, err := http.NewRequestWithContext(ctx, r.Method, upstreamURL, r.Body)
if err != nil {
lastErr = fmt.Errorf("build request: %w", err)
continue
}
// Copy headers, replacing Authorization with the account key.
copyHeaders(req.Header, r.Header)
req.Header.Set("Authorization", "Bearer "+acct.APIKey)
req.Host = "" // let the URL determine Host
start := time.Now()
resp, err := h.Client.Do(req)
latency := time.Since(start)
if err != nil {
// Network/timeout error: log and try next account.
h.Log.Warn("upstream request failed",
"account", acct.Name, "account_id", acct.ID,
"path", r.URL.Path, "latency_ms", latency.Milliseconds(), "err", err)
lastErr = err
continue
}
// Pre-stream decision: 429 or 5xx → cooldown + failover.
if resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode >= 500 {
bodyPreview := readPreview(resp.Body, 256)
resp.Body.Close()
h.Log.Warn("upstream rejected, failover",
"account", acct.Name, "account_id", acct.ID,
"path", r.URL.Path, "status", resp.StatusCode,
"latency_ms", latency.Milliseconds(), "preview", bodyPreview)
if resp.StatusCode == http.StatusTooManyRequests {
h.Balancer.MarkCooldown(acct.ID)
}
lastErr = fmt.Errorf("upstream %d: %s", resp.StatusCode, bodyPreview)
continue
}
// 2xx (or other non-retriable): commit and stream.
h.copyResponse(w, resp)
h.Log.Info("proxied",
"account", acct.Name, "account_id", acct.ID,
"path", r.URL.Path, "status", resp.StatusCode,
"latency_ms", latency.Milliseconds(), "stream", isStreaming(resp))
return
}
// Exhausted retries. Distinguish "everything is rate-limited" (→ 429) from
// "mixed upstream errors" (→ 502).
if h.Balancer.Len() > 0 {
allCooldown := true
status := h.Balancer.Status()
for _, a := range h.Balancer.Accounts() {
if !status[a.ID].InCooldown {
allCooldown = false
break
}
}
if allCooldown {
msg := "all accounts rate-limited"
if lastErr != nil {
msg = msg + ": " + lastErr.Error()
}
writeError(w, http.StatusTooManyRequests, msg)
return
}
}
if lastErr != nil {
writeError(w, http.StatusBadGateway, "all accounts failed: "+lastErr.Error())
return
}
writeError(w, http.StatusBadGateway, "all accounts failed")
}
// copyResponse streams the upstream response body to the client. For chunked /
// SSE responses we flush after every read so tokens reach the client immediately.
func (h *Handler) copyResponse(w http.ResponseWriter, resp *http.Response) {
defer resp.Body.Close()
// Copy headers (except hop-by-hop ones).
for k, vs := range resp.Header {
for _, v := range vs {
w.Header().Add(k, v)
}
}
w.WriteHeader(resp.StatusCode)
flusher, _ := w.(http.Flusher)
buf := make([]byte, 4096)
for {
n, err := resp.Body.Read(buf)
if n > 0 {
if _, werr := w.Write(buf[:n]); werr != nil {
// client went away; stop copying silently
return
}
if flusher != nil {
flusher.Flush()
}
}
if err != nil {
if !errors.Is(err, io.EOF) {
h.Log.Debug("upstream body read ended", "err", err)
}
return
}
}
}
// copyHeaders duplicates src into dst, dropping hop-by-hop headers and any
// Authorization that the client may have sent (we always set our own).
func copyHeaders(dst, src http.Header) {
hopByHop := []string{
"Connection", "Keep-Alive", "Proxy-Authenticate", "Proxy-Authorization",
"Te", "Trailers", "Transfer-Encoding", "Upgrade",
"Authorization", // always overwritten by the account key
}
for k, vs := range src {
skip := false
for _, h := range hopByHop {
if strings.EqualFold(k, h) {
skip = true
break
}
}
if skip {
continue
}
for _, v := range vs {
dst.Add(k, v)
}
}
}
// readPreview reads up to n bytes from r and returns them as a string, always
// closing the reader.
func readPreview(r io.ReadCloser, n int) string {
defer r.Close()
buf := make([]byte, n)
m, _ := r.Read(buf)
return string(buf[:m])
}
// isStreaming reports whether the response is streaming (SSE or NDJSON/chunked).
func isStreaming(resp *http.Response) bool {
ct := resp.Header.Get("Content-Type")
if strings.Contains(ct, "text/event-stream") {
return true
}
if strings.Contains(ct, "application/x-ndjson") {
return true
}
te := resp.Header.Get("Transfer-Encoding")
return strings.Contains(strings.ToLower(te), "chunked")
}
// writeError emits a JSON-formatted error to the client, mirroring Ollama's
// error shape so OpenAI-compatible clients can parse it.
func writeError(w http.ResponseWriter, status int, msg string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_, _ = fmt.Fprintf(w, `{"error":{"message":%q,"type":"ollama_proxy"}}`, msg)
}

View file

@ -0,0 +1,245 @@
package proxy
import (
"io"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/Atte149/ollama-proxy/internal/config"
"github.com/Atte149/ollama-proxy/internal/log"
)
// mockUpstream is a configurable test upstream. It responds to /api/chat and
// /v1/chat/completions; for other paths it returns 200 with a short body.
type mockUpstream struct {
status int32 // current status to return
body string
allowAuth string // if non-empty, require this Bearer token
chunks []string
requestAuth atomic.Int32 // number of requests seen with each auth value
}
func (m *mockUpstream) handler(w http.ResponseWriter, r *http.Request) {
auth := r.Header.Get("Authorization")
if m.allowAuth != "" && auth != "Bearer "+m.allowAuth {
http.Error(w, "bad auth", http.StatusUnauthorized)
return
}
if m.status >= 500 || m.status == 429 {
http.Error(w, "rate limited", int(m.status))
return
}
if len(m.chunks) > 0 {
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Transfer-Encoding", "chunked")
w.WriteHeader(200)
flusher, _ := w.(http.Flusher)
for _, c := range m.chunks {
_, _ = io.WriteString(w, c)
if flusher != nil {
flusher.Flush()
}
}
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(int(m.status))
_, _ = io.WriteString(w, m.body)
}
func newTestHandler(t *testing.T, upstreamURL string, accounts []config.Account, retries int) *Handler {
t.Helper()
b := NewBalancer(accounts, 50*time.Millisecond)
return NewHandler(b, upstreamURL, retries, log.New("debug"))
}
func TestHandler_ProxiesVersion(t *testing.T) {
up := &mockUpstream{status: 200, body: `{"version":"0.1.42"}`}
srv := httptest.NewServer(http.HandlerFunc(up.handler))
defer srv.Close()
h := newTestHandler(t, srv.URL, mkAccts(1), 1)
rec := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/api/version", nil)
h.ServeHTTP(rec, req)
if rec.Code != 200 {
t.Fatalf("status = %d, want 200", rec.Code)
}
if !strings.Contains(rec.Body.String(), "0.1.42") {
t.Errorf("body = %q, want version", rec.Body.String())
}
}
func TestHandler_FailoverOn429(t *testing.T) {
// Two upstreams: first always 429, second always 200.
badUp := &mockUpstream{status: 429, body: "rate limited"}
badSrv := httptest.NewServer(http.HandlerFunc(badUp.handler))
goodUp := &mockUpstream{status: 200, body: `{"ok":true}`}
goodSrv := httptest.NewServer(http.HandlerFunc(goodUp.handler))
defer badSrv.Close()
defer goodSrv.Close()
// Two accounts, each pointing at a different upstream via per-account BaseURL.
accts := []config.Account{
{ID: "idA", Name: "a", APIKey: "k1", BaseURL: badSrv.URL},
{ID: "idB", Name: "b", APIKey: "k2", BaseURL: goodSrv.URL},
}
h := newTestHandler(t, "https://unused.example.com", accts, 2)
rec := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/api/version", nil)
h.ServeHTTP(rec, req)
if rec.Code != 200 {
t.Fatalf("status = %d, want 200 (failover to good account)", rec.Code)
}
if !strings.Contains(rec.Body.String(), `"ok":true`) {
t.Errorf("body = %q, want good upstream response", rec.Body.String())
}
}
func TestHandler_AllAccounts429_Returns429(t *testing.T) {
up := &mockUpstream{status: 429, body: "rate limited"}
srv := httptest.NewServer(http.HandlerFunc(up.handler))
defer srv.Close()
accts := []config.Account{
{ID: "idA", Name: "a", APIKey: "k1", BaseURL: srv.URL},
{ID: "idB", Name: "b", APIKey: "k2", BaseURL: srv.URL},
}
h := newTestHandler(t, srv.URL, accts, 2)
rec := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/api/version", nil)
h.ServeHTTP(rec, req)
if rec.Code != http.StatusTooManyRequests {
t.Fatalf("status = %d, want 429", rec.Code)
}
}
func TestHandler_SSEStreaming(t *testing.T) {
up := &mockUpstream{
status: 200,
chunks: []string{"data: {\"a\":1}\n\n", "data: {\"a\":2}\n\n", "data: [DONE]\n\n"},
}
srv := httptest.NewServer(http.HandlerFunc(up.handler))
defer srv.Close()
h := newTestHandler(t, srv.URL, mkAccts(1), 1)
rec := httptest.NewRecorder()
req := httptest.NewRequest("POST", "/v1/chat/completions", strings.NewReader(`{"stream":true}`))
h.ServeHTTP(rec, req)
if rec.Code != 200 {
t.Fatalf("status = %d, want 200", rec.Code)
}
body := rec.Body.String()
if !strings.Contains(body, "[DONE]") {
t.Errorf("body = %q, want to contain [DONE]", body)
}
if !strings.Contains(body, "\"a\":1") || !strings.Contains(body, "\"a\":2") {
t.Errorf("body = %q, missing chunks", body)
}
if ct := rec.Header().Get("Content-Type"); !strings.Contains(ct, "text/event-stream") {
t.Errorf("Content-Type = %q, want text/event-stream", ct)
}
}
func TestHandler_RejectsUnknownPath(t *testing.T) {
up := &mockUpstream{status: 200, body: "x"}
srv := httptest.NewServer(http.HandlerFunc(up.handler))
defer srv.Close()
h := newTestHandler(t, srv.URL, mkAccts(1), 1)
rec := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/admin", nil)
h.ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Errorf("status = %d, want 404 for unknown path", rec.Code)
}
}
func TestHandler_NoAccounts(t *testing.T) {
h := newTestHandler(t, "http://unused.example.com", nil, 1)
rec := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/api/version", nil)
h.ServeHTTP(rec, req)
if rec.Code != http.StatusServiceUnavailable {
t.Errorf("status = %d, want 503", rec.Code)
}
}
func TestHandler_StripsClientAuthorization(t *testing.T) {
// Upstream echoes the received Authorization header in its body so we can
// assert the proxy overrode it with the account's key.
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(200)
_, _ = io.WriteString(w, `{"got_auth":"`+r.Header.Get("Authorization")+`"}`)
}))
defer srv.Close()
accts := []config.Account{{ID: "idA", Name: "a", APIKey: "sk-account-key-1234", BaseURL: srv.URL}}
h := newTestHandler(t, srv.URL, accts, 1)
rec := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/api/version", nil)
req.Header.Set("Authorization", "Bearer client-should-be-stripped")
h.ServeHTTP(rec, req)
if !strings.Contains(rec.Body.String(), "sk-account-key-1234") {
t.Errorf("body = %q, want account key forwarded", rec.Body.String())
}
if strings.Contains(rec.Body.String(), "client-should-be-stripped") {
t.Errorf("body = %q, client Authorization leaked to upstream", rec.Body.String())
}
}
func TestHandler_RetriesOn5xx(t *testing.T) {
// First account returns 500, second returns 200.
badUp := &mockUpstream{status: 500}
badSrv := httptest.NewServer(http.HandlerFunc(badUp.handler))
goodUp := &mockUpstream{status: 200, body: `{"ok":true}`}
goodSrv := httptest.NewServer(http.HandlerFunc(goodUp.handler))
defer badSrv.Close()
defer goodSrv.Close()
accts := []config.Account{
{ID: "idA", Name: "a", APIKey: "k1", BaseURL: badSrv.URL},
{ID: "idB", Name: "b", APIKey: "k2", BaseURL: goodSrv.URL},
}
h := newTestHandler(t, "https://unused.example.com", accts, 2)
rec := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/api/version", nil)
h.ServeHTTP(rec, req)
if rec.Code != 200 {
t.Fatalf("status = %d, want 200 (failover on 500)", rec.Code)
}
}
func TestIsAllowed(t *testing.T) {
good := []string{
"/api/chat", "/api/generate", "/api/tags", "/api/show", "/api/ps",
"/api/version", "/api/delete",
"/v1/chat/completions", "/v1/completions", "/v1/models",
"/v1/embeddings", "/v1/files", "/v1/files/abc-123",
}
for _, p := range good {
if !isAllowed(p) {
t.Errorf("isAllowed(%q) = false, want true", p)
}
}
bad := []string{"/admin", "/", "/api/unknown", "/v2/foo", "/etc/passwd"}
for _, p := range bad {
if isAllowed(p) {
t.Errorf("isAllowed(%q) = true, want false", p)
}
}
}

28
internal/proxy/retry.go Normal file
View file

@ -0,0 +1,28 @@
package proxy
// retry.go holds the failover helpers used by handler.go. The retry loop itself
// lives inside Handler.proxyWithFailover (see handler.go) because it needs
// tight control over the moment a response starts streaming vs. is rejected
// pre-stream. This file documents the invariants the loop must maintain.
// Streaming invariant
// ===================
// Once an upstream account has returned a 2xx status AND the proxy has started
// writing the response body to the client (a single byte flushed), the request
// is committed: we MUST NOT switch accounts for that request. Any mid-stream
// upstream error is surfaced to the client as-is (truncated response); we never
// attempt to "restart" a streamed request on a different account, because the
// client has already received partial output and a retry would duplicate it.
//
// Pre-stream failover
// -------------------
// The window in which we CAN retry on another account is exactly:
// 1. The upstream HTTP request returned an error (network, timeout, EOF
// before any response).
// 2. The upstream returned 429 (Too Many Requests) — we mark the account
// cooldown and try the next.
// 3. The upstream returned 5xx — we try the next account WITHOUT marking
// cooldown (5xx may be transient and is not necessarily a rate limit).
//
// Once copyResponse has called WriteHeader, no further retries are possible
// for this request.