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:
@@ -50,18 +50,16 @@ Get 10% OFF GLM CODING PLAN:https://z.ai/subscribe?ic=8JVLJQFSKB
|
|||||||
- OpenAI/Gemini/Claude compatible API endpoints for CLI models
|
- OpenAI/Gemini/Claude compatible API endpoints for CLI models
|
||||||
- OpenAI Codex support (GPT models) via OAuth login
|
- OpenAI Codex support (GPT models) via OAuth login
|
||||||
- Claude Code support via OAuth login
|
- Claude Code support via OAuth login
|
||||||
- iFlow support via OAuth login
|
|
||||||
- Amp CLI and IDE extensions support with provider routing
|
- Amp CLI and IDE extensions support with provider routing
|
||||||
- Streaming and non-streaming responses
|
- Streaming and non-streaming responses
|
||||||
- Function calling/tools support
|
- Function calling/tools support
|
||||||
- Multimodal input support (text and images)
|
- Multimodal input support (text and images)
|
||||||
- Multiple accounts with round-robin load balancing (Gemini, OpenAI, Claude and iFlow)
|
- Multiple accounts with round-robin load balancing (Gemini, OpenAI, Claude)
|
||||||
- Simple CLI authentication flows (Gemini, OpenAI, Claude and iFlow)
|
- Simple CLI authentication flows (Gemini, OpenAI, Claude)
|
||||||
- Generative Language API Key support
|
- Generative Language API Key support
|
||||||
- AI Studio Build multi-account load balancing
|
- AI Studio Build multi-account load balancing
|
||||||
- Gemini CLI multi-account load balancing
|
- Gemini CLI multi-account load balancing
|
||||||
- Claude Code multi-account load balancing
|
- Claude Code multi-account load balancing
|
||||||
- iFlow multi-account load balancing
|
|
||||||
- OpenAI Codex multi-account load balancing
|
- OpenAI Codex multi-account load balancing
|
||||||
- OpenAI-compatible upstream providers via config (e.g., OpenRouter)
|
- OpenAI-compatible upstream providers via config (e.g., OpenRouter)
|
||||||
- Reusable Go SDK for embedding the proxy (see `docs/sdk-usage.md`)
|
- 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)
|
### [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)
|
### [CLIProxyAPI Quota Inspector](https://github.com/AllenReder/CLIProxyAPI-Quota-Inspector)
|
||||||
|
|
||||||
|
|||||||
+3
-5
@@ -51,17 +51,15 @@ GLM CODING PLAN 是专为AI编码打造的订阅套餐,每月最低仅需20元
|
|||||||
- 为 CLI 模型提供 OpenAI/Gemini/Claude/Codex 兼容的 API 端点
|
- 为 CLI 模型提供 OpenAI/Gemini/Claude/Codex 兼容的 API 端点
|
||||||
- 新增 OpenAI Codex(GPT 系列)支持(OAuth 登录)
|
- 新增 OpenAI Codex(GPT 系列)支持(OAuth 登录)
|
||||||
- 新增 Claude Code 支持(OAuth 登录)
|
- 新增 Claude Code 支持(OAuth 登录)
|
||||||
- 新增 iFlow 支持(OAuth 登录)
|
|
||||||
- 支持流式与非流式响应
|
- 支持流式与非流式响应
|
||||||
- 函数调用/工具支持
|
- 函数调用/工具支持
|
||||||
- 多模态输入(文本、图片)
|
- 多模态输入(文本、图片)
|
||||||
- 多账户支持与轮询负载均衡(Gemini、OpenAI、Claude 与 iFlow)
|
- 多账户支持与轮询负载均衡(Gemini、OpenAI、Claude)
|
||||||
- 简单的 CLI 身份验证流程(Gemini、OpenAI、Claude 与 iFlow)
|
- 简单的 CLI 身份验证流程(Gemini、OpenAI、Claude)
|
||||||
- 支持 Gemini AIStudio API 密钥
|
- 支持 Gemini AIStudio API 密钥
|
||||||
- 支持 AI Studio Build 多账户轮询
|
- 支持 AI Studio Build 多账户轮询
|
||||||
- 支持 Gemini CLI 多账户轮询
|
- 支持 Gemini CLI 多账户轮询
|
||||||
- 支持 Claude Code 多账户轮询
|
- 支持 Claude Code 多账户轮询
|
||||||
- 支持 iFlow 多账户轮询
|
|
||||||
- 支持 OpenAI Codex 多账户轮询
|
- 支持 OpenAI Codex 多账户轮询
|
||||||
- 通过配置接入上游 OpenAI 兼容提供商(例如 OpenRouter)
|
- 通过配置接入上游 OpenAI 兼容提供商(例如 OpenRouter)
|
||||||
- 可复用的 Go SDK(见 `docs/sdk-usage_CN.md`)
|
- 可复用的 Go SDK(见 `docs/sdk-usage_CN.md`)
|
||||||
@@ -173,7 +171,7 @@ Shadow AI 是一款专为受限环境设计的 AI 辅助工具。提供无窗口
|
|||||||
|
|
||||||
### [ProxyPal](https://github.com/buddingnewinsights/proxypal)
|
### [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)
|
### [CLIProxyAPI Quota Inspector](https://github.com/AllenReder/CLIProxyAPI-Quota-Inspector)
|
||||||
|
|
||||||
|
|||||||
+3
-5
@@ -50,18 +50,16 @@ GLM CODING PLANを10%割引で取得:https://z.ai/subscribe?ic=8JVLJQFSKB
|
|||||||
- CLIモデル向けのOpenAI/Gemini/Claude互換APIエンドポイント
|
- CLIモデル向けのOpenAI/Gemini/Claude互換APIエンドポイント
|
||||||
- OAuthログインによるOpenAI Codexサポート(GPTモデル)
|
- OAuthログインによるOpenAI Codexサポート(GPTモデル)
|
||||||
- OAuthログインによるClaude Codeサポート
|
- OAuthログインによるClaude Codeサポート
|
||||||
- OAuthログインによるiFlowサポート
|
|
||||||
- プロバイダールーティングによるAmp CLIおよびIDE拡張機能のサポート
|
- プロバイダールーティングによるAmp CLIおよびIDE拡張機能のサポート
|
||||||
- ストリーミングおよび非ストリーミングレスポンス
|
- ストリーミングおよび非ストリーミングレスポンス
|
||||||
- 関数呼び出し/ツールのサポート
|
- 関数呼び出し/ツールのサポート
|
||||||
- マルチモーダル入力サポート(テキストと画像)
|
- マルチモーダル入力サポート(テキストと画像)
|
||||||
- ラウンドロビン負荷分散による複数アカウント対応(Gemini、OpenAI、ClaudeおよびiFlow)
|
- ラウンドロビン負荷分散による複数アカウント対応(Gemini、OpenAI、Claude)
|
||||||
- シンプルなCLI認証フロー(Gemini、OpenAI、ClaudeおよびiFlow)
|
- シンプルなCLI認証フロー(Gemini、OpenAI、Claude)
|
||||||
- Generative Language APIキーのサポート
|
- Generative Language APIキーのサポート
|
||||||
- AI Studioビルドのマルチアカウント負荷分散
|
- AI Studioビルドのマルチアカウント負荷分散
|
||||||
- Gemini CLIのマルチアカウント負荷分散
|
- Gemini CLIのマルチアカウント負荷分散
|
||||||
- Claude Codeのマルチアカウント負荷分散
|
- Claude Codeのマルチアカウント負荷分散
|
||||||
- iFlowのマルチアカウント負荷分散
|
|
||||||
- OpenAI Codexのマルチアカウント負荷分散
|
- OpenAI Codexのマルチアカウント負荷分散
|
||||||
- 設定によるOpenAI互換アップストリームプロバイダー(例:OpenRouter)
|
- 設定によるOpenAI互換アップストリームプロバイダー(例:OpenRouter)
|
||||||
- プロキシ埋め込み用の再利用可能なGo SDK(`docs/sdk-usage.md`を参照)
|
- プロキシ埋め込み用の再利用可能なGo SDK(`docs/sdk-usage.md`を参照)
|
||||||
@@ -174,7 +172,7 @@ Shadow AIは制限された環境向けに特別に設計されたAIアシスタ
|
|||||||
|
|
||||||
### [ProxyPal](https://github.com/buddingnewinsights/proxypal)
|
### [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)
|
### [CLIProxyAPI Quota Inspector](https://github.com/AllenReder/CLIProxyAPI-Quota-Inspector)
|
||||||
|
|
||||||
|
|||||||
@@ -61,8 +61,6 @@ func main() {
|
|||||||
var codexLogin bool
|
var codexLogin bool
|
||||||
var codexDeviceLogin bool
|
var codexDeviceLogin bool
|
||||||
var claudeLogin bool
|
var claudeLogin bool
|
||||||
var iflowLogin bool
|
|
||||||
var iflowCookie bool
|
|
||||||
var noBrowser bool
|
var noBrowser bool
|
||||||
var oauthCallbackPort int
|
var oauthCallbackPort int
|
||||||
var antigravityLogin bool
|
var antigravityLogin bool
|
||||||
@@ -81,8 +79,6 @@ func main() {
|
|||||||
flag.BoolVar(&codexLogin, "codex-login", false, "Login to Codex using OAuth")
|
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(&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(&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.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.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")
|
flag.BoolVar(&antigravityLogin, "antigravity-login", false, "Login to Antigravity using OAuth")
|
||||||
@@ -482,10 +478,6 @@ func main() {
|
|||||||
} else if claudeLogin {
|
} else if claudeLogin {
|
||||||
// Handle Claude login
|
// Handle Claude login
|
||||||
cmd.DoClaudeLogin(cfg, options)
|
cmd.DoClaudeLogin(cfg, options)
|
||||||
} else if iflowLogin {
|
|
||||||
cmd.DoIFlowLogin(cfg, options)
|
|
||||||
} else if iflowCookie {
|
|
||||||
cmd.DoIFlowCookieAuth(cfg, options)
|
|
||||||
} else if kimiLogin {
|
} else if kimiLogin {
|
||||||
cmd.DoKimiLogin(cfg, options)
|
cmd.DoKimiLogin(cfg, options)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
+1
-6
@@ -309,7 +309,7 @@ nonstream-keepalive-interval: 0
|
|||||||
|
|
||||||
# Global OAuth model name aliases (per channel)
|
# Global OAuth model name aliases (per channel)
|
||||||
# These aliases rename model IDs for both model listing and request routing.
|
# 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: 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
|
# 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
|
# client-visible names can become ambiguous across providers. /api/provider/{provider}/... helps
|
||||||
@@ -336,9 +336,6 @@ nonstream-keepalive-interval: 0
|
|||||||
# codex:
|
# codex:
|
||||||
# - name: "gpt-5"
|
# - name: "gpt-5"
|
||||||
# alias: "g5"
|
# alias: "g5"
|
||||||
# iflow:
|
|
||||||
# - name: "glm-4.7"
|
|
||||||
# alias: "glm-god"
|
|
||||||
# kimi:
|
# kimi:
|
||||||
# - name: "kimi-k2.5"
|
# - name: "kimi-k2.5"
|
||||||
# alias: "k2.5"
|
# alias: "k2.5"
|
||||||
@@ -360,8 +357,6 @@ nonstream-keepalive-interval: 0
|
|||||||
# - "claude-3-5-haiku-20241022"
|
# - "claude-3-5-haiku-20241022"
|
||||||
# codex:
|
# codex:
|
||||||
# - "gpt-5-codex-mini"
|
# - "gpt-5-codex-mini"
|
||||||
# iflow:
|
|
||||||
# - "tstars2.0"
|
|
||||||
# kimi:
|
# kimi:
|
||||||
# - "kimi-k2-thinking"
|
# - "kimi-k2-thinking"
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ import (
|
|||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/auth/claude"
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/auth/claude"
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/auth/codex"
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/auth/codex"
|
||||||
geminiAuth "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/gemini"
|
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/auth/kimi"
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces"
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces"
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/misc"
|
"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})
|
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{}
|
type projectSelectionRequiredError struct{}
|
||||||
|
|
||||||
func (e *projectSelectionRequiredError) Error() string {
|
func (e *projectSelectionRequiredError) Error() string {
|
||||||
|
|||||||
@@ -225,8 +225,6 @@ func NormalizeOAuthProvider(provider string) (string, error) {
|
|||||||
return "codex", nil
|
return "codex", nil
|
||||||
case "gemini", "google":
|
case "gemini", "google":
|
||||||
return "gemini", nil
|
return "gemini", nil
|
||||||
case "iflow", "i-flow":
|
|
||||||
return "iflow", nil
|
|
||||||
case "antigravity", "anti-gravity":
|
case "antigravity", "anti-gravity":
|
||||||
return "antigravity", nil
|
return "antigravity", nil
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -411,20 +411,6 @@ func (s *Server) setupRoutes() {
|
|||||||
c.String(http.StatusOK, oauthCallbackSuccessHTML)
|
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) {
|
s.engine.GET("/antigravity/callback", func(c *gin.Context) {
|
||||||
code := c.Query("code")
|
code := c.Query("code")
|
||||||
state := c.Query("state")
|
state := c.Query("state")
|
||||||
@@ -641,8 +627,6 @@ func (s *Server) registerManagementRoutes() {
|
|||||||
mgmt.GET("/gemini-cli-auth-url", s.mgmt.RequestGeminiCLIToken)
|
mgmt.GET("/gemini-cli-auth-url", s.mgmt.RequestGeminiCLIToken)
|
||||||
mgmt.GET("/antigravity-auth-url", s.mgmt.RequestAntigravityToken)
|
mgmt.GET("/antigravity-auth-url", s.mgmt.RequestAntigravityToken)
|
||||||
mgmt.GET("/kimi-auth-url", s.mgmt.RequestKimiToken)
|
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.POST("/oauth-callback", s.mgmt.PostOAuthCallback)
|
||||||
mgmt.GET("/get-auth-status", s.mgmt.GetAuthStatus)
|
mgmt.GET("/get-auth-status", s.mgmt.GetAuthStatus)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
|
|
||||||
// newAuthManager creates a new authentication manager instance with all supported
|
// newAuthManager creates a new authentication manager instance with all supported
|
||||||
// authenticators and a file-based token store. It initializes authenticators for
|
// 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:
|
// Returns:
|
||||||
// - *sdkAuth.Manager: A configured authentication manager instance
|
// - *sdkAuth.Manager: A configured authentication manager instance
|
||||||
@@ -16,7 +16,6 @@ func newAuthManager() *sdkAuth.Manager {
|
|||||||
sdkAuth.NewGeminiAuthenticator(),
|
sdkAuth.NewGeminiAuthenticator(),
|
||||||
sdkAuth.NewCodexAuthenticator(),
|
sdkAuth.NewCodexAuthenticator(),
|
||||||
sdkAuth.NewClaudeAuthenticator(),
|
sdkAuth.NewClaudeAuthenticator(),
|
||||||
sdkAuth.NewIFlowAuthenticator(),
|
|
||||||
sdkAuth.NewAntigravityAuthenticator(),
|
sdkAuth.NewAntigravityAuthenticator(),
|
||||||
sdkAuth.NewKimiAuthenticator(),
|
sdkAuth.NewKimiAuthenticator(),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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())
|
|
||||||
}
|
|
||||||
@@ -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!")
|
|
||||||
}
|
|
||||||
@@ -128,7 +128,7 @@ type Config struct {
|
|||||||
|
|
||||||
// OAuthModelAlias defines global model name aliases for OAuth/file-backed auth channels.
|
// OAuthModelAlias defines global model name aliases for OAuth/file-backed auth channels.
|
||||||
// These aliases affect both model listing and model routing for supported 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:
|
// 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.
|
// gemini-api-key, codex-api-key, claude-api-key, openai-compatibility, vertex-api-key, and ampcode.
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ type staticModelsJSON struct {
|
|||||||
CodexTeam []*ModelInfo `json:"codex-team"`
|
CodexTeam []*ModelInfo `json:"codex-team"`
|
||||||
CodexPlus []*ModelInfo `json:"codex-plus"`
|
CodexPlus []*ModelInfo `json:"codex-plus"`
|
||||||
CodexPro []*ModelInfo `json:"codex-pro"`
|
CodexPro []*ModelInfo `json:"codex-pro"`
|
||||||
IFlow []*ModelInfo `json:"iflow"`
|
|
||||||
Kimi []*ModelInfo `json:"kimi"`
|
Kimi []*ModelInfo `json:"kimi"`
|
||||||
Antigravity []*ModelInfo `json:"antigravity"`
|
Antigravity []*ModelInfo `json:"antigravity"`
|
||||||
}
|
}
|
||||||
@@ -67,11 +66,6 @@ func GetCodexProModels() []*ModelInfo {
|
|||||||
return cloneModelInfos(getModels().CodexPro)
|
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.
|
// GetKimiModels returns the standard Kimi (Moonshot AI) model definitions.
|
||||||
func GetKimiModels() []*ModelInfo {
|
func GetKimiModels() []*ModelInfo {
|
||||||
return cloneModelInfos(getModels().Kimi)
|
return cloneModelInfos(getModels().Kimi)
|
||||||
@@ -104,7 +98,6 @@ func cloneModelInfos(models []*ModelInfo) []*ModelInfo {
|
|||||||
// - gemini-cli
|
// - gemini-cli
|
||||||
// - aistudio
|
// - aistudio
|
||||||
// - codex
|
// - codex
|
||||||
// - iflow
|
|
||||||
// - kimi
|
// - kimi
|
||||||
// - antigravity
|
// - antigravity
|
||||||
func GetStaticModelDefinitionsByChannel(channel string) []*ModelInfo {
|
func GetStaticModelDefinitionsByChannel(channel string) []*ModelInfo {
|
||||||
@@ -122,8 +115,6 @@ func GetStaticModelDefinitionsByChannel(channel string) []*ModelInfo {
|
|||||||
return GetAIStudioModels()
|
return GetAIStudioModels()
|
||||||
case "codex":
|
case "codex":
|
||||||
return GetCodexProModels()
|
return GetCodexProModels()
|
||||||
case "iflow":
|
|
||||||
return GetIFlowModels()
|
|
||||||
case "kimi":
|
case "kimi":
|
||||||
return GetKimiModels()
|
return GetKimiModels()
|
||||||
case "antigravity":
|
case "antigravity":
|
||||||
@@ -148,7 +139,6 @@ func LookupStaticModelInfo(modelID string) *ModelInfo {
|
|||||||
data.GeminiCLI,
|
data.GeminiCLI,
|
||||||
data.AIStudio,
|
data.AIStudio,
|
||||||
data.CodexPro,
|
data.CodexPro,
|
||||||
data.IFlow,
|
|
||||||
data.Kimi,
|
data.Kimi,
|
||||||
data.Antigravity,
|
data.Antigravity,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -213,7 +213,6 @@ func detectChangedProviders(oldData, newData *staticModelsJSON) []string {
|
|||||||
{"codex", oldData.CodexTeam, newData.CodexTeam},
|
{"codex", oldData.CodexTeam, newData.CodexTeam},
|
||||||
{"codex", oldData.CodexPlus, newData.CodexPlus},
|
{"codex", oldData.CodexPlus, newData.CodexPlus},
|
||||||
{"codex", oldData.CodexPro, newData.CodexPro},
|
{"codex", oldData.CodexPro, newData.CodexPro},
|
||||||
{"iflow", oldData.IFlow, newData.IFlow},
|
|
||||||
{"kimi", oldData.Kimi, newData.Kimi},
|
{"kimi", oldData.Kimi, newData.Kimi},
|
||||||
{"antigravity", oldData.Antigravity, newData.Antigravity},
|
{"antigravity", oldData.Antigravity, newData.Antigravity},
|
||||||
}
|
}
|
||||||
@@ -334,7 +333,6 @@ func validateModelsCatalog(data *staticModelsJSON) error {
|
|||||||
{name: "codex-team", models: data.CodexTeam},
|
{name: "codex-team", models: data.CodexTeam},
|
||||||
{name: "codex-plus", models: data.CodexPlus},
|
{name: "codex-plus", models: data.CodexPlus},
|
||||||
{name: "codex-pro", models: data.CodexPro},
|
{name: "codex-pro", models: data.CodexPro},
|
||||||
{name: "iflow", models: data.IFlow},
|
|
||||||
{name: "kimi", models: data.Kimi},
|
{name: "kimi", models: data.Kimi},
|
||||||
{name: "antigravity", models: data.Antigravity},
|
{name: "antigravity", models: data.Antigravity},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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": [
|
"kimi": [
|
||||||
{
|
{
|
||||||
"id": "kimi-k2",
|
"id": "kimi-k2",
|
||||||
|
|||||||
@@ -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/codex"
|
||||||
_ "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking/provider/gemini"
|
_ "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/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/kimi"
|
||||||
_ "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking/provider/openai"
|
_ "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking/provider/openai"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,585 +0,0 @@
|
|||||||
package executor
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bufio"
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"crypto/hmac"
|
|
||||||
"crypto/sha256"
|
|
||||||
"encoding/hex"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
iflowauth "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/iflow"
|
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/runtime/executor/helps"
|
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/thinking"
|
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
|
|
||||||
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
|
||||||
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
|
|
||||||
sdktranslator "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator"
|
|
||||||
log "github.com/sirupsen/logrus"
|
|
||||||
"github.com/tidwall/gjson"
|
|
||||||
"github.com/tidwall/sjson"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
iflowDefaultEndpoint = "/chat/completions"
|
|
||||||
iflowUserAgent = "iFlow-Cli"
|
|
||||||
)
|
|
||||||
|
|
||||||
// IFlowExecutor executes OpenAI-compatible chat completions against the iFlow API using API keys derived from OAuth.
|
|
||||||
type IFlowExecutor struct {
|
|
||||||
cfg *config.Config
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewIFlowExecutor constructs a new executor instance.
|
|
||||||
func NewIFlowExecutor(cfg *config.Config) *IFlowExecutor { return &IFlowExecutor{cfg: cfg} }
|
|
||||||
|
|
||||||
// Identifier returns the provider key.
|
|
||||||
func (e *IFlowExecutor) Identifier() string { return "iflow" }
|
|
||||||
|
|
||||||
// PrepareRequest injects iFlow credentials into the outgoing HTTP request.
|
|
||||||
func (e *IFlowExecutor) PrepareRequest(req *http.Request, auth *cliproxyauth.Auth) error {
|
|
||||||
if req == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
apiKey, _ := iflowCreds(auth)
|
|
||||||
if strings.TrimSpace(apiKey) != "" {
|
|
||||||
req.Header.Set("Authorization", "Bearer "+apiKey)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// HttpRequest injects iFlow credentials into the request and executes it.
|
|
||||||
func (e *IFlowExecutor) HttpRequest(ctx context.Context, auth *cliproxyauth.Auth, req *http.Request) (*http.Response, error) {
|
|
||||||
if req == nil {
|
|
||||||
return nil, fmt.Errorf("iflow executor: request is nil")
|
|
||||||
}
|
|
||||||
if ctx == nil {
|
|
||||||
ctx = req.Context()
|
|
||||||
}
|
|
||||||
httpReq := req.WithContext(ctx)
|
|
||||||
if err := e.PrepareRequest(httpReq, auth); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
httpClient := helps.NewProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
|
|
||||||
return httpClient.Do(httpReq)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Execute performs a non-streaming chat completion request.
|
|
||||||
func (e *IFlowExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (resp cliproxyexecutor.Response, err error) {
|
|
||||||
if opts.Alt == "responses/compact" {
|
|
||||||
return resp, statusErr{code: http.StatusNotImplemented, msg: "/responses/compact not supported"}
|
|
||||||
}
|
|
||||||
baseModel := thinking.ParseSuffix(req.Model).ModelName
|
|
||||||
|
|
||||||
apiKey, baseURL := iflowCreds(auth)
|
|
||||||
if strings.TrimSpace(apiKey) == "" {
|
|
||||||
err = fmt.Errorf("iflow executor: missing api key")
|
|
||||||
return resp, err
|
|
||||||
}
|
|
||||||
if baseURL == "" {
|
|
||||||
baseURL = iflowauth.DefaultAPIBaseURL
|
|
||||||
}
|
|
||||||
|
|
||||||
reporter := helps.NewUsageReporter(ctx, e.Identifier(), baseModel, auth)
|
|
||||||
defer reporter.TrackFailure(ctx, &err)
|
|
||||||
|
|
||||||
from := opts.SourceFormat
|
|
||||||
to := sdktranslator.FromString("openai")
|
|
||||||
originalPayloadSource := req.Payload
|
|
||||||
if len(opts.OriginalRequest) > 0 {
|
|
||||||
originalPayloadSource = opts.OriginalRequest
|
|
||||||
}
|
|
||||||
originalPayload := originalPayloadSource
|
|
||||||
originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, false)
|
|
||||||
body := sdktranslator.TranslateRequest(from, to, baseModel, req.Payload, false)
|
|
||||||
body, _ = sjson.SetBytes(body, "model", baseModel)
|
|
||||||
|
|
||||||
body, err = thinking.ApplyThinking(body, req.Model, from.String(), "iflow", e.Identifier())
|
|
||||||
if err != nil {
|
|
||||||
return resp, err
|
|
||||||
}
|
|
||||||
|
|
||||||
body = preserveReasoningContentInMessages(body)
|
|
||||||
requestedModel := helps.PayloadRequestedModel(opts, req.Model)
|
|
||||||
body = helps.ApplyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", body, originalTranslated, requestedModel)
|
|
||||||
|
|
||||||
endpoint := strings.TrimSuffix(baseURL, "/") + iflowDefaultEndpoint
|
|
||||||
|
|
||||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
|
|
||||||
if err != nil {
|
|
||||||
return resp, err
|
|
||||||
}
|
|
||||||
applyIFlowHeaders(httpReq, apiKey, false)
|
|
||||||
var attrs map[string]string
|
|
||||||
if auth != nil {
|
|
||||||
attrs = auth.Attributes
|
|
||||||
}
|
|
||||||
util.ApplyCustomHeadersFromAttrs(httpReq, attrs)
|
|
||||||
var authID, authLabel, authType, authValue string
|
|
||||||
if auth != nil {
|
|
||||||
authID = auth.ID
|
|
||||||
authLabel = auth.Label
|
|
||||||
authType, authValue = auth.AccountInfo()
|
|
||||||
}
|
|
||||||
helps.RecordAPIRequest(ctx, e.cfg, helps.UpstreamRequestLog{
|
|
||||||
URL: endpoint,
|
|
||||||
Method: http.MethodPost,
|
|
||||||
Headers: httpReq.Header.Clone(),
|
|
||||||
Body: body,
|
|
||||||
Provider: e.Identifier(),
|
|
||||||
AuthID: authID,
|
|
||||||
AuthLabel: authLabel,
|
|
||||||
AuthType: authType,
|
|
||||||
AuthValue: authValue,
|
|
||||||
})
|
|
||||||
|
|
||||||
httpClient := helps.NewProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
|
|
||||||
httpResp, err := httpClient.Do(httpReq)
|
|
||||||
if err != nil {
|
|
||||||
helps.RecordAPIResponseError(ctx, e.cfg, err)
|
|
||||||
return resp, err
|
|
||||||
}
|
|
||||||
defer func() {
|
|
||||||
if errClose := httpResp.Body.Close(); errClose != nil {
|
|
||||||
log.Errorf("iflow executor: close response body error: %v", errClose)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
helps.RecordAPIResponseMetadata(ctx, e.cfg, httpResp.StatusCode, httpResp.Header.Clone())
|
|
||||||
|
|
||||||
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
|
|
||||||
b, _ := io.ReadAll(httpResp.Body)
|
|
||||||
helps.AppendAPIResponseChunk(ctx, e.cfg, b)
|
|
||||||
helps.LogWithRequestID(ctx).Debugf("request error, error status: %d error message: %s", httpResp.StatusCode, helps.SummarizeErrorBody(httpResp.Header.Get("Content-Type"), b))
|
|
||||||
err = statusErr{code: httpResp.StatusCode, msg: string(b)}
|
|
||||||
return resp, err
|
|
||||||
}
|
|
||||||
|
|
||||||
data, err := io.ReadAll(httpResp.Body)
|
|
||||||
if err != nil {
|
|
||||||
helps.RecordAPIResponseError(ctx, e.cfg, err)
|
|
||||||
return resp, err
|
|
||||||
}
|
|
||||||
helps.AppendAPIResponseChunk(ctx, e.cfg, data)
|
|
||||||
reporter.Publish(ctx, helps.ParseOpenAIUsage(data))
|
|
||||||
// Ensure usage is recorded even if upstream omits usage metadata.
|
|
||||||
reporter.EnsurePublished(ctx)
|
|
||||||
|
|
||||||
var param any
|
|
||||||
// Note: TranslateNonStream uses req.Model (original with suffix) to preserve
|
|
||||||
// the original model name in the response for client compatibility.
|
|
||||||
out := sdktranslator.TranslateNonStream(ctx, to, from, req.Model, opts.OriginalRequest, body, data, ¶m)
|
|
||||||
resp = cliproxyexecutor.Response{Payload: out, Headers: httpResp.Header.Clone()}
|
|
||||||
return resp, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ExecuteStream performs a streaming chat completion request.
|
|
||||||
func (e *IFlowExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (_ *cliproxyexecutor.StreamResult, err error) {
|
|
||||||
if opts.Alt == "responses/compact" {
|
|
||||||
return nil, statusErr{code: http.StatusNotImplemented, msg: "/responses/compact not supported"}
|
|
||||||
}
|
|
||||||
baseModel := thinking.ParseSuffix(req.Model).ModelName
|
|
||||||
|
|
||||||
apiKey, baseURL := iflowCreds(auth)
|
|
||||||
if strings.TrimSpace(apiKey) == "" {
|
|
||||||
err = fmt.Errorf("iflow executor: missing api key")
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if baseURL == "" {
|
|
||||||
baseURL = iflowauth.DefaultAPIBaseURL
|
|
||||||
}
|
|
||||||
|
|
||||||
reporter := helps.NewUsageReporter(ctx, e.Identifier(), baseModel, auth)
|
|
||||||
defer reporter.TrackFailure(ctx, &err)
|
|
||||||
|
|
||||||
from := opts.SourceFormat
|
|
||||||
to := sdktranslator.FromString("openai")
|
|
||||||
originalPayloadSource := req.Payload
|
|
||||||
if len(opts.OriginalRequest) > 0 {
|
|
||||||
originalPayloadSource = opts.OriginalRequest
|
|
||||||
}
|
|
||||||
originalPayload := originalPayloadSource
|
|
||||||
originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, true)
|
|
||||||
body := sdktranslator.TranslateRequest(from, to, baseModel, req.Payload, true)
|
|
||||||
body, _ = sjson.SetBytes(body, "model", baseModel)
|
|
||||||
|
|
||||||
body, err = thinking.ApplyThinking(body, req.Model, from.String(), "iflow", e.Identifier())
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
body = preserveReasoningContentInMessages(body)
|
|
||||||
// Ensure tools array exists to avoid provider quirks observed in some upstreams.
|
|
||||||
toolsResult := gjson.GetBytes(body, "tools")
|
|
||||||
if toolsResult.Exists() && toolsResult.IsArray() && len(toolsResult.Array()) == 0 {
|
|
||||||
body = ensureToolsArray(body)
|
|
||||||
}
|
|
||||||
requestedModel := helps.PayloadRequestedModel(opts, req.Model)
|
|
||||||
body = helps.ApplyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", body, originalTranslated, requestedModel)
|
|
||||||
|
|
||||||
endpoint := strings.TrimSuffix(baseURL, "/") + iflowDefaultEndpoint
|
|
||||||
|
|
||||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
applyIFlowHeaders(httpReq, apiKey, true)
|
|
||||||
var attrs map[string]string
|
|
||||||
if auth != nil {
|
|
||||||
attrs = auth.Attributes
|
|
||||||
}
|
|
||||||
util.ApplyCustomHeadersFromAttrs(httpReq, attrs)
|
|
||||||
var authID, authLabel, authType, authValue string
|
|
||||||
if auth != nil {
|
|
||||||
authID = auth.ID
|
|
||||||
authLabel = auth.Label
|
|
||||||
authType, authValue = auth.AccountInfo()
|
|
||||||
}
|
|
||||||
helps.RecordAPIRequest(ctx, e.cfg, helps.UpstreamRequestLog{
|
|
||||||
URL: endpoint,
|
|
||||||
Method: http.MethodPost,
|
|
||||||
Headers: httpReq.Header.Clone(),
|
|
||||||
Body: body,
|
|
||||||
Provider: e.Identifier(),
|
|
||||||
AuthID: authID,
|
|
||||||
AuthLabel: authLabel,
|
|
||||||
AuthType: authType,
|
|
||||||
AuthValue: authValue,
|
|
||||||
})
|
|
||||||
|
|
||||||
httpClient := helps.NewProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
|
|
||||||
httpResp, err := httpClient.Do(httpReq)
|
|
||||||
if err != nil {
|
|
||||||
helps.RecordAPIResponseError(ctx, e.cfg, err)
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
helps.RecordAPIResponseMetadata(ctx, e.cfg, httpResp.StatusCode, httpResp.Header.Clone())
|
|
||||||
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
|
|
||||||
data, _ := io.ReadAll(httpResp.Body)
|
|
||||||
if errClose := httpResp.Body.Close(); errClose != nil {
|
|
||||||
log.Errorf("iflow executor: close response body error: %v", errClose)
|
|
||||||
}
|
|
||||||
helps.AppendAPIResponseChunk(ctx, e.cfg, data)
|
|
||||||
helps.LogWithRequestID(ctx).Debugf("request error, error status: %d error message: %s", httpResp.StatusCode, helps.SummarizeErrorBody(httpResp.Header.Get("Content-Type"), data))
|
|
||||||
err = statusErr{code: httpResp.StatusCode, msg: string(data)}
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
out := make(chan cliproxyexecutor.StreamChunk)
|
|
||||||
go func() {
|
|
||||||
defer close(out)
|
|
||||||
defer func() {
|
|
||||||
if errClose := httpResp.Body.Close(); errClose != nil {
|
|
||||||
log.Errorf("iflow executor: close response body error: %v", errClose)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
scanner := bufio.NewScanner(httpResp.Body)
|
|
||||||
scanner.Buffer(nil, 52_428_800) // 50MB
|
|
||||||
var param any
|
|
||||||
for scanner.Scan() {
|
|
||||||
line := scanner.Bytes()
|
|
||||||
helps.AppendAPIResponseChunk(ctx, e.cfg, line)
|
|
||||||
if detail, ok := helps.ParseOpenAIStreamUsage(line); ok {
|
|
||||||
reporter.Publish(ctx, detail)
|
|
||||||
}
|
|
||||||
chunks := sdktranslator.TranslateStream(ctx, to, from, req.Model, opts.OriginalRequest, body, bytes.Clone(line), ¶m)
|
|
||||||
for i := range chunks {
|
|
||||||
out <- cliproxyexecutor.StreamChunk{Payload: chunks[i]}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if errScan := scanner.Err(); errScan != nil {
|
|
||||||
helps.RecordAPIResponseError(ctx, e.cfg, errScan)
|
|
||||||
reporter.PublishFailure(ctx)
|
|
||||||
out <- cliproxyexecutor.StreamChunk{Err: errScan}
|
|
||||||
}
|
|
||||||
// Guarantee a usage record exists even if the stream never emitted usage data.
|
|
||||||
reporter.EnsurePublished(ctx)
|
|
||||||
}()
|
|
||||||
|
|
||||||
return &cliproxyexecutor.StreamResult{Headers: httpResp.Header.Clone(), Chunks: out}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *IFlowExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) {
|
|
||||||
baseModel := thinking.ParseSuffix(req.Model).ModelName
|
|
||||||
|
|
||||||
from := opts.SourceFormat
|
|
||||||
to := sdktranslator.FromString("openai")
|
|
||||||
body := sdktranslator.TranslateRequest(from, to, baseModel, req.Payload, false)
|
|
||||||
|
|
||||||
enc, err := helps.TokenizerForModel(baseModel)
|
|
||||||
if err != nil {
|
|
||||||
return cliproxyexecutor.Response{}, fmt.Errorf("iflow executor: tokenizer init failed: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
count, err := helps.CountOpenAIChatTokens(enc, body)
|
|
||||||
if err != nil {
|
|
||||||
return cliproxyexecutor.Response{}, fmt.Errorf("iflow executor: token counting failed: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
usageJSON := helps.BuildOpenAIUsageJSON(count)
|
|
||||||
translated := sdktranslator.TranslateTokenCount(ctx, to, from, count, usageJSON)
|
|
||||||
return cliproxyexecutor.Response{Payload: translated}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Refresh refreshes OAuth tokens or cookie-based API keys and updates the stored API key.
|
|
||||||
func (e *IFlowExecutor) Refresh(ctx context.Context, auth *cliproxyauth.Auth) (*cliproxyauth.Auth, error) {
|
|
||||||
log.Debugf("iflow executor: refresh called")
|
|
||||||
if auth == nil {
|
|
||||||
return nil, fmt.Errorf("iflow executor: auth is nil")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if this is cookie-based authentication
|
|
||||||
var cookie string
|
|
||||||
var email string
|
|
||||||
if auth.Metadata != nil {
|
|
||||||
if v, ok := auth.Metadata["cookie"].(string); ok {
|
|
||||||
cookie = strings.TrimSpace(v)
|
|
||||||
}
|
|
||||||
if v, ok := auth.Metadata["email"].(string); ok {
|
|
||||||
email = strings.TrimSpace(v)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If cookie is present, use cookie-based refresh
|
|
||||||
if cookie != "" && email != "" {
|
|
||||||
return e.refreshCookieBased(ctx, auth, cookie, email)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Otherwise, use OAuth-based refresh
|
|
||||||
return e.refreshOAuthBased(ctx, auth)
|
|
||||||
}
|
|
||||||
|
|
||||||
// refreshCookieBased refreshes API key using browser cookie
|
|
||||||
func (e *IFlowExecutor) refreshCookieBased(ctx context.Context, auth *cliproxyauth.Auth, cookie, email string) (*cliproxyauth.Auth, error) {
|
|
||||||
log.Debugf("iflow executor: checking refresh need for cookie-based API key for user: %s", email)
|
|
||||||
|
|
||||||
// Get current expiry time from metadata
|
|
||||||
var currentExpire string
|
|
||||||
if auth.Metadata != nil {
|
|
||||||
if v, ok := auth.Metadata["expired"].(string); ok {
|
|
||||||
currentExpire = strings.TrimSpace(v)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if refresh is needed
|
|
||||||
needsRefresh, _, err := iflowauth.ShouldRefreshAPIKey(currentExpire)
|
|
||||||
if err != nil {
|
|
||||||
log.Warnf("iflow executor: failed to check refresh need: %v", err)
|
|
||||||
// If we can't check, continue with refresh anyway as a safety measure
|
|
||||||
} else if !needsRefresh {
|
|
||||||
log.Debugf("iflow executor: no refresh needed for user: %s", email)
|
|
||||||
return auth, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Infof("iflow executor: refreshing cookie-based API key for user: %s", email)
|
|
||||||
|
|
||||||
svc := iflowauth.NewIFlowAuthWithProxyURL(e.cfg, auth.ProxyURL)
|
|
||||||
keyData, err := svc.RefreshAPIKey(ctx, cookie, email)
|
|
||||||
if err != nil {
|
|
||||||
log.Errorf("iflow executor: cookie-based API key refresh failed: %v", err)
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if auth.Metadata == nil {
|
|
||||||
auth.Metadata = make(map[string]any)
|
|
||||||
}
|
|
||||||
auth.Metadata["api_key"] = keyData.APIKey
|
|
||||||
auth.Metadata["expired"] = keyData.ExpireTime
|
|
||||||
auth.Metadata["type"] = "iflow"
|
|
||||||
auth.Metadata["last_refresh"] = time.Now().Format(time.RFC3339)
|
|
||||||
auth.Metadata["cookie"] = cookie
|
|
||||||
auth.Metadata["email"] = email
|
|
||||||
|
|
||||||
log.Infof("iflow executor: cookie-based API key refreshed successfully, new expiry: %s", keyData.ExpireTime)
|
|
||||||
|
|
||||||
if auth.Attributes == nil {
|
|
||||||
auth.Attributes = make(map[string]string)
|
|
||||||
}
|
|
||||||
auth.Attributes["api_key"] = keyData.APIKey
|
|
||||||
|
|
||||||
return auth, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// refreshOAuthBased refreshes tokens using OAuth refresh token
|
|
||||||
func (e *IFlowExecutor) refreshOAuthBased(ctx context.Context, auth *cliproxyauth.Auth) (*cliproxyauth.Auth, error) {
|
|
||||||
refreshToken := ""
|
|
||||||
oldAccessToken := ""
|
|
||||||
if auth.Metadata != nil {
|
|
||||||
if v, ok := auth.Metadata["refresh_token"].(string); ok {
|
|
||||||
refreshToken = strings.TrimSpace(v)
|
|
||||||
}
|
|
||||||
if v, ok := auth.Metadata["access_token"].(string); ok {
|
|
||||||
oldAccessToken = strings.TrimSpace(v)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if refreshToken == "" {
|
|
||||||
return auth, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Log the old access token (masked) before refresh
|
|
||||||
if oldAccessToken != "" {
|
|
||||||
log.Debugf("iflow executor: refreshing access token, old: %s", util.HideAPIKey(oldAccessToken))
|
|
||||||
}
|
|
||||||
|
|
||||||
svc := iflowauth.NewIFlowAuthWithProxyURL(e.cfg, auth.ProxyURL)
|
|
||||||
tokenData, err := svc.RefreshTokens(ctx, refreshToken)
|
|
||||||
if err != nil {
|
|
||||||
log.Errorf("iflow executor: token refresh failed: %v", err)
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if auth.Metadata == nil {
|
|
||||||
auth.Metadata = make(map[string]any)
|
|
||||||
}
|
|
||||||
auth.Metadata["access_token"] = tokenData.AccessToken
|
|
||||||
if tokenData.RefreshToken != "" {
|
|
||||||
auth.Metadata["refresh_token"] = tokenData.RefreshToken
|
|
||||||
}
|
|
||||||
if tokenData.APIKey != "" {
|
|
||||||
auth.Metadata["api_key"] = tokenData.APIKey
|
|
||||||
}
|
|
||||||
auth.Metadata["expired"] = tokenData.Expire
|
|
||||||
auth.Metadata["type"] = "iflow"
|
|
||||||
auth.Metadata["last_refresh"] = time.Now().Format(time.RFC3339)
|
|
||||||
|
|
||||||
// Log the new access token (masked) after successful refresh
|
|
||||||
log.Debugf("iflow executor: token refresh successful, new: %s", util.HideAPIKey(tokenData.AccessToken))
|
|
||||||
|
|
||||||
if auth.Attributes == nil {
|
|
||||||
auth.Attributes = make(map[string]string)
|
|
||||||
}
|
|
||||||
if tokenData.APIKey != "" {
|
|
||||||
auth.Attributes["api_key"] = tokenData.APIKey
|
|
||||||
}
|
|
||||||
|
|
||||||
return auth, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func applyIFlowHeaders(r *http.Request, apiKey string, stream bool) {
|
|
||||||
r.Header.Set("Content-Type", "application/json")
|
|
||||||
r.Header.Set("Authorization", "Bearer "+apiKey)
|
|
||||||
r.Header.Set("User-Agent", iflowUserAgent)
|
|
||||||
|
|
||||||
// Generate session-id
|
|
||||||
sessionID := "session-" + generateUUID()
|
|
||||||
r.Header.Set("session-id", sessionID)
|
|
||||||
|
|
||||||
// Generate timestamp and signature
|
|
||||||
timestamp := time.Now().UnixMilli()
|
|
||||||
r.Header.Set("x-iflow-timestamp", fmt.Sprintf("%d", timestamp))
|
|
||||||
|
|
||||||
signature := createIFlowSignature(iflowUserAgent, sessionID, timestamp, apiKey)
|
|
||||||
if signature != "" {
|
|
||||||
r.Header.Set("x-iflow-signature", signature)
|
|
||||||
}
|
|
||||||
|
|
||||||
if stream {
|
|
||||||
r.Header.Set("Accept", "text/event-stream")
|
|
||||||
} else {
|
|
||||||
r.Header.Set("Accept", "application/json")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// createIFlowSignature generates HMAC-SHA256 signature for iFlow API requests.
|
|
||||||
// The signature payload format is: userAgent:sessionId:timestamp
|
|
||||||
func createIFlowSignature(userAgent, sessionID string, timestamp int64, apiKey string) string {
|
|
||||||
if apiKey == "" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
payload := fmt.Sprintf("%s:%s:%d", userAgent, sessionID, timestamp)
|
|
||||||
h := hmac.New(sha256.New, []byte(apiKey))
|
|
||||||
h.Write([]byte(payload))
|
|
||||||
return hex.EncodeToString(h.Sum(nil))
|
|
||||||
}
|
|
||||||
|
|
||||||
// generateUUID generates a random UUID v4 string.
|
|
||||||
func generateUUID() string {
|
|
||||||
return uuid.New().String()
|
|
||||||
}
|
|
||||||
|
|
||||||
func iflowCreds(a *cliproxyauth.Auth) (apiKey, baseURL string) {
|
|
||||||
if a == nil {
|
|
||||||
return "", ""
|
|
||||||
}
|
|
||||||
if a.Attributes != nil {
|
|
||||||
if v := strings.TrimSpace(a.Attributes["api_key"]); v != "" {
|
|
||||||
apiKey = v
|
|
||||||
}
|
|
||||||
if v := strings.TrimSpace(a.Attributes["base_url"]); v != "" {
|
|
||||||
baseURL = v
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if apiKey == "" && a.Metadata != nil {
|
|
||||||
if v, ok := a.Metadata["api_key"].(string); ok {
|
|
||||||
apiKey = strings.TrimSpace(v)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if baseURL == "" && a.Metadata != nil {
|
|
||||||
if v, ok := a.Metadata["base_url"].(string); ok {
|
|
||||||
baseURL = strings.TrimSpace(v)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return apiKey, baseURL
|
|
||||||
}
|
|
||||||
|
|
||||||
func ensureToolsArray(body []byte) []byte {
|
|
||||||
placeholder := `[{"type":"function","function":{"name":"noop","description":"Placeholder tool to stabilise streaming","parameters":{"type":"object"}}}]`
|
|
||||||
updated, err := sjson.SetRawBytes(body, "tools", []byte(placeholder))
|
|
||||||
if err != nil {
|
|
||||||
return body
|
|
||||||
}
|
|
||||||
return updated
|
|
||||||
}
|
|
||||||
|
|
||||||
// preserveReasoningContentInMessages checks if reasoning_content from assistant messages
|
|
||||||
// is preserved in conversation history for iFlow models that support thinking.
|
|
||||||
// This is helpful for multi-turn conversations where the model may benefit from seeing
|
|
||||||
// its previous reasoning to maintain coherent thought chains.
|
|
||||||
//
|
|
||||||
// For GLM-4.6/4.7 and MiniMax M2/M2.1, it is recommended to include the full assistant
|
|
||||||
// response (including reasoning_content) in message history for better context continuity.
|
|
||||||
func preserveReasoningContentInMessages(body []byte) []byte {
|
|
||||||
model := strings.ToLower(gjson.GetBytes(body, "model").String())
|
|
||||||
|
|
||||||
// Only apply to models that support thinking with history preservation
|
|
||||||
needsPreservation := strings.HasPrefix(model, "glm-4") || strings.HasPrefix(model, "minimax-m2")
|
|
||||||
|
|
||||||
if !needsPreservation {
|
|
||||||
return body
|
|
||||||
}
|
|
||||||
|
|
||||||
messages := gjson.GetBytes(body, "messages")
|
|
||||||
if !messages.Exists() || !messages.IsArray() {
|
|
||||||
return body
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if any assistant message already has reasoning_content preserved
|
|
||||||
hasReasoningContent := false
|
|
||||||
messages.ForEach(func(_, msg gjson.Result) bool {
|
|
||||||
role := msg.Get("role").String()
|
|
||||||
if role == "assistant" {
|
|
||||||
rc := msg.Get("reasoning_content")
|
|
||||||
if rc.Exists() && rc.String() != "" {
|
|
||||||
hasReasoningContent = true
|
|
||||||
return false // stop iteration
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
|
|
||||||
// If reasoning content is already present, the messages are properly formatted
|
|
||||||
// No need to modify - the client has correctly preserved reasoning in history
|
|
||||||
if hasReasoningContent {
|
|
||||||
log.Debugf("iflow executor: reasoning_content found in message history for %s", model)
|
|
||||||
}
|
|
||||||
|
|
||||||
return body
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -16,7 +16,6 @@ var providerAppliers = map[string]ProviderApplier{
|
|||||||
"claude": nil,
|
"claude": nil,
|
||||||
"openai": nil,
|
"openai": nil,
|
||||||
"codex": nil,
|
"codex": nil,
|
||||||
"iflow": nil,
|
|
||||||
"antigravity": nil,
|
"antigravity": nil,
|
||||||
"kimi": nil,
|
"kimi": nil,
|
||||||
}
|
}
|
||||||
@@ -63,7 +62,7 @@ func IsUserDefinedModel(modelInfo *registry.ModelInfo) bool {
|
|||||||
// - body: Original request body JSON
|
// - body: Original request body JSON
|
||||||
// - model: Model name, optionally with thinking suffix (e.g., "claude-sonnet-4-5(16384)")
|
// - model: Model name, optionally with thinking suffix (e.g., "claude-sonnet-4-5(16384)")
|
||||||
// - fromFormat: Source request format (e.g., openai, codex, gemini)
|
// - 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)
|
// - providerKey: Provider identifier used for registry model lookups (may differ from toFormat, e.g., openrouter -> openai)
|
||||||
//
|
//
|
||||||
// Returns:
|
// Returns:
|
||||||
@@ -327,12 +326,6 @@ func extractThinkingConfig(body []byte, provider string) ThinkingConfig {
|
|||||||
return extractOpenAIConfig(body)
|
return extractOpenAIConfig(body)
|
||||||
case "codex":
|
case "codex":
|
||||||
return extractCodexConfig(body)
|
return extractCodexConfig(body)
|
||||||
case "iflow":
|
|
||||||
config := extractIFlowConfig(body)
|
|
||||||
if hasThinkingConfig(config) {
|
|
||||||
return config
|
|
||||||
}
|
|
||||||
return extractOpenAIConfig(body)
|
|
||||||
case "kimi":
|
case "kimi":
|
||||||
// Kimi uses OpenAI-compatible reasoning_effort format
|
// Kimi uses OpenAI-compatible reasoning_effort format
|
||||||
return extractOpenAIConfig(body)
|
return extractOpenAIConfig(body)
|
||||||
@@ -494,34 +487,3 @@ func extractCodexConfig(body []byte) ThinkingConfig {
|
|||||||
|
|
||||||
return 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{}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -155,7 +155,7 @@ const (
|
|||||||
// It analyzes the model's ThinkingSupport configuration to classify the model:
|
// It analyzes the model's ThinkingSupport configuration to classify the model:
|
||||||
// - CapabilityNone: modelInfo.Thinking is nil (model doesn't support thinking)
|
// - CapabilityNone: modelInfo.Thinking is nil (model doesn't support thinking)
|
||||||
// - CapabilityBudgetOnly: Has Min/Max but no Levels (Claude, Gemini 2.5)
|
// - 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)
|
// - CapabilityHybrid: Has both Min/Max and Levels (Gemini 3)
|
||||||
//
|
//
|
||||||
// Note: Returns a special sentinel value when modelInfo itself is nil (unknown model).
|
// Note: Returns a special sentinel value when modelInfo itself is nil (unknown model).
|
||||||
|
|||||||
@@ -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")
|
|
||||||
}
|
|
||||||
@@ -44,13 +44,6 @@ func StripThinkingConfig(body []byte, provider string) []byte {
|
|||||||
}
|
}
|
||||||
case "codex":
|
case "codex":
|
||||||
paths = []string{"reasoning.effort"}
|
paths = []string{"reasoning.effort"}
|
||||||
case "iflow":
|
|
||||||
paths = []string{
|
|
||||||
"chat_template_kwargs.enable_thinking",
|
|
||||||
"chat_template_kwargs.clear_thinking",
|
|
||||||
"reasoning_split",
|
|
||||||
"reasoning_effort",
|
|
||||||
}
|
|
||||||
default:
|
default:
|
||||||
return body
|
return body
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// Package thinking provides unified thinking configuration processing.
|
// Package thinking provides unified thinking configuration processing.
|
||||||
//
|
//
|
||||||
// This package offers a unified interface for parsing, validating, and applying
|
// 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
|
package thinking
|
||||||
|
|
||||||
import "github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
|
import "github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ var oauthProviders = []oauthProvider{
|
|||||||
{"Codex (OpenAI)", "codex-auth-url", "🟩"},
|
{"Codex (OpenAI)", "codex-auth-url", "🟩"},
|
||||||
{"Antigravity", "antigravity-auth-url", "🟪"},
|
{"Antigravity", "antigravity-auth-url", "🟪"},
|
||||||
{"Kimi", "kimi-auth-url", "🟫"},
|
{"Kimi", "kimi-auth-url", "🟫"},
|
||||||
{"IFlow", "iflow-auth-url", "⬜"},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// oauthTabModel handles OAuth login flows.
|
// oauthTabModel handles OAuth login flows.
|
||||||
@@ -281,8 +280,6 @@ func (m oauthTabModel) submitCallback(callbackURL string) tea.Cmd {
|
|||||||
providerKey = "antigravity"
|
providerKey = "antigravity"
|
||||||
case "kimi-auth-url":
|
case "kimi-auth-url":
|
||||||
providerKey = "kimi"
|
providerKey = "kimi"
|
||||||
case "iflow-auth-url":
|
|
||||||
providerKey = "iflow"
|
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,8 +18,6 @@ type ManagementTokenRequester interface {
|
|||||||
RequestCodexToken(*gin.Context)
|
RequestCodexToken(*gin.Context)
|
||||||
RequestAntigravityToken(*gin.Context)
|
RequestAntigravityToken(*gin.Context)
|
||||||
RequestKimiToken(*gin.Context)
|
RequestKimiToken(*gin.Context)
|
||||||
RequestIFlowToken(*gin.Context)
|
|
||||||
RequestIFlowCookieToken(*gin.Context)
|
|
||||||
GetAuthStatus(c *gin.Context)
|
GetAuthStatus(c *gin.Context)
|
||||||
PostOAuthCallback(c *gin.Context)
|
PostOAuthCallback(c *gin.Context)
|
||||||
}
|
}
|
||||||
@@ -55,14 +53,6 @@ func (m *managementTokenRequester) RequestKimiToken(c *gin.Context) {
|
|||||||
m.handler.RequestKimiToken(c)
|
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) {
|
func (m *managementTokenRequester) GetAuthStatus(c *gin.Context) {
|
||||||
m.handler.GetAuthStatus(c)
|
m.handler.GetAuthStatus(c)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -9,7 +9,6 @@ import (
|
|||||||
func init() {
|
func init() {
|
||||||
registerRefreshLead("codex", func() Authenticator { return NewCodexAuthenticator() })
|
registerRefreshLead("codex", func() Authenticator { return NewCodexAuthenticator() })
|
||||||
registerRefreshLead("claude", func() Authenticator { return NewClaudeAuthenticator() })
|
registerRefreshLead("claude", func() Authenticator { return NewClaudeAuthenticator() })
|
||||||
registerRefreshLead("iflow", func() Authenticator { return NewIFlowAuthenticator() })
|
|
||||||
registerRefreshLead("gemini", func() Authenticator { return NewGeminiAuthenticator() })
|
registerRefreshLead("gemini", func() Authenticator { return NewGeminiAuthenticator() })
|
||||||
registerRefreshLead("gemini-cli", func() Authenticator { return NewGeminiAuthenticator() })
|
registerRefreshLead("gemini-cli", func() Authenticator { return NewGeminiAuthenticator() })
|
||||||
registerRefreshLead("antigravity", func() Authenticator { return NewAntigravityAuthenticator() })
|
registerRefreshLead("antigravity", func() Authenticator { return NewAntigravityAuthenticator() })
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ func TestManager_ShouldRetryAfterError_UsesOAuthModelAliasForCooldown(t *testing
|
|||||||
m := NewManager(nil, nil, nil)
|
m := NewManager(nil, nil, nil)
|
||||||
m.SetRetryConfig(3, 30*time.Second, 0)
|
m.SetRetryConfig(3, 30*time.Second, 0)
|
||||||
m.SetOAuthModelAlias(map[string][]internalconfig.OAuthModelAlias{
|
m.SetOAuthModelAlias(map[string][]internalconfig.OAuthModelAlias{
|
||||||
"iflow": {
|
"kimi": {
|
||||||
{Name: "deepseek-v3.1", Alias: "pool-model"},
|
{Name: "deepseek-v3.1", Alias: "pool-model"},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -80,7 +80,7 @@ func TestManager_ShouldRetryAfterError_UsesOAuthModelAliasForCooldown(t *testing
|
|||||||
|
|
||||||
auth := &Auth{
|
auth := &Auth{
|
||||||
ID: "auth-1",
|
ID: "auth-1",
|
||||||
Provider: "iflow",
|
Provider: "kimi",
|
||||||
ModelStates: map[string]*ModelState{
|
ModelStates: map[string]*ModelState{
|
||||||
upstreamModel: {
|
upstreamModel: {
|
||||||
Unavailable: true,
|
Unavailable: true,
|
||||||
@@ -99,7 +99,7 @@ func TestManager_ShouldRetryAfterError_UsesOAuthModelAliasForCooldown(t *testing
|
|||||||
}
|
}
|
||||||
|
|
||||||
_, _, maxWait := m.retrySettings()
|
_, _, 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 {
|
if !shouldRetry {
|
||||||
t.Fatalf("expected shouldRetry=true, got false (wait=%v)", wait)
|
t.Fatalf("expected shouldRetry=true, got false (wait=%v)", wait)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -265,7 +265,7 @@ func modelAliasChannel(auth *Auth) string {
|
|||||||
// and auth kind. Returns empty string if the provider/authKind combination doesn't support
|
// and auth kind. Returns empty string if the provider/authKind combination doesn't support
|
||||||
// OAuth model alias (e.g., API key authentication).
|
// 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 {
|
func OAuthModelAliasChannel(provider, authKind string) string {
|
||||||
provider = strings.ToLower(strings.TrimSpace(provider))
|
provider = strings.ToLower(strings.TrimSpace(provider))
|
||||||
authKind = strings.ToLower(strings.TrimSpace(authKind))
|
authKind = strings.ToLower(strings.TrimSpace(authKind))
|
||||||
@@ -289,7 +289,7 @@ func OAuthModelAliasChannel(provider, authKind string) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
return "codex"
|
return "codex"
|
||||||
case "gemini-cli", "aistudio", "antigravity", "iflow", "kimi":
|
case "gemini-cli", "aistudio", "antigravity", "kimi":
|
||||||
return provider
|
return provider
|
||||||
default:
|
default:
|
||||||
return ""
|
return ""
|
||||||
|
|||||||
@@ -157,8 +157,6 @@ func createAuthForChannel(channel string) *Auth {
|
|||||||
return &Auth{Provider: "aistudio"}
|
return &Auth{Provider: "aistudio"}
|
||||||
case "antigravity":
|
case "antigravity":
|
||||||
return &Auth{Provider: "antigravity"}
|
return &Auth{Provider: "antigravity"}
|
||||||
case "iflow":
|
|
||||||
return &Auth{Provider: "iflow"}
|
|
||||||
case "kimi":
|
case "kimi":
|
||||||
return &Auth{Provider: "kimi"}
|
return &Auth{Provider: "kimi"}
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -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)
|
// Check metadata for email first (OAuth-style auth)
|
||||||
if a.Metadata != nil {
|
if a.Metadata != nil {
|
||||||
if v, ok := a.Metadata["email"].(string); ok {
|
if v, ok := a.Metadata["email"].(string); ok {
|
||||||
|
|||||||
@@ -392,7 +392,7 @@ func (s *Service) ensureExecutorsForAuthWithMode(a *coreauth.Auth, forceReplace
|
|||||||
}
|
}
|
||||||
// Skip disabled auth entries when (re)binding executors.
|
// Skip disabled auth entries when (re)binding executors.
|
||||||
// Disabled auths can linger during config reloads (e.g., removed OpenAI-compat entries)
|
// 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 {
|
if a.Disabled {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -422,8 +422,6 @@ func (s *Service) ensureExecutorsForAuthWithMode(a *coreauth.Auth, forceReplace
|
|||||||
s.coreManager.RegisterExecutor(executor.NewAntigravityExecutor(s.cfg))
|
s.coreManager.RegisterExecutor(executor.NewAntigravityExecutor(s.cfg))
|
||||||
case "claude":
|
case "claude":
|
||||||
s.coreManager.RegisterExecutor(executor.NewClaudeExecutor(s.cfg))
|
s.coreManager.RegisterExecutor(executor.NewClaudeExecutor(s.cfg))
|
||||||
case "iflow":
|
|
||||||
s.coreManager.RegisterExecutor(executor.NewIFlowExecutor(s.cfg))
|
|
||||||
case "kimi":
|
case "kimi":
|
||||||
s.coreManager.RegisterExecutor(executor.NewKimiExecutor(s.cfg))
|
s.coreManager.RegisterExecutor(executor.NewKimiExecutor(s.cfg))
|
||||||
default:
|
default:
|
||||||
@@ -926,9 +924,6 @@ func (s *Service) registerModelsForAuth(a *coreauth.Auth) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
models = applyExcludedModels(models, excluded)
|
models = applyExcludedModels(models, excluded)
|
||||||
case "iflow":
|
|
||||||
models = registry.GetIFlowModels()
|
|
||||||
models = applyExcludedModels(models, excluded)
|
|
||||||
case "kimi":
|
case "kimi":
|
||||||
models = registry.GetKimiModels()
|
models = registry.GetKimiModels()
|
||||||
models = applyExcludedModels(models, excluded)
|
models = applyExcludedModels(models, excluded)
|
||||||
|
|||||||
@@ -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/codex"
|
||||||
_ "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking/provider/gemini"
|
_ "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/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/kimi"
|
||||||
_ "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking/provider/openai"
|
_ "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking/provider/openai"
|
||||||
|
|
||||||
@@ -1067,184 +1066,6 @@ func TestThinkingE2EMatrix_Suffix(t *testing.T) {
|
|||||||
expectErr: false,
|
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)
|
// Gemini Family Cross-Channel Consistency (Cases 106-114)
|
||||||
// Tests that gemini/gemini-cli/antigravity as same API family should have consistent validation behavior
|
// 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,
|
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)
|
// Gemini Family Cross-Channel Consistency (Cases 106-114)
|
||||||
// Tests that gemini/gemini-cli/antigravity as same API family should have consistent validation behavior
|
// 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",
|
expectValue: "high",
|
||||||
expectErr: false,
|
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",
|
name: "C21",
|
||||||
from: "claude",
|
from: "claude",
|
||||||
@@ -3215,24 +2837,6 @@ func getTestModels() []*registry.ModelInfo {
|
|||||||
UserDefined: true,
|
UserDefined: true,
|
||||||
Thinking: nil,
|
Thinking: nil,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
ID: "glm-test",
|
|
||||||
Object: "model",
|
|
||||||
Created: 1700000000,
|
|
||||||
OwnedBy: "test",
|
|
||||||
Type: "iflow",
|
|
||||||
DisplayName: "GLM Test Model",
|
|
||||||
Thinking: ®istry.ThinkingSupport{Levels: []string{"none", "auto", "minimal", "low", "medium", "high", "xhigh"}},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
ID: "minimax-test",
|
|
||||||
Object: "model",
|
|
||||||
Created: 1700000000,
|
|
||||||
OwnedBy: "test",
|
|
||||||
Type: "iflow",
|
|
||||||
DisplayName: "MiniMax Test Model",
|
|
||||||
Thinking: ®istry.ThinkingSupport{Levels: []string{"none", "auto", "minimal", "low", "medium", "high", "xhigh"}},
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3247,10 +2851,6 @@ func runThinkingTests(t *testing.T, cases []thinkingTestCase) {
|
|||||||
|
|
||||||
translateTo := tc.to
|
translateTo := tc.to
|
||||||
applyTo := tc.to
|
applyTo := tc.to
|
||||||
if tc.to == "iflow" {
|
|
||||||
translateTo = "openai"
|
|
||||||
applyTo = "iflow"
|
|
||||||
}
|
|
||||||
|
|
||||||
body := sdktranslator.TranslateRequest(
|
body := sdktranslator.TranslateRequest(
|
||||||
sdktranslator.FromString(tc.from),
|
sdktranslator.FromString(tc.from),
|
||||||
@@ -3290,8 +2890,6 @@ func runThinkingTests(t *testing.T, cases []thinkingTestCase) {
|
|||||||
hasThinking = gjson.GetBytes(body, "reasoning_effort").Exists()
|
hasThinking = gjson.GetBytes(body, "reasoning_effort").Exists()
|
||||||
case "codex":
|
case "codex":
|
||||||
hasThinking = gjson.GetBytes(body, "reasoning.effort").Exists() || gjson.GetBytes(body, "reasoning").Exists()
|
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 {
|
if hasThinking {
|
||||||
t.Fatalf("expected no thinking field but found one, body=%s", string(body))
|
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))
|
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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user