diff --git a/.gitignore b/.gitignore index a5394700..a691dcfd 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ -config.yaml \ No newline at end of file +config.yaml +docs/ \ No newline at end of file diff --git a/cmd/server/main.go b/cmd/server/main.go index 6e40c4f0..df0d8350 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -63,14 +63,17 @@ func main() { var wd string // Load configuration from the specified path or the default path. + var configFilePath string if configPath != "" { + configFilePath = configPath cfg, err = config.LoadConfig(configPath) } else { wd, err = os.Getwd() if err != nil { log.Fatalf("failed to get working directory: %v", err) } - cfg, err = config.LoadConfig(path.Join(wd, "config.yaml")) + configFilePath = path.Join(wd, "config.yaml") + cfg, err = config.LoadConfig(configFilePath) } if err != nil { log.Fatalf("failed to load config: %v", err) @@ -102,6 +105,6 @@ func main() { if login { cmd.DoLogin(cfg, projectID) } else { - cmd.StartService(cfg) + cmd.StartService(cfg, configFilePath) } } diff --git a/go.mod b/go.mod index 85b446fc..6c44518c 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 github.com/tidwall/gjson v1.18.0 github.com/tidwall/sjson v1.2.5 + golang.org/x/net v0.37.1-0.20250305215238-2914f4677317 golang.org/x/oauth2 v0.30.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -18,6 +19,7 @@ require ( github.com/bytedance/sonic/loader v0.1.1 // indirect github.com/cloudwego/base64x v0.1.4 // indirect github.com/cloudwego/iasm v0.2.0 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/gabriel-vasile/mimetype v1.4.3 // indirect github.com/gin-contrib/sse v0.1.0 // indirect github.com/go-playground/locales v0.14.1 // indirect @@ -37,7 +39,6 @@ require ( github.com/ugorji/go/codec v1.2.12 // indirect golang.org/x/arch v0.8.0 // indirect golang.org/x/crypto v0.36.0 // indirect - golang.org/x/net v0.37.1-0.20250305215238-2914f4677317 // indirect golang.org/x/sys v0.31.0 // indirect golang.org/x/text v0.23.0 // indirect google.golang.org/protobuf v1.34.1 // indirect diff --git a/go.sum b/go.sum index c52fcff4..4703cffb 100644 --- a/go.sum +++ b/go.sum @@ -11,6 +11,8 @@ github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= diff --git a/internal/api/handlers.go b/internal/api/handlers.go index d05ffe54..23bf833f 100644 --- a/internal/api/handlers.go +++ b/internal/api/handlers.go @@ -36,6 +36,12 @@ func NewAPIHandlers(cliClients []*client.Client, cfg *config.Config) *APIHandler } } +// UpdateClients updates the handlers' client list and configuration +func (h *APIHandlers) UpdateClients(clients []*client.Client, cfg *config.Config) { + h.cliClients = clients + h.cfg = cfg +} + // Models handles the /v1/models endpoint. // It returns a hardcoded list of available AI models. func (h *APIHandlers) Models(c *gin.Context) { diff --git a/internal/api/server.go b/internal/api/server.go index 5774d6aa..b3b6f505 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -139,6 +139,13 @@ func corsMiddleware() gin.HandlerFunc { } } +// UpdateClients updates the server's client list and configuration +func (s *Server) UpdateClients(clients []*client.Client, cfg *config.Config) { + s.cfg = cfg + s.handlers.UpdateClients(clients, cfg) + log.Infof("server clients and configuration updated: %d clients", len(clients)) +} + // AuthMiddleware returns a Gin middleware handler that authenticates requests // using API keys. If no API keys are configured, it allows all requests. func AuthMiddleware(cfg *config.Config) gin.HandlerFunc { diff --git a/internal/cmd/run.go b/internal/cmd/run.go index a2ac7511..37b0118c 100644 --- a/internal/cmd/run.go +++ b/internal/cmd/run.go @@ -8,6 +8,7 @@ import ( "github.com/luispater/CLIProxyAPI/internal/client" "github.com/luispater/CLIProxyAPI/internal/config" "github.com/luispater/CLIProxyAPI/internal/util" + "github.com/luispater/CLIProxyAPI/internal/watcher" log "github.com/sirupsen/logrus" "io/fs" "net/http" @@ -22,7 +23,7 @@ import ( // StartService initializes and starts the main API proxy service. // It loads all available authentication tokens, creates a pool of clients, // starts the API server, and handles graceful shutdown signals. -func StartService(cfg *config.Config) { +func StartService(cfg *config.Config, configPath string) { // Create a pool of API clients, one for each token file found. cliClients := make([]*client.Client, 0) err := filepath.Walk(cfg.AuthDir, func(path string, info fs.FileInfo, err error) error { @@ -82,10 +83,46 @@ func StartService(cfg *config.Config) { // Create and start the API server with the pool of clients. apiServer := api.NewServer(cfg, cliClients) log.Infof("Starting API server on port %d", cfg.Port) - if err = apiServer.Start(); err != nil { - log.Fatalf("API server failed to start: %v", err) + + // Start the API server in a goroutine so it doesn't block the main thread + go func() { + if err = apiServer.Start(); err != nil { + log.Fatalf("API server failed to start: %v", err) + } + }() + + // Give the server a moment to start up + time.Sleep(100 * time.Millisecond) + log.Info("API server started successfully") + + // Setup file watcher for config and auth directory changes + fileWatcher, errNewWatcher := watcher.NewWatcher(configPath, cfg.AuthDir, func(newClients []*client.Client, newCfg *config.Config) { + // Update the API server with new clients and configuration + apiServer.UpdateClients(newClients, newCfg) + }) + if errNewWatcher != nil { + log.Fatalf("failed to create file watcher: %v", errNewWatcher) } + // Set initial state for the watcher + fileWatcher.SetConfig(cfg) + fileWatcher.SetClients(cliClients) + + // Start the file watcher + watcherCtx, watcherCancel := context.WithCancel(context.Background()) + if errStartWatcher := fileWatcher.Start(watcherCtx); errStartWatcher != nil { + log.Fatalf("failed to start file watcher: %v", errStartWatcher) + } + log.Info("file watcher started for config and auth directory changes") + + defer func() { + watcherCancel() + errStopWatcher := fileWatcher.Stop() + if errStopWatcher != nil { + log.Errorf("error stopping file watcher: %v", errStopWatcher) + } + }() + // Set up a channel to listen for OS signals for graceful shutdown. sigChan := make(chan os.Signal, 1) signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) diff --git a/internal/watcher/watcher.go b/internal/watcher/watcher.go new file mode 100644 index 00000000..0efbaa5f --- /dev/null +++ b/internal/watcher/watcher.go @@ -0,0 +1,282 @@ +package watcher + +import ( + "context" + "encoding/json" + "github.com/fsnotify/fsnotify" + "github.com/luispater/CLIProxyAPI/internal/auth" + "github.com/luispater/CLIProxyAPI/internal/client" + "github.com/luispater/CLIProxyAPI/internal/config" + "github.com/luispater/CLIProxyAPI/internal/util" + log "github.com/sirupsen/logrus" + "io/fs" + "net/http" + "os" + "path/filepath" + "strings" + "sync" + "time" +) + +// Watcher manages file watching for configuration and authentication files +type Watcher struct { + configPath string + authDir string + config *config.Config + clients []*client.Client + clientsMutex sync.RWMutex + reloadCallback func([]*client.Client, *config.Config) + watcher *fsnotify.Watcher +} + +// NewWatcher creates a new file watcher instance +func NewWatcher(configPath, authDir string, reloadCallback func([]*client.Client, *config.Config)) (*Watcher, error) { + watcher, errNewWatcher := fsnotify.NewWatcher() + if errNewWatcher != nil { + return nil, errNewWatcher + } + + return &Watcher{ + configPath: configPath, + authDir: authDir, + reloadCallback: reloadCallback, + watcher: watcher, + }, nil +} + +// Start begins watching the configuration file and authentication directory +func (w *Watcher) Start(ctx context.Context) error { + // Watch the config file + if errAddConfig := w.watcher.Add(w.configPath); errAddConfig != nil { + log.Errorf("failed to watch config file %s: %v", w.configPath, errAddConfig) + return errAddConfig + } + log.Debugf("watching config file: %s", w.configPath) + + // Watch the auth directory + if errAddAuthDir := w.watcher.Add(w.authDir); errAddAuthDir != nil { + log.Errorf("failed to watch auth directory %s: %v", w.authDir, errAddAuthDir) + return errAddAuthDir + } + log.Debugf("watching auth directory: %s", w.authDir) + + // Start the event processing goroutine + go w.processEvents(ctx) + + return nil +} + +// Stop stops the file watcher +func (w *Watcher) Stop() error { + return w.watcher.Close() +} + +// SetConfig updates the current configuration +func (w *Watcher) SetConfig(cfg *config.Config) { + w.clientsMutex.Lock() + defer w.clientsMutex.Unlock() + w.config = cfg +} + +// SetClients updates the current client list +func (w *Watcher) SetClients(clients []*client.Client) { + w.clientsMutex.Lock() + defer w.clientsMutex.Unlock() + w.clients = clients +} + +// processEvents handles file system events +func (w *Watcher) processEvents(ctx context.Context) { + for { + select { + case <-ctx.Done(): + return + case event, ok := <-w.watcher.Events: + if !ok { + return + } + w.handleEvent(event) + case errWatch, ok := <-w.watcher.Errors: + if !ok { + return + } + log.Errorf("file watcher error: %v", errWatch) + } + } +} + +// handleEvent processes individual file system events +func (w *Watcher) handleEvent(event fsnotify.Event) { + now := time.Now() + + log.Debugf("file system event detected: %s %s", event.Op.String(), event.Name) + + // Handle config file changes + if event.Name == w.configPath && (event.Op&fsnotify.Write == fsnotify.Write || event.Op&fsnotify.Create == fsnotify.Create) { + log.Infof("config file changed, reloading: %s", w.configPath) + log.Debugf("config file change details - operation: %s, timestamp: %s", event.Op.String(), now.Format("2006-01-02 15:04:05.000")) + w.reloadConfig() + return + } + + // Handle auth directory changes (only for .json files) + // Simplified: reload on any change to .json files in auth directory + if strings.HasPrefix(event.Name, w.authDir) && strings.HasSuffix(event.Name, ".json") { + log.Infof("auth file changed (%s): %s, reloading clients", event.Op.String(), filepath.Base(event.Name)) + log.Debugf("auth file change details - operation: %s, file: %s, timestamp: %s", + event.Op.String(), filepath.Base(event.Name), now.Format("2006-01-02 15:04:05.000")) + w.reloadClients() + } +} + +// reloadConfig reloads the configuration and triggers a full reload +func (w *Watcher) reloadConfig() { + log.Debugf("starting config reload from: %s", w.configPath) + + newConfig, errLoadConfig := config.LoadConfig(w.configPath) + if errLoadConfig != nil { + log.Errorf("failed to reload config: %v", errLoadConfig) + return + } + + w.clientsMutex.Lock() + oldConfig := w.config + w.config = newConfig + w.clientsMutex.Unlock() + + // Log configuration changes in debug mode + if oldConfig != nil { + log.Debugf("config changes detected:") + if oldConfig.Port != newConfig.Port { + log.Debugf(" port: %d -> %d", oldConfig.Port, newConfig.Port) + } + if oldConfig.AuthDir != newConfig.AuthDir { + log.Debugf(" auth-dir: %s -> %s", oldConfig.AuthDir, newConfig.AuthDir) + } + if oldConfig.Debug != newConfig.Debug { + log.Debugf(" debug: %t -> %t", oldConfig.Debug, newConfig.Debug) + } + if oldConfig.ProxyUrl != newConfig.ProxyUrl { + log.Debugf(" proxy-url: %s -> %s", oldConfig.ProxyUrl, newConfig.ProxyUrl) + } + if len(oldConfig.ApiKeys) != len(newConfig.ApiKeys) { + log.Debugf(" api-keys count: %d -> %d", len(oldConfig.ApiKeys), len(newConfig.ApiKeys)) + } + if len(oldConfig.GlAPIKey) != len(newConfig.GlAPIKey) { + log.Debugf(" generative-language-api-key count: %d -> %d", len(oldConfig.GlAPIKey), len(newConfig.GlAPIKey)) + } + } + + log.Infof("config successfully reloaded, triggering client reload") + // Reload clients with new config + w.reloadClients() +} + +// reloadClients reloads all authentication clients +func (w *Watcher) reloadClients() { + log.Debugf("starting client reload process") + + w.clientsMutex.RLock() + cfg := w.config + oldClientCount := len(w.clients) + w.clientsMutex.RUnlock() + + if cfg == nil { + log.Error("config is nil, cannot reload clients") + return + } + + log.Debugf("scanning auth directory: %s", cfg.AuthDir) + + // Create new client list + newClients := make([]*client.Client, 0) + authFileCount := 0 + successfulAuthCount := 0 + + // Load clients from auth directory + errWalk := filepath.Walk(cfg.AuthDir, func(path string, info fs.FileInfo, err error) error { + if err != nil { + log.Debugf("error accessing path %s: %v", path, err) + return err + } + + // Process only JSON files in the auth directory + if !info.IsDir() && strings.HasSuffix(info.Name(), ".json") { + authFileCount++ + log.Debugf("processing auth file %d: %s", authFileCount, filepath.Base(path)) + + f, errOpen := os.Open(path) + if errOpen != nil { + log.Errorf("failed to open token file %s: %v", path, errOpen) + return nil // Continue processing other files + } + defer func() { + errClose := f.Close() + if errClose != nil { + log.Errorf("failed to close token file %s: %v", path, errClose) + } + }() + + // Decode the token storage file + var ts auth.TokenStorage + if errDecode := json.NewDecoder(f).Decode(&ts); errDecode == nil { + // For each valid token, create an authenticated client + clientCtx := context.Background() + log.Debugf(" initializing authentication for token from %s...", filepath.Base(path)) + httpClient, errGetClient := auth.GetAuthenticatedClient(clientCtx, &ts, cfg) + if errGetClient != nil { + log.Errorf(" failed to get authenticated client for token %s: %v", path, errGetClient) + return nil // Continue processing other files + } + log.Debugf(" authentication successful for token from %s", filepath.Base(path)) + + // Add the new client to the pool + cliClient := client.NewClient(httpClient, &ts, cfg) + newClients = append(newClients, cliClient) + successfulAuthCount++ + } else { + log.Errorf(" failed to decode token file %s: %v", path, errDecode) + } + } + return nil + }) + if errWalk != nil { + log.Errorf("error walking auth directory: %v", errWalk) + return + } + + log.Debugf("auth directory scan complete - found %d .json files, %d successful authentications", authFileCount, successfulAuthCount) + + // Add clients for Generative Language API keys if configured + glApiKeyCount := 0 + if len(cfg.GlAPIKey) > 0 { + log.Debugf("processing %d Generative Language API keys", len(cfg.GlAPIKey)) + for i := 0; i < len(cfg.GlAPIKey); i++ { + httpClient, errSetProxy := util.SetProxy(cfg, &http.Client{}) + if errSetProxy != nil { + log.Errorf("set proxy failed for GL API key %d: %v", i+1, errSetProxy) + continue + } + + log.Debugf(" initializing with Generative Language API key %d...", i+1) + cliClient := client.NewClient(httpClient, nil, cfg, cfg.GlAPIKey[i]) + newClients = append(newClients, cliClient) + glApiKeyCount++ + } + log.Debugf("successfully initialized %d Generative Language API key clients", glApiKeyCount) + } + + // Update the client list + w.clientsMutex.Lock() + w.clients = newClients + w.clientsMutex.Unlock() + + log.Infof("client reload complete - old: %d clients, new: %d clients (%d auth files + %d GL API keys)", + oldClientCount, len(newClients), successfulAuthCount, glApiKeyCount) + + // Trigger the callback to update the server + if w.reloadCallback != nil { + log.Debugf("triggering server update callback") + w.reloadCallback(newClients, cfg) + } +}