package anthropic_test import ( "context" "encoding/json" "net/http" "net/http/httptest" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/veylant/ia-gateway/internal/provider" "github.com/veylant/ia-gateway/internal/provider/anthropic" ) // newTestAdapter creates an Adapter pointed at the given mock server URL. func newTestAdapter(t *testing.T, serverURL string) *anthropic.Adapter { t.Helper() return anthropic.NewWithBaseURL(serverURL, anthropic.Config{ APIKey: "test-key", Version: "2023-06-01", TimeoutSeconds: 5, MaxConns: 10, }) } // anthropicResponseBody builds a minimal Anthropic Messages API response JSON. func anthropicResponseBody(id, text, stopReason string, inputTokens, outputTokens int) map[string]any { return map[string]any{ "id": id, "type": "message", "model": "claude-3-5-sonnet-20241022", "content": []map[string]any{ {"type": "text", "text": text}, }, "stop_reason": stopReason, "usage": map[string]any{ "input_tokens": inputTokens, "output_tokens": outputTokens, }, } } // ─── Send ──────────────────────────────────────────────────────────────────── func TestAnthropicAdapter_Send_Nominal(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Verify Anthropic-specific headers assert.Equal(t, "test-key", r.Header.Get("x-api-key")) assert.Equal(t, "2023-06-01", r.Header.Get("anthropic-version")) assert.Empty(t, r.Header.Get("Authorization")) assert.Equal(t, "/messages", r.URL.Path) w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(anthropicResponseBody( "msg-test-01", "Hello from Anthropic!", "end_turn", 10, 5, )) })) defer srv.Close() req := &provider.ChatRequest{ Model: "claude-3-5-sonnet-20241022", Messages: []provider.Message{{Role: "user", Content: "hello"}}, } got, err := newTestAdapter(t, srv.URL).Send(context.Background(), req) require.NoError(t, err) assert.Equal(t, "msg-test-01", got.ID) assert.Equal(t, "chat.completion", got.Object) assert.Equal(t, "Hello from Anthropic!", got.Choices[0].Message.Content) assert.Equal(t, "assistant", got.Choices[0].Message.Role) assert.Equal(t, "end_turn", got.Choices[0].FinishReason) assert.Equal(t, 10, got.Usage.PromptTokens) assert.Equal(t, 5, got.Usage.CompletionTokens) assert.Equal(t, 15, got.Usage.TotalTokens) } func TestAnthropicAdapter_Send_SystemMessageExtracted(t *testing.T) { var capturedBody map[string]any srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _ = json.NewDecoder(r.Body).Decode(&capturedBody) w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(anthropicResponseBody("msg-02", "ok", "end_turn", 5, 2)) })) defer srv.Close() req := &provider.ChatRequest{ Model: "claude-3-5-sonnet-20241022", Messages: []provider.Message{ {Role: "system", Content: "You are a helpful assistant."}, {Role: "user", Content: "hello"}, }, } _, err := newTestAdapter(t, srv.URL).Send(context.Background(), req) require.NoError(t, err) // System message must appear at top level, NOT in messages[] assert.Equal(t, "You are a helpful assistant.", capturedBody["system"]) msgs := capturedBody["messages"].([]any) assert.Len(t, msgs, 1) assert.Equal(t, "user", msgs[0].(map[string]any)["role"]) } func TestAnthropicAdapter_Send_MaxTokensDefault(t *testing.T) { var capturedBody map[string]any srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _ = json.NewDecoder(r.Body).Decode(&capturedBody) w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(anthropicResponseBody("msg-03", "ok", "end_turn", 5, 2)) })) defer srv.Close() req := &provider.ChatRequest{ Model: "claude-3-5-sonnet-20241022", Messages: []provider.Message{{Role: "user", Content: "hi"}}, // MaxTokens not set } _, err := newTestAdapter(t, srv.URL).Send(context.Background(), req) require.NoError(t, err) assert.Equal(t, float64(4096), capturedBody["max_tokens"]) } func TestAnthropicAdapter_Send_MaxTokensRespected(t *testing.T) { var capturedBody map[string]any srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _ = json.NewDecoder(r.Body).Decode(&capturedBody) w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(anthropicResponseBody("msg-04", "ok", "end_turn", 5, 2)) })) defer srv.Close() maxTok := 1024 req := &provider.ChatRequest{ Model: "claude-3-5-sonnet-20241022", Messages: []provider.Message{{Role: "user", Content: "hi"}}, MaxTokens: &maxTok, } _, err := newTestAdapter(t, srv.URL).Send(context.Background(), req) require.NoError(t, err) assert.Equal(t, float64(1024), capturedBody["max_tokens"]) } func TestAnthropicAdapter_Send_Upstream401_ReturnsAuthError(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusUnauthorized) })) defer srv.Close() req := &provider.ChatRequest{ Model: "claude-3-5-sonnet-20241022", Messages: []provider.Message{{Role: "user", Content: "hi"}}, } _, err := newTestAdapter(t, srv.URL).Send(context.Background(), req) require.Error(t, err) assert.Contains(t, err.Error(), "API key") } func TestAnthropicAdapter_Send_Upstream429_ReturnsRateLimit(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusTooManyRequests) })) defer srv.Close() req := &provider.ChatRequest{ Model: "claude-3-5-sonnet-20241022", Messages: []provider.Message{{Role: "user", Content: "hi"}}, } _, err := newTestAdapter(t, srv.URL).Send(context.Background(), req) require.Error(t, err) assert.Contains(t, err.Error(), "rate limit") } func TestAnthropicAdapter_Send_Upstream400_ReturnsBadRequest(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusBadRequest) })) defer srv.Close() req := &provider.ChatRequest{ Model: "claude-3-5-sonnet-20241022", Messages: []provider.Message{{Role: "user", Content: "hi"}}, } _, err := newTestAdapter(t, srv.URL).Send(context.Background(), req) require.Error(t, err) assert.Contains(t, err.Error(), "rejected") } func TestAnthropicAdapter_Send_Upstream500_ReturnsUpstreamError(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusInternalServerError) })) defer srv.Close() req := &provider.ChatRequest{ Model: "claude-3-5-sonnet-20241022", Messages: []provider.Message{{Role: "user", Content: "hi"}}, } _, err := newTestAdapter(t, srv.URL).Send(context.Background(), req) require.Error(t, err) assert.Contains(t, err.Error(), "500") } // ─── Stream ────────────────────────────────────────────────────────────────── func TestAnthropicAdapter_Stream_Nominal(t *testing.T) { // Anthropic SSE payload with two text deltas and a message_stop. ssePayload := "" + "event: message_start\n" + "data: {\"type\":\"message_start\",\"message\":{\"id\":\"msg-1\"}}\n\n" + "event: content_block_start\n" + "data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\n" + "event: content_block_delta\n" + "data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"Hel\"}}\n\n" + "event: content_block_delta\n" + "data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"lo\"}}\n\n" + "event: message_stop\n" + "data: {\"type\":\"message_stop\"}\n\n" srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "text/event-stream") w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(ssePayload)) })) defer srv.Close() rec := httptest.NewRecorder() rec.Header().Set("Content-Type", "text/event-stream") req := &provider.ChatRequest{ Model: "claude-3-5-sonnet-20241022", Messages: []provider.Message{{Role: "user", Content: "hello"}}, Stream: true, } err := newTestAdapter(t, srv.URL).Stream(context.Background(), req, rec) require.NoError(t, err) body := rec.Body.String() // Verify OpenAI-compat chunks were emitted (not raw Anthropic events) assert.Contains(t, body, "chat.completion.chunk") assert.Contains(t, body, "Hel") assert.Contains(t, body, "lo") assert.Contains(t, body, "[DONE]") // Verify raw Anthropic event names are not leaked assert.NotContains(t, body, "content_block_delta") assert.NotContains(t, body, "message_stop") } func TestAnthropicAdapter_Stream_Upstream401(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusUnauthorized) })) defer srv.Close() rec := httptest.NewRecorder() req := &provider.ChatRequest{ Model: "claude-3-5-sonnet-20241022", Messages: []provider.Message{{Role: "user", Content: "hi"}}, } err := newTestAdapter(t, srv.URL).Stream(context.Background(), req, rec) require.Error(t, err) assert.Contains(t, err.Error(), "API key") } // ─── Validate ──────────────────────────────────────────────────────────────── func TestAnthropicAdapter_Validate_MissingModel(t *testing.T) { a := anthropic.NewWithBaseURL("http://unused", anthropic.Config{APIKey: "k"}) err := a.Validate(&provider.ChatRequest{ Messages: []provider.Message{{Role: "user", Content: "hi"}}, }) require.Error(t, err) assert.Contains(t, err.Error(), "model") } func TestAnthropicAdapter_Validate_EmptyMessages(t *testing.T) { a := anthropic.NewWithBaseURL("http://unused", anthropic.Config{APIKey: "k"}) err := a.Validate(&provider.ChatRequest{Model: "claude-3-5-sonnet-20241022"}) require.Error(t, err) assert.Contains(t, err.Error(), "messages") } // ─── HealthCheck ───────────────────────────────────────────────────────────── func TestAnthropicAdapter_HealthCheck_Nominal(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, "/models", r.URL.Path) assert.Equal(t, "test-key", r.Header.Get("x-api-key")) w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`{"data":[]}`)) })) defer srv.Close() err := newTestAdapter(t, srv.URL).HealthCheck(context.Background()) require.NoError(t, err) } func TestAnthropicAdapter_HealthCheck_Failure(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusServiceUnavailable) })) defer srv.Close() err := newTestAdapter(t, srv.URL).HealthCheck(context.Background()) require.Error(t, err) assert.Contains(t, err.Error(), "503") }