From f5dc6483d5bfdc254938f91e824a45afb0774005 Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Fri, 17 Apr 2026 01:07:12 +0800 Subject: [PATCH] chore: remove iFlow-related modules and dependencies - Deleted `iflow` provider implementation, including thinking configuration (`apply.go`) and authentication modules. - Removed iFlow-specific tests, executors, and helpers across SDK and internal components. - Updated all references to exclude iFlow functionality. --- README.md | 8 +- README_CN.md | 8 +- README_JA.md | 8 +- cmd/server/main.go | 8 - config.example.yaml | 7 +- .../api/handlers/management/auth_files.go | 210 ------- .../api/handlers/management/oauth_sessions.go | 2 - internal/api/server.go | 16 - internal/auth/iflow/cookie_helpers.go | 99 --- internal/auth/iflow/iflow_auth.go | 538 ---------------- internal/auth/iflow/iflow_auth_test.go | 42 -- internal/auth/iflow/iflow_token.go | 59 -- internal/auth/iflow/oauth_server.go | 143 ----- internal/cmd/auth_manager.go | 3 +- internal/cmd/iflow_cookie.go | 98 --- internal/cmd/iflow_login.go | 48 -- internal/config/config.go | 2 +- internal/registry/model_definitions.go | 10 - internal/registry/model_updater.go | 2 - internal/registry/models/models.json | 183 +----- .../executor/helps/thinking_providers.go | 1 - internal/runtime/executor/iflow_executor.go | 585 ------------------ .../runtime/executor/iflow_executor_test.go | 67 -- internal/thinking/apply.go | 40 +- internal/thinking/convert.go | 2 +- internal/thinking/provider/iflow/apply.go | 173 ------ internal/thinking/strip.go | 7 - internal/thinking/types.go | 2 +- internal/tui/oauth_tab.go | 3 - sdk/api/management.go | 10 - sdk/auth/iflow.go | 196 ------ sdk/auth/refresh_registry.go | 1 - sdk/cliproxy/auth/conductor_overrides_test.go | 6 +- sdk/cliproxy/auth/oauth_model_alias.go | 4 +- sdk/cliproxy/auth/oauth_model_alias_test.go | 2 - sdk/cliproxy/auth/types.go | 12 - sdk/cliproxy/service.go | 7 +- test/thinking_conversion_test.go | 419 ------------- 38 files changed, 22 insertions(+), 3009 deletions(-) delete mode 100644 internal/auth/iflow/cookie_helpers.go delete mode 100644 internal/auth/iflow/iflow_auth.go delete mode 100644 internal/auth/iflow/iflow_auth_test.go delete mode 100644 internal/auth/iflow/iflow_token.go delete mode 100644 internal/auth/iflow/oauth_server.go delete mode 100644 internal/cmd/iflow_cookie.go delete mode 100644 internal/cmd/iflow_login.go delete mode 100644 internal/runtime/executor/iflow_executor.go delete mode 100644 internal/runtime/executor/iflow_executor_test.go delete mode 100644 internal/thinking/provider/iflow/apply.go delete mode 100644 sdk/auth/iflow.go diff --git a/README.md b/README.md index 4b4cb4f3e..53acdd517 100644 --- a/README.md +++ b/README.md @@ -50,18 +50,16 @@ Get 10% OFF GLM CODING PLAN:https://z.ai/subscribe?ic=8JVLJQFSKB - OpenAI/Gemini/Claude compatible API endpoints for CLI models - OpenAI Codex support (GPT models) via OAuth login - Claude Code support via OAuth login -- iFlow support via OAuth login - Amp CLI and IDE extensions support with provider routing - Streaming and non-streaming responses - Function calling/tools support - Multimodal input support (text and images) -- Multiple accounts with round-robin load balancing (Gemini, OpenAI, Claude and iFlow) -- Simple CLI authentication flows (Gemini, OpenAI, Claude and iFlow) +- Multiple accounts with round-robin load balancing (Gemini, OpenAI, Claude) +- Simple CLI authentication flows (Gemini, OpenAI, Claude) - Generative Language API Key support - AI Studio Build multi-account load balancing - Gemini CLI multi-account load balancing - Claude Code multi-account load balancing -- iFlow multi-account load balancing - OpenAI Codex multi-account load balancing - OpenAI-compatible upstream providers via config (e.g., OpenRouter) - Reusable Go SDK for embedding the proxy (see `docs/sdk-usage.md`) @@ -177,7 +175,7 @@ helping users to immersively use AI assistants across applications on controlled ### [ProxyPal](https://github.com/buddingnewinsights/proxypal) -Cross-platform desktop app (macOS, Windows, Linux) wrapping CLIProxyAPI with a native GUI. Connects Claude, ChatGPT, Gemini, GitHub Copilot, iFlow, and custom OpenAI-compatible endpoints with usage analytics, request monitoring, and auto-configuration for popular coding tools - no API keys needed. +Cross-platform desktop app (macOS, Windows, Linux) wrapping CLIProxyAPI with a native GUI. Connects Claude, ChatGPT, Gemini, GitHub Copilot, and custom OpenAI-compatible endpoints with usage analytics, request monitoring, and auto-configuration for popular coding tools - no API keys needed. ### [CLIProxyAPI Quota Inspector](https://github.com/AllenReder/CLIProxyAPI-Quota-Inspector) diff --git a/README_CN.md b/README_CN.md index 16bce7ecd..86ea95420 100644 --- a/README_CN.md +++ b/README_CN.md @@ -51,17 +51,15 @@ GLM CODING PLAN 是专为AI编码打造的订阅套餐,每月最低仅需20元 - 为 CLI 模型提供 OpenAI/Gemini/Claude/Codex 兼容的 API 端点 - 新增 OpenAI Codex(GPT 系列)支持(OAuth 登录) - 新增 Claude Code 支持(OAuth 登录) -- 新增 iFlow 支持(OAuth 登录) - 支持流式与非流式响应 - 函数调用/工具支持 - 多模态输入(文本、图片) -- 多账户支持与轮询负载均衡(Gemini、OpenAI、Claude 与 iFlow) -- 简单的 CLI 身份验证流程(Gemini、OpenAI、Claude 与 iFlow) +- 多账户支持与轮询负载均衡(Gemini、OpenAI、Claude) +- 简单的 CLI 身份验证流程(Gemini、OpenAI、Claude) - 支持 Gemini AIStudio API 密钥 - 支持 AI Studio Build 多账户轮询 - 支持 Gemini CLI 多账户轮询 - 支持 Claude Code 多账户轮询 -- 支持 iFlow 多账户轮询 - 支持 OpenAI Codex 多账户轮询 - 通过配置接入上游 OpenAI 兼容提供商(例如 OpenRouter) - 可复用的 Go SDK(见 `docs/sdk-usage_CN.md`) @@ -173,7 +171,7 @@ Shadow AI 是一款专为受限环境设计的 AI 辅助工具。提供无窗口 ### [ProxyPal](https://github.com/buddingnewinsights/proxypal) -跨平台桌面应用(macOS、Windows、Linux),以原生 GUI 封装 CLIProxyAPI。支持连接 Claude、ChatGPT、Gemini、GitHub Copilot、iFlow 及自定义 OpenAI 兼容端点,具备使用分析、请求监控和热门编程工具自动配置功能,无需 API 密钥。 +跨平台桌面应用(macOS、Windows、Linux),以原生 GUI 封装 CLIProxyAPI。支持连接 Claude、ChatGPT、Gemini、GitHub Copilot 及自定义 OpenAI 兼容端点,具备使用分析、请求监控和热门编程工具自动配置功能,无需 API 密钥。 ### [CLIProxyAPI Quota Inspector](https://github.com/AllenReder/CLIProxyAPI-Quota-Inspector) diff --git a/README_JA.md b/README_JA.md index 8ba801466..8c34325b4 100644 --- a/README_JA.md +++ b/README_JA.md @@ -50,18 +50,16 @@ GLM CODING PLANを10%割引で取得:https://z.ai/subscribe?ic=8JVLJQFSKB - CLIモデル向けのOpenAI/Gemini/Claude互換APIエンドポイント - OAuthログインによるOpenAI Codexサポート(GPTモデル) - OAuthログインによるClaude Codeサポート -- OAuthログインによるiFlowサポート - プロバイダールーティングによるAmp CLIおよびIDE拡張機能のサポート - ストリーミングおよび非ストリーミングレスポンス - 関数呼び出し/ツールのサポート - マルチモーダル入力サポート(テキストと画像) -- ラウンドロビン負荷分散による複数アカウント対応(Gemini、OpenAI、ClaudeおよびiFlow) -- シンプルなCLI認証フロー(Gemini、OpenAI、ClaudeおよびiFlow) +- ラウンドロビン負荷分散による複数アカウント対応(Gemini、OpenAI、Claude) +- シンプルなCLI認証フロー(Gemini、OpenAI、Claude) - Generative Language APIキーのサポート - AI Studioビルドのマルチアカウント負荷分散 - Gemini CLIのマルチアカウント負荷分散 - Claude Codeのマルチアカウント負荷分散 -- iFlowのマルチアカウント負荷分散 - OpenAI Codexのマルチアカウント負荷分散 - 設定によるOpenAI互換アップストリームプロバイダー(例:OpenRouter) - プロキシ埋め込み用の再利用可能なGo SDK(`docs/sdk-usage.md`を参照) @@ -174,7 +172,7 @@ Shadow AIは制限された環境向けに特別に設計されたAIアシスタ ### [ProxyPal](https://github.com/buddingnewinsights/proxypal) -CLIProxyAPIをネイティブGUIでラップしたクロスプラットフォームデスクトップアプリ(macOS、Windows、Linux)。Claude、ChatGPT、Gemini、GitHub Copilot、iFlow、カスタムOpenAI互換エンドポイントに対応し、使用状況分析、リクエスト監視、人気コーディングツールの自動設定機能を搭載 - APIキー不要 +CLIProxyAPIをネイティブGUIでラップしたクロスプラットフォームデスクトップアプリ(macOS、Windows、Linux)。Claude、ChatGPT、Gemini、GitHub Copilot、カスタムOpenAI互換エンドポイントに対応し、使用状況分析、リクエスト監視、人気コーディングツールの自動設定機能を搭載 - APIキー不要 ### [CLIProxyAPI Quota Inspector](https://github.com/AllenReder/CLIProxyAPI-Quota-Inspector) diff --git a/cmd/server/main.go b/cmd/server/main.go index e4a423eac..b8707f0a4 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -61,8 +61,6 @@ func main() { var codexLogin bool var codexDeviceLogin bool var claudeLogin bool - var iflowLogin bool - var iflowCookie bool var noBrowser bool var oauthCallbackPort int var antigravityLogin bool @@ -81,8 +79,6 @@ func main() { flag.BoolVar(&codexLogin, "codex-login", false, "Login to Codex using OAuth") flag.BoolVar(&codexDeviceLogin, "codex-device-login", false, "Login to Codex using device code flow") flag.BoolVar(&claudeLogin, "claude-login", false, "Login to Claude using OAuth") - flag.BoolVar(&iflowLogin, "iflow-login", false, "Login to iFlow using OAuth") - flag.BoolVar(&iflowCookie, "iflow-cookie", false, "Login to iFlow using Cookie") flag.BoolVar(&noBrowser, "no-browser", false, "Don't open browser automatically for OAuth") flag.IntVar(&oauthCallbackPort, "oauth-callback-port", 0, "Override OAuth callback port (defaults to provider-specific port)") flag.BoolVar(&antigravityLogin, "antigravity-login", false, "Login to Antigravity using OAuth") @@ -482,10 +478,6 @@ func main() { } else if claudeLogin { // Handle Claude login cmd.DoClaudeLogin(cfg, options) - } else if iflowLogin { - cmd.DoIFlowLogin(cfg, options) - } else if iflowCookie { - cmd.DoIFlowCookieAuth(cfg, options) } else if kimiLogin { cmd.DoKimiLogin(cfg, options) } else { diff --git a/config.example.yaml b/config.example.yaml index f423f8181..734dd7d52 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -309,7 +309,7 @@ nonstream-keepalive-interval: 0 # Global OAuth model name aliases (per channel) # These aliases rename model IDs for both model listing and request routing. -# Supported channels: gemini-cli, vertex, aistudio, antigravity, claude, codex, iflow, kimi. +# Supported channels: gemini-cli, vertex, aistudio, antigravity, claude, codex, kimi. # NOTE: Aliases do not apply to gemini-api-key, codex-api-key, claude-api-key, openai-compatibility, vertex-api-key, or ampcode. # NOTE: Because aliases affect the merged /v1 model list and merged request routing, overlapping # client-visible names can become ambiguous across providers. /api/provider/{provider}/... helps @@ -336,9 +336,6 @@ nonstream-keepalive-interval: 0 # codex: # - name: "gpt-5" # alias: "g5" -# iflow: -# - name: "glm-4.7" -# alias: "glm-god" # kimi: # - name: "kimi-k2.5" # alias: "k2.5" @@ -360,8 +357,6 @@ nonstream-keepalive-interval: 0 # - "claude-3-5-haiku-20241022" # codex: # - "gpt-5-codex-mini" -# iflow: -# - "tstars2.0" # kimi: # - "kimi-k2-thinking" diff --git a/internal/api/handlers/management/auth_files.go b/internal/api/handlers/management/auth_files.go index 4e2bd69c5..8f7b8c5e1 100644 --- a/internal/api/handlers/management/auth_files.go +++ b/internal/api/handlers/management/auth_files.go @@ -26,7 +26,6 @@ import ( "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/claude" "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/codex" geminiAuth "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/gemini" - iflowauth "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/iflow" "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/kimi" "github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces" "github.com/router-for-me/CLIProxyAPI/v6/internal/misc" @@ -2179,215 +2178,6 @@ func (h *Handler) RequestKimiToken(c *gin.Context) { c.JSON(200, gin.H{"status": "ok", "url": authURL, "state": state}) } -func (h *Handler) RequestIFlowToken(c *gin.Context) { - ctx := context.Background() - ctx = PopulateAuthContext(ctx, c) - - fmt.Println("Initializing iFlow authentication...") - - state := fmt.Sprintf("ifl-%d", time.Now().UnixNano()) - authSvc := iflowauth.NewIFlowAuth(h.cfg) - authURL, redirectURI := authSvc.AuthorizationURL(state, iflowauth.CallbackPort) - - RegisterOAuthSession(state, "iflow") - - isWebUI := isWebUIRequest(c) - var forwarder *callbackForwarder - if isWebUI { - targetURL, errTarget := h.managementCallbackURL("/iflow/callback") - if errTarget != nil { - log.WithError(errTarget).Error("failed to compute iflow callback target") - c.JSON(http.StatusInternalServerError, gin.H{"status": "error", "error": "callback server unavailable"}) - return - } - var errStart error - if forwarder, errStart = startCallbackForwarder(iflowauth.CallbackPort, "iflow", targetURL); errStart != nil { - log.WithError(errStart).Error("failed to start iflow callback forwarder") - c.JSON(http.StatusInternalServerError, gin.H{"status": "error", "error": "failed to start callback server"}) - return - } - } - - go func() { - if isWebUI { - defer stopCallbackForwarderInstance(iflowauth.CallbackPort, forwarder) - } - fmt.Println("Waiting for authentication...") - - waitFile := filepath.Join(h.cfg.AuthDir, fmt.Sprintf(".oauth-iflow-%s.oauth", state)) - deadline := time.Now().Add(5 * time.Minute) - var resultMap map[string]string - for { - if !IsOAuthSessionPending(state, "iflow") { - return - } - if time.Now().After(deadline) { - SetOAuthSessionError(state, "Authentication failed") - fmt.Println("Authentication failed: timeout waiting for callback") - return - } - if data, errR := os.ReadFile(waitFile); errR == nil { - _ = os.Remove(waitFile) - _ = json.Unmarshal(data, &resultMap) - break - } - time.Sleep(500 * time.Millisecond) - } - - if errStr := strings.TrimSpace(resultMap["error"]); errStr != "" { - SetOAuthSessionError(state, "Authentication failed") - fmt.Printf("Authentication failed: %s\n", errStr) - return - } - if resultState := strings.TrimSpace(resultMap["state"]); resultState != state { - SetOAuthSessionError(state, "Authentication failed") - fmt.Println("Authentication failed: state mismatch") - return - } - - code := strings.TrimSpace(resultMap["code"]) - if code == "" { - SetOAuthSessionError(state, "Authentication failed") - fmt.Println("Authentication failed: code missing") - return - } - - tokenData, errExchange := authSvc.ExchangeCodeForTokens(ctx, code, redirectURI) - if errExchange != nil { - SetOAuthSessionError(state, "Authentication failed") - fmt.Printf("Authentication failed: %v\n", errExchange) - return - } - - tokenStorage := authSvc.CreateTokenStorage(tokenData) - identifier := strings.TrimSpace(tokenStorage.Email) - if identifier == "" { - identifier = fmt.Sprintf("%d", time.Now().UnixMilli()) - tokenStorage.Email = identifier - } - record := &coreauth.Auth{ - ID: fmt.Sprintf("iflow-%s.json", identifier), - Provider: "iflow", - FileName: fmt.Sprintf("iflow-%s.json", identifier), - Storage: tokenStorage, - Metadata: map[string]any{"email": identifier, "api_key": tokenStorage.APIKey}, - Attributes: map[string]string{"api_key": tokenStorage.APIKey}, - } - - savedPath, errSave := h.saveTokenRecord(ctx, record) - if errSave != nil { - SetOAuthSessionError(state, "Failed to save authentication tokens") - log.Errorf("Failed to save authentication tokens: %v", errSave) - return - } - - fmt.Printf("Authentication successful! Token saved to %s\n", savedPath) - if tokenStorage.APIKey != "" { - fmt.Println("API key obtained and saved") - } - fmt.Println("You can now use iFlow services through this CLI") - CompleteOAuthSession(state) - CompleteOAuthSessionsByProvider("iflow") - }() - - c.JSON(http.StatusOK, gin.H{"status": "ok", "url": authURL, "state": state}) -} - -func (h *Handler) RequestIFlowCookieToken(c *gin.Context) { - ctx := context.Background() - - var payload struct { - Cookie string `json:"cookie"` - } - if err := c.ShouldBindJSON(&payload); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": "cookie is required"}) - return - } - - cookieValue := strings.TrimSpace(payload.Cookie) - - if cookieValue == "" { - c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": "cookie is required"}) - return - } - - cookieValue, errNormalize := iflowauth.NormalizeCookie(cookieValue) - if errNormalize != nil { - c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": errNormalize.Error()}) - 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 { - c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": errAuth.Error()}) - return - } - - tokenData.Cookie = cookieValue - - tokenStorage := authSvc.CreateCookieTokenStorage(tokenData) - email := strings.TrimSpace(tokenStorage.Email) - if email == "" { - c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": "failed to extract email from token"}) - return - } - - fileName := iflowauth.SanitizeIFlowFileName(email) - if fileName == "" { - fileName = fmt.Sprintf("iflow-%d", time.Now().UnixMilli()) - } else { - fileName = fmt.Sprintf("iflow-%s", fileName) - } - - tokenStorage.Email = email - timestamp := time.Now().Unix() - - record := &coreauth.Auth{ - ID: fmt.Sprintf("%s-%d.json", fileName, timestamp), - Provider: "iflow", - FileName: fmt.Sprintf("%s-%d.json", fileName, timestamp), - Storage: tokenStorage, - Metadata: map[string]any{ - "email": email, - "api_key": tokenStorage.APIKey, - "expired": tokenStorage.Expire, - "cookie": tokenStorage.Cookie, - "type": tokenStorage.Type, - "last_refresh": tokenStorage.LastRefresh, - }, - Attributes: map[string]string{ - "api_key": tokenStorage.APIKey, - }, - } - - savedPath, errSave := h.saveTokenRecord(ctx, record) - if errSave != nil { - c.JSON(http.StatusInternalServerError, gin.H{"status": "error", "error": "failed to save authentication tokens"}) - return - } - - fmt.Printf("iFlow cookie authentication successful. Token saved to %s\n", savedPath) - c.JSON(http.StatusOK, gin.H{ - "status": "ok", - "saved_path": savedPath, - "email": email, - "expired": tokenStorage.Expire, - "type": tokenStorage.Type, - }) -} - type projectSelectionRequiredError struct{} func (e *projectSelectionRequiredError) Error() string { diff --git a/internal/api/handlers/management/oauth_sessions.go b/internal/api/handlers/management/oauth_sessions.go index 5beaa4739..9ab9766fb 100644 --- a/internal/api/handlers/management/oauth_sessions.go +++ b/internal/api/handlers/management/oauth_sessions.go @@ -225,8 +225,6 @@ func NormalizeOAuthProvider(provider string) (string, error) { return "codex", nil case "gemini", "google": return "gemini", nil - case "iflow", "i-flow": - return "iflow", nil case "antigravity", "anti-gravity": return "antigravity", nil default: diff --git a/internal/api/server.go b/internal/api/server.go index 3dfeddc1c..075455ba8 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -411,20 +411,6 @@ func (s *Server) setupRoutes() { c.String(http.StatusOK, oauthCallbackSuccessHTML) }) - s.engine.GET("/iflow/callback", func(c *gin.Context) { - code := c.Query("code") - state := c.Query("state") - errStr := c.Query("error") - if errStr == "" { - errStr = c.Query("error_description") - } - if state != "" { - _, _ = managementHandlers.WriteOAuthCallbackFileForPendingSession(s.cfg.AuthDir, "iflow", state, code, errStr) - } - c.Header("Content-Type", "text/html; charset=utf-8") - c.String(http.StatusOK, oauthCallbackSuccessHTML) - }) - s.engine.GET("/antigravity/callback", func(c *gin.Context) { code := c.Query("code") state := c.Query("state") @@ -641,8 +627,6 @@ func (s *Server) registerManagementRoutes() { mgmt.GET("/gemini-cli-auth-url", s.mgmt.RequestGeminiCLIToken) mgmt.GET("/antigravity-auth-url", s.mgmt.RequestAntigravityToken) mgmt.GET("/kimi-auth-url", s.mgmt.RequestKimiToken) - mgmt.GET("/iflow-auth-url", s.mgmt.RequestIFlowToken) - mgmt.POST("/iflow-auth-url", s.mgmt.RequestIFlowCookieToken) mgmt.POST("/oauth-callback", s.mgmt.PostOAuthCallback) mgmt.GET("/get-auth-status", s.mgmt.GetAuthStatus) } diff --git a/internal/auth/iflow/cookie_helpers.go b/internal/auth/iflow/cookie_helpers.go deleted file mode 100644 index 7e0f4264b..000000000 --- a/internal/auth/iflow/cookie_helpers.go +++ /dev/null @@ -1,99 +0,0 @@ -package iflow - -import ( - "encoding/json" - "fmt" - "os" - "path/filepath" - "strings" -) - -// NormalizeCookie normalizes raw cookie strings for iFlow authentication flows. -func NormalizeCookie(raw string) (string, error) { - trimmed := strings.TrimSpace(raw) - if trimmed == "" { - return "", fmt.Errorf("cookie cannot be empty") - } - - combined := strings.Join(strings.Fields(trimmed), " ") - if !strings.HasSuffix(combined, ";") { - combined += ";" - } - if !strings.Contains(combined, "BXAuth=") { - return "", fmt.Errorf("cookie missing BXAuth field") - } - return combined, nil -} - -// SanitizeIFlowFileName normalizes user identifiers for safe filename usage. -func SanitizeIFlowFileName(raw string) string { - if raw == "" { - return "" - } - cleanEmail := strings.ReplaceAll(raw, "*", "x") - var result strings.Builder - for _, r := range cleanEmail { - if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_' || r == '@' || r == '.' || r == '-' { - result.WriteRune(r) - } - } - 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 deleted file mode 100644 index cffa0460c..000000000 --- a/internal/auth/iflow/iflow_auth.go +++ /dev/null @@ -1,538 +0,0 @@ -package iflow - -import ( - "compress/gzip" - "context" - "encoding/base64" - "encoding/json" - "fmt" - "io" - "net/http" - "net/url" - "strings" - "time" - - "github.com/router-for-me/CLIProxyAPI/v6/internal/config" - "github.com/router-for-me/CLIProxyAPI/v6/internal/util" - log "github.com/sirupsen/logrus" -) - -const ( - // OAuth endpoints and client metadata are derived from the reference Python implementation. - iFlowOAuthTokenEndpoint = "https://iflow.cn/oauth/token" - iFlowOAuthAuthorizeEndpoint = "https://iflow.cn/oauth" - iFlowUserInfoEndpoint = "https://iflow.cn/api/oauth/getUserInfo" - iFlowSuccessRedirectURL = "https://iflow.cn/oauth/success" - - // Cookie authentication endpoints - iFlowAPIKeyEndpoint = "https://platform.iflow.cn/api/openapi/apikey" - - // Client credentials provided by iFlow for the Code Assist integration. - iFlowOAuthClientID = "10009311001" - iFlowOAuthClientSecret = "4Z3YjXycVsQvyGF1etiNlIBB4RsqSDtW" -) - -// DefaultAPIBaseURL is the canonical chat completions endpoint. -const DefaultAPIBaseURL = "https://apis.iflow.cn/v1" - -// SuccessRedirectURL is exposed for consumers needing the official success page. -const SuccessRedirectURL = iFlowSuccessRedirectURL - -// CallbackPort defines the local port used for OAuth callbacks. -const CallbackPort = 11451 - -// IFlowAuth encapsulates the HTTP client helpers for the OAuth flow. -type IFlowAuth struct { - httpClient *http.Client -} - -// NewIFlowAuth constructs a new IFlowAuth with proxy-aware transport. -func NewIFlowAuth(cfg *config.Config) *IFlowAuth { - return NewIFlowAuthWithProxyURL(cfg, "") -} - -// NewIFlowAuthWithProxyURL constructs a new IFlowAuth with a proxy override. -// proxyURL takes precedence over cfg.ProxyURL when non-empty. -func NewIFlowAuthWithProxyURL(cfg *config.Config, proxyURL string) *IFlowAuth { - client := &http.Client{Timeout: 30 * time.Second} - effectiveProxyURL := strings.TrimSpace(proxyURL) - var sdkCfg config.SDKConfig - if cfg != nil { - sdkCfg = cfg.SDKConfig - if effectiveProxyURL == "" { - effectiveProxyURL = strings.TrimSpace(cfg.ProxyURL) - } - } - sdkCfg.ProxyURL = effectiveProxyURL - return &IFlowAuth{httpClient: util.SetProxy(&sdkCfg, client)} -} - -// AuthorizationURL builds the authorization URL and matching redirect URI. -func (ia *IFlowAuth) AuthorizationURL(state string, port int) (authURL, redirectURI string) { - redirectURI = fmt.Sprintf("http://localhost:%d/oauth2callback", port) - values := url.Values{} - values.Set("loginMethod", "phone") - values.Set("type", "phone") - values.Set("redirect", redirectURI) - values.Set("state", state) - values.Set("client_id", iFlowOAuthClientID) - authURL = fmt.Sprintf("%s?%s", iFlowOAuthAuthorizeEndpoint, values.Encode()) - return authURL, redirectURI -} - -// ExchangeCodeForTokens exchanges an authorization code for access and refresh tokens. -func (ia *IFlowAuth) ExchangeCodeForTokens(ctx context.Context, code, redirectURI string) (*IFlowTokenData, error) { - form := url.Values{} - form.Set("grant_type", "authorization_code") - form.Set("code", code) - form.Set("redirect_uri", redirectURI) - form.Set("client_id", iFlowOAuthClientID) - form.Set("client_secret", iFlowOAuthClientSecret) - - req, err := ia.newTokenRequest(ctx, form) - if err != nil { - return nil, err - } - - return ia.doTokenRequest(ctx, req) -} - -// RefreshTokens exchanges a refresh token for a new access token. -func (ia *IFlowAuth) RefreshTokens(ctx context.Context, refreshToken string) (*IFlowTokenData, error) { - form := url.Values{} - form.Set("grant_type", "refresh_token") - form.Set("refresh_token", refreshToken) - form.Set("client_id", iFlowOAuthClientID) - form.Set("client_secret", iFlowOAuthClientSecret) - - req, err := ia.newTokenRequest(ctx, form) - if err != nil { - return nil, err - } - - return ia.doTokenRequest(ctx, req) -} - -func (ia *IFlowAuth) newTokenRequest(ctx context.Context, form url.Values) (*http.Request, error) { - req, err := http.NewRequestWithContext(ctx, http.MethodPost, iFlowOAuthTokenEndpoint, strings.NewReader(form.Encode())) - if err != nil { - return nil, fmt.Errorf("iflow token: create request failed: %w", err) - } - - basic := base64.StdEncoding.EncodeToString([]byte(iFlowOAuthClientID + ":" + iFlowOAuthClientSecret)) - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - req.Header.Set("Accept", "application/json") - req.Header.Set("Authorization", "Basic "+basic) - return req, nil -} - -func (ia *IFlowAuth) doTokenRequest(ctx context.Context, req *http.Request) (*IFlowTokenData, error) { - resp, err := ia.httpClient.Do(req) - if err != nil { - return nil, fmt.Errorf("iflow token: request failed: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("iflow token: read response failed: %w", err) - } - - if resp.StatusCode != http.StatusOK { - log.Debugf("iflow token request failed: status=%d body=%s", resp.StatusCode, string(body)) - return nil, fmt.Errorf("iflow token: %d %s", resp.StatusCode, strings.TrimSpace(string(body))) - } - - var tokenResp IFlowTokenResponse - if err = json.Unmarshal(body, &tokenResp); err != nil { - return nil, fmt.Errorf("iflow token: decode response failed: %w", err) - } - - data := &IFlowTokenData{ - AccessToken: tokenResp.AccessToken, - RefreshToken: tokenResp.RefreshToken, - TokenType: tokenResp.TokenType, - Scope: tokenResp.Scope, - Expire: time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second).Format(time.RFC3339), - } - - if tokenResp.AccessToken == "" { - log.Debug(string(body)) - return nil, fmt.Errorf("iflow token: missing access token in response") - } - - info, errAPI := ia.FetchUserInfo(ctx, tokenResp.AccessToken) - if errAPI != nil { - return nil, fmt.Errorf("iflow token: fetch user info failed: %w", errAPI) - } - if strings.TrimSpace(info.APIKey) == "" { - return nil, fmt.Errorf("iflow token: empty api key returned") - } - email := strings.TrimSpace(info.Email) - if email == "" { - email = strings.TrimSpace(info.Phone) - } - if email == "" { - return nil, fmt.Errorf("iflow token: missing account email/phone in user info") - } - data.APIKey = info.APIKey - data.Email = email - - return data, nil -} - -// FetchUserInfo retrieves account metadata (including API key) for the provided access token. -func (ia *IFlowAuth) FetchUserInfo(ctx context.Context, accessToken string) (*userInfoData, error) { - if strings.TrimSpace(accessToken) == "" { - return nil, fmt.Errorf("iflow api key: access token is empty") - } - - endpoint := fmt.Sprintf("%s?accessToken=%s", iFlowUserInfoEndpoint, url.QueryEscape(accessToken)) - req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) - if err != nil { - return nil, fmt.Errorf("iflow api key: create request failed: %w", err) - } - req.Header.Set("Accept", "application/json") - - resp, err := ia.httpClient.Do(req) - if err != nil { - return nil, fmt.Errorf("iflow api key: request failed: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("iflow api key: read response failed: %w", err) - } - - if resp.StatusCode != http.StatusOK { - log.Debugf("iflow api key failed: status=%d body=%s", resp.StatusCode, string(body)) - return nil, fmt.Errorf("iflow api key: %d %s", resp.StatusCode, strings.TrimSpace(string(body))) - } - - var result userInfoResponse - if err = json.Unmarshal(body, &result); err != nil { - return nil, fmt.Errorf("iflow api key: decode body failed: %w", err) - } - - if !result.Success { - return nil, fmt.Errorf("iflow api key: request not successful") - } - - if result.Data.APIKey == "" { - return nil, fmt.Errorf("iflow api key: missing api key in response") - } - - return &result.Data, nil -} - -// CreateTokenStorage converts token data into persistence storage. -func (ia *IFlowAuth) CreateTokenStorage(data *IFlowTokenData) *IFlowTokenStorage { - if data == nil { - return nil - } - return &IFlowTokenStorage{ - AccessToken: data.AccessToken, - RefreshToken: data.RefreshToken, - LastRefresh: time.Now().Format(time.RFC3339), - Expire: data.Expire, - APIKey: data.APIKey, - Email: data.Email, - TokenType: data.TokenType, - Scope: data.Scope, - } -} - -// UpdateTokenStorage updates the persisted token storage with latest token data. -func (ia *IFlowAuth) UpdateTokenStorage(storage *IFlowTokenStorage, data *IFlowTokenData) { - if storage == nil || data == nil { - return - } - storage.AccessToken = data.AccessToken - storage.RefreshToken = data.RefreshToken - storage.LastRefresh = time.Now().Format(time.RFC3339) - storage.Expire = data.Expire - if data.APIKey != "" { - storage.APIKey = data.APIKey - } - if data.Email != "" { - storage.Email = data.Email - } - storage.TokenType = data.TokenType - storage.Scope = data.Scope -} - -// IFlowTokenResponse models the OAuth token endpoint response. -type IFlowTokenResponse struct { - AccessToken string `json:"access_token"` - RefreshToken string `json:"refresh_token"` - ExpiresIn int `json:"expires_in"` - TokenType string `json:"token_type"` - Scope string `json:"scope"` -} - -// IFlowTokenData captures processed token details. -type IFlowTokenData struct { - AccessToken string - RefreshToken string - TokenType string - Scope string - Expire string - APIKey string - Email string - Cookie string -} - -// userInfoResponse represents the structure returned by the user info endpoint. -type userInfoResponse struct { - Success bool `json:"success"` - Data userInfoData `json:"data"` -} - -type userInfoData struct { - APIKey string `json:"apiKey"` - Email string `json:"email"` - Phone string `json:"phone"` -} - -// iFlowAPIKeyResponse represents the response from the API key endpoint -type iFlowAPIKeyResponse struct { - Success bool `json:"success"` - Code string `json:"code"` - Message string `json:"message"` - Data iFlowKeyData `json:"data"` - Extra interface{} `json:"extra"` -} - -// iFlowKeyData contains the API key information -type iFlowKeyData struct { - HasExpired bool `json:"hasExpired"` - ExpireTime string `json:"expireTime"` - Name string `json:"name"` - APIKey string `json:"apiKey"` - APIKeyMask string `json:"apiKeyMask"` -} - -// iFlowRefreshRequest represents the request body for refreshing API key -type iFlowRefreshRequest struct { - Name string `json:"name"` -} - -// AuthenticateWithCookie performs authentication using browser cookies -func (ia *IFlowAuth) AuthenticateWithCookie(ctx context.Context, cookie string) (*IFlowTokenData, error) { - if strings.TrimSpace(cookie) == "" { - return nil, fmt.Errorf("iflow cookie authentication: cookie is empty") - } - - // First, get initial API key information using GET request to obtain the name - keyInfo, err := ia.fetchAPIKeyInfo(ctx, cookie) - if err != nil { - return nil, fmt.Errorf("iflow cookie authentication: fetch initial API key info failed: %w", err) - } - - // Refresh the API key using POST request - refreshedKeyInfo, err := ia.RefreshAPIKey(ctx, cookie, keyInfo.Name) - if err != nil { - return nil, fmt.Errorf("iflow cookie authentication: refresh API key failed: %w", err) - } - - // Convert to token data format using refreshed key - data := &IFlowTokenData{ - APIKey: refreshedKeyInfo.APIKey, - Expire: refreshedKeyInfo.ExpireTime, - Email: refreshedKeyInfo.Name, - Cookie: cookie, - } - - return data, nil -} - -// fetchAPIKeyInfo retrieves API key information using GET request with cookie -func (ia *IFlowAuth) fetchAPIKeyInfo(ctx context.Context, cookie string) (*iFlowKeyData, error) { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, iFlowAPIKeyEndpoint, nil) - if err != nil { - return nil, fmt.Errorf("iflow cookie: create GET request failed: %w", err) - } - - // Set cookie and other headers to mimic browser - req.Header.Set("Cookie", cookie) - req.Header.Set("Accept", "application/json, text/plain, */*") - req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36") - req.Header.Set("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8") - req.Header.Set("Accept-Encoding", "gzip, deflate, br") - req.Header.Set("Connection", "keep-alive") - req.Header.Set("Sec-Fetch-Dest", "empty") - req.Header.Set("Sec-Fetch-Mode", "cors") - req.Header.Set("Sec-Fetch-Site", "same-origin") - - resp, err := ia.httpClient.Do(req) - if err != nil { - return nil, fmt.Errorf("iflow cookie: GET request failed: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - // Handle gzip compression - var reader io.Reader = resp.Body - if resp.Header.Get("Content-Encoding") == "gzip" { - gzipReader, err := gzip.NewReader(resp.Body) - if err != nil { - return nil, fmt.Errorf("iflow cookie: create gzip reader failed: %w", err) - } - defer func() { _ = gzipReader.Close() }() - reader = gzipReader - } - - body, err := io.ReadAll(reader) - if err != nil { - return nil, fmt.Errorf("iflow cookie: read GET response failed: %w", err) - } - - if resp.StatusCode != http.StatusOK { - log.Debugf("iflow cookie GET request failed: status=%d body=%s", resp.StatusCode, string(body)) - return nil, fmt.Errorf("iflow cookie: GET request failed with status %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) - } - - var keyResp iFlowAPIKeyResponse - if err = json.Unmarshal(body, &keyResp); err != nil { - return nil, fmt.Errorf("iflow cookie: decode GET response failed: %w", err) - } - - if !keyResp.Success { - return nil, fmt.Errorf("iflow cookie: GET request not successful: %s", keyResp.Message) - } - - // Handle initial response where apiKey field might be apiKeyMask - if keyResp.Data.APIKey == "" && keyResp.Data.APIKeyMask != "" { - keyResp.Data.APIKey = keyResp.Data.APIKeyMask - } - - return &keyResp.Data, nil -} - -// RefreshAPIKey refreshes the API key using POST request -func (ia *IFlowAuth) RefreshAPIKey(ctx context.Context, cookie, name string) (*iFlowKeyData, error) { - if strings.TrimSpace(cookie) == "" { - return nil, fmt.Errorf("iflow cookie refresh: cookie is empty") - } - if strings.TrimSpace(name) == "" { - return nil, fmt.Errorf("iflow cookie refresh: name is empty") - } - - // Prepare request body - refreshReq := iFlowRefreshRequest{ - Name: name, - } - - bodyBytes, err := json.Marshal(refreshReq) - if err != nil { - return nil, fmt.Errorf("iflow cookie refresh: marshal request failed: %w", err) - } - - req, err := http.NewRequestWithContext(ctx, http.MethodPost, iFlowAPIKeyEndpoint, strings.NewReader(string(bodyBytes))) - if err != nil { - return nil, fmt.Errorf("iflow cookie refresh: create POST request failed: %w", err) - } - - // Set cookie and other headers to mimic browser - req.Header.Set("Cookie", cookie) - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Accept", "application/json, text/plain, */*") - req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36") - req.Header.Set("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8") - req.Header.Set("Accept-Encoding", "gzip, deflate, br") - req.Header.Set("Connection", "keep-alive") - req.Header.Set("Origin", "https://platform.iflow.cn") - req.Header.Set("Referer", "https://platform.iflow.cn/") - - resp, err := ia.httpClient.Do(req) - if err != nil { - return nil, fmt.Errorf("iflow cookie refresh: POST request failed: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - // Handle gzip compression - var reader io.Reader = resp.Body - if resp.Header.Get("Content-Encoding") == "gzip" { - gzipReader, err := gzip.NewReader(resp.Body) - if err != nil { - return nil, fmt.Errorf("iflow cookie refresh: create gzip reader failed: %w", err) - } - defer func() { _ = gzipReader.Close() }() - reader = gzipReader - } - - body, err := io.ReadAll(reader) - if err != nil { - return nil, fmt.Errorf("iflow cookie refresh: read POST response failed: %w", err) - } - - if resp.StatusCode != http.StatusOK { - log.Debugf("iflow cookie POST request failed: status=%d body=%s", resp.StatusCode, string(body)) - return nil, fmt.Errorf("iflow cookie refresh: POST request failed with status %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) - } - - var keyResp iFlowAPIKeyResponse - if err = json.Unmarshal(body, &keyResp); err != nil { - return nil, fmt.Errorf("iflow cookie refresh: decode POST response failed: %w", err) - } - - if !keyResp.Success { - return nil, fmt.Errorf("iflow cookie refresh: POST request not successful: %s", keyResp.Message) - } - - return &keyResp.Data, nil -} - -// ShouldRefreshAPIKey checks if the API key needs to be refreshed (within 2 days of expiry) -func ShouldRefreshAPIKey(expireTime string) (bool, time.Duration, error) { - if strings.TrimSpace(expireTime) == "" { - return false, 0, fmt.Errorf("iflow cookie: expire time is empty") - } - - expire, err := time.Parse("2006-01-02 15:04", expireTime) - if err != nil { - return false, 0, fmt.Errorf("iflow cookie: parse expire time failed: %w", err) - } - - now := time.Now() - twoDaysFromNow := now.Add(48 * time.Hour) - - needsRefresh := expire.Before(twoDaysFromNow) - timeUntilExpiry := expire.Sub(now) - - return needsRefresh, timeUntilExpiry, nil -} - -// CreateCookieTokenStorage converts cookie-based token data into persistence storage -func (ia *IFlowAuth) CreateCookieTokenStorage(data *IFlowTokenData) *IFlowTokenStorage { - if data == nil { - 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: cookieToSave, - LastRefresh: time.Now().Format(time.RFC3339), - Type: "iflow", - } -} - -// UpdateCookieTokenStorage updates the persisted token storage with refreshed API key data -func (ia *IFlowAuth) UpdateCookieTokenStorage(storage *IFlowTokenStorage, keyData *iFlowKeyData) { - if storage == nil || keyData == nil { - return - } - - storage.APIKey = keyData.APIKey - storage.Expire = keyData.ExpireTime - storage.LastRefresh = time.Now().Format(time.RFC3339) -} diff --git a/internal/auth/iflow/iflow_auth_test.go b/internal/auth/iflow/iflow_auth_test.go deleted file mode 100644 index 7512c7a7d..000000000 --- a/internal/auth/iflow/iflow_auth_test.go +++ /dev/null @@ -1,42 +0,0 @@ -package iflow - -import ( - "net/http" - "testing" - - "github.com/router-for-me/CLIProxyAPI/v6/internal/config" -) - -func TestNewIFlowAuthWithProxyURL_OverrideDirectDisablesProxy(t *testing.T) { - cfg := &config.Config{SDKConfig: config.SDKConfig{ProxyURL: "http://proxy.example.com:8080"}} - auth := NewIFlowAuthWithProxyURL(cfg, "direct") - - transport, ok := auth.httpClient.Transport.(*http.Transport) - if !ok || transport == nil { - t.Fatalf("expected http.Transport, got %T", auth.httpClient.Transport) - } - if transport.Proxy != nil { - t.Fatal("expected direct transport to disable proxy function") - } -} - -func TestNewIFlowAuthWithProxyURL_OverrideProxyTakesPrecedence(t *testing.T) { - cfg := &config.Config{SDKConfig: config.SDKConfig{ProxyURL: "http://global.example.com:8080"}} - auth := NewIFlowAuthWithProxyURL(cfg, "http://override.example.com:8081") - - transport, ok := auth.httpClient.Transport.(*http.Transport) - if !ok || transport == nil { - t.Fatalf("expected http.Transport, got %T", auth.httpClient.Transport) - } - req, errReq := http.NewRequest(http.MethodGet, "https://example.com", nil) - if errReq != nil { - t.Fatalf("new request: %v", errReq) - } - proxyURL, errProxy := transport.Proxy(req) - if errProxy != nil { - t.Fatalf("proxy func: %v", errProxy) - } - if proxyURL == nil || proxyURL.String() != "http://override.example.com:8081" { - t.Fatalf("proxy URL = %v, want http://override.example.com:8081", proxyURL) - } -} diff --git a/internal/auth/iflow/iflow_token.go b/internal/auth/iflow/iflow_token.go deleted file mode 100644 index a515c926e..000000000 --- a/internal/auth/iflow/iflow_token.go +++ /dev/null @@ -1,59 +0,0 @@ -package iflow - -import ( - "encoding/json" - "fmt" - "os" - "path/filepath" - - "github.com/router-for-me/CLIProxyAPI/v6/internal/misc" -) - -// IFlowTokenStorage persists iFlow OAuth credentials alongside the derived API key. -type IFlowTokenStorage struct { - AccessToken string `json:"access_token"` - RefreshToken string `json:"refresh_token"` - LastRefresh string `json:"last_refresh"` - Expire string `json:"expired"` - APIKey string `json:"api_key"` - Email string `json:"email"` - TokenType string `json:"token_type"` - Scope string `json:"scope"` - Cookie string `json:"cookie"` - Type string `json:"type"` - - // Metadata holds arbitrary key-value pairs injected via hooks. - // It is not exported to JSON directly to allow flattening during serialization. - Metadata map[string]any `json:"-"` -} - -// SetMetadata allows external callers to inject metadata into the storage before saving. -func (ts *IFlowTokenStorage) SetMetadata(meta map[string]any) { - ts.Metadata = meta -} - -// SaveTokenToFile serialises the token storage to disk. -func (ts *IFlowTokenStorage) SaveTokenToFile(authFilePath string) error { - misc.LogSavingCredentials(authFilePath) - ts.Type = "iflow" - if err := os.MkdirAll(filepath.Dir(authFilePath), 0o700); err != nil { - return fmt.Errorf("iflow token: create directory failed: %w", err) - } - - f, err := os.Create(authFilePath) - if err != nil { - return fmt.Errorf("iflow token: create file failed: %w", err) - } - defer func() { _ = f.Close() }() - - // Merge metadata using helper - data, errMerge := misc.MergeMetadata(ts, ts.Metadata) - if errMerge != nil { - return fmt.Errorf("failed to merge metadata: %w", errMerge) - } - - if err = json.NewEncoder(f).Encode(data); err != nil { - return fmt.Errorf("iflow token: encode token failed: %w", err) - } - return nil -} diff --git a/internal/auth/iflow/oauth_server.go b/internal/auth/iflow/oauth_server.go deleted file mode 100644 index 2a8b7b9f5..000000000 --- a/internal/auth/iflow/oauth_server.go +++ /dev/null @@ -1,143 +0,0 @@ -package iflow - -import ( - "context" - "fmt" - "net" - "net/http" - "strings" - "sync" - "time" - - log "github.com/sirupsen/logrus" -) - -const errorRedirectURL = "https://iflow.cn/oauth/error" - -// OAuthResult captures the outcome of the local OAuth callback. -type OAuthResult struct { - Code string - State string - Error string -} - -// OAuthServer provides a minimal HTTP server for handling the iFlow OAuth callback. -type OAuthServer struct { - server *http.Server - port int - result chan *OAuthResult - errChan chan error - mu sync.Mutex - running bool -} - -// NewOAuthServer constructs a new OAuthServer bound to the provided port. -func NewOAuthServer(port int) *OAuthServer { - return &OAuthServer{ - port: port, - result: make(chan *OAuthResult, 1), - errChan: make(chan error, 1), - } -} - -// Start launches the callback listener. -func (s *OAuthServer) Start() error { - s.mu.Lock() - defer s.mu.Unlock() - if s.running { - return fmt.Errorf("iflow oauth server already running") - } - if !s.isPortAvailable() { - return fmt.Errorf("port %d is already in use", s.port) - } - - mux := http.NewServeMux() - mux.HandleFunc("/oauth2callback", s.handleCallback) - - s.server = &http.Server{ - Addr: fmt.Sprintf(":%d", s.port), - Handler: mux, - ReadTimeout: 10 * time.Second, - WriteTimeout: 10 * time.Second, - } - - s.running = true - - go func() { - if err := s.server.ListenAndServe(); err != nil && err != http.ErrServerClosed { - s.errChan <- err - } - }() - - time.Sleep(100 * time.Millisecond) - return nil -} - -// Stop gracefully terminates the callback listener. -func (s *OAuthServer) Stop(ctx context.Context) error { - s.mu.Lock() - defer s.mu.Unlock() - if !s.running || s.server == nil { - return nil - } - defer func() { - s.running = false - s.server = nil - }() - return s.server.Shutdown(ctx) -} - -// WaitForCallback blocks until a callback result, server error, or timeout occurs. -func (s *OAuthServer) WaitForCallback(timeout time.Duration) (*OAuthResult, error) { - select { - case res := <-s.result: - return res, nil - case err := <-s.errChan: - return nil, err - case <-time.After(timeout): - return nil, fmt.Errorf("timeout waiting for OAuth callback") - } -} - -func (s *OAuthServer) handleCallback(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - return - } - - query := r.URL.Query() - if errParam := strings.TrimSpace(query.Get("error")); errParam != "" { - s.sendResult(&OAuthResult{Error: errParam}) - http.Redirect(w, r, errorRedirectURL, http.StatusFound) - return - } - - code := strings.TrimSpace(query.Get("code")) - if code == "" { - s.sendResult(&OAuthResult{Error: "missing_code"}) - http.Redirect(w, r, errorRedirectURL, http.StatusFound) - return - } - - state := query.Get("state") - s.sendResult(&OAuthResult{Code: code, State: state}) - http.Redirect(w, r, SuccessRedirectURL, http.StatusFound) -} - -func (s *OAuthServer) sendResult(res *OAuthResult) { - select { - case s.result <- res: - default: - log.Debug("iflow oauth result channel full, dropping result") - } -} - -func (s *OAuthServer) isPortAvailable() bool { - addr := fmt.Sprintf(":%d", s.port) - listener, err := net.Listen("tcp", addr) - if err != nil { - return false - } - _ = listener.Close() - return true -} diff --git a/internal/cmd/auth_manager.go b/internal/cmd/auth_manager.go index b93d8771e..265471790 100644 --- a/internal/cmd/auth_manager.go +++ b/internal/cmd/auth_manager.go @@ -6,7 +6,7 @@ import ( // newAuthManager creates a new authentication manager instance with all supported // authenticators and a file-based token store. It initializes authenticators for -// Gemini, Codex, Claude, iFlow, Antigravity, and Kimi providers. +// Gemini, Codex, Claude, Antigravity, and Kimi providers. // // Returns: // - *sdkAuth.Manager: A configured authentication manager instance @@ -16,7 +16,6 @@ func newAuthManager() *sdkAuth.Manager { sdkAuth.NewGeminiAuthenticator(), sdkAuth.NewCodexAuthenticator(), sdkAuth.NewClaudeAuthenticator(), - sdkAuth.NewIFlowAuthenticator(), sdkAuth.NewAntigravityAuthenticator(), sdkAuth.NewKimiAuthenticator(), ) diff --git a/internal/cmd/iflow_cookie.go b/internal/cmd/iflow_cookie.go deleted file mode 100644 index 358b80627..000000000 --- a/internal/cmd/iflow_cookie.go +++ /dev/null @@ -1,98 +0,0 @@ -package cmd - -import ( - "bufio" - "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" -) - -// DoIFlowCookieAuth performs the iFlow cookie-based authentication. -func DoIFlowCookieAuth(cfg *config.Config, options *LoginOptions) { - if options == nil { - options = &LoginOptions{} - } - - promptFn := options.Prompt - if promptFn == nil { - reader := bufio.NewReader(os.Stdin) - promptFn = func(prompt string) (string, error) { - fmt.Print(prompt) - value, err := reader.ReadString('\n') - if err != nil { - return "", err - } - return strings.TrimSpace(value), nil - } - } - - // Prompt user for cookie - cookie, err := promptForCookie(promptFn) - if err != nil { - fmt.Printf("Failed to get cookie: %v\n", err) - 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() - - tokenData, err := auth.AuthenticateWithCookie(ctx, cookie) - if err != nil { - fmt.Printf("iFlow cookie authentication failed: %v\n", err) - return - } - - // Create token storage - tokenStorage := auth.CreateCookieTokenStorage(tokenData) - - // Get auth file path using email in filename - authFilePath := getAuthFilePath(cfg, "iflow", tokenData.Email) - - // Save token to file - if err := tokenStorage.SaveTokenToFile(authFilePath); err != nil { - fmt.Printf("Failed to save authentication: %v\n", err) - return - } - - fmt.Printf("Authentication successful! API key: %s\n", tokenData.APIKey) - fmt.Printf("Expires at: %s\n", tokenData.Expire) - fmt.Printf("Authentication saved to: %s\n", authFilePath) -} - -// promptForCookie prompts the user to enter their iFlow cookie -func promptForCookie(promptFn func(string) (string, error)) (string, error) { - line, err := promptFn("Enter iFlow Cookie (from browser cookies): ") - if err != nil { - return "", fmt.Errorf("failed to read cookie: %w", err) - } - - cookie, err := iflow.NormalizeCookie(line) - if err != nil { - return "", err - } - - return cookie, nil -} - -// 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-%d.json", cfg.AuthDir, provider, fileName, time.Now().Unix()) -} diff --git a/internal/cmd/iflow_login.go b/internal/cmd/iflow_login.go deleted file mode 100644 index 49e18e5b7..000000000 --- a/internal/cmd/iflow_login.go +++ /dev/null @@ -1,48 +0,0 @@ -package cmd - -import ( - "context" - "errors" - "fmt" - - "github.com/router-for-me/CLIProxyAPI/v6/internal/config" - sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth" - log "github.com/sirupsen/logrus" -) - -// DoIFlowLogin performs the iFlow OAuth login via the shared authentication manager. -func DoIFlowLogin(cfg *config.Config, options *LoginOptions) { - if options == nil { - options = &LoginOptions{} - } - - manager := newAuthManager() - - promptFn := options.Prompt - if promptFn == nil { - promptFn = defaultProjectPrompt() - } - - authOpts := &sdkAuth.LoginOptions{ - NoBrowser: options.NoBrowser, - CallbackPort: options.CallbackPort, - Metadata: map[string]string{}, - Prompt: promptFn, - } - - _, savedPath, err := manager.Login(context.Background(), "iflow", cfg, authOpts) - if err != nil { - if emailErr, ok := errors.AsType[*sdkAuth.EmailRequiredError](err); ok { - log.Error(emailErr.Error()) - return - } - fmt.Printf("iFlow authentication failed: %v\n", err) - return - } - - if savedPath != "" { - fmt.Printf("Authentication saved to %s\n", savedPath) - } - - fmt.Println("iFlow authentication successful!") -} diff --git a/internal/config/config.go b/internal/config/config.go index 7b2f9611e..760d43ec4 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -128,7 +128,7 @@ type Config struct { // OAuthModelAlias defines global model name aliases for OAuth/file-backed auth channels. // These aliases affect both model listing and model routing for supported channels: - // gemini-cli, vertex, aistudio, antigravity, claude, codex, iflow. + // gemini-cli, vertex, aistudio, antigravity, claude, codex, kimi. // // NOTE: This does not apply to existing per-credential model alias features under: // gemini-api-key, codex-api-key, claude-api-key, openai-compatibility, vertex-api-key, and ampcode. diff --git a/internal/registry/model_definitions.go b/internal/registry/model_definitions.go index 9edba0c22..ab7258f84 100644 --- a/internal/registry/model_definitions.go +++ b/internal/registry/model_definitions.go @@ -17,7 +17,6 @@ type staticModelsJSON struct { CodexTeam []*ModelInfo `json:"codex-team"` CodexPlus []*ModelInfo `json:"codex-plus"` CodexPro []*ModelInfo `json:"codex-pro"` - IFlow []*ModelInfo `json:"iflow"` Kimi []*ModelInfo `json:"kimi"` Antigravity []*ModelInfo `json:"antigravity"` } @@ -67,11 +66,6 @@ func GetCodexProModels() []*ModelInfo { return cloneModelInfos(getModels().CodexPro) } -// GetIFlowModels returns the standard iFlow model definitions. -func GetIFlowModels() []*ModelInfo { - return cloneModelInfos(getModels().IFlow) -} - // GetKimiModels returns the standard Kimi (Moonshot AI) model definitions. func GetKimiModels() []*ModelInfo { return cloneModelInfos(getModels().Kimi) @@ -104,7 +98,6 @@ func cloneModelInfos(models []*ModelInfo) []*ModelInfo { // - gemini-cli // - aistudio // - codex -// - iflow // - kimi // - antigravity func GetStaticModelDefinitionsByChannel(channel string) []*ModelInfo { @@ -122,8 +115,6 @@ func GetStaticModelDefinitionsByChannel(channel string) []*ModelInfo { return GetAIStudioModels() case "codex": return GetCodexProModels() - case "iflow": - return GetIFlowModels() case "kimi": return GetKimiModels() case "antigravity": @@ -148,7 +139,6 @@ func LookupStaticModelInfo(modelID string) *ModelInfo { data.GeminiCLI, data.AIStudio, data.CodexPro, - data.IFlow, data.Kimi, data.Antigravity, } diff --git a/internal/registry/model_updater.go b/internal/registry/model_updater.go index 9ed09c2f1..2512a296b 100644 --- a/internal/registry/model_updater.go +++ b/internal/registry/model_updater.go @@ -213,7 +213,6 @@ func detectChangedProviders(oldData, newData *staticModelsJSON) []string { {"codex", oldData.CodexTeam, newData.CodexTeam}, {"codex", oldData.CodexPlus, newData.CodexPlus}, {"codex", oldData.CodexPro, newData.CodexPro}, - {"iflow", oldData.IFlow, newData.IFlow}, {"kimi", oldData.Kimi, newData.Kimi}, {"antigravity", oldData.Antigravity, newData.Antigravity}, } @@ -334,7 +333,6 @@ func validateModelsCatalog(data *staticModelsJSON) error { {name: "codex-team", models: data.CodexTeam}, {name: "codex-plus", models: data.CodexPlus}, {name: "codex-pro", models: data.CodexPro}, - {name: "iflow", models: data.IFlow}, {name: "kimi", models: data.Kimi}, {name: "antigravity", models: data.Antigravity}, } diff --git a/internal/registry/models/models.json b/internal/registry/models/models.json index d4788ec11..0da3cc41b 100644 --- a/internal/registry/models/models.json +++ b/internal/registry/models/models.json @@ -1602,187 +1602,6 @@ } } ], - "iflow": [ - { - "id": "qwen3-coder-plus", - "object": "model", - "created": 1753228800, - "owned_by": "iflow", - "type": "iflow", - "display_name": "Qwen3-Coder-Plus", - "description": "Qwen3 Coder Plus code generation" - }, - { - "id": "qwen3-max", - "object": "model", - "created": 1758672000, - "owned_by": "iflow", - "type": "iflow", - "display_name": "Qwen3-Max", - "description": "Qwen3 flagship model" - }, - { - "id": "qwen3-vl-plus", - "object": "model", - "created": 1758672000, - "owned_by": "iflow", - "type": "iflow", - "display_name": "Qwen3-VL-Plus", - "description": "Qwen3 multimodal vision-language" - }, - { - "id": "qwen3-max-preview", - "object": "model", - "created": 1757030400, - "owned_by": "iflow", - "type": "iflow", - "display_name": "Qwen3-Max-Preview", - "description": "Qwen3 Max preview build", - "thinking": { - "levels": [ - "none", - "auto", - "minimal", - "low", - "medium", - "high", - "xhigh" - ] - } - }, - { - "id": "glm-4.6", - "object": "model", - "created": 1759190400, - "owned_by": "iflow", - "type": "iflow", - "display_name": "GLM-4.6", - "description": "Zhipu GLM 4.6 general model", - "thinking": { - "levels": [ - "none", - "auto", - "minimal", - "low", - "medium", - "high", - "xhigh" - ] - } - }, - { - "id": "kimi-k2", - "object": "model", - "created": 1752192000, - "owned_by": "iflow", - "type": "iflow", - "display_name": "Kimi-K2", - "description": "Moonshot Kimi K2 general model" - }, - { - "id": "deepseek-v3.2", - "object": "model", - "created": 1759104000, - "owned_by": "iflow", - "type": "iflow", - "display_name": "DeepSeek-V3.2-Exp", - "description": "DeepSeek V3.2 experimental", - "thinking": { - "levels": [ - "none", - "auto", - "minimal", - "low", - "medium", - "high", - "xhigh" - ] - } - }, - { - "id": "deepseek-v3.1", - "object": "model", - "created": 1756339200, - "owned_by": "iflow", - "type": "iflow", - "display_name": "DeepSeek-V3.1-Terminus", - "description": "DeepSeek V3.1 Terminus", - "thinking": { - "levels": [ - "none", - "auto", - "minimal", - "low", - "medium", - "high", - "xhigh" - ] - } - }, - { - "id": "deepseek-r1", - "object": "model", - "created": 1737331200, - "owned_by": "iflow", - "type": "iflow", - "display_name": "DeepSeek-R1", - "description": "DeepSeek reasoning model R1" - }, - { - "id": "deepseek-v3", - "object": "model", - "created": 1734307200, - "owned_by": "iflow", - "type": "iflow", - "display_name": "DeepSeek-V3-671B", - "description": "DeepSeek V3 671B" - }, - { - "id": "qwen3-32b", - "object": "model", - "created": 1747094400, - "owned_by": "iflow", - "type": "iflow", - "display_name": "Qwen3-32B", - "description": "Qwen3 32B" - }, - { - "id": "qwen3-235b-a22b-thinking-2507", - "object": "model", - "created": 1753401600, - "owned_by": "iflow", - "type": "iflow", - "display_name": "Qwen3-235B-A22B-Thinking", - "description": "Qwen3 235B A22B Thinking (2507)" - }, - { - "id": "qwen3-235b-a22b-instruct", - "object": "model", - "created": 1753401600, - "owned_by": "iflow", - "type": "iflow", - "display_name": "Qwen3-235B-A22B-Instruct", - "description": "Qwen3 235B A22B Instruct" - }, - { - "id": "qwen3-235b", - "object": "model", - "created": 1753401600, - "owned_by": "iflow", - "type": "iflow", - "display_name": "Qwen3-235B-A22B", - "description": "Qwen3 235B A22B" - }, - { - "id": "iflow-rome-30ba3b", - "object": "model", - "created": 1736899200, - "owned_by": "iflow", - "type": "iflow", - "display_name": "iFlow-ROME", - "description": "iFlow Rome 30BA3B model" - } - ], "kimi": [ { "id": "kimi-k2", @@ -2022,4 +1841,4 @@ } } ] -} \ No newline at end of file +} diff --git a/internal/runtime/executor/helps/thinking_providers.go b/internal/runtime/executor/helps/thinking_providers.go index 36b63c90e..bbd019624 100644 --- a/internal/runtime/executor/helps/thinking_providers.go +++ b/internal/runtime/executor/helps/thinking_providers.go @@ -6,7 +6,6 @@ import ( _ "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking/provider/codex" _ "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking/provider/gemini" _ "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking/provider/geminicli" - _ "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking/provider/iflow" _ "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking/provider/kimi" _ "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking/provider/openai" ) diff --git a/internal/runtime/executor/iflow_executor.go b/internal/runtime/executor/iflow_executor.go deleted file mode 100644 index da56ec3c2..000000000 --- a/internal/runtime/executor/iflow_executor.go +++ /dev/null @@ -1,585 +0,0 @@ -package executor - -import ( - "bufio" - "bytes" - "context" - "crypto/hmac" - "crypto/sha256" - "encoding/hex" - "fmt" - "io" - "net/http" - "strings" - "time" - - "github.com/google/uuid" - iflowauth "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/iflow" - "github.com/router-for-me/CLIProxyAPI/v6/internal/config" - "github.com/router-for-me/CLIProxyAPI/v6/internal/runtime/executor/helps" - "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking" - "github.com/router-for-me/CLIProxyAPI/v6/internal/util" - cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" - cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor" - sdktranslator "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator" - log "github.com/sirupsen/logrus" - "github.com/tidwall/gjson" - "github.com/tidwall/sjson" -) - -const ( - iflowDefaultEndpoint = "/chat/completions" - iflowUserAgent = "iFlow-Cli" -) - -// IFlowExecutor executes OpenAI-compatible chat completions against the iFlow API using API keys derived from OAuth. -type IFlowExecutor struct { - cfg *config.Config -} - -// NewIFlowExecutor constructs a new executor instance. -func NewIFlowExecutor(cfg *config.Config) *IFlowExecutor { return &IFlowExecutor{cfg: cfg} } - -// Identifier returns the provider key. -func (e *IFlowExecutor) Identifier() string { return "iflow" } - -// PrepareRequest injects iFlow credentials into the outgoing HTTP request. -func (e *IFlowExecutor) PrepareRequest(req *http.Request, auth *cliproxyauth.Auth) error { - if req == nil { - return nil - } - apiKey, _ := iflowCreds(auth) - if strings.TrimSpace(apiKey) != "" { - req.Header.Set("Authorization", "Bearer "+apiKey) - } - return nil -} - -// HttpRequest injects iFlow credentials into the request and executes it. -func (e *IFlowExecutor) HttpRequest(ctx context.Context, auth *cliproxyauth.Auth, req *http.Request) (*http.Response, error) { - if req == nil { - return nil, fmt.Errorf("iflow executor: request is nil") - } - if ctx == nil { - ctx = req.Context() - } - httpReq := req.WithContext(ctx) - if err := e.PrepareRequest(httpReq, auth); err != nil { - return nil, err - } - httpClient := helps.NewProxyAwareHTTPClient(ctx, e.cfg, auth, 0) - return httpClient.Do(httpReq) -} - -// Execute performs a non-streaming chat completion request. -func (e *IFlowExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (resp cliproxyexecutor.Response, err error) { - if opts.Alt == "responses/compact" { - return resp, statusErr{code: http.StatusNotImplemented, msg: "/responses/compact not supported"} - } - baseModel := thinking.ParseSuffix(req.Model).ModelName - - apiKey, baseURL := iflowCreds(auth) - if strings.TrimSpace(apiKey) == "" { - err = fmt.Errorf("iflow executor: missing api key") - return resp, err - } - if baseURL == "" { - baseURL = iflowauth.DefaultAPIBaseURL - } - - reporter := helps.NewUsageReporter(ctx, e.Identifier(), baseModel, auth) - defer reporter.TrackFailure(ctx, &err) - - from := opts.SourceFormat - to := sdktranslator.FromString("openai") - originalPayloadSource := req.Payload - if len(opts.OriginalRequest) > 0 { - originalPayloadSource = opts.OriginalRequest - } - originalPayload := originalPayloadSource - originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, false) - body := sdktranslator.TranslateRequest(from, to, baseModel, req.Payload, false) - body, _ = sjson.SetBytes(body, "model", baseModel) - - body, err = thinking.ApplyThinking(body, req.Model, from.String(), "iflow", e.Identifier()) - if err != nil { - return resp, err - } - - body = preserveReasoningContentInMessages(body) - requestedModel := helps.PayloadRequestedModel(opts, req.Model) - body = helps.ApplyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", body, originalTranslated, requestedModel) - - endpoint := strings.TrimSuffix(baseURL, "/") + iflowDefaultEndpoint - - httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body)) - if err != nil { - return resp, err - } - applyIFlowHeaders(httpReq, apiKey, false) - var attrs map[string]string - if auth != nil { - attrs = auth.Attributes - } - util.ApplyCustomHeadersFromAttrs(httpReq, attrs) - var authID, authLabel, authType, authValue string - if auth != nil { - authID = auth.ID - authLabel = auth.Label - authType, authValue = auth.AccountInfo() - } - helps.RecordAPIRequest(ctx, e.cfg, helps.UpstreamRequestLog{ - URL: endpoint, - Method: http.MethodPost, - Headers: httpReq.Header.Clone(), - Body: body, - Provider: e.Identifier(), - AuthID: authID, - AuthLabel: authLabel, - AuthType: authType, - AuthValue: authValue, - }) - - httpClient := helps.NewProxyAwareHTTPClient(ctx, e.cfg, auth, 0) - httpResp, err := httpClient.Do(httpReq) - if err != nil { - helps.RecordAPIResponseError(ctx, e.cfg, err) - return resp, err - } - defer func() { - if errClose := httpResp.Body.Close(); errClose != nil { - log.Errorf("iflow executor: close response body error: %v", errClose) - } - }() - helps.RecordAPIResponseMetadata(ctx, e.cfg, httpResp.StatusCode, httpResp.Header.Clone()) - - if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 { - b, _ := io.ReadAll(httpResp.Body) - helps.AppendAPIResponseChunk(ctx, e.cfg, b) - helps.LogWithRequestID(ctx).Debugf("request error, error status: %d error message: %s", httpResp.StatusCode, helps.SummarizeErrorBody(httpResp.Header.Get("Content-Type"), b)) - err = statusErr{code: httpResp.StatusCode, msg: string(b)} - return resp, err - } - - data, err := io.ReadAll(httpResp.Body) - if err != nil { - helps.RecordAPIResponseError(ctx, e.cfg, err) - return resp, err - } - helps.AppendAPIResponseChunk(ctx, e.cfg, data) - reporter.Publish(ctx, helps.ParseOpenAIUsage(data)) - // Ensure usage is recorded even if upstream omits usage metadata. - reporter.EnsurePublished(ctx) - - var param any - // Note: TranslateNonStream uses req.Model (original with suffix) to preserve - // the original model name in the response for client compatibility. - out := sdktranslator.TranslateNonStream(ctx, to, from, req.Model, opts.OriginalRequest, body, data, ¶m) - resp = cliproxyexecutor.Response{Payload: out, Headers: httpResp.Header.Clone()} - return resp, nil -} - -// ExecuteStream performs a streaming chat completion request. -func (e *IFlowExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (_ *cliproxyexecutor.StreamResult, err error) { - if opts.Alt == "responses/compact" { - return nil, statusErr{code: http.StatusNotImplemented, msg: "/responses/compact not supported"} - } - baseModel := thinking.ParseSuffix(req.Model).ModelName - - apiKey, baseURL := iflowCreds(auth) - if strings.TrimSpace(apiKey) == "" { - err = fmt.Errorf("iflow executor: missing api key") - return nil, err - } - if baseURL == "" { - baseURL = iflowauth.DefaultAPIBaseURL - } - - reporter := helps.NewUsageReporter(ctx, e.Identifier(), baseModel, auth) - defer reporter.TrackFailure(ctx, &err) - - from := opts.SourceFormat - to := sdktranslator.FromString("openai") - originalPayloadSource := req.Payload - if len(opts.OriginalRequest) > 0 { - originalPayloadSource = opts.OriginalRequest - } - originalPayload := originalPayloadSource - originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, true) - body := sdktranslator.TranslateRequest(from, to, baseModel, req.Payload, true) - body, _ = sjson.SetBytes(body, "model", baseModel) - - body, err = thinking.ApplyThinking(body, req.Model, from.String(), "iflow", e.Identifier()) - if err != nil { - return nil, err - } - - body = preserveReasoningContentInMessages(body) - // Ensure tools array exists to avoid provider quirks observed in some upstreams. - toolsResult := gjson.GetBytes(body, "tools") - if toolsResult.Exists() && toolsResult.IsArray() && len(toolsResult.Array()) == 0 { - body = ensureToolsArray(body) - } - requestedModel := helps.PayloadRequestedModel(opts, req.Model) - body = helps.ApplyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", body, originalTranslated, requestedModel) - - endpoint := strings.TrimSuffix(baseURL, "/") + iflowDefaultEndpoint - - httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body)) - if err != nil { - return nil, err - } - applyIFlowHeaders(httpReq, apiKey, true) - var attrs map[string]string - if auth != nil { - attrs = auth.Attributes - } - util.ApplyCustomHeadersFromAttrs(httpReq, attrs) - var authID, authLabel, authType, authValue string - if auth != nil { - authID = auth.ID - authLabel = auth.Label - authType, authValue = auth.AccountInfo() - } - helps.RecordAPIRequest(ctx, e.cfg, helps.UpstreamRequestLog{ - URL: endpoint, - Method: http.MethodPost, - Headers: httpReq.Header.Clone(), - Body: body, - Provider: e.Identifier(), - AuthID: authID, - AuthLabel: authLabel, - AuthType: authType, - AuthValue: authValue, - }) - - httpClient := helps.NewProxyAwareHTTPClient(ctx, e.cfg, auth, 0) - httpResp, err := httpClient.Do(httpReq) - if err != nil { - helps.RecordAPIResponseError(ctx, e.cfg, err) - return nil, err - } - - helps.RecordAPIResponseMetadata(ctx, e.cfg, httpResp.StatusCode, httpResp.Header.Clone()) - if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 { - data, _ := io.ReadAll(httpResp.Body) - if errClose := httpResp.Body.Close(); errClose != nil { - log.Errorf("iflow executor: close response body error: %v", errClose) - } - helps.AppendAPIResponseChunk(ctx, e.cfg, data) - helps.LogWithRequestID(ctx).Debugf("request error, error status: %d error message: %s", httpResp.StatusCode, helps.SummarizeErrorBody(httpResp.Header.Get("Content-Type"), data)) - err = statusErr{code: httpResp.StatusCode, msg: string(data)} - return nil, err - } - - out := make(chan cliproxyexecutor.StreamChunk) - go func() { - defer close(out) - defer func() { - if errClose := httpResp.Body.Close(); errClose != nil { - log.Errorf("iflow executor: close response body error: %v", errClose) - } - }() - - scanner := bufio.NewScanner(httpResp.Body) - scanner.Buffer(nil, 52_428_800) // 50MB - var param any - for scanner.Scan() { - line := scanner.Bytes() - helps.AppendAPIResponseChunk(ctx, e.cfg, line) - if detail, ok := helps.ParseOpenAIStreamUsage(line); ok { - reporter.Publish(ctx, detail) - } - chunks := sdktranslator.TranslateStream(ctx, to, from, req.Model, opts.OriginalRequest, body, bytes.Clone(line), ¶m) - for i := range chunks { - out <- cliproxyexecutor.StreamChunk{Payload: chunks[i]} - } - } - if errScan := scanner.Err(); errScan != nil { - helps.RecordAPIResponseError(ctx, e.cfg, errScan) - reporter.PublishFailure(ctx) - out <- cliproxyexecutor.StreamChunk{Err: errScan} - } - // Guarantee a usage record exists even if the stream never emitted usage data. - reporter.EnsurePublished(ctx) - }() - - return &cliproxyexecutor.StreamResult{Headers: httpResp.Header.Clone(), Chunks: out}, nil -} - -func (e *IFlowExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) { - baseModel := thinking.ParseSuffix(req.Model).ModelName - - from := opts.SourceFormat - to := sdktranslator.FromString("openai") - body := sdktranslator.TranslateRequest(from, to, baseModel, req.Payload, false) - - enc, err := helps.TokenizerForModel(baseModel) - if err != nil { - return cliproxyexecutor.Response{}, fmt.Errorf("iflow executor: tokenizer init failed: %w", err) - } - - count, err := helps.CountOpenAIChatTokens(enc, body) - if err != nil { - return cliproxyexecutor.Response{}, fmt.Errorf("iflow executor: token counting failed: %w", err) - } - - usageJSON := helps.BuildOpenAIUsageJSON(count) - translated := sdktranslator.TranslateTokenCount(ctx, to, from, count, usageJSON) - return cliproxyexecutor.Response{Payload: translated}, nil -} - -// Refresh refreshes OAuth tokens or cookie-based API keys and updates the stored API key. -func (e *IFlowExecutor) Refresh(ctx context.Context, auth *cliproxyauth.Auth) (*cliproxyauth.Auth, error) { - log.Debugf("iflow executor: refresh called") - if auth == nil { - return nil, fmt.Errorf("iflow executor: auth is nil") - } - - // Check if this is cookie-based authentication - var cookie string - var email string - if auth.Metadata != nil { - if v, ok := auth.Metadata["cookie"].(string); ok { - cookie = strings.TrimSpace(v) - } - if v, ok := auth.Metadata["email"].(string); ok { - email = strings.TrimSpace(v) - } - } - - // If cookie is present, use cookie-based refresh - if cookie != "" && email != "" { - return e.refreshCookieBased(ctx, auth, cookie, email) - } - - // Otherwise, use OAuth-based refresh - return e.refreshOAuthBased(ctx, auth) -} - -// refreshCookieBased refreshes API key using browser cookie -func (e *IFlowExecutor) refreshCookieBased(ctx context.Context, auth *cliproxyauth.Auth, cookie, email string) (*cliproxyauth.Auth, error) { - log.Debugf("iflow executor: checking refresh need for cookie-based API key for user: %s", email) - - // Get current expiry time from metadata - var currentExpire string - if auth.Metadata != nil { - if v, ok := auth.Metadata["expired"].(string); ok { - currentExpire = strings.TrimSpace(v) - } - } - - // Check if refresh is needed - needsRefresh, _, err := iflowauth.ShouldRefreshAPIKey(currentExpire) - if err != nil { - log.Warnf("iflow executor: failed to check refresh need: %v", err) - // If we can't check, continue with refresh anyway as a safety measure - } else if !needsRefresh { - log.Debugf("iflow executor: no refresh needed for user: %s", email) - return auth, nil - } - - log.Infof("iflow executor: refreshing cookie-based API key for user: %s", email) - - svc := iflowauth.NewIFlowAuthWithProxyURL(e.cfg, auth.ProxyURL) - keyData, err := svc.RefreshAPIKey(ctx, cookie, email) - if err != nil { - log.Errorf("iflow executor: cookie-based API key refresh failed: %v", err) - return nil, err - } - - if auth.Metadata == nil { - auth.Metadata = make(map[string]any) - } - auth.Metadata["api_key"] = keyData.APIKey - auth.Metadata["expired"] = keyData.ExpireTime - auth.Metadata["type"] = "iflow" - auth.Metadata["last_refresh"] = time.Now().Format(time.RFC3339) - auth.Metadata["cookie"] = cookie - auth.Metadata["email"] = email - - log.Infof("iflow executor: cookie-based API key refreshed successfully, new expiry: %s", keyData.ExpireTime) - - if auth.Attributes == nil { - auth.Attributes = make(map[string]string) - } - auth.Attributes["api_key"] = keyData.APIKey - - return auth, nil -} - -// refreshOAuthBased refreshes tokens using OAuth refresh token -func (e *IFlowExecutor) refreshOAuthBased(ctx context.Context, auth *cliproxyauth.Auth) (*cliproxyauth.Auth, error) { - refreshToken := "" - oldAccessToken := "" - if auth.Metadata != nil { - if v, ok := auth.Metadata["refresh_token"].(string); ok { - refreshToken = strings.TrimSpace(v) - } - if v, ok := auth.Metadata["access_token"].(string); ok { - oldAccessToken = strings.TrimSpace(v) - } - } - if refreshToken == "" { - return auth, nil - } - - // Log the old access token (masked) before refresh - if oldAccessToken != "" { - log.Debugf("iflow executor: refreshing access token, old: %s", util.HideAPIKey(oldAccessToken)) - } - - svc := iflowauth.NewIFlowAuthWithProxyURL(e.cfg, auth.ProxyURL) - tokenData, err := svc.RefreshTokens(ctx, refreshToken) - if err != nil { - log.Errorf("iflow executor: token refresh failed: %v", err) - return nil, err - } - - if auth.Metadata == nil { - auth.Metadata = make(map[string]any) - } - auth.Metadata["access_token"] = tokenData.AccessToken - if tokenData.RefreshToken != "" { - auth.Metadata["refresh_token"] = tokenData.RefreshToken - } - if tokenData.APIKey != "" { - auth.Metadata["api_key"] = tokenData.APIKey - } - auth.Metadata["expired"] = tokenData.Expire - auth.Metadata["type"] = "iflow" - auth.Metadata["last_refresh"] = time.Now().Format(time.RFC3339) - - // Log the new access token (masked) after successful refresh - log.Debugf("iflow executor: token refresh successful, new: %s", util.HideAPIKey(tokenData.AccessToken)) - - if auth.Attributes == nil { - auth.Attributes = make(map[string]string) - } - if tokenData.APIKey != "" { - auth.Attributes["api_key"] = tokenData.APIKey - } - - return auth, nil -} - -func applyIFlowHeaders(r *http.Request, apiKey string, stream bool) { - r.Header.Set("Content-Type", "application/json") - r.Header.Set("Authorization", "Bearer "+apiKey) - r.Header.Set("User-Agent", iflowUserAgent) - - // Generate session-id - sessionID := "session-" + generateUUID() - r.Header.Set("session-id", sessionID) - - // Generate timestamp and signature - timestamp := time.Now().UnixMilli() - r.Header.Set("x-iflow-timestamp", fmt.Sprintf("%d", timestamp)) - - signature := createIFlowSignature(iflowUserAgent, sessionID, timestamp, apiKey) - if signature != "" { - r.Header.Set("x-iflow-signature", signature) - } - - if stream { - r.Header.Set("Accept", "text/event-stream") - } else { - r.Header.Set("Accept", "application/json") - } -} - -// createIFlowSignature generates HMAC-SHA256 signature for iFlow API requests. -// The signature payload format is: userAgent:sessionId:timestamp -func createIFlowSignature(userAgent, sessionID string, timestamp int64, apiKey string) string { - if apiKey == "" { - return "" - } - payload := fmt.Sprintf("%s:%s:%d", userAgent, sessionID, timestamp) - h := hmac.New(sha256.New, []byte(apiKey)) - h.Write([]byte(payload)) - return hex.EncodeToString(h.Sum(nil)) -} - -// generateUUID generates a random UUID v4 string. -func generateUUID() string { - return uuid.New().String() -} - -func iflowCreds(a *cliproxyauth.Auth) (apiKey, baseURL string) { - if a == nil { - return "", "" - } - if a.Attributes != nil { - if v := strings.TrimSpace(a.Attributes["api_key"]); v != "" { - apiKey = v - } - if v := strings.TrimSpace(a.Attributes["base_url"]); v != "" { - baseURL = v - } - } - if apiKey == "" && a.Metadata != nil { - if v, ok := a.Metadata["api_key"].(string); ok { - apiKey = strings.TrimSpace(v) - } - } - if baseURL == "" && a.Metadata != nil { - if v, ok := a.Metadata["base_url"].(string); ok { - baseURL = strings.TrimSpace(v) - } - } - return apiKey, baseURL -} - -func ensureToolsArray(body []byte) []byte { - placeholder := `[{"type":"function","function":{"name":"noop","description":"Placeholder tool to stabilise streaming","parameters":{"type":"object"}}}]` - updated, err := sjson.SetRawBytes(body, "tools", []byte(placeholder)) - if err != nil { - return body - } - return updated -} - -// preserveReasoningContentInMessages checks if reasoning_content from assistant messages -// is preserved in conversation history for iFlow models that support thinking. -// This is helpful for multi-turn conversations where the model may benefit from seeing -// its previous reasoning to maintain coherent thought chains. -// -// For GLM-4.6/4.7 and MiniMax M2/M2.1, it is recommended to include the full assistant -// response (including reasoning_content) in message history for better context continuity. -func preserveReasoningContentInMessages(body []byte) []byte { - model := strings.ToLower(gjson.GetBytes(body, "model").String()) - - // Only apply to models that support thinking with history preservation - needsPreservation := strings.HasPrefix(model, "glm-4") || strings.HasPrefix(model, "minimax-m2") - - if !needsPreservation { - return body - } - - messages := gjson.GetBytes(body, "messages") - if !messages.Exists() || !messages.IsArray() { - return body - } - - // Check if any assistant message already has reasoning_content preserved - hasReasoningContent := false - messages.ForEach(func(_, msg gjson.Result) bool { - role := msg.Get("role").String() - if role == "assistant" { - rc := msg.Get("reasoning_content") - if rc.Exists() && rc.String() != "" { - hasReasoningContent = true - return false // stop iteration - } - } - return true - }) - - // If reasoning content is already present, the messages are properly formatted - // No need to modify - the client has correctly preserved reasoning in history - if hasReasoningContent { - log.Debugf("iflow executor: reasoning_content found in message history for %s", model) - } - - return body -} diff --git a/internal/runtime/executor/iflow_executor_test.go b/internal/runtime/executor/iflow_executor_test.go deleted file mode 100644 index e588548b0..000000000 --- a/internal/runtime/executor/iflow_executor_test.go +++ /dev/null @@ -1,67 +0,0 @@ -package executor - -import ( - "testing" - - "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking" -) - -func TestIFlowExecutorParseSuffix(t *testing.T) { - tests := []struct { - name string - model string - wantBase string - wantLevel string - }{ - {"no suffix", "glm-4", "glm-4", ""}, - {"glm with suffix", "glm-4.1-flash(high)", "glm-4.1-flash", "high"}, - {"minimax no suffix", "minimax-m2", "minimax-m2", ""}, - {"minimax with suffix", "minimax-m2.1(medium)", "minimax-m2.1", "medium"}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := thinking.ParseSuffix(tt.model) - if result.ModelName != tt.wantBase { - t.Errorf("ParseSuffix(%q).ModelName = %q, want %q", tt.model, result.ModelName, tt.wantBase) - } - }) - } -} - -func TestPreserveReasoningContentInMessages(t *testing.T) { - tests := []struct { - name string - input []byte - want []byte // nil means output should equal input - }{ - { - "non-glm model passthrough", - []byte(`{"model":"gpt-4","messages":[]}`), - nil, - }, - { - "glm model with empty messages", - []byte(`{"model":"glm-4","messages":[]}`), - nil, - }, - { - "glm model preserves existing reasoning_content", - []byte(`{"model":"glm-4","messages":[{"role":"assistant","content":"hi","reasoning_content":"thinking..."}]}`), - nil, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := preserveReasoningContentInMessages(tt.input) - want := tt.want - if want == nil { - want = tt.input - } - if string(got) != string(want) { - t.Errorf("preserveReasoningContentInMessages() = %s, want %s", got, want) - } - }) - } -} diff --git a/internal/thinking/apply.go b/internal/thinking/apply.go index c79ecd8ee..1edeac874 100644 --- a/internal/thinking/apply.go +++ b/internal/thinking/apply.go @@ -16,7 +16,6 @@ var providerAppliers = map[string]ProviderApplier{ "claude": nil, "openai": nil, "codex": nil, - "iflow": nil, "antigravity": nil, "kimi": nil, } @@ -63,7 +62,7 @@ func IsUserDefinedModel(modelInfo *registry.ModelInfo) bool { // - body: Original request body JSON // - model: Model name, optionally with thinking suffix (e.g., "claude-sonnet-4-5(16384)") // - fromFormat: Source request format (e.g., openai, codex, gemini) -// - toFormat: Target provider format for the request body (gemini, gemini-cli, antigravity, claude, openai, codex, iflow) +// - toFormat: Target provider format for the request body (gemini, gemini-cli, antigravity, claude, openai, codex, kimi) // - providerKey: Provider identifier used for registry model lookups (may differ from toFormat, e.g., openrouter -> openai) // // Returns: @@ -327,12 +326,6 @@ func extractThinkingConfig(body []byte, provider string) ThinkingConfig { return extractOpenAIConfig(body) case "codex": return extractCodexConfig(body) - case "iflow": - config := extractIFlowConfig(body) - if hasThinkingConfig(config) { - return config - } - return extractOpenAIConfig(body) case "kimi": // Kimi uses OpenAI-compatible reasoning_effort format return extractOpenAIConfig(body) @@ -494,34 +487,3 @@ func extractCodexConfig(body []byte) ThinkingConfig { return ThinkingConfig{} } - -// extractIFlowConfig extracts thinking configuration from iFlow format request body. -// -// iFlow API format (supports multiple model families): -// - GLM format: chat_template_kwargs.enable_thinking (boolean) -// - MiniMax format: reasoning_split (boolean) -// -// Returns ModeBudget with Budget=1 as a sentinel value indicating "enabled". -// The actual budget/configuration is determined by the iFlow applier based on model capabilities. -// Budget=1 is used because iFlow models don't use numeric budgets; they only support on/off. -func extractIFlowConfig(body []byte) ThinkingConfig { - // GLM format: chat_template_kwargs.enable_thinking - if enabled := gjson.GetBytes(body, "chat_template_kwargs.enable_thinking"); enabled.Exists() { - if enabled.Bool() { - // Budget=1 is a sentinel meaning "enabled" (iFlow doesn't use numeric budgets) - return ThinkingConfig{Mode: ModeBudget, Budget: 1} - } - return ThinkingConfig{Mode: ModeNone, Budget: 0} - } - - // MiniMax format: reasoning_split - if split := gjson.GetBytes(body, "reasoning_split"); split.Exists() { - if split.Bool() { - // Budget=1 is a sentinel meaning "enabled" (iFlow doesn't use numeric budgets) - return ThinkingConfig{Mode: ModeBudget, Budget: 1} - } - return ThinkingConfig{Mode: ModeNone, Budget: 0} - } - - return ThinkingConfig{} -} diff --git a/internal/thinking/convert.go b/internal/thinking/convert.go index 89db77457..b22a0879e 100644 --- a/internal/thinking/convert.go +++ b/internal/thinking/convert.go @@ -155,7 +155,7 @@ const ( // It analyzes the model's ThinkingSupport configuration to classify the model: // - CapabilityNone: modelInfo.Thinking is nil (model doesn't support thinking) // - CapabilityBudgetOnly: Has Min/Max but no Levels (Claude, Gemini 2.5) -// - CapabilityLevelOnly: Has Levels but no Min/Max (OpenAI, iFlow) +// - CapabilityLevelOnly: Has Levels but no Min/Max (OpenAI, Codex, Kimi) // - CapabilityHybrid: Has both Min/Max and Levels (Gemini 3) // // Note: Returns a special sentinel value when modelInfo itself is nil (unknown model). diff --git a/internal/thinking/provider/iflow/apply.go b/internal/thinking/provider/iflow/apply.go deleted file mode 100644 index 082cacffe..000000000 --- a/internal/thinking/provider/iflow/apply.go +++ /dev/null @@ -1,173 +0,0 @@ -// Package iflow implements thinking configuration for iFlow models. -// -// iFlow models use boolean toggle semantics: -// - Models using chat_template_kwargs.enable_thinking (boolean toggle) -// - MiniMax models: reasoning_split (boolean) -// -// Level values are converted to boolean: none=false, all others=true -// See: _bmad-output/planning-artifacts/architecture.md#Epic-9 -package iflow - -import ( - "strings" - - "github.com/router-for-me/CLIProxyAPI/v6/internal/registry" - "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking" - "github.com/tidwall/gjson" - "github.com/tidwall/sjson" -) - -// Applier implements thinking.ProviderApplier for iFlow models. -// -// iFlow-specific behavior: -// - enable_thinking toggle models: enable_thinking boolean -// - GLM models: enable_thinking boolean + clear_thinking=false -// - MiniMax models: reasoning_split boolean -// - Level to boolean: none=false, others=true -// - No quantized support (only on/off) -type Applier struct{} - -var _ thinking.ProviderApplier = (*Applier)(nil) - -// NewApplier creates a new iFlow thinking applier. -func NewApplier() *Applier { - return &Applier{} -} - -func init() { - thinking.RegisterProvider("iflow", NewApplier()) -} - -// Apply applies thinking configuration to iFlow request body. -// -// Expected output format (GLM): -// -// { -// "chat_template_kwargs": { -// "enable_thinking": true, -// "clear_thinking": false -// } -// } -// -// Expected output format (MiniMax): -// -// { -// "reasoning_split": true -// } -func (a *Applier) Apply(body []byte, config thinking.ThinkingConfig, modelInfo *registry.ModelInfo) ([]byte, error) { - if thinking.IsUserDefinedModel(modelInfo) { - return body, nil - } - if modelInfo.Thinking == nil { - return body, nil - } - - if isEnableThinkingModel(modelInfo.ID) { - return applyEnableThinking(body, config, isGLMModel(modelInfo.ID)), nil - } - - if isMiniMaxModel(modelInfo.ID) { - return applyMiniMax(body, config), nil - } - - return body, nil -} - -// configToBoolean converts ThinkingConfig to boolean for iFlow models. -// -// Conversion rules: -// - ModeNone: false -// - ModeAuto: true -// - ModeBudget + Budget=0: false -// - ModeBudget + Budget>0: true -// - ModeLevel + Level="none": false -// - ModeLevel + any other level: true -// - Default (unknown mode): true -func configToBoolean(config thinking.ThinkingConfig) bool { - switch config.Mode { - case thinking.ModeNone: - return false - case thinking.ModeAuto: - return true - case thinking.ModeBudget: - return config.Budget > 0 - case thinking.ModeLevel: - return config.Level != thinking.LevelNone - default: - return true - } -} - -// applyEnableThinking applies thinking configuration for models that use -// chat_template_kwargs.enable_thinking format. -// -// Output format when enabled: -// -// {"chat_template_kwargs": {"enable_thinking": true, "clear_thinking": false}} -// -// Output format when disabled: -// -// {"chat_template_kwargs": {"enable_thinking": false}} -// -// Note: clear_thinking is only set for GLM models when thinking is enabled. -func applyEnableThinking(body []byte, config thinking.ThinkingConfig, setClearThinking bool) []byte { - enableThinking := configToBoolean(config) - - if len(body) == 0 || !gjson.ValidBytes(body) { - body = []byte(`{}`) - } - - result, _ := sjson.SetBytes(body, "chat_template_kwargs.enable_thinking", enableThinking) - - // clear_thinking is a GLM-only knob, strip it for other models. - result, _ = sjson.DeleteBytes(result, "chat_template_kwargs.clear_thinking") - - // clear_thinking only needed when thinking is enabled - if enableThinking && setClearThinking { - result, _ = sjson.SetBytes(result, "chat_template_kwargs.clear_thinking", false) - } - - return result -} - -// applyMiniMax applies thinking configuration for MiniMax models. -// -// Output format: -// -// {"reasoning_split": true/false} -func applyMiniMax(body []byte, config thinking.ThinkingConfig) []byte { - reasoningSplit := configToBoolean(config) - - if len(body) == 0 || !gjson.ValidBytes(body) { - body = []byte(`{}`) - } - - result, _ := sjson.SetBytes(body, "reasoning_split", reasoningSplit) - - return result -} - -// isEnableThinkingModel determines if the model uses chat_template_kwargs.enable_thinking format. -func isEnableThinkingModel(modelID string) bool { - if isGLMModel(modelID) { - return true - } - id := strings.ToLower(modelID) - switch id { - case "deepseek-v3.2", "deepseek-v3.1": - return true - default: - return false - } -} - -// isGLMModel determines if the model is a GLM series model. -func isGLMModel(modelID string) bool { - return strings.HasPrefix(strings.ToLower(modelID), "glm") -} - -// isMiniMaxModel determines if the model is a MiniMax series model. -// MiniMax models use reasoning_split format. -func isMiniMaxModel(modelID string) bool { - return strings.HasPrefix(strings.ToLower(modelID), "minimax") -} diff --git a/internal/thinking/strip.go b/internal/thinking/strip.go index 85498c010..1e1712d19 100644 --- a/internal/thinking/strip.go +++ b/internal/thinking/strip.go @@ -44,13 +44,6 @@ func StripThinkingConfig(body []byte, provider string) []byte { } case "codex": paths = []string{"reasoning.effort"} - case "iflow": - paths = []string{ - "chat_template_kwargs.enable_thinking", - "chat_template_kwargs.clear_thinking", - "reasoning_split", - "reasoning_effort", - } default: return body } diff --git a/internal/thinking/types.go b/internal/thinking/types.go index 5e45fc6b1..a31d79819 100644 --- a/internal/thinking/types.go +++ b/internal/thinking/types.go @@ -1,7 +1,7 @@ // Package thinking provides unified thinking configuration processing. // // This package offers a unified interface for parsing, validating, and applying -// thinking configurations across various AI providers (Claude, Gemini, OpenAI, iFlow). +// thinking configurations across various AI providers (Claude, Gemini, OpenAI, Codex, Antigravity, Kimi). package thinking import "github.com/router-for-me/CLIProxyAPI/v6/internal/registry" diff --git a/internal/tui/oauth_tab.go b/internal/tui/oauth_tab.go index 1df045acd..bed17e4fa 100644 --- a/internal/tui/oauth_tab.go +++ b/internal/tui/oauth_tab.go @@ -24,7 +24,6 @@ var oauthProviders = []oauthProvider{ {"Codex (OpenAI)", "codex-auth-url", "🟩"}, {"Antigravity", "antigravity-auth-url", "🟪"}, {"Kimi", "kimi-auth-url", "🟫"}, - {"IFlow", "iflow-auth-url", "⬜"}, } // oauthTabModel handles OAuth login flows. @@ -281,8 +280,6 @@ func (m oauthTabModel) submitCallback(callbackURL string) tea.Cmd { providerKey = "antigravity" case "kimi-auth-url": providerKey = "kimi" - case "iflow-auth-url": - providerKey = "iflow" } break } diff --git a/sdk/api/management.go b/sdk/api/management.go index b1a7f8e36..a5a1cfc49 100644 --- a/sdk/api/management.go +++ b/sdk/api/management.go @@ -18,8 +18,6 @@ type ManagementTokenRequester interface { RequestCodexToken(*gin.Context) RequestAntigravityToken(*gin.Context) RequestKimiToken(*gin.Context) - RequestIFlowToken(*gin.Context) - RequestIFlowCookieToken(*gin.Context) GetAuthStatus(c *gin.Context) PostOAuthCallback(c *gin.Context) } @@ -55,14 +53,6 @@ func (m *managementTokenRequester) RequestKimiToken(c *gin.Context) { m.handler.RequestKimiToken(c) } -func (m *managementTokenRequester) RequestIFlowToken(c *gin.Context) { - m.handler.RequestIFlowToken(c) -} - -func (m *managementTokenRequester) RequestIFlowCookieToken(c *gin.Context) { - m.handler.RequestIFlowCookieToken(c) -} - func (m *managementTokenRequester) GetAuthStatus(c *gin.Context) { m.handler.GetAuthStatus(c) } diff --git a/sdk/auth/iflow.go b/sdk/auth/iflow.go deleted file mode 100644 index 584a31696..000000000 --- a/sdk/auth/iflow.go +++ /dev/null @@ -1,196 +0,0 @@ -package auth - -import ( - "context" - "fmt" - "strings" - "time" - - "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/iflow" - "github.com/router-for-me/CLIProxyAPI/v6/internal/browser" - "github.com/router-for-me/CLIProxyAPI/v6/internal/config" - "github.com/router-for-me/CLIProxyAPI/v6/internal/misc" - "github.com/router-for-me/CLIProxyAPI/v6/internal/util" - coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" - log "github.com/sirupsen/logrus" -) - -// IFlowAuthenticator implements the OAuth login flow for iFlow accounts. -type IFlowAuthenticator struct{} - -// NewIFlowAuthenticator constructs a new authenticator instance. -func NewIFlowAuthenticator() *IFlowAuthenticator { return &IFlowAuthenticator{} } - -// Provider returns the provider key for the authenticator. -func (a *IFlowAuthenticator) Provider() string { return "iflow" } - -// RefreshLead indicates how soon before expiry a refresh should be attempted. -func (a *IFlowAuthenticator) RefreshLead() *time.Duration { - return new(24 * time.Hour) -} - -// Login performs the OAuth code flow using a local callback server. -func (a *IFlowAuthenticator) Login(ctx context.Context, cfg *config.Config, opts *LoginOptions) (*coreauth.Auth, error) { - if cfg == nil { - return nil, fmt.Errorf("cliproxy auth: configuration is required") - } - if ctx == nil { - ctx = context.Background() - } - if opts == nil { - opts = &LoginOptions{} - } - - callbackPort := iflow.CallbackPort - if opts.CallbackPort > 0 { - callbackPort = opts.CallbackPort - } - - authSvc := iflow.NewIFlowAuth(cfg) - - oauthServer := iflow.NewOAuthServer(callbackPort) - if err := oauthServer.Start(); err != nil { - if strings.Contains(err.Error(), "already in use") { - return nil, fmt.Errorf("iflow authentication server port in use: %w", err) - } - return nil, fmt.Errorf("iflow authentication server failed: %w", err) - } - defer func() { - stopCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second) - defer cancel() - if stopErr := oauthServer.Stop(stopCtx); stopErr != nil { - log.Warnf("iflow oauth server stop error: %v", stopErr) - } - }() - - state, err := misc.GenerateRandomState() - if err != nil { - return nil, fmt.Errorf("iflow auth: failed to generate state: %w", err) - } - - authURL, redirectURI := authSvc.AuthorizationURL(state, callbackPort) - - if !opts.NoBrowser { - fmt.Println("Opening browser for iFlow authentication") - if !browser.IsAvailable() { - log.Warn("No browser available; please open the URL manually") - util.PrintSSHTunnelInstructions(callbackPort) - fmt.Printf("Visit the following URL to continue authentication:\n%s\n", authURL) - } else if err = browser.OpenURL(authURL); err != nil { - log.Warnf("Failed to open browser automatically: %v", err) - util.PrintSSHTunnelInstructions(callbackPort) - fmt.Printf("Visit the following URL to continue authentication:\n%s\n", authURL) - } - } else { - util.PrintSSHTunnelInstructions(callbackPort) - fmt.Printf("Visit the following URL to continue authentication:\n%s\n", authURL) - } - - fmt.Println("Waiting for iFlow authentication callback...") - - callbackCh := make(chan *iflow.OAuthResult, 1) - callbackErrCh := make(chan error, 1) - - go func() { - result, errWait := oauthServer.WaitForCallback(5 * time.Minute) - if errWait != nil { - callbackErrCh <- errWait - return - } - callbackCh <- result - }() - - var result *iflow.OAuthResult - var manualPromptTimer *time.Timer - var manualPromptC <-chan time.Time - if opts.Prompt != nil { - manualPromptTimer = time.NewTimer(15 * time.Second) - manualPromptC = manualPromptTimer.C - defer manualPromptTimer.Stop() - } - - var manualInputCh <-chan string - var manualInputErrCh <-chan error - -waitForCallback: - for { - select { - case result = <-callbackCh: - break waitForCallback - case err = <-callbackErrCh: - return nil, fmt.Errorf("iflow auth: callback wait failed: %w", err) - case <-manualPromptC: - manualPromptC = nil - if manualPromptTimer != nil { - manualPromptTimer.Stop() - } - select { - case result = <-callbackCh: - break waitForCallback - case err = <-callbackErrCh: - return nil, fmt.Errorf("iflow auth: callback wait failed: %w", err) - default: - } - manualInputCh, manualInputErrCh = misc.AsyncPrompt(opts.Prompt, "Paste the iFlow callback URL (or press Enter to keep waiting): ") - continue - case input := <-manualInputCh: - manualInputCh = nil - manualInputErrCh = nil - parsed, errParse := misc.ParseOAuthCallback(input) - if errParse != nil { - return nil, errParse - } - if parsed == nil { - continue - } - result = &iflow.OAuthResult{ - Code: parsed.Code, - State: parsed.State, - Error: parsed.Error, - } - break waitForCallback - case errManual := <-manualInputErrCh: - return nil, errManual - } - } - if result.Error != "" { - return nil, fmt.Errorf("iflow auth: provider returned error %s", result.Error) - } - if result.State != state { - return nil, fmt.Errorf("iflow auth: state mismatch") - } - - tokenData, err := authSvc.ExchangeCodeForTokens(ctx, result.Code, redirectURI) - if err != nil { - return nil, fmt.Errorf("iflow authentication failed: %w", err) - } - - tokenStorage := authSvc.CreateTokenStorage(tokenData) - - email := strings.TrimSpace(tokenStorage.Email) - if email == "" { - return nil, fmt.Errorf("iflow authentication failed: missing account identifier") - } - - fileName := fmt.Sprintf("iflow-%s-%d.json", email, time.Now().Unix()) - metadata := map[string]any{ - "email": email, - "api_key": tokenStorage.APIKey, - "access_token": tokenStorage.AccessToken, - "refresh_token": tokenStorage.RefreshToken, - "expired": tokenStorage.Expire, - } - - fmt.Println("iFlow authentication successful") - - return &coreauth.Auth{ - ID: fileName, - Provider: a.Provider(), - FileName: fileName, - Storage: tokenStorage, - Metadata: metadata, - Attributes: map[string]string{ - "api_key": tokenStorage.APIKey, - }, - }, nil -} diff --git a/sdk/auth/refresh_registry.go b/sdk/auth/refresh_registry.go index 59ffb0e1a..ae60f56a6 100644 --- a/sdk/auth/refresh_registry.go +++ b/sdk/auth/refresh_registry.go @@ -9,7 +9,6 @@ import ( func init() { registerRefreshLead("codex", func() Authenticator { return NewCodexAuthenticator() }) registerRefreshLead("claude", func() Authenticator { return NewClaudeAuthenticator() }) - registerRefreshLead("iflow", func() Authenticator { return NewIFlowAuthenticator() }) registerRefreshLead("gemini", func() Authenticator { return NewGeminiAuthenticator() }) registerRefreshLead("gemini-cli", func() Authenticator { return NewGeminiAuthenticator() }) registerRefreshLead("antigravity", func() Authenticator { return NewAntigravityAuthenticator() }) diff --git a/sdk/cliproxy/auth/conductor_overrides_test.go b/sdk/cliproxy/auth/conductor_overrides_test.go index 0adc83a6c..f74621bec 100644 --- a/sdk/cliproxy/auth/conductor_overrides_test.go +++ b/sdk/cliproxy/auth/conductor_overrides_test.go @@ -69,7 +69,7 @@ func TestManager_ShouldRetryAfterError_UsesOAuthModelAliasForCooldown(t *testing m := NewManager(nil, nil, nil) m.SetRetryConfig(3, 30*time.Second, 0) m.SetOAuthModelAlias(map[string][]internalconfig.OAuthModelAlias{ - "iflow": { + "kimi": { {Name: "deepseek-v3.1", Alias: "pool-model"}, }, }) @@ -80,7 +80,7 @@ func TestManager_ShouldRetryAfterError_UsesOAuthModelAliasForCooldown(t *testing auth := &Auth{ ID: "auth-1", - Provider: "iflow", + Provider: "kimi", ModelStates: map[string]*ModelState{ upstreamModel: { Unavailable: true, @@ -99,7 +99,7 @@ func TestManager_ShouldRetryAfterError_UsesOAuthModelAliasForCooldown(t *testing } _, _, maxWait := m.retrySettings() - wait, shouldRetry := m.shouldRetryAfterError(&Error{HTTPStatus: 429, Message: "quota"}, 0, []string{"iflow"}, routeModel, maxWait) + wait, shouldRetry := m.shouldRetryAfterError(&Error{HTTPStatus: 429, Message: "quota"}, 0, []string{"kimi"}, routeModel, maxWait) if !shouldRetry { t.Fatalf("expected shouldRetry=true, got false (wait=%v)", wait) } diff --git a/sdk/cliproxy/auth/oauth_model_alias.go b/sdk/cliproxy/auth/oauth_model_alias.go index 3b9b6bd45..46c82a9c5 100644 --- a/sdk/cliproxy/auth/oauth_model_alias.go +++ b/sdk/cliproxy/auth/oauth_model_alias.go @@ -265,7 +265,7 @@ func modelAliasChannel(auth *Auth) string { // and auth kind. Returns empty string if the provider/authKind combination doesn't support // OAuth model alias (e.g., API key authentication). // -// Supported channels: gemini-cli, vertex, aistudio, antigravity, claude, codex, iflow, kimi. +// Supported channels: gemini-cli, vertex, aistudio, antigravity, claude, codex, kimi. func OAuthModelAliasChannel(provider, authKind string) string { provider = strings.ToLower(strings.TrimSpace(provider)) authKind = strings.ToLower(strings.TrimSpace(authKind)) @@ -289,7 +289,7 @@ func OAuthModelAliasChannel(provider, authKind string) string { return "" } return "codex" - case "gemini-cli", "aistudio", "antigravity", "iflow", "kimi": + case "gemini-cli", "aistudio", "antigravity", "kimi": return provider default: return "" diff --git a/sdk/cliproxy/auth/oauth_model_alias_test.go b/sdk/cliproxy/auth/oauth_model_alias_test.go index 49defdb99..73ddbe675 100644 --- a/sdk/cliproxy/auth/oauth_model_alias_test.go +++ b/sdk/cliproxy/auth/oauth_model_alias_test.go @@ -157,8 +157,6 @@ func createAuthForChannel(channel string) *Auth { return &Auth{Provider: "aistudio"} case "antigravity": return &Auth{Provider: "antigravity"} - case "iflow": - return &Auth{Provider: "iflow"} case "kimi": return &Auth{Provider: "kimi"} default: diff --git a/sdk/cliproxy/auth/types.go b/sdk/cliproxy/auth/types.go index 8390b051c..f30f4dc01 100644 --- a/sdk/cliproxy/auth/types.go +++ b/sdk/cliproxy/auth/types.go @@ -406,18 +406,6 @@ func (a *Auth) AccountInfo() (string, string) { } } - // For iFlow provider, prioritize OAuth type if email is present - if strings.ToLower(a.Provider) == "iflow" { - if a.Metadata != nil { - if email, ok := a.Metadata["email"].(string); ok { - email = strings.TrimSpace(email) - if email != "" { - return "oauth", email - } - } - } - } - // Check metadata for email first (OAuth-style auth) if a.Metadata != nil { if v, ok := a.Metadata["email"].(string); ok { diff --git a/sdk/cliproxy/service.go b/sdk/cliproxy/service.go index 3471994e0..5e873d370 100644 --- a/sdk/cliproxy/service.go +++ b/sdk/cliproxy/service.go @@ -392,7 +392,7 @@ func (s *Service) ensureExecutorsForAuthWithMode(a *coreauth.Auth, forceReplace } // Skip disabled auth entries when (re)binding executors. // Disabled auths can linger during config reloads (e.g., removed OpenAI-compat entries) - // and must not override active provider executors (such as iFlow OAuth accounts). + // and must not override active provider executors. if a.Disabled { return } @@ -422,8 +422,6 @@ func (s *Service) ensureExecutorsForAuthWithMode(a *coreauth.Auth, forceReplace s.coreManager.RegisterExecutor(executor.NewAntigravityExecutor(s.cfg)) case "claude": s.coreManager.RegisterExecutor(executor.NewClaudeExecutor(s.cfg)) - case "iflow": - s.coreManager.RegisterExecutor(executor.NewIFlowExecutor(s.cfg)) case "kimi": s.coreManager.RegisterExecutor(executor.NewKimiExecutor(s.cfg)) default: @@ -926,9 +924,6 @@ func (s *Service) registerModelsForAuth(a *coreauth.Auth) { } } models = applyExcludedModels(models, excluded) - case "iflow": - models = registry.GetIFlowModels() - models = applyExcludedModels(models, excluded) case "kimi": models = registry.GetKimiModels() models = applyExcludedModels(models, excluded) diff --git a/test/thinking_conversion_test.go b/test/thinking_conversion_test.go index 7d9b7b867..c6ade7b2a 100644 --- a/test/thinking_conversion_test.go +++ b/test/thinking_conversion_test.go @@ -14,7 +14,6 @@ import ( _ "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking/provider/codex" _ "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking/provider/gemini" _ "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking/provider/geminicli" - _ "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking/provider/iflow" _ "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking/provider/kimi" _ "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking/provider/openai" @@ -1067,184 +1066,6 @@ func TestThinkingE2EMatrix_Suffix(t *testing.T) { expectErr: false, }, - // iflow tests: glm-test and minimax-test (Cases 90-105) - - // glm-test (from: openai, claude) - // Case 90: OpenAI to iflow, no suffix → passthrough - { - name: "90", - from: "openai", - to: "iflow", - model: "glm-test", - inputJSON: `{"model":"glm-test","messages":[{"role":"user","content":"hi"}]}`, - expectField: "", - expectErr: false, - }, - // Case 91: OpenAI to iflow, (medium) → enable_thinking=true - { - name: "91", - from: "openai", - to: "iflow", - model: "glm-test(medium)", - inputJSON: `{"model":"glm-test(medium)","messages":[{"role":"user","content":"hi"}]}`, - expectField: "chat_template_kwargs.enable_thinking", - expectValue: "true", - expectErr: false, - }, - // Case 92: OpenAI to iflow, (auto) → enable_thinking=true - { - name: "92", - from: "openai", - to: "iflow", - model: "glm-test(auto)", - inputJSON: `{"model":"glm-test(auto)","messages":[{"role":"user","content":"hi"}]}`, - expectField: "chat_template_kwargs.enable_thinking", - expectValue: "true", - expectErr: false, - }, - // Case 93: OpenAI to iflow, (none) → enable_thinking=false - { - name: "93", - from: "openai", - to: "iflow", - model: "glm-test(none)", - inputJSON: `{"model":"glm-test(none)","messages":[{"role":"user","content":"hi"}]}`, - expectField: "chat_template_kwargs.enable_thinking", - expectValue: "false", - expectErr: false, - }, - // Case 94: Claude to iflow, no suffix → passthrough - { - name: "94", - from: "claude", - to: "iflow", - model: "glm-test", - inputJSON: `{"model":"glm-test","messages":[{"role":"user","content":"hi"}]}`, - expectField: "", - expectErr: false, - }, - // Case 95: Claude to iflow, (8192) → enable_thinking=true - { - name: "95", - from: "claude", - to: "iflow", - model: "glm-test(8192)", - inputJSON: `{"model":"glm-test(8192)","messages":[{"role":"user","content":"hi"}]}`, - expectField: "chat_template_kwargs.enable_thinking", - expectValue: "true", - expectErr: false, - }, - // Case 96: Claude to iflow, (-1) → enable_thinking=true - { - name: "96", - from: "claude", - to: "iflow", - model: "glm-test(-1)", - inputJSON: `{"model":"glm-test(-1)","messages":[{"role":"user","content":"hi"}]}`, - expectField: "chat_template_kwargs.enable_thinking", - expectValue: "true", - expectErr: false, - }, - // Case 97: Claude to iflow, (0) → enable_thinking=false - { - name: "97", - from: "claude", - to: "iflow", - model: "glm-test(0)", - inputJSON: `{"model":"glm-test(0)","messages":[{"role":"user","content":"hi"}]}`, - expectField: "chat_template_kwargs.enable_thinking", - expectValue: "false", - expectErr: false, - }, - - // minimax-test (from: openai, gemini) - // Case 98: OpenAI to iflow, no suffix → passthrough - { - name: "98", - from: "openai", - to: "iflow", - model: "minimax-test", - inputJSON: `{"model":"minimax-test","messages":[{"role":"user","content":"hi"}]}`, - expectField: "", - expectErr: false, - }, - // Case 99: OpenAI to iflow, (medium) → reasoning_split=true - { - name: "99", - from: "openai", - to: "iflow", - model: "minimax-test(medium)", - inputJSON: `{"model":"minimax-test(medium)","messages":[{"role":"user","content":"hi"}]}`, - expectField: "reasoning_split", - expectValue: "true", - expectErr: false, - }, - // Case 100: OpenAI to iflow, (auto) → reasoning_split=true - { - name: "100", - from: "openai", - to: "iflow", - model: "minimax-test(auto)", - inputJSON: `{"model":"minimax-test(auto)","messages":[{"role":"user","content":"hi"}]}`, - expectField: "reasoning_split", - expectValue: "true", - expectErr: false, - }, - // Case 101: OpenAI to iflow, (none) → reasoning_split=false - { - name: "101", - from: "openai", - to: "iflow", - model: "minimax-test(none)", - inputJSON: `{"model":"minimax-test(none)","messages":[{"role":"user","content":"hi"}]}`, - expectField: "reasoning_split", - expectValue: "false", - expectErr: false, - }, - // Case 102: Gemini to iflow, no suffix → passthrough - { - name: "102", - from: "gemini", - to: "iflow", - model: "minimax-test", - inputJSON: `{"model":"minimax-test","contents":[{"role":"user","parts":[{"text":"hi"}]}]}`, - expectField: "", - expectErr: false, - }, - // Case 103: Gemini to iflow, (8192) → reasoning_split=true - { - name: "103", - from: "gemini", - to: "iflow", - model: "minimax-test(8192)", - inputJSON: `{"model":"minimax-test(8192)","contents":[{"role":"user","parts":[{"text":"hi"}]}]}`, - expectField: "reasoning_split", - expectValue: "true", - expectErr: false, - }, - // Case 104: Gemini to iflow, (-1) → reasoning_split=true - { - name: "104", - from: "gemini", - to: "iflow", - model: "minimax-test(-1)", - inputJSON: `{"model":"minimax-test(-1)","contents":[{"role":"user","parts":[{"text":"hi"}]}]}`, - expectField: "reasoning_split", - expectValue: "true", - expectErr: false, - }, - // Case 105: Gemini to iflow, (0) → reasoning_split=false - { - name: "105", - from: "gemini", - to: "iflow", - model: "minimax-test(0)", - inputJSON: `{"model":"minimax-test(0)","contents":[{"role":"user","parts":[{"text":"hi"}]}]}`, - expectField: "reasoning_split", - expectValue: "false", - expectErr: false, - }, - // Gemini Family Cross-Channel Consistency (Cases 106-114) // Tests that gemini/gemini-cli/antigravity as same API family should have consistent validation behavior @@ -2346,184 +2167,6 @@ func TestThinkingE2EMatrix_Body(t *testing.T) { expectErr: true, }, - // iflow tests: glm-test and minimax-test (Cases 90-105) - - // glm-test (from: openai, claude) - // Case 90: OpenAI to iflow, no param → passthrough - { - name: "90", - from: "openai", - to: "iflow", - model: "glm-test", - inputJSON: `{"model":"glm-test","messages":[{"role":"user","content":"hi"}]}`, - expectField: "", - expectErr: false, - }, - // Case 91: OpenAI to iflow, reasoning_effort=medium → enable_thinking=true - { - name: "91", - from: "openai", - to: "iflow", - model: "glm-test", - inputJSON: `{"model":"glm-test","messages":[{"role":"user","content":"hi"}],"reasoning_effort":"medium"}`, - expectField: "chat_template_kwargs.enable_thinking", - expectValue: "true", - expectErr: false, - }, - // Case 92: OpenAI to iflow, reasoning_effort=auto → enable_thinking=true - { - name: "92", - from: "openai", - to: "iflow", - model: "glm-test", - inputJSON: `{"model":"glm-test","messages":[{"role":"user","content":"hi"}],"reasoning_effort":"auto"}`, - expectField: "chat_template_kwargs.enable_thinking", - expectValue: "true", - expectErr: false, - }, - // Case 93: OpenAI to iflow, reasoning_effort=none → enable_thinking=false - { - name: "93", - from: "openai", - to: "iflow", - model: "glm-test", - inputJSON: `{"model":"glm-test","messages":[{"role":"user","content":"hi"}],"reasoning_effort":"none"}`, - expectField: "chat_template_kwargs.enable_thinking", - expectValue: "false", - expectErr: false, - }, - // Case 94: Claude to iflow, no param → passthrough - { - name: "94", - from: "claude", - to: "iflow", - model: "glm-test", - inputJSON: `{"model":"glm-test","messages":[{"role":"user","content":"hi"}]}`, - expectField: "", - expectErr: false, - }, - // Case 95: Claude to iflow, thinking.budget_tokens=8192 → enable_thinking=true - { - name: "95", - from: "claude", - to: "iflow", - model: "glm-test", - inputJSON: `{"model":"glm-test","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"enabled","budget_tokens":8192}}`, - expectField: "chat_template_kwargs.enable_thinking", - expectValue: "true", - expectErr: false, - }, - // Case 96: Claude to iflow, thinking.budget_tokens=-1 → enable_thinking=true - { - name: "96", - from: "claude", - to: "iflow", - model: "glm-test", - inputJSON: `{"model":"glm-test","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"enabled","budget_tokens":-1}}`, - expectField: "chat_template_kwargs.enable_thinking", - expectValue: "true", - expectErr: false, - }, - // Case 97: Claude to iflow, thinking.budget_tokens=0 → enable_thinking=false - { - name: "97", - from: "claude", - to: "iflow", - model: "glm-test", - inputJSON: `{"model":"glm-test","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"enabled","budget_tokens":0}}`, - expectField: "chat_template_kwargs.enable_thinking", - expectValue: "false", - expectErr: false, - }, - - // minimax-test (from: openai, gemini) - // Case 98: OpenAI to iflow, no param → passthrough - { - name: "98", - from: "openai", - to: "iflow", - model: "minimax-test", - inputJSON: `{"model":"minimax-test","messages":[{"role":"user","content":"hi"}]}`, - expectField: "", - expectErr: false, - }, - // Case 99: OpenAI to iflow, reasoning_effort=medium → reasoning_split=true - { - name: "99", - from: "openai", - to: "iflow", - model: "minimax-test", - inputJSON: `{"model":"minimax-test","messages":[{"role":"user","content":"hi"}],"reasoning_effort":"medium"}`, - expectField: "reasoning_split", - expectValue: "true", - expectErr: false, - }, - // Case 100: OpenAI to iflow, reasoning_effort=auto → reasoning_split=true - { - name: "100", - from: "openai", - to: "iflow", - model: "minimax-test", - inputJSON: `{"model":"minimax-test","messages":[{"role":"user","content":"hi"}],"reasoning_effort":"auto"}`, - expectField: "reasoning_split", - expectValue: "true", - expectErr: false, - }, - // Case 101: OpenAI to iflow, reasoning_effort=none → reasoning_split=false - { - name: "101", - from: "openai", - to: "iflow", - model: "minimax-test", - inputJSON: `{"model":"minimax-test","messages":[{"role":"user","content":"hi"}],"reasoning_effort":"none"}`, - expectField: "reasoning_split", - expectValue: "false", - expectErr: false, - }, - // Case 102: Gemini to iflow, no param → passthrough - { - name: "102", - from: "gemini", - to: "iflow", - model: "minimax-test", - inputJSON: `{"model":"minimax-test","contents":[{"role":"user","parts":[{"text":"hi"}]}]}`, - expectField: "", - expectErr: false, - }, - // Case 103: Gemini to iflow, thinkingBudget=8192 → reasoning_split=true - { - name: "103", - from: "gemini", - to: "iflow", - model: "minimax-test", - inputJSON: `{"model":"minimax-test","contents":[{"role":"user","parts":[{"text":"hi"}]}],"generationConfig":{"thinkingConfig":{"thinkingBudget":8192}}}`, - expectField: "reasoning_split", - expectValue: "true", - expectErr: false, - }, - // Case 104: Gemini to iflow, thinkingBudget=-1 → reasoning_split=true - { - name: "104", - from: "gemini", - to: "iflow", - model: "minimax-test", - inputJSON: `{"model":"minimax-test","contents":[{"role":"user","parts":[{"text":"hi"}]}],"generationConfig":{"thinkingConfig":{"thinkingBudget":-1}}}`, - expectField: "reasoning_split", - expectValue: "true", - expectErr: false, - }, - // Case 105: Gemini to iflow, thinkingBudget=0 → reasoning_split=false - { - name: "105", - from: "gemini", - to: "iflow", - model: "minimax-test", - inputJSON: `{"model":"minimax-test","contents":[{"role":"user","parts":[{"text":"hi"}]}],"generationConfig":{"thinkingConfig":{"thinkingBudget":0}}}`, - expectField: "reasoning_split", - expectValue: "false", - expectErr: false, - }, - // Gemini Family Cross-Channel Consistency (Cases 106-114) // Tests that gemini/gemini-cli/antigravity as same API family should have consistent validation behavior @@ -3018,27 +2661,6 @@ func TestThinkingE2EClaudeAdaptive_Body(t *testing.T) { expectValue: "high", expectErr: false, }, - - { - name: "C19", - from: "claude", - to: "iflow", - model: "glm-test", - inputJSON: `{"model":"glm-test","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"adaptive"},"output_config":{"effort":"minimal"}}`, - expectField: "chat_template_kwargs.enable_thinking", - expectValue: "true", - expectErr: false, - }, - { - name: "C20", - from: "claude", - to: "iflow", - model: "minimax-test", - inputJSON: `{"model":"minimax-test","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"adaptive"},"output_config":{"effort":"high"}}`, - expectField: "reasoning_split", - expectValue: "true", - expectErr: false, - }, { name: "C21", from: "claude", @@ -3215,24 +2837,6 @@ func getTestModels() []*registry.ModelInfo { UserDefined: true, Thinking: nil, }, - { - ID: "glm-test", - Object: "model", - Created: 1700000000, - OwnedBy: "test", - Type: "iflow", - DisplayName: "GLM Test Model", - Thinking: ®istry.ThinkingSupport{Levels: []string{"none", "auto", "minimal", "low", "medium", "high", "xhigh"}}, - }, - { - ID: "minimax-test", - Object: "model", - Created: 1700000000, - OwnedBy: "test", - Type: "iflow", - DisplayName: "MiniMax Test Model", - Thinking: ®istry.ThinkingSupport{Levels: []string{"none", "auto", "minimal", "low", "medium", "high", "xhigh"}}, - }, } } @@ -3247,10 +2851,6 @@ func runThinkingTests(t *testing.T, cases []thinkingTestCase) { translateTo := tc.to applyTo := tc.to - if tc.to == "iflow" { - translateTo = "openai" - applyTo = "iflow" - } body := sdktranslator.TranslateRequest( sdktranslator.FromString(tc.from), @@ -3290,8 +2890,6 @@ func runThinkingTests(t *testing.T, cases []thinkingTestCase) { hasThinking = gjson.GetBytes(body, "reasoning_effort").Exists() case "codex": hasThinking = gjson.GetBytes(body, "reasoning.effort").Exists() || gjson.GetBytes(body, "reasoning").Exists() - case "iflow": - hasThinking = gjson.GetBytes(body, "chat_template_kwargs.enable_thinking").Exists() || gjson.GetBytes(body, "reasoning_split").Exists() } if hasThinking { t.Fatalf("expected no thinking field but found one, body=%s", string(body)) @@ -3332,23 +2930,6 @@ func runThinkingTests(t *testing.T, cases []thinkingTestCase) { t.Fatalf("includeThoughts: expected %s, got %s, body=%s", tc.includeThoughts, actual, string(body)) } } - - // Verify clear_thinking for iFlow GLM models when enable_thinking=true - if tc.to == "iflow" && tc.expectField == "chat_template_kwargs.enable_thinking" && tc.expectValue == "true" { - baseModel := thinking.ParseSuffix(tc.model).ModelName - isGLM := strings.HasPrefix(strings.ToLower(baseModel), "glm") - ctVal := gjson.GetBytes(body, "chat_template_kwargs.clear_thinking") - if isGLM { - if !ctVal.Exists() { - t.Fatalf("expected clear_thinking field not found for GLM model, body=%s", string(body)) - } - if ctVal.Bool() != false { - t.Fatalf("clear_thinking: expected false, got %v, body=%s", ctVal.Bool(), string(body)) - } - } else if ctVal.Exists() { - t.Fatalf("expected no clear_thinking field for non-GLM enable_thinking model, body=%s", string(body)) - } - } }) } }