feat(providers): add anthropic-beta header for OAuth token support

Add OAuth token (sk-ant-oat01-*) support to the Anthropic provider.
The anthropic-beta: oauth-2025-04-20 header is required by the Anthropic
API for OAuth authentication.

Implementation:
- Add isOAuth field to Provider, set in NewProviderWithTokenSource*
- Add oauthOpts() helper that injects the header only when targeting
  the official Anthropic API (apiBase guard prevents sending to custom
  endpoints like Vertex AI, Bedrock, or LiteLLM)
- Chat() calls oauthOpts() to prepend OAuth headers when applicable

Tests:
- OAuth token with default API → header IS sent
- Regular API key → header is NOT sent
- OAuth token with custom baseURL → header is NOT sent
This commit is contained in:
xj 2026-02-19 00:35:48 -08:00
parent 56a060ff61
commit 08ecfe9f74
2 changed files with 121 additions and 1 deletions

View file

@ -22,10 +22,15 @@ type ToolFunctionDefinition = protocoltypes.ToolFunctionDefinition
const defaultBaseURL = "https://api.anthropic.com" 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 { type Provider struct {
client *anthropic.Client client *anthropic.Client
tokenSource func() (string, error) tokenSource func() (string, error)
baseURL string baseURL string
isOAuth bool
} }
func NewProvider(token string) *Provider { 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 { func NewProviderWithTokenSourceAndBaseURL(token string, tokenSource func() (string, error), apiBase string) *Provider {
p := NewProviderWithBaseURL(token, apiBase) p := NewProviderWithBaseURL(token, apiBase)
p.tokenSource = tokenSource p.tokenSource = tokenSource
p.isOAuth = true
return p 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) { 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 { if p.tokenSource != nil {
tok, err := p.tokenSource() tok, err := p.tokenSource()
if err != nil { if err != nil {

View file

@ -4,6 +4,7 @@ import (
"encoding/json" "encoding/json"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strings"
"sync/atomic" "sync/atomic"
"testing" "testing"
@ -263,3 +264,104 @@ func createAnthropicTestClient(baseURL, token string) *anthropic.Client {
) )
return &c 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)
}
}