diff --git a/internal/api/handlers/management/auth_files.go b/internal/api/handlers/management/auth_files.go index e626af47..5909dffc 100644 --- a/internal/api/handlers/management/auth_files.go +++ b/internal/api/handlers/management/auth_files.go @@ -1722,6 +1722,17 @@ func (h *Handler) RequestIFlowCookieToken(c *gin.Context) { return } + // Check for duplicate BXAuth before authentication + bxAuth := iflowauth.ExtractBXAuth(cookieValue) + if existingFile, err := iflowauth.CheckDuplicateBXAuth(h.cfg.AuthDir, bxAuth); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"status": "error", "error": "failed to check duplicate"}) + return + } else if existingFile != "" { + existingFileName := filepath.Base(existingFile) + c.JSON(http.StatusConflict, gin.H{"status": "error", "error": "duplicate BXAuth found", "existing_file": existingFileName}) + return + } + authSvc := iflowauth.NewIFlowAuth(h.cfg) tokenData, errAuth := authSvc.AuthenticateWithCookie(ctx, cookieValue) if errAuth != nil { @@ -1744,11 +1755,12 @@ func (h *Handler) RequestIFlowCookieToken(c *gin.Context) { } tokenStorage.Email = email + timestamp := time.Now().Unix() record := &coreauth.Auth{ - ID: fmt.Sprintf("iflow-%s.json", fileName), + ID: fmt.Sprintf("iflow-%s-%d.json", fileName, timestamp), Provider: "iflow", - FileName: fmt.Sprintf("iflow-%s.json", fileName), + FileName: fmt.Sprintf("iflow-%s-%d.json", fileName, timestamp), Storage: tokenStorage, Metadata: map[string]any{ "email": email, diff --git a/internal/auth/iflow/cookie_helpers.go b/internal/auth/iflow/cookie_helpers.go index 6848f4b0..7e0f4264 100644 --- a/internal/auth/iflow/cookie_helpers.go +++ b/internal/auth/iflow/cookie_helpers.go @@ -1,7 +1,10 @@ package iflow import ( + "encoding/json" "fmt" + "os" + "path/filepath" "strings" ) @@ -36,3 +39,61 @@ func SanitizeIFlowFileName(raw string) string { } return strings.TrimSpace(result.String()) } + +// ExtractBXAuth extracts the BXAuth value from a cookie string. +func ExtractBXAuth(cookie string) string { + parts := strings.Split(cookie, ";") + for _, part := range parts { + part = strings.TrimSpace(part) + if strings.HasPrefix(part, "BXAuth=") { + return strings.TrimPrefix(part, "BXAuth=") + } + } + return "" +} + +// CheckDuplicateBXAuth checks if the given BXAuth value already exists in any iflow auth file. +// Returns the path of the existing file if found, empty string otherwise. +func CheckDuplicateBXAuth(authDir, bxAuth string) (string, error) { + if bxAuth == "" { + return "", nil + } + + entries, err := os.ReadDir(authDir) + if err != nil { + if os.IsNotExist(err) { + return "", nil + } + return "", fmt.Errorf("read auth dir failed: %w", err) + } + + for _, entry := range entries { + if entry.IsDir() { + continue + } + name := entry.Name() + if !strings.HasPrefix(name, "iflow-") || !strings.HasSuffix(name, ".json") { + continue + } + + filePath := filepath.Join(authDir, name) + data, err := os.ReadFile(filePath) + if err != nil { + continue + } + + var tokenData struct { + Cookie string `json:"cookie"` + } + if err := json.Unmarshal(data, &tokenData); err != nil { + continue + } + + existingBXAuth := ExtractBXAuth(tokenData.Cookie) + if existingBXAuth != "" && existingBXAuth == bxAuth { + return filePath, nil + } + } + + return "", nil +} diff --git a/internal/auth/iflow/iflow_auth.go b/internal/auth/iflow/iflow_auth.go index 2978e94c..fa9f38c3 100644 --- a/internal/auth/iflow/iflow_auth.go +++ b/internal/auth/iflow/iflow_auth.go @@ -494,11 +494,18 @@ func (ia *IFlowAuth) CreateCookieTokenStorage(data *IFlowTokenData) *IFlowTokenS return nil } + // Only save the BXAuth field from the cookie + bxAuth := ExtractBXAuth(data.Cookie) + cookieToSave := "" + if bxAuth != "" { + cookieToSave = "BXAuth=" + bxAuth + ";" + } + return &IFlowTokenStorage{ APIKey: data.APIKey, Email: data.Email, Expire: data.Expire, - Cookie: data.Cookie, + Cookie: cookieToSave, LastRefresh: time.Now().Format(time.RFC3339), Type: "iflow", } diff --git a/internal/cmd/iflow_cookie.go b/internal/cmd/iflow_cookie.go index b1cb1f9c..358b8062 100644 --- a/internal/cmd/iflow_cookie.go +++ b/internal/cmd/iflow_cookie.go @@ -5,7 +5,9 @@ import ( "context" "fmt" "os" + "path/filepath" "strings" + "time" "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/iflow" "github.com/router-for-me/CLIProxyAPI/v6/internal/config" @@ -37,6 +39,16 @@ func DoIFlowCookieAuth(cfg *config.Config, options *LoginOptions) { return } + // Check for duplicate BXAuth before authentication + bxAuth := iflow.ExtractBXAuth(cookie) + if existingFile, err := iflow.CheckDuplicateBXAuth(cfg.AuthDir, bxAuth); err != nil { + fmt.Printf("Failed to check duplicate: %v\n", err) + return + } else if existingFile != "" { + fmt.Printf("Duplicate BXAuth found, authentication already exists: %s\n", filepath.Base(existingFile)) + return + } + // Authenticate with cookie auth := iflow.NewIFlowAuth(cfg) ctx := context.Background() @@ -82,5 +94,5 @@ func promptForCookie(promptFn func(string) (string, error)) (string, error) { // getAuthFilePath returns the auth file path for the given provider and email func getAuthFilePath(cfg *config.Config, provider, email string) string { fileName := iflow.SanitizeIFlowFileName(email) - return fmt.Sprintf("%s/%s-%s.json", cfg.AuthDir, provider, fileName) + return fmt.Sprintf("%s/%s-%s-%d.json", cfg.AuthDir, provider, fileName, time.Now().Unix()) } diff --git a/internal/runtime/executor/openai_compat_executor.go b/internal/runtime/executor/openai_compat_executor.go index ac932c0b..d4b0afcb 100644 --- a/internal/runtime/executor/openai_compat_executor.go +++ b/internal/runtime/executor/openai_compat_executor.go @@ -54,13 +54,14 @@ func (e *OpenAICompatExecutor) Execute(ctx context.Context, auth *cliproxyauth.A from := opts.SourceFormat to := sdktranslator.FromString("openai") translated := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), opts.Stream) - if modelOverride := e.resolveUpstreamModel(req.Model, auth); modelOverride != "" { + modelOverride := e.resolveUpstreamModel(req.Model, auth) + if modelOverride != "" { translated = e.overrideModel(translated, modelOverride) } translated = applyPayloadConfigWithRoot(e.cfg, req.Model, to.String(), "", translated) translated = applyReasoningEffortMetadata(translated, req.Metadata, req.Model, "reasoning_effort") upstreamModel := util.ResolveOriginalModel(req.Model, req.Metadata) - if upstreamModel != "" { + if upstreamModel != "" && modelOverride == "" { translated, _ = sjson.SetBytes(translated, "model", upstreamModel) } translated = normalizeThinkingConfig(translated, upstreamModel) @@ -148,13 +149,14 @@ func (e *OpenAICompatExecutor) ExecuteStream(ctx context.Context, auth *cliproxy from := opts.SourceFormat to := sdktranslator.FromString("openai") translated := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), true) - if modelOverride := e.resolveUpstreamModel(req.Model, auth); modelOverride != "" { + modelOverride := e.resolveUpstreamModel(req.Model, auth) + if modelOverride != "" { translated = e.overrideModel(translated, modelOverride) } translated = applyPayloadConfigWithRoot(e.cfg, req.Model, to.String(), "", translated) translated = applyReasoningEffortMetadata(translated, req.Metadata, req.Model, "reasoning_effort") upstreamModel := util.ResolveOriginalModel(req.Model, req.Metadata) - if upstreamModel != "" { + if upstreamModel != "" && modelOverride == "" { translated, _ = sjson.SetBytes(translated, "model", upstreamModel) } translated = normalizeThinkingConfig(translated, upstreamModel) diff --git a/internal/runtime/executor/payload_helpers.go b/internal/runtime/executor/payload_helpers.go index 222c6e37..b791dac7 100644 --- a/internal/runtime/executor/payload_helpers.go +++ b/internal/runtime/executor/payload_helpers.go @@ -52,10 +52,14 @@ func applyReasoningEffortMetadata(payload []byte, metadata map[string]any, model if len(metadata) == 0 { return payload } - if !util.ModelSupportsThinking(model) { + if field == "" { return payload } - if field == "" { + baseModel := util.ResolveOriginalModel(model, metadata) + if baseModel == "" { + baseModel = model + } + if !util.ModelSupportsThinking(baseModel) && !util.IsOpenAICompatibilityModel(baseModel) { return payload } if effort, ok := util.ReasoningEffortFromMetadata(metadata); ok && effort != "" { @@ -239,6 +243,9 @@ func normalizeThinkingConfig(payload []byte, model string) []byte { } if !util.ModelSupportsThinking(model) { + if util.IsOpenAICompatibilityModel(model) { + return payload + } return stripThinkingFields(payload, false) } diff --git a/internal/util/thinking.go b/internal/util/thinking.go index 9671f20b..793134fc 100644 --- a/internal/util/thinking.go +++ b/internal/util/thinking.go @@ -25,33 +25,33 @@ func ModelSupportsThinking(model string) bool { // or min (0 if zero is allowed and mid <= 0). func NormalizeThinkingBudget(model string, budget int) int { if budget == -1 { // dynamic - if found, min, max, zeroAllowed, dynamicAllowed := thinkingRangeFromRegistry(model); found { + if found, minBudget, maxBudget, zeroAllowed, dynamicAllowed := thinkingRangeFromRegistry(model); found { if dynamicAllowed { return -1 } - mid := (min + max) / 2 + mid := (minBudget + maxBudget) / 2 if mid <= 0 && zeroAllowed { return 0 } if mid <= 0 { - return min + return minBudget } return mid } return -1 } - if found, min, max, zeroAllowed, _ := thinkingRangeFromRegistry(model); found { + if found, minBudget, maxBudget, zeroAllowed, _ := thinkingRangeFromRegistry(model); found { if budget == 0 { if zeroAllowed { return 0 } - return min + return minBudget } - if budget < min { - return min + if budget < minBudget { + return minBudget } - if budget > max { - return max + if budget > maxBudget { + return maxBudget } return budget } @@ -105,3 +105,16 @@ func NormalizeReasoningEffortLevel(model, effort string) (string, bool) { } return "", false } + +// IsOpenAICompatibilityModel reports whether the model is registered as an OpenAI-compatibility model. +// These models may not advertise Thinking metadata in the registry. +func IsOpenAICompatibilityModel(model string) bool { + if model == "" { + return false + } + info := registry.GetGlobalRegistry().GetModelInfo(model) + if info == nil { + return false + } + return strings.EqualFold(strings.TrimSpace(info.Type), "openai-compatibility") +} diff --git a/internal/watcher/watcher.go b/internal/watcher/watcher.go index 1f4f9043..f321a7c9 100644 --- a/internal/watcher/watcher.go +++ b/internal/watcher/watcher.go @@ -14,6 +14,7 @@ import ( "os" "path/filepath" "reflect" + "runtime" "sort" "strings" "sync" @@ -61,6 +62,7 @@ type Watcher struct { reloadCallback func(*config.Config) watcher *fsnotify.Watcher lastAuthHashes map[string]string + lastRemoveTimes map[string]time.Time lastConfigHash string authQueue chan<- AuthUpdate currentAuths map[string]*coreauth.Auth @@ -127,8 +129,9 @@ type AuthUpdate struct { const ( // replaceCheckDelay is a short delay to allow atomic replace (rename) to settle // before deciding whether a Remove event indicates a real deletion. - replaceCheckDelay = 50 * time.Millisecond - configReloadDebounce = 150 * time.Millisecond + replaceCheckDelay = 50 * time.Millisecond + configReloadDebounce = 150 * time.Millisecond + authRemoveDebounceWindow = 1 * time.Second ) // NewWatcher creates a new file watcher instance @@ -721,8 +724,9 @@ func (w *Watcher) authFileUnchanged(path string) (bool, error) { sum := sha256.Sum256(data) curHash := hex.EncodeToString(sum[:]) + normalized := w.normalizeAuthPath(path) w.clientsMutex.RLock() - prevHash, ok := w.lastAuthHashes[path] + prevHash, ok := w.lastAuthHashes[normalized] w.clientsMutex.RUnlock() if ok && prevHash == curHash { return true, nil @@ -731,19 +735,63 @@ func (w *Watcher) authFileUnchanged(path string) (bool, error) { } func (w *Watcher) isKnownAuthFile(path string) bool { + normalized := w.normalizeAuthPath(path) w.clientsMutex.RLock() defer w.clientsMutex.RUnlock() - _, ok := w.lastAuthHashes[path] + _, ok := w.lastAuthHashes[normalized] return ok } +func (w *Watcher) normalizeAuthPath(path string) string { + trimmed := strings.TrimSpace(path) + if trimmed == "" { + return "" + } + cleaned := filepath.Clean(trimmed) + if runtime.GOOS == "windows" { + cleaned = strings.TrimPrefix(cleaned, `\\?\`) + cleaned = strings.ToLower(cleaned) + } + return cleaned +} + +func (w *Watcher) shouldDebounceRemove(normalizedPath string, now time.Time) bool { + if normalizedPath == "" { + return false + } + w.clientsMutex.Lock() + if w.lastRemoveTimes == nil { + w.lastRemoveTimes = make(map[string]time.Time) + } + if last, ok := w.lastRemoveTimes[normalizedPath]; ok { + if now.Sub(last) < authRemoveDebounceWindow { + w.clientsMutex.Unlock() + return true + } + } + w.lastRemoveTimes[normalizedPath] = now + if len(w.lastRemoveTimes) > 128 { + cutoff := now.Add(-2 * authRemoveDebounceWindow) + for p, t := range w.lastRemoveTimes { + if t.Before(cutoff) { + delete(w.lastRemoveTimes, p) + } + } + } + w.clientsMutex.Unlock() + return false +} + // handleEvent processes individual file system events func (w *Watcher) handleEvent(event fsnotify.Event) { // Filter only relevant events: config file or auth-dir JSON files. configOps := fsnotify.Write | fsnotify.Create | fsnotify.Rename - isConfigEvent := event.Name == w.configPath && event.Op&configOps != 0 + normalizedName := w.normalizeAuthPath(event.Name) + normalizedConfigPath := w.normalizeAuthPath(w.configPath) + normalizedAuthDir := w.normalizeAuthPath(w.authDir) + isConfigEvent := normalizedName == normalizedConfigPath && event.Op&configOps != 0 authOps := fsnotify.Create | fsnotify.Write | fsnotify.Remove | fsnotify.Rename - isAuthJSON := strings.HasPrefix(event.Name, w.authDir) && strings.HasSuffix(event.Name, ".json") && event.Op&authOps != 0 + isAuthJSON := strings.HasPrefix(normalizedName, normalizedAuthDir) && strings.HasSuffix(normalizedName, ".json") && event.Op&authOps != 0 if !isConfigEvent && !isAuthJSON { // Ignore unrelated files (e.g., cookie snapshots *.cookie) and other noise. return @@ -761,6 +809,10 @@ func (w *Watcher) handleEvent(event fsnotify.Event) { // Handle auth directory changes incrementally (.json only) if event.Op&(fsnotify.Remove|fsnotify.Rename) != 0 { + if w.shouldDebounceRemove(normalizedName, now) { + log.Debugf("debouncing remove event for %s", filepath.Base(event.Name)) + return + } // Atomic replace on some platforms may surface as Rename (or Remove) before the new file is ready. // Wait briefly; if the path exists again, treat as an update instead of removal. time.Sleep(replaceCheckDelay) @@ -978,7 +1030,8 @@ func (w *Watcher) reloadClients(rescanAuth bool, affectedOAuthProviders []string if !info.IsDir() && strings.HasSuffix(strings.ToLower(info.Name()), ".json") { if data, errReadFile := os.ReadFile(path); errReadFile == nil && len(data) > 0 { sum := sha256.Sum256(data) - w.lastAuthHashes[path] = hex.EncodeToString(sum[:]) + normalizedPath := w.normalizeAuthPath(path) + w.lastAuthHashes[normalizedPath] = hex.EncodeToString(sum[:]) } } return nil @@ -1025,6 +1078,7 @@ func (w *Watcher) addOrUpdateClient(path string) { sum := sha256.Sum256(data) curHash := hex.EncodeToString(sum[:]) + normalized := w.normalizeAuthPath(path) w.clientsMutex.Lock() @@ -1034,14 +1088,14 @@ func (w *Watcher) addOrUpdateClient(path string) { w.clientsMutex.Unlock() return } - if prev, ok := w.lastAuthHashes[path]; ok && prev == curHash { + if prev, ok := w.lastAuthHashes[normalized]; ok && prev == curHash { log.Debugf("auth file unchanged (hash match), skipping reload: %s", filepath.Base(path)) w.clientsMutex.Unlock() return } // Update hash cache - w.lastAuthHashes[path] = curHash + w.lastAuthHashes[normalized] = curHash w.clientsMutex.Unlock() // Unlock before the callback @@ -1056,10 +1110,11 @@ func (w *Watcher) addOrUpdateClient(path string) { // removeClient handles the removal of a single client. func (w *Watcher) removeClient(path string) { + normalized := w.normalizeAuthPath(path) w.clientsMutex.Lock() cfg := w.config - delete(w.lastAuthHashes, path) + delete(w.lastAuthHashes, normalized) w.clientsMutex.Unlock() // Release the lock before the callback diff --git a/sdk/auth/iflow.go b/sdk/auth/iflow.go index a240b431..ee96bdaa 100644 --- a/sdk/auth/iflow.go +++ b/sdk/auth/iflow.go @@ -107,7 +107,7 @@ func (a *IFlowAuthenticator) Login(ctx context.Context, cfg *config.Config, opts return nil, fmt.Errorf("iflow authentication failed: missing account identifier") } - fileName := fmt.Sprintf("iflow-%s.json", email) + fileName := fmt.Sprintf("iflow-%s-%d.json", email, time.Now().Unix()) metadata := map[string]any{ "email": email, "api_key": tokenStorage.APIKey,