Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions .github/workflows/release.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,38 @@ jobs:
VERSION: ${{ env.VERSION }}
COMMIT: ${{ env.COMMIT }}
BUILD_DATE: ${{ env.BUILD_DATE }}

build-termux:
name: Build Termux (aarch64)
runs-on: ubuntu-24.04-arm
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Build in Termux Container
run: |
# Prepare metadata on host
VERSION=$(git describe --tags --always --dirty | sed 's/^v//')
COMMIT=$(git rev-parse --short HEAD)
BUILD_DATE=$(date -u +%Y-%m-%dT%H:%M:%SZ)

# Ensure the workspace is writable by the container
chmod -R 777 .

# Run the build inside Termux container
docker run --rm -v $(pwd):/workspace -w /workspace \
-e VERSION=$VERSION -e COMMIT=$COMMIT -e BUILD_DATE=$BUILD_DATE \
termux/termux-docker:aarch64 bash -c "
pkg update -y && pkg upgrade -y -o Dpkg::Options::='--force-confdef' -o Dpkg::Options::='--force-confold'
pkg install -y golang build-essential tar
CGO_ENABLED=0 go build -ldflags \"-s -w -X 'main.Version=\$VERSION' -X 'main.Commit=\$COMMIT' -X 'main.BuildDate=\$BUILD_DATE'\" -o cli-proxy-api ./cmd/server
tar -czf cli-proxy-api-termux-aarch64.tar.gz cli-proxy-api LICENSE README.md README_CN.md config.example.yaml
"
- name: Upload to Release
uses: softprops/action-gh-release@v2
if: startsWith(github.ref, 'refs/tags/')
with:
files: cli-proxy-api-termux-aarch64.tar.gz
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
Comment on lines +41 to +74
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Prevent release-publish race between goreleaser and build-termux.

Both jobs publish to the same tag release. Without a dependency chain, parallel execution can intermittently fail due to competing release create/update operations.

Suggested fix
   build-termux:
+    needs: goreleaser
     name: Build Termux (aarch64)
     runs-on: ubuntu-24.04-arm
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
build-termux:
name: Build Termux (aarch64)
runs-on: ubuntu-24.04-arm
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Build in Termux Container
run: |
# Prepare metadata on host
VERSION=$(git describe --tags --always --dirty | sed 's/^v//')
COMMIT=$(git rev-parse --short HEAD)
BUILD_DATE=$(date -u +%Y-%m-%dT%H:%M:%SZ)
# Ensure the workspace is writable by the container
chmod -R 777 .
# Run the build inside Termux container
docker run --rm -v $(pwd):/workspace -w /workspace \
-e VERSION=$VERSION -e COMMIT=$COMMIT -e BUILD_DATE=$BUILD_DATE \
termux/termux-docker:aarch64 bash -c "
pkg update -y && pkg upgrade -y -o Dpkg::Options::='--force-confdef' -o Dpkg::Options::='--force-confold'
pkg install -y golang build-essential tar
CGO_ENABLED=0 go build -ldflags \"-s -w -X 'main.Version=\$VERSION' -X 'main.Commit=\$COMMIT' -X 'main.BuildDate=\$BUILD_DATE'\" -o cli-proxy-api ./cmd/server
tar -czf cli-proxy-api-termux-aarch64.tar.gz cli-proxy-api LICENSE README.md README_CN.md config.example.yaml
"
- name: Upload to Release
uses: softprops/action-gh-release@v2
if: startsWith(github.ref, 'refs/tags/')
with:
files: cli-proxy-api-termux-aarch64.tar.gz
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
build-termux:
needs: goreleaser
name: Build Termux (aarch64)
runs-on: ubuntu-24.04-arm
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Build in Termux Container
run: |
# Prepare metadata on host
VERSION=$(git describe --tags --always --dirty | sed 's/^v//')
COMMIT=$(git rev-parse --short HEAD)
BUILD_DATE=$(date -u +%Y-%m-%dT%H:%M:%SZ)
# Ensure the workspace is writable by the container
chmod -R 777 .
# Run the build inside Termux container
docker run --rm -v $(pwd):/workspace -w /workspace \
-e VERSION=$VERSION -e COMMIT=$COMMIT -e BUILD_DATE=$BUILD_DATE \
termux/termux-docker:aarch64 bash -c "
pkg update -y && pkg upgrade -y -o Dpkg::Options::='--force-confdef' -o Dpkg::Options::='--force-confold'
pkg install -y golang build-essential tar
CGO_ENABLED=0 go build -ldflags \"-s -w -X 'main.Version=\$VERSION' -X 'main.Commit=\$COMMIT' -X 'main.BuildDate=\$BUILD_DATE'\" -o cli-proxy-api ./cmd/server
tar -czf cli-proxy-api-termux-aarch64.tar.gz cli-proxy-api LICENSE README.md README_CN.md config.example.yaml
"
- name: Upload to Release
uses: softprops/action-gh-release@v2
if: startsWith(github.ref, 'refs/tags/')
with:
files: cli-proxy-api-termux-aarch64.tar.gz
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.github/workflows/release.yaml around lines 41 - 74, The build-termux job
can race with the goreleaser job when both publish to the same release; to
prevent this add an explicit dependency so build-termux runs after the release
job (e.g., add needs: [goreleaser] to the build-termux job) or point it to the
actual job name that creates the GitHub release so the Termux build waits for
goreleaser to finish before running the Upload to Release step.

2 changes: 2 additions & 0 deletions internal/api/handlers/management/config_basic.go
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,8 @@ func normalizeRoutingStrategy(strategy string) (string, bool) {
return "round-robin", true
case "fill-first", "fillfirst", "ff":
return "fill-first", true
case "sticky-round-robin", "stickyroundrobin", "srr":
return "sticky-round-robin", true
default:
return "", false
}
Expand Down
2 changes: 1 addition & 1 deletion internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,7 @@ type QuotaExceeded struct {
// RoutingConfig configures how credentials are selected for requests.
type RoutingConfig struct {
// Strategy selects the credential selection strategy.
// Supported values: "round-robin" (default), "fill-first".
// Supported values: "round-robin" (default), "fill-first", "sticky-round-robin".
Strategy string `yaml:"strategy,omitempty" json:"strategy,omitempty"`
Comment on lines +211 to 212
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Sticky strategy is documented, but env override still rejects it.

RoutingConfig.Strategy now lists "sticky-round-robin" as supported, but ApplyEnvOverrides does not accept it for CLIPROXY_ROUTING_STRATEGY. Env-based deployments cannot enable the new strategy.

Suggested fix
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@
 	if val := os.Getenv("CLIPROXY_ROUTING_STRATEGY"); val != "" {
 		normalized := strings.ToLower(strings.TrimSpace(val))
 		switch normalized {
 		case "round-robin", "roundrobin", "rr":
 			cfg.Routing.Strategy = "round-robin"
 			log.Info("Applied CLIPROXY_ROUTING_STRATEGY override: round-robin")
 		case "fill-first", "fillfirst", "ff":
 			cfg.Routing.Strategy = "fill-first"
 			log.Info("Applied CLIPROXY_ROUTING_STRATEGY override: fill-first")
+		case "sticky-round-robin", "stickyroundrobin", "srr":
+			cfg.Routing.Strategy = "sticky-round-robin"
+			log.Info("Applied CLIPROXY_ROUTING_STRATEGY override: sticky-round-robin")
 		default:
 			log.WithField("value", val).Warn("Invalid CLIPROXY_ROUTING_STRATEGY value, ignoring")
 		}
 	}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/config/config.go` around lines 211 - 212, RoutingConfig.Strategy
documents "sticky-round-robin" but ApplyEnvOverrides still rejects that value
when reading CLIPROXY_ROUTING_STRATEGY; update ApplyEnvOverrides to accept
"sticky-round-robin" (in addition to "round-robin", "fill-first") when
validating/setting RoutingConfig.Strategy so env-based deployments can enable
the new strategy; locate the validation/branch that checks
CLIPROXY_ROUTING_STRATEGY in ApplyEnvOverrides and add the "sticky-round-robin"
case (or treat unknown/empty values appropriately) and ensure the config is set
to the provided env value.

}

Expand Down
108 changes: 88 additions & 20 deletions internal/runtime/executor/antigravity_executor.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,78 @@ const (
var (
randSource = rand.New(rand.NewSource(time.Now().UnixNano()))
randSourceMutex sync.Mutex
// antigravityPrimaryModelsCache keeps the latest non-empty model list fetched
// from any antigravity auth. Empty fetches never overwrite this cache.
antigravityPrimaryModelsCache struct {
mu sync.RWMutex
models []*registry.ModelInfo
}
)

func cloneAntigravityModels(models []*registry.ModelInfo) []*registry.ModelInfo {
if len(models) == 0 {
return nil
}
out := make([]*registry.ModelInfo, 0, len(models))
for _, model := range models {
if model == nil || strings.TrimSpace(model.ID) == "" {
continue
}
out = append(out, cloneAntigravityModelInfo(model))
}
if len(out) == 0 {
return nil
}
return out
}

func cloneAntigravityModelInfo(model *registry.ModelInfo) *registry.ModelInfo {
if model == nil {
return nil
}
clone := *model
if len(model.SupportedGenerationMethods) > 0 {
clone.SupportedGenerationMethods = append([]string(nil), model.SupportedGenerationMethods...)
}
if len(model.SupportedParameters) > 0 {
clone.SupportedParameters = append([]string(nil), model.SupportedParameters...)
}
if model.Thinking != nil {
thinkingClone := *model.Thinking
if len(model.Thinking.Levels) > 0 {
thinkingClone.Levels = append([]string(nil), model.Thinking.Levels...)
}
clone.Thinking = &thinkingClone
}
return &clone
Comment on lines +82 to +100
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Deep copy is incomplete for SupportedEndpoints.

cloneAntigravityModelInfo copies SupportedGenerationMethods and SupportedParameters, but not SupportedEndpoints. If that field is populated, callers can still mutate shared backing data.

♻️ Proposed fix
 func cloneAntigravityModelInfo(model *registry.ModelInfo) *registry.ModelInfo {
 	if model == nil {
 		return nil
 	}
 	clone := *model
 	if len(model.SupportedGenerationMethods) > 0 {
 		clone.SupportedGenerationMethods = append([]string(nil), model.SupportedGenerationMethods...)
 	}
 	if len(model.SupportedParameters) > 0 {
 		clone.SupportedParameters = append([]string(nil), model.SupportedParameters...)
 	}
+	if len(model.SupportedEndpoints) > 0 {
+		clone.SupportedEndpoints = append([]string(nil), model.SupportedEndpoints...)
+	}
 	if model.Thinking != nil {
 		thinkingClone := *model.Thinking
 		if len(model.Thinking.Levels) > 0 {
 			thinkingClone.Levels = append([]string(nil), model.Thinking.Levels...)
 		}
 		clone.Thinking = &thinkingClone
 	}
 	return &clone
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/runtime/executor/antigravity_executor.go` around lines 82 - 100,
cloneAntigravityModelInfo currently deep-copies SupportedGenerationMethods,
SupportedParameters and Thinking but misses SupportedEndpoints, leaving callers
able to mutate shared slice data; update cloneAntigravityModelInfo to deep-copy
model.SupportedEndpoints (e.g., when len(model.SupportedEndpoints) > 0 set
clone.SupportedEndpoints = append([]string(nil), model.SupportedEndpoints...))
alongside the existing slice copies so the returned *registry.ModelInfo has its
own backing slice for SupportedEndpoints.

}

func storeAntigravityPrimaryModels(models []*registry.ModelInfo) bool {
cloned := cloneAntigravityModels(models)
if len(cloned) == 0 {
return false
}
antigravityPrimaryModelsCache.mu.Lock()
antigravityPrimaryModelsCache.models = cloned
antigravityPrimaryModelsCache.mu.Unlock()
return true
}

func loadAntigravityPrimaryModels() []*registry.ModelInfo {
antigravityPrimaryModelsCache.mu.RLock()
cloned := cloneAntigravityModels(antigravityPrimaryModelsCache.models)
antigravityPrimaryModelsCache.mu.RUnlock()
return cloned
}

func fallbackAntigravityPrimaryModels() []*registry.ModelInfo {
models := loadAntigravityPrimaryModels()
if len(models) > 0 {
log.Debugf("antigravity executor: using cached primary model list (%d models)", len(models))
}
return models
}

// AntigravityExecutor proxies requests to the antigravity upstream.
type AntigravityExecutor struct {
cfg *config.Config
Expand Down Expand Up @@ -1006,13 +1076,8 @@ func (e *AntigravityExecutor) CountTokens(ctx context.Context, auth *cliproxyaut
func FetchAntigravityModels(ctx context.Context, auth *cliproxyauth.Auth, cfg *config.Config) []*registry.ModelInfo {
exec := &AntigravityExecutor{cfg: cfg}
token, updatedAuth, errToken := exec.ensureAccessToken(ctx, auth)
if errToken != nil {
log.Warnf("antigravity executor: fetch models failed for %s: token error: %v", auth.ID, errToken)
return nil
}
if token == "" {
log.Warnf("antigravity executor: fetch models failed for %s: got empty token", auth.ID)
return nil
if errToken != nil || token == "" {
return fallbackAntigravityPrimaryModels()
}
if updatedAuth != nil {
auth = updatedAuth
Expand All @@ -1025,8 +1090,7 @@ func FetchAntigravityModels(ctx context.Context, auth *cliproxyauth.Auth, cfg *c
modelsURL := baseURL + antigravityModelsPath
httpReq, errReq := http.NewRequestWithContext(ctx, http.MethodPost, modelsURL, bytes.NewReader([]byte(`{}`)))
if errReq != nil {
log.Warnf("antigravity executor: fetch models failed for %s: create request error: %v", auth.ID, errReq)
return nil
return fallbackAntigravityPrimaryModels()
}
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("Authorization", "Bearer "+token)
Expand All @@ -1038,15 +1102,13 @@ func FetchAntigravityModels(ctx context.Context, auth *cliproxyauth.Auth, cfg *c
httpResp, errDo := httpClient.Do(httpReq)
if errDo != nil {
if errors.Is(errDo, context.Canceled) || errors.Is(errDo, context.DeadlineExceeded) {
log.Warnf("antigravity executor: fetch models failed for %s: context canceled: %v", auth.ID, errDo)
return nil
return fallbackAntigravityPrimaryModels()
}
if idx+1 < len(baseURLs) {
log.Debugf("antigravity executor: models request error on base url %s, retrying with fallback base url: %s", baseURL, baseURLs[idx+1])
continue
}
log.Warnf("antigravity executor: fetch models failed for %s: request error: %v", auth.ID, errDo)
return nil
return fallbackAntigravityPrimaryModels()
}

bodyBytes, errRead := io.ReadAll(httpResp.Body)
Expand All @@ -1058,22 +1120,27 @@ func FetchAntigravityModels(ctx context.Context, auth *cliproxyauth.Auth, cfg *c
log.Debugf("antigravity executor: models read error on base url %s, retrying with fallback base url: %s", baseURL, baseURLs[idx+1])
continue
}
log.Warnf("antigravity executor: fetch models failed for %s: read body error: %v", auth.ID, errRead)
return nil
return fallbackAntigravityPrimaryModels()
}
if httpResp.StatusCode < http.StatusOK || httpResp.StatusCode >= http.StatusMultipleChoices {
if httpResp.StatusCode == http.StatusTooManyRequests && idx+1 < len(baseURLs) {
log.Debugf("antigravity executor: models request rate limited on base url %s, retrying with fallback base url: %s", baseURL, baseURLs[idx+1])
continue
}
log.Warnf("antigravity executor: fetch models failed for %s: unexpected status %d, body: %s", auth.ID, httpResp.StatusCode, string(bodyBytes))
return nil
if idx+1 < len(baseURLs) {
log.Debugf("antigravity executor: models request failed with status %d on base url %s, retrying with fallback base url: %s", httpResp.StatusCode, baseURL, baseURLs[idx+1])
continue
}
return fallbackAntigravityPrimaryModels()
}

result := gjson.GetBytes(bodyBytes, "models")
if !result.Exists() {
log.Warnf("antigravity executor: fetch models failed for %s: no models field in response, body: %s", auth.ID, string(bodyBytes))
return nil
if idx+1 < len(baseURLs) {
log.Debugf("antigravity executor: models field missing on base url %s, retrying with fallback base url: %s", baseURL, baseURLs[idx+1])
continue
}
return fallbackAntigravityPrimaryModels()
}

now := time.Now().Unix()
Expand Down Expand Up @@ -1118,9 +1185,10 @@ func FetchAntigravityModels(ctx context.Context, auth *cliproxyauth.Auth, cfg *c
}
models = append(models, modelInfo)
}
storeAntigravityPrimaryModels(models)
return models
}
return nil
return fallbackAntigravityPrimaryModels()
}

func (e *AntigravityExecutor) ensureAccessToken(ctx context.Context, auth *cliproxyauth.Auth) (string, *cliproxyauth.Auth, error) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
package executor

import (
"testing"

"github.com/kooshapari/cliproxyapi-plusplus/v6/internal/registry"
)

func resetAntigravityPrimaryModelsCacheForTest() {
antigravityPrimaryModelsCache.mu.Lock()
antigravityPrimaryModelsCache.models = nil
antigravityPrimaryModelsCache.mu.Unlock()
}

func TestStoreAntigravityPrimaryModels_EmptyDoesNotOverwrite(t *testing.T) {
resetAntigravityPrimaryModelsCacheForTest()
t.Cleanup(resetAntigravityPrimaryModelsCacheForTest)

seed := []*registry.ModelInfo{
{ID: "claude-sonnet-4-5"},
{ID: "gemini-2.5-pro"},
}
if updated := storeAntigravityPrimaryModels(seed); !updated {
t.Fatal("expected non-empty model list to update primary cache")
}

if updated := storeAntigravityPrimaryModels(nil); updated {
t.Fatal("expected nil model list not to overwrite primary cache")
}
if updated := storeAntigravityPrimaryModels([]*registry.ModelInfo{}); updated {
t.Fatal("expected empty model list not to overwrite primary cache")
}

got := loadAntigravityPrimaryModels()
if len(got) != 2 {
t.Fatalf("expected cached model count 2, got %d", len(got))
}
if got[0].ID != "claude-sonnet-4-5" || got[1].ID != "gemini-2.5-pro" {
t.Fatalf("unexpected cached model ids: %q, %q", got[0].ID, got[1].ID)
}
}

func TestLoadAntigravityPrimaryModels_ReturnsClone(t *testing.T) {
resetAntigravityPrimaryModelsCacheForTest()
t.Cleanup(resetAntigravityPrimaryModelsCacheForTest)

if updated := storeAntigravityPrimaryModels([]*registry.ModelInfo{{
ID: "gpt-5",
DisplayName: "GPT-5",
SupportedGenerationMethods: []string{"generateContent"},
SupportedParameters: []string{"temperature"},
Thinking: &registry.ThinkingSupport{
Levels: []string{"high"},
},
}}); !updated {
t.Fatal("expected model cache update")
}

got := loadAntigravityPrimaryModels()
if len(got) != 1 {
t.Fatalf("expected one cached model, got %d", len(got))
}
got[0].ID = "mutated-id"
if len(got[0].SupportedGenerationMethods) > 0 {
got[0].SupportedGenerationMethods[0] = "mutated-method"
}
if len(got[0].SupportedParameters) > 0 {
got[0].SupportedParameters[0] = "mutated-parameter"
}
if got[0].Thinking != nil && len(got[0].Thinking.Levels) > 0 {
got[0].Thinking.Levels[0] = "mutated-level"
}

again := loadAntigravityPrimaryModels()
if len(again) != 1 {
t.Fatalf("expected one cached model after mutation, got %d", len(again))
}
if again[0].ID != "gpt-5" {
t.Fatalf("expected cached model id to remain %q, got %q", "gpt-5", again[0].ID)
}
if len(again[0].SupportedGenerationMethods) == 0 || again[0].SupportedGenerationMethods[0] != "generateContent" {
t.Fatalf("expected cached generation methods to be unmutated, got %v", again[0].SupportedGenerationMethods)
}
if len(again[0].SupportedParameters) == 0 || again[0].SupportedParameters[0] != "temperature" {
t.Fatalf("expected cached supported parameters to be unmutated, got %v", again[0].SupportedParameters)
}
if again[0].Thinking == nil || len(again[0].Thinking.Levels) == 0 || again[0].Thinking.Levels[0] != "high" {
t.Fatalf("expected cached model thinking levels to be unmutated, got %v", again[0].Thinking)
}
}
Loading