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.
This commit is contained in:
Luis Pater
2026-04-17 01:07:12 +08:00
parent d949921143
commit f5dc6483d5
38 changed files with 22 additions and 3009 deletions

View File

@@ -50,18 +50,16 @@ Get 10% OFF GLM CODING PLANhttps://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)

View File

@@ -51,17 +51,15 @@ GLM CODING PLAN 是专为AI编码打造的订阅套餐每月最低仅需20元
- 为 CLI 模型提供 OpenAI/Gemini/Claude/Codex 兼容的 API 端点
- 新增 OpenAI CodexGPT 系列支持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)

View File

@@ -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)

View File

@@ -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 {

View File

@@ -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"

View File

@@ -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 {

View File

@@ -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:

View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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)
}
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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(),
)

View File

@@ -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())
}

View File

@@ -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!")
}

View File

@@ -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.

View File

@@ -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,
}

View File

@@ -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},
}

View File

@@ -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 @@
}
}
]
}
}

View File

@@ -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"
)

View File

@@ -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, &param)
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), &param)
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
}

View File

@@ -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)
}
})
}
}

View File

@@ -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{}
}

View File

@@ -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).

View File

@@ -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")
}

View File

@@ -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
}

View File

@@ -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"

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -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() })

View File

@@ -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)
}

View File

@@ -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 ""

View File

@@ -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:

View File

@@ -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 {

View File

@@ -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)

View File

@@ -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: &registry.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: &registry.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))
}
}
})
}
}