Files
anthropic-proxy/internal/proxy/sniff.go
T
Alexander 76aeeb6be1 fix(auth): add oauth-2025-04-20 beta header + debug logging
Ensure anthropic-beta includes oauth-2025-04-20 when using OAuth tokens,
fixing 401 "OAuth authentication is currently not supported" errors.
Add debug-level logging for upstream requests/responses, sniffed headers,
and token refresh operations.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 11:08:08 +02:00

183 lines
5.3 KiB
Go

package proxy
import (
"fmt"
"io"
"net"
"net/http"
"os/exec"
"strings"
"sync"
"time"
"github.com/rs/zerolog/log"
)
// 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.Info().
Str("version", profile.Version).
Int("headers", len(profile.Headers)).
Int("body_size", len(profile.Body)).
Msg("sniffed claude-code profile")
for _, h := range profile.Headers {
log.Debug().Str("header", h[0]).Str("value", h[1]).Msg("sniffed header")
}
return profile, nil
}
func extractProfile(r *http.Request, body []byte) *SniffedProfile {
// Capture raw headers preserving original casing.
var headers [][2]string
for name, vals := range r.Header {
if skipHeaders[strings.ToLower(name)] {
continue
}
for _, v := range vals {
headers = append(headers, [2]string{name, v})
}
}
// 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
}
}
return &SniffedProfile{
Headers: deduped,
Body: body,
Version: version,
}
}