Compare commits

..

13 Commits

Author SHA1 Message Date
Luis Pater
4375822cbb Resolve logsDir path relative to configuration directory in FileRequestLogger 2025-09-12 15:28:07 +08:00
Luis Pater
e0d13148ef Merge branch 'main' into dev 2025-09-12 13:12:02 +08:00
Luis Pater
bd68472d3c Merge pull request #41 from kaixxx/main
Codex CLI - setting 'store = false' to prevent the request being rejected by OpenAI
2025-09-12 13:10:22 +08:00
kaixxx
b3c534bae5 Build instructions reformatting 2025-09-12 01:16:54 +02:00
kaixxx
b7d6ae1b48 Windows build instructions 2025-09-12 01:12:51 +02:00
kaixxx
aacfcae382 Codex CLI - setting 'store = false'
store = true leads to:
BadRequestError("Error code: 400 - {'detail': 'Store must be set to false'}")
2025-09-12 00:59:49 +02:00
Luis Pater
1c92034191 Implement IP-based rate limiting and ban mechanism for management API
- Introduced a new `attemptInfo` structure to track failed login attempts per IP.
- Added logic to temporarily ban IPs exceeding the allowed number of failures.
- Enhanced middleware to reset failed attempt counters on successful authentication.
- Updated `Handler` to include a `failedAttempts` map with thread-safe access.
2025-09-12 03:00:37 +08:00
Luis Pater
ef8820e4e4 Default tokenType to an empty string instead of "gemini" in watcher.go and run.go. 2025-09-11 21:09:27 +08:00
Luis Pater
35daffdb2f Ensure auth directory existence before processing tokens in run.go 2025-09-11 18:03:42 +08:00
Luis Pater
0983119ae2 Remove API key truncation in Gemini client ID generation 2025-09-11 10:22:42 +08:00
Luis Pater
0371062e86 Normalize select to STRING type in Gemini OpenAI request outputs 2025-09-10 23:54:16 +08:00
Luis Pater
74bae32c83 Filter OpenAI models response to include only essential fields (id, object, created, owned_by). 2025-09-10 17:50:33 +08:00
Luis Pater
4e67cd4baf Resolve relative logsDir to executable directory in FileRequestLogger 2025-09-10 03:15:58 +08:00
11 changed files with 143 additions and 16 deletions

View File

@@ -47,9 +47,16 @@ The first Chinese provider has now been added: [Qwen Code](https://github.com/Qw
```
2. Build the application:
Linux, macOS:
```bash
go build -o cli-proxy-api ./cmd/server
```
Windows:
```bash
go build -o cli-proxy-api.exe ./cmd/server
```
## Usage

View File

@@ -7,22 +7,31 @@ import (
"net/http"
"strings"
"sync"
"time"
"github.com/gin-gonic/gin"
"github.com/luispater/CLIProxyAPI/internal/config"
"golang.org/x/crypto/bcrypt"
)
type attemptInfo struct {
count int
blockedUntil time.Time
}
// Handler aggregates config reference, persistence path and helpers.
type Handler struct {
cfg *config.Config
configFilePath string
mu sync.Mutex
attemptsMu sync.Mutex
failedAttempts map[string]*attemptInfo // keyed by client IP
}
// NewHandler creates a new management handler instance.
func NewHandler(cfg *config.Config, configFilePath string) *Handler {
return &Handler{cfg: cfg, configFilePath: configFilePath}
return &Handler{cfg: cfg, configFilePath: configFilePath, failedAttempts: make(map[string]*attemptInfo)}
}
// SetConfig updates the in-memory config reference when the server hot-reloads.
@@ -32,11 +41,32 @@ func (h *Handler) SetConfig(cfg *config.Config) { h.cfg = cfg }
// All requests (local and remote) require a valid management key.
// Additionally, remote access requires allow-remote-management=true.
func (h *Handler) Middleware() gin.HandlerFunc {
const maxFailures = 5
const banDuration = 30 * time.Minute
return func(c *gin.Context) {
clientIP := c.ClientIP()
// Remote access control: when not loopback, must be enabled
// For remote IPs, enforce allow-remote-management and ban checks
if !(clientIP == "127.0.0.1" || clientIP == "::1") {
// Check if IP is currently blocked
h.attemptsMu.Lock()
ai := h.failedAttempts[clientIP]
if ai != nil {
if !ai.blockedUntil.IsZero() {
if time.Now().Before(ai.blockedUntil) {
remaining := time.Until(ai.blockedUntil).Round(time.Second)
h.attemptsMu.Unlock()
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": fmt.Sprintf("IP banned due to too many failed attempts. Try again in %s", remaining)})
return
}
// Ban expired, reset state
ai.blockedUntil = time.Time{}
ai.count = 0
}
}
h.attemptsMu.Unlock()
allowRemote := h.cfg.RemoteManagement.AllowRemote
if !allowRemote {
allowRemote = true
@@ -67,15 +97,41 @@ func (h *Handler) Middleware() gin.HandlerFunc {
}
if !(clientIP == "127.0.0.1" || clientIP == "::1") {
// For remote IPs, enforce key and track failures
fail := func() {
h.attemptsMu.Lock()
ai := h.failedAttempts[clientIP]
if ai == nil {
ai = &attemptInfo{}
h.failedAttempts[clientIP] = ai
}
ai.count++
if ai.count >= maxFailures {
ai.blockedUntil = time.Now().Add(banDuration)
ai.count = 0
}
h.attemptsMu.Unlock()
}
if provided == "" {
fail()
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing management key"})
return
}
if err := bcrypt.CompareHashAndPassword([]byte(secret), []byte(provided)); err != nil {
fail()
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid management key"})
return
}
// Success: reset failed count for this IP
h.attemptsMu.Lock()
if ai := h.failedAttempts[clientIP]; ai != nil {
ai.count = 0
ai.blockedUntil = time.Time{}
}
h.attemptsMu.Unlock()
}
c.Next()

View File

@@ -60,9 +60,33 @@ func (h *OpenAIAPIHandler) Models() []map[string]any {
// It returns a list of available AI models with their capabilities
// and specifications in OpenAI-compatible format.
func (h *OpenAIAPIHandler) OpenAIModels(c *gin.Context) {
// Get all available models
allModels := h.Models()
// Filter to only include the 4 required fields: id, object, created, owned_by
filteredModels := make([]map[string]any, len(allModels))
for i, model := range allModels {
filteredModel := map[string]any{
"id": model["id"],
"object": model["object"],
}
// Add created field if it exists
if created, exists := model["created"]; exists {
filteredModel["created"] = created
}
// Add owned_by field if it exists
if ownedBy, exists := model["owned_by"]; exists {
filteredModel["owned_by"] = ownedBy
}
filteredModels[i] = filteredModel
}
c.JSON(http.StatusOK, gin.H{
"object": "list",
"data": h.Models(),
"data": filteredModels,
})
}

View File

@@ -10,6 +10,7 @@ import (
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"github.com/gin-gonic/gin"
@@ -75,7 +76,8 @@ func NewServer(cfg *config.Config, cliClients []interfaces.Client, configFilePat
engine.Use(gin.Recovery())
// Add request logging middleware (positioned after recovery, before auth)
requestLogger := logging.NewFileRequestLogger(cfg.RequestLog, "logs")
// Resolve logs directory relative to the configuration file directory.
requestLogger := logging.NewFileRequestLogger(cfg.RequestLog, "logs", filepath.Dir(configFilePath))
engine.Use(middleware.RequestLoggingMiddleware(requestLogger))
engine.Use(corsMiddleware())

View File

@@ -46,7 +46,7 @@ type GeminiClient struct {
// - *GeminiClient: A new Gemini client instance.
func NewGeminiClient(httpClient *http.Client, cfg *config.Config, glAPIKey string) *GeminiClient {
// Generate unique client ID
clientID := fmt.Sprintf("gemini-apikey-%s-%d", glAPIKey[:8], time.Now().UnixNano()) // Use first 8 chars of API key
clientID := fmt.Sprintf("gemini-apikey-%s-%d", glAPIKey, time.Now().UnixNano())
client := &GeminiClient{
ClientBase: ClientBase{

View File

@@ -51,6 +51,20 @@ func StartService(cfg *config.Config, configPath string) {
// Create a pool of API clients, one for each token file found.
cliClients := make(map[string]interfaces.Client)
successfulAuthCount := 0
// Ensure the auth directory exists before walking it.
if info, statErr := os.Stat(cfg.AuthDir); statErr != nil {
if os.IsNotExist(statErr) {
if mkErr := os.MkdirAll(cfg.AuthDir, 0755); mkErr != nil {
log.Fatalf("failed to create auth directory %s: %v", cfg.AuthDir, mkErr)
}
log.Infof("created missing auth directory: %s", cfg.AuthDir)
} else {
log.Fatalf("error checking auth directory %s: %v", cfg.AuthDir, statErr)
}
} else if !info.IsDir() {
log.Fatalf("auth path exists but is not a directory: %s", cfg.AuthDir)
}
err := filepath.Walk(cfg.AuthDir, func(path string, info fs.FileInfo, err error) error {
if err != nil {
return err
@@ -65,7 +79,7 @@ func StartService(cfg *config.Config, configPath string) {
}
// Determine token type from JSON data, defaulting to "gemini" if not specified.
tokenType := "gemini"
tokenType := ""
typeResult := gjson.GetBytes(data, "type")
if typeResult.Exists() {
tokenType = typeResult.String()

View File

@@ -98,11 +98,20 @@ type FileRequestLogger struct {
//
// Parameters:
// - enabled: Whether request logging should be enabled
// - logsDir: The directory where log files should be stored
// - logsDir: The directory where log files should be stored (can be relative)
// - configDir: The directory of the configuration file; when logsDir is
// relative, it will be resolved relative to this directory
//
// Returns:
// - *FileRequestLogger: A new file-based request logger instance
func NewFileRequestLogger(enabled bool, logsDir string) *FileRequestLogger {
func NewFileRequestLogger(enabled bool, logsDir string, configDir string) *FileRequestLogger {
// Resolve logsDir relative to the configuration file directory when it's not absolute.
if !filepath.IsAbs(logsDir) {
// If configDir is provided, resolve logsDir relative to it.
if configDir != "" {
logsDir = filepath.Join(configDir, logsDir)
}
}
return &FileRequestLogger{
enabled: enabled,
logsDir: logsDir,

View File

@@ -259,9 +259,6 @@ func ConvertOpenAIRequestToCodex(modelName string, inputRawJSON []byte, stream b
out, _ = sjson.Set(out, "text.verbosity", v.Value())
}
}
// The examples include store: true when response_format is provided
store = true
} else if text.Exists() {
// If only text.verbosity present (no response_format), map verbosity
if v := text.Get("verbosity"); v.Exists() {
@@ -306,10 +303,6 @@ func ConvertOpenAIRequestToCodex(modelName string, inputRawJSON []byte, stream b
out, _ = sjson.SetRaw(out, "tools.-1", item)
}
}
// The examples include store: true when tools and formatting are used; be conservative
if rf.Exists() {
store = true
}
}
out, _ = sjson.Set(out, "store", store)

View File

@@ -8,6 +8,7 @@ import (
"strings"
"github.com/luispater/CLIProxyAPI/internal/misc"
"github.com/luispater/CLIProxyAPI/internal/util"
log "github.com/sirupsen/logrus"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
@@ -230,6 +231,16 @@ func ConvertOpenAIRequestToGeminiCLI(modelName string, inputRawJSON []byte, _ bo
}
}
var pathsToType []string
root := gjson.ParseBytes(out)
util.Walk(root, "", "type", &pathsToType)
for _, p := range pathsToType {
typeResult := gjson.GetBytes(out, p)
if strings.ToLower(typeResult.String()) == "select" {
out, _ = sjson.SetBytes(out, p, "STRING")
}
}
return out
}

View File

@@ -8,6 +8,7 @@ import (
"strings"
"github.com/luispater/CLIProxyAPI/internal/misc"
"github.com/luispater/CLIProxyAPI/internal/util"
log "github.com/sirupsen/logrus"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
@@ -230,6 +231,16 @@ func ConvertOpenAIRequestToGemini(modelName string, inputRawJSON []byte, _ bool)
}
}
var pathsToType []string
root := gjson.ParseBytes(out)
util.Walk(root, "", "type", &pathsToType)
for _, p := range pathsToType {
typeResult := gjson.GetBytes(out, p)
if strings.ToLower(typeResult.String()) == "select" {
out, _ = sjson.SetBytes(out, p, "STRING")
}
}
return out
}

View File

@@ -335,7 +335,7 @@ func (w *Watcher) createClientFromFile(path string, cfg *config.Config) (interfa
return nil, nil // Not an error, just nothing to process yet.
}
tokenType := "gemini"
tokenType := ""
typeResult := gjson.GetBytes(data, "type")
if typeResult.Exists() {
tokenType = typeResult.String()