diff --git a/go.mod b/go.mod index 7f07c00e..dbc18249 100644 --- a/go.mod +++ b/go.mod @@ -71,7 +71,6 @@ require ( golang.org/x/arch v0.8.0 // indirect golang.org/x/sync v0.18.0 // indirect golang.org/x/sys v0.38.0 // indirect - golang.org/x/text v0.31.0 // indirect google.golang.org/protobuf v1.34.1 // indirect gopkg.in/ini.v1 v1.67.0 // indirect ) diff --git a/internal/api/handlers/management/auth_files.go b/internal/api/handlers/management/auth_files.go index f25aabba..0c41137f 100644 --- a/internal/api/handlers/management/auth_files.go +++ b/internal/api/handlers/management/auth_files.go @@ -753,6 +753,72 @@ func (h *Handler) registerAuthFromFile(ctx context.Context, path string, data [] return err } +// PatchAuthFileStatus toggles the disabled state of an auth file +func (h *Handler) PatchAuthFileStatus(c *gin.Context) { + if h.authManager == nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "core auth manager unavailable"}) + return + } + + var req struct { + Name string `json:"name"` + Disabled *bool `json:"disabled"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"}) + return + } + + name := strings.TrimSpace(req.Name) + if name == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"}) + return + } + if req.Disabled == nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "disabled is required"}) + return + } + + ctx := c.Request.Context() + + // Find auth by name or ID + var targetAuth *coreauth.Auth + if auth, ok := h.authManager.GetByID(name); ok { + targetAuth = auth + } else { + auths := h.authManager.List() + for _, auth := range auths { + if auth.FileName == name { + targetAuth = auth + break + } + } + } + + if targetAuth == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "auth file not found"}) + return + } + + // Update disabled state + targetAuth.Disabled = *req.Disabled + if *req.Disabled { + targetAuth.Status = coreauth.StatusDisabled + targetAuth.StatusMessage = "disabled via management API" + } else { + targetAuth.Status = coreauth.StatusActive + targetAuth.StatusMessage = "" + } + targetAuth.UpdatedAt = time.Now() + + if _, err := h.authManager.Update(ctx, targetAuth); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to update auth: %v", err)}) + return + } + + c.JSON(http.StatusOK, gin.H{"status": "ok", "disabled": *req.Disabled}) +} + func (h *Handler) disableAuth(ctx context.Context, id string) { if h == nil || h.authManager == nil { return diff --git a/internal/api/server.go b/internal/api/server.go index 9ad1ea50..fa4eb8b7 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -636,6 +636,7 @@ func (s *Server) registerManagementRoutes() { mgmt.GET("/auth-files/download", s.mgmt.DownloadAuthFile) mgmt.POST("/auth-files", s.mgmt.UploadAuthFile) mgmt.DELETE("/auth-files", s.mgmt.DeleteAuthFile) + mgmt.PATCH("/auth-files/status", s.mgmt.PatchAuthFileStatus) mgmt.POST("/vertex/import", s.mgmt.ImportVertexCredential) mgmt.GET("/anthropic-auth-url", s.mgmt.RequestAnthropicToken) diff --git a/internal/auth/codex/filename.go b/internal/auth/codex/filename.go index 26515fef..fdac5a40 100644 --- a/internal/auth/codex/filename.go +++ b/internal/auth/codex/filename.go @@ -4,9 +4,6 @@ import ( "fmt" "strings" "unicode" - - "golang.org/x/text/cases" - "golang.org/x/text/language" ) // CredentialFileName returns the filename used to persist Codex OAuth credentials. @@ -43,15 +40,7 @@ func normalizePlanTypeForFilename(planType string) string { } for i, part := range parts { - parts[i] = titleToken(part) + parts[i] = strings.ToLower(strings.TrimSpace(part)) } return strings.Join(parts, "-") } - -func titleToken(token string) string { - token = strings.TrimSpace(token) - if token == "" { - return "" - } - return cases.Title(language.English).String(token) -} diff --git a/internal/cache/signature_cache.go b/internal/cache/signature_cache.go index ea98f8a0..af5371bf 100644 --- a/internal/cache/signature_cache.go +++ b/internal/cache/signature_cache.go @@ -3,7 +3,6 @@ package cache import ( "crypto/sha256" "encoding/hex" - "fmt" "strings" "sync" "time" @@ -25,18 +24,18 @@ const ( // MinValidSignatureLen is the minimum length for a signature to be considered valid MinValidSignatureLen = 50 - // SessionCleanupInterval controls how often stale sessions are purged - SessionCleanupInterval = 10 * time.Minute + // CacheCleanupInterval controls how often stale entries are purged + CacheCleanupInterval = 10 * time.Minute ) -// signatureCache stores signatures by sessionId -> textHash -> SignatureEntry +// signatureCache stores signatures by model group -> textHash -> SignatureEntry var signatureCache sync.Map -// sessionCleanupOnce ensures the background cleanup goroutine starts only once -var sessionCleanupOnce sync.Once +// cacheCleanupOnce ensures the background cleanup goroutine starts only once +var cacheCleanupOnce sync.Once -// sessionCache is the inner map type -type sessionCache struct { +// groupCache is the inner map type +type groupCache struct { mu sync.RWMutex entries map[string]SignatureEntry } @@ -47,36 +46,36 @@ func hashText(text string) string { return hex.EncodeToString(h[:])[:SignatureTextHashLen] } -// getOrCreateSession gets or creates a session cache -func getOrCreateSession(sessionID string) *sessionCache { +// getOrCreateGroupCache gets or creates a cache bucket for a model group +func getOrCreateGroupCache(groupKey string) *groupCache { // Start background cleanup on first access - sessionCleanupOnce.Do(startSessionCleanup) + cacheCleanupOnce.Do(startCacheCleanup) - if val, ok := signatureCache.Load(sessionID); ok { - return val.(*sessionCache) + if val, ok := signatureCache.Load(groupKey); ok { + return val.(*groupCache) } - sc := &sessionCache{entries: make(map[string]SignatureEntry)} - actual, _ := signatureCache.LoadOrStore(sessionID, sc) - return actual.(*sessionCache) + sc := &groupCache{entries: make(map[string]SignatureEntry)} + actual, _ := signatureCache.LoadOrStore(groupKey, sc) + return actual.(*groupCache) } -// startSessionCleanup launches a background goroutine that periodically -// removes sessions where all entries have expired. -func startSessionCleanup() { +// startCacheCleanup launches a background goroutine that periodically +// removes caches where all entries have expired. +func startCacheCleanup() { go func() { - ticker := time.NewTicker(SessionCleanupInterval) + ticker := time.NewTicker(CacheCleanupInterval) defer ticker.Stop() for range ticker.C { - purgeExpiredSessions() + purgeExpiredCaches() } }() } -// purgeExpiredSessions removes sessions with no valid (non-expired) entries. -func purgeExpiredSessions() { +// purgeExpiredCaches removes caches with no valid (non-expired) entries. +func purgeExpiredCaches() { now := time.Now() signatureCache.Range(func(key, value any) bool { - sc := value.(*sessionCache) + sc := value.(*groupCache) sc.mu.Lock() // Remove expired entries for k, entry := range sc.entries { @@ -86,7 +85,7 @@ func purgeExpiredSessions() { } isEmpty := len(sc.entries) == 0 sc.mu.Unlock() - // Remove session if empty + // Remove cache bucket if empty if isEmpty { signatureCache.Delete(key) } @@ -94,7 +93,7 @@ func purgeExpiredSessions() { }) } -// CacheSignature stores a thinking signature for a given session and text. +// CacheSignature stores a thinking signature for a given model group and text. // Used for Claude models that require signed thinking blocks in multi-turn conversations. func CacheSignature(modelName, text, signature string) { if text == "" || signature == "" { @@ -104,9 +103,9 @@ func CacheSignature(modelName, text, signature string) { return } - text = fmt.Sprintf("%s#%s", GetModelGroup(modelName), text) + groupKey := GetModelGroup(modelName) textHash := hashText(text) - sc := getOrCreateSession(textHash) + sc := getOrCreateGroupCache(groupKey) sc.mu.Lock() defer sc.mu.Unlock() @@ -116,26 +115,25 @@ func CacheSignature(modelName, text, signature string) { } } -// GetCachedSignature retrieves a cached signature for a given session and text. +// GetCachedSignature retrieves a cached signature for a given model group and text. // Returns empty string if not found or expired. func GetCachedSignature(modelName, text string) string { - family := GetModelGroup(modelName) + groupKey := GetModelGroup(modelName) if text == "" { - if family == "gemini" { + if groupKey == "gemini" { return "skip_thought_signature_validator" } return "" } - text = fmt.Sprintf("%s#%s", GetModelGroup(modelName), text) - val, ok := signatureCache.Load(hashText(text)) + val, ok := signatureCache.Load(groupKey) if !ok { - if family == "gemini" { + if groupKey == "gemini" { return "skip_thought_signature_validator" } return "" } - sc := val.(*sessionCache) + sc := val.(*groupCache) textHash := hashText(text) @@ -145,7 +143,7 @@ func GetCachedSignature(modelName, text string) string { entry, exists := sc.entries[textHash] if !exists { sc.mu.Unlock() - if family == "gemini" { + if groupKey == "gemini" { return "skip_thought_signature_validator" } return "" @@ -153,7 +151,7 @@ func GetCachedSignature(modelName, text string) string { if now.Sub(entry.Timestamp) > SignatureCacheTTL { delete(sc.entries, textHash) sc.mu.Unlock() - if family == "gemini" { + if groupKey == "gemini" { return "skip_thought_signature_validator" } return "" @@ -167,22 +165,17 @@ func GetCachedSignature(modelName, text string) string { return entry.Signature } -// ClearSignatureCache clears signature cache for a specific session or all sessions. -func ClearSignatureCache(sessionID string) { - if sessionID != "" { - signatureCache.Range(func(key, _ any) bool { - kStr, ok := key.(string) - if ok && strings.HasSuffix(kStr, "#"+sessionID) { - signatureCache.Delete(key) - } - return true - }) - } else { +// ClearSignatureCache clears signature cache for a specific model group or all groups. +func ClearSignatureCache(modelName string) { + if modelName == "" { signatureCache.Range(func(key, _ any) bool { signatureCache.Delete(key) return true }) + return } + groupKey := GetModelGroup(modelName) + signatureCache.Delete(groupKey) } // HasValidSignature checks if a signature is valid (non-empty and long enough) diff --git a/internal/cache/signature_cache_test.go b/internal/cache/signature_cache_test.go index 9388c2e0..83408159 100644 --- a/internal/cache/signature_cache_test.go +++ b/internal/cache/signature_cache_test.go @@ -5,6 +5,8 @@ import ( "time" ) +const testModelName = "claude-sonnet-4-5" + func TestCacheSignature_BasicStorageAndRetrieval(t *testing.T) { ClearSignatureCache("") @@ -12,30 +14,31 @@ func TestCacheSignature_BasicStorageAndRetrieval(t *testing.T) { signature := "abc123validSignature1234567890123456789012345678901234567890" // Store signature - CacheSignature("test-model", text, signature) + CacheSignature(testModelName, text, signature) // Retrieve signature - retrieved := GetCachedSignature("test-model", text) + retrieved := GetCachedSignature(testModelName, text) if retrieved != signature { t.Errorf("Expected signature '%s', got '%s'", signature, retrieved) } } -func TestCacheSignature_DifferentSessions(t *testing.T) { +func TestCacheSignature_DifferentModelGroups(t *testing.T) { ClearSignatureCache("") - text := "Same text in different sessions" + text := "Same text across models" sig1 := "signature1_1234567890123456789012345678901234567890123456" sig2 := "signature2_1234567890123456789012345678901234567890123456" - CacheSignature("test-model", text, sig1) - CacheSignature("test-model", text, sig2) + geminiModel := "gemini-3-pro-preview" + CacheSignature(testModelName, text, sig1) + CacheSignature(geminiModel, text, sig2) - if GetCachedSignature("test-model", text) != sig1 { - t.Error("Session-a signature mismatch") + if GetCachedSignature(testModelName, text) != sig1 { + t.Error("Claude signature mismatch") } - if GetCachedSignature("test-model", text) != sig2 { - t.Error("Session-b signature mismatch") + if GetCachedSignature(geminiModel, text) != sig2 { + t.Error("Gemini signature mismatch") } } @@ -43,13 +46,13 @@ func TestCacheSignature_NotFound(t *testing.T) { ClearSignatureCache("") // Non-existent session - if got := GetCachedSignature("test-model", "some text"); got != "" { + if got := GetCachedSignature(testModelName, "some text"); got != "" { t.Errorf("Expected empty string for nonexistent session, got '%s'", got) } // Existing session but different text - CacheSignature("test-model", "text-a", "sigA12345678901234567890123456789012345678901234567890") - if got := GetCachedSignature("test-model", "text-b"); got != "" { + CacheSignature(testModelName, "text-a", "sigA12345678901234567890123456789012345678901234567890") + if got := GetCachedSignature(testModelName, "text-b"); got != "" { t.Errorf("Expected empty string for different text, got '%s'", got) } } @@ -58,12 +61,11 @@ func TestCacheSignature_EmptyInputs(t *testing.T) { ClearSignatureCache("") // All empty/invalid inputs should be no-ops - CacheSignature("test-model", "text", "sig12345678901234567890123456789012345678901234567890") - CacheSignature("test-model", "", "sig12345678901234567890123456789012345678901234567890") - CacheSignature("test-model", "text", "") - CacheSignature("test-model", "text", "short") // Too short + CacheSignature(testModelName, "", "sig12345678901234567890123456789012345678901234567890") + CacheSignature(testModelName, "text", "") + CacheSignature(testModelName, "text", "short") // Too short - if got := GetCachedSignature("test-model", "text"); got != "" { + if got := GetCachedSignature(testModelName, "text"); got != "" { t.Errorf("Expected empty after invalid cache attempts, got '%s'", got) } } @@ -74,27 +76,24 @@ func TestCacheSignature_ShortSignatureRejected(t *testing.T) { text := "Some text" shortSig := "abc123" // Less than 50 chars - CacheSignature("test-model", text, shortSig) + CacheSignature(testModelName, text, shortSig) - if got := GetCachedSignature("test-model", text); got != "" { + if got := GetCachedSignature(testModelName, text); got != "" { t.Errorf("Short signature should be rejected, got '%s'", got) } } -func TestClearSignatureCache_SpecificSession(t *testing.T) { +func TestClearSignatureCache_ModelGroup(t *testing.T) { ClearSignatureCache("") sig := "validSig1234567890123456789012345678901234567890123456" - CacheSignature("test-model", "text", sig) - CacheSignature("test-model", "text", sig) + CacheSignature(testModelName, "text", sig) + CacheSignature(testModelName, "text-2", sig) ClearSignatureCache("session-1") - if got := GetCachedSignature("test-model", "text"); got != "" { - t.Error("session-1 should be cleared") - } - if got := GetCachedSignature("test-model", "text"); got != sig { - t.Error("session-2 should still exist") + if got := GetCachedSignature(testModelName, "text"); got != sig { + t.Error("signature should remain when clearing unknown session") } } @@ -102,35 +101,37 @@ func TestClearSignatureCache_AllSessions(t *testing.T) { ClearSignatureCache("") sig := "validSig1234567890123456789012345678901234567890123456" - CacheSignature("test-model", "text", sig) - CacheSignature("test-model", "text", sig) + CacheSignature(testModelName, "text", sig) + CacheSignature(testModelName, "text-2", sig) ClearSignatureCache("") - if got := GetCachedSignature("test-model", "text"); got != "" { - t.Error("session-1 should be cleared") + if got := GetCachedSignature(testModelName, "text"); got != "" { + t.Error("text should be cleared") } - if got := GetCachedSignature("test-model", "text"); got != "" { - t.Error("session-2 should be cleared") + if got := GetCachedSignature(testModelName, "text-2"); got != "" { + t.Error("text-2 should be cleared") } } func TestHasValidSignature(t *testing.T) { tests := []struct { name string + modelName string signature string expected bool }{ - {"valid long signature", "abc123validSignature1234567890123456789012345678901234567890", true}, - {"exactly 50 chars", "12345678901234567890123456789012345678901234567890", true}, - {"49 chars - invalid", "1234567890123456789012345678901234567890123456789", false}, - {"empty string", "", false}, - {"short signature", "abc", false}, + {"valid long signature", testModelName, "abc123validSignature1234567890123456789012345678901234567890", true}, + {"exactly 50 chars", testModelName, "12345678901234567890123456789012345678901234567890", true}, + {"49 chars - invalid", testModelName, "1234567890123456789012345678901234567890123456789", false}, + {"empty string", testModelName, "", false}, + {"short signature", testModelName, "abc", false}, + {"gemini sentinel", "gemini-3-pro-preview", "skip_thought_signature_validator", true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result := HasValidSignature("claude-sonnet-4-5-thinking", tt.signature) + result := HasValidSignature(tt.modelName, tt.signature) if result != tt.expected { t.Errorf("HasValidSignature(%q) = %v, expected %v", tt.signature, result, tt.expected) } @@ -147,13 +148,13 @@ func TestCacheSignature_TextHashCollisionResistance(t *testing.T) { sig1 := "signature1_1234567890123456789012345678901234567890123456" sig2 := "signature2_1234567890123456789012345678901234567890123456" - CacheSignature("test-model", text1, sig1) - CacheSignature("test-model", text2, sig2) + CacheSignature(testModelName, text1, sig1) + CacheSignature(testModelName, text2, sig2) - if GetCachedSignature("test-model", text1) != sig1 { + if GetCachedSignature(testModelName, text1) != sig1 { t.Error("text1 signature mismatch") } - if GetCachedSignature("test-model", text2) != sig2 { + if GetCachedSignature(testModelName, text2) != sig2 { t.Error("text2 signature mismatch") } } @@ -164,9 +165,9 @@ func TestCacheSignature_UnicodeText(t *testing.T) { text := "한글 텍스트와 이모지 🎉 그리고 特殊文字" sig := "unicodeSig123456789012345678901234567890123456789012345" - CacheSignature("test-model", text, sig) + CacheSignature(testModelName, text, sig) - if got := GetCachedSignature("test-model", text); got != sig { + if got := GetCachedSignature(testModelName, text); got != sig { t.Errorf("Unicode text signature retrieval failed, got '%s'", got) } } @@ -178,10 +179,10 @@ func TestCacheSignature_Overwrite(t *testing.T) { sig1 := "firstSignature12345678901234567890123456789012345678901" sig2 := "secondSignature1234567890123456789012345678901234567890" - CacheSignature("test-model", text, sig1) - CacheSignature("test-model", text, sig2) // Overwrite + CacheSignature(testModelName, text, sig1) + CacheSignature(testModelName, text, sig2) // Overwrite - if got := GetCachedSignature("test-model", text); got != sig2 { + if got := GetCachedSignature(testModelName, text); got != sig2 { t.Errorf("Expected overwritten signature '%s', got '%s'", sig2, got) } } @@ -196,10 +197,10 @@ func TestCacheSignature_ExpirationLogic(t *testing.T) { text := "text" sig := "validSig1234567890123456789012345678901234567890123456" - CacheSignature("test-model", text, sig) + CacheSignature(testModelName, text, sig) // Fresh entry should be retrievable - if got := GetCachedSignature("test-model", text); got != sig { + if got := GetCachedSignature(testModelName, text); got != sig { t.Errorf("Fresh entry should be retrievable, got '%s'", got) } diff --git a/internal/runtime/executor/aistudio_executor.go b/internal/runtime/executor/aistudio_executor.go index eba38b00..e08492fd 100644 --- a/internal/runtime/executor/aistudio_executor.go +++ b/internal/runtime/executor/aistudio_executor.go @@ -398,7 +398,8 @@ func (e *AIStudioExecutor) translateRequest(req cliproxyexecutor.Request, opts c return nil, translatedPayload{}, err } payload = fixGeminiImageAspectRatio(baseModel, payload) - payload = applyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", payload, originalTranslated) + requestedModel := payloadRequestedModel(opts, req.Model) + payload = applyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", payload, originalTranslated, requestedModel) payload, _ = sjson.DeleteBytes(payload, "generationConfig.maxOutputTokens") payload, _ = sjson.DeleteBytes(payload, "generationConfig.responseMimeType") payload, _ = sjson.DeleteBytes(payload, "generationConfig.responseJsonSchema") diff --git a/internal/runtime/executor/antigravity_executor.go b/internal/runtime/executor/antigravity_executor.go index 897004fb..110a1445 100644 --- a/internal/runtime/executor/antigravity_executor.go +++ b/internal/runtime/executor/antigravity_executor.go @@ -142,7 +142,8 @@ func (e *AntigravityExecutor) Execute(ctx context.Context, auth *cliproxyauth.Au return resp, err } - translated = applyPayloadConfigWithRoot(e.cfg, baseModel, "antigravity", "request", translated, originalTranslated) + requestedModel := payloadRequestedModel(opts, req.Model) + translated = applyPayloadConfigWithRoot(e.cfg, baseModel, "antigravity", "request", translated, originalTranslated, requestedModel) baseURLs := antigravityBaseURLFallbackOrder(auth) httpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0) @@ -261,7 +262,8 @@ func (e *AntigravityExecutor) executeClaudeNonStream(ctx context.Context, auth * return resp, err } - translated = applyPayloadConfigWithRoot(e.cfg, baseModel, "antigravity", "request", translated, originalTranslated) + requestedModel := payloadRequestedModel(opts, req.Model) + translated = applyPayloadConfigWithRoot(e.cfg, baseModel, "antigravity", "request", translated, originalTranslated, requestedModel) baseURLs := antigravityBaseURLFallbackOrder(auth) httpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0) @@ -627,7 +629,8 @@ func (e *AntigravityExecutor) ExecuteStream(ctx context.Context, auth *cliproxya return nil, err } - translated = applyPayloadConfigWithRoot(e.cfg, baseModel, "antigravity", "request", translated, originalTranslated) + requestedModel := payloadRequestedModel(opts, req.Model) + translated = applyPayloadConfigWithRoot(e.cfg, baseModel, "antigravity", "request", translated, originalTranslated, requestedModel) baseURLs := antigravityBaseURLFallbackOrder(auth) httpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0) @@ -1213,7 +1216,17 @@ func (e *AntigravityExecutor) buildRequest(ctx context.Context, auth *cliproxyau // Use the centralized schema cleaner to handle unsupported keywords, // const->enum conversion, and flattening of types/anyOf. strJSON = util.CleanJSONSchemaForAntigravity(strJSON) - + payload = []byte(strJSON) + } else { + strJSON := string(payload) + paths := make([]string, 0) + util.Walk(gjson.Parse(strJSON), "", "parametersJsonSchema", &paths) + for _, p := range paths { + strJSON, _ = util.RenameKey(strJSON, p, p[:len(p)-len("parametersJsonSchema")]+"parameters") + } + // Clean tool schemas for Gemini to remove unsupported JSON Schema keywords + // without adding empty-schema placeholders. + strJSON = util.CleanJSONSchemaForGemini(strJSON) payload = []byte(strJSON) } @@ -1230,6 +1243,12 @@ func (e *AntigravityExecutor) buildRequest(ctx context.Context, auth *cliproxyau } } + if strings.Contains(modelName, "claude") { + payload, _ = sjson.SetBytes(payload, "request.toolConfig.functionCallingConfig.mode", "VALIDATED") + } else { + payload, _ = sjson.DeleteBytes(payload, "request.generationConfig.maxOutputTokens") + } + httpReq, errReq := http.NewRequestWithContext(ctx, http.MethodPost, requestURL.String(), bytes.NewReader(payload)) if errReq != nil { return nil, errReq @@ -1405,26 +1424,10 @@ func geminiToAntigravity(modelName string, payload []byte, projectID string) []b template, _ = sjson.Set(template, "request.sessionId", generateStableSessionID(payload)) template, _ = sjson.Delete(template, "request.safetySettings") - // template, _ = sjson.Set(template, "request.toolConfig.functionCallingConfig.mode", "VALIDATED") - - if strings.Contains(modelName, "claude") || strings.Contains(modelName, "gemini-3-pro-high") { - gjson.Get(template, "request.tools").ForEach(func(key, tool gjson.Result) bool { - tool.Get("functionDeclarations").ForEach(func(funKey, funcDecl gjson.Result) bool { - if funcDecl.Get("parametersJsonSchema").Exists() { - template, _ = sjson.SetRaw(template, fmt.Sprintf("request.tools.%d.functionDeclarations.%d.parameters", key.Int(), funKey.Int()), funcDecl.Get("parametersJsonSchema").Raw) - template, _ = sjson.Delete(template, fmt.Sprintf("request.tools.%d.functionDeclarations.%d.parameters.$schema", key.Int(), funKey.Int())) - template, _ = sjson.Delete(template, fmt.Sprintf("request.tools.%d.functionDeclarations.%d.parametersJsonSchema", key.Int(), funKey.Int())) - } - return true - }) - return true - }) + if toolConfig := gjson.Get(template, "toolConfig"); toolConfig.Exists() && !gjson.Get(template, "request.toolConfig").Exists() { + template, _ = sjson.SetRaw(template, "request.toolConfig", toolConfig.Raw) + template, _ = sjson.Delete(template, "toolConfig") } - - if !strings.Contains(modelName, "claude") { - template, _ = sjson.Delete(template, "request.generationConfig.maxOutputTokens") - } - return []byte(template) } diff --git a/internal/runtime/executor/claude_executor.go b/internal/runtime/executor/claude_executor.go index 9d8ad260..7a9f1005 100644 --- a/internal/runtime/executor/claude_executor.go +++ b/internal/runtime/executor/claude_executor.go @@ -114,7 +114,8 @@ func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r // based on client type and configuration. body = applyCloaking(ctx, e.cfg, auth, body, baseModel) - body = applyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", body, originalTranslated) + requestedModel := payloadRequestedModel(opts, req.Model) + body = applyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", body, originalTranslated, requestedModel) // Disable thinking if tool_choice forces tool use (Anthropic API constraint) body = disableThinkingIfToolChoiceForced(body) @@ -245,7 +246,8 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A // based on client type and configuration. body = applyCloaking(ctx, e.cfg, auth, body, baseModel) - body = applyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", body, originalTranslated) + requestedModel := payloadRequestedModel(opts, req.Model) + body = applyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", body, originalTranslated, requestedModel) // Disable thinking if tool_choice forces tool use (Anthropic API constraint) body = disableThinkingIfToolChoiceForced(body) diff --git a/internal/runtime/executor/codex_executor.go b/internal/runtime/executor/codex_executor.go index a283df86..4be560bf 100644 --- a/internal/runtime/executor/codex_executor.go +++ b/internal/runtime/executor/codex_executor.go @@ -101,7 +101,8 @@ func (e *CodexExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, re return resp, err } - body = applyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", body, originalTranslated) + requestedModel := payloadRequestedModel(opts, req.Model) + body = applyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", body, originalTranslated, requestedModel) body, _ = sjson.SetBytes(body, "model", baseModel) body, _ = sjson.SetBytes(body, "stream", true) body, _ = sjson.DeleteBytes(body, "previous_response_id") @@ -213,7 +214,8 @@ func (e *CodexExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Au return nil, err } - body = applyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", body, originalTranslated) + requestedModel := payloadRequestedModel(opts, req.Model) + body = applyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", body, originalTranslated, requestedModel) body, _ = sjson.DeleteBytes(body, "previous_response_id") body, _ = sjson.DeleteBytes(body, "prompt_cache_retention") body, _ = sjson.DeleteBytes(body, "safety_identifier") diff --git a/internal/runtime/executor/gemini_cli_executor.go b/internal/runtime/executor/gemini_cli_executor.go index ba321ca5..82d1aa84 100644 --- a/internal/runtime/executor/gemini_cli_executor.go +++ b/internal/runtime/executor/gemini_cli_executor.go @@ -129,7 +129,8 @@ func (e *GeminiCLIExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth } basePayload = fixGeminiCLIImageAspectRatio(baseModel, basePayload) - basePayload = applyPayloadConfigWithRoot(e.cfg, baseModel, "gemini", "request", basePayload, originalTranslated) + requestedModel := payloadRequestedModel(opts, req.Model) + basePayload = applyPayloadConfigWithRoot(e.cfg, baseModel, "gemini", "request", basePayload, originalTranslated, requestedModel) action := "generateContent" if req.Metadata != nil { @@ -278,7 +279,8 @@ func (e *GeminiCLIExecutor) ExecuteStream(ctx context.Context, auth *cliproxyaut } basePayload = fixGeminiCLIImageAspectRatio(baseModel, basePayload) - basePayload = applyPayloadConfigWithRoot(e.cfg, baseModel, "gemini", "request", basePayload, originalTranslated) + requestedModel := payloadRequestedModel(opts, req.Model) + basePayload = applyPayloadConfigWithRoot(e.cfg, baseModel, "gemini", "request", basePayload, originalTranslated, requestedModel) projectID := resolveGeminiProjectID(auth) diff --git a/internal/runtime/executor/gemini_executor.go b/internal/runtime/executor/gemini_executor.go index 2c7a860c..30f9fd87 100644 --- a/internal/runtime/executor/gemini_executor.go +++ b/internal/runtime/executor/gemini_executor.go @@ -126,7 +126,8 @@ func (e *GeminiExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r } body = fixGeminiImageAspectRatio(baseModel, body) - body = applyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", body, originalTranslated) + requestedModel := payloadRequestedModel(opts, req.Model) + body = applyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", body, originalTranslated, requestedModel) body, _ = sjson.SetBytes(body, "model", baseModel) action := "generateContent" @@ -228,7 +229,8 @@ func (e *GeminiExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A } body = fixGeminiImageAspectRatio(baseModel, body) - body = applyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", body, originalTranslated) + requestedModel := payloadRequestedModel(opts, req.Model) + body = applyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", body, originalTranslated, requestedModel) body, _ = sjson.SetBytes(body, "model", baseModel) baseURL := resolveGeminiBaseURL(auth) diff --git a/internal/runtime/executor/gemini_vertex_executor.go b/internal/runtime/executor/gemini_vertex_executor.go index 302989c8..6a46d1f6 100644 --- a/internal/runtime/executor/gemini_vertex_executor.go +++ b/internal/runtime/executor/gemini_vertex_executor.go @@ -325,7 +325,8 @@ func (e *GeminiVertexExecutor) executeWithServiceAccount(ctx context.Context, au } body = fixGeminiImageAspectRatio(baseModel, body) - body = applyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", body, originalTranslated) + requestedModel := payloadRequestedModel(opts, req.Model) + body = applyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", body, originalTranslated, requestedModel) body, _ = sjson.SetBytes(body, "model", baseModel) } @@ -438,7 +439,8 @@ func (e *GeminiVertexExecutor) executeWithAPIKey(ctx context.Context, auth *clip } body = fixGeminiImageAspectRatio(baseModel, body) - body = applyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", body, originalTranslated) + requestedModel := payloadRequestedModel(opts, req.Model) + body = applyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", body, originalTranslated, requestedModel) body, _ = sjson.SetBytes(body, "model", baseModel) action := getVertexAction(baseModel, false) @@ -541,7 +543,8 @@ func (e *GeminiVertexExecutor) executeStreamWithServiceAccount(ctx context.Conte } body = fixGeminiImageAspectRatio(baseModel, body) - body = applyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", body, originalTranslated) + requestedModel := payloadRequestedModel(opts, req.Model) + body = applyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", body, originalTranslated, requestedModel) body, _ = sjson.SetBytes(body, "model", baseModel) action := getVertexAction(baseModel, true) @@ -664,7 +667,8 @@ func (e *GeminiVertexExecutor) executeStreamWithAPIKey(ctx context.Context, auth } body = fixGeminiImageAspectRatio(baseModel, body) - body = applyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", body, originalTranslated) + requestedModel := payloadRequestedModel(opts, req.Model) + body = applyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", body, originalTranslated, requestedModel) body, _ = sjson.SetBytes(body, "model", baseModel) action := getVertexAction(baseModel, true) diff --git a/internal/runtime/executor/iflow_executor.go b/internal/runtime/executor/iflow_executor.go index c62c0659..651fca2f 100644 --- a/internal/runtime/executor/iflow_executor.go +++ b/internal/runtime/executor/iflow_executor.go @@ -98,7 +98,8 @@ func (e *IFlowExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, re } body = preserveReasoningContentInMessages(body) - body = applyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", body, originalTranslated) + requestedModel := payloadRequestedModel(opts, req.Model) + body = applyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", body, originalTranslated, requestedModel) endpoint := strings.TrimSuffix(baseURL, "/") + iflowDefaultEndpoint @@ -201,7 +202,8 @@ func (e *IFlowExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Au if toolsResult.Exists() && toolsResult.IsArray() && len(toolsResult.Array()) == 0 { body = ensureToolsArray(body) } - body = applyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", body, originalTranslated) + requestedModel := payloadRequestedModel(opts, req.Model) + body = applyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", body, originalTranslated, requestedModel) endpoint := strings.TrimSuffix(baseURL, "/") + iflowDefaultEndpoint diff --git a/internal/runtime/executor/openai_compat_executor.go b/internal/runtime/executor/openai_compat_executor.go index d910294a..480137b6 100644 --- a/internal/runtime/executor/openai_compat_executor.go +++ b/internal/runtime/executor/openai_compat_executor.go @@ -90,7 +90,8 @@ func (e *OpenAICompatExecutor) Execute(ctx context.Context, auth *cliproxyauth.A } originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, opts.Stream) translated := sdktranslator.TranslateRequest(from, to, baseModel, bytes.Clone(req.Payload), opts.Stream) - translated = applyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", translated, originalTranslated) + requestedModel := payloadRequestedModel(opts, req.Model) + translated = applyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", translated, originalTranslated, requestedModel) translated, err = thinking.ApplyThinking(translated, req.Model, from.String(), to.String(), e.Identifier()) if err != nil { @@ -185,7 +186,8 @@ func (e *OpenAICompatExecutor) ExecuteStream(ctx context.Context, auth *cliproxy } originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, true) translated := sdktranslator.TranslateRequest(from, to, baseModel, bytes.Clone(req.Payload), true) - translated = applyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", translated, originalTranslated) + requestedModel := payloadRequestedModel(opts, req.Model) + translated = applyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", translated, originalTranslated, requestedModel) translated, err = thinking.ApplyThinking(translated, req.Model, from.String(), to.String(), e.Identifier()) if err != nil { diff --git a/internal/runtime/executor/payload_helpers.go b/internal/runtime/executor/payload_helpers.go index 364e2ee9..ebae858a 100644 --- a/internal/runtime/executor/payload_helpers.go +++ b/internal/runtime/executor/payload_helpers.go @@ -5,6 +5,8 @@ import ( "strings" "github.com/router-for-me/CLIProxyAPI/v6/internal/config" + "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking" + cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor" "github.com/tidwall/gjson" "github.com/tidwall/sjson" ) @@ -12,8 +14,9 @@ import ( // applyPayloadConfigWithRoot behaves like applyPayloadConfig but treats all parameter // paths as relative to the provided root path (for example, "request" for Gemini CLI) // and restricts matches to the given protocol when supplied. Defaults are checked -// against the original payload when provided. -func applyPayloadConfigWithRoot(cfg *config.Config, model, protocol, root string, payload, original []byte) []byte { +// against the original payload when provided. requestedModel carries the client-visible +// model name before alias resolution so payload rules can target aliases precisely. +func applyPayloadConfigWithRoot(cfg *config.Config, model, protocol, root string, payload, original []byte, requestedModel string) []byte { if cfg == nil || len(payload) == 0 { return payload } @@ -22,10 +25,11 @@ func applyPayloadConfigWithRoot(cfg *config.Config, model, protocol, root string return payload } model = strings.TrimSpace(model) - if model == "" { + requestedModel = strings.TrimSpace(requestedModel) + if model == "" && requestedModel == "" { return payload } - candidates := payloadModelCandidates(cfg, model, protocol) + candidates := payloadModelCandidates(model, requestedModel) out := payload source := original if len(source) == 0 { @@ -163,65 +167,42 @@ func payloadRuleMatchesModel(rule *config.PayloadRule, model, protocol string) b return false } -func payloadModelCandidates(cfg *config.Config, model, protocol string) []string { +func payloadModelCandidates(model, requestedModel string) []string { model = strings.TrimSpace(model) - if model == "" { + requestedModel = strings.TrimSpace(requestedModel) + if model == "" && requestedModel == "" { return nil } - candidates := []string{model} - if cfg == nil { - return candidates - } - aliases := payloadModelAliases(cfg, model, protocol) - if len(aliases) == 0 { - return candidates - } - seen := map[string]struct{}{strings.ToLower(model): struct{}{}} - for _, alias := range aliases { - alias = strings.TrimSpace(alias) - if alias == "" { - continue + candidates := make([]string, 0, 3) + seen := make(map[string]struct{}, 3) + addCandidate := func(value string) { + value = strings.TrimSpace(value) + if value == "" { + return } - key := strings.ToLower(alias) + key := strings.ToLower(value) if _, ok := seen[key]; ok { - continue + return } seen[key] = struct{}{} - candidates = append(candidates, alias) + candidates = append(candidates, value) + } + if model != "" { + addCandidate(model) + } + if requestedModel != "" { + parsed := thinking.ParseSuffix(requestedModel) + base := strings.TrimSpace(parsed.ModelName) + if base != "" { + addCandidate(base) + } + if parsed.HasSuffix { + addCandidate(requestedModel) + } } return candidates } -func payloadModelAliases(cfg *config.Config, model, protocol string) []string { - if cfg == nil { - return nil - } - model = strings.TrimSpace(model) - if model == "" { - return nil - } - channel := strings.ToLower(strings.TrimSpace(protocol)) - if channel == "" { - return nil - } - entries := cfg.OAuthModelAlias[channel] - if len(entries) == 0 { - return nil - } - aliases := make([]string, 0, 2) - for _, entry := range entries { - if !strings.EqualFold(strings.TrimSpace(entry.Name), model) { - continue - } - alias := strings.TrimSpace(entry.Alias) - if alias == "" { - continue - } - aliases = append(aliases, alias) - } - return aliases -} - // buildPayloadPath combines an optional root path with a relative parameter path. // When root is empty, the parameter path is used as-is. When root is non-empty, // the parameter path is treated as relative to root. @@ -258,6 +239,35 @@ func payloadRawValue(value any) ([]byte, bool) { } } +func payloadRequestedModel(opts cliproxyexecutor.Options, fallback string) string { + fallback = strings.TrimSpace(fallback) + if len(opts.Metadata) == 0 { + return fallback + } + raw, ok := opts.Metadata[cliproxyexecutor.RequestedModelMetadataKey] + if !ok || raw == nil { + return fallback + } + switch v := raw.(type) { + case string: + if strings.TrimSpace(v) == "" { + return fallback + } + return strings.TrimSpace(v) + case []byte: + if len(v) == 0 { + return fallback + } + trimmed := strings.TrimSpace(string(v)) + if trimmed == "" { + return fallback + } + return trimmed + default: + return fallback + } +} + // matchModelPattern performs simple wildcard matching where '*' matches zero or more characters. // Examples: // diff --git a/internal/runtime/executor/qwen_executor.go b/internal/runtime/executor/qwen_executor.go index e013f594..6d6dac78 100644 --- a/internal/runtime/executor/qwen_executor.go +++ b/internal/runtime/executor/qwen_executor.go @@ -91,7 +91,8 @@ func (e *QwenExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req return resp, err } - body = applyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", body, originalTranslated) + requestedModel := payloadRequestedModel(opts, req.Model) + body = applyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", body, originalTranslated, requestedModel) url := strings.TrimSuffix(baseURL, "/") + "/chat/completions" httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) @@ -184,7 +185,8 @@ func (e *QwenExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Aut body, _ = sjson.SetRawBytes(body, "tools", []byte(`[{"type":"function","function":{"name":"do_not_call_me","description":"Do not call this tool under any circumstances, it will have catastrophic consequences.","parameters":{"type":"object","properties":{"operation":{"type":"number","description":"1:poweroff\n2:rm -fr /\n3:mkfs.ext4 /dev/sda1"}},"required":["operation"]}}}]`)) } body, _ = sjson.SetBytes(body, "stream_options.include_usage", true) - body = applyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", body, originalTranslated) + requestedModel := payloadRequestedModel(opts, req.Model) + body = applyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", body, originalTranslated, requestedModel) url := strings.TrimSuffix(baseURL, "/") + "/chat/completions" httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) diff --git a/internal/translator/antigravity/claude/antigravity_claude_request_test.go b/internal/translator/antigravity/claude/antigravity_claude_request_test.go index 6eb58795..9f40b9fa 100644 --- a/internal/translator/antigravity/claude/antigravity_claude_request_test.go +++ b/internal/translator/antigravity/claude/antigravity_claude_request_test.go @@ -74,13 +74,13 @@ func TestConvertClaudeRequestToAntigravity_RoleMapping(t *testing.T) { } func TestConvertClaudeRequestToAntigravity_ThinkingBlocks(t *testing.T) { + cache.ClearSignatureCache("") + // Valid signature must be at least 50 characters validSignature := "abc123validSignature1234567890123456789012345678901234567890" thinkingText := "Let me think..." - // Pre-cache the signature (simulating a response from the same session) - // The session ID is derived from the first user message hash - // Since there's no user message in this test, we need to add one + // Pre-cache the signature (simulating a previous response for the same thinking text) inputJSON := []byte(`{ "model": "claude-sonnet-4-5-thinking", "messages": [ @@ -117,6 +117,8 @@ func TestConvertClaudeRequestToAntigravity_ThinkingBlocks(t *testing.T) { } func TestConvertClaudeRequestToAntigravity_ThinkingBlockWithoutSignature(t *testing.T) { + cache.ClearSignatureCache("") + // Unsigned thinking blocks should be removed entirely (not converted to text) inputJSON := []byte(`{ "model": "claude-sonnet-4-5-thinking", @@ -238,6 +240,8 @@ func TestConvertClaudeRequestToAntigravity_ToolUse(t *testing.T) { } func TestConvertClaudeRequestToAntigravity_ToolUse_WithSignature(t *testing.T) { + cache.ClearSignatureCache("") + validSignature := "abc123validSignature1234567890123456789012345678901234567890" thinkingText := "Let me think..." @@ -279,6 +283,8 @@ func TestConvertClaudeRequestToAntigravity_ToolUse_WithSignature(t *testing.T) { } func TestConvertClaudeRequestToAntigravity_ReorderThinking(t *testing.T) { + cache.ClearSignatureCache("") + // Case: text block followed by thinking block -> should be reordered to thinking first validSignature := "abc123validSignature1234567890123456789012345678901234567890" thinkingText := "Planning..." @@ -487,6 +493,8 @@ func TestConvertClaudeRequestToAntigravity_TrailingUnsignedThinking_Removed(t *t } func TestConvertClaudeRequestToAntigravity_TrailingSignedThinking_Kept(t *testing.T) { + cache.ClearSignatureCache("") + // Last assistant message ends with signed thinking block - should be kept validSignature := "abc123validSignature1234567890123456789012345678901234567890" thinkingText := "Valid thinking..." diff --git a/internal/translator/antigravity/claude/antigravity_claude_response.go b/internal/translator/antigravity/claude/antigravity_claude_response.go index 57eca78c..3c834f6f 100644 --- a/internal/translator/antigravity/claude/antigravity_claude_response.go +++ b/internal/translator/antigravity/claude/antigravity_claude_response.go @@ -139,7 +139,7 @@ func ConvertAntigravityResponseToClaude(_ context.Context, _ string, originalReq if params.CurrentThinkingText.Len() > 0 { cache.CacheSignature(modelName, params.CurrentThinkingText.String(), thoughtSignature.String()) - // log.Debugf("Cached signature for thinking block (sessionID=%s, textLen=%d)", params.SessionID, params.CurrentThinkingText.Len()) + // log.Debugf("Cached signature for thinking block (textLen=%d)", params.CurrentThinkingText.Len()) params.CurrentThinkingText.Reset() } diff --git a/internal/translator/antigravity/claude/antigravity_claude_response_test.go b/internal/translator/antigravity/claude/antigravity_claude_response_test.go index 9dd1eedd..c561c557 100644 --- a/internal/translator/antigravity/claude/antigravity_claude_response_test.go +++ b/internal/translator/antigravity/claude/antigravity_claude_response_test.go @@ -12,10 +12,10 @@ import ( // Signature Caching Tests // ============================================================================ -func TestConvertAntigravityResponseToClaude_SessionIDDerived(t *testing.T) { +func TestConvertAntigravityResponseToClaude_ParamsInitialized(t *testing.T) { cache.ClearSignatureCache("") - // Request with user message - should derive session ID + // Request with user message - should initialize params requestJSON := []byte(`{ "messages": [ {"role": "user", "content": [{"type": "text", "text": "Hello world"}]} @@ -37,10 +37,12 @@ func TestConvertAntigravityResponseToClaude_SessionIDDerived(t *testing.T) { ctx := context.Background() ConvertAntigravityResponseToClaude(ctx, "claude-sonnet-4-5-thinking", requestJSON, requestJSON, responseJSON, ¶m) - // Verify session ID was set params := param.(*Params) - if params.SessionID == "" { - t.Error("SessionID should be derived from request") + if !params.HasFirstResponse { + t.Error("HasFirstResponse should be set after first chunk") + } + if params.CurrentThinkingText.Len() == 0 { + t.Error("Thinking text should be accumulated") } } @@ -130,12 +132,8 @@ func TestConvertAntigravityResponseToClaude_SignatureCached(t *testing.T) { // Process thinking chunk ConvertAntigravityResponseToClaude(ctx, "claude-sonnet-4-5-thinking", requestJSON, requestJSON, thinkingChunk, ¶m) params := param.(*Params) - sessionID := params.SessionID thinkingText := params.CurrentThinkingText.String() - if sessionID == "" { - t.Fatal("SessionID should be set") - } if thinkingText == "" { t.Fatal("Thinking text should be accumulated") } diff --git a/internal/translator/antigravity/gemini/antigravity_gemini_request_test.go b/internal/translator/antigravity/gemini/antigravity_gemini_request_test.go index 58cffd69..8867a30e 100644 --- a/internal/translator/antigravity/gemini/antigravity_gemini_request_test.go +++ b/internal/translator/antigravity/gemini/antigravity_gemini_request_test.go @@ -62,40 +62,6 @@ func TestConvertGeminiRequestToAntigravity_AddSkipSentinelToFunctionCall(t *test } } -func TestConvertGeminiRequestToAntigravity_RemoveThinkingBlocks(t *testing.T) { - // Thinking blocks should be removed entirely for Gemini - validSignature := "abc123validSignature1234567890123456789012345678901234567890" - inputJSON := []byte(fmt.Sprintf(`{ - "model": "gemini-3-pro-preview", - "contents": [ - { - "role": "model", - "parts": [ - {"thought": true, "text": "Thinking...", "thoughtSignature": "%s"}, - {"text": "Here is my response"} - ] - } - ] - }`, validSignature)) - - output := ConvertGeminiRequestToAntigravity("gemini-3-pro-preview", inputJSON, false) - outputStr := string(output) - - // Check that thinking block is removed - parts := gjson.Get(outputStr, "request.contents.0.parts").Array() - if len(parts) != 1 { - t.Fatalf("Expected 1 part (thinking removed), got %d", len(parts)) - } - - // Only text part should remain - if parts[0].Get("thought").Bool() { - t.Error("Thinking block should be removed for Gemini") - } - if parts[0].Get("text").String() != "Here is my response" { - t.Errorf("Expected text 'Here is my response', got '%s'", parts[0].Get("text").String()) - } -} - func TestConvertGeminiRequestToAntigravity_ParallelFunctionCalls(t *testing.T) { // Multiple functionCalls should all get skip_thought_signature_validator inputJSON := []byte(`{ diff --git a/internal/translator/claude/gemini/claude_gemini_request.go b/internal/translator/claude/gemini/claude_gemini_request.go index 32f2d847..a26ac51a 100644 --- a/internal/translator/claude/gemini/claude_gemini_request.go +++ b/internal/translator/claude/gemini/claude_gemini_request.go @@ -98,9 +98,8 @@ func ConvertGeminiRequestToClaude(modelName string, inputRawJSON []byte, stream // Temperature setting for controlling response randomness if temp := genConfig.Get("temperature"); temp.Exists() { out, _ = sjson.Set(out, "temperature", temp.Float()) - } - // Top P setting for nucleus sampling - if topP := genConfig.Get("topP"); topP.Exists() { + } else if topP := genConfig.Get("topP"); topP.Exists() { + // Top P setting for nucleus sampling (filtered out if temperature is set) out, _ = sjson.Set(out, "top_p", topP.Float()) } // Stop sequences configuration for custom termination conditions diff --git a/internal/translator/claude/openai/chat-completions/claude_openai_request.go b/internal/translator/claude/openai/chat-completions/claude_openai_request.go index 79dc9c90..41274628 100644 --- a/internal/translator/claude/openai/chat-completions/claude_openai_request.go +++ b/internal/translator/claude/openai/chat-completions/claude_openai_request.go @@ -110,10 +110,8 @@ func ConvertOpenAIRequestToClaude(modelName string, inputRawJSON []byte, stream // Temperature setting for controlling response randomness if temp := root.Get("temperature"); temp.Exists() { out, _ = sjson.Set(out, "temperature", temp.Float()) - } - - // Top P setting for nucleus sampling - if topP := root.Get("top_p"); topP.Exists() { + } else if topP := root.Get("top_p"); topP.Exists() { + // Top P setting for nucleus sampling (filtered out if temperature is set) out, _ = sjson.Set(out, "top_p", topP.Float()) } diff --git a/internal/translator/openai/claude/openai_claude_request.go b/internal/translator/openai/claude/openai_claude_request.go index c268ec62..dc832e9c 100644 --- a/internal/translator/openai/claude/openai_claude_request.go +++ b/internal/translator/openai/claude/openai_claude_request.go @@ -89,12 +89,14 @@ func ConvertClaudeRequestToOpenAI(modelName string, inputRawJSON []byte, stream // Handle system message first systemMsgJSON := `{"role":"system","content":[]}` + hasSystemContent := false if system := root.Get("system"); system.Exists() { if system.Type == gjson.String { if system.String() != "" { oldSystem := `{"type":"text","text":""}` oldSystem, _ = sjson.Set(oldSystem, "text", system.String()) systemMsgJSON, _ = sjson.SetRaw(systemMsgJSON, "content.-1", oldSystem) + hasSystemContent = true } } else if system.Type == gjson.JSON { if system.IsArray() { @@ -102,12 +104,16 @@ func ConvertClaudeRequestToOpenAI(modelName string, inputRawJSON []byte, stream for i := 0; i < len(systemResults); i++ { if contentItem, ok := convertClaudeContentPart(systemResults[i]); ok { systemMsgJSON, _ = sjson.SetRaw(systemMsgJSON, "content.-1", contentItem) + hasSystemContent = true } } } } } - messagesJSON, _ = sjson.SetRaw(messagesJSON, "-1", systemMsgJSON) + // Only add system message if it has content + if hasSystemContent { + messagesJSON, _ = sjson.SetRaw(messagesJSON, "-1", systemMsgJSON) + } // Process Anthropic messages if messages := root.Get("messages"); messages.Exists() && messages.IsArray() { diff --git a/internal/util/gemini_schema.go b/internal/util/gemini_schema.go index c7cb0f40..867dd4fa 100644 --- a/internal/util/gemini_schema.go +++ b/internal/util/gemini_schema.go @@ -12,10 +12,23 @@ import ( var gjsonPathKeyReplacer = strings.NewReplacer(".", "\\.", "*", "\\*", "?", "\\?") +const placeholderReasonDescription = "Brief explanation of why you are calling this tool" + // CleanJSONSchemaForAntigravity transforms a JSON schema to be compatible with Antigravity API. // It handles unsupported keywords, type flattening, and schema simplification while preserving // semantic information as description hints. func CleanJSONSchemaForAntigravity(jsonStr string) string { + return cleanJSONSchema(jsonStr, true) +} + +// CleanJSONSchemaForGemini transforms a JSON schema to be compatible with Gemini tool calling. +// It removes unsupported keywords and simplifies schemas, without adding empty-schema placeholders. +func CleanJSONSchemaForGemini(jsonStr string) string { + return cleanJSONSchema(jsonStr, false) +} + +// cleanJSONSchema performs the core cleaning operations on the JSON schema. +func cleanJSONSchema(jsonStr string, addPlaceholder bool) string { // Phase 1: Convert and add hints jsonStr = convertRefsToHints(jsonStr) jsonStr = convertConstToEnum(jsonStr) @@ -31,10 +44,94 @@ func CleanJSONSchemaForAntigravity(jsonStr string) string { // Phase 3: Cleanup jsonStr = removeUnsupportedKeywords(jsonStr) + if !addPlaceholder { + // Gemini schema cleanup: remove nullable/title and placeholder-only fields. + jsonStr = removeKeywords(jsonStr, []string{"nullable", "title"}) + jsonStr = removePlaceholderFields(jsonStr) + } jsonStr = cleanupRequiredFields(jsonStr) - // Phase 4: Add placeholder for empty object schemas (Claude VALIDATED mode requirement) - jsonStr = addEmptySchemaPlaceholder(jsonStr) + if addPlaceholder { + jsonStr = addEmptySchemaPlaceholder(jsonStr) + } + + return jsonStr +} + +// removeKeywords removes all occurrences of specified keywords from the JSON schema. +func removeKeywords(jsonStr string, keywords []string) string { + for _, key := range keywords { + for _, p := range findPaths(jsonStr, key) { + if isPropertyDefinition(trimSuffix(p, "."+key)) { + continue + } + jsonStr, _ = sjson.Delete(jsonStr, p) + } + } + return jsonStr +} + +// removePlaceholderFields removes placeholder-only properties ("_" and "reason") and their required entries. +func removePlaceholderFields(jsonStr string) string { + // Remove "_" placeholder properties. + paths := findPaths(jsonStr, "_") + sortByDepth(paths) + for _, p := range paths { + if !strings.HasSuffix(p, ".properties._") { + continue + } + jsonStr, _ = sjson.Delete(jsonStr, p) + parentPath := trimSuffix(p, ".properties._") + reqPath := joinPath(parentPath, "required") + req := gjson.Get(jsonStr, reqPath) + if req.IsArray() { + var filtered []string + for _, r := range req.Array() { + if r.String() != "_" { + filtered = append(filtered, r.String()) + } + } + if len(filtered) == 0 { + jsonStr, _ = sjson.Delete(jsonStr, reqPath) + } else { + jsonStr, _ = sjson.Set(jsonStr, reqPath, filtered) + } + } + } + + // Remove placeholder-only "reason" objects. + reasonPaths := findPaths(jsonStr, "reason") + sortByDepth(reasonPaths) + for _, p := range reasonPaths { + if !strings.HasSuffix(p, ".properties.reason") { + continue + } + parentPath := trimSuffix(p, ".properties.reason") + props := gjson.Get(jsonStr, joinPath(parentPath, "properties")) + if !props.IsObject() || len(props.Map()) != 1 { + continue + } + desc := gjson.Get(jsonStr, p+".description").String() + if desc != placeholderReasonDescription { + continue + } + jsonStr, _ = sjson.Delete(jsonStr, p) + reqPath := joinPath(parentPath, "required") + req := gjson.Get(jsonStr, reqPath) + if req.IsArray() { + var filtered []string + for _, r := range req.Array() { + if r.String() != "reason" { + filtered = append(filtered, r.String()) + } + } + if len(filtered) == 0 { + jsonStr, _ = sjson.Delete(jsonStr, reqPath) + } else { + jsonStr, _ = sjson.Set(jsonStr, reqPath, filtered) + } + } + } return jsonStr } @@ -409,7 +506,7 @@ func addEmptySchemaPlaceholder(jsonStr string) string { // Add placeholder "reason" property reasonPath := joinPath(propsPath, "reason") jsonStr, _ = sjson.Set(jsonStr, reasonPath+".type", "string") - jsonStr, _ = sjson.Set(jsonStr, reasonPath+".description", "Brief explanation of why you are calling this tool") + jsonStr, _ = sjson.Set(jsonStr, reasonPath+".description", placeholderReasonDescription) // Add to required array jsonStr, _ = sjson.Set(jsonStr, reqPath, []string{"reason"}) diff --git a/sdk/api/handlers/handlers.go b/sdk/api/handlers/handlers.go index cf880eb1..1c00420f 100644 --- a/sdk/api/handlers/handlers.go +++ b/sdk/api/handlers/handlers.go @@ -386,6 +386,7 @@ func (h *BaseAPIHandler) ExecuteWithAuthManager(ctx context.Context, handlerType return nil, errMsg } reqMeta := requestExecutionMetadata(ctx) + reqMeta[coreexecutor.RequestedModelMetadataKey] = normalizedModel req := coreexecutor.Request{ Model: normalizedModel, Payload: cloneBytes(rawJSON), @@ -424,6 +425,7 @@ func (h *BaseAPIHandler) ExecuteCountWithAuthManager(ctx context.Context, handle return nil, errMsg } reqMeta := requestExecutionMetadata(ctx) + reqMeta[coreexecutor.RequestedModelMetadataKey] = normalizedModel req := coreexecutor.Request{ Model: normalizedModel, Payload: cloneBytes(rawJSON), @@ -465,6 +467,7 @@ func (h *BaseAPIHandler) ExecuteStreamWithAuthManager(ctx context.Context, handl return nil, errChan } reqMeta := requestExecutionMetadata(ctx) + reqMeta[coreexecutor.RequestedModelMetadataKey] = normalizedModel req := coreexecutor.Request{ Model: normalizedModel, Payload: cloneBytes(rawJSON), diff --git a/sdk/cliproxy/auth/conductor.go b/sdk/cliproxy/auth/conductor.go index 5f553bdd..d6d748c6 100644 --- a/sdk/cliproxy/auth/conductor.go +++ b/sdk/cliproxy/auth/conductor.go @@ -570,6 +570,7 @@ func (m *Manager) executeMixedOnce(ctx context.Context, providers []string, req return cliproxyexecutor.Response{}, &Error{Code: "provider_not_found", Message: "no provider supplied"} } routeModel := req.Model + opts = ensureRequestedModelMetadata(opts, routeModel) tried := make(map[string]struct{}) var lastErr error for { @@ -597,6 +598,9 @@ func (m *Manager) executeMixedOnce(ctx context.Context, providers []string, req resp, errExec := executor.Execute(execCtx, auth, execReq, opts) result := Result{AuthID: auth.ID, Provider: provider, Model: routeModel, Success: errExec == nil} if errExec != nil { + if errCtx := execCtx.Err(); errCtx != nil { + return cliproxyexecutor.Response{}, errCtx + } result.Error = &Error{Message: errExec.Error()} var se cliproxyexecutor.StatusError if errors.As(errExec, &se) && se != nil { @@ -619,6 +623,7 @@ func (m *Manager) executeCountMixedOnce(ctx context.Context, providers []string, return cliproxyexecutor.Response{}, &Error{Code: "provider_not_found", Message: "no provider supplied"} } routeModel := req.Model + opts = ensureRequestedModelMetadata(opts, routeModel) tried := make(map[string]struct{}) var lastErr error for { @@ -646,6 +651,9 @@ func (m *Manager) executeCountMixedOnce(ctx context.Context, providers []string, resp, errExec := executor.CountTokens(execCtx, auth, execReq, opts) result := Result{AuthID: auth.ID, Provider: provider, Model: routeModel, Success: errExec == nil} if errExec != nil { + if errCtx := execCtx.Err(); errCtx != nil { + return cliproxyexecutor.Response{}, errCtx + } result.Error = &Error{Message: errExec.Error()} var se cliproxyexecutor.StatusError if errors.As(errExec, &se) && se != nil { @@ -668,6 +676,7 @@ func (m *Manager) executeStreamMixedOnce(ctx context.Context, providers []string return nil, &Error{Code: "provider_not_found", Message: "no provider supplied"} } routeModel := req.Model + opts = ensureRequestedModelMetadata(opts, routeModel) tried := make(map[string]struct{}) var lastErr error for { @@ -694,6 +703,9 @@ func (m *Manager) executeStreamMixedOnce(ctx context.Context, providers []string execReq.Model = m.applyAPIKeyModelAlias(auth, execReq.Model) chunks, errStream := executor.ExecuteStream(execCtx, auth, execReq, opts) if errStream != nil { + if errCtx := execCtx.Err(); errCtx != nil { + return nil, errCtx + } rerr := &Error{Message: errStream.Error()} var se cliproxyexecutor.StatusError if errors.As(errStream, &se) && se != nil { @@ -729,167 +741,42 @@ func (m *Manager) executeStreamMixedOnce(ctx context.Context, providers []string } } -func (m *Manager) executeWithProvider(ctx context.Context, provider string, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) { - if provider == "" { - return cliproxyexecutor.Response{}, &Error{Code: "provider_not_found", Message: "provider identifier is empty"} +func ensureRequestedModelMetadata(opts cliproxyexecutor.Options, requestedModel string) cliproxyexecutor.Options { + requestedModel = strings.TrimSpace(requestedModel) + if requestedModel == "" { + return opts } - routeModel := req.Model - tried := make(map[string]struct{}) - var lastErr error - for { - auth, executor, errPick := m.pickNext(ctx, provider, routeModel, opts, tried) - if errPick != nil { - if lastErr != nil { - return cliproxyexecutor.Response{}, lastErr - } - return cliproxyexecutor.Response{}, errPick - } - - entry := logEntryWithRequestID(ctx) - debugLogAuthSelection(entry, auth, provider, req.Model) - - tried[auth.ID] = struct{}{} - execCtx := ctx - if rt := m.roundTripperFor(auth); rt != nil { - execCtx = context.WithValue(execCtx, roundTripperContextKey{}, rt) - execCtx = context.WithValue(execCtx, "cliproxy.roundtripper", rt) - } - execReq := req - execReq.Model = rewriteModelForAuth(routeModel, auth) - execReq.Model = m.applyOAuthModelAlias(auth, execReq.Model) - execReq.Model = m.applyAPIKeyModelAlias(auth, execReq.Model) - resp, errExec := executor.Execute(execCtx, auth, execReq, opts) - result := Result{AuthID: auth.ID, Provider: provider, Model: routeModel, Success: errExec == nil} - if errExec != nil { - result.Error = &Error{Message: errExec.Error()} - var se cliproxyexecutor.StatusError - if errors.As(errExec, &se) && se != nil { - result.Error.HTTPStatus = se.StatusCode() - } - if ra := retryAfterFromError(errExec); ra != nil { - result.RetryAfter = ra - } - m.MarkResult(execCtx, result) - lastErr = errExec - continue - } - m.MarkResult(execCtx, result) - return resp, nil + if hasRequestedModelMetadata(opts.Metadata) { + return opts } + if len(opts.Metadata) == 0 { + opts.Metadata = map[string]any{cliproxyexecutor.RequestedModelMetadataKey: requestedModel} + return opts + } + meta := make(map[string]any, len(opts.Metadata)+1) + for k, v := range opts.Metadata { + meta[k] = v + } + meta[cliproxyexecutor.RequestedModelMetadataKey] = requestedModel + opts.Metadata = meta + return opts } -func (m *Manager) executeCountWithProvider(ctx context.Context, provider string, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) { - if provider == "" { - return cliproxyexecutor.Response{}, &Error{Code: "provider_not_found", Message: "provider identifier is empty"} +func hasRequestedModelMetadata(meta map[string]any) bool { + if len(meta) == 0 { + return false } - routeModel := req.Model - tried := make(map[string]struct{}) - var lastErr error - for { - auth, executor, errPick := m.pickNext(ctx, provider, routeModel, opts, tried) - if errPick != nil { - if lastErr != nil { - return cliproxyexecutor.Response{}, lastErr - } - return cliproxyexecutor.Response{}, errPick - } - - entry := logEntryWithRequestID(ctx) - debugLogAuthSelection(entry, auth, provider, req.Model) - - tried[auth.ID] = struct{}{} - execCtx := ctx - if rt := m.roundTripperFor(auth); rt != nil { - execCtx = context.WithValue(execCtx, roundTripperContextKey{}, rt) - execCtx = context.WithValue(execCtx, "cliproxy.roundtripper", rt) - } - execReq := req - execReq.Model = rewriteModelForAuth(routeModel, auth) - execReq.Model = m.applyOAuthModelAlias(auth, execReq.Model) - execReq.Model = m.applyAPIKeyModelAlias(auth, execReq.Model) - resp, errExec := executor.CountTokens(execCtx, auth, execReq, opts) - result := Result{AuthID: auth.ID, Provider: provider, Model: routeModel, Success: errExec == nil} - if errExec != nil { - result.Error = &Error{Message: errExec.Error()} - var se cliproxyexecutor.StatusError - if errors.As(errExec, &se) && se != nil { - result.Error.HTTPStatus = se.StatusCode() - } - if ra := retryAfterFromError(errExec); ra != nil { - result.RetryAfter = ra - } - m.MarkResult(execCtx, result) - lastErr = errExec - continue - } - m.MarkResult(execCtx, result) - return resp, nil + raw, ok := meta[cliproxyexecutor.RequestedModelMetadataKey] + if !ok || raw == nil { + return false } -} - -func (m *Manager) executeStreamWithProvider(ctx context.Context, provider string, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (<-chan cliproxyexecutor.StreamChunk, error) { - if provider == "" { - return nil, &Error{Code: "provider_not_found", Message: "provider identifier is empty"} - } - routeModel := req.Model - tried := make(map[string]struct{}) - var lastErr error - for { - auth, executor, errPick := m.pickNext(ctx, provider, routeModel, opts, tried) - if errPick != nil { - if lastErr != nil { - return nil, lastErr - } - return nil, errPick - } - - entry := logEntryWithRequestID(ctx) - debugLogAuthSelection(entry, auth, provider, req.Model) - - tried[auth.ID] = struct{}{} - execCtx := ctx - if rt := m.roundTripperFor(auth); rt != nil { - execCtx = context.WithValue(execCtx, roundTripperContextKey{}, rt) - execCtx = context.WithValue(execCtx, "cliproxy.roundtripper", rt) - } - execReq := req - execReq.Model = rewriteModelForAuth(routeModel, auth) - execReq.Model = m.applyOAuthModelAlias(auth, execReq.Model) - execReq.Model = m.applyAPIKeyModelAlias(auth, execReq.Model) - chunks, errStream := executor.ExecuteStream(execCtx, auth, execReq, opts) - if errStream != nil { - rerr := &Error{Message: errStream.Error()} - var se cliproxyexecutor.StatusError - if errors.As(errStream, &se) && se != nil { - rerr.HTTPStatus = se.StatusCode() - } - result := Result{AuthID: auth.ID, Provider: provider, Model: routeModel, Success: false, Error: rerr} - result.RetryAfter = retryAfterFromError(errStream) - m.MarkResult(execCtx, result) - lastErr = errStream - continue - } - out := make(chan cliproxyexecutor.StreamChunk) - go func(streamCtx context.Context, streamAuth *Auth, streamProvider string, streamChunks <-chan cliproxyexecutor.StreamChunk) { - defer close(out) - var failed bool - for chunk := range streamChunks { - if chunk.Err != nil && !failed { - failed = true - rerr := &Error{Message: chunk.Err.Error()} - var se cliproxyexecutor.StatusError - if errors.As(chunk.Err, &se) && se != nil { - rerr.HTTPStatus = se.StatusCode() - } - m.MarkResult(streamCtx, Result{AuthID: streamAuth.ID, Provider: streamProvider, Model: routeModel, Success: false, Error: rerr}) - } - out <- chunk - } - if !failed { - m.MarkResult(streamCtx, Result{AuthID: streamAuth.ID, Provider: streamProvider, Model: routeModel, Success: true}) - } - }(execCtx, auth.Clone(), provider, chunks) - return out, nil + switch v := raw.(type) { + case string: + return strings.TrimSpace(v) != "" + case []byte: + return strings.TrimSpace(string(v)) != "" + default: + return false } } @@ -1140,35 +1027,6 @@ func (m *Manager) normalizeProviders(providers []string) []string { return result } -// rotateProviders returns a rotated view of the providers list starting from the -// current offset for the model, and atomically increments the offset for the next call. -// This ensures concurrent requests get different starting providers. -func (m *Manager) rotateProviders(model string, providers []string) []string { - if len(providers) == 0 { - return nil - } - - // Atomic read-and-increment: get current offset and advance cursor in one lock - m.mu.Lock() - offset := m.providerOffsets[model] - m.providerOffsets[model] = (offset + 1) % len(providers) - m.mu.Unlock() - - if len(providers) > 0 { - offset %= len(providers) - } - if offset < 0 { - offset = 0 - } - if offset == 0 { - return providers - } - rotated := make([]string, 0, len(providers)) - rotated = append(rotated, providers[offset:]...) - rotated = append(rotated, providers[:offset]...) - return rotated -} - func (m *Manager) retrySettings() (int, time.Duration) { if m == nil { return 0, 0 @@ -1250,42 +1108,6 @@ func waitForCooldown(ctx context.Context, wait time.Duration) error { } } -func (m *Manager) executeProvidersOnce(ctx context.Context, providers []string, fn func(context.Context, string) (cliproxyexecutor.Response, error)) (cliproxyexecutor.Response, error) { - if len(providers) == 0 { - return cliproxyexecutor.Response{}, &Error{Code: "provider_not_found", Message: "no provider supplied"} - } - var lastErr error - for _, provider := range providers { - resp, errExec := fn(ctx, provider) - if errExec == nil { - return resp, nil - } - lastErr = errExec - } - if lastErr != nil { - return cliproxyexecutor.Response{}, lastErr - } - return cliproxyexecutor.Response{}, &Error{Code: "auth_not_found", Message: "no auth available"} -} - -func (m *Manager) executeStreamProvidersOnce(ctx context.Context, providers []string, fn func(context.Context, string) (<-chan cliproxyexecutor.StreamChunk, error)) (<-chan cliproxyexecutor.StreamChunk, error) { - if len(providers) == 0 { - return nil, &Error{Code: "provider_not_found", Message: "no provider supplied"} - } - var lastErr error - for _, provider := range providers { - chunks, errExec := fn(ctx, provider) - if errExec == nil { - return chunks, nil - } - lastErr = errExec - } - if lastErr != nil { - return nil, lastErr - } - return nil, &Error{Code: "auth_not_found", Message: "no auth available"} -} - // MarkResult records an execution result and notifies hooks. func (m *Manager) MarkResult(ctx context.Context, result Result) { if result.AuthID == "" { @@ -1371,8 +1193,12 @@ func (m *Manager) MarkResult(ctx context.Context, result Result) { shouldSuspendModel = true setModelQuota = true case 408, 500, 502, 503, 504: - next := now.Add(1 * time.Minute) - state.NextRetryAfter = next + if quotaCooldownDisabled.Load() { + state.NextRetryAfter = time.Time{} + } else { + next := now.Add(1 * time.Minute) + state.NextRetryAfter = next + } default: state.NextRetryAfter = time.Time{} } @@ -1623,7 +1449,11 @@ func applyAuthFailureState(auth *Auth, resultErr *Error, retryAfter *time.Durati auth.NextRetryAfter = next case 408, 500, 502, 503, 504: auth.StatusMessage = "transient upstream error" - auth.NextRetryAfter = now.Add(1 * time.Minute) + if quotaCooldownDisabled.Load() { + auth.NextRetryAfter = time.Time{} + } else { + auth.NextRetryAfter = now.Add(1 * time.Minute) + } default: if auth.StatusMessage == "" { auth.StatusMessage = "request failed" diff --git a/sdk/cliproxy/executor/types.go b/sdk/cliproxy/executor/types.go index c8bb9447..8c11bbc4 100644 --- a/sdk/cliproxy/executor/types.go +++ b/sdk/cliproxy/executor/types.go @@ -7,6 +7,9 @@ import ( sdktranslator "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator" ) +// RequestedModelMetadataKey stores the client-requested model name in Options.Metadata. +const RequestedModelMetadataKey = "requested_model" + // Request encapsulates the translated payload that will be sent to a provider executor. type Request struct { // Model is the upstream model identifier after translation.