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:
Alexander
2026-04-09 21:05:32 +02:00
commit c4c1d4daa4
17 changed files with 1417 additions and 0 deletions
+90
View File
@@ -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
}
+107
View File
@@ -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)
}
}
+193
View File
@@ -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,
}
}
+111
View File
@@ -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
}
+105
View File
@@ -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
}