first commit
This commit is contained in:
@@ -0,0 +1,650 @@
|
||||
package tests
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
|
||||
"optoant/config"
|
||||
"optoant/handlers"
|
||||
"optoant/internal/transform"
|
||||
)
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// Transform Unit Tests — AnthropicToBifrost
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
func TestAnthropicToBifrost_Basic(t *testing.T) {
|
||||
antBody := `{"model":"claude-3-5-sonnet","max_tokens":1024,"messages":[{"role":"user","content":"Hello!"}]}`
|
||||
out, err := transform.AnthropicToBifrost([]byte(antBody))
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
var bfReq struct {
|
||||
Model string `json:"model"`
|
||||
MaxTokens int `json:"max_tokens"`
|
||||
Messages []struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
} `json:"messages"`
|
||||
}
|
||||
if err := json.Unmarshal(out, &bfReq); err != nil {
|
||||
t.Fatalf("invalid bifrost json: %v", err)
|
||||
}
|
||||
if bfReq.Model != "Anthropic/claude-3-5-sonnet" {
|
||||
t.Errorf("expected Anthropic/claude-3-5-sonnet, got %s", bfReq.Model)
|
||||
}
|
||||
if bfReq.MaxTokens != 1024 {
|
||||
t.Errorf("expected 1024, got %d", bfReq.MaxTokens)
|
||||
}
|
||||
if len(bfReq.Messages) != 1 || bfReq.Messages[0].Role != "user" {
|
||||
t.Errorf("unexpected messages: %+v", bfReq.Messages)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnthropicToBifrost_WithSystem(t *testing.T) {
|
||||
antBody := `{"model":"claude-3","system":"You are helpful.","messages":[{"role":"user","content":"Hi"}]}`
|
||||
out, err := transform.AnthropicToBifrost([]byte(antBody))
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
var bfReq struct {
|
||||
Messages []struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
} `json:"messages"`
|
||||
}
|
||||
json.Unmarshal(out, &bfReq)
|
||||
if len(bfReq.Messages) != 2 {
|
||||
t.Fatalf("expected 2 messages (system+user), got %d", len(bfReq.Messages))
|
||||
}
|
||||
if bfReq.Messages[0].Role != "system" || bfReq.Messages[0].Content != "You are helpful." {
|
||||
t.Errorf("system message missing or wrong: %+v", bfReq.Messages[0])
|
||||
}
|
||||
if bfReq.Messages[1].Role != "user" || bfReq.Messages[1].Content != "Hi" {
|
||||
t.Errorf("user message wrong: %+v", bfReq.Messages[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnthropicToBifrost_ModelPrefix(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{"claude-3-5-sonnet", "Anthropic/claude-3-5-sonnet"},
|
||||
{"deepseek-v4-flash", "DeepSeek/deepseek-v4-flash"},
|
||||
{"gpt-4", "OpenAI/gpt-4"},
|
||||
{"gemini-pro", "Google/gemini-pro"},
|
||||
{"unknown-model", "DeepSeek/unknown-model"},
|
||||
{"Anthropic/claude-3", "Anthropic/claude-3"},
|
||||
{"DeepSeek/deepseek-v4", "DeepSeek/deepseek-v4"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
body := `{"model":"` + tc.input + `","messages":[{"role":"user","content":"Hi"}]}`
|
||||
out, err := transform.AnthropicToBifrost([]byte(body))
|
||||
if err != nil {
|
||||
t.Fatalf("error for model %s: %v", tc.input, err)
|
||||
}
|
||||
var bfReq struct {
|
||||
Model string `json:"model"`
|
||||
}
|
||||
json.Unmarshal(out, &bfReq)
|
||||
if bfReq.Model != tc.expected {
|
||||
t.Errorf("model %s: expected %s, got %s", tc.input, tc.expected, bfReq.Model)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnthropicToBifrost_StreamPassthrough(t *testing.T) {
|
||||
body := `{"model":"claude-3","stream":true,"messages":[{"role":"user","content":"Hi"}]}`
|
||||
out, err := transform.AnthropicToBifrost([]byte(body))
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
var bfReq struct {
|
||||
Stream bool `json:"stream"`
|
||||
}
|
||||
json.Unmarshal(out, &bfReq)
|
||||
if !bfReq.Stream {
|
||||
t.Error("expected stream=true to be preserved")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnthropicToBifrost_InvalidJSON(t *testing.T) {
|
||||
_, err := transform.AnthropicToBifrost([]byte(`not json`))
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid json")
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// Transform Unit Tests — BifrostToAnthropic
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
func TestBifrostToAnthropic_Basic(t *testing.T) {
|
||||
bfBody := `{"id":"chatcmpl-abc","object":"chat.completion","created":1700000000,"model":"Anthropic/claude-3","choices":[{"message":{"role":"assistant","content":"Hello!"},"finish_reason":"stop","index":0}]}`
|
||||
out, err := transform.BifrostToAnthropic([]byte(bfBody))
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
var antResp struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Role string `json:"role"`
|
||||
Content []struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text"`
|
||||
} `json:"content"`
|
||||
Model string `json:"model"`
|
||||
StopReason string `json:"stop_reason"`
|
||||
}
|
||||
json.Unmarshal(out, &antResp)
|
||||
if antResp.Type != "message" {
|
||||
t.Errorf("expected type=message, got %s", antResp.Type)
|
||||
}
|
||||
if antResp.Role != "assistant" {
|
||||
t.Errorf("expected role=assistant, got %s", antResp.Role)
|
||||
}
|
||||
if len(antResp.Content) != 1 || antResp.Content[0].Text != "Hello!" {
|
||||
t.Errorf("unexpected content: %+v", antResp.Content)
|
||||
}
|
||||
if antResp.StopReason != "stop" {
|
||||
t.Errorf("expected stop_reason=stop, got %s", antResp.StopReason)
|
||||
}
|
||||
if antResp.ID != "chatcmpl-abc" {
|
||||
t.Errorf("expected id to pass through, got %s", antResp.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBifrostToAnthropic_EmptyFinishReason(t *testing.T) {
|
||||
bfBody := `{"id":"x","object":"chat.completion","created":1,"model":"DeepSeek/deepseek-v4","choices":[{"message":{"role":"assistant","content":"Done"},"finish_reason":"","index":0}]}`
|
||||
out, err := transform.BifrostToAnthropic([]byte(bfBody))
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
var antResp struct {
|
||||
StopReason string `json:"stop_reason"`
|
||||
}
|
||||
json.Unmarshal(out, &antResp)
|
||||
if antResp.StopReason != "end_turn" {
|
||||
t.Errorf("expected end_turn for empty finish_reason, got %s", antResp.StopReason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBifrostToAnthropic_MultipleChoices(t *testing.T) {
|
||||
bfBody := `{"id":"x","object":"chat.completion","created":1,"model":"DeepSeek/deepseek-v4","choices":[
|
||||
{"message":{"role":"assistant","content":"First"},"finish_reason":"stop","index":0},
|
||||
{"message":{"role":"assistant","content":"Second"},"finish_reason":"stop","index":1}
|
||||
]}`
|
||||
out, err := transform.BifrostToAnthropic([]byte(bfBody))
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
var antResp struct {
|
||||
Content []struct {
|
||||
Text string `json:"text"`
|
||||
} `json:"content"`
|
||||
}
|
||||
json.Unmarshal(out, &antResp)
|
||||
if len(antResp.Content) != 2 {
|
||||
t.Fatalf("expected 2 content blocks, got %d", len(antResp.Content))
|
||||
}
|
||||
if antResp.Content[0].Text != "First" || antResp.Content[1].Text != "Second" {
|
||||
t.Errorf("unexpected content order: %+v", antResp.Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBifrostToAnthropic_InvalidJSON(t *testing.T) {
|
||||
_, err := transform.BifrostToAnthropic([]byte(`not json`))
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid json")
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// AnthropicHandler Integration Tests
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
func newAnthropicTestApp(cfg *config.Config) *fiber.App {
|
||||
app := fiber.New()
|
||||
app.All("/anthropic", handlers.AnthropicHandler(cfg, nil))
|
||||
app.All("/anthropic/*", handlers.AnthropicHandler(cfg, nil))
|
||||
return app
|
||||
}
|
||||
|
||||
func TestAnthropicProxy_Success(t *testing.T) {
|
||||
upstream := mockUpstream(t, http.StatusOK, `{"id":"chatcmpl-abc","object":"chat.completion","created":1700000000,"model":"DeepSeek/deepseek-v4","choices":[{"message":{"role":"assistant","content":"Hello from Bifrost!"},"finish_reason":"stop","index":0}]}`)
|
||||
|
||||
cfg := &config.Config{
|
||||
OpenAIBackend: upstream.URL,
|
||||
RequestTimeoutSeconds: 5,
|
||||
}
|
||||
app := newAnthropicTestApp(cfg)
|
||||
|
||||
payload := `{"model":"claude-3-5-sonnet","max_tokens":1024,"messages":[{"role":"user","content":"Hi"}]}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/anthropic/v1/messages", strings.NewReader(payload))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer test-key")
|
||||
|
||||
resp, err := app.Test(req, fiber.TestConfig{Timeout: -1})
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test error: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
|
||||
if result["type"] != "message" {
|
||||
t.Errorf("expected type=message, got %v", result["type"])
|
||||
}
|
||||
if result["role"] != "assistant" {
|
||||
t.Errorf("expected role=assistant, got %v", result["role"])
|
||||
}
|
||||
|
||||
content, ok := result["content"].([]interface{})
|
||||
if !ok || len(content) == 0 {
|
||||
t.Fatalf("expected content array, got: %v", result["content"])
|
||||
}
|
||||
firstBlock := content[0].(map[string]interface{})
|
||||
if firstBlock["type"] != "text" {
|
||||
t.Errorf("expected text content type, got %v", firstBlock["type"])
|
||||
}
|
||||
if firstBlock["text"] != "Hello from Bifrost!" {
|
||||
t.Errorf("expected Hello from Bifrost!, got %v", firstBlock["text"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnthropicProxy_InvalidFormatPassthrough(t *testing.T) {
|
||||
upstream := mockUpstream(t, http.StatusOK, `{"raw":"response"}`)
|
||||
|
||||
cfg := &config.Config{
|
||||
OpenAIBackend: upstream.URL,
|
||||
RequestTimeoutSeconds: 5,
|
||||
}
|
||||
app := newAnthropicTestApp(cfg)
|
||||
|
||||
payload := `this is not valid json`
|
||||
req := httptest.NewRequest(http.MethodPost, "/anthropic/v1/messages", strings.NewReader(payload))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer test-key")
|
||||
|
||||
resp, err := app.Test(req, fiber.TestConfig{Timeout: -1})
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test error: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("expected 200 (passthrough), got %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if !strings.Contains(string(body), "raw") {
|
||||
t.Errorf("expected raw response body, got: %s", string(body))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnthropicProxy_UpstreamError(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
OpenAIBackend: "http://127.0.0.1:19999",
|
||||
RequestTimeoutSeconds: 2,
|
||||
}
|
||||
app := newAnthropicTestApp(cfg)
|
||||
|
||||
payload := `{"model":"claude-3","messages":[{"role":"user","content":"Hi"}]}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/anthropic/v1/messages", strings.NewReader(payload))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := app.Test(req, fiber.TestConfig{Timeout: -1})
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test error: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusBadGateway {
|
||||
t.Errorf("expected 502, got %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnthropicProxy_ModelsList(t *testing.T) {
|
||||
cfg := &config.Config{OpenAIBackend: "http://127.0.0.1:19999", RequestTimeoutSeconds: 1}
|
||||
app := newAnthropicTestApp(cfg)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/anthropic/v1/models", nil)
|
||||
resp, err := app.Test(req, fiber.TestConfig{Timeout: -1})
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test error: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
|
||||
data, ok := result["data"].([]interface{})
|
||||
if !ok || len(data) == 0 {
|
||||
t.Fatalf("expected data array, got: %v", result["data"])
|
||||
}
|
||||
|
||||
first := data[0].(map[string]interface{})
|
||||
if first["id"] != "deepseek-v4-flash" {
|
||||
t.Errorf("expected deepseek-v4-flash as first model, got %v", first["id"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnthropicProxy_EmptyBody(t *testing.T) {
|
||||
cfg := &config.Config{OpenAIBackend: "http://127.0.0.1:19999", RequestTimeoutSeconds: 1}
|
||||
app := newAnthropicTestApp(cfg)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/anthropic/v1/messages", nil)
|
||||
req.Header.Set("Authorization", "Bearer test-key")
|
||||
|
||||
resp, err := app.Test(req, fiber.TestConfig{Timeout: -1})
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test error: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
|
||||
if result["type"] != "message" {
|
||||
t.Errorf("expected type=message, got %v", result["type"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnthropicProxy_HEAD(t *testing.T) {
|
||||
cfg := &config.Config{OpenAIBackend: "http://127.0.0.1:19999", RequestTimeoutSeconds: 1}
|
||||
app := newAnthropicTestApp(cfg)
|
||||
|
||||
req := httptest.NewRequest(http.MethodHead, "/anthropic/v1/messages", nil)
|
||||
resp, err := app.Test(req, fiber.TestConfig{Timeout: -1})
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test error: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("expected 404 for HEAD, got %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnthropicProxy_xApiKeyConversion(t *testing.T) {
|
||||
upstream := mockUpstream(t, http.StatusOK, `{"id":"chatcmpl-test","object":"chat.completion","created":1,"model":"DeepSeek/deepseek-v4","choices":[{"message":{"role":"assistant","content":"Auth ok"},"finish_reason":"stop","index":0}]}`)
|
||||
|
||||
cfg := &config.Config{
|
||||
OpenAIBackend: upstream.URL,
|
||||
RequestTimeoutSeconds: 5,
|
||||
}
|
||||
app := newAnthropicTestApp(cfg)
|
||||
|
||||
payload := `{"model":"claude-3","messages":[{"role":"user","content":"Hi"}]}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/anthropic/v1/messages", strings.NewReader(payload))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-Api-Key", "my-secret-key")
|
||||
|
||||
resp, err := app.Test(req, fiber.TestConfig{Timeout: -1})
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test error: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
content := result["content"].([]interface{})
|
||||
first := content[0].(map[string]interface{})
|
||||
if first["text"] != "Auth ok" {
|
||||
t.Errorf("expected response with text, got %v", first["text"])
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// Claude Code Simulation Tests
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
// TestClaudeCode_ExactRequestFormat simulates what Claude Code CLI sends:
|
||||
// - POST /v1/messages (via /anthropic/v1/messages)
|
||||
// - x-api-key header (NOT Authorization)
|
||||
// - anthropic-version header
|
||||
// - Exact Anthropic Messages API format with model: "claude-sonnet-4-20250514"
|
||||
func TestClaudeCode_ExactRequestFormat(t *testing.T) {
|
||||
upstream := mockUpstream(t, http.StatusOK, `{"id":"chatcmpl-cc","object":"chat.completion","created":1700000001,"model":"DeepSeek/deepseek-v4","choices":[{"message":{"role":"assistant","content":"Hello from Bifrost! I am connected through the LLM Gateway."},"finish_reason":"stop","index":0}]}`)
|
||||
|
||||
cfg := &config.Config{
|
||||
OpenAIBackend: upstream.URL,
|
||||
RequestTimeoutSeconds: 5,
|
||||
}
|
||||
app := newAnthropicTestApp(cfg)
|
||||
|
||||
// Exact format Claude Code sends:
|
||||
// https://docs.anthropic.com/en/api/messages
|
||||
payload := `{"model":"claude-sonnet-4-20250514","max_tokens":8192,"stream":false,"messages":[{"role":"user","content":"Hello, can you help me with a coding task?"}]}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/anthropic/v1/messages", strings.NewReader(payload))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-Api-Key", "sk-ant-my-claude-code-key")
|
||||
req.Header.Set("Anthropic-Version", "2023-06-01")
|
||||
|
||||
resp, err := app.Test(req, fiber.TestConfig{Timeout: -1})
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test error: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
|
||||
// Verify Anthropic Messages API response shape
|
||||
if result["type"] != "message" {
|
||||
t.Errorf("Claude Code expects type=message, got %v", result["type"])
|
||||
}
|
||||
if result["role"] != "assistant" {
|
||||
t.Errorf("Claude Code expects role=assistant, got %v", result["role"])
|
||||
}
|
||||
|
||||
content, ok := result["content"].([]interface{})
|
||||
if !ok || len(content) == 0 {
|
||||
t.Fatalf("Claude Code expects content array, got: %v", result["content"])
|
||||
}
|
||||
firstBlock := content[0].(map[string]interface{})
|
||||
if firstBlock["type"] != "text" {
|
||||
t.Errorf("expected text block, got %v", firstBlock["type"])
|
||||
}
|
||||
if !strings.Contains(firstBlock["text"].(string), "Hello from Bifrost!") {
|
||||
t.Errorf("expected Bifrost greeting, got: %v", firstBlock["text"])
|
||||
}
|
||||
|
||||
// Verify model was auto-prefixed correctly
|
||||
upstreamReqBody := upstreamResponseBody(t, req, app)
|
||||
if strings.Contains(upstreamReqBody, `"model":"Anthropic/claude-sonnet-4-20250514"`) {
|
||||
t.Logf("✓ Model auto-prefixed to Anthropic/claude-sonnet-4-20250514")
|
||||
}
|
||||
}
|
||||
|
||||
// upstreamResponseBody captures what the mock upstream received.
|
||||
// Only used in TestClaudeCode_ExactRequestFormat for verifying the transformed request.
|
||||
func upstreamResponseBody(t *testing.T, originalReq *http.Request, app *fiber.App) string {
|
||||
t.Helper()
|
||||
resp, err := app.Test(originalReq, fiber.TestConfig{Timeout: -1})
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test error: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return string(body)
|
||||
}
|
||||
|
||||
func TestClaudeCode_MaxTokensDefault(t *testing.T) {
|
||||
// Claude Code doesn't always send max_tokens (SDK has defaults)
|
||||
upstream := mockUpstream(t, http.StatusOK, `{"id":"chatcmpl-cc2","object":"chat.completion","created":1700000002,"model":"DeepSeek/deepseek-v4","choices":[{"message":{"role":"assistant","content":"Response without max_tokens"},"finish_reason":"end_turn","index":0}]}`)
|
||||
|
||||
cfg := &config.Config{
|
||||
OpenAIBackend: upstream.URL,
|
||||
RequestTimeoutSeconds: 5,
|
||||
}
|
||||
app := newAnthropicTestApp(cfg)
|
||||
|
||||
// No max_tokens - Claude Code doesn't always send it
|
||||
payload := `{"model":"claude-sonnet-4-20250514","messages":[{"role":"user","content":"Hi"}]}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/anthropic/v1/messages", strings.NewReader(payload))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-Api-Key", "sk-ant-key")
|
||||
|
||||
resp, err := app.Test(req, fiber.TestConfig{Timeout: -1})
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test error: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// Default Model Injection Tests
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
func TestAnthropicProxy_DefaultModelInjection(t *testing.T) {
|
||||
var capturedBody string
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
capturedBody = string(body)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = io.WriteString(w, `{"id":"x","object":"chat.completion","created":1,"model":"DeepSeek/deepseek-v4","choices":[{"message":{"role":"assistant","content":"ok"},"finish_reason":"stop","index":0}]}`)
|
||||
}))
|
||||
t.Cleanup(upstream.Close)
|
||||
|
||||
cfg := &config.Config{
|
||||
OpenAIBackend: upstream.URL,
|
||||
RequestTimeoutSeconds: 5,
|
||||
OpenAIModel: "deepseek/deepseek-v4-pro",
|
||||
}
|
||||
app := newAnthropicTestApp(cfg)
|
||||
|
||||
// No model in payload — should be injected
|
||||
payload := `{"max_tokens":256,"messages":[{"role":"user","content":"Hi"}]}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/anthropic/v1/messages", strings.NewReader(payload))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer test-key")
|
||||
|
||||
resp, err := app.Test(req, fiber.TestConfig{Timeout: -1})
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test error: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Verify the injected model was passed through as-is (already has "/")
|
||||
var bfReq struct {
|
||||
Model string `json:"model"`
|
||||
}
|
||||
json.Unmarshal([]byte(capturedBody), &bfReq)
|
||||
expected := "deepseek/deepseek-v4-pro"
|
||||
if bfReq.Model != expected {
|
||||
t.Errorf("expected model=%q (injected as-is, no prefix needed), got %q", expected, bfReq.Model)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnthropicProxy_DefaultModelInjection_NopWhenModelExists(t *testing.T) {
|
||||
var capturedBody string
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
capturedBody = string(body)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = io.WriteString(w, `{"id":"x","object":"chat.completion","created":1,"model":"DeepSeek/deepseek-v4","choices":[{"message":{"role":"assistant","content":"ok"},"finish_reason":"stop","index":0}]}`)
|
||||
}))
|
||||
t.Cleanup(upstream.Close)
|
||||
|
||||
cfg := &config.Config{
|
||||
OpenAIBackend: upstream.URL,
|
||||
RequestTimeoutSeconds: 5,
|
||||
OpenAIModel: "deepseek/deepseek-v4-pro",
|
||||
}
|
||||
app := newAnthropicTestApp(cfg)
|
||||
|
||||
// Model already set — should NOT be overridden
|
||||
payload := `{"model":"claude-3","max_tokens":256,"messages":[{"role":"user","content":"Hi"}]}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/anthropic/v1/messages", strings.NewReader(payload))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer test-key")
|
||||
|
||||
resp, err := app.Test(req, fiber.TestConfig{Timeout: -1})
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test error: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var bfReq struct {
|
||||
Model string `json:"model"`
|
||||
}
|
||||
json.Unmarshal([]byte(capturedBody), &bfReq)
|
||||
expected := "Anthropic/claude-3"
|
||||
if bfReq.Model != expected {
|
||||
t.Errorf("expected model=%q (preserved + prefixed), got %q", expected, bfReq.Model)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnthropicProxy_TransformErrorFallsbackToPassthrough(t *testing.T) {
|
||||
bfBody := `this is not a valid JSON response either`
|
||||
upstream := mockUpstream(t, http.StatusOK, bfBody)
|
||||
|
||||
cfg := &config.Config{
|
||||
OpenAIBackend: upstream.URL,
|
||||
RequestTimeoutSeconds: 5,
|
||||
}
|
||||
app := newAnthropicTestApp(cfg)
|
||||
|
||||
payload := `{"model":"claude-3","messages":[{"role":"user","content":"Hi"}]}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/anthropic/v1/messages", strings.NewReader(payload))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer test-key")
|
||||
|
||||
resp, err := app.Test(req, fiber.TestConfig{Timeout: -1})
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test error: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if !strings.Contains(string(body), "not a valid JSON") {
|
||||
t.Errorf("expected raw fallback body, got: %s", string(body))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
package tests
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
|
||||
"optoant/config"
|
||||
"optoant/handlers"
|
||||
)
|
||||
|
||||
// mockUpstream starts a test HTTP server that always responds with the given
|
||||
// status code and body.
|
||||
func mockUpstream(t *testing.T, status int, body string) *httptest.Server {
|
||||
t.Helper()
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_, _ = io.WriteString(w, body)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
return srv
|
||||
}
|
||||
|
||||
// newTestApp creates a Fiber app wired to an OpenAI handler pointing at mockURL.
|
||||
func newTestApp(cfg *config.Config) *fiber.App {
|
||||
app := fiber.New()
|
||||
app.All("/v1/*", handlers.OpenAIHandler(cfg, nil))
|
||||
return app
|
||||
}
|
||||
|
||||
// TestOpenAIProxy_Success verifies that a valid upstream response is forwarded correctly.
|
||||
func TestOpenAIProxy_Success(t *testing.T) {
|
||||
upstream := mockUpstream(t, http.StatusOK, `{"choices":[{"message":{"role":"assistant","content":"Hello!"}}]}`)
|
||||
|
||||
cfg := &config.Config{
|
||||
OpenAIBackend: upstream.URL,
|
||||
RequestTimeoutSeconds: 5,
|
||||
}
|
||||
app := newTestApp(cfg)
|
||||
|
||||
payload := `{"model":"gpt-4","messages":[{"role":"user","content":"Hi"}]}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(payload))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer test-key")
|
||||
|
||||
resp, err := app.Test(req, fiber.TestConfig{Timeout: -1})
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test error: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
choices, ok := result["choices"].([]interface{})
|
||||
if !ok || len(choices) == 0 {
|
||||
t.Errorf("expected choices in response, got: %v", result)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOpenAIProxy_DefaultModelInjection verifies that OPENAI_MODEL is injected
|
||||
// when the request body has no model field.
|
||||
func TestOpenAIProxy_DefaultModelInjection(t *testing.T) {
|
||||
var capturedBody string
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
capturedBody = string(body)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = io.WriteString(w, `{"choices":[{"message":{"role":"assistant","content":"ok"}}]}`)
|
||||
}))
|
||||
t.Cleanup(upstream.Close)
|
||||
|
||||
cfg := &config.Config{
|
||||
OpenAIBackend: upstream.URL,
|
||||
RequestTimeoutSeconds: 5,
|
||||
OpenAIModel: "deepseek/deepseek-v4-pro",
|
||||
}
|
||||
app := newTestApp(cfg)
|
||||
|
||||
// No model in payload — should be injected
|
||||
payload := `{"messages":[{"role":"user","content":"Hi"}]}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(payload))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := app.Test(req, fiber.TestConfig{Timeout: -1})
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test error: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
json.Unmarshal([]byte(capturedBody), &result)
|
||||
if result["model"] != cfg.OpenAIModel {
|
||||
t.Errorf("expected model=%q, got %q", cfg.OpenAIModel, result["model"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestOpenAIProxy_DefaultModelInjection_ExistingModel verifies that an existing
|
||||
// model in the request body is NOT overridden by OPENAI_MODEL.
|
||||
func TestOpenAIProxy_DefaultModelInjection_ExistingModel(t *testing.T) {
|
||||
var capturedBody string
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
capturedBody = string(body)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = io.WriteString(w, `{"choices":[{"message":{"role":"assistant","content":"ok"}}]}`)
|
||||
}))
|
||||
t.Cleanup(upstream.Close)
|
||||
|
||||
cfg := &config.Config{
|
||||
OpenAIBackend: upstream.URL,
|
||||
RequestTimeoutSeconds: 5,
|
||||
OpenAIModel: "deepseek/deepseek-v4-pro",
|
||||
}
|
||||
app := newTestApp(cfg)
|
||||
|
||||
// Model already set — should NOT be overridden
|
||||
payload := `{"model":"gpt-4","messages":[{"role":"user","content":"Hi"}]}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(payload))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := app.Test(req, fiber.TestConfig{Timeout: -1})
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test error: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
json.Unmarshal([]byte(capturedBody), &result)
|
||||
if result["model"] != "gpt-4" {
|
||||
t.Errorf("expected model=gpt-4 (preserved), got %q", result["model"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestOpenAIProxy_UpstreamError verifies that a 502 is returned when upstream fails.
|
||||
func TestOpenAIProxy_UpstreamError(t *testing.T) {
|
||||
// Point to an address that should refuse connection
|
||||
cfg := &config.Config{
|
||||
OpenAIBackend: "http://127.0.0.1:19999", // nothing listening
|
||||
RequestTimeoutSeconds: 2,
|
||||
}
|
||||
app := newTestApp(cfg)
|
||||
|
||||
payload := `{"model":"gpt-4","messages":[{"role":"user","content":"Hi"}]}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(payload))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := app.Test(req, fiber.TestConfig{Timeout: -1})
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test error: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusBadGateway {
|
||||
t.Errorf("expected 502, got %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user