ollama-proxy/internal/proxy/balancer.go
Atte149 4fe15324c8 feat: multi-provider support — Ollama Cloud + OpenCode Go
- Account.Provider field (ollama-cloud | opencode-go), backward compatible
- Model-based routing: common models served by combined pool, unique models
  routed to their provider only
- /go/v1/* path forces OpenCode Go provider (prefix stripped upstream)
- Merged /v1/models endpoint returns union of both catalogs (44 models)
- Failover: 429/402 → cooldown + failover; 5xx → retry without cooldown
- CLI: accounts add --provider flag, list shows provider column
- Body buffering: request body buffered (8 MiB cap) for failover replay
- opencode integration: unified provider 'oc' with all merged models
- 64 tests pass (unit + integration)
- Verified: glm-5 → ollama, mimo-v2.5 → go, gpt-oss:20b → ollama
2026-06-24 15:37:28 +03:00

183 lines
5.7 KiB
Go

// 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; normalise empty provider to ollama-cloud.
accts := make([]config.Account, len(accounts))
for i, a := range accounts {
if a.Provider == "" {
a.Provider = config.ProviderOllamaCloud
}
accts[i] = a
}
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.
//
// Next considers all accounts regardless of provider. Use NextFor to restrict
// to a set of eligible providers (e.g. for model-based routing).
func (b *Balancer) Next() (config.Account, error) {
return b.NextFor(nil)
}
// NextFor picks the next available account whose provider is in the eligible
// list. When eligible is nil or empty, all providers are eligible (matches
// the original Next behaviour). Accounts in cooldown are skipped.
func (b *Balancer) NextFor(eligible []config.ProviderType) (config.Account, error) {
if len(b.accounts) == 0 {
return config.Account{}, ErrNoAccounts{}
}
allowed := make(map[config.ProviderType]bool, len(eligible))
for _, p := range eligible {
allowed[p] = true
}
allAllowed := len(eligible) == 0
now := time.Now()
var earliest time.Time
for i := 0; i < len(b.accounts); i++ {
idx := int(b.rr.Add(1)) % len(b.accounts)
acct := b.accounts[idx]
// 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 !allAllowed && !allowed[acct.Provider] {
continue
}
if !b.isCooldown(acct.ID, now) {
return acct, nil
}
if earliest.IsZero() || b.cooldownUntil(acct.ID).Before(earliest) {
earliest = b.cooldownUntil(acct.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
}