690 lines
22 KiB
Go
690 lines
22 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestURLBuilding(t *testing.T) {
|
|
tests := []struct {
|
|
endpoint string
|
|
reqPath string
|
|
expected string
|
|
}{
|
|
{"https://api.openai.com/v1", "/v1/chat/completions", "https://api.openai.com/v1/chat/completions"},
|
|
{"https://api.openai.com/v1/", "/v1/chat/completions", "https://api.openai.com/v1/chat/completions"},
|
|
{"https://api.openai.com", "/v1/chat/completions", "https://api.openai.com/v1/chat/completions"},
|
|
{"http://localhost:8000/api/v1", "/v1/chat/completions", "http://localhost:8000/api/v1/chat/completions"},
|
|
{"http://localhost:11434", "/v1/chat/completions", "http://localhost:11434/v1/chat/completions"},
|
|
{"https://custom.endpoint/v1/custom", "/v1/chat/completions", "https://custom.endpoint/v1/custom/v1/chat/completions"},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.endpoint+"+"+tc.reqPath, func(t *testing.T) {
|
|
got := buildURL(tc.endpoint, tc.reqPath)
|
|
if got != tc.expected {
|
|
t.Errorf("buildURL(%q, %q) = %q; want %q", tc.endpoint, tc.reqPath, got, tc.expected)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestTrialOrdering(t *testing.T) {
|
|
configs := []ModelConfig{
|
|
{Model: "gpt-4", Key: "key-a1", Endpoint: "ep-a"},
|
|
{Model: "gpt-4", Key: "key-a2", Endpoint: "ep-a"},
|
|
{Model: "gpt-3.5", Key: "key-b1", Endpoint: "ep-b"},
|
|
{Model: "claude", Key: "key-c1", Endpoint: "ep-c"},
|
|
}
|
|
uniqueModels := []string{"gpt-4", "gpt-3.5", "claude"}
|
|
|
|
// Test case 1: Requested model matches the first model "gpt-4"
|
|
{
|
|
trials := getTrialConfigs(configs, uniqueModels, "gpt-4")
|
|
expected := []ModelConfig{
|
|
{Model: "gpt-4", Key: "key-a1", Endpoint: "ep-a"},
|
|
{Model: "gpt-4", Key: "key-a2", Endpoint: "ep-a"},
|
|
{Model: "gpt-3.5", Key: "key-b1", Endpoint: "ep-b"},
|
|
{Model: "claude", Key: "key-c1", Endpoint: "ep-c"},
|
|
}
|
|
if !reflect.DeepEqual(trials, expected) {
|
|
t.Errorf("Trial ordering for gpt-4 mismatch.\nGot: %+v\nWant: %+v", trials, expected)
|
|
}
|
|
}
|
|
|
|
// Test case 2: Requested model matches the second model "gpt-3.5"
|
|
{
|
|
trials := getTrialConfigs(configs, uniqueModels, "gpt-3.5")
|
|
expected := []ModelConfig{
|
|
{Model: "gpt-3.5", Key: "key-b1", Endpoint: "ep-b"},
|
|
{Model: "claude", Key: "key-c1", Endpoint: "ep-c"},
|
|
{Model: "gpt-4", Key: "key-a1", Endpoint: "ep-a"},
|
|
{Model: "gpt-4", Key: "key-a2", Endpoint: "ep-a"},
|
|
}
|
|
if !reflect.DeepEqual(trials, expected) {
|
|
t.Errorf("Trial ordering for gpt-3.5 mismatch.\nGot: %+v\nWant: %+v", trials, expected)
|
|
}
|
|
}
|
|
|
|
// Test case 3: Requested model matches the last model "claude"
|
|
{
|
|
trials := getTrialConfigs(configs, uniqueModels, "claude")
|
|
expected := []ModelConfig{
|
|
{Model: "claude", Key: "key-c1", Endpoint: "ep-c"},
|
|
{Model: "gpt-4", Key: "key-a1", Endpoint: "ep-a"},
|
|
{Model: "gpt-4", Key: "key-a2", Endpoint: "ep-a"},
|
|
{Model: "gpt-3.5", Key: "key-b1", Endpoint: "ep-b"},
|
|
}
|
|
if !reflect.DeepEqual(trials, expected) {
|
|
t.Errorf("Trial ordering for claude mismatch.\nGot: %+v\nWant: %+v", trials, expected)
|
|
}
|
|
}
|
|
|
|
// Test case 4: Requested model doesn't match any config
|
|
{
|
|
trials := getTrialConfigs(configs, uniqueModels, "non-existent")
|
|
expected := []ModelConfig{
|
|
{Model: "gpt-4", Key: "key-a1", Endpoint: "ep-a"},
|
|
{Model: "gpt-4", Key: "key-a2", Endpoint: "ep-a"},
|
|
{Model: "gpt-3.5", Key: "key-b1", Endpoint: "ep-b"},
|
|
{Model: "claude", Key: "key-c1", Endpoint: "ep-c"},
|
|
}
|
|
if !reflect.DeepEqual(trials, expected) {
|
|
t.Errorf("Trial ordering for non-existent model mismatch.\nGot: %+v\nWant: %+v", trials, expected)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestConfigManagerCSVWatcher(t *testing.T) {
|
|
tmpDir, err := os.MkdirTemp("", "dynagate-test")
|
|
if err != nil {
|
|
t.Fatalf("failed to create temp dir: %v", err)
|
|
}
|
|
defer os.RemoveAll(tmpDir)
|
|
|
|
csvFile := filepath.Join(tmpDir, "models.csv")
|
|
|
|
// Write initial content
|
|
initialContent := `model,key,endpoint
|
|
gpt-4,key-1,http://localhost:8001
|
|
gpt-3.5,key-2,http://localhost:8002
|
|
`
|
|
if err := os.WriteFile(csvFile, []byte(initialContent), 0644); err != nil {
|
|
t.Fatalf("failed to write csv file: %v", err)
|
|
}
|
|
|
|
cm := NewConfigManager(csvFile)
|
|
configs := cm.GetConfigs()
|
|
if len(configs) != 2 || configs[0].Model != "gpt-4" || configs[1].Model != "gpt-3.5" {
|
|
t.Fatalf("unexpected initial configs: %+v", configs)
|
|
}
|
|
|
|
// Start watching in background
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
go cm.Watch(ctx)
|
|
|
|
// Wait briefly, modify file, and check if reload occurs
|
|
time.Sleep(100 * time.Millisecond)
|
|
|
|
updatedContent := `model,key,endpoint
|
|
gpt-4,key-1,http://localhost:8001
|
|
gpt-3.5,key-2,http://localhost:8002
|
|
claude,key-3,http://localhost:8003
|
|
`
|
|
// Make sure we sleep slightly to ensure file modtime actually changes on modern filesystems
|
|
time.Sleep(1 * time.Second)
|
|
if err := os.WriteFile(csvFile, []byte(updatedContent), 0644); err != nil {
|
|
t.Fatalf("failed to update csv file: %v", err)
|
|
}
|
|
|
|
// Poll GetConfigs until it gets updated (timeout after 3 seconds)
|
|
deadline := time.Now().Add(3 * time.Second)
|
|
var updatedConfigs []ModelConfig
|
|
for time.Now().Before(deadline) {
|
|
updatedConfigs = cm.GetConfigs()
|
|
if len(updatedConfigs) == 3 {
|
|
break
|
|
}
|
|
time.Sleep(100 * time.Millisecond)
|
|
}
|
|
|
|
if len(updatedConfigs) != 3 || updatedConfigs[2].Model != "claude" {
|
|
t.Errorf("Hot-reload failed. Got configs: %+v", updatedConfigs)
|
|
}
|
|
}
|
|
|
|
func TestGatewayCompletionsFailover(t *testing.T) {
|
|
// We will setup 3 mock upstream servers.
|
|
// Server A: Always returns 500
|
|
// Server B: Always returns 401
|
|
// Server C: Succeeds and returns a chat completion response
|
|
|
|
serverA := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
_, _ = w.Write([]byte(`{"error": "Internal Server Error A"}`))
|
|
}))
|
|
defer serverA.Close()
|
|
|
|
serverB := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
_, _ = w.Write([]byte(`{"error": "Unauthorized B"}`))
|
|
}))
|
|
defer serverB.Close()
|
|
|
|
serverC := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
// Read request body to verify model was modified
|
|
var body map[string]any
|
|
_ = json.NewDecoder(r.Body).Decode(&body)
|
|
modelSent := body["model"].(string)
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte(fmt.Sprintf(`{"choices":[{"message":{"content":"Hello from %s"}}]}`, modelSent)))
|
|
}))
|
|
defer serverC.Close()
|
|
|
|
// Configure gateway ConfigManager with endpoints pointing to these servers
|
|
// We want to verify that when we call completions:
|
|
// - First it tries model-1 (pointing to serverA) -> fails
|
|
// - Then tries model-1 next key (pointing to serverB) -> fails
|
|
// - Then retries with next model model-2 (pointing to serverC) -> succeeds!
|
|
configs := []ModelConfig{
|
|
{Model: "model-1", Key: "key-fail-1", Endpoint: serverA.URL},
|
|
{Model: "model-1", Key: "key-fail-2", Endpoint: serverB.URL},
|
|
{Model: "model-2", Key: "key-success", Endpoint: serverC.URL},
|
|
}
|
|
|
|
cm := &ConfigManager{
|
|
configs: configs,
|
|
uniqueModels: []string{"model-1", "model-2"},
|
|
}
|
|
|
|
handler := handleChatCompletions(cm, "")
|
|
|
|
// 1. Test non-streaming failover request starting with model-1
|
|
reqBodyObj := map[string]any{
|
|
"model": "model-1",
|
|
"messages": []map[string]string{
|
|
{"role": "user", "content": "Hi"},
|
|
},
|
|
}
|
|
reqBytes, _ := json.Marshal(reqBodyObj)
|
|
|
|
req := httptest.NewRequest("POST", "/v1/chat/completions", bytes.NewReader(reqBytes))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
w := httptest.NewRecorder()
|
|
|
|
handler.ServeHTTP(w, req)
|
|
|
|
resp := w.Result()
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("Expected status 200, got %d", resp.StatusCode)
|
|
}
|
|
|
|
var respObj map[string]any
|
|
_ = json.NewDecoder(resp.Body).Decode(&respObj)
|
|
|
|
choices, ok := respObj["choices"].([]any)
|
|
if !ok || len(choices) == 0 {
|
|
t.Fatalf("Invalid response structure: %+v", respObj)
|
|
}
|
|
|
|
content := choices[0].(map[string]any)["message"].(map[string]any)["content"].(string)
|
|
expectedContent := "Hello from model-2"
|
|
if content != expectedContent {
|
|
t.Errorf("Expected content %q, got %q (failover to model-2 did not happen or model was not updated)", expectedContent, content)
|
|
}
|
|
}
|
|
|
|
func TestGatewayCompletionsStreamingFailover(t *testing.T) {
|
|
// We will setup 2 mock upstream servers.
|
|
// Server A: Always returns 500
|
|
// Server B: Streams chat completions chunks
|
|
|
|
serverA := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
}))
|
|
defer serverA.Close()
|
|
|
|
serverB := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "text/event-stream")
|
|
w.Header().Set("Cache-Control", "no-cache")
|
|
w.Header().Set("Connection", "keep-alive")
|
|
w.WriteHeader(http.StatusOK)
|
|
|
|
flusher, _ := w.(http.Flusher)
|
|
_, _ = w.Write([]byte("data: chunk1\n\n"))
|
|
flusher.Flush()
|
|
_, _ = w.Write([]byte("data: chunk2\n\n"))
|
|
flusher.Flush()
|
|
}))
|
|
defer serverB.Close()
|
|
|
|
configs := []ModelConfig{
|
|
{Model: "model-1", Key: "key-fail", Endpoint: serverA.URL},
|
|
{Model: "model-2", Key: "key-success", Endpoint: serverB.URL},
|
|
}
|
|
|
|
cm := &ConfigManager{
|
|
configs: configs,
|
|
uniqueModels: []string{"model-1", "model-2"},
|
|
}
|
|
|
|
handler := handleChatCompletions(cm, "")
|
|
|
|
reqBodyObj := map[string]any{
|
|
"model": "model-1",
|
|
"stream": true,
|
|
"messages": []map[string]string{
|
|
{"role": "user", "content": "Hi"},
|
|
},
|
|
}
|
|
reqBytes, _ := json.Marshal(reqBodyObj)
|
|
|
|
req := httptest.NewRequest("POST", "/v1/chat/completions", bytes.NewReader(reqBytes))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
w := httptest.NewRecorder()
|
|
|
|
handler.ServeHTTP(w, req)
|
|
|
|
resp := w.Result()
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("Expected status 200, got %d", resp.StatusCode)
|
|
}
|
|
|
|
respBytes, _ := io.ReadAll(resp.Body)
|
|
respStr := string(respBytes)
|
|
|
|
if !strings.Contains(respStr, "data: chunk1") || !strings.Contains(respStr, "data: chunk2") {
|
|
t.Errorf("Expected streaming chunks to be forwarded, got: %q", respStr)
|
|
}
|
|
}
|
|
|
|
func TestModelsEndpoint(t *testing.T) {
|
|
configs := []ModelConfig{
|
|
{Model: "model-1", Key: "key-1", Endpoint: "ep-1"},
|
|
{Model: "model-1", Key: "key-2", Endpoint: "ep-1"},
|
|
{Model: "model-2", Key: "key-3", Endpoint: "ep-2"},
|
|
}
|
|
|
|
cm := &ConfigManager{
|
|
configs: configs,
|
|
uniqueModels: []string{"model-1", "model-2"},
|
|
}
|
|
|
|
handler := handleModels(cm, "")
|
|
|
|
req := httptest.NewRequest("GET", "/v1/models", nil)
|
|
w := httptest.NewRecorder()
|
|
|
|
handler.ServeHTTP(w, req)
|
|
|
|
resp := w.Result()
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("Expected status 200, got %d", resp.StatusCode)
|
|
}
|
|
|
|
var respObj map[string]any
|
|
_ = json.NewDecoder(resp.Body).Decode(&respObj)
|
|
|
|
data, ok := respObj["data"].([]any)
|
|
if !ok || len(data) != 2 {
|
|
t.Fatalf("Expected 2 models in response data, got: %+v", respObj)
|
|
}
|
|
|
|
model1 := data[0].(map[string]any)["id"].(string)
|
|
model2 := data[1].(map[string]any)["id"].(string)
|
|
|
|
if model1 != "model-1" || model2 != "model-2" {
|
|
t.Errorf("Unexpected models. Got: %s, %s", model1, model2)
|
|
}
|
|
}
|
|
|
|
func TestGatewayAuthentication(t *testing.T) {
|
|
configs := []ModelConfig{
|
|
{Model: "model-1", Key: "key-1", Endpoint: "ep-1"},
|
|
}
|
|
cm := &ConfigManager{
|
|
configs: configs,
|
|
uniqueModels: []string{"model-1"},
|
|
}
|
|
|
|
// Set up handlers expecting "secret-token"
|
|
modelsHandler := handleModels(cm, "secret-token")
|
|
completionsHandler := handleChatCompletions(cm, "secret-token")
|
|
|
|
// Case 1: No Authorization header
|
|
{
|
|
req := httptest.NewRequest("GET", "/v1/models", nil)
|
|
w := httptest.NewRecorder()
|
|
modelsHandler.ServeHTTP(w, req)
|
|
if w.Result().StatusCode != http.StatusUnauthorized {
|
|
t.Errorf("Expected 401 Unauthorized for missing token, got %d", w.Result().StatusCode)
|
|
}
|
|
}
|
|
|
|
// Case 2: Incorrect Authorization header
|
|
{
|
|
req := httptest.NewRequest("GET", "/v1/models", nil)
|
|
req.Header.Set("Authorization", "Bearer wrong-token")
|
|
w := httptest.NewRecorder()
|
|
modelsHandler.ServeHTTP(w, req)
|
|
if w.Result().StatusCode != http.StatusUnauthorized {
|
|
t.Errorf("Expected 401 Unauthorized for wrong token, got %d", w.Result().StatusCode)
|
|
}
|
|
}
|
|
|
|
// Case 3: Correct Authorization header
|
|
{
|
|
req := httptest.NewRequest("GET", "/v1/models", nil)
|
|
req.Header.Set("Authorization", "Bearer secret-token")
|
|
w := httptest.NewRecorder()
|
|
modelsHandler.ServeHTTP(w, req)
|
|
if w.Result().StatusCode != http.StatusOK {
|
|
t.Errorf("Expected 200 OK for correct token, got %d", w.Result().StatusCode)
|
|
}
|
|
}
|
|
|
|
// Case 4: Completions endpoint with correct token
|
|
{
|
|
reqBodyObj := map[string]any{
|
|
"model": "model-1",
|
|
}
|
|
reqBytes, _ := json.Marshal(reqBodyObj)
|
|
req := httptest.NewRequest("POST", "/v1/chat/completions", bytes.NewReader(reqBytes))
|
|
req.Header.Set("Authorization", "Bearer secret-token")
|
|
req.Header.Set("Content-Type", "application/json")
|
|
w := httptest.NewRecorder()
|
|
completionsHandler.ServeHTTP(w, req)
|
|
// It should proceed past auth and return 502/503/500 because "ep-1" is not a valid endpoint,
|
|
// but critically, it should NOT return 401.
|
|
status := w.Result().StatusCode
|
|
if status == http.StatusUnauthorized {
|
|
t.Errorf("Expected completions request to pass authentication, but got 401 Unauthorized")
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCSVUpdater(t *testing.T) {
|
|
tmpDir, err := os.MkdirTemp("", "dynagate-updater-test")
|
|
if err != nil {
|
|
t.Fatalf("failed to create temp dir: %v", err)
|
|
}
|
|
defer os.RemoveAll(tmpDir)
|
|
|
|
csvFile := filepath.Join(tmpDir, "models.csv")
|
|
initialContent := "model,key,endpoint\ngpt-4,key-1,http://localhost:8001\n"
|
|
if err := os.WriteFile(csvFile, []byte(initialContent), 0644); err != nil {
|
|
t.Fatalf("failed to write csv file: %v", err)
|
|
}
|
|
|
|
// Create a command to append a model row to the CSV file
|
|
command := fmt.Sprintf("echo 'gpt-3.5,key-2,http://localhost:8002' >> %s", csvFile)
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
|
|
// Start background updater with a fast interval (100ms)
|
|
go startCSVUpdater(ctx, command, 100*time.Millisecond)
|
|
|
|
// The updater runs once immediately at startup, so we expect the file to have the appended line
|
|
// almost immediately. Let's poll to check.
|
|
deadline := time.Now().Add(2 * time.Second)
|
|
var contentBytes []byte
|
|
var containsNewModel bool
|
|
|
|
for time.Now().Before(deadline) {
|
|
contentBytes, err = os.ReadFile(csvFile)
|
|
if err == nil && strings.Contains(string(contentBytes), "gpt-3.5") {
|
|
containsNewModel = true
|
|
break
|
|
}
|
|
time.Sleep(50 * time.Millisecond)
|
|
}
|
|
|
|
if !containsNewModel {
|
|
t.Fatalf("Expected CSV file to contain the updater-appended model, but it didn't. Content: %s", string(contentBytes))
|
|
}
|
|
}
|
|
|
|
func TestGatewayImageGenerations(t *testing.T) {
|
|
// We will setup a mock upstream server that handles image generation.
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/v1/images/generations" {
|
|
w.WriteHeader(http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
var body map[string]any
|
|
_ = json.NewDecoder(r.Body).Decode(&body)
|
|
modelSent := body["model"].(string)
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte(fmt.Sprintf(`{"created":1589478378,"data":[{"url":"http://image-url/generated-by-%s"}]}`, modelSent)))
|
|
}))
|
|
defer server.Close()
|
|
|
|
configs := []ModelConfig{
|
|
{Model: "dall-e-3", Key: "dalle-key", Endpoint: server.URL},
|
|
}
|
|
|
|
cm := &ConfigManager{
|
|
configs: configs,
|
|
uniqueModels: []string{"dall-e-3"},
|
|
}
|
|
|
|
handler := handleImageGenerations(cm, "")
|
|
|
|
reqBodyObj := map[string]any{
|
|
"prompt": "a beautiful kitten",
|
|
"model": "dall-e-3",
|
|
}
|
|
reqBytes, _ := json.Marshal(reqBodyObj)
|
|
|
|
req := httptest.NewRequest("POST", "/v1/images/generations", bytes.NewReader(reqBytes))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
w := httptest.NewRecorder()
|
|
|
|
handler.ServeHTTP(w, req)
|
|
|
|
resp := w.Result()
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("Expected status 200, got %d", resp.StatusCode)
|
|
}
|
|
|
|
var respObj map[string]any
|
|
_ = json.NewDecoder(resp.Body).Decode(&respObj)
|
|
|
|
data, ok := respObj["data"].([]any)
|
|
if !ok || len(data) == 0 {
|
|
t.Fatalf("Expected data field to contain image urls, got: %+v", respObj)
|
|
}
|
|
|
|
urlVal := data[0].(map[string]any)["url"].(string)
|
|
expectedURL := "http://image-url/generated-by-dall-e-3"
|
|
if urlVal != expectedURL {
|
|
t.Errorf("Expected URL %q, got %q", expectedURL, urlVal)
|
|
}
|
|
}
|
|
|
|
func TestGatewayNoAuthHeaderOnEmptyKey(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if auth := r.Header.Get("Authorization"); auth != "" {
|
|
t.Errorf("Expected no Authorization header, but got %q", auth)
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"Hello"}}]}n`))
|
|
}))
|
|
defer server.Close()
|
|
|
|
configs := []ModelConfig{
|
|
{Model: "model-no-key", Key: "", Endpoint: server.URL},
|
|
{Model: "model-no-key-2", Key: "-", Endpoint: server.URL},
|
|
}
|
|
|
|
cm := &ConfigManager{
|
|
configs: configs,
|
|
uniqueModels: []string{"model-no-key", "model-no-key-2"},
|
|
}
|
|
|
|
handler := handleChatCompletions(cm, "")
|
|
|
|
// Case 1: Empty Key
|
|
{
|
|
reqBodyObj := map[string]any{
|
|
"model": "model-no-key",
|
|
"messages": []map[string]string{
|
|
{"role": "user", "content": "Hi"},
|
|
},
|
|
}
|
|
reqBytes, _ := json.Marshal(reqBodyObj)
|
|
req := httptest.NewRequest("POST", "/v1/chat/completions", bytes.NewReader(reqBytes))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("Authorization", "Bearer some-client-token")
|
|
w := httptest.NewRecorder()
|
|
handler.ServeHTTP(w, req)
|
|
if w.Result().StatusCode != http.StatusOK {
|
|
t.Errorf("Expected status 200 for empty key, got %d", w.Result().StatusCode)
|
|
}
|
|
}
|
|
|
|
// Case 2: Dash Key
|
|
{
|
|
reqBodyObj := map[string]any{
|
|
"model": "model-no-key-2",
|
|
"messages": []map[string]string{
|
|
{"role": "user", "content": "Hi"},
|
|
},
|
|
}
|
|
reqBytes, _ := json.Marshal(reqBodyObj)
|
|
req := httptest.NewRequest("POST", "/v1/chat/completions", bytes.NewReader(reqBytes))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("Authorization", "Bearer some-client-token")
|
|
w := httptest.NewRecorder()
|
|
handler.ServeHTTP(w, req)
|
|
if w.Result().StatusCode != http.StatusOK {
|
|
t.Errorf("Expected status 200 for dash key, got %d", w.Result().StatusCode)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestGatewayBlankKey(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
auth := r.Header.Get("Authorization")
|
|
if auth != "Bearer" {
|
|
t.Errorf("Expected Authorization header to be exactly 'Bearer', but got %q", auth)
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"Hello"}}]}n`))
|
|
}))
|
|
defer server.Close()
|
|
|
|
configs := []ModelConfig{
|
|
{Model: "model-blank-key", Key: "-blank-", Endpoint: server.URL},
|
|
}
|
|
|
|
cm := &ConfigManager{
|
|
configs: configs,
|
|
uniqueModels: []string{"model-blank-key"},
|
|
}
|
|
|
|
handler := handleChatCompletions(cm, "")
|
|
|
|
reqBodyObj := map[string]any{
|
|
"model": "model-blank-key",
|
|
"messages": []map[string]string{
|
|
{"role": "user", "content": "Hi"},
|
|
},
|
|
}
|
|
reqBytes, _ := json.Marshal(reqBodyObj)
|
|
|
|
req := httptest.NewRequest("POST", "/v1/chat/completions", bytes.NewReader(reqBytes))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("Authorization", "Bearer some-client-token")
|
|
w := httptest.NewRecorder()
|
|
|
|
handler.ServeHTTP(w, req)
|
|
|
|
if w.Result().StatusCode != http.StatusOK {
|
|
t.Errorf("Expected status 200 for -blank- key, got %d", w.Result().StatusCode)
|
|
}
|
|
}
|
|
|
|
func TestGatewayExtraHeaders(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if h := r.Header.Get("X-My-Custom-Header"); h != "HeaderValue" {
|
|
t.Errorf("Expected X-My-Custom-Header to be 'HeaderValue', got %q", h)
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
return
|
|
}
|
|
if h := r.Header.Get("X-Another-Header"); h != "123" {
|
|
t.Errorf("Expected X-Another-Header to be '123', got %q", h)
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"Hello"}}]}n`))
|
|
}))
|
|
defer server.Close()
|
|
|
|
configs := []ModelConfig{
|
|
{Model: "model-extra-headers", Key: "my-key", Endpoint: server.URL, Extra: `{"X-My-Custom-Header":"HeaderValue","X-Another-Header":123}`},
|
|
}
|
|
|
|
cm := &ConfigManager{
|
|
configs: configs,
|
|
uniqueModels: []string{"model-extra-headers"},
|
|
}
|
|
|
|
handler := handleChatCompletions(cm, "")
|
|
|
|
reqBodyObj := map[string]any{
|
|
"model": "model-extra-headers",
|
|
"messages": []map[string]string{
|
|
{"role": "user", "content": "Hi"},
|
|
},
|
|
}
|
|
reqBytes, _ := json.Marshal(reqBodyObj)
|
|
|
|
req := httptest.NewRequest("POST", "/v1/chat/completions", bytes.NewReader(reqBytes))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
w := httptest.NewRecorder()
|
|
|
|
handler.ServeHTTP(w, req)
|
|
|
|
if w.Result().StatusCode != http.StatusOK {
|
|
t.Errorf("Expected status 200, got %d", w.Result().StatusCode)
|
|
}
|
|
}
|
|
|
|
|
|
|