From f22765d8f0fe23c9eaf7ec79d02b0a31d8a1dc9a Mon Sep 17 00:00:00 2001 From: Alexander Date: Thu, 9 Apr 2026 23:06:17 +0200 Subject: [PATCH] Fixes, readme --- README.md | 58 ++++++++++++++++++++++++++ internal/auth/refresh.go | 11 +++-- internal/proxy/handler.go | 5 +-- internal/server/server.go | 87 ++++++++++++++++++++++++++++++++------- main.go | 32 +++++++++++++- 5 files changed, 170 insertions(+), 23 deletions(-) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..81b9bf5 --- /dev/null +++ b/README.md @@ -0,0 +1,58 @@ +# anthropic-proxy + +Reverse proxy that lets OpenCode (and similar tools) use a Claude subscription instead of an API key. + +## Prerequisites + +- Go 1.26+ +- **Claude Code CLI** — installed and logged in (`claude auth login`). The proxy reads the OAuth token from `~/.claude/.credentials.json`. + +Optional: [Nix](https://nixos.org/) flake for dev shell (`nix develop`). + +## Setup + +``` +cp config.example.yaml config.yaml +``` + +Edit `config.yaml`: +- `api_keys` — key(s) your clients use to authenticate with the proxy +- `claude_credentials` — path to your Claude credentials file +- `auth_dir` — optional, directory with additional OAuth credential JSON files +- `claude_binary` — path to `claude` binary (used on startup to capture request fingerprint) + +## Build and run + +``` +go build -o anthropic-proxy . +./anthropic-proxy +``` + +## Usage with OpenCode + +``` +export ANTHROPIC_API_KEY=your-proxy-api-key +export ANTHROPIC_BASE_URL=http://localhost:8082 +opencode +``` + +## Endpoints + +| Method | Path | Description | +|--------|------|-------------| +| POST | `/v1/messages` | Anthropic messages API (proxied) | +| POST | `/messages` | Same, without `/v1` prefix | +| GET | `/healthz` | Health check | +| POST | `/reload` | Hot-reload `config.yaml` | + +## Request sanitization + +The `sanitize` section in config renames tool names and replaces strings in system prompts before forwarding to Anthropic. Responses are de-sanitized before returning to the client. + +See `config.example.yaml` for the default rules. + +Reload after editing config: + +``` +curl -X POST localhost:8082/reload +``` diff --git a/internal/auth/refresh.go b/internal/auth/refresh.go index c49c428..141be19 100644 --- a/internal/auth/refresh.go +++ b/internal/auth/refresh.go @@ -215,11 +215,16 @@ func (t *utlsRefreshTransport) RoundTrip(req *http.Request) (*http.Response, err } // StartBackgroundRefresh runs a goroutine that checks and refreshes tokens periodically. -func StartBackgroundRefresh(pool *Pool) { +func StartBackgroundRefresh(ctx context.Context, pool *Pool) { go func() { for { - time.Sleep(refreshInterval) - refreshAll(pool) + select { + case <-ctx.Done(): + log.Printf("background refresh stopped") + return + case <-time.After(refreshInterval): + refreshAll(pool) + } } }() } diff --git a/internal/proxy/handler.go b/internal/proxy/handler.go index f27e674..3a20c6b 100644 --- a/internal/proxy/handler.go +++ b/internal/proxy/handler.go @@ -10,12 +10,10 @@ 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, sanitizeCfg config.SanitizeConfig) gin.HandlerFunc { +func HandleMessages(pool *auth.Pool, profile *SniffedProfile, getSanitizer func() *Sanitizer) gin.HandlerFunc { upstream := NewUpstreamClient(profile) - san := NewSanitizer(sanitizeCfg) return func(c *gin.Context) { body, err := io.ReadAll(c.Request.Body) @@ -26,6 +24,7 @@ func HandleMessages(pool *auth.Pool, profile *SniffedProfile, sanitizeCfg config log.Printf("incoming: %s %s (%d bytes) model=%s", c.Request.Method, c.Request.URL.Path, len(body), gjson.GetBytes(body, "model").String()) + san := getSanitizer() body = san.SanitizeRequest(body) cred, err := pool.Pick() diff --git a/internal/server/server.go b/internal/server/server.go index 1833d1a..0b844a6 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -1,10 +1,12 @@ package server import ( + "context" "fmt" "log" "net/http" "strings" + "sync/atomic" "github.com/gin-gonic/gin" @@ -14,21 +16,35 @@ import ( ) type Server struct { - engine *gin.Engine - port int + httpServer *http.Server + engine *gin.Engine + configPath string + sanitizer atomic.Pointer[proxy.Sanitizer] + apiKeys atomic.Pointer[map[string]struct{}] } func New(cfg *config.Config, pool *auth.Pool, profile *proxy.SniffedProfile) *Server { + s := &Server{configPath: "config.yaml"} + + san := proxy.NewSanitizer(cfg.Sanitize) + s.sanitizer.Store(san) + + keys := makeKeySet(cfg.APIKeys) + s.apiKeys.Store(&keys) + gin.SetMode(gin.ReleaseMode) engine := gin.New() engine.Use(gin.Recovery()) engine.Use(corsMiddleware()) - engine.Use(authMiddleware(cfg.APIKeys)) + engine.Use(s.authMiddleware()) - handler := proxy.HandleMessages(pool, profile, cfg.Sanitize) + handler := proxy.HandleMessages(pool, profile, func() *proxy.Sanitizer { + return s.sanitizer.Load() + }) engine.POST("/v1/messages", handler) engine.POST("/messages", handler) + engine.POST("/reload", s.handleReload()) engine.GET("/healthz", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"status": "ok"}) }) @@ -37,12 +53,56 @@ func New(cfg *config.Config, pool *auth.Pool, profile *proxy.SniffedProfile) *Se c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) }) - return &Server{engine: engine, port: cfg.Port} + s.engine = engine + s.httpServer = &http.Server{ + Addr: fmt.Sprintf(":%d", cfg.Port), + Handler: engine, + } + + return s } func (s *Server) Start() error { - addr := fmt.Sprintf(":%d", s.port) - return s.engine.Run(addr) + return s.httpServer.ListenAndServe() +} + +func (s *Server) Shutdown(ctx context.Context) error { + return s.httpServer.Shutdown(ctx) +} + +func (s *Server) handleReload() gin.HandlerFunc { + return func(c *gin.Context) { + cfg, err := config.Load(s.configPath) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("load config: %v", err)}) + return + } + + san := proxy.NewSanitizer(cfg.Sanitize) + s.sanitizer.Store(san) + + keys := makeKeySet(cfg.APIKeys) + s.apiKeys.Store(&keys) + + log.Printf("config reloaded: %d tool renames, %d system rules, %d body rules, %d api keys", + len(cfg.Sanitize.Tools), len(cfg.Sanitize.System), len(cfg.Sanitize.Body), len(cfg.APIKeys)) + + c.JSON(http.StatusOK, gin.H{ + "status": "reloaded", + "tool_renames": len(cfg.Sanitize.Tools), + "system_rules": len(cfg.Sanitize.System), + "body_rules": len(cfg.Sanitize.Body), + "api_keys": len(cfg.APIKeys), + }) + } +} + +func makeKeySet(apiKeys []string) map[string]struct{} { + keySet := make(map[string]struct{}, len(apiKeys)) + for _, k := range apiKeys { + keySet[k] = struct{}{} + } + return keySet } func corsMiddleware() gin.HandlerFunc { @@ -60,14 +120,10 @@ func corsMiddleware() gin.HandlerFunc { } } -func authMiddleware(apiKeys []string) gin.HandlerFunc { - keySet := make(map[string]struct{}, len(apiKeys)) - for _, k := range apiKeys { - keySet[k] = struct{}{} - } - +func (s *Server) authMiddleware() gin.HandlerFunc { return func(c *gin.Context) { - if c.Request.URL.Path == "/healthz" { + path := c.Request.URL.Path + if path == "/healthz" || path == "/reload" { c.Next() return } @@ -85,7 +141,8 @@ func authMiddleware(apiKeys []string) gin.HandlerFunc { return } - if _, ok := keySet[token]; !ok { + keys := s.apiKeys.Load() + if _, ok := (*keys)[token]; !ok { c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "invalid api key"}) return } diff --git a/main.go b/main.go index 59ddb90..6167e15 100644 --- a/main.go +++ b/main.go @@ -4,7 +4,11 @@ import ( "context" "fmt" "log" + "net/http" "os" + "os/signal" + "syscall" + "time" "github.com/fujin/anthropic-proxy/internal/auth" "github.com/fujin/anthropic-proxy/internal/config" @@ -33,8 +37,11 @@ func run() error { pool := auth.NewPool(creds) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + pool.RefreshExpiring(context.Background()) - auth.StartBackgroundRefresh(pool) + auth.StartBackgroundRefresh(ctx, pool) var profile *proxy.SniffedProfile if cfg.ClaudeBinary != "" { @@ -47,7 +54,28 @@ func run() error { log.Printf("starting server on port %d", cfg.Port) srv := server.New(cfg, pool, profile) - return srv.Start() + + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + + go func() { + <-quit + log.Printf("shutting down...") + cancel() + + shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second) + defer shutdownCancel() + + if err := srv.Shutdown(shutdownCtx); err != nil { + log.Printf("shutdown error: %v", err) + } + }() + + if err := srv.Start(); err != nil && err != http.ErrServerClosed { + return err + } + + return nil } func main() {