diff --git a/MANAGEMENT_API.md b/MANAGEMENT_API.md index 5a062049..898cb12e 100644 --- a/MANAGEMENT_API.md +++ b/MANAGEMENT_API.md @@ -663,6 +663,17 @@ These endpoints initiate provider login flows and return a URL to open in a brow { "status": "ok", "url": "https://..." } ``` +- GET `/iflow-auth-url` — Start iFlow login + - Request: + ```bash + curl -H 'Authorization: Bearer ' \ + http://localhost:8317/v0/management/iflow-auth-url + ``` + - Response: + ```json + { "status": "ok", "url": "https://..." } + ``` + - GET `/get-auth-status?state=` — Poll OAuth flow status - Request: ```bash diff --git a/MANAGEMENT_API_CN.md b/MANAGEMENT_API_CN.md index bb0d8ee0..7ae97f8f 100644 --- a/MANAGEMENT_API_CN.md +++ b/MANAGEMENT_API_CN.md @@ -663,6 +663,17 @@ { "status": "ok", "url": "https://..." } ``` +- GET `/iflow-auth-url` — 开始 iFlow 登录 + - 请求: + ```bash + curl -H 'Authorization: Bearer ' \ + http://localhost:8317/v0/management/iflow-auth-url + ``` + - 响应: + ```json + { "status": "ok", "url": "https://..." } + ``` + - GET `/get-auth-status?state=` — 轮询 OAuth 流程状态 - 请求: ```bash diff --git a/README.md b/README.md index 32448f0b..397cc504 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ It now also supports OpenAI Codex (GPT models) and Claude Code via OAuth. So you can use local or multi-account CLI access with OpenAI(include Responses)/Gemini/Claude-compatible clients and SDKs. -The first Chinese provider has now been added: [Qwen Code](https://github.com/QwenLM/qwen-code). +Chinese providers have now been added: [Qwen Code](https://github.com/QwenLM/qwen-code), [iFlow](https://iflow.cn/). ## Features @@ -16,19 +16,21 @@ The first Chinese provider has now been added: [Qwen Code](https://github.com/Qw - OpenAI Codex support (GPT models) via OAuth login - Claude Code support via OAuth login - Qwen Code support via OAuth login +- iFlow support via OAuth login - Gemini Web support via cookie-based login - Streaming and non-streaming responses - Function calling/tools support - Multimodal input support (text and images) -- Multiple accounts with round-robin load balancing (Gemini, OpenAI, Claude and Qwen) -- Simple CLI authentication flows (Gemini, OpenAI, Claude and Qwen) +- Multiple accounts with round-robin load balancing (Gemini, OpenAI, Claude, Qwen and iFlow) +- Simple CLI authentication flows (Gemini, OpenAI, Claude, Qwen and iFlow) - Generative Language API Key support - Gemini CLI multi-account load balancing - Claude Code multi-account load balancing - Qwen Code multi-account load balancing +- iFlow multi-account load balancing - OpenAI Codex multi-account load balancing - OpenAI-compatible upstream providers via config (e.g., OpenRouter) -- Reusable Go SDK for embedding the proxy (see `docs/sdk-usage.md`, 中文: `docs/sdk-usage_CN.md`) +- Reusable Go SDK for embedding the proxy (see `docs/sdk-usage.md`) ## Installation @@ -39,6 +41,7 @@ The first Chinese provider has now been added: [Qwen Code](https://github.com/Qw - An OpenAI account for Codex/GPT access (optional) - An Anthropic account for Claude Code access (optional) - A Qwen Chat account for Qwen Code access (optional) +- An iFlow account for iFlow access (optional) ### Building from Source @@ -76,7 +79,7 @@ Set `remote-management.disable-control-panel` to `true` if you prefer to host th ### Authentication -You can authenticate for Gemini, OpenAI, and/or Claude. All can coexist in the same `auth-dir` and will be load balanced. +You can authenticate for Gemini, OpenAI, Claude, Qwen, and/or iFlow. All can coexist in the same `auth-dir` and will be load balanced. - Gemini (Google): ```bash @@ -115,6 +118,12 @@ You can authenticate for Gemini, OpenAI, and/or Claude. All can coexist in the s ``` Options: add `--no-browser` to print the login URL instead of opening a browser. Use the Qwen Chat's OAuth device flow. +- iFlow (iFlow via OAuth): + ```bash + ./cli-proxy-api --iflow-login + ``` + Options: add `--no-browser` to print the login URL instead of opening a browser. The local OAuth callback uses port `11451`. + ### Starting the Server @@ -156,7 +165,7 @@ Request body example: ``` Notes: -- Use a `gemini-*` model for Gemini (e.g., "gemini-2.5-pro"), a `gpt-*` model for OpenAI (e.g., "gpt-5"), a `claude-*` model for Claude (e.g., "claude-3-5-sonnet-20241022"), or a `qwen-*` model for Qwen (e.g., "qwen3-coder-plus"). The proxy will route to the correct provider automatically. +- Use a `gemini-*` model for Gemini (e.g., "gemini-2.5-pro"), a `gpt-*` model for OpenAI (e.g., "gpt-5"), a `claude-*` model for Claude (e.g., "claude-3-5-sonnet-20241022"), a `qwen-*` model for Qwen (e.g., "qwen3-coder-plus"), or an iFlow-supported model (e.g., "tstars2.0", "deepseek-v3.1", "kimi-k2", etc.). The proxy will route to the correct provider automatically. #### Claude Messages (SSE-compatible) @@ -259,6 +268,16 @@ console.log(await claudeResponse.json()); - claude-3-5-haiku-20241022 - qwen3-coder-plus - qwen3-coder-flash +- qwen3-max +- qwen3-vl-plus +- deepseek-v3.2 +- deepseek-v3.1 +- deepseek-r1 +- deepseek-v3 +- kimi-k2 +- glm-4.5 +- tstars2.0 +- And other iFlow-supported models - Gemini models auto-switch to preview variants when needed ## Configuration @@ -532,6 +551,14 @@ export ANTHROPIC_MODEL=qwen3-coder-plus export ANTHROPIC_SMALL_FAST_MODEL=qwen3-coder-flash ``` +Using iFlow models: +```bash +export ANTHROPIC_BASE_URL=http://127.0.0.1:8317 +export ANTHROPIC_AUTH_TOKEN=sk-dummy +export ANTHROPIC_MODEL=qwen3-max +export ANTHROPIC_SMALL_FAST_MODEL=qwen3-235b-a22b-instruct +``` + ## Codex with multiple account load balancing Start CLI Proxy API server, and then edit the `~/.codex/config.toml` and `~/.codex/auth.json` files. @@ -587,6 +614,12 @@ Run the following command to login (Qwen OAuth): docker run -it -rm -v /path/to/your/config.yaml:/CLIProxyAPI/config.yaml -v /path/to/your/auth-dir:/root/.cli-proxy-api eceasy/cli-proxy-api:latest /CLIProxyAPI/CLIProxyAPI --qwen-login ``` +Run the following command to login (iFlow OAuth on port 11451): + +```bash +docker run --rm -p 11451:11451 -v /path/to/your/config.yaml:/CLIProxyAPI/config.yaml -v /path/to/your/auth-dir:/root/.cli-proxy-api eceasy/cli-proxy-api:latest /CLIProxyAPI/CLIProxyAPI --iflow-login +``` + Run the following command to start the server: ```bash @@ -645,10 +678,14 @@ docker run --rm -p 8317:8317 -v /path/to/your/config.yaml:/CLIProxyAPI/config.ya ```bash docker compose exec cli-proxy-api /CLIProxyAPI/CLIProxyAPI -no-browser --claude-login ``` - - **Qwen**: + - **Qwen**: ```bash docker compose exec cli-proxy-api /CLIProxyAPI/CLIProxyAPI -no-browser --qwen-login ``` + - **iFlow**: + ```bash + docker compose exec cli-proxy-api /CLIProxyAPI/CLIProxyAPI -no-browser --iflow-login + ``` 5. To view the server logs: ```bash diff --git a/README_CN.md b/README_CN.md index d440f831..9f21980c 100644 --- a/README_CN.md +++ b/README_CN.md @@ -28,7 +28,7 @@ 您可以使用本地或多账户的CLI方式,通过任何与 OpenAI(包括Responses)/Gemini/Claude 兼容的客户端和SDK进行访问。 -现已新增首个中国提供商:[Qwen Code](https://github.com/QwenLM/qwen-code)。 +现已新增国内提供商:[Qwen Code](https://github.com/QwenLM/qwen-code)、[iFlow](https://iflow.cn/)。 ## 功能特性 @@ -36,19 +36,21 @@ - 新增 OpenAI Codex(GPT 系列)支持(OAuth 登录) - 新增 Claude Code 支持(OAuth 登录) - 新增 Qwen Code 支持(OAuth 登录) +- 新增 iFlow 支持(OAuth 登录) - 新增 Gemini Web 支持(通过 Cookie 登录) - 支持流式与非流式响应 - 函数调用/工具支持 - 多模态输入(文本、图片) -- 多账户支持与轮询负载均衡(Gemini、OpenAI、Claude 与 Qwen) -- 简单的 CLI 身份验证流程(Gemini、OpenAI、Claude 与 Qwen) +- 多账户支持与轮询负载均衡(Gemini、OpenAI、Claude、Qwen 与 iFlow) +- 简单的 CLI 身份验证流程(Gemini、OpenAI、Claude、Qwen 与 iFlow) - 支持 Gemini AIStudio API 密钥 - 支持 Gemini CLI 多账户轮询 - 支持 Claude Code 多账户轮询 - 支持 Qwen Code 多账户轮询 +- 支持 iFlow 多账户轮询 - 支持 OpenAI Codex 多账户轮询 - 通过配置接入上游 OpenAI 兼容提供商(例如 OpenRouter) -- 可复用的 Go SDK(见 `docs/sdk-usage.md`) +- 可复用的 Go SDK(见 `docs/sdk-usage_CN.md`) ## 安装 @@ -59,6 +61,7 @@ - 有权访问 OpenAI Codex/GPT 的 OpenAI 账户(可选) - 有权访问 Claude Code 的 Anthropic 账户(可选) - 有权访问 Qwen Code 的 Qwen Chat 账户(可选) +- 有权访问 iFlow 的 iFlow 账户(可选) ### 从源码构建 @@ -89,7 +92,7 @@ CLIProxyAPI 的基于 Web 的管理中心。 ### 身份验证 -您可以分别为 Gemini、OpenAI 和 Claude 进行身份验证,三者可同时存在于同一个 `auth-dir` 中并参与负载均衡。 +您可以分别为 Gemini、OpenAI、Claude、Qwen 和 iFlow 进行身份验证,它们可同时存在于同一个 `auth-dir` 中并参与负载均衡。 - Gemini(Google): ```bash @@ -128,6 +131,12 @@ CLIProxyAPI 的基于 Web 的管理中心。 ``` 选项:加上 `--no-browser` 可打印登录地址而不自动打开浏览器。使用 Qwen Chat 的 OAuth 设备登录流程。 +- iFlow(iFlow,OAuth): + ```bash + ./cli-proxy-api --iflow-login + ``` + 选项:加上 `--no-browser` 可打印登录地址而不自动打开浏览器。本地 OAuth 回调端口为 `11451`。 + ### 启动服务器 身份验证完成后,启动服务器: @@ -168,7 +177,7 @@ POST http://localhost:8317/v1/chat/completions ``` 说明: -- 使用 "gemini-*" 模型(例如 "gemini-2.5-pro")来调用 Gemini,使用 "gpt-*" 模型(例如 "gpt-5")来调用 OpenAI,使用 "claude-*" 模型(例如 "claude-3-5-sonnet-20241022")来调用 Claude,或者使用 "qwen-*" 模型(例如 "qwen3-coder-plus")来调用 Qwen。代理服务会自动将请求路由到相应的提供商。 +- 使用 "gemini-*" 模型(例如 "gemini-2.5-pro")来调用 Gemini,使用 "gpt-*" 模型(例如 "gpt-5")来调用 OpenAI,使用 "claude-*" 模型(例如 "claude-3-5-sonnet-20241022")来调用 Claude,使用 "qwen-*" 模型(例如 "qwen3-coder-plus")来调用 Qwen,或者使用 iFlow 支持的模型(例如 "tstars2.0"、"deepseek-v3.1"、"kimi-k2" 等)来调用 iFlow。代理服务会自动将请求路由到相应的提供商。 #### Claude 消息(SSE 兼容) @@ -271,6 +280,16 @@ console.log(await claudeResponse.json()); - claude-3-5-haiku-20241022 - qwen3-coder-plus - qwen3-coder-flash +- qwen3-max +- qwen3-vl-plus +- deepseek-v3.2 +- deepseek-v3.1 +- deepseek-r1 +- deepseek-v3 +- kimi-k2 +- glm-4.5 +- tstars2.0 +- 以及其他 iFlow 支持的模型 - Gemini 模型在需要时自动切换到对应的 preview 版本 ## 配置 @@ -540,6 +559,14 @@ export ANTHROPIC_MODEL=qwen3-coder-plus export ANTHROPIC_SMALL_FAST_MODEL=qwen3-coder-flash ``` +使用 iFlow 模型: +```bash +export ANTHROPIC_BASE_URL=http://127.0.0.1:8317 +export ANTHROPIC_AUTH_TOKEN=sk-dummy +export ANTHROPIC_MODEL=qwen3-max +export ANTHROPIC_SMALL_FAST_MODEL=qwen3-235b-a22b-instruct +``` + ## Codex 多账户负载均衡 启动 CLI Proxy API 服务器, 修改 `~/.codex/config.toml` 和 `~/.codex/auth.json` 文件。 @@ -595,6 +622,12 @@ docker run --rm -p 54545:54545 -v /path/to/your/config.yaml:/CLIProxyAPI/config. docker run -it -rm -v /path/to/your/config.yaml:/CLIProxyAPI/config.yaml -v /path/to/your/auth-dir:/root/.cli-proxy-api eceasy/cli-proxy-api:latest /CLIProxyAPI/CLIProxyAPI --qwen-login ``` +运行以下命令进行登录(iFlow OAuth,端口 11451): + +```bash +docker run --rm -p 11451:11451 -v /path/to/your/config.yaml:/CLIProxyAPI/config.yaml -v /path/to/your/auth-dir:/root/.cli-proxy-api eceasy/cli-proxy-api:latest /CLIProxyAPI/CLIProxyAPI --iflow-login +``` + 运行以下命令启动服务器: @@ -658,6 +691,10 @@ docker run --rm -p 8317:8317 -v /path/to/your/config.yaml:/CLIProxyAPI/config.ya ```bash docker compose exec cli-proxy-api /CLIProxyAPI/CLIProxyAPI -no-browser --qwen-login ``` + - **iFlow**: + ```bash + docker compose exec cli-proxy-api /CLIProxyAPI/CLIProxyAPI -no-browser --iflow-login + ``` 5. 查看服务器日志: ```bash diff --git a/cmd/server/main.go b/cmd/server/main.go index 3e0b4cf2..1f93a12f 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -42,6 +42,7 @@ func main() { var codexLogin bool var claudeLogin bool var qwenLogin bool + var iflowLogin bool var geminiWebAuth bool var noBrowser bool var projectID string @@ -53,6 +54,7 @@ func main() { flag.BoolVar(&codexLogin, "codex-login", false, "Login to Codex using OAuth") flag.BoolVar(&claudeLogin, "claude-login", false, "Login to Claude using OAuth") flag.BoolVar(&qwenLogin, "qwen-login", false, "Login to Qwen using OAuth") + flag.BoolVar(&iflowLogin, "iflow-login", false, "Login to iFlow using OAuth") flag.BoolVar(&geminiWebAuth, "gemini-web-auth", false, "Auth Gemini Web using cookies") flag.BoolVar(&noBrowser, "no-browser", false, "Don't open browser automatically for OAuth") flag.StringVar(&projectID, "project_id", "", "Project ID (Gemini only, not required)") @@ -153,6 +155,8 @@ func main() { cmd.DoClaudeLogin(cfg, options) } else if qwenLogin { cmd.DoQwenLogin(cfg, options) + } else if iflowLogin { + cmd.DoIFlowLogin(cfg, options) } else if geminiWebAuth { cmd.DoGeminiWebAuth(cfg) } else { diff --git a/docker-compose.yml b/docker-compose.yml index aadb5c56..753bf5c9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,9 +15,10 @@ services: - "8085:8085" - "1455:1455" - "54545:54545" + - "11451:11451" volumes: - ./config.yaml:/CLIProxyAPI/config.yaml - ./auths:/root/.cli-proxy-api - ./logs:/CLIProxyAPI/logs - ./conv:/CLIProxyAPI/conv - restart: unless-stopped \ No newline at end of file + restart: unless-stopped diff --git a/internal/api/handlers/management/auth_files.go b/internal/api/handlers/management/auth_files.go index cd970963..053d57dd 100644 --- a/internal/api/handlers/management/auth_files.go +++ b/internal/api/handlers/management/auth_files.go @@ -19,6 +19,7 @@ import ( "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/claude" "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/codex" geminiAuth "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/gemini" + iflowauth "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/iflow" "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/qwen" // legacy client removed "github.com/router-for-me/CLIProxyAPI/v6/internal/misc" @@ -958,6 +959,93 @@ func (h *Handler) RequestQwenToken(c *gin.Context) { c.JSON(200, gin.H{"status": "ok", "url": authURL, "state": state}) } +func (h *Handler) RequestIFlowToken(c *gin.Context) { + ctx := context.Background() + + fmt.Println("Initializing iFlow authentication...") + + state := fmt.Sprintf("ifl-%d", time.Now().UnixNano()) + authSvc := iflowauth.NewIFlowAuth(h.cfg) + oauthServer := iflowauth.NewOAuthServer(iflowauth.CallbackPort) + if err := oauthServer.Start(); err != nil { + oauthStatus[state] = "Failed to start authentication server" + log.Errorf("Failed to start iFlow OAuth server: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"status": "error", "error": "failed to start local oauth server"}) + return + } + + authURL, redirectURI := authSvc.AuthorizationURL(state, iflowauth.CallbackPort) + + go func() { + fmt.Println("Waiting for authentication...") + defer func() { + stopCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + if err := oauthServer.Stop(stopCtx); err != nil { + log.Warnf("Failed to stop iFlow OAuth server: %v", err) + } + }() + + result, err := oauthServer.WaitForCallback(5 * time.Minute) + if err != nil { + oauthStatus[state] = "Authentication failed" + fmt.Printf("Authentication failed: %v\n", err) + return + } + + if result.Error != "" { + oauthStatus[state] = "Authentication failed" + fmt.Printf("Authentication failed: %s\n", result.Error) + return + } + + if result.State != state { + oauthStatus[state] = "Authentication failed" + fmt.Println("Authentication failed: state mismatch") + return + } + + tokenData, errExchange := authSvc.ExchangeCodeForTokens(ctx, result.Code, redirectURI) + if errExchange != nil { + oauthStatus[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("iflow-%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 { + oauthStatus[state] = "Failed to save authentication tokens" + log.Fatalf("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") + delete(oauthStatus, state) + }() + + oauthStatus[state] = "" + c.JSON(200, gin.H{"status": "ok", "url": authURL, "state": state}) +} + func (h *Handler) GetAuthStatus(c *gin.Context) { state := c.Query("state") if err, ok := oauthStatus[state]; ok { diff --git a/internal/api/server.go b/internal/api/server.go index 9751f41d..2fb74e03 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -407,6 +407,7 @@ func (s *Server) registerManagementRoutes() { mgmt.GET("/gemini-cli-auth-url", s.mgmt.RequestGeminiCLIToken) mgmt.POST("/gemini-web-token", s.mgmt.CreateGeminiWebToken) mgmt.GET("/qwen-auth-url", s.mgmt.RequestQwenToken) + mgmt.GET("/iflow-auth-url", s.mgmt.RequestIFlowToken) mgmt.GET("/get-auth-status", s.mgmt.GetAuthStatus) } } diff --git a/internal/auth/iflow/iflow_auth.go b/internal/auth/iflow/iflow_auth.go new file mode 100644 index 00000000..f8480f19 --- /dev/null +++ b/internal/auth/iflow/iflow_auth.go @@ -0,0 +1,275 @@ +package iflow + +import ( + "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" + + // 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 { + client := &http.Client{Timeout: 30 * time.Second} + return &IFlowAuth{httpClient: util.SetProxy(&cfg.SDKConfig, 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 == "" { + 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 +} + +// 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"` +} diff --git a/internal/auth/iflow/iflow_token.go b/internal/auth/iflow/iflow_token.go new file mode 100644 index 00000000..154ac4dd --- /dev/null +++ b/internal/auth/iflow/iflow_token.go @@ -0,0 +1,43 @@ +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"` + Type string `json:"type"` +} + +// 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() }() + + if err = json.NewEncoder(f).Encode(ts); err != nil { + return fmt.Errorf("iflow token: encode token failed: %w", err) + } + return nil +} diff --git a/internal/auth/iflow/oauth_server.go b/internal/auth/iflow/oauth_server.go new file mode 100644 index 00000000..2a8b7b9f --- /dev/null +++ b/internal/auth/iflow/oauth_server.go @@ -0,0 +1,143 @@ +package iflow + +import ( + "context" + "fmt" + "net" + "net/http" + "strings" + "sync" + "time" + + log "github.com/sirupsen/logrus" +) + +const errorRedirectURL = "https://iflow.cn/oauth/error" + +// OAuthResult captures the outcome of the local OAuth callback. +type OAuthResult struct { + Code string + State string + Error string +} + +// OAuthServer provides a minimal HTTP server for handling the iFlow OAuth callback. +type OAuthServer struct { + server *http.Server + port int + result chan *OAuthResult + errChan chan error + mu sync.Mutex + running bool +} + +// NewOAuthServer constructs a new OAuthServer bound to the provided port. +func NewOAuthServer(port int) *OAuthServer { + return &OAuthServer{ + port: port, + result: make(chan *OAuthResult, 1), + errChan: make(chan error, 1), + } +} + +// Start launches the callback listener. +func (s *OAuthServer) Start() error { + s.mu.Lock() + defer s.mu.Unlock() + if s.running { + return fmt.Errorf("iflow oauth server already running") + } + if !s.isPortAvailable() { + return fmt.Errorf("port %d is already in use", s.port) + } + + mux := http.NewServeMux() + mux.HandleFunc("/oauth2callback", s.handleCallback) + + s.server = &http.Server{ + Addr: fmt.Sprintf(":%d", s.port), + Handler: mux, + ReadTimeout: 10 * time.Second, + WriteTimeout: 10 * time.Second, + } + + s.running = true + + go func() { + if err := s.server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + s.errChan <- err + } + }() + + time.Sleep(100 * time.Millisecond) + return nil +} + +// Stop gracefully terminates the callback listener. +func (s *OAuthServer) Stop(ctx context.Context) error { + s.mu.Lock() + defer s.mu.Unlock() + if !s.running || s.server == nil { + return nil + } + defer func() { + s.running = false + s.server = nil + }() + return s.server.Shutdown(ctx) +} + +// WaitForCallback blocks until a callback result, server error, or timeout occurs. +func (s *OAuthServer) WaitForCallback(timeout time.Duration) (*OAuthResult, error) { + select { + case res := <-s.result: + return res, nil + case err := <-s.errChan: + return nil, err + case <-time.After(timeout): + return nil, fmt.Errorf("timeout waiting for OAuth callback") + } +} + +func (s *OAuthServer) handleCallback(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + query := r.URL.Query() + if errParam := strings.TrimSpace(query.Get("error")); errParam != "" { + s.sendResult(&OAuthResult{Error: errParam}) + http.Redirect(w, r, errorRedirectURL, http.StatusFound) + return + } + + code := strings.TrimSpace(query.Get("code")) + if code == "" { + s.sendResult(&OAuthResult{Error: "missing_code"}) + http.Redirect(w, r, errorRedirectURL, http.StatusFound) + return + } + + state := query.Get("state") + s.sendResult(&OAuthResult{Code: code, State: state}) + http.Redirect(w, r, SuccessRedirectURL, http.StatusFound) +} + +func (s *OAuthServer) sendResult(res *OAuthResult) { + select { + case s.result <- res: + default: + log.Debug("iflow oauth result channel full, dropping result") + } +} + +func (s *OAuthServer) isPortAvailable() bool { + addr := fmt.Sprintf(":%d", s.port) + listener, err := net.Listen("tcp", addr) + if err != nil { + return false + } + _ = listener.Close() + return true +} diff --git a/internal/cmd/auth_manager.go b/internal/cmd/auth_manager.go index 220aa43d..6514c1cb 100644 --- a/internal/cmd/auth_manager.go +++ b/internal/cmd/auth_manager.go @@ -17,6 +17,7 @@ func newAuthManager() *sdkAuth.Manager { sdkAuth.NewCodexAuthenticator(), sdkAuth.NewClaudeAuthenticator(), sdkAuth.NewQwenAuthenticator(), + sdkAuth.NewIFlowAuthenticator(), ) return manager } diff --git a/internal/cmd/iflow_login.go b/internal/cmd/iflow_login.go new file mode 100644 index 00000000..ba43470b --- /dev/null +++ b/internal/cmd/iflow_login.go @@ -0,0 +1,54 @@ +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 = func(prompt string) (string, error) { + fmt.Println() + fmt.Println(prompt) + var value string + _, err := fmt.Scanln(&value) + return value, err + } + } + + authOpts := &sdkAuth.LoginOptions{ + NoBrowser: options.NoBrowser, + Metadata: map[string]string{}, + Prompt: promptFn, + } + + _, savedPath, err := manager.Login(context.Background(), "iflow", cfg, authOpts) + if err != nil { + var emailErr *sdkAuth.EmailRequiredError + if errors.As(err, &emailErr) { + log.Error(emailErr.Error()) + return + } + fmt.Printf("iFlow authentication failed: %v\n", err) + return + } + + if savedPath != "" { + fmt.Printf("Authentication saved to %s\n", savedPath) + } + + fmt.Println("iFlow authentication successful!") +} diff --git a/internal/registry/model_definitions.go b/internal/registry/model_definitions.go index 530309a3..0dd537b4 100644 --- a/internal/registry/model_definitions.go +++ b/internal/registry/model_definitions.go @@ -322,3 +322,45 @@ func GetQwenModels() []*ModelInfo { }, } } + +// GetIFlowModels returns supported models for iFlow OAuth accounts. + +func GetIFlowModels() []*ModelInfo { + created := time.Now().Unix() + entries := []struct { + ID string + DisplayName string + Description string + }{ + {ID: "tstars2.0", DisplayName: "TStars-2.0", Description: "iFlow TStars-2.0 multimodal assistant"}, + {ID: "qwen3-coder-plus", DisplayName: "Qwen3-Coder-Plus", Description: "Qwen3 Coder Plus code generation"}, + {ID: "qwen3-coder", DisplayName: "Qwen3-Coder-480B-A35B", Description: "Qwen3 Coder 480B A35B"}, + {ID: "qwen3-max", DisplayName: "Qwen3-Max", Description: "Qwen3 flagship model"}, + {ID: "qwen3-vl-plus", DisplayName: "Qwen3-VL-Plus", Description: "Qwen3 multimodal vision-language"}, + {ID: "qwen3-max-preview", DisplayName: "Qwen3-Max-Preview", Description: "Qwen3 Max preview build"}, + {ID: "kimi-k2-0905", DisplayName: "Kimi-K2-Instruct-0905", Description: "Moonshot Kimi K2 instruct 0905"}, + {ID: "glm-4.5", DisplayName: "GLM-4.5", Description: "Zhipu GLM 4.5 general model"}, + {ID: "kimi-k2", DisplayName: "Kimi-K2", Description: "Moonshot Kimi K2 general model"}, + {ID: "deepseek-v3.2", DisplayName: "DeepSeek-V3.2-Exp", Description: "DeepSeek V3.2 experimental"}, + {ID: "deepseek-v3.1", DisplayName: "DeepSeek-V3.1-Terminus", Description: "DeepSeek V3.1 Terminus"}, + {ID: "deepseek-r1", DisplayName: "DeepSeek-R1", Description: "DeepSeek reasoning model R1"}, + {ID: "deepseek-v3", DisplayName: "DeepSeek-V3-671B", Description: "DeepSeek V3 671B"}, + {ID: "qwen3-32b", DisplayName: "Qwen3-32B", Description: "Qwen3 32B"}, + {ID: "qwen3-235b-a22b-thinking-2507", DisplayName: "Qwen3-235B-A22B-Thinking", Description: "Qwen3 235B A22B Thinking (2507)"}, + {ID: "qwen3-235b-a22b-instruct", DisplayName: "Qwen3-235B-A22B-Instruct", Description: "Qwen3 235B A22B Instruct"}, + {ID: "qwen3-235b", DisplayName: "Qwen3-235B-A22B", Description: "Qwen3 235B A22B"}, + } + models := make([]*ModelInfo, 0, len(entries)) + for _, entry := range entries { + models = append(models, &ModelInfo{ + ID: entry.ID, + Object: "model", + Created: created, + OwnedBy: "iflow", + Type: "iflow", + DisplayName: entry.DisplayName, + Description: entry.Description, + }) + } + return models +} diff --git a/internal/runtime/executor/iflow_executor.go b/internal/runtime/executor/iflow_executor.go new file mode 100644 index 00000000..7768fd84 --- /dev/null +++ b/internal/runtime/executor/iflow_executor.go @@ -0,0 +1,261 @@ +package executor + +import ( + "bufio" + "bytes" + "context" + "fmt" + "io" + "net/http" + "strings" + "time" + + iflowauth "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/iflow" + "github.com/router-for-me/CLIProxyAPI/v6/internal/config" + 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 implements ProviderExecutor but requires no preprocessing. +func (e *IFlowExecutor) PrepareRequest(_ *http.Request, _ *cliproxyauth.Auth) error { return nil } + +// Execute performs a non-streaming chat completion request. +func (e *IFlowExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) { + apiKey, baseURL := iflowCreds(auth) + if strings.TrimSpace(apiKey) == "" { + return cliproxyexecutor.Response{}, fmt.Errorf("iflow executor: missing api key") + } + if baseURL == "" { + baseURL = iflowauth.DefaultAPIBaseURL + } + + reporter := newUsageReporter(ctx, e.Identifier(), req.Model, auth) + + from := opts.SourceFormat + to := sdktranslator.FromString("openai") + body := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), false) + + endpoint := strings.TrimSuffix(baseURL, "/") + iflowDefaultEndpoint + recordAPIRequest(ctx, e.cfg, body) + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body)) + if err != nil { + return cliproxyexecutor.Response{}, err + } + applyIFlowHeaders(httpReq, apiKey, false) + + httpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0) + resp, err := httpClient.Do(httpReq) + if err != nil { + return cliproxyexecutor.Response{}, err + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + b, _ := io.ReadAll(resp.Body) + appendAPIResponseChunk(ctx, e.cfg, b) + log.Debugf("iflow request error: status %d body %s", resp.StatusCode, string(b)) + return cliproxyexecutor.Response{}, statusErr{code: resp.StatusCode, msg: string(b)} + } + + data, err := io.ReadAll(resp.Body) + if err != nil { + return cliproxyexecutor.Response{}, err + } + appendAPIResponseChunk(ctx, e.cfg, data) + reporter.publish(ctx, parseOpenAIUsage(data)) + + var param any + out := sdktranslator.TranslateNonStream(ctx, to, from, req.Model, bytes.Clone(opts.OriginalRequest), body, data, ¶m) + return cliproxyexecutor.Response{Payload: []byte(out)}, nil +} + +// ExecuteStream performs a streaming chat completion request. +func (e *IFlowExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (<-chan cliproxyexecutor.StreamChunk, error) { + apiKey, baseURL := iflowCreds(auth) + if strings.TrimSpace(apiKey) == "" { + return nil, fmt.Errorf("iflow executor: missing api key") + } + if baseURL == "" { + baseURL = iflowauth.DefaultAPIBaseURL + } + + reporter := newUsageReporter(ctx, e.Identifier(), req.Model, auth) + + from := opts.SourceFormat + to := sdktranslator.FromString("openai") + body := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), true) + + // Ensure tools array exists to avoid provider quirks similar to Qwen's behaviour. + toolsResult := gjson.GetBytes(body, "tools") + if toolsResult.Exists() && toolsResult.IsArray() && len(toolsResult.Array()) == 0 { + body = ensureToolsArray(body) + } + + endpoint := strings.TrimSuffix(baseURL, "/") + iflowDefaultEndpoint + recordAPIRequest(ctx, e.cfg, body) + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body)) + if err != nil { + return nil, err + } + applyIFlowHeaders(httpReq, apiKey, true) + + httpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0) + resp, err := httpClient.Do(httpReq) + if err != nil { + return nil, err + } + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + defer func() { _ = resp.Body.Close() }() + b, _ := io.ReadAll(resp.Body) + appendAPIResponseChunk(ctx, e.cfg, b) + log.Debugf("iflow streaming error: status %d body %s", resp.StatusCode, string(b)) + return nil, statusErr{code: resp.StatusCode, msg: string(b)} + } + + out := make(chan cliproxyexecutor.StreamChunk) + go func() { + defer close(out) + defer func() { _ = resp.Body.Close() }() + + scanner := bufio.NewScanner(resp.Body) + buf := make([]byte, 1024*1024) + scanner.Buffer(buf, 1024*1024) + var param any + for scanner.Scan() { + line := scanner.Bytes() + appendAPIResponseChunk(ctx, e.cfg, line) + if detail, ok := parseOpenAIStreamUsage(line); ok { + reporter.publish(ctx, detail) + } + chunks := sdktranslator.TranslateStream(ctx, to, from, req.Model, bytes.Clone(opts.OriginalRequest), body, bytes.Clone(line), ¶m) + for i := range chunks { + out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunks[i])} + } + } + if err := scanner.Err(); err != nil { + out <- cliproxyexecutor.StreamChunk{Err: err} + } + }() + + return out, nil +} + +// CountTokens is not implemented for iFlow. +func (e *IFlowExecutor) CountTokens(context.Context, *cliproxyauth.Auth, cliproxyexecutor.Request, cliproxyexecutor.Options) (cliproxyexecutor.Response, error) { + return cliproxyexecutor.Response{Payload: nil}, fmt.Errorf("not implemented") +} + +// Refresh refreshes OAuth tokens 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") + } + + refreshToken := "" + if auth.Metadata != nil { + if v, ok := auth.Metadata["refresh_token"].(string); ok { + refreshToken = strings.TrimSpace(v) + } + } + if refreshToken == "" { + return auth, nil + } + + svc := iflowauth.NewIFlowAuth(e.cfg) + tokenData, err := svc.RefreshTokens(ctx, refreshToken) + if err != nil { + 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) + + 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) + if stream { + r.Header.Set("Accept", "text/event-stream") + } else { + r.Header.Set("Accept", "application/json") + } +} + +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 +} diff --git a/sdk/auth/iflow.go b/sdk/auth/iflow.go new file mode 100644 index 00000000..8ea6f755 --- /dev/null +++ b/sdk/auth/iflow.go @@ -0,0 +1,131 @@ +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 { + d := 3 * time.Hour + return &d +} + +// 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{} + } + + authSvc := iflow.NewIFlowAuth(cfg) + + oauthServer := iflow.NewOAuthServer(iflow.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, iflow.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(iflow.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(iflow.CallbackPort) + fmt.Printf("Visit the following URL to continue authentication:\n%s\n", authURL) + } + } else { + util.PrintSSHTunnelInstructions(iflow.CallbackPort) + fmt.Printf("Visit the following URL to continue authentication:\n%s\n", authURL) + } + + fmt.Println("Waiting for iFlow authentication callback...") + + result, err := oauthServer.WaitForCallback(5 * time.Minute) + if err != nil { + return nil, fmt.Errorf("iflow auth: callback wait failed: %w", err) + } + 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.json", email) + metadata := map[string]any{ + "email": email, + "api_key": tokenStorage.APIKey, + "access_token": tokenStorage.AccessToken, + "refresh_token": tokenStorage.RefreshToken, + "expired": tokenStorage.Expire, + } + + fmt.Println("iFlow authentication successful") + + return &coreauth.Auth{ + ID: fileName, + Provider: a.Provider(), + FileName: fileName, + Storage: tokenStorage, + Metadata: metadata, + Attributes: map[string]string{ + "api_key": tokenStorage.APIKey, + }, + }, nil +} diff --git a/sdk/auth/refresh_registry.go b/sdk/auth/refresh_registry.go index 0f7fb505..74529e04 100644 --- a/sdk/auth/refresh_registry.go +++ b/sdk/auth/refresh_registry.go @@ -10,6 +10,7 @@ func init() { registerRefreshLead("codex", func() Authenticator { return NewCodexAuthenticator() }) registerRefreshLead("claude", func() Authenticator { return NewClaudeAuthenticator() }) registerRefreshLead("qwen", func() Authenticator { return NewQwenAuthenticator() }) + registerRefreshLead("iflow", func() Authenticator { return NewIFlowAuthenticator() }) registerRefreshLead("gemini", func() Authenticator { return NewGeminiAuthenticator() }) registerRefreshLead("gemini-cli", func() Authenticator { return NewGeminiAuthenticator() }) registerRefreshLead("gemini-web", func() Authenticator { return NewGeminiWebAuthenticator() }) diff --git a/sdk/cliproxy/service.go b/sdk/cliproxy/service.go index 13376c09..b8acf15a 100644 --- a/sdk/cliproxy/service.go +++ b/sdk/cliproxy/service.go @@ -250,6 +250,8 @@ func (s *Service) ensureExecutorsForAuth(a *coreauth.Auth) { s.coreManager.RegisterExecutor(executor.NewCodexExecutor(s.cfg)) case "qwen": s.coreManager.RegisterExecutor(executor.NewQwenExecutor(s.cfg)) + case "iflow": + s.coreManager.RegisterExecutor(executor.NewIFlowExecutor(s.cfg)) default: providerKey := strings.ToLower(strings.TrimSpace(a.Provider)) if providerKey == "" { @@ -496,6 +498,8 @@ func (s *Service) registerModelsForAuth(a *coreauth.Auth) { models = registry.GetOpenAIModels() case "qwen": models = registry.GetQwenModels() + case "iflow": + models = registry.GetIFlowModels() default: // Handle OpenAI-compatibility providers by name using config if s.cfg != nil {