680 lines · 22.3 KB · Go
RawDownload
// Package api provides the HTTP API server implementation for the CLI Proxy API.// It includes the main server struct, routing setup, middleware for CORS and authentication,// and integration with various AI API handlers (OpenAI, Claude, Gemini).// The server supports hot-reloading of clients and configuration.package api import (	"context"	"crypto/subtle"	"errors"	"fmt"	"net/http"	"os"	"path/filepath"	"strings"	"time" 	"github.com/gin-gonic/gin"	"github.com/router-for-me/CLIProxyAPI/v6/internal/access"	managementHandlers "github.com/router-for-me/CLIProxyAPI/v6/internal/api/handlers/management"	"github.com/router-for-me/CLIProxyAPI/v6/internal/api/middleware"	"github.com/router-for-me/CLIProxyAPI/v6/internal/config"	"github.com/router-for-me/CLIProxyAPI/v6/internal/logging"	"github.com/router-for-me/CLIProxyAPI/v6/internal/usage"	"github.com/router-for-me/CLIProxyAPI/v6/internal/util"	sdkaccess "github.com/router-for-me/CLIProxyAPI/v6/sdk/access"	"github.com/router-for-me/CLIProxyAPI/v6/sdk/api/handlers"	"github.com/router-for-me/CLIProxyAPI/v6/sdk/api/handlers/claude"	"github.com/router-for-me/CLIProxyAPI/v6/sdk/api/handlers/gemini"	"github.com/router-for-me/CLIProxyAPI/v6/sdk/api/handlers/openai"	"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"	log "github.com/sirupsen/logrus") type serverOptionConfig struct {	extraMiddleware      []gin.HandlerFunc	engineConfigurator   func(*gin.Engine)	routerConfigurator   func(*gin.Engine, *handlers.BaseAPIHandler, *config.Config)	requestLoggerFactory func(*config.Config, string) logging.RequestLogger	localPassword        string	keepAliveEnabled     bool	keepAliveTimeout     time.Duration	keepAliveOnTimeout   func()} // ServerOption customises HTTP server construction.type ServerOption func(*serverOptionConfig) func defaultRequestLoggerFactory(cfg *config.Config, configPath string) logging.RequestLogger {	return logging.NewFileRequestLogger(cfg.RequestLog, "logs", filepath.Dir(configPath))} // WithMiddleware appends additional Gin middleware during server construction.func WithMiddleware(mw ...gin.HandlerFunc) ServerOption {	return func(cfg *serverOptionConfig) {		cfg.extraMiddleware = append(cfg.extraMiddleware, mw...)	}} // WithEngineConfigurator allows callers to mutate the Gin engine prior to middleware setup.func WithEngineConfigurator(fn func(*gin.Engine)) ServerOption {	return func(cfg *serverOptionConfig) {		cfg.engineConfigurator = fn	}} // WithRouterConfigurator appends a callback after default routes are registered.func WithRouterConfigurator(fn func(*gin.Engine, *handlers.BaseAPIHandler, *config.Config)) ServerOption {	return func(cfg *serverOptionConfig) {		cfg.routerConfigurator = fn	}} // WithLocalManagementPassword stores a runtime-only management password accepted for localhost requests.func WithLocalManagementPassword(password string) ServerOption {	return func(cfg *serverOptionConfig) {		cfg.localPassword = password	}} // WithKeepAliveEndpoint enables a keep-alive endpoint with the provided timeout and callback.func WithKeepAliveEndpoint(timeout time.Duration, onTimeout func()) ServerOption {	return func(cfg *serverOptionConfig) {		if timeout <= 0 || onTimeout == nil {			return		}		cfg.keepAliveEnabled = true		cfg.keepAliveTimeout = timeout		cfg.keepAliveOnTimeout = onTimeout	}} // WithRequestLoggerFactory customises request logger creation.func WithRequestLoggerFactory(factory func(*config.Config, string) logging.RequestLogger) ServerOption {	return func(cfg *serverOptionConfig) {		cfg.requestLoggerFactory = factory	}} // Server represents the main API server.// It encapsulates the Gin engine, HTTP server, handlers, and configuration.type Server struct {	// engine is the Gin web framework engine instance.	engine *gin.Engine 	// server is the underlying HTTP server.	server *http.Server 	// handlers contains the API handlers for processing requests.	handlers *handlers.BaseAPIHandler 	// cfg holds the current server configuration.	cfg *config.Config 	// accessManager handles request authentication providers.	accessManager *sdkaccess.Manager 	// requestLogger is the request logger instance for dynamic configuration updates.	requestLogger logging.RequestLogger	loggerToggle  func(bool) 	// configFilePath is the absolute path to the YAML config file for persistence.	configFilePath string 	// management handler	mgmt *managementHandlers.Handler 	localPassword string 	keepAliveEnabled   bool	keepAliveTimeout   time.Duration	keepAliveOnTimeout func()	keepAliveHeartbeat chan struct{}	keepAliveStop      chan struct{}} // NewServer creates and initializes a new API server instance.// It sets up the Gin engine, middleware, routes, and handlers.//// Parameters://   - cfg: The server configuration//   - authManager: core runtime auth manager//   - accessManager: request authentication manager//// Returns://   - *Server: A new server instancefunc NewServer(cfg *config.Config, authManager *auth.Manager, accessManager *sdkaccess.Manager, configFilePath string, opts ...ServerOption) *Server {	optionState := &serverOptionConfig{		requestLoggerFactory: defaultRequestLoggerFactory,	}	for i := range opts {		opts[i](optionState)	}	// Set gin mode	if !cfg.Debug {		gin.SetMode(gin.ReleaseMode)	} 	// Create gin engine	engine := gin.New()	if optionState.engineConfigurator != nil {		optionState.engineConfigurator(engine)	} 	// Add middleware	engine.Use(logging.GinLogrusLogger())	engine.Use(logging.GinLogrusRecovery())	for _, mw := range optionState.extraMiddleware {		engine.Use(mw)	} 	// Add request logging middleware (positioned after recovery, before auth)	// Resolve logs directory relative to the configuration file directory.	var requestLogger logging.RequestLogger	var toggle func(bool)	if optionState.requestLoggerFactory != nil {		requestLogger = optionState.requestLoggerFactory(cfg, configFilePath)	}	if requestLogger != nil {		engine.Use(middleware.RequestLoggingMiddleware(requestLogger))		if setter, ok := requestLogger.(interface{ SetEnabled(bool) }); ok {			toggle = setter.SetEnabled		}	} 	engine.Use(corsMiddleware()) 	// Create server instance	s := &Server{		engine:         engine,		handlers:       handlers.NewBaseAPIHandlers(&cfg.SDKConfig, authManager),		cfg:            cfg,		accessManager:  accessManager,		requestLogger:  requestLogger,		loggerToggle:   toggle,		configFilePath: configFilePath,	}	s.applyAccessConfig(nil, cfg)	// Initialize management handler	s.mgmt = managementHandlers.NewHandler(cfg, configFilePath, authManager)	if optionState.localPassword != "" {		s.mgmt.SetLocalPassword(optionState.localPassword)	}	s.localPassword = optionState.localPassword 	// Setup routes	s.setupRoutes()	if optionState.routerConfigurator != nil {		optionState.routerConfigurator(engine, s.handlers, cfg)	} 	if optionState.keepAliveEnabled {		s.enableKeepAlive(optionState.keepAliveTimeout, optionState.keepAliveOnTimeout)	} 	// Create HTTP server	s.server = &http.Server{		Addr:    fmt.Sprintf(":%d", cfg.Port),		Handler: engine,	} 	return s} // setupRoutes configures the API routes for the server.// It defines the endpoints and associates them with their respective handlers.func (s *Server) setupRoutes() {	openaiHandlers := openai.NewOpenAIAPIHandler(s.handlers)	geminiHandlers := gemini.NewGeminiAPIHandler(s.handlers)	geminiCLIHandlers := gemini.NewGeminiCLIAPIHandler(s.handlers)	claudeCodeHandlers := claude.NewClaudeCodeAPIHandler(s.handlers)	openaiResponsesHandlers := openai.NewOpenAIResponsesAPIHandler(s.handlers) 	// OpenAI compatible API routes	v1 := s.engine.Group("/v1")	v1.Use(AuthMiddleware(s.accessManager))	{		v1.GET("/models", s.unifiedModelsHandler(openaiHandlers, claudeCodeHandlers))		v1.POST("/chat/completions", openaiHandlers.ChatCompletions)		v1.POST("/completions", openaiHandlers.Completions)		v1.POST("/messages", claudeCodeHandlers.ClaudeMessages)		v1.POST("/messages/count_tokens", claudeCodeHandlers.ClaudeCountTokens)		v1.POST("/responses", openaiResponsesHandlers.Responses)	} 	// Gemini compatible API routes	v1beta := s.engine.Group("/v1beta")	v1beta.Use(AuthMiddleware(s.accessManager))	{		v1beta.GET("/models", geminiHandlers.GeminiModels)		v1beta.POST("/models/:action", geminiHandlers.GeminiHandler)		v1beta.GET("/models/:action", geminiHandlers.GeminiGetHandler)	} 	// Root endpoint	s.engine.GET("/", func(c *gin.Context) {		c.JSON(http.StatusOK, gin.H{			"message": "CLI Proxy API Server",			"version": "1.0.0",			"endpoints": []string{				"POST /v1/chat/completions",				"POST /v1/completions",				"GET /v1/models",			},		})	})	s.engine.POST("/v1internal:method", geminiCLIHandlers.CLIHandler) 	// OAuth callback endpoints (reuse main server port)	// These endpoints receive provider redirects and persist	// the short-lived code/state for the waiting goroutine.	s.engine.GET("/anthropic/callback", func(c *gin.Context) {		code := c.Query("code")		state := c.Query("state")		errStr := c.Query("error")		// Persist to a temporary file keyed by state		if state != "" {			file := fmt.Sprintf("%s/.oauth-anthropic-%s.oauth", s.cfg.AuthDir, state)			_ = os.WriteFile(file, []byte(fmt.Sprintf(`{"code":"%s","state":"%s","error":"%s"}`, code, state, errStr)), 0o600)		}		c.Header("Content-Type", "text/html; charset=utf-8")		c.String(http.StatusOK, "<html><body><h1>Authentication successful!</h1><p>You can close this window.</p></body></html>")	}) 	s.engine.GET("/codex/callback", func(c *gin.Context) {		code := c.Query("code")		state := c.Query("state")		errStr := c.Query("error")		if state != "" {			file := fmt.Sprintf("%s/.oauth-codex-%s.oauth", s.cfg.AuthDir, state)			_ = os.WriteFile(file, []byte(fmt.Sprintf(`{"code":"%s","state":"%s","error":"%s"}`, code, state, errStr)), 0o600)		}		c.Header("Content-Type", "text/html; charset=utf-8")		c.String(http.StatusOK, "<html><body><h1>Authentication successful!</h1><p>You can close this window.</p></body></html>")	}) 	s.engine.GET("/google/callback", func(c *gin.Context) {		code := c.Query("code")		state := c.Query("state")		errStr := c.Query("error")		if state != "" {			file := fmt.Sprintf("%s/.oauth-gemini-%s.oauth", s.cfg.AuthDir, state)			_ = os.WriteFile(file, []byte(fmt.Sprintf(`{"code":"%s","state":"%s","error":"%s"}`, code, state, errStr)), 0o600)		}		c.Header("Content-Type", "text/html; charset=utf-8")		c.String(http.StatusOK, "<html><body><h1>Authentication successful!</h1><p>You can close this window.</p></body></html>")	}) 	// Management API routes (delegated to management handlers)	// New logic: if remote-management-key is empty, do not expose any management endpoint (404).	if s.cfg.RemoteManagement.SecretKey != "" {		mgmt := s.engine.Group("/v0/management")		mgmt.Use(s.mgmt.Middleware())		{			mgmt.GET("/usage", s.mgmt.GetUsageStatistics)			mgmt.GET("/config", s.mgmt.GetConfig) 			mgmt.GET("/debug", s.mgmt.GetDebug)			mgmt.PUT("/debug", s.mgmt.PutDebug)			mgmt.PATCH("/debug", s.mgmt.PutDebug) 			mgmt.GET("/logging-to-file", s.mgmt.GetLoggingToFile)			mgmt.PUT("/logging-to-file", s.mgmt.PutLoggingToFile)			mgmt.PATCH("/logging-to-file", s.mgmt.PutLoggingToFile) 			mgmt.GET("/usage-statistics-enabled", s.mgmt.GetUsageStatisticsEnabled)			mgmt.PUT("/usage-statistics-enabled", s.mgmt.PutUsageStatisticsEnabled)			mgmt.PATCH("/usage-statistics-enabled", s.mgmt.PutUsageStatisticsEnabled) 			mgmt.GET("/proxy-url", s.mgmt.GetProxyURL)			mgmt.PUT("/proxy-url", s.mgmt.PutProxyURL)			mgmt.PATCH("/proxy-url", s.mgmt.PutProxyURL)			mgmt.DELETE("/proxy-url", s.mgmt.DeleteProxyURL) 			mgmt.GET("/quota-exceeded/switch-project", s.mgmt.GetSwitchProject)			mgmt.PUT("/quota-exceeded/switch-project", s.mgmt.PutSwitchProject)			mgmt.PATCH("/quota-exceeded/switch-project", s.mgmt.PutSwitchProject) 			mgmt.GET("/quota-exceeded/switch-preview-model", s.mgmt.GetSwitchPreviewModel)			mgmt.PUT("/quota-exceeded/switch-preview-model", s.mgmt.PutSwitchPreviewModel)			mgmt.PATCH("/quota-exceeded/switch-preview-model", s.mgmt.PutSwitchPreviewModel) 			mgmt.GET("/api-keys", s.mgmt.GetAPIKeys)			mgmt.PUT("/api-keys", s.mgmt.PutAPIKeys)			mgmt.PATCH("/api-keys", s.mgmt.PatchAPIKeys)			mgmt.DELETE("/api-keys", s.mgmt.DeleteAPIKeys) 			mgmt.GET("/generative-language-api-key", s.mgmt.GetGlKeys)			mgmt.PUT("/generative-language-api-key", s.mgmt.PutGlKeys)			mgmt.PATCH("/generative-language-api-key", s.mgmt.PatchGlKeys)			mgmt.DELETE("/generative-language-api-key", s.mgmt.DeleteGlKeys) 			mgmt.GET("/request-log", s.mgmt.GetRequestLog)			mgmt.PUT("/request-log", s.mgmt.PutRequestLog)			mgmt.PATCH("/request-log", s.mgmt.PutRequestLog) 			mgmt.GET("/request-retry", s.mgmt.GetRequestRetry)			mgmt.PUT("/request-retry", s.mgmt.PutRequestRetry)			mgmt.PATCH("/request-retry", s.mgmt.PutRequestRetry) 			mgmt.GET("/claude-api-key", s.mgmt.GetClaudeKeys)			mgmt.PUT("/claude-api-key", s.mgmt.PutClaudeKeys)			mgmt.PATCH("/claude-api-key", s.mgmt.PatchClaudeKey)			mgmt.DELETE("/claude-api-key", s.mgmt.DeleteClaudeKey) 			mgmt.GET("/codex-api-key", s.mgmt.GetCodexKeys)			mgmt.PUT("/codex-api-key", s.mgmt.PutCodexKeys)			mgmt.PATCH("/codex-api-key", s.mgmt.PatchCodexKey)			mgmt.DELETE("/codex-api-key", s.mgmt.DeleteCodexKey) 			mgmt.GET("/openai-compatibility", s.mgmt.GetOpenAICompat)			mgmt.PUT("/openai-compatibility", s.mgmt.PutOpenAICompat)			mgmt.PATCH("/openai-compatibility", s.mgmt.PatchOpenAICompat)			mgmt.DELETE("/openai-compatibility", s.mgmt.DeleteOpenAICompat) 			mgmt.GET("/auth-files", s.mgmt.ListAuthFiles)			mgmt.GET("/auth-files/download", s.mgmt.DownloadAuthFile)			mgmt.POST("/auth-files", s.mgmt.UploadAuthFile)			mgmt.DELETE("/auth-files", s.mgmt.DeleteAuthFile) 			mgmt.GET("/anthropic-auth-url", s.mgmt.RequestAnthropicToken)			mgmt.GET("/codex-auth-url", s.mgmt.RequestCodexToken)			mgmt.GET("/gemini-cli-auth-url", s.mgmt.RequestGeminiCLIToken)			mgmt.POST("/gemini-web-token", s.mgmt.CreateGeminiWebToken)			mgmt.GET("/qwen-auth-url", s.mgmt.RequestQwenToken)			mgmt.GET("/get-auth-status", s.mgmt.GetAuthStatus)		}	}} func (s *Server) enableKeepAlive(timeout time.Duration, onTimeout func()) {	if timeout <= 0 || onTimeout == nil {		return	} 	s.keepAliveEnabled = true	s.keepAliveTimeout = timeout	s.keepAliveOnTimeout = onTimeout	s.keepAliveHeartbeat = make(chan struct{}, 1)	s.keepAliveStop = make(chan struct{}, 1) 	s.engine.GET("/keep-alive", s.handleKeepAlive) 	go s.watchKeepAlive()} func (s *Server) handleKeepAlive(c *gin.Context) {	if s.localPassword != "" {		provided := strings.TrimSpace(c.GetHeader("Authorization"))		if provided != "" {			parts := strings.SplitN(provided, " ", 2)			if len(parts) == 2 && strings.EqualFold(parts[0], "bearer") {				provided = parts[1]			}		}		if provided == "" {			provided = strings.TrimSpace(c.GetHeader("X-Local-Password"))		}		if subtle.ConstantTimeCompare([]byte(provided), []byte(s.localPassword)) != 1 {			c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid password"})			return		}	} 	s.signalKeepAlive()	c.JSON(http.StatusOK, gin.H{"status": "ok"})} func (s *Server) signalKeepAlive() {	if !s.keepAliveEnabled {		return	}	select {	case s.keepAliveHeartbeat <- struct{}{}:	default:	}} func (s *Server) watchKeepAlive() {	if !s.keepAliveEnabled {		return	} 	timer := time.NewTimer(s.keepAliveTimeout)	defer timer.Stop() 	for {		select {		case <-timer.C:			log.Warnf("keep-alive endpoint idle for %s, shutting down", s.keepAliveTimeout)			if s.keepAliveOnTimeout != nil {				s.keepAliveOnTimeout()			}			return		case <-s.keepAliveHeartbeat:			if !timer.Stop() {				select {				case <-timer.C:				default:				}			}			timer.Reset(s.keepAliveTimeout)		case <-s.keepAliveStop:			return		}	}} // unifiedModelsHandler creates a unified handler for the /v1/models endpoint// that routes to different handlers based on the User-Agent header.// If User-Agent starts with "claude-cli", it routes to Claude handler,// otherwise it routes to OpenAI handler.func (s *Server) unifiedModelsHandler(openaiHandler *openai.OpenAIAPIHandler, claudeHandler *claude.ClaudeCodeAPIHandler) gin.HandlerFunc {	return func(c *gin.Context) {		userAgent := c.GetHeader("User-Agent") 		// Route to Claude handler if User-Agent starts with "claude-cli"		if strings.HasPrefix(userAgent, "claude-cli") {			// log.Debugf("Routing /v1/models to Claude handler for User-Agent: %s", userAgent)			claudeHandler.ClaudeModels(c)		} else {			// log.Debugf("Routing /v1/models to OpenAI handler for User-Agent: %s", userAgent)			openaiHandler.OpenAIModels(c)		}	}} // Start begins listening for and serving HTTP requests.// It's a blocking call and will only return on an unrecoverable error.//// Returns://   - error: An error if the server fails to startfunc (s *Server) Start() error {	log.Debugf("Starting API server on %s", s.server.Addr) 	// Start the HTTP server.	if err := s.server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {		return fmt.Errorf("failed to start HTTP server: %v", err)	} 

Lines 1–500 of 680.