-
Notifications
You must be signed in to change notification settings - Fork 2
Replay: 12 upstream features (routing, retries, schema fixes) #696
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
0f95eec
1a16404
4b1f611
5d9ba9d
bf47c0f
0868605
843434d
288f1c1
d26ed4a
5562777
a5c6845
8ddc791
7aa5aac
33c4db4
654e324
c8ce1b6
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Sticky strategy is documented, but env override still rejects it.
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 |
||
| } | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Deep copy is incomplete for
♻️ 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 |
||
| } | ||
|
|
||
| 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 | ||
|
|
@@ -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 | ||
|
|
@@ -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) | ||
|
|
@@ -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) | ||
|
|
@@ -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() | ||
|
|
@@ -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) { | ||
|
|
||
| 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: ®istry.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) | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Prevent release-publish race between
goreleaserandbuild-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
🤖 Prompt for AI Agents