Compare commits

...

7 Commits

Author SHA1 Message Date
Luis Pater
56a05d2cce Merge pull request #26 from luispater/flash-lite
Some checks failed
docker-image / docker (push) Has been cancelled
goreleaser / goreleaser (push) Has been cancelled
Add Gemini 2.5 Flash-Lite Model
2025-09-04 16:11:43 +08:00
hkfires
3e09bc9470 Add Gemini 2.5 Flash-Lite Model 2025-09-04 11:59:48 +08:00
hkfires
5ed79e5aa3 Add debounce logic for file events to prevent duplicate reloads 2025-09-04 10:28:54 +08:00
hkfires
f38b78dbe6 Update the README to include Docker Compose usage instructions 2025-09-04 10:00:56 +08:00
Luis Pater
f1d6f01585 Add reasoning/thinking configuration handling for Claude and OpenAI translators
Some checks failed
docker-image / docker (push) Has been cancelled
goreleaser / goreleaser (push) Has been cancelled
- Implemented `thinkingConfig` handling to allow reasoning effort configuration in request generation.
- Added support for reasoning content deltas (`thinking_delta`) in response processing.
- Enhanced reasoning-related token budget mappings for various reasoning levels.
- Improved response handling logic to ensure proper reasoning content inclusion.
2025-09-04 09:43:22 +08:00
hkfires
9b627a93ac Add Docker Compose 2025-09-04 09:23:35 +08:00
Luis Pater
d4709ffcf9 Replace path with filepath for cross-platform compatibility
Some checks failed
docker-image / docker (push) Has been cancelled
goreleaser / goreleaser (push) Has been cancelled
- Updated imports and function calls to use `filepath` across all token storage implementations and server entry point.
- Ensured consistent handling of directory and file paths for improved portability.
2025-09-04 08:23:51 +08:00
19 changed files with 198 additions and 21 deletions

25
.dockerignore Normal file
View File

@@ -0,0 +1,25 @@
# Git and GitHub folders
.git
.github
# Docker and CI/CD related files
docker-compose.yml
.dockerignore
.gitignore
.goreleaser.yml
Dockerfile
# Documentation and license
README.md
README_CN.md
MANAGEMENT_API.md
MANAGEMENT_API_CN.md
LICENSE
# Example configuration
config.example.yaml
# Runtime data folders (should be mounted as volumes)
auths
logs
config.yaml

6
.gitignore vendored
View File

@@ -1,3 +1,5 @@
config.yaml
docs/
logs/
docs/*
logs/*
auths/*
!auths/.gitkeep

View File

@@ -486,6 +486,31 @@ Run the following command to start the server:
docker run --rm -p 8317:8317 -v /path/to/your/config.yaml:/CLIProxyAPI/config.yaml -v /path/to/your/auth-dir:/root/.cli-proxy-api eceasy/cli-proxy-api:latest
```
## Run with Docker Compose
1. Create a `config.yaml` from `config.example.yaml` and customize it.
2. Build and start the services using Docker Compose:
```bash
docker compose up -d --build
```
3. To authenticate with providers, run the login command inside the container:
- **Gemini**: `docker compose exec cli-proxy-api /CLIProxyAPI/CLIProxyAPI -no-browser --login`
- **OpenAI (Codex)**: `docker compose exec cli-proxy-api /CLIProxyAPI/CLIProxyAPI -no-browser --codex-login`
- **Claude**: `docker compose exec cli-proxy-api /CLIProxyAPI/CLIProxyAPI -no-browser --claude-login`
- **Qwen**: `docker compose exec cli-proxy-api /CLIProxyAPI/CLIProxyAPI -no-browser --qwen-login`
4. To view the server logs:
```bash
docker compose logs -f
```
5. To stop the application:
```bash
docker compose down
```
## Management API
see [MANAGEMENT_API.md](MANAGEMENT_API.md)

View File

@@ -499,6 +499,31 @@ docker run -it -rm -v /path/to/your/config.yaml:/CLIProxyAPI/config.yaml -v /pat
docker run --rm -p 8317:8317 -v /path/to/your/config.yaml:/CLIProxyAPI/config.yaml -v /path/to/your/auth-dir:/root/.cli-proxy-api eceasy/cli-proxy-api:latest
```
## 使用 Docker Compose 运行
1. 从 `config.example.yaml` 创建一个 `config.yaml` 文件并进行自定义。
2. 使用 Docker Compose 构建并启动服务:
```bash
docker compose up -d --build
```
3. 要在容器内运行登录命令进行身份验证:
- **Gemini**: `docker compose exec cli-proxy-api /CLIProxyAPI/CLIProxyAPI -no-browser --login`
- **OpenAI (Codex)**: `docker compose exec cli-proxy-api /CLIProxyAPI/CLIProxyAPI -no-browser --codex-login`
- **Claude**: `docker compose exec cli-proxy-api /CLIProxyAPI/CLIProxyAPI -no-browser --claude-login`
- **Qwen**: `docker compose exec cli-proxy-api /CLIProxyAPI/CLIProxyAPI -no-browser --qwen-login`
4. 查看服务器日志:
```bash
docker compose logs -f
```
5. 停止应用程序:
```bash
docker compose down
```
## 管理 API 文档
请参见 [MANAGEMENT_API_CN.md](MANAGEMENT_API_CN.md)

0
auths/.gitkeep Normal file
View File

View File

@@ -8,7 +8,7 @@ import (
"flag"
"fmt"
"os"
"path"
"path/filepath"
"strings"
"github.com/luispater/CLIProxyAPI/internal/cmd"
@@ -36,7 +36,7 @@ func (m *LogFormatter) Format(entry *log.Entry) ([]byte, error) {
timestamp := entry.Time.Format("2006-01-02 15:04:05")
var newLog string
// Customize the log format to include timestamp, level, caller file/line, and message.
newLog = fmt.Sprintf("[%s] [%s] [%s:%d] %s\n", timestamp, entry.Level, path.Base(entry.Caller.File), entry.Caller.Line, entry.Message)
newLog = fmt.Sprintf("[%s] [%s] [%s:%d] %s\n", timestamp, entry.Level, filepath.Base(entry.Caller.File), entry.Caller.Line, entry.Message)
b.WriteString(newLog)
return b.Bytes(), nil
@@ -96,7 +96,7 @@ func main() {
if err != nil {
log.Fatalf("failed to get working directory: %v", err)
}
configFilePath = path.Join(wd, "config.yaml")
configFilePath = filepath.Join(wd, "config.yaml")
cfg, err = config.LoadConfig(configFilePath)
}
if err != nil {
@@ -120,7 +120,7 @@ func main() {
parts := strings.Split(cfg.AuthDir, string(os.PathSeparator))
if len(parts) > 1 {
parts[0] = home
cfg.AuthDir = path.Join(parts...)
cfg.AuthDir = filepath.Join(parts...)
} else {
// If the path is just "~", set it to the home directory.
cfg.AuthDir = home

17
docker-compose.yml Normal file
View File

@@ -0,0 +1,17 @@
services:
cli-proxy-api:
build:
context: .
dockerfile: Dockerfile
image: cli-proxy-api:latest
container_name: cli-proxy-api
ports:
- "8317:8317"
- "8085:8085"
- "1455:1455"
- "54545:54545"
volumes:
- ./config.yaml:/CLIProxyAPI/config.yaml
- ./auths:/root/.cli-proxy-api
- ./logs:/CLIProxyAPI/logs
restart: unless-stopped

View File

@@ -7,7 +7,7 @@ import (
"encoding/json"
"fmt"
"os"
"path"
"path/filepath"
)
// ClaudeTokenStorage stores OAuth2 token information for Anthropic Claude API authentication.
@@ -49,7 +49,7 @@ func (ts *ClaudeTokenStorage) SaveTokenToFile(authFilePath string) error {
ts.Type = "claude"
// Create directory structure if it doesn't exist
if err := os.MkdirAll(path.Dir(authFilePath), 0700); err != nil {
if err := os.MkdirAll(filepath.Dir(authFilePath), 0700); err != nil {
return fmt.Errorf("failed to create directory: %v", err)
}

View File

@@ -7,7 +7,7 @@ import (
"encoding/json"
"fmt"
"os"
"path"
"path/filepath"
)
// CodexTokenStorage stores OAuth2 token information for OpenAI Codex API authentication.
@@ -43,7 +43,7 @@ type CodexTokenStorage struct {
// - error: An error if the operation fails, nil otherwise
func (ts *CodexTokenStorage) SaveTokenToFile(authFilePath string) error {
ts.Type = "codex"
if err := os.MkdirAll(path.Dir(authFilePath), 0700); err != nil {
if err := os.MkdirAll(filepath.Dir(authFilePath), 0700); err != nil {
return fmt.Errorf("failed to create directory: %v", err)
}

View File

@@ -7,7 +7,7 @@ import (
"encoding/json"
"fmt"
"os"
"path"
"path/filepath"
log "github.com/sirupsen/logrus"
)
@@ -46,7 +46,7 @@ type GeminiTokenStorage struct {
// - error: An error if the operation fails, nil otherwise
func (ts *GeminiTokenStorage) SaveTokenToFile(authFilePath string) error {
ts.Type = "gemini"
if err := os.MkdirAll(path.Dir(authFilePath), 0700); err != nil {
if err := os.MkdirAll(filepath.Dir(authFilePath), 0700); err != nil {
return fmt.Errorf("failed to create directory: %v", err)
}

View File

@@ -7,7 +7,7 @@ import (
"encoding/json"
"fmt"
"os"
"path"
"path/filepath"
)
// QwenTokenStorage stores OAuth2 token information for Alibaba Qwen API authentication.
@@ -41,7 +41,7 @@ type QwenTokenStorage struct {
// - error: An error if the operation fails, nil otherwise
func (ts *QwenTokenStorage) SaveTokenToFile(authFilePath string) error {
ts.Type = "qwen"
if err := os.MkdirAll(path.Dir(authFilePath), 0700); err != nil {
if err := os.MkdirAll(filepath.Dir(authFilePath), 0700); err != nil {
return fmt.Errorf("failed to create directory: %v", err)
}

View File

@@ -38,8 +38,9 @@ const (
var (
previewModels = map[string][]string{
"gemini-2.5-pro": {"gemini-2.5-pro-preview-05-06", "gemini-2.5-pro-preview-06-05"},
"gemini-2.5-flash": {"gemini-2.5-flash-preview-04-17", "gemini-2.5-flash-preview-05-20"},
"gemini-2.5-pro": {"gemini-2.5-pro-preview-05-06", "gemini-2.5-pro-preview-06-05"},
"gemini-2.5-flash": {"gemini-2.5-flash-preview-04-17", "gemini-2.5-flash-preview-05-20"},
"gemini-2.5-flash-lite": {"gemini-2.5-flash-lite-preview-06-17"},
}
)
@@ -99,6 +100,7 @@ func (c *GeminiCLIClient) CanProvideModel(modelName string) bool {
models := []string{
"gemini-2.5-pro",
"gemini-2.5-flash",
"gemini-2.5-flash-lite",
}
return util.InArray(models, modelName)
}

View File

@@ -130,6 +130,20 @@ func GetGeminiCLIModels() []*ModelInfo {
OutputTokenLimit: 65536,
SupportedGenerationMethods: []string{"generateContent", "countTokens", "createCachedContent", "batchGenerateContent"},
},
{
ID: "gemini-2.5-flash-lite",
Object: "model",
Created: time.Now().Unix(),
OwnedBy: "google",
Type: "gemini",
Name: "models/gemini-2.5-flash-lite",
Version: "2.5",
DisplayName: "Gemini 2.5 Flash Lite",
Description: "Our smallest and most cost effective model, built for at scale usage.",
InputTokenLimit: 1048576,
OutputTokenLimit: 65536,
SupportedGenerationMethods: []string{"generateContent", "countTokens", "createCachedContent", "batchGenerateContent"},
},
}
}

View File

@@ -89,6 +89,17 @@ func ConvertGeminiRequestToClaude(modelName string, inputRawJSON []byte, stream
out, _ = sjson.Set(out, "stop_sequences", stopSequences)
}
}
// Include thoughts configuration for reasoning process visibility
if thinkingConfig := genConfig.Get("thinkingConfig"); thinkingConfig.Exists() && thinkingConfig.IsObject() {
if includeThoughts := thinkingConfig.Get("include_thoughts"); includeThoughts.Exists() {
if includeThoughts.Type == gjson.True {
out, _ = sjson.Set(out, "thinking.type", "enabled")
if thinkingBudget := thinkingConfig.Get("thinkingBudget"); thinkingBudget.Exists() {
out, _ = sjson.Set(out, "thinking.budget_tokens", thinkingBudget.Int())
}
}
}
}
}
// System instruction conversion to Claude Code format

View File

@@ -128,7 +128,7 @@ func ConvertClaudeResponseToGemini(_ context.Context, modelName string, original
}
case "thinking_delta":
// Thinking/reasoning content delta for models with reasoning capabilities
if text := delta.Get("text"); text.Exists() && text.String() != "" {
if text := delta.Get("thinking"); text.Exists() && text.String() != "" {
thinkingPart := `{"thought":true,"text":""}`
thinkingPart, _ = sjson.Set(thinkingPart, "text", text.String())
template, _ = sjson.SetRaw(template, "candidates.0.content.parts.-1", thinkingPart)
@@ -411,7 +411,7 @@ func ConvertClaudeResponseToGeminiNonStream(_ context.Context, modelName string,
}
case "thinking_delta":
// Process reasoning/thinking content
if text := delta.Get("text"); text.Exists() && text.String() != "" {
if text := delta.Get("thinking"); text.Exists() && text.String() != "" {
partJSON := `{"thought":true,"text":""}`
partJSON, _ = sjson.Set(partJSON, "text", text.String())
part := gjson.Parse(partJSON).Value().(map[string]interface{})

View File

@@ -41,6 +41,21 @@ func ConvertOpenAIRequestToClaude(modelName string, inputRawJSON []byte, stream
root := gjson.ParseBytes(rawJSON)
if v := root.Get("reasoning_effort"); v.Exists() {
out, _ = sjson.Set(out, "thinking.type", "enabled")
switch v.String() {
case "none":
out, _ = sjson.Set(out, "thinking.type", "disabled")
case "low":
out, _ = sjson.Set(out, "thinking.budget_tokens", 1024)
case "medium":
out, _ = sjson.Set(out, "thinking.budget_tokens", 8192)
case "high":
out, _ = sjson.Set(out, "thinking.budget_tokens", 24576)
}
}
// Helper for generating tool call IDs in the form: toolu_<alphanum>
// This ensures unique identifiers for tool calls in the Claude Code format
genToolCallID := func() string {

View File

@@ -128,10 +128,11 @@ func ConvertClaudeResponseToOpenAI(_ context.Context, modelName string, original
return []string{}
}
}
return []string{template}
return []string{}
case "content_block_delta":
// Handle content delta (text, tool use arguments, or reasoning content)
hasContent := false
if delta := root.Get("delta"); delta.Exists() {
deltaType := delta.Get("type").String()
@@ -140,8 +141,14 @@ func ConvertClaudeResponseToOpenAI(_ context.Context, modelName string, original
// Text content delta - send incremental text updates
if text := delta.Get("text"); text.Exists() {
template, _ = sjson.Set(template, "choices.0.delta.content", text.String())
hasContent = true
}
case "thinking_delta":
// Accumulate reasoning/thinking content
if thinking := delta.Get("thinking"); thinking.Exists() {
template, _ = sjson.Set(template, "choices.0.delta.reasoning_content", thinking.String())
hasContent = true
}
case "input_json_delta":
// Tool use input delta - accumulate arguments for tool calls
if partialJSON := delta.Get("partial_json"); partialJSON.Exists() {
@@ -156,7 +163,11 @@ func ConvertClaudeResponseToOpenAI(_ context.Context, modelName string, original
return []string{}
}
}
return []string{template}
if hasContent {
return []string{template}
} else {
return []string{}
}
case "content_block_stop":
// End of content block - output complete tool call if it's a tool_use block

View File

@@ -28,6 +28,23 @@ func ConvertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte
root := gjson.ParseBytes(rawJSON)
if v := root.Get("reasoning.effort"); v.Exists() {
out, _ = sjson.Set(out, "thinking.type", "enabled")
switch v.String() {
case "none":
out, _ = sjson.Set(out, "thinking.type", "disabled")
case "minimal":
out, _ = sjson.Set(out, "thinking.budget_tokens", 1024)
case "low":
out, _ = sjson.Set(out, "thinking.budget_tokens", 4096)
case "medium":
out, _ = sjson.Set(out, "thinking.budget_tokens", 8192)
case "high":
out, _ = sjson.Set(out, "thinking.budget_tokens", 24576)
}
}
// Helper for generating tool call IDs when missing
genToolCallID := func() string {
const letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"

View File

@@ -39,6 +39,8 @@ type Watcher struct {
clientsMutex sync.RWMutex
reloadCallback func(map[string]interfaces.Client, *config.Config)
watcher *fsnotify.Watcher
eventTimes map[string]time.Time
eventMutex sync.Mutex
}
// NewWatcher creates a new file watcher instance
@@ -54,6 +56,7 @@ func NewWatcher(configPath, authDir string, reloadCallback func(map[string]inter
reloadCallback: reloadCallback,
watcher: watcher,
clients: make(map[string]interfaces.Client),
eventTimes: make(map[string]time.Time),
}, nil
}
@@ -123,6 +126,16 @@ func (w *Watcher) handleEvent(event fsnotify.Event) {
now := time.Now()
log.Debugf("file system event detected: %s %s", event.Op.String(), event.Name)
// Debounce logic to prevent rapid reloads
w.eventMutex.Lock()
if lastTime, ok := w.eventTimes[event.Name]; ok && now.Sub(lastTime) < 500*time.Millisecond {
log.Debugf("debouncing event for %s", event.Name)
w.eventMutex.Unlock()
return
}
w.eventTimes[event.Name] = now
w.eventMutex.Unlock()
// 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)