mirror of
https://github.com/githubhjs/CLIProxyAPIPlus.git
synced 2026-07-13 18:15:24 +00:00
@@ -71,7 +71,6 @@ require (
|
|||||||
golang.org/x/arch v0.8.0 // indirect
|
golang.org/x/arch v0.8.0 // indirect
|
||||||
golang.org/x/sync v0.18.0 // indirect
|
golang.org/x/sync v0.18.0 // indirect
|
||||||
golang.org/x/sys v0.38.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
|
google.golang.org/protobuf v1.34.1 // indirect
|
||||||
gopkg.in/ini.v1 v1.67.0 // indirect
|
gopkg.in/ini.v1 v1.67.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -753,6 +753,72 @@ func (h *Handler) registerAuthFromFile(ctx context.Context, path string, data []
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PatchAuthFileStatus toggles the disabled state of an auth file
|
||||||
|
func (h *Handler) PatchAuthFileStatus(c *gin.Context) {
|
||||||
|
if h.authManager == nil {
|
||||||
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "core auth manager unavailable"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Disabled *bool `json:"disabled"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
name := strings.TrimSpace(req.Name)
|
||||||
|
if name == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Disabled == nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "disabled is required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
|
||||||
|
// Find auth by name or ID
|
||||||
|
var targetAuth *coreauth.Auth
|
||||||
|
if auth, ok := h.authManager.GetByID(name); ok {
|
||||||
|
targetAuth = auth
|
||||||
|
} else {
|
||||||
|
auths := h.authManager.List()
|
||||||
|
for _, auth := range auths {
|
||||||
|
if auth.FileName == name {
|
||||||
|
targetAuth = auth
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if targetAuth == nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "auth file not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update disabled state
|
||||||
|
targetAuth.Disabled = *req.Disabled
|
||||||
|
if *req.Disabled {
|
||||||
|
targetAuth.Status = coreauth.StatusDisabled
|
||||||
|
targetAuth.StatusMessage = "disabled via management API"
|
||||||
|
} else {
|
||||||
|
targetAuth.Status = coreauth.StatusActive
|
||||||
|
targetAuth.StatusMessage = ""
|
||||||
|
}
|
||||||
|
targetAuth.UpdatedAt = time.Now()
|
||||||
|
|
||||||
|
if _, err := h.authManager.Update(ctx, targetAuth); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to update auth: %v", err)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"status": "ok", "disabled": *req.Disabled})
|
||||||
|
}
|
||||||
|
|
||||||
func (h *Handler) disableAuth(ctx context.Context, id string) {
|
func (h *Handler) disableAuth(ctx context.Context, id string) {
|
||||||
if h == nil || h.authManager == nil {
|
if h == nil || h.authManager == nil {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -636,6 +636,7 @@ func (s *Server) registerManagementRoutes() {
|
|||||||
mgmt.GET("/auth-files/download", s.mgmt.DownloadAuthFile)
|
mgmt.GET("/auth-files/download", s.mgmt.DownloadAuthFile)
|
||||||
mgmt.POST("/auth-files", s.mgmt.UploadAuthFile)
|
mgmt.POST("/auth-files", s.mgmt.UploadAuthFile)
|
||||||
mgmt.DELETE("/auth-files", s.mgmt.DeleteAuthFile)
|
mgmt.DELETE("/auth-files", s.mgmt.DeleteAuthFile)
|
||||||
|
mgmt.PATCH("/auth-files/status", s.mgmt.PatchAuthFileStatus)
|
||||||
mgmt.POST("/vertex/import", s.mgmt.ImportVertexCredential)
|
mgmt.POST("/vertex/import", s.mgmt.ImportVertexCredential)
|
||||||
|
|
||||||
mgmt.GET("/anthropic-auth-url", s.mgmt.RequestAnthropicToken)
|
mgmt.GET("/anthropic-auth-url", s.mgmt.RequestAnthropicToken)
|
||||||
|
|||||||
@@ -4,9 +4,6 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"unicode"
|
"unicode"
|
||||||
|
|
||||||
"golang.org/x/text/cases"
|
|
||||||
"golang.org/x/text/language"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// CredentialFileName returns the filename used to persist Codex OAuth credentials.
|
// CredentialFileName returns the filename used to persist Codex OAuth credentials.
|
||||||
@@ -43,15 +40,7 @@ func normalizePlanTypeForFilename(planType string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for i, part := range parts {
|
for i, part := range parts {
|
||||||
parts[i] = titleToken(part)
|
parts[i] = strings.ToLower(strings.TrimSpace(part))
|
||||||
}
|
}
|
||||||
return strings.Join(parts, "-")
|
return strings.Join(parts, "-")
|
||||||
}
|
}
|
||||||
|
|
||||||
func titleToken(token string) string {
|
|
||||||
token = strings.TrimSpace(token)
|
|
||||||
if token == "" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return cases.Title(language.English).String(token)
|
|
||||||
}
|
|
||||||
|
|||||||
Vendored
+41
-48
@@ -3,7 +3,6 @@ package cache
|
|||||||
import (
|
import (
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"fmt"
|
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -25,18 +24,18 @@ const (
|
|||||||
// MinValidSignatureLen is the minimum length for a signature to be considered valid
|
// MinValidSignatureLen is the minimum length for a signature to be considered valid
|
||||||
MinValidSignatureLen = 50
|
MinValidSignatureLen = 50
|
||||||
|
|
||||||
// SessionCleanupInterval controls how often stale sessions are purged
|
// CacheCleanupInterval controls how often stale entries are purged
|
||||||
SessionCleanupInterval = 10 * time.Minute
|
CacheCleanupInterval = 10 * time.Minute
|
||||||
)
|
)
|
||||||
|
|
||||||
// signatureCache stores signatures by sessionId -> textHash -> SignatureEntry
|
// signatureCache stores signatures by model group -> textHash -> SignatureEntry
|
||||||
var signatureCache sync.Map
|
var signatureCache sync.Map
|
||||||
|
|
||||||
// sessionCleanupOnce ensures the background cleanup goroutine starts only once
|
// cacheCleanupOnce ensures the background cleanup goroutine starts only once
|
||||||
var sessionCleanupOnce sync.Once
|
var cacheCleanupOnce sync.Once
|
||||||
|
|
||||||
// sessionCache is the inner map type
|
// groupCache is the inner map type
|
||||||
type sessionCache struct {
|
type groupCache struct {
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
entries map[string]SignatureEntry
|
entries map[string]SignatureEntry
|
||||||
}
|
}
|
||||||
@@ -47,36 +46,36 @@ func hashText(text string) string {
|
|||||||
return hex.EncodeToString(h[:])[:SignatureTextHashLen]
|
return hex.EncodeToString(h[:])[:SignatureTextHashLen]
|
||||||
}
|
}
|
||||||
|
|
||||||
// getOrCreateSession gets or creates a session cache
|
// getOrCreateGroupCache gets or creates a cache bucket for a model group
|
||||||
func getOrCreateSession(sessionID string) *sessionCache {
|
func getOrCreateGroupCache(groupKey string) *groupCache {
|
||||||
// Start background cleanup on first access
|
// Start background cleanup on first access
|
||||||
sessionCleanupOnce.Do(startSessionCleanup)
|
cacheCleanupOnce.Do(startCacheCleanup)
|
||||||
|
|
||||||
if val, ok := signatureCache.Load(sessionID); ok {
|
if val, ok := signatureCache.Load(groupKey); ok {
|
||||||
return val.(*sessionCache)
|
return val.(*groupCache)
|
||||||
}
|
}
|
||||||
sc := &sessionCache{entries: make(map[string]SignatureEntry)}
|
sc := &groupCache{entries: make(map[string]SignatureEntry)}
|
||||||
actual, _ := signatureCache.LoadOrStore(sessionID, sc)
|
actual, _ := signatureCache.LoadOrStore(groupKey, sc)
|
||||||
return actual.(*sessionCache)
|
return actual.(*groupCache)
|
||||||
}
|
}
|
||||||
|
|
||||||
// startSessionCleanup launches a background goroutine that periodically
|
// startCacheCleanup launches a background goroutine that periodically
|
||||||
// removes sessions where all entries have expired.
|
// removes caches where all entries have expired.
|
||||||
func startSessionCleanup() {
|
func startCacheCleanup() {
|
||||||
go func() {
|
go func() {
|
||||||
ticker := time.NewTicker(SessionCleanupInterval)
|
ticker := time.NewTicker(CacheCleanupInterval)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
for range ticker.C {
|
for range ticker.C {
|
||||||
purgeExpiredSessions()
|
purgeExpiredCaches()
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
// purgeExpiredSessions removes sessions with no valid (non-expired) entries.
|
// purgeExpiredCaches removes caches with no valid (non-expired) entries.
|
||||||
func purgeExpiredSessions() {
|
func purgeExpiredCaches() {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
signatureCache.Range(func(key, value any) bool {
|
signatureCache.Range(func(key, value any) bool {
|
||||||
sc := value.(*sessionCache)
|
sc := value.(*groupCache)
|
||||||
sc.mu.Lock()
|
sc.mu.Lock()
|
||||||
// Remove expired entries
|
// Remove expired entries
|
||||||
for k, entry := range sc.entries {
|
for k, entry := range sc.entries {
|
||||||
@@ -86,7 +85,7 @@ func purgeExpiredSessions() {
|
|||||||
}
|
}
|
||||||
isEmpty := len(sc.entries) == 0
|
isEmpty := len(sc.entries) == 0
|
||||||
sc.mu.Unlock()
|
sc.mu.Unlock()
|
||||||
// Remove session if empty
|
// Remove cache bucket if empty
|
||||||
if isEmpty {
|
if isEmpty {
|
||||||
signatureCache.Delete(key)
|
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.
|
// Used for Claude models that require signed thinking blocks in multi-turn conversations.
|
||||||
func CacheSignature(modelName, text, signature string) {
|
func CacheSignature(modelName, text, signature string) {
|
||||||
if text == "" || signature == "" {
|
if text == "" || signature == "" {
|
||||||
@@ -104,9 +103,9 @@ func CacheSignature(modelName, text, signature string) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
text = fmt.Sprintf("%s#%s", GetModelGroup(modelName), text)
|
groupKey := GetModelGroup(modelName)
|
||||||
textHash := hashText(text)
|
textHash := hashText(text)
|
||||||
sc := getOrCreateSession(textHash)
|
sc := getOrCreateGroupCache(groupKey)
|
||||||
sc.mu.Lock()
|
sc.mu.Lock()
|
||||||
defer sc.mu.Unlock()
|
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.
|
// Returns empty string if not found or expired.
|
||||||
func GetCachedSignature(modelName, text string) string {
|
func GetCachedSignature(modelName, text string) string {
|
||||||
family := GetModelGroup(modelName)
|
groupKey := GetModelGroup(modelName)
|
||||||
|
|
||||||
if text == "" {
|
if text == "" {
|
||||||
if family == "gemini" {
|
if groupKey == "gemini" {
|
||||||
return "skip_thought_signature_validator"
|
return "skip_thought_signature_validator"
|
||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
text = fmt.Sprintf("%s#%s", GetModelGroup(modelName), text)
|
val, ok := signatureCache.Load(groupKey)
|
||||||
val, ok := signatureCache.Load(hashText(text))
|
|
||||||
if !ok {
|
if !ok {
|
||||||
if family == "gemini" {
|
if groupKey == "gemini" {
|
||||||
return "skip_thought_signature_validator"
|
return "skip_thought_signature_validator"
|
||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
sc := val.(*sessionCache)
|
sc := val.(*groupCache)
|
||||||
|
|
||||||
textHash := hashText(text)
|
textHash := hashText(text)
|
||||||
|
|
||||||
@@ -145,7 +143,7 @@ func GetCachedSignature(modelName, text string) string {
|
|||||||
entry, exists := sc.entries[textHash]
|
entry, exists := sc.entries[textHash]
|
||||||
if !exists {
|
if !exists {
|
||||||
sc.mu.Unlock()
|
sc.mu.Unlock()
|
||||||
if family == "gemini" {
|
if groupKey == "gemini" {
|
||||||
return "skip_thought_signature_validator"
|
return "skip_thought_signature_validator"
|
||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
@@ -153,7 +151,7 @@ func GetCachedSignature(modelName, text string) string {
|
|||||||
if now.Sub(entry.Timestamp) > SignatureCacheTTL {
|
if now.Sub(entry.Timestamp) > SignatureCacheTTL {
|
||||||
delete(sc.entries, textHash)
|
delete(sc.entries, textHash)
|
||||||
sc.mu.Unlock()
|
sc.mu.Unlock()
|
||||||
if family == "gemini" {
|
if groupKey == "gemini" {
|
||||||
return "skip_thought_signature_validator"
|
return "skip_thought_signature_validator"
|
||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
@@ -167,22 +165,17 @@ func GetCachedSignature(modelName, text string) string {
|
|||||||
return entry.Signature
|
return entry.Signature
|
||||||
}
|
}
|
||||||
|
|
||||||
// ClearSignatureCache clears signature cache for a specific session or all sessions.
|
// ClearSignatureCache clears signature cache for a specific model group or all groups.
|
||||||
func ClearSignatureCache(sessionID string) {
|
func ClearSignatureCache(modelName string) {
|
||||||
if sessionID != "" {
|
if modelName == "" {
|
||||||
signatureCache.Range(func(key, _ any) bool {
|
|
||||||
kStr, ok := key.(string)
|
|
||||||
if ok && strings.HasSuffix(kStr, "#"+sessionID) {
|
|
||||||
signatureCache.Delete(key)
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
signatureCache.Range(func(key, _ any) bool {
|
signatureCache.Range(func(key, _ any) bool {
|
||||||
signatureCache.Delete(key)
|
signatureCache.Delete(key)
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
groupKey := GetModelGroup(modelName)
|
||||||
|
signatureCache.Delete(groupKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
// HasValidSignature checks if a signature is valid (non-empty and long enough)
|
// HasValidSignature checks if a signature is valid (non-empty and long enough)
|
||||||
|
|||||||
+52
-51
@@ -5,6 +5,8 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const testModelName = "claude-sonnet-4-5"
|
||||||
|
|
||||||
func TestCacheSignature_BasicStorageAndRetrieval(t *testing.T) {
|
func TestCacheSignature_BasicStorageAndRetrieval(t *testing.T) {
|
||||||
ClearSignatureCache("")
|
ClearSignatureCache("")
|
||||||
|
|
||||||
@@ -12,30 +14,31 @@ func TestCacheSignature_BasicStorageAndRetrieval(t *testing.T) {
|
|||||||
signature := "abc123validSignature1234567890123456789012345678901234567890"
|
signature := "abc123validSignature1234567890123456789012345678901234567890"
|
||||||
|
|
||||||
// Store signature
|
// Store signature
|
||||||
CacheSignature("test-model", text, signature)
|
CacheSignature(testModelName, text, signature)
|
||||||
|
|
||||||
// Retrieve signature
|
// Retrieve signature
|
||||||
retrieved := GetCachedSignature("test-model", text)
|
retrieved := GetCachedSignature(testModelName, text)
|
||||||
if retrieved != signature {
|
if retrieved != signature {
|
||||||
t.Errorf("Expected signature '%s', got '%s'", signature, retrieved)
|
t.Errorf("Expected signature '%s', got '%s'", signature, retrieved)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCacheSignature_DifferentSessions(t *testing.T) {
|
func TestCacheSignature_DifferentModelGroups(t *testing.T) {
|
||||||
ClearSignatureCache("")
|
ClearSignatureCache("")
|
||||||
|
|
||||||
text := "Same text in different sessions"
|
text := "Same text across models"
|
||||||
sig1 := "signature1_1234567890123456789012345678901234567890123456"
|
sig1 := "signature1_1234567890123456789012345678901234567890123456"
|
||||||
sig2 := "signature2_1234567890123456789012345678901234567890123456"
|
sig2 := "signature2_1234567890123456789012345678901234567890123456"
|
||||||
|
|
||||||
CacheSignature("test-model", text, sig1)
|
geminiModel := "gemini-3-pro-preview"
|
||||||
CacheSignature("test-model", text, sig2)
|
CacheSignature(testModelName, text, sig1)
|
||||||
|
CacheSignature(geminiModel, text, sig2)
|
||||||
|
|
||||||
if GetCachedSignature("test-model", text) != sig1 {
|
if GetCachedSignature(testModelName, text) != sig1 {
|
||||||
t.Error("Session-a signature mismatch")
|
t.Error("Claude signature mismatch")
|
||||||
}
|
}
|
||||||
if GetCachedSignature("test-model", text) != sig2 {
|
if GetCachedSignature(geminiModel, text) != sig2 {
|
||||||
t.Error("Session-b signature mismatch")
|
t.Error("Gemini signature mismatch")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,13 +46,13 @@ func TestCacheSignature_NotFound(t *testing.T) {
|
|||||||
ClearSignatureCache("")
|
ClearSignatureCache("")
|
||||||
|
|
||||||
// Non-existent session
|
// Non-existent session
|
||||||
if got := GetCachedSignature("test-model", "some text"); got != "" {
|
if got := GetCachedSignature(testModelName, "some text"); got != "" {
|
||||||
t.Errorf("Expected empty string for nonexistent session, got '%s'", got)
|
t.Errorf("Expected empty string for nonexistent session, got '%s'", got)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Existing session but different text
|
// Existing session but different text
|
||||||
CacheSignature("test-model", "text-a", "sigA12345678901234567890123456789012345678901234567890")
|
CacheSignature(testModelName, "text-a", "sigA12345678901234567890123456789012345678901234567890")
|
||||||
if got := GetCachedSignature("test-model", "text-b"); got != "" {
|
if got := GetCachedSignature(testModelName, "text-b"); got != "" {
|
||||||
t.Errorf("Expected empty string for different text, got '%s'", got)
|
t.Errorf("Expected empty string for different text, got '%s'", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -58,12 +61,11 @@ func TestCacheSignature_EmptyInputs(t *testing.T) {
|
|||||||
ClearSignatureCache("")
|
ClearSignatureCache("")
|
||||||
|
|
||||||
// All empty/invalid inputs should be no-ops
|
// All empty/invalid inputs should be no-ops
|
||||||
CacheSignature("test-model", "text", "sig12345678901234567890123456789012345678901234567890")
|
CacheSignature(testModelName, "", "sig12345678901234567890123456789012345678901234567890")
|
||||||
CacheSignature("test-model", "", "sig12345678901234567890123456789012345678901234567890")
|
CacheSignature(testModelName, "text", "")
|
||||||
CacheSignature("test-model", "text", "")
|
CacheSignature(testModelName, "text", "short") // Too short
|
||||||
CacheSignature("test-model", "text", "short") // Too short
|
|
||||||
|
|
||||||
if got := GetCachedSignature("test-model", "text"); got != "" {
|
if got := GetCachedSignature(testModelName, "text"); got != "" {
|
||||||
t.Errorf("Expected empty after invalid cache attempts, got '%s'", got)
|
t.Errorf("Expected empty after invalid cache attempts, got '%s'", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -74,27 +76,24 @@ func TestCacheSignature_ShortSignatureRejected(t *testing.T) {
|
|||||||
text := "Some text"
|
text := "Some text"
|
||||||
shortSig := "abc123" // Less than 50 chars
|
shortSig := "abc123" // Less than 50 chars
|
||||||
|
|
||||||
CacheSignature("test-model", text, shortSig)
|
CacheSignature(testModelName, text, shortSig)
|
||||||
|
|
||||||
if got := GetCachedSignature("test-model", text); got != "" {
|
if got := GetCachedSignature(testModelName, text); got != "" {
|
||||||
t.Errorf("Short signature should be rejected, got '%s'", got)
|
t.Errorf("Short signature should be rejected, got '%s'", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestClearSignatureCache_SpecificSession(t *testing.T) {
|
func TestClearSignatureCache_ModelGroup(t *testing.T) {
|
||||||
ClearSignatureCache("")
|
ClearSignatureCache("")
|
||||||
|
|
||||||
sig := "validSig1234567890123456789012345678901234567890123456"
|
sig := "validSig1234567890123456789012345678901234567890123456"
|
||||||
CacheSignature("test-model", "text", sig)
|
CacheSignature(testModelName, "text", sig)
|
||||||
CacheSignature("test-model", "text", sig)
|
CacheSignature(testModelName, "text-2", sig)
|
||||||
|
|
||||||
ClearSignatureCache("session-1")
|
ClearSignatureCache("session-1")
|
||||||
|
|
||||||
if got := GetCachedSignature("test-model", "text"); got != "" {
|
if got := GetCachedSignature(testModelName, "text"); got != sig {
|
||||||
t.Error("session-1 should be cleared")
|
t.Error("signature should remain when clearing unknown session")
|
||||||
}
|
|
||||||
if got := GetCachedSignature("test-model", "text"); got != sig {
|
|
||||||
t.Error("session-2 should still exist")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,35 +101,37 @@ func TestClearSignatureCache_AllSessions(t *testing.T) {
|
|||||||
ClearSignatureCache("")
|
ClearSignatureCache("")
|
||||||
|
|
||||||
sig := "validSig1234567890123456789012345678901234567890123456"
|
sig := "validSig1234567890123456789012345678901234567890123456"
|
||||||
CacheSignature("test-model", "text", sig)
|
CacheSignature(testModelName, "text", sig)
|
||||||
CacheSignature("test-model", "text", sig)
|
CacheSignature(testModelName, "text-2", sig)
|
||||||
|
|
||||||
ClearSignatureCache("")
|
ClearSignatureCache("")
|
||||||
|
|
||||||
if got := GetCachedSignature("test-model", "text"); got != "" {
|
if got := GetCachedSignature(testModelName, "text"); got != "" {
|
||||||
t.Error("session-1 should be cleared")
|
t.Error("text should be cleared")
|
||||||
}
|
}
|
||||||
if got := GetCachedSignature("test-model", "text"); got != "" {
|
if got := GetCachedSignature(testModelName, "text-2"); got != "" {
|
||||||
t.Error("session-2 should be cleared")
|
t.Error("text-2 should be cleared")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHasValidSignature(t *testing.T) {
|
func TestHasValidSignature(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
|
modelName string
|
||||||
signature string
|
signature string
|
||||||
expected bool
|
expected bool
|
||||||
}{
|
}{
|
||||||
{"valid long signature", "abc123validSignature1234567890123456789012345678901234567890", true},
|
{"valid long signature", testModelName, "abc123validSignature1234567890123456789012345678901234567890", true},
|
||||||
{"exactly 50 chars", "12345678901234567890123456789012345678901234567890", true},
|
{"exactly 50 chars", testModelName, "12345678901234567890123456789012345678901234567890", true},
|
||||||
{"49 chars - invalid", "1234567890123456789012345678901234567890123456789", false},
|
{"49 chars - invalid", testModelName, "1234567890123456789012345678901234567890123456789", false},
|
||||||
{"empty string", "", false},
|
{"empty string", testModelName, "", false},
|
||||||
{"short signature", "abc", false},
|
{"short signature", testModelName, "abc", false},
|
||||||
|
{"gemini sentinel", "gemini-3-pro-preview", "skip_thought_signature_validator", true},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
result := HasValidSignature("claude-sonnet-4-5-thinking", tt.signature)
|
result := HasValidSignature(tt.modelName, tt.signature)
|
||||||
if result != tt.expected {
|
if result != tt.expected {
|
||||||
t.Errorf("HasValidSignature(%q) = %v, expected %v", tt.signature, result, tt.expected)
|
t.Errorf("HasValidSignature(%q) = %v, expected %v", tt.signature, result, tt.expected)
|
||||||
}
|
}
|
||||||
@@ -147,13 +148,13 @@ func TestCacheSignature_TextHashCollisionResistance(t *testing.T) {
|
|||||||
sig1 := "signature1_1234567890123456789012345678901234567890123456"
|
sig1 := "signature1_1234567890123456789012345678901234567890123456"
|
||||||
sig2 := "signature2_1234567890123456789012345678901234567890123456"
|
sig2 := "signature2_1234567890123456789012345678901234567890123456"
|
||||||
|
|
||||||
CacheSignature("test-model", text1, sig1)
|
CacheSignature(testModelName, text1, sig1)
|
||||||
CacheSignature("test-model", text2, sig2)
|
CacheSignature(testModelName, text2, sig2)
|
||||||
|
|
||||||
if GetCachedSignature("test-model", text1) != sig1 {
|
if GetCachedSignature(testModelName, text1) != sig1 {
|
||||||
t.Error("text1 signature mismatch")
|
t.Error("text1 signature mismatch")
|
||||||
}
|
}
|
||||||
if GetCachedSignature("test-model", text2) != sig2 {
|
if GetCachedSignature(testModelName, text2) != sig2 {
|
||||||
t.Error("text2 signature mismatch")
|
t.Error("text2 signature mismatch")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -164,9 +165,9 @@ func TestCacheSignature_UnicodeText(t *testing.T) {
|
|||||||
text := "한글 텍스트와 이모지 🎉 그리고 特殊文字"
|
text := "한글 텍스트와 이모지 🎉 그리고 特殊文字"
|
||||||
sig := "unicodeSig123456789012345678901234567890123456789012345"
|
sig := "unicodeSig123456789012345678901234567890123456789012345"
|
||||||
|
|
||||||
CacheSignature("test-model", text, sig)
|
CacheSignature(testModelName, text, sig)
|
||||||
|
|
||||||
if got := GetCachedSignature("test-model", text); got != sig {
|
if got := GetCachedSignature(testModelName, text); got != sig {
|
||||||
t.Errorf("Unicode text signature retrieval failed, got '%s'", got)
|
t.Errorf("Unicode text signature retrieval failed, got '%s'", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -178,10 +179,10 @@ func TestCacheSignature_Overwrite(t *testing.T) {
|
|||||||
sig1 := "firstSignature12345678901234567890123456789012345678901"
|
sig1 := "firstSignature12345678901234567890123456789012345678901"
|
||||||
sig2 := "secondSignature1234567890123456789012345678901234567890"
|
sig2 := "secondSignature1234567890123456789012345678901234567890"
|
||||||
|
|
||||||
CacheSignature("test-model", text, sig1)
|
CacheSignature(testModelName, text, sig1)
|
||||||
CacheSignature("test-model", text, sig2) // Overwrite
|
CacheSignature(testModelName, text, sig2) // Overwrite
|
||||||
|
|
||||||
if got := GetCachedSignature("test-model", text); got != sig2 {
|
if got := GetCachedSignature(testModelName, text); got != sig2 {
|
||||||
t.Errorf("Expected overwritten signature '%s', got '%s'", sig2, got)
|
t.Errorf("Expected overwritten signature '%s', got '%s'", sig2, got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -196,10 +197,10 @@ func TestCacheSignature_ExpirationLogic(t *testing.T) {
|
|||||||
text := "text"
|
text := "text"
|
||||||
sig := "validSig1234567890123456789012345678901234567890123456"
|
sig := "validSig1234567890123456789012345678901234567890123456"
|
||||||
|
|
||||||
CacheSignature("test-model", text, sig)
|
CacheSignature(testModelName, text, sig)
|
||||||
|
|
||||||
// Fresh entry should be retrievable
|
// Fresh entry should be retrievable
|
||||||
if got := GetCachedSignature("test-model", text); got != sig {
|
if got := GetCachedSignature(testModelName, text); got != sig {
|
||||||
t.Errorf("Fresh entry should be retrievable, got '%s'", got)
|
t.Errorf("Fresh entry should be retrievable, got '%s'", got)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -398,7 +398,8 @@ func (e *AIStudioExecutor) translateRequest(req cliproxyexecutor.Request, opts c
|
|||||||
return nil, translatedPayload{}, err
|
return nil, translatedPayload{}, err
|
||||||
}
|
}
|
||||||
payload = fixGeminiImageAspectRatio(baseModel, payload)
|
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.maxOutputTokens")
|
||||||
payload, _ = sjson.DeleteBytes(payload, "generationConfig.responseMimeType")
|
payload, _ = sjson.DeleteBytes(payload, "generationConfig.responseMimeType")
|
||||||
payload, _ = sjson.DeleteBytes(payload, "generationConfig.responseJsonSchema")
|
payload, _ = sjson.DeleteBytes(payload, "generationConfig.responseJsonSchema")
|
||||||
|
|||||||
@@ -142,7 +142,8 @@ func (e *AntigravityExecutor) Execute(ctx context.Context, auth *cliproxyauth.Au
|
|||||||
return resp, err
|
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)
|
baseURLs := antigravityBaseURLFallbackOrder(auth)
|
||||||
httpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
|
httpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
|
||||||
@@ -261,7 +262,8 @@ func (e *AntigravityExecutor) executeClaudeNonStream(ctx context.Context, auth *
|
|||||||
return resp, err
|
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)
|
baseURLs := antigravityBaseURLFallbackOrder(auth)
|
||||||
httpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
|
httpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
|
||||||
@@ -627,7 +629,8 @@ func (e *AntigravityExecutor) ExecuteStream(ctx context.Context, auth *cliproxya
|
|||||||
return nil, err
|
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)
|
baseURLs := antigravityBaseURLFallbackOrder(auth)
|
||||||
httpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
|
httpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
|
||||||
@@ -1213,7 +1216,17 @@ func (e *AntigravityExecutor) buildRequest(ctx context.Context, auth *cliproxyau
|
|||||||
// Use the centralized schema cleaner to handle unsupported keywords,
|
// Use the centralized schema cleaner to handle unsupported keywords,
|
||||||
// const->enum conversion, and flattening of types/anyOf.
|
// const->enum conversion, and flattening of types/anyOf.
|
||||||
strJSON = util.CleanJSONSchemaForAntigravity(strJSON)
|
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)
|
payload = []byte(strJSON)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1230,6 +1243,12 @@ func (e *AntigravityExecutor) buildRequest(ctx context.Context, auth *cliproxyau
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if strings.Contains(modelName, "claude") {
|
||||||
|
payload, _ = sjson.SetBytes(payload, "request.toolConfig.functionCallingConfig.mode", "VALIDATED")
|
||||||
|
} else {
|
||||||
|
payload, _ = sjson.DeleteBytes(payload, "request.generationConfig.maxOutputTokens")
|
||||||
|
}
|
||||||
|
|
||||||
httpReq, errReq := http.NewRequestWithContext(ctx, http.MethodPost, requestURL.String(), bytes.NewReader(payload))
|
httpReq, errReq := http.NewRequestWithContext(ctx, http.MethodPost, requestURL.String(), bytes.NewReader(payload))
|
||||||
if errReq != nil {
|
if errReq != nil {
|
||||||
return nil, errReq
|
return nil, errReq
|
||||||
@@ -1405,26 +1424,10 @@ func geminiToAntigravity(modelName string, payload []byte, projectID string) []b
|
|||||||
template, _ = sjson.Set(template, "request.sessionId", generateStableSessionID(payload))
|
template, _ = sjson.Set(template, "request.sessionId", generateStableSessionID(payload))
|
||||||
|
|
||||||
template, _ = sjson.Delete(template, "request.safetySettings")
|
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)
|
||||||
if strings.Contains(modelName, "claude") || strings.Contains(modelName, "gemini-3-pro-high") {
|
template, _ = sjson.Delete(template, "toolConfig")
|
||||||
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)
|
return []byte(template)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -114,7 +114,8 @@ func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r
|
|||||||
// based on client type and configuration.
|
// based on client type and configuration.
|
||||||
body = applyCloaking(ctx, e.cfg, auth, body, baseModel)
|
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)
|
// Disable thinking if tool_choice forces tool use (Anthropic API constraint)
|
||||||
body = disableThinkingIfToolChoiceForced(body)
|
body = disableThinkingIfToolChoiceForced(body)
|
||||||
@@ -245,7 +246,8 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A
|
|||||||
// based on client type and configuration.
|
// based on client type and configuration.
|
||||||
body = applyCloaking(ctx, e.cfg, auth, body, baseModel)
|
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)
|
// Disable thinking if tool_choice forces tool use (Anthropic API constraint)
|
||||||
body = disableThinkingIfToolChoiceForced(body)
|
body = disableThinkingIfToolChoiceForced(body)
|
||||||
|
|||||||
@@ -101,7 +101,8 @@ func (e *CodexExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, re
|
|||||||
return resp, err
|
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, "model", baseModel)
|
||||||
body, _ = sjson.SetBytes(body, "stream", true)
|
body, _ = sjson.SetBytes(body, "stream", true)
|
||||||
body, _ = sjson.DeleteBytes(body, "previous_response_id")
|
body, _ = sjson.DeleteBytes(body, "previous_response_id")
|
||||||
@@ -213,7 +214,8 @@ func (e *CodexExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Au
|
|||||||
return nil, err
|
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, "previous_response_id")
|
||||||
body, _ = sjson.DeleteBytes(body, "prompt_cache_retention")
|
body, _ = sjson.DeleteBytes(body, "prompt_cache_retention")
|
||||||
body, _ = sjson.DeleteBytes(body, "safety_identifier")
|
body, _ = sjson.DeleteBytes(body, "safety_identifier")
|
||||||
|
|||||||
@@ -129,7 +129,8 @@ func (e *GeminiCLIExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth
|
|||||||
}
|
}
|
||||||
|
|
||||||
basePayload = fixGeminiCLIImageAspectRatio(baseModel, basePayload)
|
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"
|
action := "generateContent"
|
||||||
if req.Metadata != nil {
|
if req.Metadata != nil {
|
||||||
@@ -278,7 +279,8 @@ func (e *GeminiCLIExecutor) ExecuteStream(ctx context.Context, auth *cliproxyaut
|
|||||||
}
|
}
|
||||||
|
|
||||||
basePayload = fixGeminiCLIImageAspectRatio(baseModel, basePayload)
|
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)
|
projectID := resolveGeminiProjectID(auth)
|
||||||
|
|
||||||
|
|||||||
@@ -126,7 +126,8 @@ func (e *GeminiExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r
|
|||||||
}
|
}
|
||||||
|
|
||||||
body = fixGeminiImageAspectRatio(baseModel, body)
|
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)
|
body, _ = sjson.SetBytes(body, "model", baseModel)
|
||||||
|
|
||||||
action := "generateContent"
|
action := "generateContent"
|
||||||
@@ -228,7 +229,8 @@ func (e *GeminiExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A
|
|||||||
}
|
}
|
||||||
|
|
||||||
body = fixGeminiImageAspectRatio(baseModel, body)
|
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)
|
body, _ = sjson.SetBytes(body, "model", baseModel)
|
||||||
|
|
||||||
baseURL := resolveGeminiBaseURL(auth)
|
baseURL := resolveGeminiBaseURL(auth)
|
||||||
|
|||||||
@@ -325,7 +325,8 @@ func (e *GeminiVertexExecutor) executeWithServiceAccount(ctx context.Context, au
|
|||||||
}
|
}
|
||||||
|
|
||||||
body = fixGeminiImageAspectRatio(baseModel, body)
|
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)
|
body, _ = sjson.SetBytes(body, "model", baseModel)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -438,7 +439,8 @@ func (e *GeminiVertexExecutor) executeWithAPIKey(ctx context.Context, auth *clip
|
|||||||
}
|
}
|
||||||
|
|
||||||
body = fixGeminiImageAspectRatio(baseModel, body)
|
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)
|
body, _ = sjson.SetBytes(body, "model", baseModel)
|
||||||
|
|
||||||
action := getVertexAction(baseModel, false)
|
action := getVertexAction(baseModel, false)
|
||||||
@@ -541,7 +543,8 @@ func (e *GeminiVertexExecutor) executeStreamWithServiceAccount(ctx context.Conte
|
|||||||
}
|
}
|
||||||
|
|
||||||
body = fixGeminiImageAspectRatio(baseModel, body)
|
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)
|
body, _ = sjson.SetBytes(body, "model", baseModel)
|
||||||
|
|
||||||
action := getVertexAction(baseModel, true)
|
action := getVertexAction(baseModel, true)
|
||||||
@@ -664,7 +667,8 @@ func (e *GeminiVertexExecutor) executeStreamWithAPIKey(ctx context.Context, auth
|
|||||||
}
|
}
|
||||||
|
|
||||||
body = fixGeminiImageAspectRatio(baseModel, body)
|
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)
|
body, _ = sjson.SetBytes(body, "model", baseModel)
|
||||||
|
|
||||||
action := getVertexAction(baseModel, true)
|
action := getVertexAction(baseModel, true)
|
||||||
|
|||||||
@@ -98,7 +98,8 @@ func (e *IFlowExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, re
|
|||||||
}
|
}
|
||||||
|
|
||||||
body = preserveReasoningContentInMessages(body)
|
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
|
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 {
|
if toolsResult.Exists() && toolsResult.IsArray() && len(toolsResult.Array()) == 0 {
|
||||||
body = ensureToolsArray(body)
|
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
|
endpoint := strings.TrimSuffix(baseURL, "/") + iflowDefaultEndpoint
|
||||||
|
|
||||||
|
|||||||
@@ -90,7 +90,8 @@ func (e *OpenAICompatExecutor) Execute(ctx context.Context, auth *cliproxyauth.A
|
|||||||
}
|
}
|
||||||
originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, opts.Stream)
|
originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, opts.Stream)
|
||||||
translated := sdktranslator.TranslateRequest(from, to, baseModel, bytes.Clone(req.Payload), 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())
|
translated, err = thinking.ApplyThinking(translated, req.Model, from.String(), to.String(), e.Identifier())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -185,7 +186,8 @@ func (e *OpenAICompatExecutor) ExecuteStream(ctx context.Context, auth *cliproxy
|
|||||||
}
|
}
|
||||||
originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, true)
|
originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, true)
|
||||||
translated := sdktranslator.TranslateRequest(from, to, baseModel, bytes.Clone(req.Payload), 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())
|
translated, err = thinking.ApplyThinking(translated, req.Model, from.String(), to.String(), e.Identifier())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
"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/gjson"
|
||||||
"github.com/tidwall/sjson"
|
"github.com/tidwall/sjson"
|
||||||
)
|
)
|
||||||
@@ -12,8 +14,9 @@ import (
|
|||||||
// applyPayloadConfigWithRoot behaves like applyPayloadConfig but treats all parameter
|
// applyPayloadConfigWithRoot behaves like applyPayloadConfig but treats all parameter
|
||||||
// paths as relative to the provided root path (for example, "request" for Gemini CLI)
|
// 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
|
// and restricts matches to the given protocol when supplied. Defaults are checked
|
||||||
// against the original payload when provided.
|
// against the original payload when provided. requestedModel carries the client-visible
|
||||||
func applyPayloadConfigWithRoot(cfg *config.Config, model, protocol, root string, payload, original []byte) []byte {
|
// 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 {
|
if cfg == nil || len(payload) == 0 {
|
||||||
return payload
|
return payload
|
||||||
}
|
}
|
||||||
@@ -22,10 +25,11 @@ func applyPayloadConfigWithRoot(cfg *config.Config, model, protocol, root string
|
|||||||
return payload
|
return payload
|
||||||
}
|
}
|
||||||
model = strings.TrimSpace(model)
|
model = strings.TrimSpace(model)
|
||||||
if model == "" {
|
requestedModel = strings.TrimSpace(requestedModel)
|
||||||
|
if model == "" && requestedModel == "" {
|
||||||
return payload
|
return payload
|
||||||
}
|
}
|
||||||
candidates := payloadModelCandidates(cfg, model, protocol)
|
candidates := payloadModelCandidates(model, requestedModel)
|
||||||
out := payload
|
out := payload
|
||||||
source := original
|
source := original
|
||||||
if len(source) == 0 {
|
if len(source) == 0 {
|
||||||
@@ -163,65 +167,42 @@ func payloadRuleMatchesModel(rule *config.PayloadRule, model, protocol string) b
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
func payloadModelCandidates(cfg *config.Config, model, protocol string) []string {
|
func payloadModelCandidates(model, requestedModel string) []string {
|
||||||
model = strings.TrimSpace(model)
|
model = strings.TrimSpace(model)
|
||||||
if model == "" {
|
requestedModel = strings.TrimSpace(requestedModel)
|
||||||
|
if model == "" && requestedModel == "" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
candidates := []string{model}
|
candidates := make([]string, 0, 3)
|
||||||
if cfg == nil {
|
seen := make(map[string]struct{}, 3)
|
||||||
return candidates
|
addCandidate := func(value string) {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if value == "" {
|
||||||
|
return
|
||||||
}
|
}
|
||||||
aliases := payloadModelAliases(cfg, model, protocol)
|
key := strings.ToLower(value)
|
||||||
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
|
|
||||||
}
|
|
||||||
key := strings.ToLower(alias)
|
|
||||||
if _, ok := seen[key]; ok {
|
if _, ok := seen[key]; ok {
|
||||||
continue
|
return
|
||||||
}
|
}
|
||||||
seen[key] = struct{}{}
|
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
|
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.
|
// 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,
|
// 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.
|
// 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.
|
// matchModelPattern performs simple wildcard matching where '*' matches zero or more characters.
|
||||||
// Examples:
|
// Examples:
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -91,7 +91,8 @@ func (e *QwenExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req
|
|||||||
return resp, err
|
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"
|
url := strings.TrimSuffix(baseURL, "/") + "/chat/completions"
|
||||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
|
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.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, _ = 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"
|
url := strings.TrimSuffix(baseURL, "/") + "/chat/completions"
|
||||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
|
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
|
||||||
|
|||||||
@@ -74,13 +74,13 @@ func TestConvertClaudeRequestToAntigravity_RoleMapping(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestConvertClaudeRequestToAntigravity_ThinkingBlocks(t *testing.T) {
|
func TestConvertClaudeRequestToAntigravity_ThinkingBlocks(t *testing.T) {
|
||||||
|
cache.ClearSignatureCache("")
|
||||||
|
|
||||||
// Valid signature must be at least 50 characters
|
// Valid signature must be at least 50 characters
|
||||||
validSignature := "abc123validSignature1234567890123456789012345678901234567890"
|
validSignature := "abc123validSignature1234567890123456789012345678901234567890"
|
||||||
thinkingText := "Let me think..."
|
thinkingText := "Let me think..."
|
||||||
|
|
||||||
// Pre-cache the signature (simulating a response from the same session)
|
// Pre-cache the signature (simulating a previous response for the same thinking text)
|
||||||
// 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
|
|
||||||
inputJSON := []byte(`{
|
inputJSON := []byte(`{
|
||||||
"model": "claude-sonnet-4-5-thinking",
|
"model": "claude-sonnet-4-5-thinking",
|
||||||
"messages": [
|
"messages": [
|
||||||
@@ -117,6 +117,8 @@ func TestConvertClaudeRequestToAntigravity_ThinkingBlocks(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestConvertClaudeRequestToAntigravity_ThinkingBlockWithoutSignature(t *testing.T) {
|
func TestConvertClaudeRequestToAntigravity_ThinkingBlockWithoutSignature(t *testing.T) {
|
||||||
|
cache.ClearSignatureCache("")
|
||||||
|
|
||||||
// Unsigned thinking blocks should be removed entirely (not converted to text)
|
// Unsigned thinking blocks should be removed entirely (not converted to text)
|
||||||
inputJSON := []byte(`{
|
inputJSON := []byte(`{
|
||||||
"model": "claude-sonnet-4-5-thinking",
|
"model": "claude-sonnet-4-5-thinking",
|
||||||
@@ -238,6 +240,8 @@ func TestConvertClaudeRequestToAntigravity_ToolUse(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestConvertClaudeRequestToAntigravity_ToolUse_WithSignature(t *testing.T) {
|
func TestConvertClaudeRequestToAntigravity_ToolUse_WithSignature(t *testing.T) {
|
||||||
|
cache.ClearSignatureCache("")
|
||||||
|
|
||||||
validSignature := "abc123validSignature1234567890123456789012345678901234567890"
|
validSignature := "abc123validSignature1234567890123456789012345678901234567890"
|
||||||
thinkingText := "Let me think..."
|
thinkingText := "Let me think..."
|
||||||
|
|
||||||
@@ -279,6 +283,8 @@ func TestConvertClaudeRequestToAntigravity_ToolUse_WithSignature(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestConvertClaudeRequestToAntigravity_ReorderThinking(t *testing.T) {
|
func TestConvertClaudeRequestToAntigravity_ReorderThinking(t *testing.T) {
|
||||||
|
cache.ClearSignatureCache("")
|
||||||
|
|
||||||
// Case: text block followed by thinking block -> should be reordered to thinking first
|
// Case: text block followed by thinking block -> should be reordered to thinking first
|
||||||
validSignature := "abc123validSignature1234567890123456789012345678901234567890"
|
validSignature := "abc123validSignature1234567890123456789012345678901234567890"
|
||||||
thinkingText := "Planning..."
|
thinkingText := "Planning..."
|
||||||
@@ -487,6 +493,8 @@ func TestConvertClaudeRequestToAntigravity_TrailingUnsignedThinking_Removed(t *t
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestConvertClaudeRequestToAntigravity_TrailingSignedThinking_Kept(t *testing.T) {
|
func TestConvertClaudeRequestToAntigravity_TrailingSignedThinking_Kept(t *testing.T) {
|
||||||
|
cache.ClearSignatureCache("")
|
||||||
|
|
||||||
// Last assistant message ends with signed thinking block - should be kept
|
// Last assistant message ends with signed thinking block - should be kept
|
||||||
validSignature := "abc123validSignature1234567890123456789012345678901234567890"
|
validSignature := "abc123validSignature1234567890123456789012345678901234567890"
|
||||||
thinkingText := "Valid thinking..."
|
thinkingText := "Valid thinking..."
|
||||||
|
|||||||
@@ -139,7 +139,7 @@ func ConvertAntigravityResponseToClaude(_ context.Context, _ string, originalReq
|
|||||||
|
|
||||||
if params.CurrentThinkingText.Len() > 0 {
|
if params.CurrentThinkingText.Len() > 0 {
|
||||||
cache.CacheSignature(modelName, params.CurrentThinkingText.String(), thoughtSignature.String())
|
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()
|
params.CurrentThinkingText.Reset()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,10 +12,10 @@ import (
|
|||||||
// Signature Caching Tests
|
// Signature Caching Tests
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
func TestConvertAntigravityResponseToClaude_SessionIDDerived(t *testing.T) {
|
func TestConvertAntigravityResponseToClaude_ParamsInitialized(t *testing.T) {
|
||||||
cache.ClearSignatureCache("")
|
cache.ClearSignatureCache("")
|
||||||
|
|
||||||
// Request with user message - should derive session ID
|
// Request with user message - should initialize params
|
||||||
requestJSON := []byte(`{
|
requestJSON := []byte(`{
|
||||||
"messages": [
|
"messages": [
|
||||||
{"role": "user", "content": [{"type": "text", "text": "Hello world"}]}
|
{"role": "user", "content": [{"type": "text", "text": "Hello world"}]}
|
||||||
@@ -37,10 +37,12 @@ func TestConvertAntigravityResponseToClaude_SessionIDDerived(t *testing.T) {
|
|||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
ConvertAntigravityResponseToClaude(ctx, "claude-sonnet-4-5-thinking", requestJSON, requestJSON, responseJSON, ¶m)
|
ConvertAntigravityResponseToClaude(ctx, "claude-sonnet-4-5-thinking", requestJSON, requestJSON, responseJSON, ¶m)
|
||||||
|
|
||||||
// Verify session ID was set
|
|
||||||
params := param.(*Params)
|
params := param.(*Params)
|
||||||
if params.SessionID == "" {
|
if !params.HasFirstResponse {
|
||||||
t.Error("SessionID should be derived from request")
|
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
|
// Process thinking chunk
|
||||||
ConvertAntigravityResponseToClaude(ctx, "claude-sonnet-4-5-thinking", requestJSON, requestJSON, thinkingChunk, ¶m)
|
ConvertAntigravityResponseToClaude(ctx, "claude-sonnet-4-5-thinking", requestJSON, requestJSON, thinkingChunk, ¶m)
|
||||||
params := param.(*Params)
|
params := param.(*Params)
|
||||||
sessionID := params.SessionID
|
|
||||||
thinkingText := params.CurrentThinkingText.String()
|
thinkingText := params.CurrentThinkingText.String()
|
||||||
|
|
||||||
if sessionID == "" {
|
|
||||||
t.Fatal("SessionID should be set")
|
|
||||||
}
|
|
||||||
if thinkingText == "" {
|
if thinkingText == "" {
|
||||||
t.Fatal("Thinking text should be accumulated")
|
t.Fatal("Thinking text should be accumulated")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
func TestConvertGeminiRequestToAntigravity_ParallelFunctionCalls(t *testing.T) {
|
||||||
// Multiple functionCalls should all get skip_thought_signature_validator
|
// Multiple functionCalls should all get skip_thought_signature_validator
|
||||||
inputJSON := []byte(`{
|
inputJSON := []byte(`{
|
||||||
|
|||||||
@@ -98,9 +98,8 @@ func ConvertGeminiRequestToClaude(modelName string, inputRawJSON []byte, stream
|
|||||||
// Temperature setting for controlling response randomness
|
// Temperature setting for controlling response randomness
|
||||||
if temp := genConfig.Get("temperature"); temp.Exists() {
|
if temp := genConfig.Get("temperature"); temp.Exists() {
|
||||||
out, _ = sjson.Set(out, "temperature", temp.Float())
|
out, _ = sjson.Set(out, "temperature", temp.Float())
|
||||||
}
|
} else if topP := genConfig.Get("topP"); topP.Exists() {
|
||||||
// Top P setting for nucleus sampling
|
// Top P setting for nucleus sampling (filtered out if temperature is set)
|
||||||
if topP := genConfig.Get("topP"); topP.Exists() {
|
|
||||||
out, _ = sjson.Set(out, "top_p", topP.Float())
|
out, _ = sjson.Set(out, "top_p", topP.Float())
|
||||||
}
|
}
|
||||||
// Stop sequences configuration for custom termination conditions
|
// Stop sequences configuration for custom termination conditions
|
||||||
|
|||||||
@@ -110,10 +110,8 @@ func ConvertOpenAIRequestToClaude(modelName string, inputRawJSON []byte, stream
|
|||||||
// Temperature setting for controlling response randomness
|
// Temperature setting for controlling response randomness
|
||||||
if temp := root.Get("temperature"); temp.Exists() {
|
if temp := root.Get("temperature"); temp.Exists() {
|
||||||
out, _ = sjson.Set(out, "temperature", temp.Float())
|
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)
|
||||||
// Top P setting for nucleus sampling
|
|
||||||
if topP := root.Get("top_p"); topP.Exists() {
|
|
||||||
out, _ = sjson.Set(out, "top_p", topP.Float())
|
out, _ = sjson.Set(out, "top_p", topP.Float())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -89,12 +89,14 @@ func ConvertClaudeRequestToOpenAI(modelName string, inputRawJSON []byte, stream
|
|||||||
|
|
||||||
// Handle system message first
|
// Handle system message first
|
||||||
systemMsgJSON := `{"role":"system","content":[]}`
|
systemMsgJSON := `{"role":"system","content":[]}`
|
||||||
|
hasSystemContent := false
|
||||||
if system := root.Get("system"); system.Exists() {
|
if system := root.Get("system"); system.Exists() {
|
||||||
if system.Type == gjson.String {
|
if system.Type == gjson.String {
|
||||||
if system.String() != "" {
|
if system.String() != "" {
|
||||||
oldSystem := `{"type":"text","text":""}`
|
oldSystem := `{"type":"text","text":""}`
|
||||||
oldSystem, _ = sjson.Set(oldSystem, "text", system.String())
|
oldSystem, _ = sjson.Set(oldSystem, "text", system.String())
|
||||||
systemMsgJSON, _ = sjson.SetRaw(systemMsgJSON, "content.-1", oldSystem)
|
systemMsgJSON, _ = sjson.SetRaw(systemMsgJSON, "content.-1", oldSystem)
|
||||||
|
hasSystemContent = true
|
||||||
}
|
}
|
||||||
} else if system.Type == gjson.JSON {
|
} else if system.Type == gjson.JSON {
|
||||||
if system.IsArray() {
|
if system.IsArray() {
|
||||||
@@ -102,12 +104,16 @@ func ConvertClaudeRequestToOpenAI(modelName string, inputRawJSON []byte, stream
|
|||||||
for i := 0; i < len(systemResults); i++ {
|
for i := 0; i < len(systemResults); i++ {
|
||||||
if contentItem, ok := convertClaudeContentPart(systemResults[i]); ok {
|
if contentItem, ok := convertClaudeContentPart(systemResults[i]); ok {
|
||||||
systemMsgJSON, _ = sjson.SetRaw(systemMsgJSON, "content.-1", contentItem)
|
systemMsgJSON, _ = sjson.SetRaw(systemMsgJSON, "content.-1", contentItem)
|
||||||
|
hasSystemContent = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Only add system message if it has content
|
||||||
|
if hasSystemContent {
|
||||||
messagesJSON, _ = sjson.SetRaw(messagesJSON, "-1", systemMsgJSON)
|
messagesJSON, _ = sjson.SetRaw(messagesJSON, "-1", systemMsgJSON)
|
||||||
|
}
|
||||||
|
|
||||||
// Process Anthropic messages
|
// Process Anthropic messages
|
||||||
if messages := root.Get("messages"); messages.Exists() && messages.IsArray() {
|
if messages := root.Get("messages"); messages.Exists() && messages.IsArray() {
|
||||||
|
|||||||
@@ -12,10 +12,23 @@ import (
|
|||||||
|
|
||||||
var gjsonPathKeyReplacer = strings.NewReplacer(".", "\\.", "*", "\\*", "?", "\\?")
|
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.
|
// CleanJSONSchemaForAntigravity transforms a JSON schema to be compatible with Antigravity API.
|
||||||
// It handles unsupported keywords, type flattening, and schema simplification while preserving
|
// It handles unsupported keywords, type flattening, and schema simplification while preserving
|
||||||
// semantic information as description hints.
|
// semantic information as description hints.
|
||||||
func CleanJSONSchemaForAntigravity(jsonStr string) string {
|
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
|
// Phase 1: Convert and add hints
|
||||||
jsonStr = convertRefsToHints(jsonStr)
|
jsonStr = convertRefsToHints(jsonStr)
|
||||||
jsonStr = convertConstToEnum(jsonStr)
|
jsonStr = convertConstToEnum(jsonStr)
|
||||||
@@ -31,10 +44,94 @@ func CleanJSONSchemaForAntigravity(jsonStr string) string {
|
|||||||
|
|
||||||
// Phase 3: Cleanup
|
// Phase 3: Cleanup
|
||||||
jsonStr = removeUnsupportedKeywords(jsonStr)
|
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)
|
jsonStr = cleanupRequiredFields(jsonStr)
|
||||||
|
|
||||||
// Phase 4: Add placeholder for empty object schemas (Claude VALIDATED mode requirement)
|
// Phase 4: Add placeholder for empty object schemas (Claude VALIDATED mode requirement)
|
||||||
|
if addPlaceholder {
|
||||||
jsonStr = addEmptySchemaPlaceholder(jsonStr)
|
jsonStr = addEmptySchemaPlaceholder(jsonStr)
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonStr
|
||||||
|
}
|
||||||
|
|
||||||
|
// removeKeywords removes all occurrences of specified keywords from the JSON schema.
|
||||||
|
func removeKeywords(jsonStr string, keywords []string) string {
|
||||||
|
for _, key := range keywords {
|
||||||
|
for _, p := range findPaths(jsonStr, key) {
|
||||||
|
if isPropertyDefinition(trimSuffix(p, "."+key)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
jsonStr, _ = sjson.Delete(jsonStr, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return jsonStr
|
||||||
|
}
|
||||||
|
|
||||||
|
// removePlaceholderFields removes placeholder-only properties ("_" and "reason") and their required entries.
|
||||||
|
func removePlaceholderFields(jsonStr string) string {
|
||||||
|
// Remove "_" placeholder properties.
|
||||||
|
paths := findPaths(jsonStr, "_")
|
||||||
|
sortByDepth(paths)
|
||||||
|
for _, p := range paths {
|
||||||
|
if !strings.HasSuffix(p, ".properties._") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
jsonStr, _ = sjson.Delete(jsonStr, p)
|
||||||
|
parentPath := trimSuffix(p, ".properties._")
|
||||||
|
reqPath := joinPath(parentPath, "required")
|
||||||
|
req := gjson.Get(jsonStr, reqPath)
|
||||||
|
if req.IsArray() {
|
||||||
|
var filtered []string
|
||||||
|
for _, r := range req.Array() {
|
||||||
|
if r.String() != "_" {
|
||||||
|
filtered = append(filtered, r.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(filtered) == 0 {
|
||||||
|
jsonStr, _ = sjson.Delete(jsonStr, reqPath)
|
||||||
|
} else {
|
||||||
|
jsonStr, _ = sjson.Set(jsonStr, reqPath, filtered)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove placeholder-only "reason" objects.
|
||||||
|
reasonPaths := findPaths(jsonStr, "reason")
|
||||||
|
sortByDepth(reasonPaths)
|
||||||
|
for _, p := range reasonPaths {
|
||||||
|
if !strings.HasSuffix(p, ".properties.reason") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
parentPath := trimSuffix(p, ".properties.reason")
|
||||||
|
props := gjson.Get(jsonStr, joinPath(parentPath, "properties"))
|
||||||
|
if !props.IsObject() || len(props.Map()) != 1 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
desc := gjson.Get(jsonStr, p+".description").String()
|
||||||
|
if desc != placeholderReasonDescription {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
jsonStr, _ = sjson.Delete(jsonStr, p)
|
||||||
|
reqPath := joinPath(parentPath, "required")
|
||||||
|
req := gjson.Get(jsonStr, reqPath)
|
||||||
|
if req.IsArray() {
|
||||||
|
var filtered []string
|
||||||
|
for _, r := range req.Array() {
|
||||||
|
if r.String() != "reason" {
|
||||||
|
filtered = append(filtered, r.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(filtered) == 0 {
|
||||||
|
jsonStr, _ = sjson.Delete(jsonStr, reqPath)
|
||||||
|
} else {
|
||||||
|
jsonStr, _ = sjson.Set(jsonStr, reqPath, filtered)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return jsonStr
|
return jsonStr
|
||||||
}
|
}
|
||||||
@@ -409,7 +506,7 @@ func addEmptySchemaPlaceholder(jsonStr string) string {
|
|||||||
// Add placeholder "reason" property
|
// Add placeholder "reason" property
|
||||||
reasonPath := joinPath(propsPath, "reason")
|
reasonPath := joinPath(propsPath, "reason")
|
||||||
jsonStr, _ = sjson.Set(jsonStr, reasonPath+".type", "string")
|
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
|
// Add to required array
|
||||||
jsonStr, _ = sjson.Set(jsonStr, reqPath, []string{"reason"})
|
jsonStr, _ = sjson.Set(jsonStr, reqPath, []string{"reason"})
|
||||||
|
|||||||
@@ -386,6 +386,7 @@ func (h *BaseAPIHandler) ExecuteWithAuthManager(ctx context.Context, handlerType
|
|||||||
return nil, errMsg
|
return nil, errMsg
|
||||||
}
|
}
|
||||||
reqMeta := requestExecutionMetadata(ctx)
|
reqMeta := requestExecutionMetadata(ctx)
|
||||||
|
reqMeta[coreexecutor.RequestedModelMetadataKey] = normalizedModel
|
||||||
req := coreexecutor.Request{
|
req := coreexecutor.Request{
|
||||||
Model: normalizedModel,
|
Model: normalizedModel,
|
||||||
Payload: cloneBytes(rawJSON),
|
Payload: cloneBytes(rawJSON),
|
||||||
@@ -424,6 +425,7 @@ func (h *BaseAPIHandler) ExecuteCountWithAuthManager(ctx context.Context, handle
|
|||||||
return nil, errMsg
|
return nil, errMsg
|
||||||
}
|
}
|
||||||
reqMeta := requestExecutionMetadata(ctx)
|
reqMeta := requestExecutionMetadata(ctx)
|
||||||
|
reqMeta[coreexecutor.RequestedModelMetadataKey] = normalizedModel
|
||||||
req := coreexecutor.Request{
|
req := coreexecutor.Request{
|
||||||
Model: normalizedModel,
|
Model: normalizedModel,
|
||||||
Payload: cloneBytes(rawJSON),
|
Payload: cloneBytes(rawJSON),
|
||||||
@@ -465,6 +467,7 @@ func (h *BaseAPIHandler) ExecuteStreamWithAuthManager(ctx context.Context, handl
|
|||||||
return nil, errChan
|
return nil, errChan
|
||||||
}
|
}
|
||||||
reqMeta := requestExecutionMetadata(ctx)
|
reqMeta := requestExecutionMetadata(ctx)
|
||||||
|
reqMeta[coreexecutor.RequestedModelMetadataKey] = normalizedModel
|
||||||
req := coreexecutor.Request{
|
req := coreexecutor.Request{
|
||||||
Model: normalizedModel,
|
Model: normalizedModel,
|
||||||
Payload: cloneBytes(rawJSON),
|
Payload: cloneBytes(rawJSON),
|
||||||
|
|||||||
+48
-218
@@ -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"}
|
return cliproxyexecutor.Response{}, &Error{Code: "provider_not_found", Message: "no provider supplied"}
|
||||||
}
|
}
|
||||||
routeModel := req.Model
|
routeModel := req.Model
|
||||||
|
opts = ensureRequestedModelMetadata(opts, routeModel)
|
||||||
tried := make(map[string]struct{})
|
tried := make(map[string]struct{})
|
||||||
var lastErr error
|
var lastErr error
|
||||||
for {
|
for {
|
||||||
@@ -597,6 +598,9 @@ func (m *Manager) executeMixedOnce(ctx context.Context, providers []string, req
|
|||||||
resp, errExec := executor.Execute(execCtx, auth, execReq, opts)
|
resp, errExec := executor.Execute(execCtx, auth, execReq, opts)
|
||||||
result := Result{AuthID: auth.ID, Provider: provider, Model: routeModel, Success: errExec == nil}
|
result := Result{AuthID: auth.ID, Provider: provider, Model: routeModel, Success: errExec == nil}
|
||||||
if errExec != nil {
|
if errExec != nil {
|
||||||
|
if errCtx := execCtx.Err(); errCtx != nil {
|
||||||
|
return cliproxyexecutor.Response{}, errCtx
|
||||||
|
}
|
||||||
result.Error = &Error{Message: errExec.Error()}
|
result.Error = &Error{Message: errExec.Error()}
|
||||||
var se cliproxyexecutor.StatusError
|
var se cliproxyexecutor.StatusError
|
||||||
if errors.As(errExec, &se) && se != nil {
|
if errors.As(errExec, &se) && se != nil {
|
||||||
@@ -619,6 +623,7 @@ func (m *Manager) executeCountMixedOnce(ctx context.Context, providers []string,
|
|||||||
return cliproxyexecutor.Response{}, &Error{Code: "provider_not_found", Message: "no provider supplied"}
|
return cliproxyexecutor.Response{}, &Error{Code: "provider_not_found", Message: "no provider supplied"}
|
||||||
}
|
}
|
||||||
routeModel := req.Model
|
routeModel := req.Model
|
||||||
|
opts = ensureRequestedModelMetadata(opts, routeModel)
|
||||||
tried := make(map[string]struct{})
|
tried := make(map[string]struct{})
|
||||||
var lastErr error
|
var lastErr error
|
||||||
for {
|
for {
|
||||||
@@ -646,6 +651,9 @@ func (m *Manager) executeCountMixedOnce(ctx context.Context, providers []string,
|
|||||||
resp, errExec := executor.CountTokens(execCtx, auth, execReq, opts)
|
resp, errExec := executor.CountTokens(execCtx, auth, execReq, opts)
|
||||||
result := Result{AuthID: auth.ID, Provider: provider, Model: routeModel, Success: errExec == nil}
|
result := Result{AuthID: auth.ID, Provider: provider, Model: routeModel, Success: errExec == nil}
|
||||||
if errExec != nil {
|
if errExec != nil {
|
||||||
|
if errCtx := execCtx.Err(); errCtx != nil {
|
||||||
|
return cliproxyexecutor.Response{}, errCtx
|
||||||
|
}
|
||||||
result.Error = &Error{Message: errExec.Error()}
|
result.Error = &Error{Message: errExec.Error()}
|
||||||
var se cliproxyexecutor.StatusError
|
var se cliproxyexecutor.StatusError
|
||||||
if errors.As(errExec, &se) && se != nil {
|
if errors.As(errExec, &se) && se != nil {
|
||||||
@@ -668,6 +676,7 @@ func (m *Manager) executeStreamMixedOnce(ctx context.Context, providers []string
|
|||||||
return nil, &Error{Code: "provider_not_found", Message: "no provider supplied"}
|
return nil, &Error{Code: "provider_not_found", Message: "no provider supplied"}
|
||||||
}
|
}
|
||||||
routeModel := req.Model
|
routeModel := req.Model
|
||||||
|
opts = ensureRequestedModelMetadata(opts, routeModel)
|
||||||
tried := make(map[string]struct{})
|
tried := make(map[string]struct{})
|
||||||
var lastErr error
|
var lastErr error
|
||||||
for {
|
for {
|
||||||
@@ -694,6 +703,9 @@ func (m *Manager) executeStreamMixedOnce(ctx context.Context, providers []string
|
|||||||
execReq.Model = m.applyAPIKeyModelAlias(auth, execReq.Model)
|
execReq.Model = m.applyAPIKeyModelAlias(auth, execReq.Model)
|
||||||
chunks, errStream := executor.ExecuteStream(execCtx, auth, execReq, opts)
|
chunks, errStream := executor.ExecuteStream(execCtx, auth, execReq, opts)
|
||||||
if errStream != nil {
|
if errStream != nil {
|
||||||
|
if errCtx := execCtx.Err(); errCtx != nil {
|
||||||
|
return nil, errCtx
|
||||||
|
}
|
||||||
rerr := &Error{Message: errStream.Error()}
|
rerr := &Error{Message: errStream.Error()}
|
||||||
var se cliproxyexecutor.StatusError
|
var se cliproxyexecutor.StatusError
|
||||||
if errors.As(errStream, &se) && se != nil {
|
if errors.As(errStream, &se) && se != nil {
|
||||||
@@ -729,167 +741,42 @@ func (m *Manager) executeStreamMixedOnce(ctx context.Context, providers []string
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) executeWithProvider(ctx context.Context, provider string, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) {
|
func ensureRequestedModelMetadata(opts cliproxyexecutor.Options, requestedModel string) cliproxyexecutor.Options {
|
||||||
if provider == "" {
|
requestedModel = strings.TrimSpace(requestedModel)
|
||||||
return cliproxyexecutor.Response{}, &Error{Code: "provider_not_found", Message: "provider identifier is empty"}
|
if requestedModel == "" {
|
||||||
|
return opts
|
||||||
}
|
}
|
||||||
routeModel := req.Model
|
if hasRequestedModelMetadata(opts.Metadata) {
|
||||||
tried := make(map[string]struct{})
|
return opts
|
||||||
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
|
if len(opts.Metadata) == 0 {
|
||||||
|
opts.Metadata = map[string]any{cliproxyexecutor.RequestedModelMetadataKey: requestedModel}
|
||||||
|
return opts
|
||||||
}
|
}
|
||||||
|
meta := make(map[string]any, len(opts.Metadata)+1)
|
||||||
entry := logEntryWithRequestID(ctx)
|
for k, v := range opts.Metadata {
|
||||||
debugLogAuthSelection(entry, auth, provider, req.Model)
|
meta[k] = v
|
||||||
|
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
meta[cliproxyexecutor.RequestedModelMetadataKey] = requestedModel
|
||||||
|
opts.Metadata = meta
|
||||||
|
return opts
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) executeCountWithProvider(ctx context.Context, provider string, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) {
|
func hasRequestedModelMetadata(meta map[string]any) bool {
|
||||||
if provider == "" {
|
if len(meta) == 0 {
|
||||||
return cliproxyexecutor.Response{}, &Error{Code: "provider_not_found", Message: "provider identifier is empty"}
|
return false
|
||||||
}
|
}
|
||||||
routeModel := req.Model
|
raw, ok := meta[cliproxyexecutor.RequestedModelMetadataKey]
|
||||||
tried := make(map[string]struct{})
|
if !ok || raw == nil {
|
||||||
var lastErr error
|
return false
|
||||||
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
|
switch v := raw.(type) {
|
||||||
}
|
case string:
|
||||||
|
return strings.TrimSpace(v) != ""
|
||||||
entry := logEntryWithRequestID(ctx)
|
case []byte:
|
||||||
debugLogAuthSelection(entry, auth, provider, req.Model)
|
return strings.TrimSpace(string(v)) != ""
|
||||||
|
default:
|
||||||
tried[auth.ID] = struct{}{}
|
return false
|
||||||
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
|
|
||||||
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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1140,35 +1027,6 @@ func (m *Manager) normalizeProviders(providers []string) []string {
|
|||||||
return result
|
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) {
|
func (m *Manager) retrySettings() (int, time.Duration) {
|
||||||
if m == nil {
|
if m == nil {
|
||||||
return 0, 0
|
return 0, 0
|
||||||
@@ -1250,42 +1108,6 @@ func waitForCooldown(ctx context.Context, wait time.Duration) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) executeProvidersOnce(ctx context.Context, providers []string, fn func(context.Context, string) (cliproxyexecutor.Response, error)) (cliproxyexecutor.Response, error) {
|
|
||||||
if len(providers) == 0 {
|
|
||||||
return cliproxyexecutor.Response{}, &Error{Code: "provider_not_found", Message: "no provider supplied"}
|
|
||||||
}
|
|
||||||
var lastErr error
|
|
||||||
for _, provider := range providers {
|
|
||||||
resp, errExec := fn(ctx, provider)
|
|
||||||
if errExec == nil {
|
|
||||||
return resp, nil
|
|
||||||
}
|
|
||||||
lastErr = errExec
|
|
||||||
}
|
|
||||||
if lastErr != nil {
|
|
||||||
return cliproxyexecutor.Response{}, lastErr
|
|
||||||
}
|
|
||||||
return cliproxyexecutor.Response{}, &Error{Code: "auth_not_found", Message: "no auth available"}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Manager) executeStreamProvidersOnce(ctx context.Context, providers []string, fn func(context.Context, string) (<-chan cliproxyexecutor.StreamChunk, error)) (<-chan cliproxyexecutor.StreamChunk, error) {
|
|
||||||
if len(providers) == 0 {
|
|
||||||
return nil, &Error{Code: "provider_not_found", Message: "no provider supplied"}
|
|
||||||
}
|
|
||||||
var lastErr error
|
|
||||||
for _, provider := range providers {
|
|
||||||
chunks, errExec := fn(ctx, provider)
|
|
||||||
if errExec == nil {
|
|
||||||
return chunks, nil
|
|
||||||
}
|
|
||||||
lastErr = errExec
|
|
||||||
}
|
|
||||||
if lastErr != nil {
|
|
||||||
return nil, lastErr
|
|
||||||
}
|
|
||||||
return nil, &Error{Code: "auth_not_found", Message: "no auth available"}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MarkResult records an execution result and notifies hooks.
|
// MarkResult records an execution result and notifies hooks.
|
||||||
func (m *Manager) MarkResult(ctx context.Context, result Result) {
|
func (m *Manager) MarkResult(ctx context.Context, result Result) {
|
||||||
if result.AuthID == "" {
|
if result.AuthID == "" {
|
||||||
@@ -1371,8 +1193,12 @@ func (m *Manager) MarkResult(ctx context.Context, result Result) {
|
|||||||
shouldSuspendModel = true
|
shouldSuspendModel = true
|
||||||
setModelQuota = true
|
setModelQuota = true
|
||||||
case 408, 500, 502, 503, 504:
|
case 408, 500, 502, 503, 504:
|
||||||
|
if quotaCooldownDisabled.Load() {
|
||||||
|
state.NextRetryAfter = time.Time{}
|
||||||
|
} else {
|
||||||
next := now.Add(1 * time.Minute)
|
next := now.Add(1 * time.Minute)
|
||||||
state.NextRetryAfter = next
|
state.NextRetryAfter = next
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
state.NextRetryAfter = time.Time{}
|
state.NextRetryAfter = time.Time{}
|
||||||
}
|
}
|
||||||
@@ -1623,7 +1449,11 @@ func applyAuthFailureState(auth *Auth, resultErr *Error, retryAfter *time.Durati
|
|||||||
auth.NextRetryAfter = next
|
auth.NextRetryAfter = next
|
||||||
case 408, 500, 502, 503, 504:
|
case 408, 500, 502, 503, 504:
|
||||||
auth.StatusMessage = "transient upstream error"
|
auth.StatusMessage = "transient upstream error"
|
||||||
|
if quotaCooldownDisabled.Load() {
|
||||||
|
auth.NextRetryAfter = time.Time{}
|
||||||
|
} else {
|
||||||
auth.NextRetryAfter = now.Add(1 * time.Minute)
|
auth.NextRetryAfter = now.Add(1 * time.Minute)
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
if auth.StatusMessage == "" {
|
if auth.StatusMessage == "" {
|
||||||
auth.StatusMessage = "request failed"
|
auth.StatusMessage = "request failed"
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ import (
|
|||||||
sdktranslator "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator"
|
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.
|
// Request encapsulates the translated payload that will be sent to a provider executor.
|
||||||
type Request struct {
|
type Request struct {
|
||||||
// Model is the upstream model identifier after translation.
|
// Model is the upstream model identifier after translation.
|
||||||
|
|||||||
Reference in New Issue
Block a user