ollama-proxy/internal/proxy/handler_test.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

446 lines
15 KiB
Go

package proxy
import (
"io"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/Atte149/ollama-proxy/internal/config"
"github.com/Atte149/ollama-proxy/internal/log"
)
// mockUpstream is a configurable test upstream. It responds to /api/chat and
// /v1/chat/completions; for other paths it returns 200 with a short body.
type mockUpstream struct {
status int32 // current status to return
body string
allowAuth string // if non-empty, require this Bearer token
chunks []string
requestAuth atomic.Int32 // number of requests seen with each auth value
}
func (m *mockUpstream) handler(w http.ResponseWriter, r *http.Request) {
auth := r.Header.Get("Authorization")
if m.allowAuth != "" && auth != "Bearer "+m.allowAuth {
http.Error(w, "bad auth", http.StatusUnauthorized)
return
}
if m.status >= 500 || m.status == 429 {
http.Error(w, "rate limited", int(m.status))
return
}
if len(m.chunks) > 0 {
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Transfer-Encoding", "chunked")
w.WriteHeader(200)
flusher, _ := w.(http.Flusher)
for _, c := range m.chunks {
_, _ = io.WriteString(w, c)
if flusher != nil {
flusher.Flush()
}
}
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(int(m.status))
_, _ = io.WriteString(w, m.body)
}
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"))
}
func TestHandler_ProxiesVersion(t *testing.T) {
up := &mockUpstream{status: 200, body: `{"version":"0.1.42"}`}
srv := httptest.NewServer(http.HandlerFunc(up.handler))
defer srv.Close()
h := newTestHandler(t, srv.URL, mkAccts(1), 1)
rec := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/api/version", nil)
h.ServeHTTP(rec, req)
if rec.Code != 200 {
t.Fatalf("status = %d, want 200", rec.Code)
}
if !strings.Contains(rec.Body.String(), "0.1.42") {
t.Errorf("body = %q, want version", rec.Body.String())
}
}
func TestHandler_FailoverOn429(t *testing.T) {
// Two upstreams: first always 429, second always 200.
badUp := &mockUpstream{status: 429, body: "rate limited"}
badSrv := httptest.NewServer(http.HandlerFunc(badUp.handler))
goodUp := &mockUpstream{status: 200, body: `{"ok":true}`}
goodSrv := httptest.NewServer(http.HandlerFunc(goodUp.handler))
defer badSrv.Close()
defer goodSrv.Close()
// Two accounts, each pointing at a different upstream via per-account BaseURL.
accts := []config.Account{
{ID: "idA", Name: "a", APIKey: "k1", BaseURL: badSrv.URL},
{ID: "idB", Name: "b", APIKey: "k2", BaseURL: goodSrv.URL},
}
h := newTestHandler(t, "https://unused.example.com", 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 (failover to good account)", rec.Code)
}
if !strings.Contains(rec.Body.String(), `"ok":true`) {
t.Errorf("body = %q, want good upstream response", rec.Body.String())
}
}
func TestHandler_AllAccounts429_Returns429(t *testing.T) {
up := &mockUpstream{status: 429, body: "rate limited"}
srv := httptest.NewServer(http.HandlerFunc(up.handler))
defer srv.Close()
accts := []config.Account{
{ID: "idA", Name: "a", APIKey: "k1", BaseURL: srv.URL},
{ID: "idB", Name: "b", APIKey: "k2", BaseURL: srv.URL},
}
h := newTestHandler(t, srv.URL, accts, 2)
rec := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/api/version", nil)
h.ServeHTTP(rec, req)
if rec.Code != http.StatusTooManyRequests {
t.Fatalf("status = %d, want 429", rec.Code)
}
}
func TestHandler_SSEStreaming(t *testing.T) {
up := &mockUpstream{
status: 200,
chunks: []string{"data: {\"a\":1}\n\n", "data: {\"a\":2}\n\n", "data: [DONE]\n\n"},
}
srv := httptest.NewServer(http.HandlerFunc(up.handler))
defer srv.Close()
h := newTestHandler(t, srv.URL, mkAccts(1), 1)
rec := httptest.NewRecorder()
req := httptest.NewRequest("POST", "/v1/chat/completions", strings.NewReader(`{"stream":true}`))
h.ServeHTTP(rec, req)
if rec.Code != 200 {
t.Fatalf("status = %d, want 200", rec.Code)
}
body := rec.Body.String()
if !strings.Contains(body, "[DONE]") {
t.Errorf("body = %q, want to contain [DONE]", body)
}
if !strings.Contains(body, "\"a\":1") || !strings.Contains(body, "\"a\":2") {
t.Errorf("body = %q, missing chunks", body)
}
if ct := rec.Header().Get("Content-Type"); !strings.Contains(ct, "text/event-stream") {
t.Errorf("Content-Type = %q, want text/event-stream", ct)
}
}
func TestHandler_RejectsUnknownPath(t *testing.T) {
up := &mockUpstream{status: 200, body: "x"}
srv := httptest.NewServer(http.HandlerFunc(up.handler))
defer srv.Close()
h := newTestHandler(t, srv.URL, mkAccts(1), 1)
rec := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/admin", nil)
h.ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Errorf("status = %d, want 404 for unknown path", rec.Code)
}
}
func TestHandler_NoAccounts(t *testing.T) {
h := newTestHandler(t, "http://unused.example.com", nil, 1)
rec := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/api/version", nil)
h.ServeHTTP(rec, req)
if rec.Code != http.StatusServiceUnavailable {
t.Errorf("status = %d, want 503", rec.Code)
}
}
func TestHandler_StripsClientAuthorization(t *testing.T) {
// Upstream echoes the received Authorization header in its body so we can
// assert the proxy overrode it with the account's key.
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(200)
_, _ = io.WriteString(w, `{"got_auth":"`+r.Header.Get("Authorization")+`"}`)
}))
defer srv.Close()
accts := []config.Account{{ID: "idA", Name: "a", APIKey: "sk-account-key-1234", BaseURL: srv.URL}}
h := newTestHandler(t, srv.URL, accts, 1)
rec := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/api/version", nil)
req.Header.Set("Authorization", "Bearer client-should-be-stripped")
h.ServeHTTP(rec, req)
if !strings.Contains(rec.Body.String(), "sk-account-key-1234") {
t.Errorf("body = %q, want account key forwarded", rec.Body.String())
}
if strings.Contains(rec.Body.String(), "client-should-be-stripped") {
t.Errorf("body = %q, client Authorization leaked to upstream", rec.Body.String())
}
}
func TestHandler_RetriesOn5xx(t *testing.T) {
// First account returns 500, second returns 200.
badUp := &mockUpstream{status: 500}
badSrv := httptest.NewServer(http.HandlerFunc(badUp.handler))
goodUp := &mockUpstream{status: 200, body: `{"ok":true}`}
goodSrv := httptest.NewServer(http.HandlerFunc(goodUp.handler))
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, "https://unused.example.com", 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 (failover on 500)", rec.Code)
}
}
func TestIsAllowed(t *testing.T) {
good := []string{
"/api/chat", "/api/generate", "/api/tags", "/api/show", "/api/ps",
"/api/version", "/api/delete",
"/v1/chat/completions", "/v1/completions", "/v1/models",
"/v1/embeddings", "/v1/files", "/v1/files/abc-123",
}
for _, p := range good {
if !isAllowed(p) {
t.Errorf("isAllowed(%q) = false, want true", p)
}
}
bad := []string{"/admin", "/", "/api/unknown", "/v2/foo", "/etc/passwd"}
for _, p := range bad {
if isAllowed(p) {
t.Errorf("isAllowed(%q) = true, want false", p)
}
}
}
// 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)
}
}