mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-02-02 04:20:50 +08:00
- Introduce Server.AttachWebsocketRoute(path, handler) to mount websocket upgrade handlers on the Gin engine. - Track registered WS paths via wsRoutes with wsRouteMu to prevent duplicate registrations; initialize in NewServer and import sync. - Add Manager.UnregisterExecutor(provider) for clean executor lifecycle management. - Add github.com/gorilla/websocket v1.5.3 dependency and update go.sum. Motivation: enable services to expose WS endpoints through the core server and allow removing auth executors dynamically while avoiding duplicate route setup. No breaking changes.
884 lines
29 KiB
Go
884 lines
29 KiB
Go
// 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"
|
|
"sync"
|
|
"sync/atomic"
|
|
"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/managementasset"
|
|
"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"
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
const oauthCallbackSuccessHTML = `<html><head><meta charset="utf-8"><title>Authentication successful</title><script>setTimeout(function(){window.close();},5000);</script></head><body><h1>Authentication successful!</h1><p>You can close this window.</p><p>This window will close automatically in 5 seconds.</p></body></html>`
|
|
|
|
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 {
|
|
configDir := filepath.Dir(configPath)
|
|
if base := util.WritablePath(); base != "" {
|
|
return logging.NewFileRequestLogger(cfg.RequestLog, filepath.Join(base, "logs"), configDir)
|
|
}
|
|
return logging.NewFileRequestLogger(cfg.RequestLog, "logs", configDir)
|
|
}
|
|
|
|
// 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
|
|
|
|
// oldConfigYaml stores a YAML snapshot of the previous configuration for change detection.
|
|
// This prevents issues when the config object is modified in place by Management API.
|
|
oldConfigYaml []byte
|
|
|
|
// 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
|
|
|
|
// currentPath is the absolute path to the current working directory.
|
|
currentPath string
|
|
|
|
// wsRoutes tracks registered websocket upgrade paths.
|
|
wsRouteMu sync.Mutex
|
|
wsRoutes map[string]struct{}
|
|
|
|
// management handler
|
|
mgmt *managementHandlers.Handler
|
|
|
|
// managementRoutesRegistered tracks whether the management routes have been attached to the engine.
|
|
managementRoutesRegistered atomic.Bool
|
|
// managementRoutesEnabled controls whether management endpoints serve real handlers.
|
|
managementRoutesEnabled atomic.Bool
|
|
|
|
// envManagementSecret indicates whether MANAGEMENT_PASSWORD is configured.
|
|
envManagementSecret bool
|
|
|
|
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 instance
|
|
func 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())
|
|
wd, err := os.Getwd()
|
|
if err != nil {
|
|
wd = configFilePath
|
|
}
|
|
|
|
envAdminPassword, envAdminPasswordSet := os.LookupEnv("MANAGEMENT_PASSWORD")
|
|
envAdminPassword = strings.TrimSpace(envAdminPassword)
|
|
envManagementSecret := envAdminPasswordSet && envAdminPassword != ""
|
|
|
|
// Create server instance
|
|
s := &Server{
|
|
engine: engine,
|
|
handlers: handlers.NewBaseAPIHandlers(&cfg.SDKConfig, authManager),
|
|
cfg: cfg,
|
|
accessManager: accessManager,
|
|
requestLogger: requestLogger,
|
|
loggerToggle: toggle,
|
|
configFilePath: configFilePath,
|
|
currentPath: wd,
|
|
envManagementSecret: envManagementSecret,
|
|
wsRoutes: make(map[string]struct{}),
|
|
}
|
|
// Save initial YAML snapshot
|
|
s.oldConfigYaml, _ = yaml.Marshal(cfg)
|
|
s.applyAccessConfig(nil, cfg)
|
|
managementasset.SetCurrentConfig(cfg)
|
|
auth.SetQuotaCooldownDisabled(cfg.DisableCooling)
|
|
// Initialize management handler
|
|
s.mgmt = managementHandlers.NewHandler(cfg, configFilePath, authManager)
|
|
if optionState.localPassword != "" {
|
|
s.mgmt.SetLocalPassword(optionState.localPassword)
|
|
}
|
|
logDir := filepath.Join(s.currentPath, "logs")
|
|
if base := util.WritablePath(); base != "" {
|
|
logDir = filepath.Join(base, "logs")
|
|
}
|
|
s.mgmt.SetLogDirectory(logDir)
|
|
s.localPassword = optionState.localPassword
|
|
|
|
// Setup routes
|
|
s.setupRoutes()
|
|
if optionState.routerConfigurator != nil {
|
|
optionState.routerConfigurator(engine, s.handlers, cfg)
|
|
}
|
|
|
|
// Register management routes when configuration or environment secrets are available.
|
|
hasManagementSecret := cfg.RemoteManagement.SecretKey != "" || envManagementSecret
|
|
s.managementRoutesEnabled.Store(hasManagementSecret)
|
|
if hasManagementSecret {
|
|
s.registerManagementRoutes()
|
|
}
|
|
|
|
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() {
|
|
s.engine.GET("/management.html", s.serveManagementControlPanel)
|
|
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",
|
|
"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, oauthCallbackSuccessHTML)
|
|
})
|
|
|
|
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, oauthCallbackSuccessHTML)
|
|
})
|
|
|
|
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, oauthCallbackSuccessHTML)
|
|
})
|
|
|
|
s.engine.GET("/iflow/callback", func(c *gin.Context) {
|
|
code := c.Query("code")
|
|
state := c.Query("state")
|
|
errStr := c.Query("error")
|
|
if state != "" {
|
|
file := fmt.Sprintf("%s/.oauth-iflow-%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, oauthCallbackSuccessHTML)
|
|
})
|
|
|
|
// Management routes are registered lazily by registerManagementRoutes when a secret is configured.
|
|
}
|
|
|
|
// AttachWebsocketRoute registers a websocket upgrade handler on the primary Gin engine.
|
|
// The handler is served as-is without additional middleware beyond the standard stack already configured.
|
|
func (s *Server) AttachWebsocketRoute(path string, handler http.Handler) {
|
|
if s == nil || s.engine == nil || handler == nil {
|
|
return
|
|
}
|
|
trimmed := strings.TrimSpace(path)
|
|
if trimmed == "" {
|
|
trimmed = "/v1/ws"
|
|
}
|
|
if !strings.HasPrefix(trimmed, "/") {
|
|
trimmed = "/" + trimmed
|
|
}
|
|
s.wsRouteMu.Lock()
|
|
if _, exists := s.wsRoutes[trimmed]; exists {
|
|
s.wsRouteMu.Unlock()
|
|
return
|
|
}
|
|
s.wsRoutes[trimmed] = struct{}{}
|
|
s.wsRouteMu.Unlock()
|
|
|
|
s.engine.GET(trimmed, func(c *gin.Context) {
|
|
handler.ServeHTTP(c.Writer, c.Request)
|
|
c.Abort()
|
|
})
|
|
}
|
|
|
|
func (s *Server) registerManagementRoutes() {
|
|
if s == nil || s.engine == nil || s.mgmt == nil {
|
|
return
|
|
}
|
|
if !s.managementRoutesRegistered.CompareAndSwap(false, true) {
|
|
return
|
|
}
|
|
|
|
log.Info("management routes registered after secret key configuration")
|
|
|
|
mgmt := s.engine.Group("/v0/management")
|
|
mgmt.Use(s.managementAvailabilityMiddleware(), s.mgmt.Middleware())
|
|
{
|
|
mgmt.GET("/usage", s.mgmt.GetUsageStatistics)
|
|
mgmt.GET("/config", s.mgmt.GetConfig)
|
|
mgmt.PUT("/config.yaml", s.mgmt.PutConfigYAML)
|
|
mgmt.GET("/config.yaml", s.mgmt.GetConfigFile)
|
|
|
|
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("/logs", s.mgmt.GetLogs)
|
|
mgmt.DELETE("/logs", s.mgmt.DeleteLogs)
|
|
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.GET("/qwen-auth-url", s.mgmt.RequestQwenToken)
|
|
mgmt.GET("/iflow-auth-url", s.mgmt.RequestIFlowToken)
|
|
mgmt.GET("/get-auth-status", s.mgmt.GetAuthStatus)
|
|
}
|
|
}
|
|
|
|
func (s *Server) managementAvailabilityMiddleware() gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
if !s.managementRoutesEnabled.Load() {
|
|
c.AbortWithStatus(http.StatusNotFound)
|
|
return
|
|
}
|
|
c.Next()
|
|
}
|
|
}
|
|
|
|
func (s *Server) serveManagementControlPanel(c *gin.Context) {
|
|
cfg := s.cfg
|
|
if cfg == nil || cfg.RemoteManagement.DisableControlPanel {
|
|
c.AbortWithStatus(http.StatusNotFound)
|
|
return
|
|
}
|
|
filePath := managementasset.FilePath(s.currentPath)
|
|
if strings.TrimSpace(filePath) == "" {
|
|
c.AbortWithStatus(http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
if _, err := os.Stat(filePath); err != nil {
|
|
if os.IsNotExist(err) {
|
|
go managementasset.EnsureLatestManagementHTML(context.Background(), managementasset.StaticDir(s.currentPath), cfg.ProxyURL)
|
|
c.AbortWithStatus(http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
log.WithError(err).Error("failed to stat management control panel asset")
|
|
c.AbortWithStatus(http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
c.File(filePath)
|
|
}
|
|
|
|
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 start
|
|
func (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)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Stop gracefully shuts down the API server without interrupting any
|
|
// active connections.
|
|
//
|
|
// Parameters:
|
|
// - ctx: The context for graceful shutdown
|
|
//
|
|
// Returns:
|
|
// - error: An error if the server fails to stop
|
|
func (s *Server) Stop(ctx context.Context) error {
|
|
log.Debug("Stopping API server...")
|
|
|
|
if s.keepAliveEnabled {
|
|
select {
|
|
case s.keepAliveStop <- struct{}{}:
|
|
default:
|
|
}
|
|
}
|
|
|
|
// Shutdown the HTTP server.
|
|
if err := s.server.Shutdown(ctx); err != nil {
|
|
return fmt.Errorf("failed to shutdown HTTP server: %v", err)
|
|
}
|
|
|
|
log.Debug("API server stopped")
|
|
return nil
|
|
}
|
|
|
|
// corsMiddleware returns a Gin middleware handler that adds CORS headers
|
|
// to every response, allowing cross-origin requests.
|
|
//
|
|
// Returns:
|
|
// - gin.HandlerFunc: The CORS middleware handler
|
|
func corsMiddleware() gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
c.Header("Access-Control-Allow-Origin", "*")
|
|
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
|
c.Header("Access-Control-Allow-Headers", "*")
|
|
|
|
if c.Request.Method == "OPTIONS" {
|
|
c.AbortWithStatus(http.StatusNoContent)
|
|
return
|
|
}
|
|
|
|
c.Next()
|
|
}
|
|
}
|
|
|
|
func (s *Server) applyAccessConfig(oldCfg, newCfg *config.Config) {
|
|
if s == nil || s.accessManager == nil || newCfg == nil {
|
|
return
|
|
}
|
|
if _, err := access.ApplyAccessProviders(s.accessManager, oldCfg, newCfg); err != nil {
|
|
return
|
|
}
|
|
}
|
|
|
|
// UpdateClients updates the server's client list and configuration.
|
|
// This method is called when the configuration or authentication tokens change.
|
|
//
|
|
// Parameters:
|
|
// - clients: The new slice of AI service clients
|
|
// - cfg: The new application configuration
|
|
func (s *Server) UpdateClients(cfg *config.Config) {
|
|
// Reconstruct old config from YAML snapshot to avoid reference sharing issues
|
|
var oldCfg *config.Config
|
|
if len(s.oldConfigYaml) > 0 {
|
|
_ = yaml.Unmarshal(s.oldConfigYaml, &oldCfg)
|
|
}
|
|
|
|
// Update request logger enabled state if it has changed
|
|
previousRequestLog := false
|
|
if oldCfg != nil {
|
|
previousRequestLog = oldCfg.RequestLog
|
|
}
|
|
if s.requestLogger != nil && (oldCfg == nil || previousRequestLog != cfg.RequestLog) {
|
|
if s.loggerToggle != nil {
|
|
s.loggerToggle(cfg.RequestLog)
|
|
} else if toggler, ok := s.requestLogger.(interface{ SetEnabled(bool) }); ok {
|
|
toggler.SetEnabled(cfg.RequestLog)
|
|
}
|
|
if oldCfg != nil {
|
|
log.Debugf("request logging updated from %t to %t", previousRequestLog, cfg.RequestLog)
|
|
} else {
|
|
log.Debugf("request logging toggled to %t", cfg.RequestLog)
|
|
}
|
|
}
|
|
|
|
if oldCfg != nil && oldCfg.LoggingToFile != cfg.LoggingToFile {
|
|
if err := logging.ConfigureLogOutput(cfg.LoggingToFile); err != nil {
|
|
log.Errorf("failed to reconfigure log output: %v", err)
|
|
} else {
|
|
log.Debugf("logging_to_file updated from %t to %t", oldCfg.LoggingToFile, cfg.LoggingToFile)
|
|
}
|
|
}
|
|
|
|
if oldCfg == nil || oldCfg.UsageStatisticsEnabled != cfg.UsageStatisticsEnabled {
|
|
usage.SetStatisticsEnabled(cfg.UsageStatisticsEnabled)
|
|
if oldCfg != nil {
|
|
log.Debugf("usage_statistics_enabled updated from %t to %t", oldCfg.UsageStatisticsEnabled, cfg.UsageStatisticsEnabled)
|
|
} else {
|
|
log.Debugf("usage_statistics_enabled toggled to %t", cfg.UsageStatisticsEnabled)
|
|
}
|
|
}
|
|
|
|
if oldCfg == nil || oldCfg.DisableCooling != cfg.DisableCooling {
|
|
auth.SetQuotaCooldownDisabled(cfg.DisableCooling)
|
|
if oldCfg != nil {
|
|
log.Debugf("disable_cooling updated from %t to %t", oldCfg.DisableCooling, cfg.DisableCooling)
|
|
} else {
|
|
log.Debugf("disable_cooling toggled to %t", cfg.DisableCooling)
|
|
}
|
|
}
|
|
|
|
// Update log level dynamically when debug flag changes
|
|
if oldCfg == nil || oldCfg.Debug != cfg.Debug {
|
|
util.SetLogLevel(cfg)
|
|
if oldCfg != nil {
|
|
log.Debugf("debug mode updated from %t to %t", oldCfg.Debug, cfg.Debug)
|
|
} else {
|
|
log.Debugf("debug mode toggled to %t", cfg.Debug)
|
|
}
|
|
}
|
|
|
|
prevSecretEmpty := true
|
|
if oldCfg != nil {
|
|
prevSecretEmpty = oldCfg.RemoteManagement.SecretKey == ""
|
|
}
|
|
newSecretEmpty := cfg.RemoteManagement.SecretKey == ""
|
|
if s.envManagementSecret {
|
|
s.registerManagementRoutes()
|
|
if s.managementRoutesEnabled.CompareAndSwap(false, true) {
|
|
log.Info("management routes enabled via MANAGEMENT_PASSWORD")
|
|
} else {
|
|
s.managementRoutesEnabled.Store(true)
|
|
}
|
|
} else {
|
|
switch {
|
|
case prevSecretEmpty && !newSecretEmpty:
|
|
s.registerManagementRoutes()
|
|
if s.managementRoutesEnabled.CompareAndSwap(false, true) {
|
|
log.Info("management routes enabled after secret key update")
|
|
} else {
|
|
s.managementRoutesEnabled.Store(true)
|
|
}
|
|
case !prevSecretEmpty && newSecretEmpty:
|
|
if s.managementRoutesEnabled.CompareAndSwap(true, false) {
|
|
log.Info("management routes disabled after secret key removal")
|
|
} else {
|
|
s.managementRoutesEnabled.Store(false)
|
|
}
|
|
default:
|
|
s.managementRoutesEnabled.Store(!newSecretEmpty)
|
|
}
|
|
}
|
|
|
|
s.applyAccessConfig(oldCfg, cfg)
|
|
s.cfg = cfg
|
|
managementasset.SetCurrentConfig(cfg)
|
|
// Save YAML snapshot for next comparison
|
|
s.oldConfigYaml, _ = yaml.Marshal(cfg)
|
|
s.handlers.UpdateClients(&cfg.SDKConfig)
|
|
|
|
if !cfg.RemoteManagement.DisableControlPanel {
|
|
staticDir := managementasset.StaticDir(s.currentPath)
|
|
go managementasset.EnsureLatestManagementHTML(context.Background(), staticDir, cfg.ProxyURL)
|
|
}
|
|
if s.mgmt != nil {
|
|
s.mgmt.SetConfig(cfg)
|
|
s.mgmt.SetAuthManager(s.handlers.AuthManager)
|
|
}
|
|
|
|
// Count client sources from configuration and auth directory
|
|
authFiles := util.CountAuthFiles(cfg.AuthDir)
|
|
glAPIKeyCount := len(cfg.GlAPIKey)
|
|
claudeAPIKeyCount := len(cfg.ClaudeKey)
|
|
codexAPIKeyCount := len(cfg.CodexKey)
|
|
openAICompatCount := 0
|
|
for i := range cfg.OpenAICompatibility {
|
|
entry := cfg.OpenAICompatibility[i]
|
|
if len(entry.APIKeyEntries) > 0 {
|
|
openAICompatCount += len(entry.APIKeyEntries)
|
|
continue
|
|
}
|
|
openAICompatCount += len(entry.APIKeys)
|
|
}
|
|
|
|
total := authFiles + glAPIKeyCount + claudeAPIKeyCount + codexAPIKeyCount + openAICompatCount
|
|
fmt.Printf("server clients and configuration updated: %d clients (%d auth files + %d GL API keys + %d Claude API keys + %d Codex keys + %d OpenAI-compat)\n",
|
|
total,
|
|
authFiles,
|
|
glAPIKeyCount,
|
|
claudeAPIKeyCount,
|
|
codexAPIKeyCount,
|
|
openAICompatCount,
|
|
)
|
|
}
|
|
|
|
// (management handlers moved to internal/api/handlers/management)
|
|
|
|
// AuthMiddleware returns a Gin middleware handler that authenticates requests
|
|
// using the configured authentication providers. When no providers are available,
|
|
// it allows all requests (legacy behaviour).
|
|
func AuthMiddleware(manager *sdkaccess.Manager) gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
if manager == nil {
|
|
c.Next()
|
|
return
|
|
}
|
|
|
|
result, err := manager.Authenticate(c.Request.Context(), c.Request)
|
|
if err == nil {
|
|
if result != nil {
|
|
c.Set("apiKey", result.Principal)
|
|
c.Set("accessProvider", result.Provider)
|
|
if len(result.Metadata) > 0 {
|
|
c.Set("accessMetadata", result.Metadata)
|
|
}
|
|
}
|
|
c.Next()
|
|
return
|
|
}
|
|
|
|
switch {
|
|
case errors.Is(err, sdkaccess.ErrNoCredentials):
|
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Missing API key"})
|
|
case errors.Is(err, sdkaccess.ErrInvalidCredential):
|
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Invalid API key"})
|
|
default:
|
|
log.Errorf("authentication middleware error: %v", err)
|
|
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "Authentication service error"})
|
|
}
|
|
}
|
|
}
|
|
|
|
// legacy clientsToSlice removed; handlers no longer consume legacy client slices
|