mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-02-03 04:50:52 +08:00
feat: Add support for iFlow provider
This commit is contained in:
@@ -42,6 +42,7 @@ func main() {
|
|||||||
var codexLogin bool
|
var codexLogin bool
|
||||||
var claudeLogin bool
|
var claudeLogin bool
|
||||||
var qwenLogin bool
|
var qwenLogin bool
|
||||||
|
var iflowLogin bool
|
||||||
var geminiWebAuth bool
|
var geminiWebAuth bool
|
||||||
var noBrowser bool
|
var noBrowser bool
|
||||||
var projectID string
|
var projectID string
|
||||||
@@ -53,6 +54,7 @@ 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(&claudeLogin, "claude-login", false, "Login to Claude 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(&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(&geminiWebAuth, "gemini-web-auth", false, "Auth Gemini Web using cookies")
|
||||||
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.StringVar(&projectID, "project_id", "", "Project ID (Gemini only, not required)")
|
flag.StringVar(&projectID, "project_id", "", "Project ID (Gemini only, not required)")
|
||||||
@@ -153,6 +155,8 @@ func main() {
|
|||||||
cmd.DoClaudeLogin(cfg, options)
|
cmd.DoClaudeLogin(cfg, options)
|
||||||
} else if qwenLogin {
|
} else if qwenLogin {
|
||||||
cmd.DoQwenLogin(cfg, options)
|
cmd.DoQwenLogin(cfg, options)
|
||||||
|
} else if iflowLogin {
|
||||||
|
cmd.DoIFlowLogin(cfg, options)
|
||||||
} else if geminiWebAuth {
|
} else if geminiWebAuth {
|
||||||
cmd.DoGeminiWebAuth(cfg)
|
cmd.DoGeminiWebAuth(cfg)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ 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/qwen"
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/auth/qwen"
|
||||||
// legacy client removed
|
// legacy client removed
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/misc"
|
"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})
|
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) {
|
func (h *Handler) GetAuthStatus(c *gin.Context) {
|
||||||
state := c.Query("state")
|
state := c.Query("state")
|
||||||
if err, ok := oauthStatus[state]; ok {
|
if err, ok := oauthStatus[state]; ok {
|
||||||
|
|||||||
@@ -407,6 +407,7 @@ func (s *Server) registerManagementRoutes() {
|
|||||||
mgmt.GET("/gemini-cli-auth-url", s.mgmt.RequestGeminiCLIToken)
|
mgmt.GET("/gemini-cli-auth-url", s.mgmt.RequestGeminiCLIToken)
|
||||||
mgmt.POST("/gemini-web-token", s.mgmt.CreateGeminiWebToken)
|
mgmt.POST("/gemini-web-token", s.mgmt.CreateGeminiWebToken)
|
||||||
mgmt.GET("/qwen-auth-url", s.mgmt.RequestQwenToken)
|
mgmt.GET("/qwen-auth-url", s.mgmt.RequestQwenToken)
|
||||||
|
mgmt.GET("/iflow-auth-url", s.mgmt.RequestIFlowToken)
|
||||||
mgmt.GET("/get-auth-status", s.mgmt.GetAuthStatus)
|
mgmt.GET("/get-auth-status", s.mgmt.GetAuthStatus)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
255
internal/auth/iflow/iflow_auth.go
Normal file
255
internal/auth/iflow/iflow_auth.go
Normal file
@@ -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"`
|
||||||
|
}
|
||||||
43
internal/auth/iflow/iflow_token.go
Normal file
43
internal/auth/iflow/iflow_token.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
143
internal/auth/iflow/oauth_server.go
Normal file
143
internal/auth/iflow/oauth_server.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ func newAuthManager() *sdkAuth.Manager {
|
|||||||
sdkAuth.NewCodexAuthenticator(),
|
sdkAuth.NewCodexAuthenticator(),
|
||||||
sdkAuth.NewClaudeAuthenticator(),
|
sdkAuth.NewClaudeAuthenticator(),
|
||||||
sdkAuth.NewQwenAuthenticator(),
|
sdkAuth.NewQwenAuthenticator(),
|
||||||
|
sdkAuth.NewIFlowAuthenticator(),
|
||||||
)
|
)
|
||||||
return manager
|
return manager
|
||||||
}
|
}
|
||||||
|
|||||||
54
internal/cmd/iflow_login.go
Normal file
54
internal/cmd/iflow_login.go
Normal file
@@ -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!")
|
||||||
|
}
|
||||||
@@ -3,7 +3,10 @@
|
|||||||
// when registering their supported models.
|
// when registering their supported models.
|
||||||
package registry
|
package registry
|
||||||
|
|
||||||
import "time"
|
import (
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
// GetClaudeModels returns the standard Claude model definitions
|
// GetClaudeModels returns the standard Claude model definitions
|
||||||
func GetClaudeModels() []*ModelInfo {
|
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
|
||||||
|
}
|
||||||
|
|||||||
257
internal/runtime/executor/iflow_executor.go
Normal file
257
internal/runtime/executor/iflow_executor.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
148
sdk/auth/iflow.go
Normal file
148
sdk/auth/iflow.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ 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("qwen", func() Authenticator { return NewQwenAuthenticator() })
|
registerRefreshLead("qwen", func() Authenticator { return NewQwenAuthenticator() })
|
||||||
|
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("gemini-web", func() Authenticator { return NewGeminiWebAuthenticator() })
|
registerRefreshLead("gemini-web", func() Authenticator { return NewGeminiWebAuthenticator() })
|
||||||
|
|||||||
@@ -250,6 +250,8 @@ func (s *Service) ensureExecutorsForAuth(a *coreauth.Auth) {
|
|||||||
s.coreManager.RegisterExecutor(executor.NewCodexExecutor(s.cfg))
|
s.coreManager.RegisterExecutor(executor.NewCodexExecutor(s.cfg))
|
||||||
case "qwen":
|
case "qwen":
|
||||||
s.coreManager.RegisterExecutor(executor.NewQwenExecutor(s.cfg))
|
s.coreManager.RegisterExecutor(executor.NewQwenExecutor(s.cfg))
|
||||||
|
case "iflow":
|
||||||
|
s.coreManager.RegisterExecutor(executor.NewIFlowExecutor(s.cfg))
|
||||||
default:
|
default:
|
||||||
providerKey := strings.ToLower(strings.TrimSpace(a.Provider))
|
providerKey := strings.ToLower(strings.TrimSpace(a.Provider))
|
||||||
if providerKey == "" {
|
if providerKey == "" {
|
||||||
@@ -496,6 +498,8 @@ func (s *Service) registerModelsForAuth(a *coreauth.Auth) {
|
|||||||
models = registry.GetOpenAIModels()
|
models = registry.GetOpenAIModels()
|
||||||
case "qwen":
|
case "qwen":
|
||||||
models = registry.GetQwenModels()
|
models = registry.GetQwenModels()
|
||||||
|
case "iflow":
|
||||||
|
models = registry.GetIFlowModels()
|
||||||
default:
|
default:
|
||||||
// Handle OpenAI-compatibility providers by name using config
|
// Handle OpenAI-compatibility providers by name using config
|
||||||
if s.cfg != nil {
|
if s.cfg != nil {
|
||||||
|
|||||||
Reference in New Issue
Block a user