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:
parent
56a060ff61
commit
08ecfe9f74
2 changed files with 121 additions and 1 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue