package llm import ( "context" "encoding/json" "fmt" "io" "net/http" "net/http/httptest" "strings" "sync/atomic" "testing" "time" ) const okResponse = `{"id":"msg_1","type":"message","role":"assistant","model":"claude-opus-5", "content":[{"type":"text","text":"hi"}],"stop_reason":"end_turn","usage":{"input_tokens":3,"output_tokens":2}}` func testClient(t *testing.T, h http.HandlerFunc) *Client { t.Helper() srv := httptest.NewServer(h) t.Cleanup(srv.Close) c := New(WithBaseURL(srv.URL), WithAPIKey("test-key")) c.RetryDelay = time.Millisecond // keep the retry tests fast return c } func TestCreateMessageSendsExpectedHeadersAndBody(t *testing.T) { var gotHeaders http.Header var gotBody map[string]any c := testClient(t, func(w http.ResponseWriter, r *http.Request) { gotHeaders = r.Header.Clone() body, _ := io.ReadAll(r.Body) json.Unmarshal(body, &gotBody) if r.URL.Path != "/v1/messages" { t.Errorf("path = %q, want /v1/messages", r.URL.Path) } io.WriteString(w, okResponse) }) resp, err := c.CreateMessage(context.Background(), Request{ Messages: []Message{{Role: RoleUser, Content: []ContentBlock{TextBlock("hello")}}}, }) if err != nil { t.Fatalf("CreateMessage: %v", err) } if resp.Text() != "hi" { t.Errorf("Text() = %q, want hi", resp.Text()) } if gotHeaders.Get("x-api-key") != "test-key" { t.Errorf("x-api-key = %q", gotHeaders.Get("x-api-key")) } if gotHeaders.Get("anthropic-version") != apiVersion { t.Errorf("anthropic-version = %q, want %s", gotHeaders.Get("anthropic-version"), apiVersion) } if gotBody["model"] != DefaultModel { t.Errorf("model = %v, want the client default %s", gotBody["model"], DefaultModel) } if gotBody["max_tokens"].(float64) != DefaultMaxTokens { t.Errorf("max_tokens = %v, want the client default", gotBody["max_tokens"]) } } func TestOAuthTokenUsesBearerAndBetaHeader(t *testing.T) { var got http.Header srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { got = r.Header.Clone() io.WriteString(w, okResponse) })) defer srv.Close() c := New(WithBaseURL(srv.URL), WithAuthToken("oauth-tok")) if _, err := c.CreateMessage(context.Background(), Request{}); err != nil { t.Fatalf("CreateMessage: %v", err) } if got.Get("authorization") != "Bearer oauth-tok" { t.Errorf("authorization = %q, want Bearer (OAuth tokens do not go on x-api-key)", got.Get("authorization")) } if got.Get("x-api-key") != "" { t.Errorf("x-api-key should be unset when using an OAuth token, got %q", got.Get("x-api-key")) } if got.Get("anthropic-beta") != oauthBeta { t.Errorf("anthropic-beta = %q, want %s", got.Get("anthropic-beta"), oauthBeta) } } func TestNoCredentialsIsAClearError(t *testing.T) { c := New(WithBaseURL("http://127.0.0.1:1")) c.apiKey, c.authToken = "", "" _, err := c.CreateMessage(context.Background(), Request{}) if err == nil || !strings.Contains(err.Error(), "ANTHROPIC_API_KEY") { t.Errorf("error = %v, want it to name the env var", err) } if c.HasCredentials() { t.Error("HasCredentials should be false") } } func TestRetriesTransientStatusesAndNotClientErrors(t *testing.T) { tests := []struct { name string status int wantCalls int32 wantSucceed bool }{ {name: "429 is retried", status: http.StatusTooManyRequests, wantCalls: 3}, {name: "500 is retried", status: http.StatusInternalServerError, wantCalls: 3}, {name: "400 is not retried", status: http.StatusBadRequest, wantCalls: 1}, {name: "401 is not retried", status: http.StatusUnauthorized, wantCalls: 1}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { var calls int32 c := testClient(t, func(w http.ResponseWriter, r *http.Request) { atomic.AddInt32(&calls, 1) w.WriteHeader(tc.status) io.WriteString(w, `{"type":"error","error":{"type":"boom","message":"nope"}}`) }) c.MaxRetries = 2 c.HTTP.Timeout = 5 * time.Second _, err := c.CreateMessage(context.Background(), Request{}) if err == nil { t.Fatal("want an error") } if got := atomic.LoadInt32(&calls); got != tc.wantCalls { t.Errorf("server saw %d calls, want %d", got, tc.wantCalls) } apiErr, ok := err.(*APIError) if !ok { t.Fatalf("error type = %T, want *APIError", err) } if apiErr.StatusCode != tc.status || apiErr.Message == "" { t.Errorf("APIError = %+v, want the status and parsed message", apiErr) } }) } } func TestRetrySucceedsOnSecondAttempt(t *testing.T) { var calls int32 c := testClient(t, func(w http.ResponseWriter, r *http.Request) { if atomic.AddInt32(&calls, 1) == 1 { w.WriteHeader(http.StatusServiceUnavailable) return } io.WriteString(w, okResponse) }) resp, err := c.CreateMessage(context.Background(), Request{}) if err != nil { t.Fatalf("CreateMessage: %v", err) } if resp.Text() != "hi" || atomic.LoadInt32(&calls) != 2 { t.Errorf("got %q after %d calls, want hi after 2", resp.Text(), calls) } } func TestContextCancellationStopsRetrying(t *testing.T) { c := testClient(t, func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusInternalServerError) }) ctx, cancel := context.WithCancel(context.Background()) cancel() if _, err := c.CreateMessage(ctx, Request{}); err == nil { t.Fatal("want an error from the cancelled context") } } func TestMalformedResponseBodyIsReported(t *testing.T) { c := testClient(t, func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, `{"content":`) }) _, err := c.CreateMessage(context.Background(), Request{}) if err == nil || !strings.Contains(err.Error(), "decoding response") { t.Errorf("error = %v, want a decode error", err) } } func TestRetryAfterHintIsParsed(t *testing.T) { got, ok := retryAfter("rate limited (retry-after 7s)") if !ok || got != 7*time.Second { t.Errorf("retryAfter = (%v, %v), want (7s, true)", got, ok) } if _, ok := retryAfter("rate limited"); ok { t.Error("want no hint when the header was absent") } } func TestBaseURLAndCredentialResolution(t *testing.T) { tests := []struct { name string env map[string]string opts []Option wantURL string wantAPIKey string wantAuthToken string }{ { name: "defaults", wantURL: DefaultBaseURL, wantAPIKey: "", }, { name: "environment is picked up", env: map[string]string{"ANTHROPIC_BASE_URL": "http://localhost:4000", "ANTHROPIC_API_KEY": "env-key"}, wantURL: "http://localhost:4000", wantAPIKey: "env-key", }, { name: "flags override the environment", env: map[string]string{"ANTHROPIC_BASE_URL": "http://from-env", "ANTHROPIC_API_KEY": "env-key"}, opts: []Option{WithBaseURL("http://from-flag"), WithAPIKey("flag-key")}, wantURL: "http://from-flag", wantAPIKey: "flag-key", }, { name: "empty options leave the environment alone", env: map[string]string{"ANTHROPIC_BASE_URL": "http://from-env", "ANTHROPIC_API_KEY": "env-key"}, opts: []Option{WithBaseURL(""), WithAPIKey(""), WithAuthToken(""), WithModel("")}, wantURL: "http://from-env", wantAPIKey: "env-key", }, { name: "trailing slashes are trimmed", env: map[string]string{"ANTHROPIC_BASE_URL": "http://gateway.internal/v1/// "}, wantURL: "http://gateway.internal/v1", }, { name: "api key shadows a bearer token from the environment", env: map[string]string{"ANTHROPIC_API_KEY": "k", "ANTHROPIC_AUTH_TOKEN": "t"}, wantURL: DefaultBaseURL, wantAPIKey: "k", wantAuthToken: "", }, { name: "bearer token is used when no api key is set", env: map[string]string{"ANTHROPIC_AUTH_TOKEN": "t"}, wantURL: DefaultBaseURL, wantAuthToken: "t", }, { name: "auth-token flag displaces an api key from the environment", env: map[string]string{"ANTHROPIC_API_KEY": "env-key"}, opts: []Option{WithAuthToken("flag-token")}, wantURL: DefaultBaseURL, wantAuthToken: "flag-token", }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { for _, k := range []string{"ANTHROPIC_BASE_URL", "ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"} { t.Setenv(k, tc.env[k]) } c := New(tc.opts...) if c.BaseURL != tc.wantURL { t.Errorf("BaseURL = %q, want %q", c.BaseURL, tc.wantURL) } if c.apiKey != tc.wantAPIKey { t.Errorf("apiKey = %q, want %q", c.apiKey, tc.wantAPIKey) } if c.authToken != tc.wantAuthToken { t.Errorf("authToken = %q, want %q", c.authToken, tc.wantAuthToken) } if wantDefault := tc.wantURL == DefaultBaseURL; c.IsDefaultEndpoint() != wantDefault { t.Errorf("IsDefaultEndpoint = %v, want %v", c.IsDefaultEndpoint(), wantDefault) } }) } } func TestCustomBaseURLIsActuallyUsed(t *testing.T) { var gotPath string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { gotPath = r.URL.Path io.WriteString(w, okResponse) })) defer srv.Close() // A trailing slash must not produce a request to "//v1/messages". c := New(WithBaseURL(srv.URL+"/"), WithAPIKey("k")) if _, err := c.CreateMessage(context.Background(), Request{}); err != nil { t.Fatalf("CreateMessage: %v", err) } if gotPath != "/v1/messages" { t.Errorf("path = %q, want /v1/messages", gotPath) } } // The model is switchable mid-session, so what a request carries is whatever // was last set rather than what the client was built with. func TestSetModelChangesWhatTheNextRequestAsksFor(t *testing.T) { var models []string c := testClient(t, func(w http.ResponseWriter, r *http.Request) { var body map[string]any raw, _ := io.ReadAll(r.Body) json.Unmarshal(raw, &body) models = append(models, fmt.Sprint(body["model"])) io.WriteString(w, okResponse) }) WithModel("claude-opus-5")(c) msg := Request{Messages: []Message{{Role: RoleUser, Content: []ContentBlock{TextBlock("hi")}}}} if _, err := c.CreateMessage(context.Background(), msg); err != nil { t.Fatalf("CreateMessage: %v", err) } c.SetModel("claude-haiku-4-5") if got := c.Model(); got != "claude-haiku-4-5" { t.Errorf("Model() = %q", got) } if _, err := c.CreateMessage(context.Background(), msg); err != nil { t.Fatalf("CreateMessage: %v", err) } if want := "claude-opus-5 claude-haiku-4-5"; strings.Join(models, " ") != want { t.Errorf("models sent = %v, want %q", models, want) } }