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:
parent
98bbc96bf5
commit
4fe15324c8
11 changed files with 973 additions and 125 deletions
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue