Align the binary, config, provider, proxy, tests, and docs with the OpenCode LocalProxy CLI rename so user-facing guidance and runtime identifiers stay consistent.
238 lines
7.0 KiB
Go
238 lines
7.0 KiB
Go
package proxy
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/anomalyco/opencode-provider-switch/internal/config"
|
|
)
|
|
|
|
func TestHandleResponsesWritesOpenAIErrorForMissingAlias(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
srv := New(&config.Config{
|
|
Server: config.Server{APIKey: "olpx-local"},
|
|
})
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{"model":"missing","stream":true}`))
|
|
req.Header.Set("Authorization", "Bearer olpx-local")
|
|
req.Header.Set("Content-Type", "application/json")
|
|
rr := httptest.NewRecorder()
|
|
|
|
srv.handleResponses(rr, req)
|
|
|
|
if rr.Code != http.StatusNotFound {
|
|
t.Fatalf("status = %d, want %d", rr.Code, http.StatusNotFound)
|
|
}
|
|
assertOpenAIError(t, rr.Body.Bytes(), "model_not_found", "invalid_request_error", `alias "missing" not found`)
|
|
}
|
|
|
|
func TestHandleResponsesFailsOverOn429(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
var firstSeenModel string
|
|
first := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
defer r.Body.Close()
|
|
var payload map[string]any
|
|
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
|
t.Fatalf("decode first payload: %v", err)
|
|
}
|
|
firstSeenModel, _ = payload["model"].(string)
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusTooManyRequests)
|
|
_, _ = w.Write([]byte(`{"error":{"message":"rate limit"}}`))
|
|
}))
|
|
defer first.Close()
|
|
|
|
var secondSeenModel string
|
|
second := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
defer r.Body.Close()
|
|
var payload map[string]any
|
|
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
|
t.Fatalf("decode second payload: %v", err)
|
|
}
|
|
secondSeenModel, _ = payload["model"].(string)
|
|
w.Header().Set("Content-Type", "text/event-stream")
|
|
_, _ = w.Write([]byte("data: ok\n\n"))
|
|
}))
|
|
defer second.Close()
|
|
|
|
srv := New(&config.Config{
|
|
Server: config.Server{APIKey: "olpx-local"},
|
|
Providers: []config.Provider{
|
|
{ID: "p1", BaseURL: first.URL + "/v1", APIKey: "sk-1"},
|
|
{ID: "p2", BaseURL: second.URL + "/v1", APIKey: "sk-2"},
|
|
},
|
|
Aliases: []config.Alias{{
|
|
Alias: "gpt-5.4",
|
|
Enabled: true,
|
|
Targets: []config.Target{{Provider: "p1", Model: "up-1", Enabled: true}, {Provider: "p2", Model: "up-2", Enabled: true}},
|
|
}},
|
|
})
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{"model":"olpx/gpt-5.4","stream":true}`))
|
|
req.Header.Set("Authorization", "Bearer olpx-local")
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("Accept", "text/event-stream")
|
|
rr := httptest.NewRecorder()
|
|
|
|
srv.handleResponses(rr, req)
|
|
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want %d", rr.Code, http.StatusOK)
|
|
}
|
|
if body := rr.Body.String(); body != "data: ok\n\n" {
|
|
t.Fatalf("body = %q, want SSE payload", body)
|
|
}
|
|
if firstSeenModel != "up-1" {
|
|
t.Fatalf("first upstream model = %q, want up-1", firstSeenModel)
|
|
}
|
|
if secondSeenModel != "up-2" {
|
|
t.Fatalf("second upstream model = %q, want up-2", secondSeenModel)
|
|
}
|
|
if got := rr.Header().Get("X-OLPX-Attempt"); got != "2" {
|
|
t.Fatalf("X-OLPX-Attempt = %q, want 2", got)
|
|
}
|
|
if got := rr.Header().Get("X-OLPX-Failover-Count"); got != "1" {
|
|
t.Fatalf("X-OLPX-Failover-Count = %q, want 1", got)
|
|
}
|
|
if got := rr.Header().Get("X-OLPX-Provider"); got != "p2" {
|
|
t.Fatalf("X-OLPX-Provider = %q, want p2", got)
|
|
}
|
|
}
|
|
|
|
func TestHandleResponsesDoesNotFailOverOn400(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
calledSecond := false
|
|
first := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
_, _ = w.Write([]byte(`{"error":{"message":"bad request"}}`))
|
|
}))
|
|
defer first.Close()
|
|
second := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
calledSecond = true
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
defer second.Close()
|
|
|
|
srv := New(&config.Config{
|
|
Server: config.Server{APIKey: "olpx-local"},
|
|
Providers: []config.Provider{
|
|
{ID: "p1", BaseURL: first.URL + "/v1"},
|
|
{ID: "p2", BaseURL: second.URL + "/v1"},
|
|
},
|
|
Aliases: []config.Alias{{
|
|
Alias: "gpt-5.4",
|
|
Enabled: true,
|
|
Targets: []config.Target{{Provider: "p1", Model: "up-1", Enabled: true}, {Provider: "p2", Model: "up-2", Enabled: true}},
|
|
}},
|
|
})
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{"model":"gpt-5.4","stream":true}`))
|
|
req.Header.Set("Authorization", "Bearer olpx-local")
|
|
req.Header.Set("Content-Type", "application/json")
|
|
rr := httptest.NewRecorder()
|
|
|
|
srv.handleResponses(rr, req)
|
|
|
|
if rr.Code != http.StatusBadRequest {
|
|
t.Fatalf("status = %d, want %d", rr.Code, http.StatusBadRequest)
|
|
}
|
|
if calledSecond {
|
|
t.Fatal("second upstream should not be called for 400 response")
|
|
}
|
|
if got := rr.Header().Get("X-OLPX-Provider"); got != "p1" {
|
|
t.Fatalf("X-OLPX-Provider = %q, want p1", got)
|
|
}
|
|
if body := rr.Body.String(); body != `{"error":{"message":"bad request"}}` {
|
|
t.Fatalf("body = %q", body)
|
|
}
|
|
}
|
|
|
|
func TestReadFirstChunk(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
t.Run("returns data", func(t *testing.T) {
|
|
t.Parallel()
|
|
buf, err := readFirstChunk(bytes.NewBufferString("abc"), 50*time.Millisecond)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if string(buf) != "abc" {
|
|
t.Fatalf("buf = %q, want abc", string(buf))
|
|
}
|
|
})
|
|
|
|
t.Run("returns eof", func(t *testing.T) {
|
|
t.Parallel()
|
|
buf, err := readFirstChunk(bytes.NewReader(nil), 50*time.Millisecond)
|
|
if !errors.Is(err, io.EOF) {
|
|
t.Fatalf("err = %v, want EOF", err)
|
|
}
|
|
if buf != nil {
|
|
t.Fatalf("buf = %v, want nil", buf)
|
|
}
|
|
})
|
|
|
|
t.Run("returns data with eof", func(t *testing.T) {
|
|
t.Parallel()
|
|
buf, err := readFirstChunk(dataEOFReader{}, 50*time.Millisecond)
|
|
if !errors.Is(err, io.EOF) {
|
|
t.Fatalf("err = %v, want EOF", err)
|
|
}
|
|
if string(buf) != "abc" {
|
|
t.Fatalf("buf = %q, want abc", string(buf))
|
|
}
|
|
})
|
|
|
|
t.Run("times out", func(t *testing.T) {
|
|
t.Parallel()
|
|
buf, err := readFirstChunk(blockingReader{}, 20*time.Millisecond)
|
|
if !errors.Is(err, errFirstByteTimeout) {
|
|
t.Fatalf("err = %v, want errFirstByteTimeout", err)
|
|
}
|
|
if buf != nil {
|
|
t.Fatalf("buf = %v, want nil", buf)
|
|
}
|
|
})
|
|
}
|
|
|
|
type blockingReader struct{}
|
|
|
|
type dataEOFReader struct{}
|
|
|
|
func (blockingReader) Read(p []byte) (int, error) {
|
|
time.Sleep(200 * time.Millisecond)
|
|
return 0, nil
|
|
}
|
|
|
|
func (dataEOFReader) Read(p []byte) (int, error) {
|
|
copy(p, []byte("abc"))
|
|
return 3, io.EOF
|
|
}
|
|
|
|
func assertOpenAIError(t *testing.T, body []byte, wantCode, wantType, wantMessage string) {
|
|
t.Helper()
|
|
var payload openAIErrorEnvelope
|
|
if err := json.Unmarshal(body, &payload); err != nil {
|
|
t.Fatalf("unmarshal error body: %v", err)
|
|
}
|
|
if payload.Error.Code != wantCode {
|
|
t.Fatalf("error.code = %q, want %q", payload.Error.Code, wantCode)
|
|
}
|
|
if payload.Error.Type != wantType {
|
|
t.Fatalf("error.type = %q, want %q", payload.Error.Type, wantType)
|
|
}
|
|
if payload.Error.Message != wantMessage {
|
|
t.Fatalf("error.message = %q, want %q", payload.Error.Message, wantMessage)
|
|
}
|
|
}
|