blob: 5667629d171a003975f62bd2ba3bf2bfcfb89b4c [file]
// Copyright 2023 The casbin Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package controllers
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/apache/casbin-gateway/object"
"github.com/apache/casbin-gateway/protocol"
beegoContext "github.com/beego/beego/context"
)
// newProxyRoute is the route one request would be relayed on, as readProxyRoute
// builds it.
func newProxyRoute(target proxyTarget, body []byte, stream bool) *proxyRoute {
var fields routingFields
_ = json.Unmarshal(body, &fields)
return &proxyRoute{
target: target, codec: protocol.Of(target.protocol),
body: body, model: fields.Model, stream: stream,
}
}
func newTestApiController() (*ApiController, *httptest.ResponseRecorder) {
recorder := httptest.NewRecorder()
ctx := beegoContext.NewContext()
ctx.Reset(recorder, httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader("{}")))
c := &ApiController{}
c.Ctx = ctx
return c, recorder
}
func TestIsEventStream(t *testing.T) {
cases := []struct {
statusCode int
contentType string
expected bool
}{
{200, "text/event-stream", true},
{200, "text/event-stream; charset=utf-8", true},
{200, "application/json", false},
// An upstream that rejects the request answers with JSON even when
// stream=true was asked for.
{429, "application/json", false},
{500, "text/event-stream", false},
}
for _, tc := range cases {
resp := &http.Response{StatusCode: tc.statusCode, Header: http.Header{"Content-Type": {tc.contentType}}}
if got := isEventStream(resp); got != tc.expected {
t.Errorf("isEventStream(%d, %s) = %v, expected %v", tc.statusCode, tc.contentType, got, tc.expected)
}
}
}
func TestIsRetryableStatus(t *testing.T) {
for _, statusCode := range []int{429, 500, 502, 503} {
if !isRetryableStatus(statusCode) {
t.Errorf("status %d should be retryable", statusCode)
}
}
for _, statusCode := range []int{200, 400, 401, 404} {
if isRetryableStatus(statusCode) {
t.Errorf("status %d should not be retryable", statusCode)
}
}
}
func TestProviderUnusableReason(t *testing.T) {
c, _ := newTestApiController()
providerUnusableReason := c.providerUnusableReason
if reason := providerUnusableReason(&object.Provider{Type: "claude", BaseUrl: "https://example.com"}); !strings.Contains(reason, "not supported") {
t.Errorf("the claude provider type should be rejected, got: %s", reason)
}
if reason := providerUnusableReason(&object.Provider{Type: "openai", BaseUrl: ""}); !strings.Contains(reason, "base URL") {
t.Errorf("an empty base URL should be rejected, got: %s", reason)
}
if reason := providerUnusableReason(&object.Provider{Type: "custom", BaseUrl: "https://example.com"}); reason != "" {
t.Errorf("the custom provider type should be usable, got: %s", reason)
}
// The wire format is no longer a reason: a request that arrived in the other
// one is translated for the provider.
other := &object.Provider{Owner: "admin", Name: "claude", Type: "anthropic", BaseUrl: "https://api.anthropic.com"}
if reason := providerUnusableReason(other); reason != "" {
t.Errorf("an anthropic provider should be usable, got: %s", reason)
}
passthrough := &object.Provider{
Owner: "admin",
Name: "passthrough",
Type: "openai",
BaseUrl: "https://api.openai.com/v1",
AuthMode: object.ProviderAuthClient,
}
if reason := providerUnusableReason(passthrough); !strings.Contains(reason, "carries none") {
t.Errorf("a client-auth provider should be rejected without a credential, got: %s", reason)
}
c.Ctx.Request.Header.Set("Authorization", "Bearer token")
if reason := providerUnusableReason(passthrough); reason != "" {
t.Errorf("a client-auth provider should be usable with a credential, got: %s", reason)
}
}
func TestRelayResponse(t *testing.T) {
c, recorder := newTestApiController()
upstreamResp := &http.Response{
StatusCode: 429,
Header: http.Header{
"Content-Type": {"application/json"},
"Connection": {"keep-alive"},
"X-Request-Id": {"req-123"},
"X-Ratelimit-Remaining": {"0"},
},
}
c.relayVerbatim(upstreamResp, strings.NewReader(`{"error":{"message":"slow down"}}`), false)
if recorder.Code != 429 {
t.Errorf("status code = %d, expected 429", recorder.Code)
}
if header := recorder.Header().Get("Connection"); header != "" {
t.Errorf("the hop-by-hop Connection header was relayed: %s", header)
}
if header := recorder.Header().Get("X-Request-Id"); header != "req-123" {
t.Errorf("X-Request-Id = %s, expected req-123", header)
}
if header := recorder.Header().Get("X-Ratelimit-Remaining"); header != "0" {
t.Errorf("X-Ratelimit-Remaining = %s, expected 0", header)
}
if body := recorder.Body.String(); !strings.Contains(body, "slow down") {
t.Errorf("body = %s", body)
}
}
func TestRelayResponseStream(t *testing.T) {
c, recorder := newTestApiController()
upstreamResp := &http.Response{
StatusCode: 200,
Header: http.Header{
"Content-Type": {"text/event-stream"},
"X-Request-Id": {"req-abc"},
"Connection": {"keep-alive"},
},
}
c.relayVerbatim(upstreamResp, strings.NewReader("data: a\n\ndata: [DONE]\n\n"), true)
if header := recorder.Header().Get("Content-Type"); header != "text/event-stream" {
t.Errorf("Content-Type = %s, expected text/event-stream", header)
}
if header := recorder.Header().Get("Cache-Control"); header != "no-cache" {
t.Errorf("Cache-Control = %s, expected no-cache", header)
}
if header := recorder.Header().Get("X-Request-Id"); header != "req-abc" {
t.Errorf("the upstream headers were dropped, X-Request-Id = %s", header)
}
if header := recorder.Header().Get("Connection"); header != "" {
t.Errorf("the hop-by-hop Connection header was relayed: %s", header)
}
if body := recorder.Body.String(); !strings.Contains(body, "[DONE]") {
t.Errorf("body = %s", body)
}
if !recorder.Flushed {
t.Error("the stream was not flushed")
}
}
// A stream that is slow but alive must not be cut off, no matter how long it
// lasts in total. Only a stalled upstream is aborted.
func TestIdleTimeoutReader(t *testing.T) {
idleTimeout := 150 * time.Millisecond
aborted := make(chan struct{})
reader, writer := io.Pipe()
idleReader := newIdleTimeoutReader(reader, idleTimeout, func() {
close(aborted)
_ = writer.CloseWithError(io.ErrUnexpectedEOF)
})
defer idleReader.Stop()
chunkCount := 5
go func() {
// The whole stream takes longer than the idle timeout, while every
// single gap stays below it.
for i := 0; i < chunkCount; i++ {
time.Sleep(idleTimeout * 2 / 3)
if _, err := writer.Write([]byte("data: chunk\n\n")); err != nil {
return
}
}
}()
buf := make([]byte, 64)
for i := 0; i < chunkCount; i++ {
if _, err := idleReader.Read(buf); err != nil {
t.Fatalf("reading chunk %d failed: %s", i, err.Error())
}
}
select {
case <-aborted:
t.Fatal("a slow but healthy stream was aborted")
default:
}
// Nothing is written anymore, so the idle timeout has to fire.
if _, err := idleReader.Read(buf); err == nil {
t.Fatal("the read on a stalled stream returned no error")
}
select {
case <-aborted:
default:
t.Fatal("the idle timeout did not fire on a stalled stream")
}
}
func TestForwardToProvider(t *testing.T) {
overloadedServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusServiceUnavailable)
_, _ = w.Write([]byte(`{"error":{"message":"overloaded"}}`))
}))
defer overloadedServer.Close()
healthyServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if header := r.Header.Get("Authorization"); header != "Bearer sk-good" {
t.Errorf("Authorization = %s, expected Bearer sk-good", header)
}
if r.URL.Path != "/v1/chat/completions" {
t.Errorf("upstream path = %s", r.URL.Path)
}
body, _ := io.ReadAll(r.Body)
if !strings.Contains(string(body), "gpt-4") {
t.Errorf("the request body was not forwarded as-is: %s", body)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"choices":[]}`))
}))
defer healthyServer.Close()
overloadedProvider := &object.Provider{Owner: "admin", Name: "overloaded", Type: "openai", BaseUrl: overloadedServer.URL, ApiKey: "sk-bad"}
healthyProvider := &object.Provider{Owner: "admin", Name: "healthy", Type: "openai", BaseUrl: healthyServer.URL + "/", ApiKey: "sk-good"}
rawBody := []byte(`{"model":"gpt-4","messages":[]}`)
route := newProxyRoute(openAiChat, rawBody, false)
// A retryable status fails over instead of reaching the client.
c, recorder := newTestApiController()
statusCode, message, written := c.forwardToProvider(overloadedProvider, route, false)
if written {
t.Fatal("a retryable status was relayed instead of failing over")
}
if statusCode != http.StatusBadGateway || !strings.Contains(message, "503") {
t.Errorf("statusCode = %d, message = %s", statusCode, message)
}
if recorder.Body.Len() != 0 {
t.Errorf("a body was written before failing over: %s", recorder.Body.String())
}
// The last provider is relayed as-is, even with a retryable status, so that
// the client sees the real upstream answer.
c, recorder = newTestApiController()
_, _, written = c.forwardToProvider(overloadedProvider, route, true)
if !written || recorder.Code != http.StatusServiceUnavailable || !strings.Contains(recorder.Body.String(), "overloaded") {
t.Errorf("the last provider was not relayed: written = %v, statusCode = %d, body = %s", written, recorder.Code, recorder.Body.String())
}
// A healthy provider, with a trailing slash in its base URL.
c, recorder = newTestApiController()
_, _, written = c.forwardToProvider(healthyProvider, route, true)
if !written || recorder.Code != http.StatusOK || !strings.Contains(recorder.Body.String(), "choices") {
t.Errorf("the healthy provider failed: written = %v, statusCode = %d, body = %s", written, recorder.Code, recorder.Body.String())
}
// stream=true, but the upstream rejected the request: the JSON error must
// not be dressed up as an SSE stream.
c, recorder = newTestApiController()
c.forwardToProvider(overloadedProvider, newProxyRoute(openAiChat, rawBody, true), true)
if header := recorder.Header().Get("Content-Type"); header != "application/json" {
t.Errorf("Content-Type = %s, expected application/json", header)
}
}
func TestForwardToProviderAnthropic(t *testing.T) {
var gotPath, gotKey, gotVersion, gotAuth string
var gotBeta []string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
gotKey = r.Header.Get("X-Api-Key")
gotVersion = r.Header.Get("Anthropic-Version")
gotBeta = r.Header.Values("Anthropic-Beta")
gotAuth = r.Header.Get("Authorization")
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"content":[]}`))
}))
defer server.Close()
provider := &object.Provider{Owner: "admin", Name: "claude", Type: "anthropic", BaseUrl: server.URL, ApiKey: "sk-ant-test"}
route := newProxyRoute(anthropicMessages, []byte(`{"model":"claude-opus-5","messages":[]}`), false)
c, recorder := newTestApiController()
c.Ctx.Request.Header.Add("Anthropic-Beta", "fine-grained-tool-streaming-2025-05-14")
if _, _, written := c.forwardToProvider(provider, route, true); !written {
t.Fatal("the anthropic provider was not relayed")
}
if recorder.Code != http.StatusOK || !strings.Contains(recorder.Body.String(), "content") {
t.Errorf("statusCode = %d, body = %s", recorder.Code, recorder.Body.String())
}
if gotPath != "/v1/messages" {
t.Errorf("upstream path = %s, expected /v1/messages", gotPath)
}
if gotKey != "sk-ant-test" {
t.Errorf("X-Api-Key = %s", gotKey)
}
if gotAuth != "" {
t.Errorf("the OpenAI Authorization header was sent to an anthropic upstream: %s", gotAuth)
}
if gotVersion != object.AnthropicVersion {
t.Errorf("Anthropic-Version = %s, expected %s", gotVersion, object.AnthropicVersion)
}
if len(gotBeta) != 1 || gotBeta[0] != "fine-grained-tool-streaming-2025-05-14" {
t.Errorf("Anthropic-Beta = %v, expected the client value to be passed on", gotBeta)
}
}
func TestWriteProxyError(t *testing.T) {
c, recorder := newTestApiController()
c.writeProxyError(protocol.Of(protocol.OpenAi), http.StatusBadRequest, "invalid_request_error", "nope")
if body := recorder.Body.String(); !strings.Contains(body, `"error":{"message":"nope"`) || strings.Contains(body, `"type":"error"`) {
t.Errorf("openai error body = %s", body)
}
c, recorder = newTestApiController()
c.writeProxyError(protocol.Of(protocol.Anthropic), http.StatusBadRequest, "invalid_request_error", "nope")
if body := recorder.Body.String(); !strings.Contains(body, `"type":"error"`) {
t.Errorf("anthropic error body = %s", body)
}
}