From 9453606f9c18108624b2ee1852b867d8480e5c19 Mon Sep 17 00:00:00 2001 From: Rahul Bansal Date: Sat, 21 Feb 2026 02:18:35 +0530 Subject: [PATCH] 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. --- cmd/picoclaw/cmd_auth.go | 88 +++- pkg/auth/anthropic_oauth.go | 445 ++++++++++++++++++++ pkg/auth/store.go | 20 +- pkg/providers/anthropic/oauth_middleware.go | 217 ++++++++++ pkg/providers/anthropic/provider.go | 29 +- pkg/providers/claude_provider.go | 23 + pkg/providers/factory.go | 2 + pkg/providers/factory_provider.go | 18 + 8 files changed, 826 insertions(+), 16 deletions(-) create mode 100644 pkg/auth/anthropic_oauth.go create mode 100644 pkg/providers/anthropic/oauth_middleware.go diff --git a/cmd/picoclaw/cmd_auth.go b/cmd/picoclaw/cmd_auth.go index 729c56177..26f64e627 100644 --- a/cmd/picoclaw/cmd_auth.go +++ b/cmd/picoclaw/cmd_auth.go @@ -51,6 +51,11 @@ func authHelp() { fmt.Println(" --provider Provider to login with (openai, anthropic, google-antigravity)") fmt.Println(" --device-code Use device code flow (for headless environments)") 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(" picoclaw auth login --provider openai") fmt.Println(" picoclaw auth login --provider openai --device-code") @@ -88,7 +93,7 @@ func authLoginCmd() { case "openai": authLoginOpenAI(useDeviceCode) case "anthropic": - authLoginPasteToken(provider) + authLoginAnthropic() case "google-antigravity", "antigravity": authLoginGoogleAntigravity() default: @@ -258,6 +263,81 @@ func fetchGoogleUserEmail(accessToken string) (string, error) { 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) { cred, err := auth.LoginPasteToken(provider, os.Stdin) if err != nil { @@ -433,6 +513,12 @@ func authStatusCmd() { if 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() { fmt.Printf(" Expires: %s\n", cred.ExpiresAt.Format("2006-01-02 15:04")) } diff --git a/pkg/auth/anthropic_oauth.go b/pkg/auth/anthropic_oauth.go new file mode 100644 index 000000000..2e9e395fe --- /dev/null +++ b/pkg/auth/anthropic_oauth.go @@ -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") +} diff --git a/pkg/auth/store.go b/pkg/auth/store.go index 64708421b..a1e1ec9e3 100644 --- a/pkg/auth/store.go +++ b/pkg/auth/store.go @@ -8,14 +8,18 @@ import ( ) type AuthCredential struct { - AccessToken string `json:"access_token"` - RefreshToken string `json:"refresh_token,omitempty"` - AccountID string `json:"account_id,omitempty"` - ExpiresAt time.Time `json:"expires_at,omitempty"` - Provider string `json:"provider"` - AuthMethod string `json:"auth_method"` - Email string `json:"email,omitempty"` - ProjectID string `json:"project_id,omitempty"` + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token,omitempty"` + AccountID string `json:"account_id,omitempty"` + ExpiresAt time.Time `json:"expires_at,omitempty"` + Provider string `json:"provider"` + AuthMethod string `json:"auth_method"` + Email string `json:"email,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 { diff --git a/pkg/providers/anthropic/oauth_middleware.go b/pkg/providers/anthropic/oauth_middleware.go new file mode 100644 index 000000000..a6daad24f --- /dev/null +++ b/pkg/providers/anthropic/oauth_middleware.go @@ -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 +} diff --git a/pkg/providers/anthropic/provider.go b/pkg/providers/anthropic/provider.go index 35f6b8f62..b23236869 100644 --- a/pkg/providers/anthropic/provider.go +++ b/pkg/providers/anthropic/provider.go @@ -64,13 +64,28 @@ func NewProviderWithTokenSourceAndBaseURL(token string, tokenSource func() (stri return p } -func (p *Provider) Chat( - ctx context.Context, - messages []Message, - tools []ToolDefinition, - model string, - options map[string]any, -) (*LLMResponse, error) { +func NewProviderWithOAuthMiddleware(tokenSource func() (string, error), apiBase string) *Provider { + baseURL := normalizeBaseURL(apiBase) + middlewareOpts := NewOAuthMiddleware(OAuthMiddlewareConfig{ + TokenSource: tokenSource, + SanitizePrompts: true, + RenameTools: true, + }) + + 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 if p.tokenSource != nil { tok, err := p.tokenSource() diff --git a/pkg/providers/claude_provider.go b/pkg/providers/claude_provider.go index 60639ca18..f0f516360 100644 --- a/pkg/providers/claude_provider.go +++ b/pkg/providers/claude_provider.go @@ -55,6 +55,12 @@ func (p *ClaudeProvider) GetDefaultModel() string { return p.delegate.GetDefaultModel() } +func NewClaudeProviderWithOAuthMiddleware(tokenSource func() (string, error)) *ClaudeProvider { + return &ClaudeProvider{ + delegate: anthropicprovider.NewProviderWithOAuthMiddleware(tokenSource, ""), + } +} + func createClaudeTokenSource() func() (string, error) { return func() (string, error) { cred, err := getCredential("anthropic") @@ -67,3 +73,20 @@ func createClaudeTokenSource() func() (string, error) { 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 + } +} diff --git a/pkg/providers/factory.go b/pkg/providers/factory.go index b6f1b5e21..9413ce571 100644 --- a/pkg/providers/factory.go +++ b/pkg/providers/factory.go @@ -11,6 +11,8 @@ import ( const defaultAnthropicAPIBase = "https://api.anthropic.com/v1" var getCredential = auth.GetCredential +var getAnthropicAccessToken = auth.GetAnthropicAccessToken +var isAnthropicMaxOAuth = auth.IsAnthropicMaxOAuth type providerType int diff --git a/pkg/providers/factory_provider.go b/pkg/providers/factory_provider.go index 74fe8a36c..7d875708a 100644 --- a/pkg/providers/factory_provider.go +++ b/pkg/providers/factory_provider.go @@ -21,6 +21,24 @@ func createClaudeAuthProvider() (LLMProvider, error) { if cred == nil { 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 }