Compare commits

..

16 Commits

Author SHA1 Message Date
Luis Pater
ef922b29c2 Update workflows and build process for enhanced metadata injection
- Upgraded GitHub Actions (`actions/checkout` to v4, `actions/setup-go` to v4, `goreleaser-action` to v4).
- Added detailed build metadata (`VERSION`, `COMMIT`, `BUILD_DATE`) to workflows.
- Unified metadata injection into binaries and Docker images.
- Enhanced `.goreleaser.yml` with checksum, snapshot, and changelog configurations.
2025-09-06 15:37:48 +08:00
Luis Pater
d10ef7b58a Merge pull request #31 from luispater/docker-build-sh
Inject build metadata into binary during release and docker build
2025-09-06 15:28:58 +08:00
hkfires
e074e957d1 Update README 2025-09-06 10:24:48 +08:00
hkfires
7b546ea2ee build(goreleaser): inject build metadata into binary during release 2025-09-06 10:13:48 +08:00
hkfires
506e2e12a6 feat(server): inject build metadata into application logs and container image 2025-09-06 09:41:27 +08:00
Luis Pater
c52255e2a4 Merge branch 'dev' 2025-09-05 23:05:03 +08:00
Luis Pater
b05d00ede9 Add versioning support to build artifacts and log outputs
- Introduced `Version` variable, set during build via `-ldflags`, to embed application version.
- Updated Dockerfile to accept `APP_VERSION` argument for version injection during build.
- Modified `.goreleaser.yml` to pass GitHub release tag as version via `ldflags`.
- Added version logging in the application startup.
2025-09-05 22:57:22 +08:00
Luis Pater
8d05489973 Add versioning support to build artifacts and log outputs
- Introduced `Version` variable, set during build via `-ldflags`, to embed application version.
- Updated Dockerfile to accept `APP_VERSION` argument for version injection during build.
- Modified `.goreleaser.yml` to pass GitHub release tag as version via `ldflags`.
- Added version logging in the application startup.
2025-09-05 22:53:49 +08:00
Luis Pater
4f18809500 Merge pull request #29 from luispater/bugfix
Enhance client counting and logging
2025-09-05 21:48:30 +08:00
hkfires
28218ec550 feat(api): implement granular client type metrics in server updates 2025-09-05 19:26:57 +08:00
hkfires
f97954c811 fix(watcher): enhance API key client counting and logging 2025-09-05 18:02:45 +08:00
Luis Pater
798f65b35e Merge pull request #28 from luispater/bugfix
Optimize and fix bugs for hot reloading
2025-09-05 15:20:27 +08:00
hkfires
57484b97bb fix(watcher): improve client reload logic and prevent redundant updates
- replace debounce timing with content-based change detection using SHA256 hashes
- skip client reload when auth file content is unchanged
- handle empty auth files gracefully by ignoring them
- ensure hash cache is updated only on successful client creation
- clean up hash cache when clients are removed
2025-09-05 13:53:15 +08:00
hkfires
0e0602c553 refactor(watcher): restructure client management and API key handling
- separate file-based and API key-based clients in watcher
- improve client reloading logic with better locking and error handling
- add dedicated functions for building API key clients and loading file clients
- update combined client map generation to include cached API key clients
- enhance logging and debugging information during client reloads
- fix potential race conditions in client updates and removals
2025-09-05 13:25:30 +08:00
Luis Pater
54ffb52838 Add FunctionCallIndex to ConvertCliToOpenAIParams and enhance tool call handling
- Introduced `FunctionCallIndex` to track and manage function call indices within `ConvertCliToOpenAIParams`.
- Enhanced handling for `response.completed` and `response.output_item.done` data types to support tool call scenarios.
- Improved logic for restoring original tool names and setting function arguments during response parsing.
2025-09-05 09:02:24 +08:00
Luis Pater
c62e45ee88 Add Codex API key support and Gemini 2.5 Flash-Lite model documentation updates
- Documented Gemini 2.5 Flash-Lite model in English and Chinese README files.
- Updated README and example configuration to include Codex API key settings.
- Added examples for custom Codex API endpoint configuration.
2025-09-04 18:23:52 +08:00
15 changed files with 513 additions and 266 deletions

View File

@@ -24,8 +24,11 @@ jobs:
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Generate App Version
run: echo APP_VERSION=`git describe --tags --always` >> $GITHUB_ENV
- name: Generate Build Metadata
run: |
echo VERSION=`git describe --tags --always --dirty` >> $GITHUB_ENV
echo COMMIT=`git rev-parse --short HEAD` >> $GITHUB_ENV
echo BUILD_DATE=`date -u +%Y-%m-%dT%H:%M:%SZ` >> $GITHUB_ENV
- name: Build and push
uses: docker/build-push-action@v6
with:
@@ -35,8 +38,9 @@ jobs:
linux/arm64
push: true
build-args: |
APP_NAME=${{ env.APP_NAME }}
APP_VERSION=${{ env.APP_VERSION }}
VERSION=${{ env.VERSION }}
COMMIT=${{ env.COMMIT }}
BUILD_DATE=${{ env.BUILD_DATE }}
tags: |
${{ env.DOCKERHUB_REPO }}:latest
${{ env.DOCKERHUB_REPO }}:${{ env.APP_VERSION }}
${{ env.DOCKERHUB_REPO }}:${{ env.VERSION }}

View File

@@ -13,18 +13,26 @@ jobs:
goreleaser:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
with:
fetch-depth: 0
- run: git fetch --force --tags
- uses: actions/setup-go@v3
- uses: actions/setup-go@v4
with:
go-version: '>=1.24.0'
cache: true
- uses: goreleaser/goreleaser-action@v3
- name: Generate Build Metadata
run: |
echo VERSION=`git describe --tags --always --dirty` >> $GITHUB_ENV
echo COMMIT=`git rev-parse --short HEAD` >> $GITHUB_ENV
echo BUILD_DATE=`date -u +%Y-%m-%dT%H:%M:%SZ` >> $GITHUB_ENV
- uses: goreleaser/goreleaser-action@v4
with:
distribution: goreleaser
version: latest
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ env.VERSION }}
COMMIT: ${{ env.COMMIT }}
BUILD_DATE: ${{ env.BUILD_DATE }}

View File

@@ -9,6 +9,8 @@ builds:
- arm64
main: ./cmd/server/
binary: cli-proxy-api
ldflags:
- -s -w -X 'main.Version={{.Version}}' -X 'main.Commit={{.ShortCommit}}' -X 'main.BuildDate={{.Date}}'
archives:
- id: "cli-proxy-api"
format: tar.gz
@@ -19,4 +21,17 @@ archives:
- LICENSE
- README.md
- README_CN.md
- config.example.yaml
- config.example.yaml
checksum:
name_template: 'checksums.txt'
snapshot:
name_template: "{{ incpatch .Version }}-next"
changelog:
sort: asc
filters:
exclude:
- '^docs:'
- '^test:'

View File

@@ -8,7 +8,11 @@ RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o ./CLIProxyAPI ./cmd/server/
ARG VERSION=dev
ARG COMMIT=none
ARG BUILD_DATE=unknown
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w -X 'main.Version=${VERSION}' -X 'main.Commit=${COMMIT}' -X 'main.BuildDate=${BUILD_DATE}'" -o ./CLIProxyAPI ./cmd/server/
FROM alpine:3.22.0

View File

@@ -220,6 +220,7 @@ console.log(await claudeResponse.json());
- gemini-2.5-pro
- gemini-2.5-flash
- gemini-2.5-flash-lite
- gpt-5
- claude-opus-4-1-20250805
- claude-opus-4-20250514
@@ -254,6 +255,9 @@ The server uses a YAML configuration file (`config.yaml`) located in the project
| `debug` | boolean | false | Enable debug mode for verbose logging. |
| `api-keys` | string[] | [] | List of API keys that can be used to authenticate requests. |
| `generative-language-api-key` | string[] | [] | List of Generative Language API keys. |
| `codex-api-key` | object | {} | List of Codex API keys. |
| `codex-api-key.api-key` | string | "" | Codex API key. |
| `codex-api-key.base-url` | string | "" | Custom Codex API endpoint, if you use a third-party API endpoint. |
| `claude-api-key` | object | {} | List of Claude API keys. |
| `claude-api-key.api-key` | string | "" | Claude API key. |
| `claude-api-key.base-url` | string | "" | Custom Claude API endpoint, if you use a third-party API endpoint. |
@@ -310,6 +314,11 @@ generative-language-api-key:
- "AIzaSy...02"
- "AIzaSy...03"
- "AIzaSy...04"
# Codex API keys
codex-api-key:
- api-key: "sk-atSM..."
base-url: "https://www.example.com" # use the custom codex API endpoint
# Claude API keys
claude-api-key:
@@ -490,10 +499,15 @@ docker run --rm -p 8317:8317 -v /path/to/your/config.yaml:/CLIProxyAPI/config.ya
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
```
2. Build and start the services using the build scripts:
- For Windows (PowerShell):
```powershell
./docker-build.ps1
```
- For Linux/macOS:
```bash
bash docker-build.sh
```
3. To authenticate with providers, run the login command inside the container:
- **Gemini**: `docker compose exec cli-proxy-api /CLIProxyAPI/CLIProxyAPI -no-browser --login`

View File

@@ -237,6 +237,7 @@ console.log(await claudeResponse.json());
- gemini-2.5-pro
- gemini-2.5-flash
- gemini-2.5-flash-lite
- gpt-5
- claude-opus-4-1-20250805
- claude-opus-4-20250514
@@ -271,6 +272,9 @@ console.log(await claudeResponse.json());
| `debug` | boolean | false | 启用调试模式以获取详细日志。 |
| `api-keys` | string[] | [] | 可用于验证请求的API密钥列表。 |
| `generative-language-api-key` | string[] | [] | 生成式语言API密钥列表。 |
| `codex-api-key` | object | {} | Codex API密钥列表。 |
| `codex-api-key.api-key` | string | "" | Codex API密钥。 |
| `codex-api-key.base-url` | string | "" | 自定义的Codex API端点 |
| `claude-api-key` | object | {} | Claude API密钥列表。 |
| `claude-api-key.api-key` | string | "" | Claude API密钥。 |
| `claude-api-key.base-url` | string | "" | 自定义的Claude API端点如果您使用第三方的API端点。 |
@@ -328,11 +332,16 @@ generative-language-api-key:
- "AIzaSy...03"
- "AIzaSy...04"
# Claude API keys
claude-api-key:
- api-key: "sk-atSM..." # use the official claude API key, no need to set the base url
# Codex API 密钥
codex-api-key:
- api-key: "sk-atSM..."
base-url: "https://www.example.com" # use the custom claude API endpoint
base-url: "https://www.example.com" # 第三方 Codex API 中转服务端点
# Claude API 密钥
claude-api-key:
- api-key: "sk-atSM..." # 如果使用官方 Claude API无需设置 base-url
- api-key: "sk-atSM..."
base-url: "https://www.example.com" # 第三方 Claude API 中转服务端点
# OpenAI 兼容提供商
openai-compatibility:
@@ -503,10 +512,15 @@ docker run --rm -p 8317:8317 -v /path/to/your/config.yaml:/CLIProxyAPI/config.ya
1. 从 `config.example.yaml` 创建一个 `config.yaml` 文件并进行自定义。
2. 使用 Docker Compose 构建并启动服务:
```bash
docker compose up -d --build
```
2. 使用构建脚本构建并启动服务:
- Windows (PowerShell):
```powershell
./docker-build.ps1
```
- Linux/macOS:
```bash
bash docker-build.sh
```
3. 要在容器内运行登录命令进行身份验证:
- **Gemini**: `docker compose exec cli-proxy-api /CLIProxyAPI/CLIProxyAPI -no-browser --login`

View File

@@ -17,6 +17,12 @@ import (
log "github.com/sirupsen/logrus"
)
var (
Version = "dev"
Commit = "none"
BuildDate = "unknown"
)
// LogFormatter defines a custom log format for logrus.
// This formatter adds timestamp, log level, and source location information
// to each log entry for better debugging and monitoring.
@@ -58,6 +64,8 @@ func init() {
// It parses command-line flags, loads configuration, and starts the appropriate
// service based on the provided flags (login, codex-login, or server mode).
func main() {
log.Infof("CLIProxyAPI Version: %s, Commit: %s, BuiltAt: %s", Version, Commit, BuildDate)
// Command-line flags to control the application's behavior.
var login bool
var codexLogin bool

View File

@@ -41,6 +41,11 @@ generative-language-api-key:
- "AIzaSy...03"
- "AIzaSy...04"
# Codex API keys
codex-api-key:
- api-key: "sk-atSM..."
base-url: "https://www.example.com" # use the custom codex API endpoint
# Claude API keys
claude-api-key:
- api-key: "sk-atSM..." # use the official claude API key, no need to set the base url

36
docker-build.ps1 Normal file
View File

@@ -0,0 +1,36 @@
# build.ps1 - Windows PowerShell Build Script
#
# This script automates the process of building and running the Docker container
# with version information dynamically injected at build time.
# Stop script execution on any error
$ErrorActionPreference = "Stop"
# --- Step 1: Get Version Information ---
# Get the latest git tag or commit hash as the version string.
$VERSION = (git describe --tags --always --dirty)
# Get the short commit hash.
$COMMIT = (git rev-parse --short HEAD)
# Get the current UTC date and time in ISO 8601 format.
$BUILD_DATE = (Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ")
Write-Host "--- Building with the following info ---"
Write-Host "Version: $VERSION"
Write-Host "Commit: $COMMIT"
Write-Host "Build Date: $BUILD_DATE"
Write-Host "----------------------------------------"
# --- Step 2: Build the Docker Image ---
# Pass the version information as build arguments to 'docker compose build'.
# These arguments are then used by the Dockerfile to inject them into the Go binary.
docker compose build --build-arg VERSION=$VERSION --build-arg COMMIT=$COMMIT --build-arg BUILD_DATE=$BUILD_DATE
# --- Step 3: Start the Services ---
# Start the services in detached mode using the newly built image.
# '--remove-orphans' cleans up any containers for services that are no longer defined.
docker compose up -d --remove-orphans
Write-Host "Build complete. Services are starting."
Write-Host "Run 'docker compose logs -f' to see the logs."

41
docker-build.sh Normal file
View File

@@ -0,0 +1,41 @@
#!/usr/bin/env bash
#
# build.sh - Linux/macOS Build Script
#
# This script automates the process of building and running the Docker container
# with version information dynamically injected at build time.
# Exit immediately if a command exits with a non-zero status.
set -euo pipefail
# --- Step 1: Get Version Information ---
# Get the latest git tag or commit hash as the version string.
VERSION="$(git describe --tags --always --dirty)"
# Get the short commit hash.
COMMIT="$(git rev-parse --short HEAD)"
# Get the current UTC date and time in ISO 8601 format.
BUILD_DATE="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
echo "--- Building with the following info ---"
echo "Version: ${VERSION}"
echo "Commit: ${COMMIT}"
echo "Build Date: ${BUILD_DATE}"
echo "----------------------------------------"
# --- Step 2: Build the Docker Image ---
# Pass the version information as build arguments to 'docker compose build'.
# These arguments are then used by the Dockerfile to inject them into the Go binary.
docker compose build \
--build-arg VERSION="${VERSION}" \
--build-arg COMMIT="${COMMIT}" \
--build-arg BUILD_DATE="${BUILD_DATE}"
# --- Step 3: Start the Services ---
# Start the services in detached mode using the newly built image.
# '--remove-orphans' cleans up any containers for services that are no longer defined.
docker compose up -d --remove-orphans
echo "Build complete. Services are starting."
echo "Run 'docker compose logs -f' to see the logs."

View File

@@ -3,6 +3,10 @@ services:
build:
context: .
dockerfile: Dockerfile
args:
VERSION: ${VERSION:-dev}
COMMIT: ${COMMIT:-none}
BUILD_DATE: ${BUILD_DATE:-unknown}
image: cli-proxy-api:latest
container_name: cli-proxy-api
ports:

View File

@@ -18,6 +18,7 @@ import (
managementHandlers "github.com/luispater/CLIProxyAPI/internal/api/handlers/management"
"github.com/luispater/CLIProxyAPI/internal/api/handlers/openai"
"github.com/luispater/CLIProxyAPI/internal/api/middleware"
"github.com/luispater/CLIProxyAPI/internal/client"
"github.com/luispater/CLIProxyAPI/internal/config"
"github.com/luispater/CLIProxyAPI/internal/interfaces"
"github.com/luispater/CLIProxyAPI/internal/logging"
@@ -315,7 +316,47 @@ func (s *Server) UpdateClients(clients map[string]interfaces.Client, cfg *config
if s.mgmt != nil {
s.mgmt.SetConfig(cfg)
}
log.Infof("server clients and configuration updated: %d clients", len(clientSlice))
// Count client types for detailed logging
authFiles := 0
glAPIKeyCount := 0
claudeAPIKeyCount := 0
codexAPIKeyCount := 0
openAICompatCount := 0
for _, c := range clientSlice {
switch cl := c.(type) {
case *client.GeminiCLIClient:
authFiles++
case *client.CodexClient:
if cl.GetAPIKey() == "" {
authFiles++
} else {
codexAPIKeyCount++
}
case *client.ClaudeClient:
if cl.GetAPIKey() == "" {
authFiles++
} else {
claudeAPIKeyCount++
}
case *client.QwenClient:
authFiles++
case *client.GeminiClient:
glAPIKeyCount++
case *client.OpenAICompatibilityClient:
openAICompatCount++
}
}
log.Infof("server clients and configuration updated: %d clients (%d auth files + %d GL API keys + %d Claude API keys + %d Codex keys + %d OpenAI-compat)",
len(clientSlice),
authFiles,
glAPIKeyCount,
claudeAPIKeyCount,
codexAPIKeyCount,
openAICompatCount,
)
}
// (management handlers moved to internal/api/handlers/management)

View File

@@ -50,6 +50,7 @@ import (
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
err := filepath.Walk(cfg.AuthDir, func(path string, info fs.FileInfo, err error) error {
if err != nil {
return err
@@ -89,6 +90,7 @@ func StartService(cfg *config.Config, configPath string) {
// Add the new client to the pool.
cliClient := client.NewGeminiCLIClient(httpClient, &ts, cfg)
cliClients[path] = cliClient
successfulAuthCount++
}
} else if tokenType == "codex" {
var ts codex.CodexTokenStorage
@@ -103,6 +105,7 @@ func StartService(cfg *config.Config, configPath string) {
}
log.Info("Authentication successful.")
cliClients[path] = codexClient
successfulAuthCount++
}
} else if tokenType == "claude" {
var ts claude.ClaudeTokenStorage
@@ -112,6 +115,7 @@ func StartService(cfg *config.Config, configPath string) {
claudeClient := client.NewClaudeClient(cfg, &ts)
log.Info("Authentication successful.")
cliClients[path] = claudeClient
successfulAuthCount++
}
} else if tokenType == "qwen" {
var ts qwen.QwenTokenStorage
@@ -121,6 +125,7 @@ func StartService(cfg *config.Config, configPath string) {
qwenClient := client.NewQwenClient(cfg, &ts)
log.Info("Authentication successful.")
cliClients[path] = qwenClient
successfulAuthCount++
}
}
}
@@ -130,51 +135,24 @@ func StartService(cfg *config.Config, configPath string) {
log.Fatalf("Error walking auth directory: %v", err)
}
clientSlice := clientsToSlice(cliClients)
apiKeyClients, glAPIKeyCount, claudeAPIKeyCount, codexAPIKeyCount, openAICompatCount := buildAPIKeyClients(cfg)
if len(cfg.GlAPIKey) > 0 {
// Initialize clients with Generative Language API Keys if provided in configuration.
for i := 0; i < len(cfg.GlAPIKey); i++ {
httpClient := util.SetProxy(cfg, &http.Client{})
totalNewClients := len(cliClients) + len(apiKeyClients)
log.Infof("full client load complete - %d clients (%d auth files + %d GL API keys + %d Claude API keys + %d Codex keys + %d OpenAI-compat)",
totalNewClients,
successfulAuthCount,
glAPIKeyCount,
claudeAPIKeyCount,
codexAPIKeyCount,
openAICompatCount,
)
log.Debug("Initializing with Generative Language API Key...")
cliClient := client.NewGeminiClient(httpClient, cfg, cfg.GlAPIKey[i])
clientSlice = append(clientSlice, cliClient)
}
}
if len(cfg.ClaudeKey) > 0 {
// Initialize clients with Claude API Keys if provided in configuration.
for i := 0; i < len(cfg.ClaudeKey); i++ {
log.Debug("Initializing with Claude API Key...")
cliClient := client.NewClaudeClientWithKey(cfg, i)
clientSlice = append(clientSlice, cliClient)
}
}
if len(cfg.CodexKey) > 0 {
// Initialize clients with Codex API Keys if provided in configuration.
for i := 0; i < len(cfg.CodexKey); i++ {
log.Debug("Initializing with Codex API Key...")
cliClient := client.NewCodexClientWithKey(cfg, i)
clientSlice = append(clientSlice, cliClient)
}
}
if len(cfg.OpenAICompatibility) > 0 {
// Initialize clients for OpenAI compatibility configurations
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.Fatalf("failed to create OpenAI compatibility client for %s: %v", compatConfig.Name, errClient)
}
clientSlice = append(clientSlice, compatClient)
}
}
// Combine file-based and API key-based clients for the initial server setup
allClients := clientsToSlice(cliClients)
allClients = append(allClients, clientsToSlice(apiKeyClients)...)
// Create and start the API server with the pool of clients in a separate goroutine.
apiServer := api.NewServer(cfg, clientSlice, configPath)
apiServer := api.NewServer(cfg, allClients, configPath)
log.Infof("Starting API server on port %d", cfg.Port)
// Start the API server in a goroutine so it doesn't block the main thread.
@@ -200,6 +178,7 @@ func StartService(cfg *config.Config, configPath string) {
// Set initial state for the watcher with current configuration and clients.
fileWatcher.SetConfig(cfg)
fileWatcher.SetClients(cliClients)
fileWatcher.SetAPIKeyClients(apiKeyClients)
// Start the file watcher in a separate context.
watcherCtx, watcherCancel := context.WithCancel(context.Background())
@@ -317,3 +296,55 @@ func clientsToSlice(clientMap map[string]interfaces.Client) []interfaces.Client
}
return s
}
// buildAPIKeyClients creates clients from API keys in the config
func buildAPIKeyClients(cfg *config.Config) (map[string]interfaces.Client, int, int, int, int) {
apiKeyClients := make(map[string]interfaces.Client)
glAPIKeyCount := 0
claudeAPIKeyCount := 0
codexAPIKeyCount := 0
openAICompatCount := 0
if len(cfg.GlAPIKey) > 0 {
for _, key := range cfg.GlAPIKey {
httpClient := util.SetProxy(cfg, &http.Client{})
log.Debug("Initializing with Generative Language API Key...")
cliClient := client.NewGeminiClient(httpClient, cfg, key)
apiKeyClients[cliClient.GetClientID()] = cliClient
glAPIKeyCount++
}
}
if len(cfg.ClaudeKey) > 0 {
for i := range cfg.ClaudeKey {
log.Debug("Initializing with Claude API Key...")
cliClient := client.NewClaudeClientWithKey(cfg, i)
apiKeyClients[cliClient.GetClientID()] = cliClient
claudeAPIKeyCount++
}
}
if len(cfg.CodexKey) > 0 {
for i := range cfg.CodexKey {
log.Debug("Initializing with Codex API Key...")
cliClient := client.NewCodexClientWithKey(cfg, i)
apiKeyClients[cliClient.GetClientID()] = cliClient
codexAPIKeyCount++
}
}
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
}
apiKeyClients[compatClient.GetClientID()] = compatClient
openAICompatCount++
}
}
return apiKeyClients, glAPIKeyCount, claudeAPIKeyCount, codexAPIKeyCount, openAICompatCount
}

View File

@@ -21,9 +21,10 @@ var (
// ConvertCliToOpenAIParams holds parameters for response conversion.
type ConvertCliToOpenAIParams struct {
ResponseID string
CreatedAt int64
Model string
ResponseID string
CreatedAt int64
Model string
FunctionCallIndex int
}
// ConvertCodexResponseToOpenAI translates a single chunk of a streaming response from the
@@ -43,9 +44,10 @@ type ConvertCliToOpenAIParams struct {
func ConvertCodexResponseToOpenAI(_ context.Context, modelName string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) []string {
if *param == nil {
*param = &ConvertCliToOpenAIParams{
Model: modelName,
CreatedAt: 0,
ResponseID: "",
Model: modelName,
CreatedAt: 0,
ResponseID: "",
FunctionCallIndex: -1,
}
}
@@ -108,27 +110,36 @@ func ConvertCodexResponseToOpenAI(_ context.Context, modelName string, originalR
template, _ = sjson.Set(template, "choices.0.delta.content", deltaResult.String())
}
} else if dataType == "response.completed" {
template, _ = sjson.Set(template, "choices.0.finish_reason", "stop")
template, _ = sjson.Set(template, "choices.0.native_finish_reason", "stop")
finishReason := "stop"
if (*param).(*ConvertCliToOpenAIParams).FunctionCallIndex != -1 {
finishReason = "tool_calls"
}
template, _ = sjson.Set(template, "choices.0.finish_reason", finishReason)
template, _ = sjson.Set(template, "choices.0.native_finish_reason", finishReason)
} else if dataType == "response.output_item.done" {
functionCallItemTemplate := `{"id": "","type": "function","function": {"name": "","arguments": ""}}`
functionCallItemTemplate := `{"index":0,"id":"","type":"function","function":{"name":"","arguments":""}}`
itemResult := rootResult.Get("item")
if itemResult.Exists() {
if itemResult.Get("type").String() != "function_call" {
return []string{}
}
// set the index
(*param).(*ConvertCliToOpenAIParams).FunctionCallIndex++
functionCallItemTemplate, _ = sjson.Set(functionCallItemTemplate, "index", (*param).(*ConvertCliToOpenAIParams).FunctionCallIndex)
template, _ = sjson.SetRaw(template, "choices.0.delta.tool_calls", `[]`)
functionCallItemTemplate, _ = sjson.Set(functionCallItemTemplate, "id", itemResult.Get("call_id").String())
{
// Restore original tool name if it was shortened
name := itemResult.Get("name").String()
// Build reverse map on demand from original request tools
rev := buildReverseMapFromOriginalOpenAI(originalRequestRawJSON)
if orig, ok := rev[name]; ok {
name = orig
}
functionCallItemTemplate, _ = sjson.Set(functionCallItemTemplate, "function.name", name)
// Restore original tool name if it was shortened
name := itemResult.Get("name").String()
// Build reverse map on demand from original request tools
rev := buildReverseMapFromOriginalOpenAI(originalRequestRawJSON)
if orig, ok := rev[name]; ok {
name = orig
}
functionCallItemTemplate, _ = sjson.Set(functionCallItemTemplate, "function.name", name)
functionCallItemTemplate, _ = sjson.Set(functionCallItemTemplate, "function.arguments", itemResult.Get("arguments").String())
template, _ = sjson.Set(template, "choices.0.delta.role", "assistant")
template, _ = sjson.SetRaw(template, "choices.0.delta.tool_calls.-1", functionCallItemTemplate)

View File

@@ -6,12 +6,12 @@ package watcher
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io/fs"
"net/http"
"os"
"path"
"path/filepath"
"strings"
"sync"
@@ -36,11 +36,11 @@ type Watcher struct {
authDir string
config *config.Config
clients map[string]interfaces.Client
apiKeyClients map[string]interfaces.Client // New field for caching API key clients
clientsMutex sync.RWMutex
reloadCallback func(map[string]interfaces.Client, *config.Config)
watcher *fsnotify.Watcher
eventTimes map[string]time.Time
eventMutex sync.Mutex
lastAuthHashes map[string]string
}
// NewWatcher creates a new file watcher instance
@@ -56,7 +56,8 @@ 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),
apiKeyClients: make(map[string]interfaces.Client),
lastAuthHashes: make(map[string]string),
}, nil
}
@@ -94,13 +95,20 @@ func (w *Watcher) SetConfig(cfg *config.Config) {
w.config = cfg
}
// SetClients updates the current client list
// SetClients sets the file-based clients.
func (w *Watcher) SetClients(clients map[string]interfaces.Client) {
w.clientsMutex.Lock()
defer w.clientsMutex.Unlock()
w.clients = clients
}
// SetAPIKeyClients sets the API key-based clients.
func (w *Watcher) SetAPIKeyClients(apiKeyClients map[string]interfaces.Client) {
w.clientsMutex.Lock()
defer w.clientsMutex.Unlock()
w.apiKeyClients = apiKeyClients
}
// processEvents handles file system events
func (w *Watcher) processEvents(ctx context.Context) {
for {
@@ -126,16 +134,6 @@ 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)
@@ -216,14 +214,14 @@ func (w *Watcher) reloadConfig() {
w.reloadClients()
}
// reloadClients performs a full scan of the auth directory and reloads all clients.
// This is used for initial startup and for handling config file reloads.
// reloadClients performs a full scan and reload of all clients.
func (w *Watcher) reloadClients() {
log.Debugf("starting full client reload process")
w.clientsMutex.RLock()
cfg := w.config
oldClientCount := len(w.clients)
oldFileClientCount := len(w.clients)
oldAPIKeyClientCount := len(w.apiKeyClients)
w.clientsMutex.RUnlock()
if cfg == nil {
@@ -231,127 +229,50 @@ func (w *Watcher) reloadClients() {
return
}
log.Debugf("scanning auth directory for initial load or full reload: %s", cfg.AuthDir)
// Create new client map
newClients := make(map[string]interfaces.Client)
authFileCount := 0
successfulAuthCount := 0
// Handle tilde expansion for auth directory
if strings.HasPrefix(cfg.AuthDir, "~") {
home, errUserHomeDir := os.UserHomeDir()
if errUserHomeDir != nil {
log.Fatalf("failed to get home directory: %v", errUserHomeDir)
}
parts := strings.Split(cfg.AuthDir, string(os.PathSeparator))
if len(parts) > 1 {
parts[0] = home
cfg.AuthDir = path.Join(parts...)
} else {
cfg.AuthDir = home
// Unregister all old API key clients before creating new ones
log.Debugf("unregistering %d old API key clients", oldAPIKeyClientCount)
for _, oldClient := range w.apiKeyClients {
if u, ok := oldClient.(interface{ UnregisterClient() }); ok {
u.UnregisterClient()
}
}
// 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
}
if !info.IsDir() && strings.HasSuffix(info.Name(), ".json") {
authFileCount++
log.Debugf("processing auth file %d: %s", authFileCount, filepath.Base(path))
if cliClient, errCreateClientFromFile := w.createClientFromFile(path, cfg); errCreateClientFromFile == nil {
newClients[path] = cliClient
successfulAuthCount++
} else {
log.Errorf("failed to create client from file %s: %v", path, errCreateClientFromFile)
}
}
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)
// Create new API key clients based on the new config
newAPIKeyClients, glAPIKeyCount, claudeAPIKeyCount, codexAPIKeyCount, openAICompatCount := buildAPIKeyClients(cfg)
log.Debugf("created %d new API key clients", len(newAPIKeyClients))
// Note: API key-based clients are not stored in the map as they don't correspond to a file.
// They are re-created each time, which is lightweight.
clientSlice := w.clientsToSlice(newClients)
// Load file-based clients
newFileClients, successfulAuthCount := w.loadFileClients(cfg)
log.Debugf("loaded %d new file-based clients", len(newFileClients))
// 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 := util.SetProxy(cfg, &http.Client{})
log.Debugf("Initializing with Generative Language API Key %d...", i+1)
cliClient := client.NewGeminiClient(httpClient, cfg, cfg.GlAPIKey[i])
clientSlice = append(clientSlice, cliClient)
glAPIKeyCount++
}
log.Debugf("Successfully initialized %d Generative Language API Key clients", glAPIKeyCount)
}
// ... (Claude, Codex, OpenAI-compat clients are handled similarly) ...
claudeAPIKeyCount := 0
if len(cfg.ClaudeKey) > 0 {
log.Debugf("processing %d Claude API Keys", len(cfg.ClaudeKey))
for i := 0; i < len(cfg.ClaudeKey); i++ {
log.Debugf("Initializing with Claude API Key %d...", i+1)
cliClient := client.NewClaudeClientWithKey(cfg, i)
clientSlice = append(clientSlice, cliClient)
claudeAPIKeyCount++
}
log.Debugf("Successfully initialized %d Claude API Key clients", claudeAPIKeyCount)
}
codexAPIKeyCount := 0
if len(cfg.CodexKey) > 0 {
log.Debugf("processing %d Codex API Keys", len(cfg.CodexKey))
for i := 0; i < len(cfg.CodexKey); i++ {
log.Debugf("Initializing with Codex API Key %d...", i+1)
cliClient := client.NewCodexClientWithKey(cfg, i)
clientSlice = append(clientSlice, cliClient)
codexAPIKeyCount++
}
log.Debugf("Successfully initialized %d Codex API Key clients", codexAPIKeyCount)
}
openAICompatCount := 0
if len(cfg.OpenAICompatibility) > 0 {
log.Debugf("processing %d OpenAI-compatibility providers", len(cfg.OpenAICompatibility))
for i := 0; i < len(cfg.OpenAICompatibility); i++ {
compat := cfg.OpenAICompatibility[i]
compatClient, errClient := client.NewOpenAICompatibilityClient(cfg, &compat)
if errClient != nil {
log.Errorf(" failed to create OpenAI-compatibility client for %s: %v", compat.Name, errClient)
continue
}
clientSlice = append(clientSlice, compatClient)
openAICompatCount++
}
log.Debugf("Successfully initialized %d OpenAI-compatibility clients", openAICompatCount)
}
// Unregister all old clients
w.clientsMutex.RLock()
// Unregister all old file-based clients
log.Debugf("unregistering %d old file-based clients", oldFileClientCount)
for _, oldClient := range w.clients {
if u, ok := any(oldClient).(interface{ UnregisterClient() }); ok {
u.UnregisterClient()
}
}
w.clientsMutex.RUnlock()
// Update the client map
// Update client maps
w.clientsMutex.Lock()
w.clients = newClients
w.clients = newFileClients
w.apiKeyClients = newAPIKeyClients
// Rebuild auth file hash cache for current clients
w.lastAuthHashes = make(map[string]string, len(newFileClients))
for path := range newFileClients {
if data, err := os.ReadFile(path); err == nil && len(data) > 0 {
sum := sha256.Sum256(data)
w.lastAuthHashes[path] = hex.EncodeToString(sum[:])
}
}
w.clientsMutex.Unlock()
totalNewClients := len(newFileClients) + len(newAPIKeyClients)
log.Infof("full client reload complete - old: %d clients, new: %d clients (%d auth files + %d GL API keys + %d Claude API keys + %d Codex keys + %d OpenAI-compat)",
oldClientCount,
len(clientSlice),
oldFileClientCount+oldAPIKeyClientCount,
totalNewClients,
successfulAuthCount,
glAPIKeyCount,
claudeAPIKeyCount,
@@ -359,10 +280,10 @@ func (w *Watcher) reloadClients() {
openAICompatCount,
)
// Trigger the callback to update the server with file-based + API key clients
// Trigger the callback to update the server
if w.reloadCallback != nil {
log.Debugf("triggering server update callback")
combinedClients := w.buildCombinedClientMap(cfg)
combinedClients := w.buildCombinedClientMap()
w.reloadCallback(combinedClients, cfg)
}
}
@@ -430,15 +351,38 @@ func (w *Watcher) clientsToSlice(clientMap map[string]interfaces.Client) []inter
// addOrUpdateClient handles the addition or update of a single client.
func (w *Watcher) addOrUpdateClient(path string) {
w.clientsMutex.Lock()
defer w.clientsMutex.Unlock()
cfg := w.config
if cfg == nil {
log.Error("config is nil, cannot add or update client")
w.clientsMutex.Unlock()
return
}
// Unregister old client if it exists
// Read file to check for emptiness and calculate hash
data, errRead := os.ReadFile(path)
if errRead != nil {
log.Errorf("failed to read auth file %s: %v", filepath.Base(path), errRead)
w.clientsMutex.Unlock()
return
}
if len(data) == 0 {
// Empty file: ignore (wait for a subsequent WRITE)
log.Debugf("ignoring empty auth file: %s", filepath.Base(path))
w.clientsMutex.Unlock()
return
}
// Calculate a hash of the current content and compare with the cache
sum := sha256.Sum256(data)
curHash := hex.EncodeToString(sum[:])
if prev, ok := w.lastAuthHashes[path]; ok && prev == curHash {
log.Debugf("auth file unchanged (hash match), skipping reload: %s", filepath.Base(path))
w.clientsMutex.Unlock()
return
}
// If an old client exists, unregister it first
if oldClient, ok := w.clients[path]; ok {
if u, canUnregister := any(oldClient).(interface{ UnregisterClient() }); canUnregister {
log.Debugf("unregistering old client for updated file: %s", filepath.Base(path))
@@ -446,23 +390,32 @@ func (w *Watcher) addOrUpdateClient(path string) {
}
}
// Create new client (reads the file again internally; this is acceptable as the files are small and it keeps the change minimal)
newClient, err := w.createClientFromFile(path, cfg)
if err != nil {
log.Errorf("failed to create/update client for %s: %v", filepath.Base(path), err)
// If creation fails, ensure the old client is removed from the map
// If creation fails, ensure the old client is removed from the map; don't update hash, let a subsequent change retry
delete(w.clients, path)
} else if newClient != nil { // Only update if a client was actually created
log.Debugf("successfully created/updated client for %s", filepath.Base(path))
w.clients[path] = newClient
} else {
// This case handles the empty file scenario gracefully
log.Debugf("ignoring empty auth file: %s", filepath.Base(path))
return // Do not trigger callback for an empty file
w.clientsMutex.Unlock()
return
}
if newClient == nil {
// This branch should not be reached normally (empty files are handled above); a fallback
log.Debugf("ignoring auth file with no client created: %s", filepath.Base(path))
w.clientsMutex.Unlock()
return
}
// Update client and hash cache
log.Debugf("successfully created/updated client for %s", filepath.Base(path))
w.clients[path] = newClient
w.lastAuthHashes[path] = curHash
w.clientsMutex.Unlock() // Unlock before the callback
if w.reloadCallback != nil {
log.Debugf("triggering server update callback after add/update")
combinedClients := w.buildCombinedClientMap(cfg)
combinedClients := w.buildCombinedClientMap()
w.reloadCallback(combinedClients, cfg)
}
}
@@ -470,9 +423,9 @@ func (w *Watcher) addOrUpdateClient(path string) {
// removeClient handles the removal of a single client.
func (w *Watcher) removeClient(path string) {
w.clientsMutex.Lock()
defer w.clientsMutex.Unlock()
cfg := w.config
var clientRemoved bool
// Unregister client if it exists
if oldClient, ok := w.clients[path]; ok {
@@ -481,63 +434,121 @@ func (w *Watcher) removeClient(path string) {
u.UnregisterClient()
}
delete(w.clients, path)
delete(w.lastAuthHashes, path)
log.Debugf("removed client for %s", filepath.Base(path))
clientRemoved = true
}
if w.reloadCallback != nil {
log.Debugf("triggering server update callback after removal")
combinedClients := w.buildCombinedClientMap(cfg)
w.reloadCallback(combinedClients, cfg)
}
w.clientsMutex.Unlock() // Release the lock before the callback
if clientRemoved && w.reloadCallback != nil {
log.Debugf("triggering server update callback after removal")
combinedClients := w.buildCombinedClientMap()
w.reloadCallback(combinedClients, cfg)
}
}
// buildCombinedClientMap merges file-based clients with API key and compatibility clients.
// This ensures the callback receives the complete set of active clients.
func (w *Watcher) buildCombinedClientMap(cfg *config.Config) map[string]interfaces.Client {
// buildCombinedClientMap merges file-based clients with API key clients from the cache.
func (w *Watcher) buildCombinedClientMap() map[string]interfaces.Client {
w.clientsMutex.RLock()
defer w.clientsMutex.RUnlock()
combined := make(map[string]interfaces.Client)
// Include file-based clients
// Add file-based clients
for k, v := range w.clients {
combined[k] = v
}
// Add Generative Language API Key clients
if len(cfg.GlAPIKey) > 0 {
for i := 0; i < len(cfg.GlAPIKey); i++ {
httpClient := util.SetProxy(cfg, &http.Client{})
cliClient := client.NewGeminiClient(httpClient, cfg, cfg.GlAPIKey[i])
combined[fmt.Sprintf("apikey:gemini:%d", i)] = cliClient
}
}
// Add Claude API Key clients
if len(cfg.ClaudeKey) > 0 {
for i := 0; i < len(cfg.ClaudeKey); i++ {
cliClient := client.NewClaudeClientWithKey(cfg, i)
combined[fmt.Sprintf("apikey:claude:%d", i)] = cliClient
}
}
// Add Codex API Key clients
if len(cfg.CodexKey) > 0 {
for i := 0; i < len(cfg.CodexKey); i++ {
cliClient := client.NewCodexClientWithKey(cfg, i)
combined[fmt.Sprintf("apikey:codex:%d", i)] = cliClient
}
}
// Add OpenAI compatibility clients
if len(cfg.OpenAICompatibility) > 0 {
for i := 0; i < len(cfg.OpenAICompatibility); i++ {
compat := cfg.OpenAICompatibility[i]
compatClient, errClient := client.NewOpenAICompatibilityClient(cfg, &compat)
if errClient != nil {
log.Errorf("failed to create OpenAI-compatibility client for %s: %v", compat.Name, errClient)
continue
}
combined[fmt.Sprintf("openai-compat:%s:%d", compat.Name, i)] = compatClient
}
// Add cached API key-based clients
for k, v := range w.apiKeyClients {
combined[k] = v
}
return combined
}
// loadFileClients scans the auth directory and creates clients from .json files.
func (w *Watcher) loadFileClients(cfg *config.Config) (map[string]interfaces.Client, int) {
newClients := make(map[string]interfaces.Client)
authFileCount := 0
successfulAuthCount := 0
authDir := cfg.AuthDir
if strings.HasPrefix(authDir, "~") {
home, err := os.UserHomeDir()
if err != nil {
log.Errorf("failed to get home directory: %v", err)
return newClients, 0
}
authDir = filepath.Join(home, authDir[1:])
}
errWalk := filepath.Walk(authDir, func(path string, info fs.FileInfo, err error) error {
if err != nil {
log.Debugf("error accessing path %s: %v", path, err)
return err
}
if !info.IsDir() && strings.HasSuffix(info.Name(), ".json") {
authFileCount++
log.Debugf("processing auth file %d: %s", authFileCount, filepath.Base(path))
if cliClient, errCreate := w.createClientFromFile(path, cfg); errCreate == nil && cliClient != nil {
newClients[path] = cliClient
successfulAuthCount++
} else if errCreate != nil {
log.Errorf("failed to create client from file %s: %v", path, errCreate)
}
}
return nil
})
if errWalk != nil {
log.Errorf("error walking auth directory: %v", errWalk)
}
log.Debugf("auth directory scan complete - found %d .json files, %d successful authentications", authFileCount, successfulAuthCount)
return newClients, successfulAuthCount
}
// buildAPIKeyClients creates clients from API keys in the config.
func buildAPIKeyClients(cfg *config.Config) (map[string]interfaces.Client, int, int, int, int) {
apiKeyClients := make(map[string]interfaces.Client)
glAPIKeyCount := 0
claudeAPIKeyCount := 0
codexAPIKeyCount := 0
openAICompatCount := 0
if len(cfg.GlAPIKey) > 0 {
for _, key := range cfg.GlAPIKey {
httpClient := util.SetProxy(cfg, &http.Client{})
cliClient := client.NewGeminiClient(httpClient, cfg, key)
apiKeyClients[cliClient.GetClientID()] = cliClient
glAPIKeyCount++
}
}
if len(cfg.ClaudeKey) > 0 {
for i := range cfg.ClaudeKey {
cliClient := client.NewClaudeClientWithKey(cfg, i)
apiKeyClients[cliClient.GetClientID()] = cliClient
claudeAPIKeyCount++
}
}
if len(cfg.CodexKey) > 0 {
for i := range cfg.CodexKey {
cliClient := client.NewCodexClientWithKey(cfg, i)
apiKeyClients[cliClient.GetClientID()] = cliClient
codexAPIKeyCount++
}
}
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
}
apiKeyClients[compatClient.GetClientID()] = compatClient
openAICompatCount++
}
}
return apiKeyClients, glAPIKeyCount, claudeAPIKeyCount, codexAPIKeyCount, openAICompatCount
}