feat: add Anthropic OAuth support for Claude Max/Pro subscriptions
Adds three auth flows for Anthropic: browser OAuth for Max/Pro (free inference via Bearer token), browser OAuth for console (creates API key), and manual paste. Includes SDK middleware for tool name prefixing, prompt sanitization, and required beta headers.
This commit is contained in:
parent
80c8b57533
commit
9453606f9c
8 changed files with 826 additions and 16 deletions
|
|
@ -51,6 +51,11 @@ func authHelp() {
|
||||||
fmt.Println(" --provider <name> Provider to login with (openai, anthropic, google-antigravity)")
|
fmt.Println(" --provider <name> Provider to login with (openai, anthropic, google-antigravity)")
|
||||||
fmt.Println(" --device-code Use device code flow (for headless environments)")
|
fmt.Println(" --device-code Use device code flow (for headless environments)")
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
|
fmt.Println("Anthropic login modes:")
|
||||||
|
fmt.Println(" 1. Claude Max/Pro (OAuth) - Free inference with your subscription")
|
||||||
|
fmt.Println(" 2. Create API Key (OAuth) - Browser-based API key creation")
|
||||||
|
fmt.Println(" 3. Paste API Key - Manual API key entry")
|
||||||
|
fmt.Println()
|
||||||
fmt.Println("Examples:")
|
fmt.Println("Examples:")
|
||||||
fmt.Println(" picoclaw auth login --provider openai")
|
fmt.Println(" picoclaw auth login --provider openai")
|
||||||
fmt.Println(" picoclaw auth login --provider openai --device-code")
|
fmt.Println(" picoclaw auth login --provider openai --device-code")
|
||||||
|
|
@ -88,7 +93,7 @@ func authLoginCmd() {
|
||||||
case "openai":
|
case "openai":
|
||||||
authLoginOpenAI(useDeviceCode)
|
authLoginOpenAI(useDeviceCode)
|
||||||
case "anthropic":
|
case "anthropic":
|
||||||
authLoginPasteToken(provider)
|
authLoginAnthropic()
|
||||||
case "google-antigravity", "antigravity":
|
case "google-antigravity", "antigravity":
|
||||||
authLoginGoogleAntigravity()
|
authLoginGoogleAntigravity()
|
||||||
default:
|
default:
|
||||||
|
|
@ -258,6 +263,81 @@ func fetchGoogleUserEmail(accessToken string) (string, error) {
|
||||||
return userInfo.Email, nil
|
return userInfo.Email, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func authLoginAnthropic() {
|
||||||
|
fmt.Println("\nAnthropic Login Methods:")
|
||||||
|
fmt.Println(" 1. Claude Max/Pro (OAuth) - Use your Claude subscription for free inference")
|
||||||
|
fmt.Println(" 2. Create API Key (OAuth) - Authenticate via browser to create an API key")
|
||||||
|
fmt.Println(" 3. Paste API Key - Manually enter an existing API key")
|
||||||
|
fmt.Print("\nChoose [1/2/3]: ")
|
||||||
|
|
||||||
|
var choice string
|
||||||
|
fmt.Scanln(&choice)
|
||||||
|
|
||||||
|
switch strings.TrimSpace(choice) {
|
||||||
|
case "1":
|
||||||
|
authLoginAnthropicOAuth(auth.AnthropicOAuthMax)
|
||||||
|
case "2":
|
||||||
|
authLoginAnthropicOAuth(auth.AnthropicOAuthConsole)
|
||||||
|
case "3":
|
||||||
|
authLoginPasteToken("anthropic")
|
||||||
|
default:
|
||||||
|
fmt.Println("Invalid choice. Please enter 1, 2, or 3.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func authLoginAnthropicOAuth(mode auth.AnthropicOAuthMode) {
|
||||||
|
cred, err := auth.LoginAnthropicOAuth(mode)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Login failed: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := auth.SetCredential("anthropic", cred); err != nil {
|
||||||
|
fmt.Printf("Failed to save credentials: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
appCfg, err := loadConfig()
|
||||||
|
if err == nil {
|
||||||
|
appCfg.Providers.Anthropic.AuthMethod = "oauth"
|
||||||
|
|
||||||
|
found := false
|
||||||
|
for i := range appCfg.ModelList {
|
||||||
|
if isAnthropicModel(appCfg.ModelList[i].Model) {
|
||||||
|
appCfg.ModelList[i].AuthMethod = "oauth"
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
appCfg.ModelList = append(appCfg.ModelList, config.ModelConfig{
|
||||||
|
ModelName: "claude-sonnet-4.6",
|
||||||
|
Model: "anthropic/claude-sonnet-4.6",
|
||||||
|
AuthMethod: "oauth",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
appCfg.Agents.Defaults.Model = "claude-sonnet-4.6"
|
||||||
|
|
||||||
|
if err := config.SaveConfig(getConfigPath(), appCfg); err != nil {
|
||||||
|
fmt.Printf("Warning: could not update config: %v\n", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
modeStr := "Claude Max/Pro OAuth"
|
||||||
|
if mode == auth.AnthropicOAuthConsole {
|
||||||
|
modeStr = "API Key (via OAuth)"
|
||||||
|
}
|
||||||
|
fmt.Printf("\nAnthropic login successful! (%s)\n", modeStr)
|
||||||
|
if cred.Email != "" {
|
||||||
|
fmt.Printf("Account: %s\n", cred.Email)
|
||||||
|
}
|
||||||
|
if cred.SubscriptionType != "" {
|
||||||
|
fmt.Printf("Plan: %s\n", cred.SubscriptionType)
|
||||||
|
}
|
||||||
|
fmt.Println("Default model set to: claude-sonnet-4.6")
|
||||||
|
}
|
||||||
|
|
||||||
func authLoginPasteToken(provider string) {
|
func authLoginPasteToken(provider string) {
|
||||||
cred, err := auth.LoginPasteToken(provider, os.Stdin)
|
cred, err := auth.LoginPasteToken(provider, os.Stdin)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -433,6 +513,12 @@ func authStatusCmd() {
|
||||||
if cred.ProjectID != "" {
|
if cred.ProjectID != "" {
|
||||||
fmt.Printf(" Project: %s\n", cred.ProjectID)
|
fmt.Printf(" Project: %s\n", cred.ProjectID)
|
||||||
}
|
}
|
||||||
|
if cred.SubscriptionType != "" {
|
||||||
|
fmt.Printf(" Plan: %s\n", cred.SubscriptionType)
|
||||||
|
}
|
||||||
|
if cred.APIKey != "" {
|
||||||
|
fmt.Printf(" API Key: %s...%s\n", cred.APIKey[:4], cred.APIKey[len(cred.APIKey)-4:])
|
||||||
|
}
|
||||||
if !cred.ExpiresAt.IsZero() {
|
if !cred.ExpiresAt.IsZero() {
|
||||||
fmt.Printf(" Expires: %s\n", cred.ExpiresAt.Format("2006-01-02 15:04"))
|
fmt.Printf(" Expires: %s\n", cred.ExpiresAt.Format("2006-01-02 15:04"))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
445
pkg/auth/anthropic_oauth.go
Normal file
445
pkg/auth/anthropic_oauth.go
Normal file
|
|
@ -0,0 +1,445 @@
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
anthropicClientID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
|
||||||
|
|
||||||
|
// OAuth endpoints
|
||||||
|
anthropicConsoleAuthorizeURL = "https://console.anthropic.com/oauth/authorize"
|
||||||
|
anthropicClaudeAIAuthorizeURL = "https://claude.ai/oauth/authorize"
|
||||||
|
anthropicTokenURL = "https://console.anthropic.com/v1/oauth/token"
|
||||||
|
anthropicCallbackURL = "https://console.anthropic.com/oauth/code/callback"
|
||||||
|
|
||||||
|
// API endpoints
|
||||||
|
anthropicCreateAPIKeyURL = "https://api.anthropic.com/api/oauth/claude_cli/create_api_key"
|
||||||
|
anthropicRolesURL = "https://api.anthropic.com/api/oauth/claude_cli/roles"
|
||||||
|
anthropicProfileURL = "https://api.anthropic.com/api/oauth/profile"
|
||||||
|
|
||||||
|
// Scopes
|
||||||
|
anthropicScopesAll = "org:create_api_key user:profile user:inference"
|
||||||
|
anthropicScopesMaxPro = "user:profile user:inference"
|
||||||
|
|
||||||
|
// Beta header required for OAuth access
|
||||||
|
AnthropicOAuthBeta = "oauth-2025-04-20"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AnthropicOAuthMode determines which authorization endpoint to use.
|
||||||
|
type AnthropicOAuthMode string
|
||||||
|
|
||||||
|
const (
|
||||||
|
// AnthropicOAuthMax uses claude.ai for Claude Max/Pro subscription users.
|
||||||
|
// Tokens are used directly with Bearer auth.
|
||||||
|
AnthropicOAuthMax AnthropicOAuthMode = "max"
|
||||||
|
|
||||||
|
// AnthropicOAuthConsole uses console.anthropic.com for API key creation.
|
||||||
|
// After OAuth, an API key is created and used for subsequent requests.
|
||||||
|
AnthropicOAuthConsole AnthropicOAuthMode = "console"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AnthropicMaxOAuthConfig returns the OAuth config for Claude Max/Pro users.
|
||||||
|
func AnthropicMaxOAuthConfig() OAuthProviderConfig {
|
||||||
|
return OAuthProviderConfig{
|
||||||
|
Issuer: anthropicClaudeAIAuthorizeURL,
|
||||||
|
ClientID: anthropicClientID,
|
||||||
|
TokenURL: anthropicTokenURL,
|
||||||
|
Scopes: anthropicScopesAll,
|
||||||
|
Port: 1456,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AnthropicConsoleOAuthConfig returns the OAuth config for API key creation via console.
|
||||||
|
func AnthropicConsoleOAuthConfig() OAuthProviderConfig {
|
||||||
|
return OAuthProviderConfig{
|
||||||
|
Issuer: anthropicConsoleAuthorizeURL,
|
||||||
|
ClientID: anthropicClientID,
|
||||||
|
TokenURL: anthropicTokenURL,
|
||||||
|
Scopes: anthropicScopesAll,
|
||||||
|
Port: 1456,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildAnthropicAuthorizeURL constructs the Anthropic OAuth authorization URL.
|
||||||
|
func buildAnthropicAuthorizeURL(mode AnthropicOAuthMode, pkce PKCECodes) string {
|
||||||
|
var baseURL string
|
||||||
|
switch mode {
|
||||||
|
case AnthropicOAuthMax:
|
||||||
|
baseURL = anthropicClaudeAIAuthorizeURL
|
||||||
|
default:
|
||||||
|
baseURL = anthropicConsoleAuthorizeURL
|
||||||
|
}
|
||||||
|
|
||||||
|
params := url.Values{
|
||||||
|
"code": {"true"},
|
||||||
|
"client_id": {anthropicClientID},
|
||||||
|
"response_type": {"code"},
|
||||||
|
"redirect_uri": {anthropicCallbackURL},
|
||||||
|
"scope": {anthropicScopesAll},
|
||||||
|
"code_challenge": {pkce.CodeChallenge},
|
||||||
|
"code_challenge_method": {"S256"},
|
||||||
|
"state": {pkce.CodeVerifier},
|
||||||
|
}
|
||||||
|
|
||||||
|
return baseURL + "?" + params.Encode()
|
||||||
|
}
|
||||||
|
|
||||||
|
// anthropicTokenResponse represents the response from Anthropic's token endpoint.
|
||||||
|
type anthropicTokenResponse struct {
|
||||||
|
AccessToken string `json:"access_token"`
|
||||||
|
RefreshToken string `json:"refresh_token"`
|
||||||
|
ExpiresIn int `json:"expires_in"`
|
||||||
|
TokenType string `json:"token_type"`
|
||||||
|
Scope string `json:"scope"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExchangeAnthropicCode exchanges an authorization code for tokens.
|
||||||
|
// The code parameter may contain a "#state" suffix (e.g. "authcode#statevalue").
|
||||||
|
// Both the code and state must be sent in the token exchange request.
|
||||||
|
func ExchangeAnthropicCode(code, verifier string) (*anthropicTokenResponse, error) {
|
||||||
|
// The authorization code comes as "code#state" - split into both parts
|
||||||
|
parts := strings.SplitN(code, "#", 2)
|
||||||
|
authCode := parts[0]
|
||||||
|
state := ""
|
||||||
|
if len(parts) > 1 {
|
||||||
|
state = parts[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
payload := map[string]string{
|
||||||
|
"grant_type": "authorization_code",
|
||||||
|
"client_id": anthropicClientID,
|
||||||
|
"redirect_uri": anthropicCallbackURL,
|
||||||
|
"code": authCode,
|
||||||
|
"code_verifier": verifier,
|
||||||
|
}
|
||||||
|
if state != "" {
|
||||||
|
payload["state"] = state
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("marshaling token request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := http.Post(anthropicTokenURL, "application/json", strings.NewReader(string(body)))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("token exchange request: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
respBody, _ := io.ReadAll(resp.Body)
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("token exchange failed (status %d): %s", resp.StatusCode, string(respBody))
|
||||||
|
}
|
||||||
|
|
||||||
|
var tokenResp anthropicTokenResponse
|
||||||
|
if err := json.Unmarshal(respBody, &tokenResp); err != nil {
|
||||||
|
return nil, fmt.Errorf("parsing token response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if tokenResp.AccessToken == "" {
|
||||||
|
return nil, fmt.Errorf("no access token in response")
|
||||||
|
}
|
||||||
|
|
||||||
|
return &tokenResp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RefreshAnthropicToken refreshes an Anthropic OAuth access token.
|
||||||
|
func RefreshAnthropicToken(refreshToken string) (*anthropicTokenResponse, error) {
|
||||||
|
payload := map[string]string{
|
||||||
|
"grant_type": "refresh_token",
|
||||||
|
"client_id": anthropicClientID,
|
||||||
|
"refresh_token": refreshToken,
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("marshaling refresh request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := http.Post(anthropicTokenURL, "application/json", strings.NewReader(string(body)))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("token refresh request: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
respBody, _ := io.ReadAll(resp.Body)
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("token refresh failed (status %d): %s", resp.StatusCode, string(respBody))
|
||||||
|
}
|
||||||
|
|
||||||
|
var tokenResp anthropicTokenResponse
|
||||||
|
if err := json.Unmarshal(respBody, &tokenResp); err != nil {
|
||||||
|
return nil, fmt.Errorf("parsing refresh response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &tokenResp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RefreshAnthropicCredential refreshes an expired Anthropic OAuth credential,
|
||||||
|
// preserving all metadata (email, plan, org, scopes), and saves it to the store.
|
||||||
|
func RefreshAnthropicCredential(cred *AuthCredential) error {
|
||||||
|
if cred.RefreshToken == "" {
|
||||||
|
return fmt.Errorf("no refresh token available")
|
||||||
|
}
|
||||||
|
tokenResp, err := RefreshAnthropicToken(cred.RefreshToken)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
cred.AccessToken = tokenResp.AccessToken
|
||||||
|
if tokenResp.RefreshToken != "" {
|
||||||
|
cred.RefreshToken = tokenResp.RefreshToken
|
||||||
|
}
|
||||||
|
if tokenResp.ExpiresIn > 0 {
|
||||||
|
cred.ExpiresAt = time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second)
|
||||||
|
}
|
||||||
|
if tokenResp.Scope != "" {
|
||||||
|
cred.Scopes = tokenResp.Scope
|
||||||
|
}
|
||||||
|
return SetCredential("anthropic", cred)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateAnthropicAPIKey creates an API key using an OAuth access token.
|
||||||
|
func CreateAnthropicAPIKey(accessToken string) (string, error) {
|
||||||
|
req, err := http.NewRequest("POST", anthropicCreateAPIKeyURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("creating API key request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: 30 * time.Second}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("API key creation request: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
respBody, _ := io.ReadAll(resp.Body)
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return "", fmt.Errorf("API key creation failed (status %d): %s", resp.StatusCode, string(respBody))
|
||||||
|
}
|
||||||
|
|
||||||
|
var result struct {
|
||||||
|
RawKey string `json:"raw_key"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(respBody, &result); err != nil {
|
||||||
|
return "", fmt.Errorf("parsing API key response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.RawKey == "" {
|
||||||
|
return "", fmt.Errorf("no API key in response")
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.RawKey, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FetchAnthropicProfile fetches the user's Anthropic profile using an OAuth access token.
|
||||||
|
func FetchAnthropicProfile(accessToken string) (*AnthropicProfile, error) {
|
||||||
|
req, err := http.NewRequest("GET", anthropicProfileURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("creating profile request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: 10 * time.Second}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("profile request: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("profile request failed (status %d): %s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
var profile AnthropicProfile
|
||||||
|
if err := json.Unmarshal(body, &profile); err != nil {
|
||||||
|
return nil, fmt.Errorf("parsing profile response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &profile, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AnthropicProfile represents the user's Anthropic account profile.
|
||||||
|
type AnthropicProfile struct {
|
||||||
|
Account struct {
|
||||||
|
UUID string `json:"uuid"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
DisplayName string `json:"display_name"`
|
||||||
|
} `json:"account"`
|
||||||
|
Organization struct {
|
||||||
|
UUID string `json:"uuid"`
|
||||||
|
OrganizationType string `json:"organization_type"`
|
||||||
|
RateLimitTier string `json:"rate_limit_tier"`
|
||||||
|
} `json:"organization"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SubscriptionType returns a normalized subscription type string.
|
||||||
|
func (p *AnthropicProfile) SubscriptionType() string {
|
||||||
|
switch p.Organization.OrganizationType {
|
||||||
|
case "claude_max":
|
||||||
|
return "max"
|
||||||
|
case "claude_pro":
|
||||||
|
return "pro"
|
||||||
|
case "claude_enterprise":
|
||||||
|
return "enterprise"
|
||||||
|
case "claude_team":
|
||||||
|
return "team"
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoginAnthropicOAuth performs the Anthropic OAuth flow.
|
||||||
|
func LoginAnthropicOAuth(mode AnthropicOAuthMode) (*AuthCredential, error) {
|
||||||
|
pkce, err := GeneratePKCE()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("generating PKCE: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
authURL := buildAnthropicAuthorizeURL(mode, pkce)
|
||||||
|
|
||||||
|
fmt.Printf("\nOpen this URL in your browser to authenticate:\n\n %s\n\n", authURL)
|
||||||
|
|
||||||
|
if err := openBrowser(authURL); err != nil {
|
||||||
|
fmt.Println("Could not open browser automatically.")
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("After authorizing, you'll be redirected to a page with a code.")
|
||||||
|
fmt.Println("Paste the full URL or just the authorization code here:")
|
||||||
|
fmt.Print("\n> ")
|
||||||
|
|
||||||
|
var input string
|
||||||
|
fmt.Scanln(&input)
|
||||||
|
input = strings.TrimSpace(input)
|
||||||
|
|
||||||
|
if input == "" {
|
||||||
|
return nil, fmt.Errorf("no authorization code provided")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract code from URL if it's a full URL
|
||||||
|
code := input
|
||||||
|
if strings.Contains(input, "?") || strings.Contains(input, "#") {
|
||||||
|
u, err := url.Parse(input)
|
||||||
|
if err == nil {
|
||||||
|
if c := u.Query().Get("code"); c != "" {
|
||||||
|
code = c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tokenResp, err := ExchangeAnthropicCode(code, pkce.CodeVerifier)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("exchanging code: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var expiresAt time.Time
|
||||||
|
if tokenResp.ExpiresIn > 0 {
|
||||||
|
expiresAt = time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second)
|
||||||
|
}
|
||||||
|
|
||||||
|
cred := &AuthCredential{
|
||||||
|
AccessToken: tokenResp.AccessToken,
|
||||||
|
RefreshToken: tokenResp.RefreshToken,
|
||||||
|
ExpiresAt: expiresAt,
|
||||||
|
Provider: "anthropic",
|
||||||
|
AuthMethod: "oauth",
|
||||||
|
Scopes: tokenResp.Scope,
|
||||||
|
}
|
||||||
|
|
||||||
|
profile, err := FetchAnthropicProfile(tokenResp.AccessToken)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Warning: could not fetch profile: %v\n", err)
|
||||||
|
} else {
|
||||||
|
cred.Email = profile.Account.Email
|
||||||
|
cred.AccountID = profile.Account.UUID
|
||||||
|
cred.OrgID = profile.Organization.UUID
|
||||||
|
cred.SubscriptionType = profile.SubscriptionType()
|
||||||
|
if profile.Account.Email != "" {
|
||||||
|
fmt.Printf("Email: %s\n", profile.Account.Email)
|
||||||
|
}
|
||||||
|
if profile.SubscriptionType() != "" {
|
||||||
|
fmt.Printf("Plan: %s\n", profile.SubscriptionType())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if mode == AnthropicOAuthConsole {
|
||||||
|
fmt.Println("Creating API key...")
|
||||||
|
apiKey, err := CreateAnthropicAPIKey(tokenResp.AccessToken)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("creating API key: %w", err)
|
||||||
|
}
|
||||||
|
cred.APIKey = apiKey
|
||||||
|
cred.SubscriptionType = "api"
|
||||||
|
fmt.Println("API key created successfully!")
|
||||||
|
} else {
|
||||||
|
if cred.SubscriptionType == "" {
|
||||||
|
cred.SubscriptionType = "max"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return cred, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAnthropicAccessToken returns a valid access token for Anthropic OAuth credentials.
|
||||||
|
func GetAnthropicAccessToken(cred *AuthCredential) (string, error) {
|
||||||
|
if cred == nil {
|
||||||
|
return "", fmt.Errorf("no credentials")
|
||||||
|
}
|
||||||
|
|
||||||
|
if cred.APIKey != "" {
|
||||||
|
return cred.APIKey, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if cred.AuthMethod == "token" {
|
||||||
|
return cred.AccessToken, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if !cred.NeedsRefresh() {
|
||||||
|
return cred.AccessToken, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if cred.RefreshToken == "" {
|
||||||
|
return cred.AccessToken, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
tokenResp, err := RefreshAnthropicToken(cred.RefreshToken)
|
||||||
|
if err != nil {
|
||||||
|
return cred.AccessToken, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
cred.AccessToken = tokenResp.AccessToken
|
||||||
|
if tokenResp.RefreshToken != "" {
|
||||||
|
cred.RefreshToken = tokenResp.RefreshToken
|
||||||
|
}
|
||||||
|
if tokenResp.ExpiresIn > 0 {
|
||||||
|
cred.ExpiresAt = time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := SetCredential("anthropic", cred); err != nil {
|
||||||
|
fmt.Printf("Warning: could not save refreshed token: %v\n", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return cred.AccessToken, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsAnthropicMaxOAuth returns true if the credential is for the Claude Max/Pro OAuth flow.
|
||||||
|
func IsAnthropicMaxOAuth(cred *AuthCredential) bool {
|
||||||
|
if cred == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return cred.Provider == "anthropic" &&
|
||||||
|
cred.AuthMethod == "oauth" &&
|
||||||
|
cred.APIKey == "" &&
|
||||||
|
(cred.SubscriptionType == "max" || cred.SubscriptionType == "pro")
|
||||||
|
}
|
||||||
|
|
@ -8,14 +8,18 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
type AuthCredential struct {
|
type AuthCredential struct {
|
||||||
AccessToken string `json:"access_token"`
|
AccessToken string `json:"access_token"`
|
||||||
RefreshToken string `json:"refresh_token,omitempty"`
|
RefreshToken string `json:"refresh_token,omitempty"`
|
||||||
AccountID string `json:"account_id,omitempty"`
|
AccountID string `json:"account_id,omitempty"`
|
||||||
ExpiresAt time.Time `json:"expires_at,omitempty"`
|
ExpiresAt time.Time `json:"expires_at,omitempty"`
|
||||||
Provider string `json:"provider"`
|
Provider string `json:"provider"`
|
||||||
AuthMethod string `json:"auth_method"`
|
AuthMethod string `json:"auth_method"`
|
||||||
Email string `json:"email,omitempty"`
|
Email string `json:"email,omitempty"`
|
||||||
ProjectID string `json:"project_id,omitempty"`
|
ProjectID string `json:"project_id,omitempty"`
|
||||||
|
Scopes string `json:"scopes,omitempty"`
|
||||||
|
SubscriptionType string `json:"subscription_type,omitempty"`
|
||||||
|
OrgID string `json:"org_id,omitempty"`
|
||||||
|
APIKey string `json:"api_key,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type AuthStore struct {
|
type AuthStore struct {
|
||||||
|
|
|
||||||
217
pkg/providers/anthropic/oauth_middleware.go
Normal file
217
pkg/providers/anthropic/oauth_middleware.go
Normal file
|
|
@ -0,0 +1,217 @@
|
||||||
|
package anthropicprovider
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/anthropics/anthropic-sdk-go/option"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
oauthBetaHeader = "oauth-2025-04-20"
|
||||||
|
interleavedThinkingBeta = "interleaved-thinking-2025-05-14"
|
||||||
|
userAgent = "claude-cli/2.1.2 (external, cli)"
|
||||||
|
appName = "PicoClaw"
|
||||||
|
mcpToolPrefix = "mcp_"
|
||||||
|
)
|
||||||
|
|
||||||
|
// OAuthMiddlewareConfig configures the OAuth request middleware.
|
||||||
|
type OAuthMiddlewareConfig struct {
|
||||||
|
TokenSource func() (string, error)
|
||||||
|
SanitizePrompts bool
|
||||||
|
RenameTools bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewOAuthMiddleware returns SDK request options that transform every outgoing
|
||||||
|
// request into a Claude-Code-compatible OAuth request.
|
||||||
|
func NewOAuthMiddleware(cfg OAuthMiddlewareConfig) []option.RequestOption {
|
||||||
|
return []option.RequestOption{
|
||||||
|
option.WithMiddleware(func(req *http.Request, next option.MiddlewareNext) (*http.Response, error) {
|
||||||
|
token, err := cfg.TokenSource()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
req.Header.Del("X-Api-Key")
|
||||||
|
req.Header.Del("x-api-key")
|
||||||
|
req.Header.Set("User-Agent", userAgent)
|
||||||
|
|
||||||
|
existingBeta := req.Header.Get("anthropic-beta")
|
||||||
|
betaValues := []string{oauthBetaHeader, interleavedThinkingBeta}
|
||||||
|
if existingBeta != "" {
|
||||||
|
betaValues = append(betaValues, existingBeta)
|
||||||
|
}
|
||||||
|
req.Header.Set("anthropic-beta", strings.Join(betaValues, ","))
|
||||||
|
|
||||||
|
if strings.Contains(req.URL.Path, "/v1/messages") {
|
||||||
|
q := req.URL.Query()
|
||||||
|
q.Set("beta", "true")
|
||||||
|
req.URL.RawQuery = q.Encode()
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Body != nil && req.Method == "POST" && (cfg.RenameTools || cfg.SanitizePrompts) {
|
||||||
|
bodyBytes, readErr := io.ReadAll(req.Body)
|
||||||
|
req.Body.Close()
|
||||||
|
if readErr == nil && len(bodyBytes) > 0 {
|
||||||
|
bodyBytes = transformRequestBody(bodyBytes, cfg.RenameTools, cfg.SanitizePrompts)
|
||||||
|
req.Body = io.NopCloser(bytes.NewReader(bodyBytes))
|
||||||
|
req.ContentLength = int64(len(bodyBytes))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := next(req)
|
||||||
|
if err != nil {
|
||||||
|
return resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.RenameTools && resp != nil && resp.Body != nil {
|
||||||
|
resp.Body = newToolNameStripper(resp.Body)
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp, err
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func transformRequestBody(body []byte, renameTools, sanitizePrompts bool) []byte {
|
||||||
|
var parsed map[string]interface{}
|
||||||
|
if err := json.Unmarshal(body, &parsed); err != nil {
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
|
||||||
|
modified := false
|
||||||
|
|
||||||
|
if renameTools {
|
||||||
|
modified = prefixToolNames(parsed) || modified
|
||||||
|
}
|
||||||
|
|
||||||
|
if sanitizePrompts {
|
||||||
|
modified = sanitizeSystemPrompts(parsed) || modified
|
||||||
|
}
|
||||||
|
|
||||||
|
if !modified {
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := json.Marshal(parsed)
|
||||||
|
if err != nil {
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func prefixToolNames(parsed map[string]interface{}) bool {
|
||||||
|
modified := false
|
||||||
|
|
||||||
|
if tools, ok := parsed["tools"].([]interface{}); ok {
|
||||||
|
for _, t := range tools {
|
||||||
|
if tool, ok := t.(map[string]interface{}); ok {
|
||||||
|
if name, ok := tool["name"].(string); ok && !strings.HasPrefix(name, mcpToolPrefix) {
|
||||||
|
tool["name"] = mcpToolPrefix + name
|
||||||
|
modified = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if messages, ok := parsed["messages"].([]interface{}); ok {
|
||||||
|
for _, m := range messages {
|
||||||
|
if msg, ok := m.(map[string]interface{}); ok {
|
||||||
|
if content, ok := msg["content"].([]interface{}); ok {
|
||||||
|
for _, c := range content {
|
||||||
|
if block, ok := c.(map[string]interface{}); ok {
|
||||||
|
if block["type"] == "tool_use" {
|
||||||
|
if name, ok := block["name"].(string); ok && !strings.HasPrefix(name, mcpToolPrefix) {
|
||||||
|
block["name"] = mcpToolPrefix + name
|
||||||
|
modified = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if block["type"] == "tool_result" {
|
||||||
|
if name, ok := block["name"].(string); ok && !strings.HasPrefix(name, mcpToolPrefix) {
|
||||||
|
block["name"] = mcpToolPrefix + name
|
||||||
|
modified = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return modified
|
||||||
|
}
|
||||||
|
|
||||||
|
func sanitizeSystemPrompts(parsed map[string]interface{}) bool {
|
||||||
|
modified := false
|
||||||
|
|
||||||
|
if system, ok := parsed["system"].([]interface{}); ok {
|
||||||
|
for _, s := range system {
|
||||||
|
if block, ok := s.(map[string]interface{}); ok {
|
||||||
|
if text, ok := block["text"].(string); ok {
|
||||||
|
newText := sanitizeText(text)
|
||||||
|
if newText != text {
|
||||||
|
block["text"] = newText
|
||||||
|
modified = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if system, ok := parsed["system"].(string); ok {
|
||||||
|
newSystem := sanitizeText(system)
|
||||||
|
if newSystem != system {
|
||||||
|
parsed["system"] = newSystem
|
||||||
|
modified = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return modified
|
||||||
|
}
|
||||||
|
|
||||||
|
func sanitizeText(text string) string {
|
||||||
|
text = strings.ReplaceAll(text, "PicoClaw", "Claude Code")
|
||||||
|
text = strings.ReplaceAll(text, "picoclaw", "Claude")
|
||||||
|
text = strings.ReplaceAll(text, "Picoclaw", "Claude Code")
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
type toolNameStripper struct {
|
||||||
|
source io.ReadCloser
|
||||||
|
buffer bytes.Buffer
|
||||||
|
remainder []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
func newToolNameStripper(source io.ReadCloser) io.ReadCloser {
|
||||||
|
return &toolNameStripper{source: source}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *toolNameStripper) Read(p []byte) (int, error) {
|
||||||
|
if s.buffer.Len() > 0 {
|
||||||
|
return s.buffer.Read(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
n, err := s.source.Read(p)
|
||||||
|
if n > 0 {
|
||||||
|
data := string(p[:n])
|
||||||
|
data = stripMCPPrefix(data)
|
||||||
|
copy(p, []byte(data))
|
||||||
|
n = len(data)
|
||||||
|
}
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *toolNameStripper) Close() error {
|
||||||
|
return s.source.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func stripMCPPrefix(data string) string {
|
||||||
|
data = strings.ReplaceAll(data, `"name":"mcp_`, `"name":"`)
|
||||||
|
data = strings.ReplaceAll(data, `"name": "mcp_`, `"name": "`)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
@ -64,13 +64,28 @@ func NewProviderWithTokenSourceAndBaseURL(token string, tokenSource func() (stri
|
||||||
return p
|
return p
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *Provider) Chat(
|
func NewProviderWithOAuthMiddleware(tokenSource func() (string, error), apiBase string) *Provider {
|
||||||
ctx context.Context,
|
baseURL := normalizeBaseURL(apiBase)
|
||||||
messages []Message,
|
middlewareOpts := NewOAuthMiddleware(OAuthMiddlewareConfig{
|
||||||
tools []ToolDefinition,
|
TokenSource: tokenSource,
|
||||||
model string,
|
SanitizePrompts: true,
|
||||||
options map[string]any,
|
RenameTools: true,
|
||||||
) (*LLMResponse, error) {
|
})
|
||||||
|
|
||||||
|
clientOpts := []option.RequestOption{
|
||||||
|
option.WithBaseURL(baseURL),
|
||||||
|
option.WithAuthToken("oauth-managed"),
|
||||||
|
}
|
||||||
|
clientOpts = append(clientOpts, middlewareOpts...)
|
||||||
|
|
||||||
|
client := anthropic.NewClient(clientOpts...)
|
||||||
|
return &Provider{
|
||||||
|
client: &client,
|
||||||
|
baseURL: baseURL,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Provider) Chat(ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (*LLMResponse, error) {
|
||||||
var opts []option.RequestOption
|
var opts []option.RequestOption
|
||||||
if p.tokenSource != nil {
|
if p.tokenSource != nil {
|
||||||
tok, err := p.tokenSource()
|
tok, err := p.tokenSource()
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,12 @@ func (p *ClaudeProvider) GetDefaultModel() string {
|
||||||
return p.delegate.GetDefaultModel()
|
return p.delegate.GetDefaultModel()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func NewClaudeProviderWithOAuthMiddleware(tokenSource func() (string, error)) *ClaudeProvider {
|
||||||
|
return &ClaudeProvider{
|
||||||
|
delegate: anthropicprovider.NewProviderWithOAuthMiddleware(tokenSource, ""),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func createClaudeTokenSource() func() (string, error) {
|
func createClaudeTokenSource() func() (string, error) {
|
||||||
return func() (string, error) {
|
return func() (string, error) {
|
||||||
cred, err := getCredential("anthropic")
|
cred, err := getCredential("anthropic")
|
||||||
|
|
@ -67,3 +73,20 @@ func createClaudeTokenSource() func() (string, error) {
|
||||||
return cred.AccessToken, nil
|
return cred.AccessToken, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func createClaudeOAuthTokenSource() func() (string, error) {
|
||||||
|
return func() (string, error) {
|
||||||
|
cred, err := getCredential("anthropic")
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("loading auth credentials: %w", err)
|
||||||
|
}
|
||||||
|
if cred == nil {
|
||||||
|
return "", fmt.Errorf("no credentials for anthropic. Run: picoclaw auth login --provider anthropic")
|
||||||
|
}
|
||||||
|
token, err := getAnthropicAccessToken(cred)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return token, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,8 @@ import (
|
||||||
const defaultAnthropicAPIBase = "https://api.anthropic.com/v1"
|
const defaultAnthropicAPIBase = "https://api.anthropic.com/v1"
|
||||||
|
|
||||||
var getCredential = auth.GetCredential
|
var getCredential = auth.GetCredential
|
||||||
|
var getAnthropicAccessToken = auth.GetAnthropicAccessToken
|
||||||
|
var isAnthropicMaxOAuth = auth.IsAnthropicMaxOAuth
|
||||||
|
|
||||||
type providerType int
|
type providerType int
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,24 @@ func createClaudeAuthProvider() (LLMProvider, error) {
|
||||||
if cred == nil {
|
if cred == nil {
|
||||||
return nil, fmt.Errorf("no credentials for anthropic. Run: picoclaw auth login --provider anthropic")
|
return nil, fmt.Errorf("no credentials for anthropic. Run: picoclaw auth login --provider anthropic")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if isAnthropicMaxOAuth(cred) {
|
||||||
|
return NewClaudeProviderWithOAuthMiddleware(createClaudeOAuthTokenSource()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if cred.APIKey != "" {
|
||||||
|
return NewClaudeProviderWithTokenSource(cred.APIKey, func() (string, error) {
|
||||||
|
c, err := getCredential("anthropic")
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if c != nil && c.APIKey != "" {
|
||||||
|
return c.APIKey, nil
|
||||||
|
}
|
||||||
|
return c.AccessToken, nil
|
||||||
|
}), nil
|
||||||
|
}
|
||||||
|
|
||||||
return NewClaudeProviderWithTokenSource(cred.AccessToken, createClaudeTokenSource()), nil
|
return NewClaudeProviderWithTokenSource(cred.AccessToken, createClaudeTokenSource()), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue