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"
|
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 {
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue