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:
commit
3963eede70
23 changed files with 2534 additions and 0 deletions
158
internal/proxy/balancer.go
Normal file
158
internal/proxy/balancer.go
Normal 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue