Compare commits

...

7 Commits

Author SHA1 Message Date
Luis Pater
7353bc0b2b Fix bug: #38 about lobechat cors policy
Some checks failed
docker-image / docker (push) Has been cancelled
goreleaser / goreleaser (push) Has been cancelled
Relax CORS policy by allowing all headers in API responses
2025-09-08 23:36:43 +08:00
Luis Pater
99c9f3069c Fixed bug #38
Some checks failed
docker-image / docker (push) Has been cancelled
goreleaser / goreleaser (push) Has been cancelled
Add support for API key indexing in OpenAI compatibility clients

- Updated `NewOpenAICompatibilityClient` to accept `apiKeyIndex` for managing multiple API keys.
- Modified client instantiation loops to initialize one client per API key.
- Adjusted client ID format to include `apiKeyIndex` for unique identification.
- Removed API key rotation logic within `GetCurrentAPIKey`.
- Updated `.gitignore` to include `AGENTS.md`.
2025-09-08 22:36:44 +08:00
Luis Pater
f9f2333997 Fix model name update during quota check to avoid incorrect logging
Some checks failed
docker-image / docker (push) Has been cancelled
goreleaser / goreleaser (push) Has been cancelled
2025-09-08 22:17:21 +08:00
Luis Pater
179b8aa88f Merge pull request #36 from luispater/ssh-tunnel
Add SSH tunnel guidance for login fallback
2025-09-08 09:16:52 +08:00
hkfires
040d66f0bb Add SSH tunnel guidance for login fallback 2025-09-08 09:01:15 +08:00
Luis Pater
c875088be2 Add dynamic log level adjustment and "type" field to auth files response
Some checks failed
docker-image / docker (push) Has been cancelled
goreleaser / goreleaser (push) Has been cancelled
- Introduced `SetLogLevel` utility function for unified log level management.
- Updated dynamic log level handling across server and watcher components.
- Extended auth files response by extracting and including the `type` field from file content.
- Updated management API documentation with the new `type` field in auth files response.
2025-09-08 01:09:39 +08:00
Luis Pater
46fa32f087 Update log level in OpenURL function from Debug to Info 2025-09-07 21:28:10 +08:00
17 changed files with 224 additions and 34 deletions

1
.gitignore vendored
View File

@@ -3,3 +3,4 @@ docs/*
logs/*
auths/*
!auths/.gitkeep
AGENTS.md

View File

@@ -466,7 +466,7 @@ Manage JSON token files under `auth-dir`: list, download, upload, delete.
```
- Response:
```json
{ "files": [ { "name": "acc1.json", "size": 1234, "modtime": "2025-08-30T12:34:56Z" } ] }
{ "files": [ { "name": "acc1.json", "size": 1234, "modtime": "2025-08-30T12:34:56Z", "type": "google" } ] }
```
- GET `/auth-files/download?name=<file.json>` — Download a single file

View File

@@ -466,7 +466,7 @@
```
- 响应:
```json
{ "files": [ { "name": "acc1.json", "size": 1234, "modtime": "2025-08-30T12:34:56Z" } ] }
{ "files": [ { "name": "acc1.json", "size": 1234, "modtime": "2025-08-30T12:34:56Z", "type": "google" } ] }
```
- GET `/auth-files/download?name=<file.json>` — 下载单个文件

View File

@@ -14,6 +14,7 @@ import (
"github.com/luispater/CLIProxyAPI/internal/cmd"
"github.com/luispater/CLIProxyAPI/internal/config"
_ "github.com/luispater/CLIProxyAPI/internal/translator"
"github.com/luispater/CLIProxyAPI/internal/util"
log "github.com/sirupsen/logrus"
)
@@ -112,11 +113,7 @@ func main() {
}
// Set the log level based on the configuration.
if cfg.Debug {
log.SetLevel(log.DebugLevel)
} else {
log.SetLevel(log.InfoLevel)
}
util.SetLogLevel(cfg)
// Expand the tilde (~) in the auth directory path to the user's home directory.
if strings.HasPrefix(cfg.AuthDir, "~") {

View File

@@ -8,6 +8,7 @@ import (
"strings"
"github.com/gin-gonic/gin"
"github.com/tidwall/gjson"
)
// List auth files
@@ -27,7 +28,16 @@ func (h *Handler) ListAuthFiles(c *gin.Context) {
continue
}
if info, errInfo := e.Info(); errInfo == nil {
files = append(files, gin.H{"name": name, "size": info.Size(), "modtime": info.ModTime()})
fileData := gin.H{"name": name, "size": info.Size(), "modtime": info.ModTime()}
// Read file to get type field
full := filepath.Join(h.cfg.AuthDir, name)
if data, errRead := os.ReadFile(full); errRead == nil {
typeValue := gjson.GetBytes(data, "type").String()
fileData["type"] = typeValue
}
files = append(files, fileData)
}
}
c.JSON(200, gin.H{"files": files})

View File

@@ -22,6 +22,7 @@ import (
"github.com/luispater/CLIProxyAPI/internal/config"
"github.com/luispater/CLIProxyAPI/internal/interfaces"
"github.com/luispater/CLIProxyAPI/internal/logging"
"github.com/luispater/CLIProxyAPI/internal/util"
log "github.com/sirupsen/logrus"
)
@@ -278,7 +279,7 @@ 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", "Origin, Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization")
c.Header("Access-Control-Allow-Headers", "*")
if c.Request.Method == "OPTIONS" {
c.AbortWithStatus(http.StatusNoContent)
@@ -305,11 +306,7 @@ func (s *Server) UpdateClients(clients map[string]interfaces.Client, cfg *config
// Update log level dynamically when debug flag changes
if s.cfg.Debug != cfg.Debug {
if cfg.Debug {
log.SetLevel(log.DebugLevel)
} else {
log.SetLevel(log.InfoLevel)
}
util.SetLogLevel(cfg)
log.Debugf("debug mode updated from %t to %t", s.cfg.Debug, cfg.Debug)
}

View File

@@ -18,6 +18,7 @@ import (
"github.com/luispater/CLIProxyAPI/internal/auth/codex"
"github.com/luispater/CLIProxyAPI/internal/browser"
"github.com/luispater/CLIProxyAPI/internal/config"
"github.com/luispater/CLIProxyAPI/internal/util"
log "github.com/sirupsen/logrus"
"github.com/tidwall/gjson"
"golang.org/x/net/proxy"
@@ -250,11 +251,13 @@ func (g *GeminiAuth) getTokenFromWeb(ctx context.Context, config *oauth2.Config,
// Check if browser is available
if !browser.IsAvailable() {
log.Warn("No browser available on this system")
util.PrintSSHTunnelInstructions(8085)
log.Infof("Please manually open this URL in your browser:\n\n%s\n", authURL)
} else {
if err := browser.OpenURL(authURL); err != nil {
authErr := codex.NewAuthenticationError(codex.ErrBrowserOpenFailed, err)
log.Warn(codex.GetUserFriendlyMessage(authErr))
util.PrintSSHTunnelInstructions(8085)
log.Infof("Please manually open this URL in your browser:\n\n%s\n", authURL)
// Log platform info for debugging
@@ -265,6 +268,7 @@ func (g *GeminiAuth) getTokenFromWeb(ctx context.Context, config *oauth2.Config,
}
}
} else {
util.PrintSSHTunnelInstructions(8085)
log.Infof("Please open this URL in your browser:\n\n%s\n", authURL)
}

View File

@@ -21,7 +21,7 @@ import (
// Returns:
// - An error if the URL cannot be opened, otherwise nil.
func OpenURL(url string) error {
log.Debugf("Attempting to open URL in browser: %s", url)
log.Infof("Attempting to open URL in browser: %s", url)
// Try using the open-golang library first
err := open.Run(url)

View File

@@ -417,6 +417,7 @@ func (c *GeminiCLIClient) SendRawTokenCount(ctx context.Context, modelName strin
if newModelName != "" {
log.Debugf("Model %s is quota exceeded. Switch to preview model %s", modelName, newModelName)
rawJSON, _ = sjson.SetBytes(rawJSON, "model", newModelName)
modelName = newModelName
continue
}
}

View File

@@ -44,7 +44,7 @@ type OpenAICompatibilityClient struct {
// Returns:
// - *OpenAICompatibilityClient: A new OpenAI compatibility client instance.
// - error: An error if the client creation fails.
func NewOpenAICompatibilityClient(cfg *config.Config, compatConfig *config.OpenAICompatibility) (*OpenAICompatibilityClient, error) {
func NewOpenAICompatibilityClient(cfg *config.Config, compatConfig *config.OpenAICompatibility, apiKeyIndex int) (*OpenAICompatibilityClient, error) {
if compatConfig == nil {
return nil, fmt.Errorf("compatibility configuration is required")
}
@@ -53,10 +53,14 @@ func NewOpenAICompatibilityClient(cfg *config.Config, compatConfig *config.OpenA
return nil, fmt.Errorf("at least one API key is required for OpenAI compatibility provider: %s", compatConfig.Name)
}
if len(compatConfig.APIKeys) <= apiKeyIndex {
return nil, fmt.Errorf("invalid API key index for OpenAI compatibility provider: %s", compatConfig.Name)
}
httpClient := util.SetProxy(cfg, &http.Client{})
// Generate unique client ID
clientID := fmt.Sprintf("openai-compatibility-%s-%d", compatConfig.Name, time.Now().UnixNano())
clientID := fmt.Sprintf("openai-compatibility-%s-%d-%d", compatConfig.Name, apiKeyIndex, time.Now().UnixNano())
client := &OpenAICompatibilityClient{
ClientBase: ClientBase{
@@ -66,7 +70,7 @@ func NewOpenAICompatibilityClient(cfg *config.Config, compatConfig *config.OpenA
modelQuotaExceeded: make(map[string]*time.Time),
},
compatConfig: compatConfig,
currentAPIKeyIndex: 0,
currentAPIKeyIndex: apiKeyIndex,
}
// Initialize model registry
@@ -134,8 +138,6 @@ func (c *OpenAICompatibilityClient) GetCurrentAPIKey() string {
}
key := c.compatConfig.APIKeys[c.currentAPIKeyIndex]
// Rotate to next key for load balancing
c.currentAPIKeyIndex = (c.currentAPIKeyIndex + 1) % len(c.compatConfig.APIKeys)
return key
}

View File

@@ -15,6 +15,7 @@ import (
"github.com/luispater/CLIProxyAPI/internal/browser"
"github.com/luispater/CLIProxyAPI/internal/client"
"github.com/luispater/CLIProxyAPI/internal/config"
"github.com/luispater/CLIProxyAPI/internal/util"
log "github.com/sirupsen/logrus"
)
@@ -86,11 +87,13 @@ func DoClaudeLogin(cfg *config.Config, options *LoginOptions) {
// Check if browser is available
if !browser.IsAvailable() {
log.Warn("No browser available on this system")
util.PrintSSHTunnelInstructions(54545)
log.Infof("Please manually open this URL in your browser:\n\n%s\n", authURL)
} else {
if err = browser.OpenURL(authURL); err != nil {
authErr := claude.NewAuthenticationError(claude.ErrBrowserOpenFailed, err)
log.Warn(claude.GetUserFriendlyMessage(authErr))
util.PrintSSHTunnelInstructions(54545)
log.Infof("Please manually open this URL in your browser:\n\n%s\n", authURL)
// Log platform info for debugging
@@ -101,6 +104,7 @@ func DoClaudeLogin(cfg *config.Config, options *LoginOptions) {
}
}
} else {
util.PrintSSHTunnelInstructions(54545)
log.Infof("Please open this URL in your browser:\n\n%s\n", authURL)
}

View File

@@ -17,6 +17,7 @@ import (
"github.com/luispater/CLIProxyAPI/internal/browser"
"github.com/luispater/CLIProxyAPI/internal/client"
"github.com/luispater/CLIProxyAPI/internal/config"
"github.com/luispater/CLIProxyAPI/internal/util"
log "github.com/sirupsen/logrus"
)
@@ -94,11 +95,13 @@ func DoCodexLogin(cfg *config.Config, options *LoginOptions) {
// Check if browser is available
if !browser.IsAvailable() {
log.Warn("No browser available on this system")
util.PrintSSHTunnelInstructions(1455)
log.Infof("Please manually open this URL in your browser:\n\n%s\n", authURL)
} else {
if err = browser.OpenURL(authURL); err != nil {
authErr := codex.NewAuthenticationError(codex.ErrBrowserOpenFailed, err)
log.Warn(codex.GetUserFriendlyMessage(authErr))
util.PrintSSHTunnelInstructions(1455)
log.Infof("Please manually open this URL in your browser:\n\n%s\n", authURL)
// Log platform info for debugging
@@ -109,6 +112,7 @@ func DoCodexLogin(cfg *config.Config, options *LoginOptions) {
}
}
} else {
util.PrintSSHTunnelInstructions(1455)
log.Infof("Please open this URL in your browser:\n\n%s\n", authURL)
}

View File

@@ -335,14 +335,16 @@ func buildAPIKeyClients(cfg *config.Config) (map[string]interfaces.Client, int,
if len(cfg.OpenAICompatibility) > 0 {
for _, compatConfig := range cfg.OpenAICompatibility {
log.Debugf("Initializing OpenAI compatibility client for provider: %s", compatConfig.Name)
compatClient, errClient := client.NewOpenAICompatibilityClient(cfg, &compatConfig)
if errClient != nil {
log.Errorf("failed to create OpenAI compatibility client for %s: %v", compatConfig.Name, errClient)
continue
for i := 0; i < len(compatConfig.APIKeys); i++ {
log.Debugf("Initializing OpenAI compatibility client for provider: %s", compatConfig.Name)
compatClient, errClient := client.NewOpenAICompatibilityClient(cfg, &compatConfig, i)
if errClient != nil {
log.Errorf("failed to create OpenAI compatibility client for %s: %v", compatConfig.Name, errClient)
continue
}
apiKeyClients[compatClient.GetClientID()] = compatClient
openAICompatCount++
}
apiKeyClients[compatClient.GetClientID()] = compatClient
openAICompatCount++
}
}

View File

@@ -1,6 +1,6 @@
// Package util provides utility functions for the CLI Proxy API server.
// It includes helper functions for proxy configuration, HTTP client setup,
// and other common operations used across the application.
// log level management, and other common operations used across the application.
package util
import (

135
internal/util/ssh_helper.go Normal file
View File

@@ -0,0 +1,135 @@
// Package util provides helper functions for SSH tunnel instructions and network-related tasks.
// This includes detecting the appropriate IP address and printing commands
// to help users connect to the local server from a remote machine.
package util
import (
"context"
"fmt"
"io"
"net"
"net/http"
"strings"
"time"
log "github.com/sirupsen/logrus"
)
var ipServices = []string{
"https://api.ipify.org",
"https://ifconfig.me/ip",
"https://icanhazip.com",
"https://ipinfo.io/ip",
}
// getPublicIP attempts to retrieve the public IP address from a list of external services.
// It iterates through the ipServices and returns the first successful response.
//
// Returns:
// - string: The public IP address as a string
// - error: An error if all services fail, nil otherwise
func getPublicIP() (string, error) {
for _, service := range ipServices {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, "GET", service, nil)
if err != nil {
log.Debugf("Failed to create request to %s: %v", service, err)
continue
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
log.Debugf("Failed to get public IP from %s: %v", service, err)
continue
}
defer func() {
if closeErr := resp.Body.Close(); closeErr != nil {
log.Warnf("Failed to close response body from %s: %v", service, closeErr)
}
}()
if resp.StatusCode != http.StatusOK {
log.Debugf("bad status code from %s: %d", service, resp.StatusCode)
continue
}
ip, err := io.ReadAll(resp.Body)
if err != nil {
log.Debugf("Failed to read response body from %s: %v", service, err)
continue
}
return strings.TrimSpace(string(ip)), nil
}
return "", fmt.Errorf("all IP services failed")
}
// getOutboundIP retrieves the preferred outbound IP address of this machine.
// It uses a UDP connection to a public DNS server to determine the local IP
// address that would be used for outbound traffic.
//
// Returns:
// - string: The outbound IP address as a string
// - error: An error if the IP address cannot be determined, nil otherwise
func getOutboundIP() (string, error) {
conn, err := net.Dial("udp", "8.8.8.8:80")
if err != nil {
return "", err
}
defer func() {
if closeErr := conn.Close(); closeErr != nil {
log.Warnf("Failed to close UDP connection: %v", closeErr)
}
}()
localAddr, ok := conn.LocalAddr().(*net.UDPAddr)
if !ok {
return "", fmt.Errorf("could not assert UDP address type")
}
return localAddr.IP.String(), nil
}
// GetIPAddress attempts to find the best-available IP address.
// It first tries to get the public IP address, and if that fails,
// it falls back to getting the local outbound IP address.
//
// Returns:
// - string: The determined IP address (preferring public IPv4)
func GetIPAddress() string {
publicIP, err := getPublicIP()
if err == nil {
log.Debugf("Public IP detected: %s", publicIP)
return publicIP
}
log.Warnf("Failed to get public IP, falling back to outbound IP: %v", err)
outboundIP, err := getOutboundIP()
if err == nil {
log.Debugf("Outbound IP detected: %s", outboundIP)
return outboundIP
}
log.Errorf("Failed to get any IP address: %v", err)
return "127.0.0.1" // Fallback
}
// PrintSSHTunnelInstructions detects the IP address and prints SSH tunnel instructions
// for the user to connect to the local OAuth callback server from a remote machine.
//
// Parameters:
// - port: The local port number for the SSH tunnel
func PrintSSHTunnelInstructions(port int) {
ipAddress := GetIPAddress()
border := "================================================================================"
log.Infof("To authenticate from a remote machine, an SSH tunnel may be required.")
fmt.Println(border)
fmt.Println(" Run one of the following commands on your local machine (NOT the server):")
fmt.Println()
fmt.Printf(" # Standard SSH command (assumes SSH port 22):\n")
fmt.Printf(" ssh -L %d:127.0.0.1:%d root@%s -p 22\n", port, port, ipAddress)
fmt.Println()
fmt.Printf(" # If using an SSH key (assumes SSH port 22):\n")
fmt.Printf(" ssh -i <path_to_your_key> -L %d:127.0.0.1:%d root@%s -p 22\n", port, port, ipAddress)
fmt.Println()
fmt.Println(" NOTE: If your server's SSH port is not 22, please modify the '-p 22' part accordingly.")
fmt.Println(border)
}

23
internal/util/util.go Normal file
View File

@@ -0,0 +1,23 @@
package util
import (
"github.com/luispater/CLIProxyAPI/internal/config"
log "github.com/sirupsen/logrus"
)
// SetLogLevel configures the logrus log level based on the configuration.
// It sets the log level to DebugLevel if debug mode is enabled, otherwise to InfoLevel.
func SetLogLevel(cfg *config.Config) {
currentLevel := log.GetLevel()
var newLevel log.Level
if cfg.Debug {
newLevel = log.DebugLevel
} else {
newLevel = log.InfoLevel
}
if currentLevel != newLevel {
log.SetLevel(newLevel)
log.Infof("log level changed from %s to %s (debug=%t)", currentLevel, newLevel, cfg.Debug)
}
}

View File

@@ -168,6 +168,14 @@ func (w *Watcher) reloadConfig() {
w.config = newConfig
w.clientsMutex.Unlock()
// Always apply the current log level based on the latest config.
// This ensures logrus reflects the desired level even if change detection misses.
util.SetLogLevel(newConfig)
// Additional debug for visibility when the flag actually changes.
if oldConfig != nil && oldConfig.Debug != newConfig.Debug {
log.Debugf("log level updated - debug mode changed from %t to %t", oldConfig.Debug, newConfig.Debug)
}
// Log configuration changes in debug mode
if oldConfig != nil {
log.Debugf("config changes detected:")
@@ -541,13 +549,15 @@ func buildAPIKeyClients(cfg *config.Config) (map[string]interfaces.Client, int,
}
if len(cfg.OpenAICompatibility) > 0 {
for _, compatConfig := range cfg.OpenAICompatibility {
compatClient, errClient := client.NewOpenAICompatibilityClient(cfg, &compatConfig)
if errClient != nil {
log.Errorf("failed to create OpenAI-compatibility client for %s: %v", compatConfig.Name, errClient)
continue
for i := 0; i < len(compatConfig.APIKeys); i++ {
compatClient, errClient := client.NewOpenAICompatibilityClient(cfg, &compatConfig, i)
if errClient != nil {
log.Errorf("failed to create OpenAI-compatibility client for %s: %v", compatConfig.Name, errClient)
continue
}
apiKeyClients[compatClient.GetClientID()] = compatClient
openAICompatCount++
}
apiKeyClients[compatClient.GetClientID()] = compatClient
openAICompatCount++
}
}
return apiKeyClients, glAPIKeyCount, claudeAPIKeyCount, codexAPIKeyCount, openAICompatCount