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
This commit is contained in:
Atte149 2026-06-24 15:37:28 +03:00
parent 98bbc96bf5
commit 4fe15324c8
11 changed files with 973 additions and 125 deletions

View file

@ -27,9 +27,14 @@ type Balancer struct {
// 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
// copy to avoid external mutation; normalise empty provider to ollama-cloud.
accts := make([]config.Account, len(accounts))
copy(accts, 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),
@ -70,21 +75,41 @@ func (e ErrAllCooldown) Error() string {
// 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 !b.isCooldown(b.accounts[idx].ID, now) {
return b.accounts[idx], nil
if !allAllowed && !allowed[acct.Provider] {
continue
}
if earliest.IsZero() || b.cooldownUntil(b.accounts[idx].ID).Before(earliest) {
earliest = b.cooldownUntil(b.accounts[idx].ID)
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}