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:
Rahul Bansal 2026-02-21 02:18:35 +05:30
parent 80c8b57533
commit 9453606f9c
8 changed files with 826 additions and 16 deletions

View file

@ -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
View 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")
}

View file

@ -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 {

View 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
}

View file

@ -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()

View file

@ -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
}
}

View file

@ -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

View file

@ -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
} }