Anthropic API proxy with OAuth credential rotation and Claude Code fingerprinting
Sniffs a real Claude Code request on startup to capture exact HTTP headers, then replays them for all proxied requests. Injects the billing header with per-request SHA256 fingerprint into the system prompt. Uses utls with Chrome TLS fingerprint to pass Cloudflare's bot detection on api.anthropic.com. Supports both streaming (SSE) and non-streaming modes, round-robin credential selection with automatic failover, and loading OAuth tokens from both cli-proxy-api auth files and native ~/.claude/.credentials.json.
This commit is contained in:
@@ -0,0 +1,109 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
tokenEndpoint = "https://api.anthropic.com/v1/oauth/token"
|
||||
clientID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
|
||||
)
|
||||
|
||||
type tokenRequest struct {
|
||||
ClientID string `json:"client_id"`
|
||||
GrantType string `json:"grant_type"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
}
|
||||
|
||||
type tokenResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
Account struct {
|
||||
EmailAddress string `json:"email_address"`
|
||||
} `json:"account"`
|
||||
}
|
||||
|
||||
type authFileJSON struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
Email string `json:"email"`
|
||||
Expired string `json:"expired"`
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
// RefreshToken performs an OAuth token refresh for the given credential.
|
||||
func RefreshToken(ctx context.Context, cred *Credential) error {
|
||||
reqBody := tokenRequest{
|
||||
ClientID: clientID,
|
||||
GrantType: "refresh_token",
|
||||
RefreshToken: cred.RefreshToken,
|
||||
}
|
||||
|
||||
body, err := json.Marshal(reqBody)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal refresh request: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, tokenEndpoint, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return fmt.Errorf("create refresh request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("execute refresh request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("refresh failed with status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var tokenResp tokenResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
|
||||
return fmt.Errorf("decode refresh response: %w", err)
|
||||
}
|
||||
|
||||
cred.mu.Lock()
|
||||
cred.AccessToken = tokenResp.AccessToken
|
||||
cred.RefreshToken = tokenResp.RefreshToken
|
||||
cred.ExpiresAt = time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second)
|
||||
if tokenResp.Account.EmailAddress != "" {
|
||||
cred.Email = tokenResp.Account.EmailAddress
|
||||
}
|
||||
cred.mu.Unlock()
|
||||
|
||||
return persistCredential(cred)
|
||||
}
|
||||
|
||||
func persistCredential(cred *Credential) error {
|
||||
cred.mu.Lock()
|
||||
data := authFileJSON{
|
||||
AccessToken: cred.AccessToken,
|
||||
RefreshToken: cred.RefreshToken,
|
||||
Email: cred.Email,
|
||||
Expired: cred.ExpiresAt.Format(time.RFC3339),
|
||||
Type: "claude",
|
||||
}
|
||||
filePath := cred.FilePath
|
||||
cred.mu.Unlock()
|
||||
|
||||
out, err := json.MarshalIndent(data, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal auth file: %w", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(filePath, out, 0600); err != nil {
|
||||
return fmt.Errorf("write auth file %s: %w", filePath, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Pool struct {
|
||||
creds []*Credential
|
||||
cursor int
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func NewPool(creds []*Credential) *Pool {
|
||||
return &Pool{creds: creds}
|
||||
}
|
||||
|
||||
func (p *Pool) Pick() (*Credential, error) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
n := len(p.creds)
|
||||
if n == 0 {
|
||||
return nil, fmt.Errorf("no credentials available")
|
||||
}
|
||||
|
||||
for i := 0; i < n; i++ {
|
||||
idx := (p.cursor + i) % n
|
||||
cred := p.creds[idx]
|
||||
if !cred.IsOnCooldown() {
|
||||
p.cursor = (idx + 1) % n
|
||||
return cred, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("all %d credentials are on cooldown", n)
|
||||
}
|
||||
|
||||
func (p *Pool) MarkFailure(cred *Credential, statusCode int) {
|
||||
switch {
|
||||
case statusCode == 429:
|
||||
cred.SetCooldown(30 * time.Second)
|
||||
case statusCode >= 500:
|
||||
cred.SetCooldown(5 * time.Second)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Pool) MarkSuccess(cred *Credential) {
|
||||
cred.mu.Lock()
|
||||
defer cred.mu.Unlock()
|
||||
cred.CooldownUntil = time.Time{}
|
||||
}
|
||||
|
||||
func (p *Pool) RefreshExpiring(ctx context.Context) {
|
||||
p.mu.Lock()
|
||||
creds := make([]*Credential, len(p.creds))
|
||||
copy(creds, p.creds)
|
||||
p.mu.Unlock()
|
||||
|
||||
threshold := time.Now().Add(5 * time.Minute)
|
||||
for _, cred := range creds {
|
||||
cred.mu.Lock()
|
||||
needsRefresh := cred.ExpiresAt.Before(threshold)
|
||||
email := cred.Email
|
||||
cred.mu.Unlock()
|
||||
|
||||
if needsRefresh {
|
||||
log.Printf("refreshing token for %s (expires %s)", email, cred.ExpiresAt.Format(time.RFC3339))
|
||||
if err := RefreshToken(ctx, cred); err != nil {
|
||||
log.Printf("failed to refresh token for %s: %v", email, err)
|
||||
} else {
|
||||
log.Printf("refreshed token for %s, new expiry %s", email, cred.ExpiresAt.Format(time.RFC3339))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Credential represents an Anthropic API credential loaded from a JSON file.
|
||||
type Credential struct {
|
||||
ID string
|
||||
Email string
|
||||
AccessToken string
|
||||
RefreshToken string
|
||||
ExpiresAt time.Time
|
||||
FilePath string
|
||||
CooldownUntil time.Time
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// IsExpired returns true if the credential's access token has expired.
|
||||
func (c *Credential) IsExpired() bool {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return time.Now().After(c.ExpiresAt)
|
||||
}
|
||||
|
||||
// IsOnCooldown returns true if the credential is currently on cooldown.
|
||||
func (c *Credential) IsOnCooldown() bool {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return time.Now().Before(c.CooldownUntil)
|
||||
}
|
||||
|
||||
// SetCooldown puts the credential on cooldown for the given duration.
|
||||
func (c *Credential) SetCooldown(duration time.Duration) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.CooldownUntil = time.Now().Add(duration)
|
||||
}
|
||||
|
||||
// Token returns the current access token.
|
||||
func (c *Credential) Token() string {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.AccessToken
|
||||
}
|
||||
Reference in New Issue
Block a user