Fixes, readme
Drop cli-proxy-api token handling, use only native Claude credentials. Simplify refresh to single endpoint (platform.claude.com) with scope. Add debug/refresh and debug/shutdown endpoints. Graceful shutdown.
This commit is contained in:
@@ -1,7 +1,6 @@
|
|||||||
port: 8082
|
port: 8082
|
||||||
api_keys:
|
api_keys:
|
||||||
- "your-proxy-api-key"
|
- "your-proxy-api-key"
|
||||||
auth_dir: ""
|
|
||||||
claude_credentials: "~/.claude/.credentials.json"
|
claude_credentials: "~/.claude/.credentials.json"
|
||||||
claude_binary: "claude"
|
claude_binary: "claude"
|
||||||
|
|
||||||
|
|||||||
+13
-57
@@ -10,7 +10,6 @@ import (
|
|||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -19,9 +18,9 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
cliProxyTokenEndpoint = "https://api.anthropic.com/v1/oauth/token"
|
tokenEndpoint = "https://platform.claude.com/v1/oauth/token"
|
||||||
nativeTokenEndpoint = "https://platform.claude.com/v1/oauth/token"
|
clientID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
|
||||||
clientID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
|
oauthScopes = "user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload"
|
||||||
|
|
||||||
refreshLead = 5 * time.Minute
|
refreshLead = 5 * time.Minute
|
||||||
refreshInterval = 30 * time.Second
|
refreshInterval = 30 * time.Second
|
||||||
@@ -34,6 +33,7 @@ type tokenRequest struct {
|
|||||||
ClientID string `json:"client_id"`
|
ClientID string `json:"client_id"`
|
||||||
GrantType string `json:"grant_type"`
|
GrantType string `json:"grant_type"`
|
||||||
RefreshToken string `json:"refresh_token"`
|
RefreshToken string `json:"refresh_token"`
|
||||||
|
Scope string `json:"scope"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type tokenResponse struct {
|
type tokenResponse struct {
|
||||||
@@ -50,23 +50,18 @@ func RefreshToken(ctx context.Context, cred *Credential) error {
|
|||||||
return fmt.Errorf("no refresh token")
|
return fmt.Errorf("no refresh token")
|
||||||
}
|
}
|
||||||
|
|
||||||
endpoint := cliProxyTokenEndpoint
|
|
||||||
if cred.ID == "claude-native" {
|
|
||||||
endpoint = nativeTokenEndpoint
|
|
||||||
}
|
|
||||||
|
|
||||||
reqBody, _ := json.Marshal(tokenRequest{
|
reqBody, _ := json.Marshal(tokenRequest{
|
||||||
ClientID: clientID,
|
ClientID: clientID,
|
||||||
GrantType: "refresh_token",
|
GrantType: "refresh_token",
|
||||||
RefreshToken: cred.RefreshToken,
|
RefreshToken: cred.RefreshToken,
|
||||||
|
Scope: oauthScopes,
|
||||||
})
|
})
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(reqBody))
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, tokenEndpoint, bytes.NewReader(reqBody))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("create request: %w", err)
|
return fmt.Errorf("create request: %w", err)
|
||||||
}
|
}
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
req.Header.Set("Accept", "application/json")
|
|
||||||
|
|
||||||
resp, err := utlsClient.Do(req)
|
resp, err := utlsClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -100,59 +95,36 @@ func RefreshToken(ctx context.Context, cred *Credential) error {
|
|||||||
|
|
||||||
func persistCredential(cred *Credential) error {
|
func persistCredential(cred *Credential) error {
|
||||||
cred.mu.Lock()
|
cred.mu.Lock()
|
||||||
id := cred.ID
|
|
||||||
filePath := cred.FilePath
|
filePath := cred.FilePath
|
||||||
accessToken := cred.AccessToken
|
accessToken := cred.AccessToken
|
||||||
refreshToken := cred.RefreshToken
|
refreshToken := cred.RefreshToken
|
||||||
expiresAt := cred.ExpiresAt
|
expiresAt := cred.ExpiresAt
|
||||||
email := cred.Email
|
|
||||||
cred.mu.Unlock()
|
cred.mu.Unlock()
|
||||||
|
|
||||||
if filePath == "" {
|
if filePath == "" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if id == "claude-native" {
|
raw, err := os.ReadFile(filePath)
|
||||||
return persistNativeCredential(filePath, accessToken, refreshToken, expiresAt)
|
|
||||||
}
|
|
||||||
return persistCliProxyCredential(filePath, accessToken, refreshToken, expiresAt, email)
|
|
||||||
}
|
|
||||||
|
|
||||||
func persistCliProxyCredential(path, accessToken, refreshToken string, expiresAt time.Time, email string) error {
|
|
||||||
data := map[string]string{
|
|
||||||
"access_token": accessToken,
|
|
||||||
"refresh_token": refreshToken,
|
|
||||||
"email": email,
|
|
||||||
"expired": expiresAt.Format(time.RFC3339),
|
|
||||||
"type": "claude",
|
|
||||||
"last_refresh": time.Now().Format(time.RFC3339),
|
|
||||||
}
|
|
||||||
out, _ := json.MarshalIndent(data, "", " ")
|
|
||||||
return os.WriteFile(path, out, 0600)
|
|
||||||
}
|
|
||||||
|
|
||||||
func persistNativeCredential(path, accessToken, refreshToken string, expiresAt time.Time) error {
|
|
||||||
raw, err := os.ReadFile(path)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
var doc map[string]interface{}
|
var doc map[string]any
|
||||||
if err := json.Unmarshal(raw, &doc); err != nil {
|
if err := json.Unmarshal(raw, &doc); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
oauth, _ := doc["claudeAiOauth"].(map[string]interface{})
|
oauth, _ := doc["claudeAiOauth"].(map[string]any)
|
||||||
if oauth == nil {
|
if oauth == nil {
|
||||||
oauth = make(map[string]interface{})
|
oauth = make(map[string]any)
|
||||||
}
|
}
|
||||||
oauth["accessToken"] = accessToken
|
oauth["accessToken"] = accessToken
|
||||||
oauth["refreshToken"] = refreshToken
|
oauth["refreshToken"] = refreshToken
|
||||||
oauth["expiresAt"] = expiresAt.UnixMilli()
|
oauth["expiresAt"] = expiresAt.UnixMilli()
|
||||||
doc["claudeAiOauth"] = oauth
|
doc["claudeAiOauth"] = oauth
|
||||||
out, _ := json.MarshalIndent(doc, "", " ")
|
out, _ := json.MarshalIndent(doc, "", " ")
|
||||||
return os.WriteFile(path, out, 0600)
|
return os.WriteFile(filePath, out, 0600)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Chrome TLS HTTP client for refresh requests (same as proxy transport).
|
|
||||||
func newUTLSClient() *http.Client {
|
func newUTLSClient() *http.Client {
|
||||||
return &http.Client{
|
return &http.Client{
|
||||||
Timeout: 15 * time.Second,
|
Timeout: 15 * time.Second,
|
||||||
@@ -214,7 +186,6 @@ func (t *utlsRefreshTransport) RoundTrip(req *http.Request) (*http.Response, err
|
|||||||
return h2Conn.RoundTrip(req)
|
return h2Conn.RoundTrip(req)
|
||||||
}
|
}
|
||||||
|
|
||||||
// StartBackgroundRefresh runs a goroutine that checks and refreshes tokens periodically.
|
|
||||||
func StartBackgroundRefresh(ctx context.Context, pool *Pool) {
|
func StartBackgroundRefresh(ctx context.Context, pool *Pool) {
|
||||||
go func() {
|
go func() {
|
||||||
for {
|
for {
|
||||||
@@ -223,13 +194,13 @@ func StartBackgroundRefresh(ctx context.Context, pool *Pool) {
|
|||||||
log.Printf("background refresh stopped")
|
log.Printf("background refresh stopped")
|
||||||
return
|
return
|
||||||
case <-time.After(refreshInterval):
|
case <-time.After(refreshInterval):
|
||||||
refreshAll(pool)
|
refreshExpiring(pool)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
func refreshAll(pool *Pool) {
|
func refreshExpiring(pool *Pool) {
|
||||||
pool.mu.Lock()
|
pool.mu.Lock()
|
||||||
creds := make([]*Credential, len(pool.creds))
|
creds := make([]*Credential, len(pool.creds))
|
||||||
copy(creds, pool.creds)
|
copy(creds, pool.creds)
|
||||||
@@ -269,18 +240,3 @@ func refreshAll(pool *Pool) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// NeedsRefresh checks if a credential needs refresh within the lead time.
|
|
||||||
func NeedsRefresh(cred *Credential) bool {
|
|
||||||
cred.mu.Lock()
|
|
||||||
defer cred.mu.Unlock()
|
|
||||||
if cred.ExpiresAt.IsZero() || cred.RefreshToken == "" {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return time.Until(cred.ExpiresAt) <= refreshLead
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsNativeCredential checks if the credential is from ~/.claude/.credentials.json.
|
|
||||||
func IsNativeCredential(cred *Credential) bool {
|
|
||||||
return cred.ID == "claude-native" || strings.Contains(cred.FilePath, ".credentials.json")
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -54,5 +54,49 @@ func (p *Pool) MarkSuccess(cred *Credential) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (p *Pool) RefreshExpiring(ctx context.Context) {
|
func (p *Pool) RefreshExpiring(ctx context.Context) {
|
||||||
refreshAll(p)
|
refreshExpiring(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Pool) RefreshAll(ctx context.Context) []map[string]string {
|
||||||
|
p.mu.Lock()
|
||||||
|
creds := make([]*Credential, len(p.creds))
|
||||||
|
copy(creds, p.creds)
|
||||||
|
p.mu.Unlock()
|
||||||
|
|
||||||
|
var results []map[string]string
|
||||||
|
for _, cred := range creds {
|
||||||
|
cred.mu.Lock()
|
||||||
|
id := cred.ID
|
||||||
|
email := cred.Email
|
||||||
|
oldExpiry := cred.ExpiresAt
|
||||||
|
hasRefresh := cred.RefreshToken != ""
|
||||||
|
cred.mu.Unlock()
|
||||||
|
|
||||||
|
r := map[string]string{
|
||||||
|
"id": id,
|
||||||
|
"email": email,
|
||||||
|
"old_expiry": oldExpiry.Format(time.RFC3339),
|
||||||
|
}
|
||||||
|
|
||||||
|
if !hasRefresh {
|
||||||
|
r["status"] = "skipped"
|
||||||
|
r["reason"] = "no refresh token"
|
||||||
|
results = append(results, r)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
err := RefreshToken(ctx, cred)
|
||||||
|
if err != nil {
|
||||||
|
r["status"] = "error"
|
||||||
|
r["error"] = err.Error()
|
||||||
|
} else {
|
||||||
|
cred.mu.Lock()
|
||||||
|
r["status"] = "ok"
|
||||||
|
r["new_expiry"] = cred.ExpiresAt.Format(time.RFC3339)
|
||||||
|
r["new_token_prefix"] = cred.AccessToken[:20] + "..."
|
||||||
|
cred.mu.Unlock()
|
||||||
|
}
|
||||||
|
results = append(results, r)
|
||||||
|
}
|
||||||
|
return results
|
||||||
}
|
}
|
||||||
|
|||||||
+15
-82
@@ -4,7 +4,6 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/fujin/anthropic-proxy/internal/auth"
|
"github.com/fujin/anthropic-proxy/internal/auth"
|
||||||
@@ -12,18 +11,17 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Port int `yaml:"port"`
|
Port int `yaml:"port"`
|
||||||
APIKeys []string `yaml:"api_keys"`
|
APIKeys []string `yaml:"api_keys"`
|
||||||
AuthDir string `yaml:"auth_dir"`
|
ClaudeCredentials string `yaml:"claude_credentials"`
|
||||||
ClaudeCredentials string `yaml:"claude_credentials"`
|
ClaudeBinary string `yaml:"claude_binary"`
|
||||||
ClaudeBinary string `yaml:"claude_binary"`
|
Sanitize SanitizeConfig `yaml:"sanitize"`
|
||||||
Sanitize SanitizeConfig `yaml:"sanitize"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type SanitizeConfig struct {
|
type SanitizeConfig struct {
|
||||||
Tools []RenameRule `yaml:"tools"`
|
Tools []RenameRule `yaml:"tools"`
|
||||||
System []ReplaceRule `yaml:"system"`
|
System []ReplaceRule `yaml:"system"`
|
||||||
Body []ReplaceRule `yaml:"body"`
|
Body []ReplaceRule `yaml:"body"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type RenameRule struct {
|
type RenameRule struct {
|
||||||
@@ -36,14 +34,6 @@ type ReplaceRule struct {
|
|||||||
Replace string `yaml:"replace"`
|
Replace string `yaml:"replace"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type authFileJSON struct {
|
|
||||||
AccessToken string `json:"access_token"`
|
|
||||||
RefreshToken string `json:"refresh_token"`
|
|
||||||
Email string `json:"email"`
|
|
||||||
Expired string `json:"expired"`
|
|
||||||
Type string `json:"type"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type claudeCredentialsJSON struct {
|
type claudeCredentialsJSON struct {
|
||||||
ClaudeAiOauth struct {
|
ClaudeAiOauth struct {
|
||||||
AccessToken string `json:"accessToken"`
|
AccessToken string `json:"accessToken"`
|
||||||
@@ -68,28 +58,19 @@ func Load(path string) (*Config, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func LoadCredentials(cfg *Config) ([]*auth.Credential, error) {
|
func LoadCredentials(cfg *Config) ([]*auth.Credential, error) {
|
||||||
var creds []*auth.Credential
|
if cfg.ClaudeCredentials == "" {
|
||||||
|
return nil, fmt.Errorf("claude_credentials not set")
|
||||||
if cfg.ClaudeCredentials != "" {
|
|
||||||
cred, err := loadClaudeCredentials(cfg.ClaudeCredentials)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("load claude credentials: %w", err)
|
|
||||||
}
|
|
||||||
creds = append(creds, cred)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if cfg.AuthDir != "" {
|
cred, err := loadCredentials(cfg.ClaudeCredentials)
|
||||||
dirCreds, err := loadAuthDir(cfg.AuthDir)
|
if err != nil {
|
||||||
if err != nil {
|
return nil, err
|
||||||
return nil, fmt.Errorf("load auth dir: %w", err)
|
|
||||||
}
|
|
||||||
creds = append(creds, dirCreds...)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return creds, nil
|
return []*auth.Credential{cred}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadClaudeCredentials(path string) (*auth.Credential, error) {
|
func loadCredentials(path string) (*auth.Credential, error) {
|
||||||
data, err := os.ReadFile(path)
|
data, err := os.ReadFile(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -114,51 +95,3 @@ func loadClaudeCredentials(path string) (*auth.Credential, error) {
|
|||||||
FilePath: path,
|
FilePath: path,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadAuthDir(authDir string) ([]*auth.Credential, error) {
|
|
||||||
pattern := filepath.Join(authDir, "*.json")
|
|
||||||
files, err := filepath.Glob(pattern)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("glob auth files: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var creds []*auth.Credential
|
|
||||||
for _, f := range files {
|
|
||||||
cred, err := loadAuthFile(f)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("load auth file %s: %w", f, err)
|
|
||||||
}
|
|
||||||
creds = append(creds, cred)
|
|
||||||
}
|
|
||||||
|
|
||||||
return creds, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func loadAuthFile(path string) (*auth.Credential, error) {
|
|
||||||
data, err := os.ReadFile(path)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
var af authFileJSON
|
|
||||||
if err := json.Unmarshal(data, &af); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
expiresAt, err := time.Parse(time.RFC3339, af.Expired)
|
|
||||||
if err != nil {
|
|
||||||
expiresAt, err = time.Parse("2006-01-02T15:04:05", af.Expired)
|
|
||||||
if err != nil {
|
|
||||||
expiresAt = time.Now()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return &auth.Credential{
|
|
||||||
ID: filepath.Base(path),
|
|
||||||
Email: af.Email,
|
|
||||||
AccessToken: af.AccessToken,
|
|
||||||
RefreshToken: af.RefreshToken,
|
|
||||||
ExpiresAt: expiresAt,
|
|
||||||
FilePath: path,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
@@ -45,6 +46,8 @@ func New(cfg *config.Config, pool *auth.Pool, profile *proxy.SniffedProfile) *Se
|
|||||||
engine.POST("/messages", handler)
|
engine.POST("/messages", handler)
|
||||||
|
|
||||||
engine.POST("/reload", s.handleReload())
|
engine.POST("/reload", s.handleReload())
|
||||||
|
engine.POST("/debug/refresh", handleDebugRefresh(pool))
|
||||||
|
engine.POST("/debug/shutdown", handleDebugShutdown(s))
|
||||||
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"})
|
||||||
})
|
})
|
||||||
@@ -97,6 +100,26 @@ func (s *Server) handleReload() gin.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func handleDebugShutdown(s *Server) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"status": "shutting down"})
|
||||||
|
go func() {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if err := s.Shutdown(ctx); err != nil {
|
||||||
|
log.Printf("shutdown error: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleDebugRefresh(pool *auth.Pool) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
results := pool.RefreshAll(c.Request.Context())
|
||||||
|
c.JSON(http.StatusOK, results)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func makeKeySet(apiKeys []string) map[string]struct{} {
|
func makeKeySet(apiKeys []string) map[string]struct{} {
|
||||||
keySet := make(map[string]struct{}, len(apiKeys))
|
keySet := make(map[string]struct{}, len(apiKeys))
|
||||||
for _, k := range apiKeys {
|
for _, k := range apiKeys {
|
||||||
@@ -123,7 +146,7 @@ func corsMiddleware() gin.HandlerFunc {
|
|||||||
func (s *Server) authMiddleware() gin.HandlerFunc {
|
func (s *Server) authMiddleware() gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
path := c.Request.URL.Path
|
path := c.Request.URL.Path
|
||||||
if path == "/healthz" || path == "/reload" {
|
if path == "/healthz" || path == "/reload" || strings.HasPrefix(path, "/debug/") {
|
||||||
c.Next()
|
c.Next()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user