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/internal/api/handlers/management/auth_files.go b/internal/api/handlers/management/auth_files.go index cd970963..81404ebb 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,89 @@ 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) + tokenStorage.Email = fmt.Sprintf("iflow-%d", time.Now().UnixMilli()) + record := &coreauth.Auth{ + ID: fmt.Sprintf("iflow-%s.json", tokenStorage.Email), + Provider: "iflow", + FileName: fmt.Sprintf("iflow-%s.json", tokenStorage.Email), + Storage: tokenStorage, + Metadata: map[string]any{"email": tokenStorage.Email, "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..2f819fa1 --- /dev/null +++ b/internal/auth/iflow/iflow_auth.go @@ -0,0 +1,255 @@ +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 = 54546 + +// 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 != "" { + apiKey, errAPI := ia.FetchAPIKey(ctx, tokenResp.AccessToken) + if errAPI != nil { + log.Warnf("iflow token: failed to fetch API key: %v", errAPI) + } else if apiKey != "" { + data.APIKey = apiKey + } + } + + return data, nil +} + +// FetchAPIKey retrieves the account API key associated with the provided access token. +func (ia *IFlowAuth) FetchAPIKey(ctx context.Context, accessToken string) (string, error) { + if strings.TrimSpace(accessToken) == "" { + return "", 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 "", 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 "", fmt.Errorf("iflow api key: request failed: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", 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 "", 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 "", fmt.Errorf("iflow api key: decode body failed: %w", err) + } + + if !result.Success { + return "", fmt.Errorf("iflow api key: request not successful") + } + + if result.Data.APIKey == "" { + return "", fmt.Errorf("iflow api key: missing api key in response") + } + + return result.Data.APIKey, 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, + 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 + } + 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 +} + +// userInfoResponse represents the structure returned by the user info endpoint. +type userInfoResponse struct { + Success bool `json:"success"` + Data struct { + APIKey string `json:"apiKey"` + Email string `json:"email"` + } `json:"data"` +} 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..92e97bc0 100644 --- a/internal/registry/model_definitions.go +++ b/internal/registry/model_definitions.go @@ -3,7 +3,10 @@ // when registering their supported models. package registry -import "time" +import ( + "strings" + "time" +) // GetClaudeModels returns the standard Claude model definitions func GetClaudeModels() []*ModelInfo { @@ -322,3 +325,39 @@ func GetQwenModels() []*ModelInfo { }, } } + +// GetIFlowModels returns supported models for iFlow OAuth accounts. +func GetIFlowModels() []*ModelInfo { + created := time.Now().Unix() + entries := []string{ + "tstars2.0", + "qwen3-coder-plus", + "qwen3-coder", + "qwen3-max", + "qwen3-vl-plus", + "qwen3-max-preview", + "kimi-k2-0905", + "glm-4.5", + "kimi-k2", + "deepseek-v3.2", + "deepseek-v3.1", + "deepseek-r1", + "deepseek-v3", + "qwen3-32b", + "qwen3-235b-a22b-thinking-2507", + "qwen3-235b-a22b-instruct", + "qwen3-235b", + } + models := make([]*ModelInfo, 0, len(entries)) + for _, id := range entries { + models = append(models, &ModelInfo{ + ID: id, + Object: "model", + Created: created, + OwnedBy: "iflow", + Type: "iflow", + DisplayName: strings.ToUpper(id), + }) + } + return models +} diff --git a/internal/runtime/executor/iflow_executor.go b/internal/runtime/executor/iflow_executor.go new file mode 100644 index 00000000..36aa3515 --- /dev/null +++ b/internal/runtime/executor/iflow_executor.go @@ -0,0 +1,257 @@ +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" + +// 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) + 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..564c93d2 --- /dev/null +++ b/sdk/auth/iflow.go @@ -0,0 +1,148 @@ +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 := "" + if opts.Metadata != nil { + email = opts.Metadata["email"] + if email == "" { + email = opts.Metadata["alias"] + } + } + + if email == "" && opts.Prompt != nil { + email, err = opts.Prompt("Please input your email address or alias for iFlow:") + if err != nil { + return nil, err + } + } + + email = strings.TrimSpace(email) + if email == "" { + return nil, &EmailRequiredError{Prompt: "Please provide an email address or alias for iFlow."} + } + + tokenStorage.Email = email + + fileName := fmt.Sprintf("iflow-%s.json", tokenStorage.Email) + metadata := map[string]any{ + "email": tokenStorage.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 {