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
}