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

@ -11,11 +11,25 @@ import (
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))}
out[i] = config.Account{
ID: "id" + string(rune('a'+i)),
Name: "a" + string(rune('a'+i)),
APIKey: "k" + string(rune('a'+i)),
Provider: config.ProviderOllamaCloud,
}
}
return out
}
func mkMixedAccts() []config.Account {
return []config.Account{
{ID: "idO1", Name: "o1", APIKey: "k1", Provider: config.ProviderOllamaCloud},
{ID: "idO2", Name: "o2", APIKey: "k2", Provider: config.ProviderOllamaCloud},
{ID: "idG1", Name: "g1", APIKey: "k3", Provider: config.ProviderOpenCodeGo},
{ID: "idG2", Name: "g2", APIKey: "k4", Provider: config.ProviderOpenCodeGo},
}
}
func TestBalancer_Next_RoundRobin(t *testing.T) {
b := NewBalancer(mkAccts(3), 60*time.Second)
seen := make(map[string]int)
@ -158,3 +172,67 @@ func TestBalancer_CooldownExpires(t *testing.T) {
t.Errorf("Next after cooldown expiry: %v", err)
}
}
func TestBalancer_NextFor_FiltersByProvider(t *testing.T) {
accts := mkMixedAccts()
b := NewBalancer(accts, 60*time.Second)
// Request only OpenCode Go accounts: every result must be a Go account.
seen := map[string]int{}
for i := 0; i < 8; i++ {
a, err := b.NextFor([]config.ProviderType{config.ProviderOpenCodeGo})
if err != nil {
t.Fatalf("NextFor %d: %v", i, err)
}
if a.Provider != config.ProviderOpenCodeGo {
t.Errorf("NextFor returned provider %q, want opencode-go", a.Provider)
}
seen[a.ID]++
}
if len(seen) != 2 {
t.Errorf("expected 2 distinct go accounts, got %d", len(seen))
}
}
func TestBalancer_NextFor_MixedPool(t *testing.T) {
accts := mkMixedAccts()
b := NewBalancer(accts, 60*time.Second)
// Eligible = both providers: all 4 accounts should be reachable.
seen := map[string]bool{}
for i := 0; i < 16; i++ {
a, err := b.NextFor([]config.ProviderType{
config.ProviderOllamaCloud, config.ProviderOpenCodeGo,
})
if err != nil {
t.Fatalf("NextFor %d: %v", i, err)
}
seen[a.ID] = true
}
if len(seen) != 4 {
t.Errorf("expected 4 distinct accounts in mixed pool, got %d", len(seen))
}
}
func TestBalancer_NextFor_NilEligibleReturnsAll(t *testing.T) {
accts := mkMixedAccts()
b := NewBalancer(accts, 60*time.Second)
seen := map[config.ProviderType]bool{}
for i := 0; i < 16; i++ {
a, err := b.NextFor(nil)
if err != nil {
t.Fatalf("NextFor nil %d: %v", i, err)
}
seen[a.Provider] = true
}
if !seen[config.ProviderOllamaCloud] || !seen[config.ProviderOpenCodeGo] {
t.Errorf("nil eligible should include both providers, got %v", seen)
}
}
func TestBalancer_NextFor_NoEligibleAccounts(t *testing.T) {
// Only Ollama accounts configured, but requesting Go.
b := NewBalancer(mkAccts(2), 60*time.Second)
_, err := b.NextFor([]config.ProviderType{config.ProviderOpenCodeGo})
if _, ok := err.(ErrAllCooldown); !ok && err == nil {
t.Errorf("NextFor with no eligible accounts: %v, want error or ErrAllCooldown", err)
}
}