diff --git a/pkg/providers/anthropic/provider.go b/pkg/providers/anthropic/provider.go index 8f46aa70c..cc49472f8 100644 --- a/pkg/providers/anthropic/provider.go +++ b/pkg/providers/anthropic/provider.go @@ -22,10 +22,15 @@ type ToolFunctionDefinition = protocoltypes.ToolFunctionDefinition const defaultBaseURL = "https://api.anthropic.com" +// anthropicOAuthBetaHeader is required for Anthropic OAuth tokens (sk-ant-oat01-*). +// Without this header, the API returns 401: "OAuth authentication is currently not supported." +const anthropicOAuthBetaHeader = "oauth-2025-04-20" + type Provider struct { client *anthropic.Client tokenSource func() (string, error) baseURL string + isOAuth bool } func NewProvider(token string) *Provider { @@ -58,11 +63,24 @@ func NewProviderWithTokenSource(token string, tokenSource func() (string, error) func NewProviderWithTokenSourceAndBaseURL(token string, tokenSource func() (string, error), apiBase string) *Provider { p := NewProviderWithBaseURL(token, apiBase) p.tokenSource = tokenSource + p.isOAuth = true return p } +// oauthOpts returns request options for OAuth token authentication. +// The anthropic-beta header is only injected when targeting the official Anthropic API, +// since custom endpoints (Vertex AI, Bedrock, LiteLLM, etc.) do not recognize it. +func (p *Provider) oauthOpts() []option.RequestOption { + if p.isOAuth && p.baseURL == defaultBaseURL { + return []option.RequestOption{ + option.WithHeader("anthropic-beta", anthropicOAuthBetaHeader), + } + } + return nil +} + func (p *Provider) Chat(ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (*LLMResponse, error) { - var opts []option.RequestOption + opts := p.oauthOpts() if p.tokenSource != nil { tok, err := p.tokenSource() if err != nil { diff --git a/pkg/providers/anthropic/provider_test.go b/pkg/providers/anthropic/provider_test.go index 6a1dabafb..18d50b563 100644 --- a/pkg/providers/anthropic/provider_test.go +++ b/pkg/providers/anthropic/provider_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "strings" "sync/atomic" "testing" @@ -263,3 +264,104 @@ func createAnthropicTestClient(baseURL, token string) *anthropic.Client { ) return &c } + +func TestProvider_OAuthToken_SendsBetaHeader(t *testing.T) { + var capturedHeaders http.Header + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedHeaders = r.Header.Clone() + resp := map[string]interface{}{ + "id": "msg_test", "type": "message", "role": "assistant", + "model": "claude-sonnet-4-5-20250929", "stop_reason": "end_turn", + "content": []map[string]interface{}{{"type": "text", "text": "ok"}}, + "usage": map[string]interface{}{"input_tokens": 1, "output_tokens": 1}, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + // Create OAuth provider via token source constructor, then point client at test server. + // We keep baseURL = defaultBaseURL so the apiBase guard allows the header. + client := anthropic.NewClient( + anthropicoption.WithAuthToken("sk-ant-oat01-initial"), + anthropicoption.WithBaseURL(server.URL), + ) + p := &Provider{ + client: &client, + baseURL: defaultBaseURL, + isOAuth: true, + tokenSource: func() (string, error) { + return "sk-ant-oat01-refreshed", nil + }, + } + + _, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "claude-sonnet-4-5-20250929", map[string]interface{}{}) + if err != nil { + t.Fatalf("Chat() error: %v", err) + } + + got := capturedHeaders.Get("Anthropic-Beta") + if !strings.Contains(got, anthropicOAuthBetaHeader) { + t.Errorf("OAuth provider: anthropic-beta header = %q, want to contain %q", got, anthropicOAuthBetaHeader) + } +} + +func TestProvider_RegularAPIKey_NoBetaHeader(t *testing.T) { + var capturedHeaders http.Header + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedHeaders = r.Header.Clone() + resp := map[string]interface{}{ + "id": "msg_test", "type": "message", "role": "assistant", + "model": "claude-sonnet-4-5-20250929", "stop_reason": "end_turn", + "content": []map[string]interface{}{{"type": "text", "text": "ok"}}, + "usage": map[string]interface{}{"input_tokens": 1, "output_tokens": 1}, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + // Regular API key provider (not OAuth) + p := NewProviderWithClient(createAnthropicTestClient(server.URL, "sk-ant-api03-regular-key")) + + _, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "claude-sonnet-4-5-20250929", map[string]interface{}{}) + if err != nil { + t.Fatalf("Chat() error: %v", err) + } + + got := capturedHeaders.Get("Anthropic-Beta") + if strings.Contains(got, anthropicOAuthBetaHeader) { + t.Errorf("Regular API key provider: anthropic-beta header = %q, should NOT contain %q", got, anthropicOAuthBetaHeader) + } +} + +func TestProvider_OAuthWithCustomBaseURL_NoBetaHeader(t *testing.T) { + var capturedHeaders http.Header + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedHeaders = r.Header.Clone() + resp := map[string]interface{}{ + "id": "msg_test", "type": "message", "role": "assistant", + "model": "claude-sonnet-4-5-20250929", "stop_reason": "end_turn", + "content": []map[string]interface{}{{"type": "text", "text": "ok"}}, + "usage": map[string]interface{}{"input_tokens": 1, "output_tokens": 1}, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + // OAuth provider but targeting a custom endpoint (e.g., LiteLLM proxy) + p := NewProviderWithTokenSourceAndBaseURL("sk-ant-oat01-initial", func() (string, error) { + return "sk-ant-oat01-refreshed", nil + }, server.URL) + + _, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "claude-sonnet-4-5-20250929", map[string]interface{}{}) + if err != nil { + t.Fatalf("Chat() error: %v", err) + } + + got := capturedHeaders.Get("Anthropic-Beta") + if strings.Contains(got, anthropicOAuthBetaHeader) { + t.Errorf("OAuth with custom baseURL: anthropic-beta header = %q, should NOT contain %q", got, anthropicOAuthBetaHeader) + } +}