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
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/fujin/anthropic-proxy/internal/auth"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Port int `yaml:"port"`
|
||||
APIKeys []string `yaml:"api_keys"`
|
||||
AuthDir string `yaml:"auth_dir"`
|
||||
ClaudeCredentials string `yaml:"claude_credentials"`
|
||||
ClaudeBinary string `yaml:"claude_binary"`
|
||||
}
|
||||
|
||||
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"`
|
||||
}
|
||||
|
||||
type claudeCredentialsJSON struct {
|
||||
ClaudeAiOauth struct {
|
||||
AccessToken string `json:"accessToken"`
|
||||
RefreshToken string `json:"refreshToken"`
|
||||
ExpiresAt int64 `json:"expiresAt"`
|
||||
SubscriptionType string `json:"subscriptionType"`
|
||||
} `json:"claudeAiOauth"`
|
||||
}
|
||||
|
||||
func Load(path string) (*Config, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read config %s: %w", path, err)
|
||||
}
|
||||
|
||||
cfg := &Config{Port: 8080}
|
||||
if err := yaml.Unmarshal(data, cfg); err != nil {
|
||||
return nil, fmt.Errorf("parse config: %w", err)
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func LoadCredentials(cfg *Config) ([]*auth.Credential, error) {
|
||||
var creds []*auth.Credential
|
||||
|
||||
if cfg.ClaudeCredentials != "" {
|
||||
cred, err := loadClaudeCredentials(cfg.ClaudeCredentials)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load claude credentials: %w", err)
|
||||
}
|
||||
creds = append(creds, cred)
|
||||
}
|
||||
|
||||
if cfg.AuthDir != "" {
|
||||
dirCreds, err := loadAuthDir(cfg.AuthDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load auth dir: %w", err)
|
||||
}
|
||||
creds = append(creds, dirCreds...)
|
||||
}
|
||||
|
||||
return creds, nil
|
||||
}
|
||||
|
||||
func loadClaudeCredentials(path string) (*auth.Credential, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var cf claudeCredentialsJSON
|
||||
if err := json.Unmarshal(data, &cf); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
oauth := cf.ClaudeAiOauth
|
||||
if oauth.AccessToken == "" {
|
||||
return nil, fmt.Errorf("no access token in %s", path)
|
||||
}
|
||||
|
||||
return &auth.Credential{
|
||||
ID: "claude-native",
|
||||
Email: oauth.SubscriptionType,
|
||||
AccessToken: oauth.AccessToken,
|
||||
RefreshToken: oauth.RefreshToken,
|
||||
ExpiresAt: time.UnixMilli(oauth.ExpiresAt),
|
||||
FilePath: path,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func loadAuthDir(authDir string) ([]*auth.Credential, error) {
|
||||
pattern := filepath.Join(authDir, "*.json")
|
||||
files, err := filepath.Glob(pattern)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("glob auth files: %w", err)
|
||||
}
|
||||
|
||||
var creds []*auth.Credential
|
||||
for _, f := range files {
|
||||
cred, err := loadAuthFile(f)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load auth file %s: %w", f, err)
|
||||
}
|
||||
creds = append(creds, cred)
|
||||
}
|
||||
|
||||
return creds, nil
|
||||
}
|
||||
|
||||
func loadAuthFile(path string) (*auth.Credential, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var af authFileJSON
|
||||
if err := json.Unmarshal(data, &af); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
expiresAt, err := time.Parse(time.RFC3339, af.Expired)
|
||||
if err != nil {
|
||||
expiresAt, err = time.Parse("2006-01-02T15:04:05", af.Expired)
|
||||
if err != nil {
|
||||
expiresAt = time.Now()
|
||||
}
|
||||
}
|
||||
|
||||
return &auth.Credential{
|
||||
ID: filepath.Base(path),
|
||||
Email: af.Email,
|
||||
AccessToken: af.AccessToken,
|
||||
RefreshToken: af.RefreshToken,
|
||||
ExpiresAt: expiresAt,
|
||||
FilePath: path,
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"unicode/utf16"
|
||||
|
||||
"github.com/tidwall/gjson"
|
||||
"github.com/tidwall/sjson"
|
||||
)
|
||||
|
||||
const fingerprintSalt = "59cf53e54c78"
|
||||
|
||||
func computeFingerprint(firstUserMessage string, version string) string {
|
||||
indices := []int{4, 7, 20}
|
||||
runes := utf16.Encode([]rune(firstUserMessage))
|
||||
var chars string
|
||||
for _, i := range indices {
|
||||
if i < len(runes) {
|
||||
chars += string(rune(runes[i]))
|
||||
} else {
|
||||
chars += "0"
|
||||
}
|
||||
}
|
||||
input := fingerprintSalt + chars + version
|
||||
hash := sha256.Sum256([]byte(input))
|
||||
return hex.EncodeToString(hash[:])[:3]
|
||||
}
|
||||
|
||||
func extractFirstUserMessage(body []byte) string {
|
||||
messages := gjson.GetBytes(body, "messages")
|
||||
if !messages.Exists() || !messages.IsArray() {
|
||||
return ""
|
||||
}
|
||||
for _, msg := range messages.Array() {
|
||||
if msg.Get("role").String() != "user" {
|
||||
continue
|
||||
}
|
||||
content := msg.Get("content")
|
||||
if content.Type == gjson.String {
|
||||
return content.String()
|
||||
}
|
||||
if content.IsArray() {
|
||||
for _, block := range content.Array() {
|
||||
if block.Get("type").String() == "text" {
|
||||
return block.Get("text").String()
|
||||
}
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func buildBillingHeader(body []byte, version string) string {
|
||||
userMsg := extractFirstUserMessage(body)
|
||||
fp := computeFingerprint(userMsg, version)
|
||||
return fmt.Sprintf("x-anthropic-billing-header: cc_version=%s.%s; cc_entrypoint=cli; cch=00000;", version, fp)
|
||||
}
|
||||
|
||||
func injectBillingHeader(body []byte, version string) []byte {
|
||||
header := buildBillingHeader(body, version)
|
||||
billingBlock := map[string]interface{}{"type": "text", "text": header}
|
||||
billingJSON, _ := json.Marshal(billingBlock)
|
||||
|
||||
existing := gjson.GetBytes(body, "system")
|
||||
if !existing.Exists() {
|
||||
body, _ = sjson.SetRawBytes(body, "system", []byte("["+string(billingJSON)+"]"))
|
||||
return body
|
||||
}
|
||||
|
||||
if existing.IsArray() {
|
||||
items := make([]json.RawMessage, 0, len(existing.Array())+1)
|
||||
items = append(items, billingJSON)
|
||||
for _, item := range existing.Array() {
|
||||
items = append(items, json.RawMessage(item.Raw))
|
||||
}
|
||||
systemJSON, _ := json.Marshal(items)
|
||||
body, _ = sjson.SetRawBytes(body, "system", systemJSON)
|
||||
return body
|
||||
}
|
||||
|
||||
origText := existing.String()
|
||||
origBlock := map[string]string{"type": "text", "text": origText}
|
||||
origJSON, _ := json.Marshal(origBlock)
|
||||
body, _ = sjson.SetRawBytes(body, "system", []byte("["+string(billingJSON)+","+string(origJSON)+"]"))
|
||||
return body
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/tidwall/gjson"
|
||||
|
||||
"github.com/fujin/anthropic-proxy/internal/auth"
|
||||
)
|
||||
|
||||
func HandleMessages(pool *auth.Pool, profile *SniffedProfile) gin.HandlerFunc {
|
||||
upstream := NewUpstreamClient(profile)
|
||||
|
||||
return func(c *gin.Context) {
|
||||
body, err := io.ReadAll(c.Request.Body)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "failed to read request body"})
|
||||
return
|
||||
}
|
||||
|
||||
cred, err := pool.Pick()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
isStream := gjson.GetBytes(body, "stream").Bool()
|
||||
|
||||
if isStream {
|
||||
handleStream(c, upstream, pool, cred, body)
|
||||
} else {
|
||||
handleNonStream(c, upstream, pool, cred, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func handleNonStream(c *gin.Context, upstream *UpstreamClient, pool *auth.Pool, cred *auth.Credential, body []byte) {
|
||||
respBody, headers, statusCode, err := upstream.Execute(c.Request.Context(), cred, body)
|
||||
if err != nil {
|
||||
log.Printf("upstream error for %s: %v", cred.Email, err)
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "upstream request failed"})
|
||||
return
|
||||
}
|
||||
|
||||
if statusCode >= 400 {
|
||||
pool.MarkFailure(cred, statusCode)
|
||||
log.Printf("upstream %d for %s", statusCode, cred.Email)
|
||||
} else {
|
||||
pool.MarkSuccess(cred)
|
||||
}
|
||||
|
||||
for _, h := range []string{"Content-Type", "X-Request-Id"} {
|
||||
if v := headers.Get(h); v != "" {
|
||||
c.Header(h, v)
|
||||
}
|
||||
}
|
||||
|
||||
c.Data(statusCode, headers.Get("Content-Type"), respBody)
|
||||
}
|
||||
|
||||
func handleStream(c *gin.Context, upstream *UpstreamClient, pool *auth.Pool, cred *auth.Credential, body []byte) {
|
||||
resp, err := upstream.ExecuteStream(c.Request.Context(), cred, body)
|
||||
if err != nil {
|
||||
log.Printf("upstream stream error for %s: %v", cred.Email, err)
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "upstream stream request failed"})
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
pool.MarkFailure(cred, resp.StatusCode)
|
||||
log.Printf("upstream stream %d for %s", resp.StatusCode, cred.Email)
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
c.Data(resp.StatusCode, resp.Header.Get("Content-Type"), respBody)
|
||||
return
|
||||
}
|
||||
|
||||
pool.MarkSuccess(cred)
|
||||
|
||||
c.Header("Content-Type", "text/event-stream")
|
||||
c.Header("Cache-Control", "no-cache")
|
||||
c.Header("Connection", "keep-alive")
|
||||
c.Status(http.StatusOK)
|
||||
|
||||
flusher, ok := c.Writer.(http.Flusher)
|
||||
if !ok {
|
||||
log.Printf("response writer does not support flushing")
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "streaming not supported"})
|
||||
return
|
||||
}
|
||||
|
||||
scanner := bufio.NewScanner(resp.Body)
|
||||
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
c.Writer.WriteString(line + "\n")
|
||||
flusher.Flush()
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
log.Printf("stream scan error: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
// SniffedProfile holds everything captured from a real Claude Code request.
|
||||
// The proxy replays these verbatim — no hardcoded values needed.
|
||||
type SniffedProfile struct {
|
||||
// Raw headers exactly as Claude Code sent them (name→value).
|
||||
// Excludes only host, content-length, and auth (we substitute our own token).
|
||||
Headers [][2]string
|
||||
|
||||
// The full request body with system prompt, tools, metadata, thinking config, etc.
|
||||
// We swap out model + messages from the incoming client request.
|
||||
Body []byte
|
||||
|
||||
// Parsed from User-Agent for billing header fingerprint computation.
|
||||
Version string
|
||||
}
|
||||
|
||||
var skipHeaders = map[string]bool{
|
||||
"host": true,
|
||||
"content-length": true,
|
||||
"authorization": true,
|
||||
"x-api-key": true,
|
||||
"connection": true,
|
||||
}
|
||||
|
||||
func SniffClaudeCode(claudeBinary string) (*SniffedProfile, error) {
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("listen: %w", err)
|
||||
}
|
||||
port := listener.Addr().(*net.TCPAddr).Port
|
||||
|
||||
var profile *SniffedProfile
|
||||
var mu sync.Mutex
|
||||
captured := make(chan struct{}, 1)
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == "HEAD" {
|
||||
w.WriteHeader(200)
|
||||
return
|
||||
}
|
||||
if r.Method != "POST" || !strings.Contains(r.URL.Path, "/v1/messages") {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(200)
|
||||
fmt.Fprint(w, `{"id":"msg_fake","type":"message","role":"assistant","content":[{"type":"text","text":"ok"}],"model":"claude-sonnet-4-6","stop_reason":"end_turn","usage":{"input_tokens":1,"output_tokens":1}}`)
|
||||
return
|
||||
}
|
||||
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
|
||||
mu.Lock()
|
||||
if profile == nil {
|
||||
profile = extractProfile(r, body)
|
||||
select {
|
||||
case captured <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
mu.Unlock()
|
||||
|
||||
if strings.Contains(string(body), `"stream":true`) {
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.WriteHeader(200)
|
||||
fmt.Fprint(w, "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_fake\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"model\":\"claude-sonnet-4-6\",\"stop_reason\":null,\"usage\":{\"input_tokens\":1,\"output_tokens\":1}}}\n\n")
|
||||
fmt.Fprint(w, "event: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\n")
|
||||
fmt.Fprint(w, "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"ok\"}}\n\n")
|
||||
fmt.Fprint(w, "event: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0}\n\n")
|
||||
fmt.Fprint(w, "event: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"},\"usage\":{\"output_tokens\":1}}\n\n")
|
||||
fmt.Fprint(w, "event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n")
|
||||
} else {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(200)
|
||||
fmt.Fprint(w, `{"id":"msg_fake","type":"message","role":"assistant","content":[{"type":"text","text":"ok"}],"model":"claude-sonnet-4-6","stop_reason":"end_turn","usage":{"input_tokens":1,"output_tokens":1}}`)
|
||||
}
|
||||
})
|
||||
|
||||
srv := &http.Server{Handler: mux}
|
||||
go srv.Serve(listener)
|
||||
defer srv.Close()
|
||||
|
||||
cmd := exec.Command(claudeBinary, "--print", "say hi")
|
||||
cmd.Env = append(cmd.Environ(), fmt.Sprintf("ANTHROPIC_BASE_URL=http://127.0.0.1:%d", port))
|
||||
if err := cmd.Start(); err != nil {
|
||||
return nil, fmt.Errorf("start claude: %w", err)
|
||||
}
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- cmd.Wait() }()
|
||||
|
||||
select {
|
||||
case <-captured:
|
||||
cmd.Process.Kill()
|
||||
case err := <-done:
|
||||
if err != nil && profile == nil {
|
||||
return nil, fmt.Errorf("claude exited: %w", err)
|
||||
}
|
||||
case <-time.After(30 * time.Second):
|
||||
cmd.Process.Kill()
|
||||
return nil, fmt.Errorf("sniff timed out after 30s")
|
||||
}
|
||||
|
||||
if profile == nil {
|
||||
return nil, fmt.Errorf("no API request captured")
|
||||
}
|
||||
|
||||
log.Printf("sniffed claude-code: version=%s headers=%d body=%d bytes",
|
||||
profile.Version, len(profile.Headers), len(profile.Body))
|
||||
return profile, nil
|
||||
}
|
||||
|
||||
func extractProfile(r *http.Request, body []byte) *SniffedProfile {
|
||||
// Capture raw headers preserving original casing and order.
|
||||
var headers [][2]string
|
||||
for i := 0; i < len(r.Header); i++ {
|
||||
for name, vals := range r.Header {
|
||||
if skipHeaders[strings.ToLower(name)] {
|
||||
continue
|
||||
}
|
||||
for _, v := range vals {
|
||||
headers = append(headers, [2]string{name, v})
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
// Deduplicate and strip subscription-specific betas.
|
||||
seen := map[string]bool{}
|
||||
var deduped [][2]string
|
||||
for _, h := range headers {
|
||||
key := strings.ToLower(h[0])
|
||||
if seen[key] {
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
if key == "anthropic-beta" {
|
||||
var filtered []string
|
||||
for _, b := range strings.Split(h[1], ",") {
|
||||
if !strings.Contains(b, "context-1m") {
|
||||
filtered = append(filtered, b)
|
||||
}
|
||||
}
|
||||
h[1] = strings.Join(filtered, ",")
|
||||
}
|
||||
deduped = append(deduped, h)
|
||||
}
|
||||
|
||||
ua := r.Header.Get("User-Agent")
|
||||
version := ""
|
||||
if i := strings.Index(ua, "/"); i > 0 {
|
||||
rest := ua[i+1:]
|
||||
if j := strings.IndexByte(rest, ' '); j > 0 {
|
||||
version = rest[:j]
|
||||
} else {
|
||||
version = rest
|
||||
}
|
||||
}
|
||||
|
||||
// Extract the system prompt template from the body (everything except the billing header block).
|
||||
// The billing header is the first system block starting with "x-anthropic-billing-header:".
|
||||
systemBlocks := gjson.GetBytes(body, "system")
|
||||
var templateSystem []string
|
||||
if systemBlocks.IsArray() {
|
||||
for _, block := range systemBlocks.Array() {
|
||||
text := block.Get("text").String()
|
||||
if strings.HasPrefix(text, "x-anthropic-billing-header:") {
|
||||
continue
|
||||
}
|
||||
templateSystem = append(templateSystem, block.Raw)
|
||||
}
|
||||
}
|
||||
_ = templateSystem // stored in body for now
|
||||
|
||||
return &SniffedProfile{
|
||||
Headers: deduped,
|
||||
Body: body,
|
||||
Version: version,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"sync"
|
||||
|
||||
tls "github.com/refraction-networking/utls"
|
||||
"golang.org/x/net/http2"
|
||||
)
|
||||
|
||||
type utlsRoundTripper struct {
|
||||
mu sync.Mutex
|
||||
connections map[string]*http2.ClientConn
|
||||
pending map[string]*sync.Cond
|
||||
}
|
||||
|
||||
func newUtlsRoundTripper() *utlsRoundTripper {
|
||||
return &utlsRoundTripper{
|
||||
connections: make(map[string]*http2.ClientConn),
|
||||
pending: make(map[string]*sync.Cond),
|
||||
}
|
||||
}
|
||||
|
||||
func (t *utlsRoundTripper) getOrCreateConnection(host, addr string) (*http2.ClientConn, error) {
|
||||
t.mu.Lock()
|
||||
|
||||
if h2Conn, ok := t.connections[host]; ok && h2Conn.CanTakeNewRequest() {
|
||||
t.mu.Unlock()
|
||||
return h2Conn, nil
|
||||
}
|
||||
|
||||
if cond, ok := t.pending[host]; ok {
|
||||
cond.Wait()
|
||||
if h2Conn, ok := t.connections[host]; ok && h2Conn.CanTakeNewRequest() {
|
||||
t.mu.Unlock()
|
||||
return h2Conn, nil
|
||||
}
|
||||
}
|
||||
|
||||
cond := sync.NewCond(&t.mu)
|
||||
t.pending[host] = cond
|
||||
t.mu.Unlock()
|
||||
|
||||
h2Conn, err := t.createConnection(host, addr)
|
||||
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
|
||||
delete(t.pending, host)
|
||||
cond.Broadcast()
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
t.connections[host] = h2Conn
|
||||
return h2Conn, nil
|
||||
}
|
||||
|
||||
func (t *utlsRoundTripper) createConnection(host, addr string) (*http2.ClientConn, error) {
|
||||
conn, err := net.Dial("tcp", addr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tlsConfig := &tls.Config{ServerName: host}
|
||||
tlsConn := tls.UClient(conn, tlsConfig, tls.HelloChrome_Auto)
|
||||
|
||||
if err := tlsConn.Handshake(); err != nil {
|
||||
conn.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tr := &http2.Transport{}
|
||||
h2Conn, err := tr.NewClientConn(tlsConn)
|
||||
if err != nil {
|
||||
tlsConn.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return h2Conn, nil
|
||||
}
|
||||
|
||||
func (t *utlsRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
hostname := req.URL.Hostname()
|
||||
port := req.URL.Port()
|
||||
if port == "" {
|
||||
port = "443"
|
||||
}
|
||||
addr := net.JoinHostPort(hostname, port)
|
||||
log.Printf("utls: RoundTrip to %s (Chrome TLS fingerprint, HTTP/2)", addr)
|
||||
|
||||
h2Conn, err := t.getOrCreateConnection(hostname, addr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp, err := h2Conn.RoundTrip(req)
|
||||
if err != nil {
|
||||
t.mu.Lock()
|
||||
if cached, ok := t.connections[hostname]; ok && cached == h2Conn {
|
||||
delete(t.connections, hostname)
|
||||
}
|
||||
t.mu.Unlock()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/fujin/anthropic-proxy/internal/auth"
|
||||
)
|
||||
|
||||
const messagesURL = "https://api.anthropic.com/v1/messages?beta=true"
|
||||
|
||||
type UpstreamClient struct {
|
||||
client http.Client
|
||||
sessionID string
|
||||
profile *SniffedProfile
|
||||
}
|
||||
|
||||
func NewUpstreamClient(profile *SniffedProfile) *UpstreamClient {
|
||||
return &UpstreamClient{
|
||||
client: http.Client{
|
||||
Timeout: 0,
|
||||
Transport: newUtlsRoundTripper(),
|
||||
},
|
||||
sessionID: uuid.New().String(),
|
||||
profile: profile,
|
||||
}
|
||||
}
|
||||
|
||||
func (u *UpstreamClient) version() string {
|
||||
if u.profile != nil && u.profile.Version != "" {
|
||||
return u.profile.Version
|
||||
}
|
||||
return "2.1.92"
|
||||
}
|
||||
|
||||
// applyHeaders replays sniffed headers, substituting auth + per-request IDs + accept.
|
||||
func (u *UpstreamClient) applyHeaders(req *http.Request, token string, streaming bool) {
|
||||
if u.profile != nil {
|
||||
for _, h := range u.profile.Headers {
|
||||
req.Header.Set(h[0], h[1])
|
||||
}
|
||||
}
|
||||
|
||||
req.Header.Del("Authorization")
|
||||
req.Header.Del("x-api-key")
|
||||
if strings.HasPrefix(token, "sk-ant-oat") {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
} else {
|
||||
req.Header.Set("x-api-key", token)
|
||||
}
|
||||
|
||||
req.Header.Set("X-Claude-Code-Session-Id", u.sessionID)
|
||||
req.Header.Set("x-client-request-id", uuid.New().String())
|
||||
|
||||
if streaming {
|
||||
req.Header.Set("Accept", "text/event-stream")
|
||||
} else {
|
||||
req.Header.Set("Accept", "application/json")
|
||||
}
|
||||
req.Header.Set("Accept-Encoding", "identity")
|
||||
}
|
||||
|
||||
func (u *UpstreamClient) Execute(ctx context.Context, cred *auth.Credential, body []byte) ([]byte, http.Header, int, error) {
|
||||
body = injectBillingHeader(body, u.version())
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, messagesURL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, nil, 0, fmt.Errorf("build upstream request: %w", err)
|
||||
}
|
||||
u.applyHeaders(req, cred.Token(), false)
|
||||
|
||||
resp, err := u.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, nil, 0, fmt.Errorf("upstream request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, nil, resp.StatusCode, fmt.Errorf("read upstream response: %w", err)
|
||||
}
|
||||
return respBody, resp.Header, resp.StatusCode, nil
|
||||
}
|
||||
|
||||
func (u *UpstreamClient) ExecuteStream(ctx context.Context, cred *auth.Credential, body []byte) (*http.Response, error) {
|
||||
body = injectBillingHeader(body, u.version())
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, messagesURL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build upstream stream request: %w", err)
|
||||
}
|
||||
u.applyHeaders(req, cred.Token(), true)
|
||||
|
||||
resp, err := u.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("upstream stream request: %w", err)
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/fujin/anthropic-proxy/internal/auth"
|
||||
"github.com/fujin/anthropic-proxy/internal/config"
|
||||
"github.com/fujin/anthropic-proxy/internal/proxy"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
engine *gin.Engine
|
||||
port int
|
||||
}
|
||||
|
||||
func New(cfg *config.Config, pool *auth.Pool, profile *proxy.SniffedProfile) *Server {
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
engine := gin.New()
|
||||
engine.Use(gin.Recovery())
|
||||
engine.Use(corsMiddleware())
|
||||
engine.Use(authMiddleware(cfg.APIKeys))
|
||||
|
||||
engine.POST("/v1/messages", proxy.HandleMessages(pool, profile))
|
||||
engine.GET("/healthz", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||
})
|
||||
|
||||
return &Server{engine: engine, port: cfg.Port}
|
||||
}
|
||||
|
||||
func (s *Server) Start() error {
|
||||
addr := fmt.Sprintf(":%d", s.port)
|
||||
return s.engine.Run(addr)
|
||||
}
|
||||
|
||||
func corsMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.Header("Access-Control-Allow-Origin", "*")
|
||||
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
||||
c.Header("Access-Control-Allow-Headers", "Origin, Content-Type, Authorization, x-api-key")
|
||||
|
||||
if c.Request.Method == http.MethodOptions {
|
||||
c.AbortWithStatus(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func authMiddleware(apiKeys []string) gin.HandlerFunc {
|
||||
keySet := make(map[string]struct{}, len(apiKeys))
|
||||
for _, k := range apiKeys {
|
||||
keySet[k] = struct{}{}
|
||||
}
|
||||
|
||||
return func(c *gin.Context) {
|
||||
if c.Request.URL.Path == "/healthz" {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
token := ""
|
||||
if authHeader := c.GetHeader("Authorization"); authHeader != "" {
|
||||
token = strings.TrimPrefix(authHeader, "Bearer ")
|
||||
}
|
||||
if token == "" {
|
||||
token = c.GetHeader("x-api-key")
|
||||
}
|
||||
|
||||
if token == "" {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing authentication"})
|
||||
return
|
||||
}
|
||||
|
||||
if _, ok := keySet[token]; !ok {
|
||||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "invalid api key"})
|
||||
return
|
||||
}
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user