From a1634909e85448354d656ad2b3b3f2337b9696bf Mon Sep 17 00:00:00 2001 From: Aldino Kemal Date: Mon, 19 Jan 2026 19:50:36 +0700 Subject: [PATCH 01/20] feat(management): add PATCH endpoint to enable/disable auth files Add new PATCH /v0/management/auth-files/status endpoint that allows toggling the disabled state of auth files without deleting them. This enables users to temporarily disable credentials from the management UI. --- .../api/handlers/management/auth_files.go | 62 +++++++++++++++++++ internal/api/server.go | 1 + 2 files changed, 63 insertions(+) diff --git a/internal/api/handlers/management/auth_files.go b/internal/api/handlers/management/auth_files.go index e6830d1d..005bc7b9 100644 --- a/internal/api/handlers/management/auth_files.go +++ b/internal/api/handlers/management/auth_files.go @@ -747,6 +747,68 @@ 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 + auths := h.authManager.List() + for _, auth := range auths { + if auth.FileName == name || auth.ID == 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 aa78ac2a..8b26044e 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -610,6 +610,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) From 2f6004d74a8cf5706526e836f4da3c13b69e3174 Mon Sep 17 00:00:00 2001 From: Aldino Kemal Date: Mon, 19 Jan 2026 20:05:37 +0700 Subject: [PATCH 02/20] perf(management): optimize auth lookup in PatchAuthFileStatus Use GetByID() for O(1) map lookup first, falling back to iteration only for FileName matching. Consistent with pattern in disableAuth(). --- internal/api/handlers/management/auth_files.go | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/internal/api/handlers/management/auth_files.go b/internal/api/handlers/management/auth_files.go index 005bc7b9..77988fea 100644 --- a/internal/api/handlers/management/auth_files.go +++ b/internal/api/handlers/management/auth_files.go @@ -777,11 +777,15 @@ func (h *Handler) PatchAuthFileStatus(c *gin.Context) { // Find auth by name or ID var targetAuth *coreauth.Auth - auths := h.authManager.List() - for _, auth := range auths { - if auth.FileName == name || auth.ID == name { - targetAuth = auth - break + 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 + } } } From a6cba25bc1ac4720304790db4b08572d2fae9299 Mon Sep 17 00:00:00 2001 From: N1GHT Date: Tue, 20 Jan 2026 17:34:26 +0100 Subject: [PATCH 03/20] Small fix to filter out Top_P when Temperature is set on Claude to make requests go through --- internal/translator/claude/gemini/claude_gemini_request.go | 4 +--- .../claude/openai/chat-completions/claude_openai_request.go | 5 +---- 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/internal/translator/claude/gemini/claude_gemini_request.go b/internal/translator/claude/gemini/claude_gemini_request.go index 32f2d847..4ca3f4a7 100644 --- a/internal/translator/claude/gemini/claude_gemini_request.go +++ b/internal/translator/claude/gemini/claude_gemini_request.go @@ -98,9 +98,7 @@ 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() { 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..526593c6 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,7 @@ 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() { out, _ = sjson.Set(out, "top_p", topP.Float()) } From d81abd401c9f7b56d6b90062c39c3cc9f5716e69 Mon Sep 17 00:00:00 2001 From: N1GHT Date: Tue, 20 Jan 2026 17:36:27 +0100 Subject: [PATCH 04/20] Returned the Code Comment I trashed --- internal/translator/claude/gemini/claude_gemini_request.go | 4 +++- .../claude/openai/chat-completions/claude_openai_request.go | 5 ++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/internal/translator/claude/gemini/claude_gemini_request.go b/internal/translator/claude/gemini/claude_gemini_request.go index 4ca3f4a7..1f25117a 100644 --- a/internal/translator/claude/gemini/claude_gemini_request.go +++ b/internal/translator/claude/gemini/claude_gemini_request.go @@ -98,7 +98,9 @@ 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()) - } else if topP := genConfig.Get("topP"); topP.Exists() { + } + // Top P setting for nucleus sampling (filtered out if temperature is set) + if topP := genConfig.Get("topP"); topP.Exists() && !genConfig.Get("temperature").Exists() { 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 526593c6..5b71ce70 100644 --- a/internal/translator/claude/openai/chat-completions/claude_openai_request.go +++ b/internal/translator/claude/openai/chat-completions/claude_openai_request.go @@ -110,7 +110,10 @@ 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()) - } else if topP := root.Get("top_p"); topP.Exists() { + } + + // Top P setting for nucleus sampling (filtered out if temperature is set) + if topP := root.Get("top_p"); topP.Exists() && !root.Get("temperature").Exists() { out, _ = sjson.Set(out, "top_p", topP.Float()) } From 09970dc7af60d429f7f52cbf3bf52ea7ee801d4e Mon Sep 17 00:00:00 2001 From: N1GHT Date: Tue, 20 Jan 2026 17:51:36 +0100 Subject: [PATCH 05/20] Accept Geminis Review Suggestion --- internal/translator/claude/gemini/claude_gemini_request.go | 5 ++--- .../claude/openai/chat-completions/claude_openai_request.go | 6 ++---- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/internal/translator/claude/gemini/claude_gemini_request.go b/internal/translator/claude/gemini/claude_gemini_request.go index 1f25117a..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 (filtered out if temperature is set) - if topP := genConfig.Get("topP"); topP.Exists() && !genConfig.Get("temperature").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 5b71ce70..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 (filtered out if temperature is set) - if topP := root.Get("top_p"); topP.Exists() && !root.Get("temperature").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()) } From d29ec9552632ae2c8bb22de4a617c590e13debfd Mon Sep 17 00:00:00 2001 From: Vino Date: Wed, 21 Jan 2026 16:45:50 +0800 Subject: [PATCH 06/20] fix(translator): ensure system message is only added if it contains content --- .../translator/openai/claude/openai_claude_request.go | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) 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() { From d9c6317c84b4b19ee2090610bcc19c7a2d9a3b4e Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Wed, 21 Jan 2026 18:30:05 +0800 Subject: [PATCH 07/20] refactor(cache, translator): refine signature caching logic and tests, replace session-based logic with model group handling --- internal/cache/signature_cache.go | 89 +++++++++---------- internal/cache/signature_cache_test.go | 46 +++++----- .../claude/antigravity_claude_request.go | 48 +++++----- .../claude/antigravity_claude_request_test.go | 4 +- .../claude/antigravity_claude_response.go | 2 +- .../antigravity_claude_response_test.go | 16 ++-- .../gemini/antigravity_gemini_request.go | 50 ++++++----- 7 files changed, 129 insertions(+), 126 deletions(-) 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..368d3195 100644 --- a/internal/cache/signature_cache_test.go +++ b/internal/cache/signature_cache_test.go @@ -21,33 +21,33 @@ func TestCacheSignature_BasicStorageAndRetrieval(t *testing.T) { } } -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) + CacheSignature("claude-sonnet-4-5-thinking", text, sig1) + CacheSignature("gpt-4o", text, sig2) - if GetCachedSignature("test-model", text) != sig1 { - t.Error("Session-a signature mismatch") + if GetCachedSignature("claude-sonnet-4-5-thinking", text) != sig1 { + t.Error("Claude signature mismatch") } - if GetCachedSignature("test-model", text) != sig2 { - t.Error("Session-b signature mismatch") + if GetCachedSignature("gpt-4o", text) != sig2 { + t.Error("GPT signature mismatch") } } func TestCacheSignature_NotFound(t *testing.T) { ClearSignatureCache("") - // Non-existent session + // Non-existent cache entry if got := GetCachedSignature("test-model", "some text"); got != "" { - t.Errorf("Expected empty string for nonexistent session, got '%s'", got) + t.Errorf("Expected empty string for missing entry, got '%s'", got) } - // Existing session but different text + // Existing cache but different text CacheSignature("test-model", "text-a", "sigA12345678901234567890123456789012345678901234567890") if got := GetCachedSignature("test-model", "text-b"); got != "" { t.Errorf("Expected empty string for different text, got '%s'", got) @@ -58,7 +58,6 @@ 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 @@ -81,20 +80,21 @@ func TestCacheSignature_ShortSignatureRejected(t *testing.T) { } } -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) + sigClaude := "validSig1234567890123456789012345678901234567890123456" + sigGpt := "validSig9876543210987654321098765432109876543210987654" + CacheSignature("claude-sonnet-4-5-thinking", "text", sigClaude) + CacheSignature("gpt-4o", "text", sigGpt) - ClearSignatureCache("session-1") + ClearSignatureCache("claude-sonnet-4-5-thinking") - if got := GetCachedSignature("test-model", "text"); got != "" { - t.Error("session-1 should be cleared") + if got := GetCachedSignature("claude-sonnet-4-5-thinking", "text"); got != "" { + t.Error("Claude cache should be cleared") } - if got := GetCachedSignature("test-model", "text"); got != sig { - t.Error("session-2 should still exist") + if got := GetCachedSignature("gpt-4o", "text"); got != sigGpt { + t.Error("GPT cache should still exist") } } @@ -108,10 +108,10 @@ func TestClearSignatureCache_AllSessions(t *testing.T) { ClearSignatureCache("") if got := GetCachedSignature("test-model", "text"); got != "" { - t.Error("session-1 should be cleared") + t.Error("cache should be cleared") } if got := GetCachedSignature("test-model", "text"); got != "" { - t.Error("session-2 should be cleared") + t.Error("cache should be cleared") } } diff --git a/internal/translator/antigravity/claude/antigravity_claude_request.go b/internal/translator/antigravity/claude/antigravity_claude_request.go index e87a7d6b..bce76892 100644 --- a/internal/translator/antigravity/claude/antigravity_claude_request.go +++ b/internal/translator/antigravity/claude/antigravity_claude_request.go @@ -98,32 +98,38 @@ func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _ // Use GetThinkingText to handle wrapped thinking objects thinkingText := thinking.GetThinkingText(contentResult) - // Always try cached signature first (more reliable than client-provided) - // Client may send stale or invalid signatures from different sessions signature := "" - if thinkingText != "" { - if cachedSig := cache.GetCachedSignature(modelName, thinkingText); cachedSig != "" { - signature = cachedSig - // log.Debugf("Using cached signature for thinking block") - } - } + signatureResult := contentResult.Get("signature") + hasClientSignature := signatureResult.Exists() && signatureResult.String() != "" - // Fallback to client signature only if cache miss and client signature is valid - if signature == "" { - signatureResult := contentResult.Get("signature") - clientSignature := "" - if signatureResult.Exists() && signatureResult.String() != "" { - arrayClientSignatures := strings.SplitN(signatureResult.String(), "#", 2) - if len(arrayClientSignatures) == 2 { - if modelName == arrayClientSignatures[0] { - clientSignature = arrayClientSignatures[1] - } + // Only consider cached signatures when the client provided a signature. + // Unsigned thinking blocks must be dropped. + if hasClientSignature { + // Always try cached signature first (more reliable than client-provided) + // Client may send stale or invalid signatures from other requests + if thinkingText != "" { + if cachedSig := cache.GetCachedSignature(modelName, thinkingText); cachedSig != "" { + signature = cachedSig + // log.Debugf("Using cached signature for thinking block") } } - if cache.HasValidSignature(modelName, clientSignature) { - signature = clientSignature + + // Fallback to client signature only if cache miss and client signature is valid + if signature == "" { + clientSignature := "" + if signatureResult.Exists() && signatureResult.String() != "" { + arrayClientSignatures := strings.SplitN(signatureResult.String(), "#", 2) + if len(arrayClientSignatures) == 2 { + if modelName == arrayClientSignatures[0] { + clientSignature = arrayClientSignatures[1] + } + } + } + if cache.HasValidSignature(modelName, clientSignature) { + signature = clientSignature + } + // log.Debugf("Using client-provided signature for thinking block") } - // log.Debugf("Using client-provided signature for thinking block") } // Store for subsequent tool_use in the same message diff --git a/internal/translator/antigravity/claude/antigravity_claude_request_test.go b/internal/translator/antigravity/claude/antigravity_claude_request_test.go index 6eb58795..7831b8bd 100644 --- a/internal/translator/antigravity/claude/antigravity_claude_request_test.go +++ b/internal/translator/antigravity/claude/antigravity_claude_request_test.go @@ -78,9 +78,7 @@ func TestConvertClaudeRequestToAntigravity_ThinkingBlocks(t *testing.T) { 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": [ 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.go b/internal/translator/antigravity/gemini/antigravity_gemini_request.go index 2ad9bd80..37346119 100644 --- a/internal/translator/antigravity/gemini/antigravity_gemini_request.go +++ b/internal/translator/antigravity/gemini/antigravity_gemini_request.go @@ -99,36 +99,44 @@ func ConvertGeminiRequestToAntigravity(modelName string, inputRawJSON []byte, _ } // Gemini-specific handling for non-Claude models: + // - Remove thinking parts entirely. // - Add skip_thought_signature_validator to functionCall parts so upstream can bypass signature validation. - // - Also mark thinking parts with the same sentinel when present (we keep the parts; we only annotate them). if !strings.Contains(modelName, "claude") { const skipSentinel = "skip_thought_signature_validator" gjson.GetBytes(rawJSON, "request.contents").ForEach(func(contentIdx, content gjson.Result) bool { - if content.Get("role").String() == "model" { - // First pass: collect indices of thinking parts to mark with skip sentinel - var thinkingIndicesToSkipSignature []int64 - content.Get("parts").ForEach(func(partIdx, part gjson.Result) bool { - // Collect indices of thinking blocks to mark with skip sentinel - if part.Get("thought").Bool() { - thinkingIndicesToSkipSignature = append(thinkingIndicesToSkipSignature, partIdx.Int()) - } - // Add skip sentinel to functionCall parts - if part.Get("functionCall").Exists() { - existingSig := part.Get("thoughtSignature").String() - if existingSig == "" || len(existingSig) < 50 { - rawJSON, _ = sjson.SetBytes(rawJSON, fmt.Sprintf("request.contents.%d.parts.%d.thoughtSignature", contentIdx.Int(), partIdx.Int()), skipSentinel) + if content.Get("role").String() != "model" { + return true + } + partsResult := content.Get("parts") + if !partsResult.IsArray() { + return true + } + + parts := partsResult.Array() + newParts := make([]interface{}, 0, len(parts)) + for _, part := range parts { + if part.Get("thought").Bool() { + continue + } + + partRaw := part.Raw + if part.Get("functionCall").Exists() { + existingSig := part.Get("thoughtSignature").String() + if existingSig == "" || len(existingSig) < 50 { + updatedPart, errSet := sjson.Set(partRaw, "thoughtSignature", skipSentinel) + if errSet != nil { + log.WithError(errSet).Debug("failed to set thoughtSignature on functionCall part") + } else { + partRaw = updatedPart } } - return true - }) - - // Add skip_thought_signature_validator sentinel to thinking blocks in reverse order to preserve indices - for i := len(thinkingIndicesToSkipSignature) - 1; i >= 0; i-- { - idx := thinkingIndicesToSkipSignature[i] - rawJSON, _ = sjson.SetBytes(rawJSON, fmt.Sprintf("request.contents.%d.parts.%d.thoughtSignature", contentIdx.Int(), idx), skipSentinel) } + + newParts = append(newParts, gjson.Parse(partRaw).Value()) } + + rawJSON, _ = sjson.SetBytes(rawJSON, fmt.Sprintf("request.contents.%d.parts", contentIdx.Int()), newParts) return true }) } From c8884f5e2588b186fa0a37afa30e27e8582cdaad Mon Sep 17 00:00:00 2001 From: hkfires <10558748+hkfires@users.noreply.github.com> Date: Wed, 21 Jan 2026 20:21:49 +0800 Subject: [PATCH 08/20] refactor(translator): enhance signature handling in Claude and Gemini requests, streamline cache usage and remove unnecessary tests --- go.mod | 2 +- .../claude/antigravity_claude_request.go | 46 ++++++++--------- .../claude/antigravity_claude_request_test.go | 10 ++++ .../gemini/antigravity_gemini_request.go | 50 ++++++++----------- .../gemini/antigravity_gemini_request_test.go | 34 ------------- 5 files changed, 52 insertions(+), 90 deletions(-) diff --git a/go.mod b/go.mod index 963d9c49..863d0413 100644 --- a/go.mod +++ b/go.mod @@ -21,6 +21,7 @@ require ( golang.org/x/crypto v0.45.0 golang.org/x/net v0.47.0 golang.org/x/oauth2 v0.30.0 + golang.org/x/text v0.31.0 gopkg.in/natefinch/lumberjack.v2 v2.2.1 gopkg.in/yaml.v3 v3.0.1 ) @@ -70,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/translator/antigravity/claude/antigravity_claude_request.go b/internal/translator/antigravity/claude/antigravity_claude_request.go index bce76892..e87a7d6b 100644 --- a/internal/translator/antigravity/claude/antigravity_claude_request.go +++ b/internal/translator/antigravity/claude/antigravity_claude_request.go @@ -98,38 +98,32 @@ func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _ // Use GetThinkingText to handle wrapped thinking objects thinkingText := thinking.GetThinkingText(contentResult) + // Always try cached signature first (more reliable than client-provided) + // Client may send stale or invalid signatures from different sessions signature := "" - signatureResult := contentResult.Get("signature") - hasClientSignature := signatureResult.Exists() && signatureResult.String() != "" - - // Only consider cached signatures when the client provided a signature. - // Unsigned thinking blocks must be dropped. - if hasClientSignature { - // Always try cached signature first (more reliable than client-provided) - // Client may send stale or invalid signatures from other requests - if thinkingText != "" { - if cachedSig := cache.GetCachedSignature(modelName, thinkingText); cachedSig != "" { - signature = cachedSig - // log.Debugf("Using cached signature for thinking block") - } + if thinkingText != "" { + if cachedSig := cache.GetCachedSignature(modelName, thinkingText); cachedSig != "" { + signature = cachedSig + // log.Debugf("Using cached signature for thinking block") } + } - // Fallback to client signature only if cache miss and client signature is valid - if signature == "" { - clientSignature := "" - if signatureResult.Exists() && signatureResult.String() != "" { - arrayClientSignatures := strings.SplitN(signatureResult.String(), "#", 2) - if len(arrayClientSignatures) == 2 { - if modelName == arrayClientSignatures[0] { - clientSignature = arrayClientSignatures[1] - } + // Fallback to client signature only if cache miss and client signature is valid + if signature == "" { + signatureResult := contentResult.Get("signature") + clientSignature := "" + if signatureResult.Exists() && signatureResult.String() != "" { + arrayClientSignatures := strings.SplitN(signatureResult.String(), "#", 2) + if len(arrayClientSignatures) == 2 { + if modelName == arrayClientSignatures[0] { + clientSignature = arrayClientSignatures[1] } } - if cache.HasValidSignature(modelName, clientSignature) { - signature = clientSignature - } - // log.Debugf("Using client-provided signature for thinking block") } + if cache.HasValidSignature(modelName, clientSignature) { + signature = clientSignature + } + // log.Debugf("Using client-provided signature for thinking block") } // Store for subsequent tool_use in the same message diff --git a/internal/translator/antigravity/claude/antigravity_claude_request_test.go b/internal/translator/antigravity/claude/antigravity_claude_request_test.go index 7831b8bd..9f40b9fa 100644 --- a/internal/translator/antigravity/claude/antigravity_claude_request_test.go +++ b/internal/translator/antigravity/claude/antigravity_claude_request_test.go @@ -74,6 +74,8 @@ 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..." @@ -115,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", @@ -236,6 +240,8 @@ func TestConvertClaudeRequestToAntigravity_ToolUse(t *testing.T) { } func TestConvertClaudeRequestToAntigravity_ToolUse_WithSignature(t *testing.T) { + cache.ClearSignatureCache("") + validSignature := "abc123validSignature1234567890123456789012345678901234567890" thinkingText := "Let me think..." @@ -277,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..." @@ -485,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/gemini/antigravity_gemini_request.go b/internal/translator/antigravity/gemini/antigravity_gemini_request.go index 37346119..2ad9bd80 100644 --- a/internal/translator/antigravity/gemini/antigravity_gemini_request.go +++ b/internal/translator/antigravity/gemini/antigravity_gemini_request.go @@ -99,44 +99,36 @@ func ConvertGeminiRequestToAntigravity(modelName string, inputRawJSON []byte, _ } // Gemini-specific handling for non-Claude models: - // - Remove thinking parts entirely. // - Add skip_thought_signature_validator to functionCall parts so upstream can bypass signature validation. + // - Also mark thinking parts with the same sentinel when present (we keep the parts; we only annotate them). if !strings.Contains(modelName, "claude") { const skipSentinel = "skip_thought_signature_validator" gjson.GetBytes(rawJSON, "request.contents").ForEach(func(contentIdx, content gjson.Result) bool { - if content.Get("role").String() != "model" { - return true - } - partsResult := content.Get("parts") - if !partsResult.IsArray() { - return true - } - - parts := partsResult.Array() - newParts := make([]interface{}, 0, len(parts)) - for _, part := range parts { - if part.Get("thought").Bool() { - continue - } - - partRaw := part.Raw - if part.Get("functionCall").Exists() { - existingSig := part.Get("thoughtSignature").String() - if existingSig == "" || len(existingSig) < 50 { - updatedPart, errSet := sjson.Set(partRaw, "thoughtSignature", skipSentinel) - if errSet != nil { - log.WithError(errSet).Debug("failed to set thoughtSignature on functionCall part") - } else { - partRaw = updatedPart + if content.Get("role").String() == "model" { + // First pass: collect indices of thinking parts to mark with skip sentinel + var thinkingIndicesToSkipSignature []int64 + content.Get("parts").ForEach(func(partIdx, part gjson.Result) bool { + // Collect indices of thinking blocks to mark with skip sentinel + if part.Get("thought").Bool() { + thinkingIndicesToSkipSignature = append(thinkingIndicesToSkipSignature, partIdx.Int()) + } + // Add skip sentinel to functionCall parts + if part.Get("functionCall").Exists() { + existingSig := part.Get("thoughtSignature").String() + if existingSig == "" || len(existingSig) < 50 { + rawJSON, _ = sjson.SetBytes(rawJSON, fmt.Sprintf("request.contents.%d.parts.%d.thoughtSignature", contentIdx.Int(), partIdx.Int()), skipSentinel) } } + return true + }) + + // Add skip_thought_signature_validator sentinel to thinking blocks in reverse order to preserve indices + for i := len(thinkingIndicesToSkipSignature) - 1; i >= 0; i-- { + idx := thinkingIndicesToSkipSignature[i] + rawJSON, _ = sjson.SetBytes(rawJSON, fmt.Sprintf("request.contents.%d.parts.%d.thoughtSignature", contentIdx.Int(), idx), skipSentinel) } - - newParts = append(newParts, gjson.Parse(partRaw).Value()) } - - rawJSON, _ = sjson.SetBytes(rawJSON, fmt.Sprintf("request.contents.%d.parts", contentIdx.Int()), newParts) return true }) } 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(`{ From 30a59168d753d3079f83b69b9247800dfb1efd31 Mon Sep 17 00:00:00 2001 From: sxjeru Date: Wed, 21 Jan 2026 21:48:23 +0800 Subject: [PATCH 09/20] fix(auth): handle quota cooldown in retry logic for transient errors --- sdk/cliproxy/auth/conductor.go | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/sdk/cliproxy/auth/conductor.go b/sdk/cliproxy/auth/conductor.go index 43483672..5285b912 100644 --- a/sdk/cliproxy/auth/conductor.go +++ b/sdk/cliproxy/auth/conductor.go @@ -1371,8 +1371,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 +1627,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" From a2f8f59192525eb5b3e6f153b9496d2cd102afbb Mon Sep 17 00:00:00 2001 From: sowar1987 <178468696@qq.com> Date: Wed, 21 Jan 2026 09:09:40 +0800 Subject: [PATCH 10/20] Fix Gemini function-calling INVALID_ARGUMENT by relaxing Gemini tool validation and cleaning schema --- .../runtime/executor/antigravity_executor.go | 19 +++- .../gemini-cli_openai_request.go | 8 +- .../gemini-cli_openai_response.go | 6 +- .../gemini-cli/gemini_gemini-cli_request.go | 13 --- .../chat-completions/gemini_openai_request.go | 8 +- .../gemini_openai_response.go | 6 +- .../gemini_openai-responses_request.go | 10 +- internal/util/gemini_schema.go | 97 ++++++++++++++++++- 8 files changed, 138 insertions(+), 29 deletions(-) diff --git a/internal/runtime/executor/antigravity_executor.go b/internal/runtime/executor/antigravity_executor.go index 897004fb..e17d525c 100644 --- a/internal/runtime/executor/antigravity_executor.go +++ b/internal/runtime/executor/antigravity_executor.go @@ -1214,6 +1214,17 @@ func (e *AntigravityExecutor) buildRequest(ctx context.Context, auth *cliproxyau // 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) } @@ -1405,7 +1416,13 @@ 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 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.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 { diff --git a/internal/translator/gemini-cli/openai/chat-completions/gemini-cli_openai_request.go b/internal/translator/gemini-cli/openai/chat-completions/gemini-cli_openai_request.go index 85669689..cf3cbaa4 100644 --- a/internal/translator/gemini-cli/openai/chat-completions/gemini-cli_openai_request.go +++ b/internal/translator/gemini-cli/openai/chat-completions/gemini-cli_openai_request.go @@ -249,7 +249,8 @@ func ConvertOpenAIRequestToGeminiCLI(modelName string, inputRawJSON []byte, _ bo fid := tc.Get("id").String() fname := tc.Get("function.name").String() fargs := tc.Get("function.arguments").String() - node, _ = sjson.SetBytes(node, "parts."+itoa(p)+".functionCall.name", fname) + node, _ = sjson.SetBytes(node, "parts."+itoa(p)+".functionCall.id", fid) + node, _ = sjson.SetBytes(node, "parts."+itoa(p)+".functionCall.name", fname) node, _ = sjson.SetRawBytes(node, "parts."+itoa(p)+".functionCall.args", []byte(fargs)) node, _ = sjson.SetBytes(node, "parts."+itoa(p)+".thoughtSignature", geminiCLIFunctionThoughtSignature) p++ @@ -264,12 +265,13 @@ func ConvertOpenAIRequestToGeminiCLI(modelName string, inputRawJSON []byte, _ bo pp := 0 for _, fid := range fIDs { if name, ok := tcID2Name[fid]; ok { - toolNode, _ = sjson.SetBytes(toolNode, "parts."+itoa(pp)+".functionResponse.name", name) + toolNode, _ = sjson.SetBytes(toolNode, "parts."+itoa(pp)+".functionResponse.id", fid) + toolNode, _ = sjson.SetBytes(toolNode, "parts."+itoa(pp)+".functionResponse.name", name) resp := toolResponses[fid] if resp == "" { resp = "{}" } - toolNode, _ = sjson.SetBytes(toolNode, "parts."+itoa(pp)+".functionResponse.response.result", []byte(resp)) + toolNode, _ = sjson.SetBytes(toolNode, "parts."+itoa(pp)+".functionResponse.response.output", []byte(resp)) pp++ } } diff --git a/internal/translator/gemini-cli/openai/chat-completions/gemini-cli_openai_response.go b/internal/translator/gemini-cli/openai/chat-completions/gemini-cli_openai_response.go index 5a1faf51..d429f7fe 100644 --- a/internal/translator/gemini-cli/openai/chat-completions/gemini-cli_openai_response.go +++ b/internal/translator/gemini-cli/openai/chat-completions/gemini-cli_openai_response.go @@ -149,7 +149,11 @@ func ConvertCliResponseToOpenAI(_ context.Context, _ string, originalRequestRawJ functionCallTemplate := `{"id": "","index": 0,"type": "function","function": {"name": "","arguments": ""}}` fcName := functionCallResult.Get("name").String() - functionCallTemplate, _ = sjson.Set(functionCallTemplate, "id", fmt.Sprintf("%s-%d-%d", fcName, time.Now().UnixNano(), atomic.AddUint64(&functionCallIDCounter, 1))) + fcID := functionCallResult.Get("id").String() + if fcID == "" { + fcID = fmt.Sprintf("%s-%d-%d", fcName, time.Now().UnixNano(), atomic.AddUint64(&functionCallIDCounter, 1)) + } + functionCallTemplate, _ = sjson.Set(functionCallTemplate, "id", fcID) functionCallTemplate, _ = sjson.Set(functionCallTemplate, "index", functionCallIndex) functionCallTemplate, _ = sjson.Set(functionCallTemplate, "function.name", fcName) if fcArgsResult := functionCallResult.Get("args"); fcArgsResult.Exists() { diff --git a/internal/translator/gemini/gemini-cli/gemini_gemini-cli_request.go b/internal/translator/gemini/gemini-cli/gemini_gemini-cli_request.go index 3b70bd3e..a917e807 100644 --- a/internal/translator/gemini/gemini-cli/gemini_gemini-cli_request.go +++ b/internal/translator/gemini/gemini-cli/gemini_gemini-cli_request.go @@ -46,19 +46,6 @@ func ConvertGeminiCLIRequestToGemini(_ string, inputRawJSON []byte, _ bool) []by } } - gjson.GetBytes(rawJSON, "contents").ForEach(func(key, content gjson.Result) bool { - if content.Get("role").String() == "model" { - content.Get("parts").ForEach(func(partKey, part gjson.Result) bool { - if part.Get("functionCall").Exists() { - rawJSON, _ = sjson.SetBytes(rawJSON, fmt.Sprintf("contents.%d.parts.%d.thoughtSignature", key.Int(), partKey.Int()), "skip_thought_signature_validator") - } else if part.Get("thoughtSignature").Exists() { - rawJSON, _ = sjson.SetBytes(rawJSON, fmt.Sprintf("contents.%d.parts.%d.thoughtSignature", key.Int(), partKey.Int()), "skip_thought_signature_validator") - } - return true - }) - } - return true - }) return common.AttachDefaultSafetySettings(rawJSON, "safetySettings") } diff --git a/internal/translator/gemini/openai/chat-completions/gemini_openai_request.go b/internal/translator/gemini/openai/chat-completions/gemini_openai_request.go index ba8b47e3..2a41e1a6 100644 --- a/internal/translator/gemini/openai/chat-completions/gemini_openai_request.go +++ b/internal/translator/gemini/openai/chat-completions/gemini_openai_request.go @@ -255,7 +255,8 @@ func ConvertOpenAIRequestToGemini(modelName string, inputRawJSON []byte, _ bool) fid := tc.Get("id").String() fname := tc.Get("function.name").String() fargs := tc.Get("function.arguments").String() - node, _ = sjson.SetBytes(node, "parts."+itoa(p)+".functionCall.name", fname) + node, _ = sjson.SetBytes(node, "parts."+itoa(p)+".functionCall.id", fid) + node, _ = sjson.SetBytes(node, "parts."+itoa(p)+".functionCall.name", fname) node, _ = sjson.SetRawBytes(node, "parts."+itoa(p)+".functionCall.args", []byte(fargs)) node, _ = sjson.SetBytes(node, "parts."+itoa(p)+".thoughtSignature", geminiFunctionThoughtSignature) p++ @@ -270,12 +271,13 @@ func ConvertOpenAIRequestToGemini(modelName string, inputRawJSON []byte, _ bool) pp := 0 for _, fid := range fIDs { if name, ok := tcID2Name[fid]; ok { - toolNode, _ = sjson.SetBytes(toolNode, "parts."+itoa(pp)+".functionResponse.name", name) + toolNode, _ = sjson.SetBytes(toolNode, "parts."+itoa(pp)+".functionResponse.id", fid) + toolNode, _ = sjson.SetBytes(toolNode, "parts."+itoa(pp)+".functionResponse.name", name) resp := toolResponses[fid] if resp == "" { resp = "{}" } - toolNode, _ = sjson.SetBytes(toolNode, "parts."+itoa(pp)+".functionResponse.response.result", []byte(resp)) + toolNode, _ = sjson.SetBytes(toolNode, "parts."+itoa(pp)+".functionResponse.response.output", []byte(resp)) pp++ } } diff --git a/internal/translator/gemini/openai/chat-completions/gemini_openai_response.go b/internal/translator/gemini/openai/chat-completions/gemini_openai_response.go index 9cce35f9..92156c7f 100644 --- a/internal/translator/gemini/openai/chat-completions/gemini_openai_response.go +++ b/internal/translator/gemini/openai/chat-completions/gemini_openai_response.go @@ -187,7 +187,11 @@ func ConvertGeminiResponseToOpenAI(_ context.Context, _ string, originalRequestR functionCallTemplate := `{"id": "","index": 0,"type": "function","function": {"name": "","arguments": ""}}` fcName := functionCallResult.Get("name").String() - functionCallTemplate, _ = sjson.Set(functionCallTemplate, "id", fmt.Sprintf("%s-%d-%d", fcName, time.Now().UnixNano(), atomic.AddUint64(&functionCallIDCounter, 1))) + fcID := functionCallResult.Get("id").String() + if fcID == "" { + fcID = fmt.Sprintf("%s-%d-%d", fcName, time.Now().UnixNano(), atomic.AddUint64(&functionCallIDCounter, 1)) + } + functionCallTemplate, _ = sjson.Set(functionCallTemplate, "id", fcID) functionCallTemplate, _ = sjson.Set(functionCallTemplate, "index", functionCallIndex) functionCallTemplate, _ = sjson.Set(functionCallTemplate, "function.name", fcName) if fcArgsResult := functionCallResult.Get("args"); fcArgsResult.Exists() { diff --git a/internal/translator/gemini/openai/responses/gemini_openai-responses_request.go b/internal/translator/gemini/openai/responses/gemini_openai-responses_request.go index 5277b71b..adeba903 100644 --- a/internal/translator/gemini/openai/responses/gemini_openai-responses_request.go +++ b/internal/translator/gemini/openai/responses/gemini_openai-responses_request.go @@ -290,11 +290,11 @@ func ConvertOpenAIResponsesRequestToGemini(modelName string, inputRawJSON []byte // Set the raw JSON output directly (preserves string encoding) if outputRaw != "" && outputRaw != "null" { output := gjson.Parse(outputRaw) - if output.Type == gjson.JSON { - functionResponse, _ = sjson.SetRaw(functionResponse, "functionResponse.response.result", output.Raw) - } else { - functionResponse, _ = sjson.Set(functionResponse, "functionResponse.response.result", outputRaw) - } + if output.Type == gjson.JSON { + functionResponse, _ = sjson.SetRaw(functionResponse, "functionResponse.response.output", output.Raw) + } else { + functionResponse, _ = sjson.Set(functionResponse, "functionResponse.response.output", outputRaw) + } } functionContent, _ = sjson.SetRaw(functionContent, "parts.-1", functionResponse) out, _ = sjson.SetRaw(out, "contents.-1", functionContent) diff --git a/internal/util/gemini_schema.go b/internal/util/gemini_schema.go index c7cb0f40..c22e98c9 100644 --- a/internal/util/gemini_schema.go +++ b/internal/util/gemini_schema.go @@ -16,6 +16,93 @@ var gjsonPathKeyReplacer = strings.NewReplacer(".", "\\.", "*", "\\*", "?", "\\? // 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) +} + +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 != "Brief explanation of why you are calling this tool" { + 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 +} + +// 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) +} + +func cleanJSONSchema(jsonStr string, addPlaceholder bool) string { // Phase 1: Convert and add hints jsonStr = convertRefsToHints(jsonStr) jsonStr = convertConstToEnum(jsonStr) @@ -31,10 +118,16 @@ 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 } From 22ce65ac72c65c10ec1d5a6eb5a9635cd02e19e1 Mon Sep 17 00:00:00 2001 From: sowar1987 <178468696@qq.com> Date: Wed, 21 Jan 2026 14:23:00 +0800 Subject: [PATCH 11/20] test: update signature cache tests Revert gemini translator changes for scheme A Co-Authored-By: Warp --- internal/cache/signature_cache_test.go | 108 +++++++++--------- .../gemini-cli_openai_request.go | 8 +- .../gemini-cli_openai_response.go | 6 +- .../gemini-cli/gemini_gemini-cli_request.go | 13 +++ .../chat-completions/gemini_openai_request.go | 8 +- .../gemini_openai_response.go | 6 +- .../gemini_openai-responses_request.go | 10 +- 7 files changed, 81 insertions(+), 78 deletions(-) diff --git a/internal/cache/signature_cache_test.go b/internal/cache/signature_cache_test.go index 368d3195..7b413473 100644 --- a/internal/cache/signature_cache_test.go +++ b/internal/cache/signature_cache_test.go @@ -4,18 +4,20 @@ import ( "testing" "time" ) +const testModelName = "claude-sonnet-4-5" func TestCacheSignature_BasicStorageAndRetrieval(t *testing.T) { ClearSignatureCache("") + sessionID := "test-session-1" text := "This is some thinking text content" signature := "abc123validSignature1234567890123456789012345678901234567890" // Store signature - CacheSignature("test-model", text, signature) + CacheSignature(testModelName, sessionID, text, signature) // Retrieve signature - retrieved := GetCachedSignature("test-model", text) + retrieved := GetCachedSignature(testModelName, sessionID, text) if retrieved != signature { t.Errorf("Expected signature '%s', got '%s'", signature, retrieved) } @@ -27,29 +29,28 @@ func TestCacheSignature_DifferentModelGroups(t *testing.T) { text := "Same text across models" sig1 := "signature1_1234567890123456789012345678901234567890123456" sig2 := "signature2_1234567890123456789012345678901234567890123456" + CacheSignature(testModelName, "session-a", text, sig1) + CacheSignature(testModelName, "session-b", text, sig2) - CacheSignature("claude-sonnet-4-5-thinking", text, sig1) - CacheSignature("gpt-4o", text, sig2) - - if GetCachedSignature("claude-sonnet-4-5-thinking", text) != sig1 { - t.Error("Claude signature mismatch") + if GetCachedSignature(testModelName, "session-a", text) != sig1 { + t.Error("Session-a signature mismatch") } - if GetCachedSignature("gpt-4o", text) != sig2 { - t.Error("GPT signature mismatch") + if GetCachedSignature(testModelName, "session-b", text) != sig2 { + t.Error("Session-b signature mismatch") } } func TestCacheSignature_NotFound(t *testing.T) { ClearSignatureCache("") - // Non-existent cache entry - if got := GetCachedSignature("test-model", "some text"); got != "" { - t.Errorf("Expected empty string for missing entry, got '%s'", got) + // Non-existent session + if got := GetCachedSignature(testModelName, "nonexistent", "some text"); got != "" { + t.Errorf("Expected empty string for nonexistent session, got '%s'", got) } - // Existing cache but different text - CacheSignature("test-model", "text-a", "sigA12345678901234567890123456789012345678901234567890") - if got := GetCachedSignature("test-model", "text-b"); got != "" { + // Existing session but different text + CacheSignature(testModelName, "session-x", "text-a", "sigA12345678901234567890123456789012345678901234567890") + if got := GetCachedSignature(testModelName, "session-x", "text-b"); got != "" { t.Errorf("Expected empty string for different text, got '%s'", got) } } @@ -58,11 +59,12 @@ func TestCacheSignature_EmptyInputs(t *testing.T) { ClearSignatureCache("") // All empty/invalid inputs should be no-ops - CacheSignature("test-model", "", "sig12345678901234567890123456789012345678901234567890") - CacheSignature("test-model", "text", "") - CacheSignature("test-model", "text", "short") // Too short + CacheSignature(testModelName, "", "text", "sig12345678901234567890123456789012345678901234567890") + CacheSignature(testModelName, "session", "", "sig12345678901234567890123456789012345678901234567890") + CacheSignature(testModelName, "session", "text", "") + CacheSignature(testModelName, "session", "text", "short") // Too short - if got := GetCachedSignature("test-model", "text"); got != "" { + if got := GetCachedSignature(testModelName, "session", "text"); got != "" { t.Errorf("Expected empty after invalid cache attempts, got '%s'", got) } } @@ -70,12 +72,12 @@ func TestCacheSignature_EmptyInputs(t *testing.T) { func TestCacheSignature_ShortSignatureRejected(t *testing.T) { ClearSignatureCache("") + sessionID := "test-short-sig" text := "Some text" shortSig := "abc123" // Less than 50 chars + CacheSignature(testModelName, sessionID, text, shortSig) - CacheSignature("test-model", text, shortSig) - - if got := GetCachedSignature("test-model", text); got != "" { + if got := GetCachedSignature(testModelName, sessionID, text); got != "" { t.Errorf("Short signature should be rejected, got '%s'", got) } } @@ -83,18 +85,17 @@ func TestCacheSignature_ShortSignatureRejected(t *testing.T) { func TestClearSignatureCache_ModelGroup(t *testing.T) { ClearSignatureCache("") - sigClaude := "validSig1234567890123456789012345678901234567890123456" - sigGpt := "validSig9876543210987654321098765432109876543210987654" - CacheSignature("claude-sonnet-4-5-thinking", "text", sigClaude) - CacheSignature("gpt-4o", "text", sigGpt) + sig := "validSig1234567890123456789012345678901234567890123456" + CacheSignature(testModelName, "session-1", "text", sig) + CacheSignature(testModelName, "session-2", "text", sig) - ClearSignatureCache("claude-sonnet-4-5-thinking") + ClearSignatureCache(GetModelGroup(testModelName) + "#session-1") - if got := GetCachedSignature("claude-sonnet-4-5-thinking", "text"); got != "" { - t.Error("Claude cache should be cleared") + if got := GetCachedSignature(testModelName, "session-1", "text"); got != "" { + t.Error("session-1 should be cleared") } - if got := GetCachedSignature("gpt-4o", "text"); got != sigGpt { - t.Error("GPT cache should still exist") + if got := GetCachedSignature(testModelName, "session-2", "text"); got != sig { + t.Error("session-2 should still exist") } } @@ -102,16 +103,16 @@ func TestClearSignatureCache_AllSessions(t *testing.T) { ClearSignatureCache("") sig := "validSig1234567890123456789012345678901234567890123456" - CacheSignature("test-model", "text", sig) - CacheSignature("test-model", "text", sig) + CacheSignature(testModelName, "session-1", "text", sig) + CacheSignature(testModelName, "session-2", "text", sig) ClearSignatureCache("") - if got := GetCachedSignature("test-model", "text"); got != "" { - t.Error("cache should be cleared") + if got := GetCachedSignature(testModelName, "session-1", "text"); got != "" { + t.Error("session-1 should be cleared") } - if got := GetCachedSignature("test-model", "text"); got != "" { - t.Error("cache should be cleared") + if got := GetCachedSignature(testModelName, "session-2", "text"); got != "" { + t.Error("session-2 should be cleared") } } @@ -130,7 +131,7 @@ func TestHasValidSignature(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result := HasValidSignature("claude-sonnet-4-5-thinking", tt.signature) + result := HasValidSignature(tt.signature) if result != tt.expected { t.Errorf("HasValidSignature(%q) = %v, expected %v", tt.signature, result, tt.expected) } @@ -141,19 +142,20 @@ func TestHasValidSignature(t *testing.T) { func TestCacheSignature_TextHashCollisionResistance(t *testing.T) { ClearSignatureCache("") + sessionID := "hash-test-session" + // Different texts should produce different hashes text1 := "First thinking text" text2 := "Second thinking text" sig1 := "signature1_1234567890123456789012345678901234567890123456" sig2 := "signature2_1234567890123456789012345678901234567890123456" + CacheSignature(testModelName, sessionID, text1, sig1) + CacheSignature(testModelName, sessionID, text2, sig2) - CacheSignature("test-model", text1, sig1) - CacheSignature("test-model", text2, sig2) - - if GetCachedSignature("test-model", text1) != sig1 { + if GetCachedSignature(testModelName, sessionID, text1) != sig1 { t.Error("text1 signature mismatch") } - if GetCachedSignature("test-model", text2) != sig2 { + if GetCachedSignature(testModelName, sessionID, text2) != sig2 { t.Error("text2 signature mismatch") } } @@ -161,12 +163,12 @@ func TestCacheSignature_TextHashCollisionResistance(t *testing.T) { func TestCacheSignature_UnicodeText(t *testing.T) { ClearSignatureCache("") + sessionID := "unicode-session" text := "한글 텍스트와 이모지 🎉 그리고 特殊文字" sig := "unicodeSig123456789012345678901234567890123456789012345" + CacheSignature(testModelName, sessionID, text, sig) - CacheSignature("test-model", text, sig) - - if got := GetCachedSignature("test-model", text); got != sig { + if got := GetCachedSignature(testModelName, sessionID, text); got != sig { t.Errorf("Unicode text signature retrieval failed, got '%s'", got) } } @@ -174,14 +176,14 @@ func TestCacheSignature_UnicodeText(t *testing.T) { func TestCacheSignature_Overwrite(t *testing.T) { ClearSignatureCache("") + sessionID := "overwrite-session" text := "Same text" sig1 := "firstSignature12345678901234567890123456789012345678901" sig2 := "secondSignature1234567890123456789012345678901234567890" + CacheSignature(testModelName, sessionID, text, sig1) + CacheSignature(testModelName, sessionID, text, sig2) // Overwrite - CacheSignature("test-model", text, sig1) - CacheSignature("test-model", text, sig2) // Overwrite - - if got := GetCachedSignature("test-model", text); got != sig2 { + if got := GetCachedSignature(testModelName, sessionID, text); got != sig2 { t.Errorf("Expected overwritten signature '%s', got '%s'", sig2, got) } } @@ -193,13 +195,13 @@ func TestCacheSignature_ExpirationLogic(t *testing.T) { // This test verifies the expiration check exists // In a real scenario, we'd mock time.Now() + sessionID := "expiration-test" text := "text" sig := "validSig1234567890123456789012345678901234567890123456" - - CacheSignature("test-model", text, sig) + CacheSignature(testModelName, sessionID, text, sig) // Fresh entry should be retrievable - if got := GetCachedSignature("test-model", text); got != sig { + if got := GetCachedSignature(testModelName, sessionID, text); got != sig { t.Errorf("Fresh entry should be retrievable, got '%s'", got) } diff --git a/internal/translator/gemini-cli/openai/chat-completions/gemini-cli_openai_request.go b/internal/translator/gemini-cli/openai/chat-completions/gemini-cli_openai_request.go index cf3cbaa4..85669689 100644 --- a/internal/translator/gemini-cli/openai/chat-completions/gemini-cli_openai_request.go +++ b/internal/translator/gemini-cli/openai/chat-completions/gemini-cli_openai_request.go @@ -249,8 +249,7 @@ func ConvertOpenAIRequestToGeminiCLI(modelName string, inputRawJSON []byte, _ bo fid := tc.Get("id").String() fname := tc.Get("function.name").String() fargs := tc.Get("function.arguments").String() - node, _ = sjson.SetBytes(node, "parts."+itoa(p)+".functionCall.id", fid) - node, _ = sjson.SetBytes(node, "parts."+itoa(p)+".functionCall.name", fname) + node, _ = sjson.SetBytes(node, "parts."+itoa(p)+".functionCall.name", fname) node, _ = sjson.SetRawBytes(node, "parts."+itoa(p)+".functionCall.args", []byte(fargs)) node, _ = sjson.SetBytes(node, "parts."+itoa(p)+".thoughtSignature", geminiCLIFunctionThoughtSignature) p++ @@ -265,13 +264,12 @@ func ConvertOpenAIRequestToGeminiCLI(modelName string, inputRawJSON []byte, _ bo pp := 0 for _, fid := range fIDs { if name, ok := tcID2Name[fid]; ok { - toolNode, _ = sjson.SetBytes(toolNode, "parts."+itoa(pp)+".functionResponse.id", fid) - toolNode, _ = sjson.SetBytes(toolNode, "parts."+itoa(pp)+".functionResponse.name", name) + toolNode, _ = sjson.SetBytes(toolNode, "parts."+itoa(pp)+".functionResponse.name", name) resp := toolResponses[fid] if resp == "" { resp = "{}" } - toolNode, _ = sjson.SetBytes(toolNode, "parts."+itoa(pp)+".functionResponse.response.output", []byte(resp)) + toolNode, _ = sjson.SetBytes(toolNode, "parts."+itoa(pp)+".functionResponse.response.result", []byte(resp)) pp++ } } diff --git a/internal/translator/gemini-cli/openai/chat-completions/gemini-cli_openai_response.go b/internal/translator/gemini-cli/openai/chat-completions/gemini-cli_openai_response.go index d429f7fe..5a1faf51 100644 --- a/internal/translator/gemini-cli/openai/chat-completions/gemini-cli_openai_response.go +++ b/internal/translator/gemini-cli/openai/chat-completions/gemini-cli_openai_response.go @@ -149,11 +149,7 @@ func ConvertCliResponseToOpenAI(_ context.Context, _ string, originalRequestRawJ functionCallTemplate := `{"id": "","index": 0,"type": "function","function": {"name": "","arguments": ""}}` fcName := functionCallResult.Get("name").String() - fcID := functionCallResult.Get("id").String() - if fcID == "" { - fcID = fmt.Sprintf("%s-%d-%d", fcName, time.Now().UnixNano(), atomic.AddUint64(&functionCallIDCounter, 1)) - } - functionCallTemplate, _ = sjson.Set(functionCallTemplate, "id", fcID) + functionCallTemplate, _ = sjson.Set(functionCallTemplate, "id", fmt.Sprintf("%s-%d-%d", fcName, time.Now().UnixNano(), atomic.AddUint64(&functionCallIDCounter, 1))) functionCallTemplate, _ = sjson.Set(functionCallTemplate, "index", functionCallIndex) functionCallTemplate, _ = sjson.Set(functionCallTemplate, "function.name", fcName) if fcArgsResult := functionCallResult.Get("args"); fcArgsResult.Exists() { diff --git a/internal/translator/gemini/gemini-cli/gemini_gemini-cli_request.go b/internal/translator/gemini/gemini-cli/gemini_gemini-cli_request.go index a917e807..3b70bd3e 100644 --- a/internal/translator/gemini/gemini-cli/gemini_gemini-cli_request.go +++ b/internal/translator/gemini/gemini-cli/gemini_gemini-cli_request.go @@ -46,6 +46,19 @@ func ConvertGeminiCLIRequestToGemini(_ string, inputRawJSON []byte, _ bool) []by } } + gjson.GetBytes(rawJSON, "contents").ForEach(func(key, content gjson.Result) bool { + if content.Get("role").String() == "model" { + content.Get("parts").ForEach(func(partKey, part gjson.Result) bool { + if part.Get("functionCall").Exists() { + rawJSON, _ = sjson.SetBytes(rawJSON, fmt.Sprintf("contents.%d.parts.%d.thoughtSignature", key.Int(), partKey.Int()), "skip_thought_signature_validator") + } else if part.Get("thoughtSignature").Exists() { + rawJSON, _ = sjson.SetBytes(rawJSON, fmt.Sprintf("contents.%d.parts.%d.thoughtSignature", key.Int(), partKey.Int()), "skip_thought_signature_validator") + } + return true + }) + } + return true + }) return common.AttachDefaultSafetySettings(rawJSON, "safetySettings") } diff --git a/internal/translator/gemini/openai/chat-completions/gemini_openai_request.go b/internal/translator/gemini/openai/chat-completions/gemini_openai_request.go index 2a41e1a6..ba8b47e3 100644 --- a/internal/translator/gemini/openai/chat-completions/gemini_openai_request.go +++ b/internal/translator/gemini/openai/chat-completions/gemini_openai_request.go @@ -255,8 +255,7 @@ func ConvertOpenAIRequestToGemini(modelName string, inputRawJSON []byte, _ bool) fid := tc.Get("id").String() fname := tc.Get("function.name").String() fargs := tc.Get("function.arguments").String() - node, _ = sjson.SetBytes(node, "parts."+itoa(p)+".functionCall.id", fid) - node, _ = sjson.SetBytes(node, "parts."+itoa(p)+".functionCall.name", fname) + node, _ = sjson.SetBytes(node, "parts."+itoa(p)+".functionCall.name", fname) node, _ = sjson.SetRawBytes(node, "parts."+itoa(p)+".functionCall.args", []byte(fargs)) node, _ = sjson.SetBytes(node, "parts."+itoa(p)+".thoughtSignature", geminiFunctionThoughtSignature) p++ @@ -271,13 +270,12 @@ func ConvertOpenAIRequestToGemini(modelName string, inputRawJSON []byte, _ bool) pp := 0 for _, fid := range fIDs { if name, ok := tcID2Name[fid]; ok { - toolNode, _ = sjson.SetBytes(toolNode, "parts."+itoa(pp)+".functionResponse.id", fid) - toolNode, _ = sjson.SetBytes(toolNode, "parts."+itoa(pp)+".functionResponse.name", name) + toolNode, _ = sjson.SetBytes(toolNode, "parts."+itoa(pp)+".functionResponse.name", name) resp := toolResponses[fid] if resp == "" { resp = "{}" } - toolNode, _ = sjson.SetBytes(toolNode, "parts."+itoa(pp)+".functionResponse.response.output", []byte(resp)) + toolNode, _ = sjson.SetBytes(toolNode, "parts."+itoa(pp)+".functionResponse.response.result", []byte(resp)) pp++ } } diff --git a/internal/translator/gemini/openai/chat-completions/gemini_openai_response.go b/internal/translator/gemini/openai/chat-completions/gemini_openai_response.go index 92156c7f..9cce35f9 100644 --- a/internal/translator/gemini/openai/chat-completions/gemini_openai_response.go +++ b/internal/translator/gemini/openai/chat-completions/gemini_openai_response.go @@ -187,11 +187,7 @@ func ConvertGeminiResponseToOpenAI(_ context.Context, _ string, originalRequestR functionCallTemplate := `{"id": "","index": 0,"type": "function","function": {"name": "","arguments": ""}}` fcName := functionCallResult.Get("name").String() - fcID := functionCallResult.Get("id").String() - if fcID == "" { - fcID = fmt.Sprintf("%s-%d-%d", fcName, time.Now().UnixNano(), atomic.AddUint64(&functionCallIDCounter, 1)) - } - functionCallTemplate, _ = sjson.Set(functionCallTemplate, "id", fcID) + functionCallTemplate, _ = sjson.Set(functionCallTemplate, "id", fmt.Sprintf("%s-%d-%d", fcName, time.Now().UnixNano(), atomic.AddUint64(&functionCallIDCounter, 1))) functionCallTemplate, _ = sjson.Set(functionCallTemplate, "index", functionCallIndex) functionCallTemplate, _ = sjson.Set(functionCallTemplate, "function.name", fcName) if fcArgsResult := functionCallResult.Get("args"); fcArgsResult.Exists() { diff --git a/internal/translator/gemini/openai/responses/gemini_openai-responses_request.go b/internal/translator/gemini/openai/responses/gemini_openai-responses_request.go index adeba903..5277b71b 100644 --- a/internal/translator/gemini/openai/responses/gemini_openai-responses_request.go +++ b/internal/translator/gemini/openai/responses/gemini_openai-responses_request.go @@ -290,11 +290,11 @@ func ConvertOpenAIResponsesRequestToGemini(modelName string, inputRawJSON []byte // Set the raw JSON output directly (preserves string encoding) if outputRaw != "" && outputRaw != "null" { output := gjson.Parse(outputRaw) - if output.Type == gjson.JSON { - functionResponse, _ = sjson.SetRaw(functionResponse, "functionResponse.response.output", output.Raw) - } else { - functionResponse, _ = sjson.Set(functionResponse, "functionResponse.response.output", outputRaw) - } + if output.Type == gjson.JSON { + functionResponse, _ = sjson.SetRaw(functionResponse, "functionResponse.response.result", output.Raw) + } else { + functionResponse, _ = sjson.Set(functionResponse, "functionResponse.response.result", outputRaw) + } } functionContent, _ = sjson.SetRaw(functionContent, "parts.-1", functionResponse) out, _ = sjson.SetRaw(out, "contents.-1", functionContent) From 269a1c5452a88a1f9d4117a48f7e7240ac845ced Mon Sep 17 00:00:00 2001 From: sowar1987 <178468696@qq.com> Date: Wed, 21 Jan 2026 14:59:30 +0800 Subject: [PATCH 12/20] refactor: reuse placeholder reason description Co-Authored-By: Warp --- internal/util/gemini_schema.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/internal/util/gemini_schema.go b/internal/util/gemini_schema.go index c22e98c9..ddcee040 100644 --- a/internal/util/gemini_schema.go +++ b/internal/util/gemini_schema.go @@ -12,6 +12,8 @@ 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. @@ -72,7 +74,7 @@ func removePlaceholderFields(jsonStr string) string { continue } desc := gjson.Get(jsonStr, p+".description").String() - if desc != "Brief explanation of why you are calling this tool" { + if desc != placeholderReasonDescription { continue } jsonStr, _ = sjson.Delete(jsonStr, p) @@ -502,7 +504,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"}) From 9c2992bfb2988c422bb753f71cb9e49acc4aea44 Mon Sep 17 00:00:00 2001 From: sowar1987 <178468696@qq.com> Date: Wed, 21 Jan 2026 15:55:07 +0800 Subject: [PATCH 13/20] test: align signature cache tests with cache behavior Co-Authored-By: Warp --- internal/cache/signature_cache_test.go | 111 ++++++++++++------------- 1 file changed, 55 insertions(+), 56 deletions(-) diff --git a/internal/cache/signature_cache_test.go b/internal/cache/signature_cache_test.go index 7b413473..83408159 100644 --- a/internal/cache/signature_cache_test.go +++ b/internal/cache/signature_cache_test.go @@ -4,20 +4,20 @@ import ( "testing" "time" ) + const testModelName = "claude-sonnet-4-5" func TestCacheSignature_BasicStorageAndRetrieval(t *testing.T) { ClearSignatureCache("") - sessionID := "test-session-1" text := "This is some thinking text content" signature := "abc123validSignature1234567890123456789012345678901234567890" // Store signature - CacheSignature(testModelName, sessionID, text, signature) + CacheSignature(testModelName, text, signature) // Retrieve signature - retrieved := GetCachedSignature(testModelName, sessionID, text) + retrieved := GetCachedSignature(testModelName, text) if retrieved != signature { t.Errorf("Expected signature '%s', got '%s'", signature, retrieved) } @@ -29,14 +29,16 @@ func TestCacheSignature_DifferentModelGroups(t *testing.T) { text := "Same text across models" sig1 := "signature1_1234567890123456789012345678901234567890123456" sig2 := "signature2_1234567890123456789012345678901234567890123456" - CacheSignature(testModelName, "session-a", text, sig1) - CacheSignature(testModelName, "session-b", text, sig2) - if GetCachedSignature(testModelName, "session-a", text) != sig1 { - t.Error("Session-a signature mismatch") + geminiModel := "gemini-3-pro-preview" + CacheSignature(testModelName, text, sig1) + CacheSignature(geminiModel, text, sig2) + + if GetCachedSignature(testModelName, text) != sig1 { + t.Error("Claude signature mismatch") } - if GetCachedSignature(testModelName, "session-b", text) != sig2 { - t.Error("Session-b signature mismatch") + if GetCachedSignature(geminiModel, text) != sig2 { + t.Error("Gemini signature mismatch") } } @@ -44,13 +46,13 @@ func TestCacheSignature_NotFound(t *testing.T) { ClearSignatureCache("") // Non-existent session - if got := GetCachedSignature(testModelName, "nonexistent", "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(testModelName, "session-x", "text-a", "sigA12345678901234567890123456789012345678901234567890") - if got := GetCachedSignature(testModelName, "session-x", "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) } } @@ -59,12 +61,11 @@ func TestCacheSignature_EmptyInputs(t *testing.T) { ClearSignatureCache("") // All empty/invalid inputs should be no-ops - CacheSignature(testModelName, "", "text", "sig12345678901234567890123456789012345678901234567890") - CacheSignature(testModelName, "session", "", "sig12345678901234567890123456789012345678901234567890") - CacheSignature(testModelName, "session", "text", "") - CacheSignature(testModelName, "session", "text", "short") // Too short + CacheSignature(testModelName, "", "sig12345678901234567890123456789012345678901234567890") + CacheSignature(testModelName, "text", "") + CacheSignature(testModelName, "text", "short") // Too short - if got := GetCachedSignature(testModelName, "session", "text"); got != "" { + if got := GetCachedSignature(testModelName, "text"); got != "" { t.Errorf("Expected empty after invalid cache attempts, got '%s'", got) } } @@ -72,12 +73,12 @@ func TestCacheSignature_EmptyInputs(t *testing.T) { func TestCacheSignature_ShortSignatureRejected(t *testing.T) { ClearSignatureCache("") - sessionID := "test-short-sig" text := "Some text" shortSig := "abc123" // Less than 50 chars - CacheSignature(testModelName, sessionID, text, shortSig) - if got := GetCachedSignature(testModelName, sessionID, text); got != "" { + CacheSignature(testModelName, text, shortSig) + + if got := GetCachedSignature(testModelName, text); got != "" { t.Errorf("Short signature should be rejected, got '%s'", got) } } @@ -86,16 +87,13 @@ func TestClearSignatureCache_ModelGroup(t *testing.T) { ClearSignatureCache("") sig := "validSig1234567890123456789012345678901234567890123456" - CacheSignature(testModelName, "session-1", "text", sig) - CacheSignature(testModelName, "session-2", "text", sig) + CacheSignature(testModelName, "text", sig) + CacheSignature(testModelName, "text-2", sig) - ClearSignatureCache(GetModelGroup(testModelName) + "#session-1") + ClearSignatureCache("session-1") - if got := GetCachedSignature(testModelName, "session-1", "text"); got != "" { - t.Error("session-1 should be cleared") - } - if got := GetCachedSignature(testModelName, "session-2", "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") } } @@ -103,35 +101,37 @@ func TestClearSignatureCache_AllSessions(t *testing.T) { ClearSignatureCache("") sig := "validSig1234567890123456789012345678901234567890123456" - CacheSignature(testModelName, "session-1", "text", sig) - CacheSignature(testModelName, "session-2", "text", sig) + CacheSignature(testModelName, "text", sig) + CacheSignature(testModelName, "text-2", sig) ClearSignatureCache("") - if got := GetCachedSignature(testModelName, "session-1", "text"); got != "" { - t.Error("session-1 should be cleared") + if got := GetCachedSignature(testModelName, "text"); got != "" { + t.Error("text should be cleared") } - if got := GetCachedSignature(testModelName, "session-2", "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(tt.signature) + result := HasValidSignature(tt.modelName, tt.signature) if result != tt.expected { t.Errorf("HasValidSignature(%q) = %v, expected %v", tt.signature, result, tt.expected) } @@ -142,20 +142,19 @@ func TestHasValidSignature(t *testing.T) { func TestCacheSignature_TextHashCollisionResistance(t *testing.T) { ClearSignatureCache("") - sessionID := "hash-test-session" - // Different texts should produce different hashes text1 := "First thinking text" text2 := "Second thinking text" sig1 := "signature1_1234567890123456789012345678901234567890123456" sig2 := "signature2_1234567890123456789012345678901234567890123456" - CacheSignature(testModelName, sessionID, text1, sig1) - CacheSignature(testModelName, sessionID, text2, sig2) - if GetCachedSignature(testModelName, sessionID, text1) != sig1 { + CacheSignature(testModelName, text1, sig1) + CacheSignature(testModelName, text2, sig2) + + if GetCachedSignature(testModelName, text1) != sig1 { t.Error("text1 signature mismatch") } - if GetCachedSignature(testModelName, sessionID, text2) != sig2 { + if GetCachedSignature(testModelName, text2) != sig2 { t.Error("text2 signature mismatch") } } @@ -163,12 +162,12 @@ func TestCacheSignature_TextHashCollisionResistance(t *testing.T) { func TestCacheSignature_UnicodeText(t *testing.T) { ClearSignatureCache("") - sessionID := "unicode-session" text := "한글 텍스트와 이모지 🎉 그리고 特殊文字" sig := "unicodeSig123456789012345678901234567890123456789012345" - CacheSignature(testModelName, sessionID, text, sig) - if got := GetCachedSignature(testModelName, sessionID, text); got != sig { + CacheSignature(testModelName, text, sig) + + if got := GetCachedSignature(testModelName, text); got != sig { t.Errorf("Unicode text signature retrieval failed, got '%s'", got) } } @@ -176,14 +175,14 @@ func TestCacheSignature_UnicodeText(t *testing.T) { func TestCacheSignature_Overwrite(t *testing.T) { ClearSignatureCache("") - sessionID := "overwrite-session" text := "Same text" sig1 := "firstSignature12345678901234567890123456789012345678901" sig2 := "secondSignature1234567890123456789012345678901234567890" - CacheSignature(testModelName, sessionID, text, sig1) - CacheSignature(testModelName, sessionID, text, sig2) // Overwrite - if got := GetCachedSignature(testModelName, sessionID, text); got != sig2 { + CacheSignature(testModelName, text, sig1) + CacheSignature(testModelName, text, sig2) // Overwrite + + if got := GetCachedSignature(testModelName, text); got != sig2 { t.Errorf("Expected overwritten signature '%s', got '%s'", sig2, got) } } @@ -195,13 +194,13 @@ func TestCacheSignature_ExpirationLogic(t *testing.T) { // This test verifies the expiration check exists // In a real scenario, we'd mock time.Now() - sessionID := "expiration-test" text := "text" sig := "validSig1234567890123456789012345678901234567890123456" - CacheSignature(testModelName, sessionID, text, sig) + + CacheSignature(testModelName, text, sig) // Fresh entry should be retrievable - if got := GetCachedSignature(testModelName, sessionID, text); got != sig { + if got := GetCachedSignature(testModelName, text); got != sig { t.Errorf("Fresh entry should be retrievable, got '%s'", got) } From abfca6aab285d137d138cc8176bde1e5ef0c54fd Mon Sep 17 00:00:00 2001 From: hkfires <10558748+hkfires@users.noreply.github.com> Date: Thu, 22 Jan 2026 18:38:48 +0800 Subject: [PATCH 14/20] refactor(util): reorder gemini schema cleaner helpers --- .../runtime/executor/antigravity_executor.go | 1 - internal/util/gemini_schema.go | 74 ++++++++++--------- 2 files changed, 38 insertions(+), 37 deletions(-) diff --git a/internal/runtime/executor/antigravity_executor.go b/internal/runtime/executor/antigravity_executor.go index e17d525c..ea73c266 100644 --- a/internal/runtime/executor/antigravity_executor.go +++ b/internal/runtime/executor/antigravity_executor.go @@ -1213,7 +1213,6 @@ 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) diff --git a/internal/util/gemini_schema.go b/internal/util/gemini_schema.go index ddcee040..867dd4fa 100644 --- a/internal/util/gemini_schema.go +++ b/internal/util/gemini_schema.go @@ -21,6 +21,44 @@ 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) + jsonStr = convertEnumValuesToStrings(jsonStr) + jsonStr = addEnumHints(jsonStr) + jsonStr = addAdditionalPropertiesHints(jsonStr) + jsonStr = moveConstraintsToDescription(jsonStr) + + // Phase 2: Flatten complex structures + jsonStr = mergeAllOf(jsonStr) + jsonStr = flattenAnyOfOneOf(jsonStr) + jsonStr = flattenTypeArrays(jsonStr) + + // 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) + 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) { @@ -98,42 +136,6 @@ func removePlaceholderFields(jsonStr string) string { return jsonStr } -// 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) -} - -func cleanJSONSchema(jsonStr string, addPlaceholder bool) string { - // Phase 1: Convert and add hints - jsonStr = convertRefsToHints(jsonStr) - jsonStr = convertConstToEnum(jsonStr) - jsonStr = convertEnumValuesToStrings(jsonStr) - jsonStr = addEnumHints(jsonStr) - jsonStr = addAdditionalPropertiesHints(jsonStr) - jsonStr = moveConstraintsToDescription(jsonStr) - - // Phase 2: Flatten complex structures - jsonStr = mergeAllOf(jsonStr) - jsonStr = flattenAnyOfOneOf(jsonStr) - jsonStr = flattenTypeArrays(jsonStr) - - // 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) - if addPlaceholder { - jsonStr = addEmptySchemaPlaceholder(jsonStr) - } - - return jsonStr -} - // convertRefsToHints converts $ref to description hints (Lazy Hint strategy). func convertRefsToHints(jsonStr string) string { paths := findPaths(jsonStr, "$ref") From 7ca045d8b9cc9deda6a5c36b9f1e99c328eeeb25 Mon Sep 17 00:00:00 2001 From: hkfires <10558748+hkfires@users.noreply.github.com> Date: Thu, 22 Jan 2026 20:28:08 +0800 Subject: [PATCH 15/20] fix(executor): adjust model-specific request payload --- .../runtime/executor/antigravity_executor.go | 28 ++++--------------- 1 file changed, 6 insertions(+), 22 deletions(-) diff --git a/internal/runtime/executor/antigravity_executor.go b/internal/runtime/executor/antigravity_executor.go index ea73c266..0baab410 100644 --- a/internal/runtime/executor/antigravity_executor.go +++ b/internal/runtime/executor/antigravity_executor.go @@ -1240,6 +1240,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 @@ -1419,28 +1425,6 @@ func geminiToAntigravity(modelName string, payload []byte, projectID string) []b template, _ = sjson.SetRaw(template, "request.toolConfig", toolConfig.Raw) template, _ = sjson.Delete(template, "toolConfig") } - if strings.Contains(modelName, "claude") { - 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 !strings.Contains(modelName, "claude") { - template, _ = sjson.Delete(template, "request.generationConfig.maxOutputTokens") - } - return []byte(template) } From ecc850bfb7ea6cf90162c9e10b3fbf20666e7695 Mon Sep 17 00:00:00 2001 From: hkfires <10558748+hkfires@users.noreply.github.com> Date: Fri, 23 Jan 2026 16:38:41 +0800 Subject: [PATCH 16/20] feat(executor): apply payload rules using requested model --- .../runtime/executor/aistudio_executor.go | 3 +- .../runtime/executor/antigravity_executor.go | 9 +- internal/runtime/executor/claude_executor.go | 6 +- internal/runtime/executor/codex_executor.go | 6 +- .../runtime/executor/gemini_cli_executor.go | 6 +- internal/runtime/executor/gemini_executor.go | 6 +- .../executor/gemini_vertex_executor.go | 12 +- internal/runtime/executor/iflow_executor.go | 6 +- .../executor/openai_compat_executor.go | 6 +- internal/runtime/executor/payload_helpers.go | 114 ++++++++++-------- internal/runtime/executor/qwen_executor.go | 6 +- sdk/api/handlers/handlers.go | 3 + sdk/cliproxy/executor/types.go | 3 + 13 files changed, 112 insertions(+), 74 deletions(-) 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 0baab410..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) 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/sdk/api/handlers/handlers.go b/sdk/api/handlers/handlers.go index 232f0b95..7108749d 100644 --- a/sdk/api/handlers/handlers.go +++ b/sdk/api/handlers/handlers.go @@ -385,6 +385,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), @@ -423,6 +424,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), @@ -464,6 +466,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/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. From 81b369aed961c3be9cd416eeb964a1cde14c047f Mon Sep 17 00:00:00 2001 From: hkfires <10558748+hkfires@users.noreply.github.com> Date: Fri, 23 Jan 2026 18:30:08 +0800 Subject: [PATCH 17/20] fix(auth): include requested model in executor metadata --- sdk/cliproxy/auth/conductor.go | 45 ++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/sdk/cliproxy/auth/conductor.go b/sdk/cliproxy/auth/conductor.go index 43483672..196a3053 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 { @@ -619,6 +620,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 { @@ -668,6 +670,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 { @@ -734,6 +737,7 @@ func (m *Manager) executeWithProvider(ctx context.Context, provider string, req return cliproxyexecutor.Response{}, &Error{Code: "provider_not_found", Message: "provider identifier is empty"} } routeModel := req.Model + opts = ensureRequestedModelMetadata(opts, routeModel) tried := make(map[string]struct{}) var lastErr error for { @@ -783,6 +787,7 @@ func (m *Manager) executeCountWithProvider(ctx context.Context, provider string, return cliproxyexecutor.Response{}, &Error{Code: "provider_not_found", Message: "provider identifier is empty"} } routeModel := req.Model + opts = ensureRequestedModelMetadata(opts, routeModel) tried := make(map[string]struct{}) var lastErr error for { @@ -832,6 +837,7 @@ func (m *Manager) executeStreamWithProvider(ctx context.Context, provider string return nil, &Error{Code: "provider_not_found", Message: "provider identifier is empty"} } routeModel := req.Model + opts = ensureRequestedModelMetadata(opts, routeModel) tried := make(map[string]struct{}) var lastErr error for { @@ -893,6 +899,45 @@ func (m *Manager) executeStreamWithProvider(ctx context.Context, provider string } } +func ensureRequestedModelMetadata(opts cliproxyexecutor.Options, requestedModel string) cliproxyexecutor.Options { + requestedModel = strings.TrimSpace(requestedModel) + if requestedModel == "" { + return opts + } + 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 hasRequestedModelMetadata(meta map[string]any) bool { + if len(meta) == 0 { + return false + } + raw, ok := meta[cliproxyexecutor.RequestedModelMetadataKey] + if !ok || raw == nil { + return false + } + switch v := raw.(type) { + case string: + return strings.TrimSpace(v) != "" + case []byte: + return strings.TrimSpace(string(v)) != "" + default: + return false + } +} + func rewriteModelForAuth(model string, auth *Auth) string { if auth == nil || model == "" { return model From cc50b634225bd48f1872d9251ef5d889e6763e1f Mon Sep 17 00:00:00 2001 From: hkfires <10558748+hkfires@users.noreply.github.com> Date: Fri, 23 Jan 2026 19:12:55 +0800 Subject: [PATCH 18/20] refactor(auth): remove unused provider execution helpers --- sdk/cliproxy/auth/conductor.go | 232 --------------------------------- 1 file changed, 232 deletions(-) diff --git a/sdk/cliproxy/auth/conductor.go b/sdk/cliproxy/auth/conductor.go index 196a3053..75c9c495 100644 --- a/sdk/cliproxy/auth/conductor.go +++ b/sdk/cliproxy/auth/conductor.go @@ -732,173 +732,6 @@ 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"} - } - routeModel := req.Model - opts = ensureRequestedModelMetadata(opts, routeModel) - 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 - } -} - -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"} - } - routeModel := req.Model - opts = ensureRequestedModelMetadata(opts, routeModel) - 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 - } -} - -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 - opts = ensureRequestedModelMetadata(opts, routeModel) - 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 - } -} - func ensureRequestedModelMetadata(opts cliproxyexecutor.Options, requestedModel string) cliproxyexecutor.Options { requestedModel = strings.TrimSpace(requestedModel) if requestedModel == "" { @@ -1185,35 +1018,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 @@ -1295,42 +1099,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 == "" { From d5e3e32d58d8a4d7ac2b9829cb8085cd882f275e Mon Sep 17 00:00:00 2001 From: hkfires <10558748+hkfires@users.noreply.github.com> Date: Fri, 23 Jan 2026 20:13:09 +0800 Subject: [PATCH 19/20] fix(auth): normalize plan type filenames to lowercase --- internal/auth/codex/filename.go | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) 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) -} From c32e2a81969de67e62ae4361585db5659097206e Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Sat, 24 Jan 2026 04:56:55 +0800 Subject: [PATCH 20/20] fix(auth): handle context cancellation in executor methods --- sdk/cliproxy/auth/conductor.go | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/sdk/cliproxy/auth/conductor.go b/sdk/cliproxy/auth/conductor.go index a6987190..6662f9b9 100644 --- a/sdk/cliproxy/auth/conductor.go +++ b/sdk/cliproxy/auth/conductor.go @@ -598,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 { @@ -648,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 { @@ -697,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 {