Fixes, readme
This commit is contained in:
@@ -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
|
||||||
|
```
|
||||||
@@ -215,12 +215,17 @@ func (t *utlsRefreshTransport) RoundTrip(req *http.Request) (*http.Response, err
|
|||||||
}
|
}
|
||||||
|
|
||||||
// StartBackgroundRefresh runs a goroutine that checks and refreshes tokens periodically.
|
// StartBackgroundRefresh runs a goroutine that checks and refreshes tokens periodically.
|
||||||
func StartBackgroundRefresh(pool *Pool) {
|
func StartBackgroundRefresh(ctx context.Context, pool *Pool) {
|
||||||
go func() {
|
go func() {
|
||||||
for {
|
for {
|
||||||
time.Sleep(refreshInterval)
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
log.Printf("background refresh stopped")
|
||||||
|
return
|
||||||
|
case <-time.After(refreshInterval):
|
||||||
refreshAll(pool)
|
refreshAll(pool)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,12 +10,10 @@ import (
|
|||||||
"github.com/tidwall/gjson"
|
"github.com/tidwall/gjson"
|
||||||
|
|
||||||
"github.com/fujin/anthropic-proxy/internal/auth"
|
"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)
|
upstream := NewUpstreamClient(profile)
|
||||||
san := NewSanitizer(sanitizeCfg)
|
|
||||||
|
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
body, err := io.ReadAll(c.Request.Body)
|
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())
|
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)
|
body = san.SanitizeRequest(body)
|
||||||
|
|
||||||
cred, err := pool.Pick()
|
cred, err := pool.Pick()
|
||||||
|
|||||||
+71
-14
@@ -1,10 +1,12 @@
|
|||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
@@ -14,21 +16,35 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Server struct {
|
type Server struct {
|
||||||
|
httpServer *http.Server
|
||||||
engine *gin.Engine
|
engine *gin.Engine
|
||||||
port int
|
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 {
|
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)
|
gin.SetMode(gin.ReleaseMode)
|
||||||
engine := gin.New()
|
engine := gin.New()
|
||||||
engine.Use(gin.Recovery())
|
engine.Use(gin.Recovery())
|
||||||
engine.Use(corsMiddleware())
|
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("/v1/messages", handler)
|
||||||
engine.POST("/messages", handler)
|
engine.POST("/messages", handler)
|
||||||
|
|
||||||
|
engine.POST("/reload", s.handleReload())
|
||||||
engine.GET("/healthz", func(c *gin.Context) {
|
engine.GET("/healthz", func(c *gin.Context) {
|
||||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
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"})
|
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 {
|
func (s *Server) Start() error {
|
||||||
addr := fmt.Sprintf(":%d", s.port)
|
return s.httpServer.ListenAndServe()
|
||||||
return s.engine.Run(addr)
|
}
|
||||||
|
|
||||||
|
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 {
|
func corsMiddleware() gin.HandlerFunc {
|
||||||
@@ -60,14 +120,10 @@ func corsMiddleware() gin.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func authMiddleware(apiKeys []string) gin.HandlerFunc {
|
func (s *Server) authMiddleware() gin.HandlerFunc {
|
||||||
keySet := make(map[string]struct{}, len(apiKeys))
|
|
||||||
for _, k := range apiKeys {
|
|
||||||
keySet[k] = struct{}{}
|
|
||||||
}
|
|
||||||
|
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
if c.Request.URL.Path == "/healthz" {
|
path := c.Request.URL.Path
|
||||||
|
if path == "/healthz" || path == "/reload" {
|
||||||
c.Next()
|
c.Next()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -85,7 +141,8 @@ func authMiddleware(apiKeys []string) gin.HandlerFunc {
|
|||||||
return
|
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"})
|
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "invalid api key"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,11 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/fujin/anthropic-proxy/internal/auth"
|
"github.com/fujin/anthropic-proxy/internal/auth"
|
||||||
"github.com/fujin/anthropic-proxy/internal/config"
|
"github.com/fujin/anthropic-proxy/internal/config"
|
||||||
@@ -33,8 +37,11 @@ func run() error {
|
|||||||
|
|
||||||
pool := auth.NewPool(creds)
|
pool := auth.NewPool(creds)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
pool.RefreshExpiring(context.Background())
|
pool.RefreshExpiring(context.Background())
|
||||||
auth.StartBackgroundRefresh(pool)
|
auth.StartBackgroundRefresh(ctx, pool)
|
||||||
|
|
||||||
var profile *proxy.SniffedProfile
|
var profile *proxy.SniffedProfile
|
||||||
if cfg.ClaudeBinary != "" {
|
if cfg.ClaudeBinary != "" {
|
||||||
@@ -47,7 +54,28 @@ func run() error {
|
|||||||
|
|
||||||
log.Printf("starting server on port %d", cfg.Port)
|
log.Printf("starting server on port %d", cfg.Port)
|
||||||
srv := server.New(cfg, pool, profile)
|
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() {
|
func main() {
|
||||||
|
|||||||
Reference in New Issue
Block a user