Add request sanitizer, background token refresh, and OpenCode support
Sanitizer renames tool names and replaces system prompt patterns that Anthropic fingerprints to detect non-Claude-Code clients. Lowercase tool names (bash, read, glob, etc.) combined together trigger rejection — renaming to PascalCase bypasses this. Configurable via YAML sanitize rules for tools, system, and body. Background OAuth token refresh every 30s with 5-minute pre-expiry lead. Uses Chrome TLS fingerprint for refresh endpoint too. Adds /messages route (without /v1 prefix) for OpenCode compat.
This commit is contained in:
@@ -10,10 +10,12 @@ import (
|
||||
"github.com/tidwall/gjson"
|
||||
|
||||
"github.com/fujin/anthropic-proxy/internal/auth"
|
||||
"github.com/fujin/anthropic-proxy/internal/config"
|
||||
)
|
||||
|
||||
func HandleMessages(pool *auth.Pool, profile *SniffedProfile) gin.HandlerFunc {
|
||||
func HandleMessages(pool *auth.Pool, profile *SniffedProfile, sanitizeCfg config.SanitizeConfig) gin.HandlerFunc {
|
||||
upstream := NewUpstreamClient(profile)
|
||||
san := NewSanitizer(sanitizeCfg)
|
||||
|
||||
return func(c *gin.Context) {
|
||||
body, err := io.ReadAll(c.Request.Body)
|
||||
@@ -22,6 +24,10 @@ func HandleMessages(pool *auth.Pool, profile *SniffedProfile) gin.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("incoming: %s %s (%d bytes) model=%s", c.Request.Method, c.Request.URL.Path, len(body), gjson.GetBytes(body, "model").String())
|
||||
|
||||
body = san.SanitizeRequest(body)
|
||||
|
||||
cred, err := pool.Pick()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()})
|
||||
@@ -31,14 +37,14 @@ func HandleMessages(pool *auth.Pool, profile *SniffedProfile) gin.HandlerFunc {
|
||||
isStream := gjson.GetBytes(body, "stream").Bool()
|
||||
|
||||
if isStream {
|
||||
handleStream(c, upstream, pool, cred, body)
|
||||
handleStream(c, upstream, san, pool, cred, body)
|
||||
} else {
|
||||
handleNonStream(c, upstream, pool, cred, body)
|
||||
handleNonStream(c, upstream, san, pool, cred, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func handleNonStream(c *gin.Context, upstream *UpstreamClient, pool *auth.Pool, cred *auth.Credential, body []byte) {
|
||||
func handleNonStream(c *gin.Context, upstream *UpstreamClient, san *Sanitizer, 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)
|
||||
@@ -48,9 +54,10 @@ func handleNonStream(c *gin.Context, upstream *UpstreamClient, pool *auth.Pool,
|
||||
|
||||
if statusCode >= 400 {
|
||||
pool.MarkFailure(cred, statusCode)
|
||||
log.Printf("upstream %d for %s", statusCode, cred.Email)
|
||||
log.Printf("upstream %d for %s: %s", statusCode, cred.Email, string(respBody))
|
||||
} else {
|
||||
pool.MarkSuccess(cred)
|
||||
respBody = san.DesanitizeResponse(respBody)
|
||||
}
|
||||
|
||||
for _, h := range []string{"Content-Type", "X-Request-Id"} {
|
||||
@@ -62,7 +69,7 @@ func handleNonStream(c *gin.Context, upstream *UpstreamClient, pool *auth.Pool,
|
||||
c.Data(statusCode, headers.Get("Content-Type"), respBody)
|
||||
}
|
||||
|
||||
func handleStream(c *gin.Context, upstream *UpstreamClient, pool *auth.Pool, cred *auth.Credential, body []byte) {
|
||||
func handleStream(c *gin.Context, upstream *UpstreamClient, san *Sanitizer, 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)
|
||||
@@ -73,8 +80,8 @@ func handleStream(c *gin.Context, upstream *UpstreamClient, pool *auth.Pool, cre
|
||||
|
||||
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)
|
||||
log.Printf("upstream stream %d for %s: %s", resp.StatusCode, cred.Email, string(respBody))
|
||||
c.Data(resp.StatusCode, resp.Header.Get("Content-Type"), respBody)
|
||||
return
|
||||
}
|
||||
@@ -96,7 +103,7 @@ func handleStream(c *gin.Context, upstream *UpstreamClient, pool *auth.Pool, cre
|
||||
scanner := bufio.NewScanner(resp.Body)
|
||||
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
line := san.DesanitizeStreamEvent(scanner.Text())
|
||||
c.Writer.WriteString(line + "\n")
|
||||
flusher.Flush()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/tidwall/gjson"
|
||||
"github.com/tidwall/sjson"
|
||||
|
||||
"github.com/fujin/anthropic-proxy/internal/config"
|
||||
)
|
||||
|
||||
type Sanitizer struct {
|
||||
toolsForward map[string]string
|
||||
toolsReverse map[string]string
|
||||
systemRules []config.ReplaceRule
|
||||
bodyRules []config.ReplaceRule
|
||||
}
|
||||
|
||||
func NewSanitizer(cfg config.SanitizeConfig) *Sanitizer {
|
||||
s := &Sanitizer{
|
||||
toolsForward: make(map[string]string),
|
||||
toolsReverse: make(map[string]string),
|
||||
systemRules: cfg.System,
|
||||
bodyRules: cfg.Body,
|
||||
}
|
||||
for _, r := range cfg.Tools {
|
||||
s.toolsForward[r.From] = r.To
|
||||
s.toolsReverse[r.To] = r.From
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *Sanitizer) SanitizeRequest(body []byte) []byte {
|
||||
body = s.renameTools(body)
|
||||
body = s.replaceSystem(body)
|
||||
body = s.replaceBody(body)
|
||||
return body
|
||||
}
|
||||
|
||||
func (s *Sanitizer) DesanitizeResponse(body []byte) []byte {
|
||||
content := gjson.GetBytes(body, "content")
|
||||
if !content.Exists() || !content.IsArray() {
|
||||
return body
|
||||
}
|
||||
for i, block := range content.Array() {
|
||||
if block.Get("type").String() != "tool_use" {
|
||||
continue
|
||||
}
|
||||
name := block.Get("name").String()
|
||||
if orig, ok := s.toolsReverse[name]; ok {
|
||||
body, _ = sjson.SetBytes(body, "content."+strconv.Itoa(i)+".name", orig)
|
||||
}
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
func (s *Sanitizer) DesanitizeStreamEvent(line string) string {
|
||||
if !strings.Contains(line, "tool_use") || !strings.HasPrefix(line, "data: ") {
|
||||
return line
|
||||
}
|
||||
data := []byte(line[6:])
|
||||
changed := false
|
||||
for _, path := range []string{"content_block.name", "delta.name"} {
|
||||
name := gjson.GetBytes(data, path).String()
|
||||
if orig, ok := s.toolsReverse[name]; ok {
|
||||
data, _ = sjson.SetBytes(data, path, orig)
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if changed {
|
||||
return "data: " + string(data)
|
||||
}
|
||||
return line
|
||||
}
|
||||
|
||||
func (s *Sanitizer) renameTools(body []byte) []byte {
|
||||
if len(s.toolsForward) == 0 {
|
||||
return body
|
||||
}
|
||||
tools := gjson.GetBytes(body, "tools")
|
||||
if !tools.Exists() || !tools.IsArray() {
|
||||
return body
|
||||
}
|
||||
for i, tool := range tools.Array() {
|
||||
name := tool.Get("name").String()
|
||||
if newName, ok := s.toolsForward[name]; ok {
|
||||
body, _ = sjson.SetBytes(body, "tools."+strconv.Itoa(i)+".name", newName)
|
||||
}
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
func (s *Sanitizer) replaceSystem(body []byte) []byte {
|
||||
if len(s.systemRules) == 0 {
|
||||
return body
|
||||
}
|
||||
system := gjson.GetBytes(body, "system")
|
||||
if !system.Exists() || !system.IsArray() {
|
||||
return body
|
||||
}
|
||||
for i, block := range system.Array() {
|
||||
text := block.Get("text").String()
|
||||
for _, rule := range s.systemRules {
|
||||
text = strings.ReplaceAll(text, rule.Match, rule.Replace)
|
||||
}
|
||||
body, _ = sjson.SetBytes(body, "system."+strconv.Itoa(i)+".text", text)
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
func (s *Sanitizer) replaceBody(body []byte) []byte {
|
||||
if len(s.bodyRules) == 0 {
|
||||
return body
|
||||
}
|
||||
str := string(body)
|
||||
for _, rule := range s.bodyRules {
|
||||
str = strings.ReplaceAll(str, rule.Match, rule.Replace)
|
||||
}
|
||||
return []byte(str)
|
||||
}
|
||||
Reference in New Issue
Block a user