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
|
|
@ -53,6 +53,12 @@ func (m *mockUpstream) handler(w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
func newTestHandler(t *testing.T, upstreamURL string, accounts []config.Account, retries int) *Handler {
|
||||
t.Helper()
|
||||
// Apply the mock upstream URL to any account without an explicit BaseURL.
|
||||
for i := range accounts {
|
||||
if accounts[i].BaseURL == "" {
|
||||
accounts[i].BaseURL = upstreamURL
|
||||
}
|
||||
}
|
||||
b := NewBalancer(accounts, 50*time.Millisecond)
|
||||
return NewHandler(b, upstreamURL, retries, log.New("debug"))
|
||||
}
|
||||
|
|
@ -243,3 +249,198 @@ func TestIsAllowed(t *testing.T) {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandler_GoPathRoutesToGoProvider verifies /go/v1/* forces the
|
||||
// opencode-go provider and strips the /go prefix before forwarding upstream.
|
||||
func TestHandler_GoPathRoutesToGoProvider(t *testing.T) {
|
||||
var seenPath, seenAuth string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
seenPath = r.URL.Path
|
||||
seenAuth = r.Header.Get("Authorization")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(200)
|
||||
_, _ = io.WriteString(w, `{"ok":true}`)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
accts := []config.Account{
|
||||
{ID: "idO", Name: "o", APIKey: "kO", Provider: config.ProviderOllamaCloud, BaseURL: "http://unused-ollama"},
|
||||
{ID: "idG", Name: "g", APIKey: "kG", Provider: config.ProviderOpenCodeGo, BaseURL: srv.URL},
|
||||
}
|
||||
h := newTestHandler(t, "http://unused", accts, 2)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", "/go/v1/models", nil)
|
||||
h.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("status = %d, want 200", rec.Code)
|
||||
}
|
||||
if seenPath != "/v1/models" {
|
||||
t.Errorf("upstream path = %q, want /v1/models", seenPath)
|
||||
}
|
||||
if seenAuth != "Bearer kG" {
|
||||
t.Errorf("upstream auth = %q, want go account key", seenAuth)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandler_ModelBasedRouting verifies a common model can be served by
|
||||
// either provider and a unique Go model routes only to Go accounts.
|
||||
func TestHandler_ModelBasedRouting(t *testing.T) {
|
||||
ollamaSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(200)
|
||||
_, _ = io.WriteString(w, `{"from":"ollama"}`)
|
||||
}))
|
||||
goSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(200)
|
||||
_, _ = io.WriteString(w, `{"from":"go"}`)
|
||||
}))
|
||||
defer ollamaSrv.Close()
|
||||
defer goSrv.Close()
|
||||
|
||||
accts := []config.Account{
|
||||
{ID: "idO", Name: "o", APIKey: "kO", Provider: config.ProviderOllamaCloud, BaseURL: ollamaSrv.URL},
|
||||
{ID: "idG", Name: "g", APIKey: "kG", Provider: config.ProviderOpenCodeGo, BaseURL: goSrv.URL},
|
||||
}
|
||||
h := newTestHandler(t, "http://unused", accts, 2)
|
||||
|
||||
// Unique Go model must route to the Go upstream.
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("POST", "/v1/chat/completions",
|
||||
strings.NewReader(`{"model":"mimo-v2.5","messages":[]}`))
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("mimo status = %d", rec.Code)
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), `"from":"go"`) {
|
||||
t.Errorf("mimo routed to wrong upstream: %s", rec.Body.String())
|
||||
}
|
||||
|
||||
// Unique Ollama model must route to the Ollama upstream.
|
||||
rec = httptest.NewRecorder()
|
||||
req = httptest.NewRequest("POST", "/v1/chat/completions",
|
||||
strings.NewReader(`{"model":"gpt-oss:20b","messages":[]}`))
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("gpt-oss status = %d", rec.Code)
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), `"from":"ollama"`) {
|
||||
t.Errorf("gpt-oss routed to wrong upstream: %s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandler_MergedModels verifies /v1/models returns the union of both
|
||||
// catalogs with raw model IDs (no labels in the id field).
|
||||
func TestHandler_MergedModels(t *testing.T) {
|
||||
accts := []config.Account{
|
||||
{ID: "idO", Name: "o", APIKey: "kO", Provider: config.ProviderOllamaCloud, BaseURL: "http://unused"},
|
||||
{ID: "idG", Name: "g", APIKey: "kG", Provider: config.ProviderOpenCodeGo, BaseURL: "http://unused"},
|
||||
}
|
||||
h := newTestHandler(t, "http://unused", accts, 2)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", "/v1/models", nil)
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("status = %d", rec.Code)
|
||||
}
|
||||
body := rec.Body.String()
|
||||
// Common model appears with raw ID (no label).
|
||||
if !strings.Contains(body, `"id":"glm-5"`) {
|
||||
t.Errorf("common model glm-5 missing from merged list")
|
||||
}
|
||||
// Unique Ollama model appears with raw ID (no label in id).
|
||||
if !strings.Contains(body, `"id":"gpt-oss:20b"`) {
|
||||
t.Errorf("unique ollama model missing: %s", body)
|
||||
}
|
||||
// Unique Go model appears with raw ID.
|
||||
if !strings.Contains(body, `"id":"mimo-v2.5"`) {
|
||||
t.Errorf("unique go model missing: %s", body)
|
||||
}
|
||||
// No labels should appear in any id field.
|
||||
if strings.Contains(body, `(ollama)`) || strings.Contains(body, `(go)`) {
|
||||
t.Errorf("labels should not appear in id field: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandler_402UsageLimitTriggersFailover verifies 402 is treated as
|
||||
// retriable and triggers failover to the next account.
|
||||
func TestHandler_402UsageLimitTriggersFailover(t *testing.T) {
|
||||
badSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(402)
|
||||
_, _ = io.WriteString(w, `{"error":{"message":"usage limit exhausted"}}`)
|
||||
}))
|
||||
goodSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(200)
|
||||
_, _ = io.WriteString(w, `{"ok":true}`)
|
||||
}))
|
||||
defer badSrv.Close()
|
||||
defer goodSrv.Close()
|
||||
|
||||
accts := []config.Account{
|
||||
{ID: "idA", Name: "a", APIKey: "k1", BaseURL: badSrv.URL},
|
||||
{ID: "idB", Name: "b", APIKey: "k2", BaseURL: goodSrv.URL},
|
||||
}
|
||||
h := newTestHandler(t, "http://unused", accts, 2)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", "/api/version", nil)
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("status = %d, want 200 after 402 failover", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIsRetriable and TestShouldCooldown cover the retry classification.
|
||||
func TestIsRetriable(t *testing.T) {
|
||||
retriable := []int{429, 402, 500, 502, 503, 408}
|
||||
for _, s := range retriable {
|
||||
if !isRetriable(s) {
|
||||
t.Errorf("isRetriable(%d) = false, want true", s)
|
||||
}
|
||||
}
|
||||
nonRetriable := []int{200, 400, 401, 403, 404, 422}
|
||||
for _, s := range nonRetriable {
|
||||
if isRetriable(s) {
|
||||
t.Errorf("isRetriable(%d) = true, want false", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldCooldown(t *testing.T) {
|
||||
if !shouldCooldown(429, "rate limit") {
|
||||
t.Error("429 should cooldown")
|
||||
}
|
||||
if !shouldCooldown(402, "usage limit") {
|
||||
t.Error("402 should cooldown")
|
||||
}
|
||||
if shouldCooldown(500, "internal error") {
|
||||
t.Error("500 should NOT cooldown")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsBillingDisable(t *testing.T) {
|
||||
if !isBillingDisable("Insufficient credits for this account") {
|
||||
t.Error("insufficient credits should be billing disable")
|
||||
}
|
||||
if !isBillingDisable("credit balance too low") {
|
||||
t.Error("credit balance should be billing disable")
|
||||
}
|
||||
if isBillingDisable("usage limit exhausted") {
|
||||
t.Error("usage limit exhausted should NOT be billing disable")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoutePath(t *testing.T) {
|
||||
up, prov, isGo := routePath("/go/v1/models")
|
||||
if up != "/v1/models" || prov != config.ProviderOpenCodeGo || !isGo {
|
||||
t.Errorf("routePath(/go/v1/models) = %q,%v,%v", up, prov, isGo)
|
||||
}
|
||||
up, prov, isGo = routePath("/v1/models")
|
||||
if up != "/v1/models" || prov != "" || isGo {
|
||||
t.Errorf("routePath(/v1/models) = %q,%v,%v", up, prov, isGo)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue