From 68cb81a25810543162a1a34a59e1597e62fbf160 Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Fri, 6 Feb 2026 20:43:30 +0800 Subject: [PATCH] feat: add Kimi authentication support and streamline device ID handling - Introduced `RequestKimiToken` API for Kimi authentication flow. - Integrated device ID management throughout Kimi-related components. - Enhanced header management for Kimi API requests with device ID context. --- .../api/handlers/management/auth_files.go | 77 +++++++++++++++++++ internal/api/server.go | 1 + internal/auth/kimi/kimi.go | 41 ++++------ internal/auth/kimi/token.go | 4 + internal/runtime/executor/kimi_executor.go | 63 ++++++++++++--- sdk/api/management.go | 5 ++ sdk/auth/kimi.go | 4 + 7 files changed, 157 insertions(+), 38 deletions(-) diff --git a/internal/api/handlers/management/auth_files.go b/internal/api/handlers/management/auth_files.go index 996ea1a7..e2ff23f1 100644 --- a/internal/api/handlers/management/auth_files.go +++ b/internal/api/handlers/management/auth_files.go @@ -25,6 +25,7 @@ import ( "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/codex" geminiAuth "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/gemini" iflowauth "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/iflow" + "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/kimi" "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/qwen" "github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces" "github.com/router-for-me/CLIProxyAPI/v6/internal/misc" @@ -1608,6 +1609,82 @@ func (h *Handler) RequestQwenToken(c *gin.Context) { c.JSON(200, gin.H{"status": "ok", "url": authURL, "state": state}) } +func (h *Handler) RequestKimiToken(c *gin.Context) { + ctx := context.Background() + + fmt.Println("Initializing Kimi authentication...") + + state := fmt.Sprintf("kmi-%d", time.Now().UnixNano()) + // Initialize Kimi auth service + kimiAuth := kimi.NewKimiAuth(h.cfg) + + // Generate authorization URL + deviceFlow, errStartDeviceFlow := kimiAuth.StartDeviceFlow(ctx) + if errStartDeviceFlow != nil { + log.Errorf("Failed to generate authorization URL: %v", errStartDeviceFlow) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate authorization url"}) + return + } + authURL := deviceFlow.VerificationURIComplete + if authURL == "" { + authURL = deviceFlow.VerificationURI + } + + RegisterOAuthSession(state, "kimi") + + go func() { + fmt.Println("Waiting for authentication...") + authBundle, errWaitForAuthorization := kimiAuth.WaitForAuthorization(ctx, deviceFlow) + if errWaitForAuthorization != nil { + SetOAuthSessionError(state, "Authentication failed") + fmt.Printf("Authentication failed: %v\n", errWaitForAuthorization) + return + } + + // Create token storage + tokenStorage := kimiAuth.CreateTokenStorage(authBundle) + + metadata := map[string]any{ + "type": "kimi", + "access_token": authBundle.TokenData.AccessToken, + "refresh_token": authBundle.TokenData.RefreshToken, + "token_type": authBundle.TokenData.TokenType, + "scope": authBundle.TokenData.Scope, + "timestamp": time.Now().UnixMilli(), + } + if authBundle.TokenData.ExpiresAt > 0 { + expired := time.Unix(authBundle.TokenData.ExpiresAt, 0).UTC().Format(time.RFC3339) + metadata["expired"] = expired + } + if strings.TrimSpace(authBundle.DeviceID) != "" { + metadata["device_id"] = strings.TrimSpace(authBundle.DeviceID) + } + + fileName := fmt.Sprintf("kimi-%d.json", time.Now().UnixMilli()) + record := &coreauth.Auth{ + ID: fileName, + Provider: "kimi", + FileName: fileName, + Label: "Kimi User", + Storage: tokenStorage, + Metadata: metadata, + } + savedPath, errSave := h.saveTokenRecord(ctx, record) + if errSave != nil { + log.Errorf("Failed to save authentication tokens: %v", errSave) + SetOAuthSessionError(state, "Failed to save authentication tokens") + return + } + + fmt.Printf("Authentication successful! Token saved to %s\n", savedPath) + fmt.Println("You can now use Kimi services through this CLI") + CompleteOAuthSession(state) + CompleteOAuthSessionsByProvider("kimi") + }() + + c.JSON(200, gin.H{"status": "ok", "url": authURL, "state": state}) +} + func (h *Handler) RequestIFlowToken(c *gin.Context) { ctx := context.Background() diff --git a/internal/api/server.go b/internal/api/server.go index f9a2abdd..5e194c56 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -623,6 +623,7 @@ func (s *Server) registerManagementRoutes() { mgmt.GET("/gemini-cli-auth-url", s.mgmt.RequestGeminiCLIToken) mgmt.GET("/antigravity-auth-url", s.mgmt.RequestAntigravityToken) mgmt.GET("/qwen-auth-url", s.mgmt.RequestQwenToken) + mgmt.GET("/kimi-auth-url", s.mgmt.RequestKimiToken) mgmt.GET("/iflow-auth-url", s.mgmt.RequestIFlowToken) mgmt.POST("/iflow-auth-url", s.mgmt.RequestIFlowCookieToken) mgmt.POST("/oauth-callback", s.mgmt.PostOAuthCallback) diff --git a/internal/auth/kimi/kimi.go b/internal/auth/kimi/kimi.go index 49daaf17..86052277 100644 --- a/internal/auth/kimi/kimi.go +++ b/internal/auth/kimi/kimi.go @@ -10,7 +10,6 @@ import ( "net/http" "net/url" "os" - "path/filepath" "runtime" "strings" "time" @@ -68,6 +67,7 @@ func (k *KimiAuth) WaitForAuthorization(ctx context.Context, deviceCode *DeviceC return &KimiAuthBundle{ TokenData: tokenData, + DeviceID: k.deviceClient.deviceID, }, nil } @@ -82,6 +82,7 @@ func (k *KimiAuth) CreateTokenStorage(bundle *KimiAuthBundle) *KimiTokenStorage RefreshToken: bundle.TokenData.RefreshToken, TokenType: bundle.TokenData.TokenType, Scope: bundle.TokenData.Scope, + DeviceID: strings.TrimSpace(bundle.DeviceID), Expired: expired, Type: "kimi", } @@ -96,42 +97,29 @@ type DeviceFlowClient struct { // NewDeviceFlowClient creates a new device flow client. func NewDeviceFlowClient(cfg *config.Config) *DeviceFlowClient { + return NewDeviceFlowClientWithDeviceID(cfg, "") +} + +// NewDeviceFlowClientWithDeviceID creates a new device flow client with the specified device ID. +func NewDeviceFlowClientWithDeviceID(cfg *config.Config, deviceID string) *DeviceFlowClient { client := &http.Client{Timeout: 30 * time.Second} if cfg != nil { client = util.SetProxy(&cfg.SDKConfig, client) } + resolvedDeviceID := strings.TrimSpace(deviceID) + if resolvedDeviceID == "" { + resolvedDeviceID = getOrCreateDeviceID() + } return &DeviceFlowClient{ httpClient: client, cfg: cfg, - deviceID: getOrCreateDeviceID(), + deviceID: resolvedDeviceID, } } -// getOrCreateDeviceID returns a stable device ID. +// getOrCreateDeviceID returns an in-memory device ID for the current authentication flow. func getOrCreateDeviceID() string { - homeDir, err := os.UserHomeDir() - if err != nil { - log.Warnf("kimi: could not get user home directory: %v. Using random device ID.", err) - return uuid.New().String() - } - configDir := filepath.Join(homeDir, ".cli-proxy-api") - deviceIDPath := filepath.Join(configDir, "kimi-device-id") - - // Try to read existing device ID - if data, err := os.ReadFile(deviceIDPath); err == nil { - return strings.TrimSpace(string(data)) - } - - // Create new device ID - deviceID := uuid.New().String() - if err := os.MkdirAll(configDir, 0700); err != nil { - log.Warnf("kimi: failed to create config directory %s, cannot persist device ID: %v", configDir, err) - return deviceID - } - if err := os.WriteFile(deviceIDPath, []byte(deviceID), 0600); err != nil { - log.Warnf("kimi: failed to write device ID to %s: %v", deviceIDPath, err) - } - return deviceID + return uuid.New().String() } // getDeviceModel returns a device model string. @@ -406,4 +394,3 @@ func (c *DeviceFlowClient) RefreshToken(ctx context.Context, refreshToken string Scope: tokenResp.Scope, }, nil } - diff --git a/internal/auth/kimi/token.go b/internal/auth/kimi/token.go index 0fc6bd71..d4d06b64 100644 --- a/internal/auth/kimi/token.go +++ b/internal/auth/kimi/token.go @@ -23,6 +23,8 @@ type KimiTokenStorage struct { TokenType string `json:"token_type"` // Scope is the OAuth2 scope granted to the token. Scope string `json:"scope,omitempty"` + // DeviceID is the OAuth device flow identifier used for Kimi requests. + DeviceID string `json:"device_id,omitempty"` // Expired is the RFC3339 timestamp when the access token expires. Expired string `json:"expired,omitempty"` // Type indicates the authentication provider type, always "kimi" for this storage. @@ -47,6 +49,8 @@ type KimiTokenData struct { type KimiAuthBundle struct { // TokenData contains the OAuth token information. TokenData *KimiTokenData + // DeviceID is the device identifier used during OAuth device flow. + DeviceID string } // DeviceCodeResponse represents Kimi's device code response. diff --git a/internal/runtime/executor/kimi_executor.go b/internal/runtime/executor/kimi_executor.go index e07b3067..1cc66341 100644 --- a/internal/runtime/executor/kimi_executor.go +++ b/internal/runtime/executor/kimi_executor.go @@ -23,7 +23,6 @@ import ( "github.com/tidwall/sjson" ) - // KimiExecutor is a stateless executor for Kimi API using OpenAI-compatible chat completions. type KimiExecutor struct { cfg *config.Config @@ -88,7 +87,7 @@ func (e *KimiExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req return resp, fmt.Errorf("kimi executor: failed to set model in payload: %w", err) } - body, err = thinking.ApplyThinking(body, req.Model, from.String(), to.String(), e.Identifier()) + body, err = thinking.ApplyThinking(body, req.Model, from.String(), "kimi", e.Identifier()) if err != nil { return resp, err } @@ -101,7 +100,7 @@ func (e *KimiExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req if err != nil { return resp, err } - applyKimiHeaders(httpReq, token, false) + applyKimiHeadersWithAuth(httpReq, token, false, auth) var authID, authLabel, authType, authValue string if auth != nil { authID = auth.ID @@ -179,7 +178,7 @@ func (e *KimiExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Aut return nil, fmt.Errorf("kimi executor: failed to set model in payload: %w", err) } - body, err = thinking.ApplyThinking(body, req.Model, from.String(), to.String(), e.Identifier()) + body, err = thinking.ApplyThinking(body, req.Model, from.String(), "kimi", e.Identifier()) if err != nil { return nil, err } @@ -196,7 +195,7 @@ func (e *KimiExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Aut if err != nil { return nil, err } - applyKimiHeaders(httpReq, token, true) + applyKimiHeadersWithAuth(httpReq, token, true, auth) var authID, authLabel, authType, authValue string if auth != nil { authID = auth.ID @@ -310,7 +309,7 @@ func (e *KimiExecutor) Refresh(ctx context.Context, auth *cliproxyauth.Auth) (*c return auth, nil } - client := kimiauth.NewDeviceFlowClient(e.cfg) + client := kimiauth.NewDeviceFlowClientWithDeviceID(e.cfg, resolveKimiDeviceID(auth)) td, err := client.RefreshToken(ctx, refreshToken) if err != nil { return nil, err @@ -351,6 +350,53 @@ func applyKimiHeaders(r *http.Request, token string, stream bool) { r.Header.Set("Accept", "application/json") } +func resolveKimiDeviceIDFromAuth(auth *cliproxyauth.Auth) string { + if auth == nil || auth.Metadata == nil { + return "" + } + + deviceIDRaw, ok := auth.Metadata["device_id"] + if !ok { + return "" + } + + deviceID, ok := deviceIDRaw.(string) + if !ok { + return "" + } + + return strings.TrimSpace(deviceID) +} + +func resolveKimiDeviceIDFromStorage(auth *cliproxyauth.Auth) string { + if auth == nil { + return "" + } + + storage, ok := auth.Storage.(*kimiauth.KimiTokenStorage) + if !ok || storage == nil { + return "" + } + + return strings.TrimSpace(storage.DeviceID) +} + +func resolveKimiDeviceID(auth *cliproxyauth.Auth) string { + deviceID := resolveKimiDeviceIDFromAuth(auth) + if deviceID != "" { + return deviceID + } + return resolveKimiDeviceIDFromStorage(auth) +} + +func applyKimiHeadersWithAuth(r *http.Request, token string, stream bool, auth *cliproxyauth.Auth) { + applyKimiHeaders(r, token, stream) + + if deviceID := resolveKimiDeviceID(auth); deviceID != "" { + r.Header.Set("X-Msh-Device-Id", deviceID) + } +} + // getKimiHostname returns the machine hostname. func getKimiHostname() string { hostname, err := os.Hostname() @@ -389,11 +435,6 @@ func getKimiDeviceID() string { if data, err := os.ReadFile(deviceIDPath); err == nil { return strings.TrimSpace(string(data)) } - // Fallback to our own device ID - ourPath := filepath.Join(homeDir, ".cli-proxy-api", "kimi-device-id") - if data, err := os.ReadFile(ourPath); err == nil { - return strings.TrimSpace(string(data)) - } return "cli-proxy-api-device" } diff --git a/sdk/api/management.go b/sdk/api/management.go index 66af41ae..6fd3b709 100644 --- a/sdk/api/management.go +++ b/sdk/api/management.go @@ -18,6 +18,7 @@ type ManagementTokenRequester interface { RequestCodexToken(*gin.Context) RequestAntigravityToken(*gin.Context) RequestQwenToken(*gin.Context) + RequestKimiToken(*gin.Context) RequestIFlowToken(*gin.Context) RequestIFlowCookieToken(*gin.Context) GetAuthStatus(c *gin.Context) @@ -55,6 +56,10 @@ func (m *managementTokenRequester) RequestQwenToken(c *gin.Context) { m.handler.RequestQwenToken(c) } +func (m *managementTokenRequester) RequestKimiToken(c *gin.Context) { + m.handler.RequestKimiToken(c) +} + func (m *managementTokenRequester) RequestIFlowToken(c *gin.Context) { m.handler.RequestIFlowToken(c) } diff --git a/sdk/auth/kimi.go b/sdk/auth/kimi.go index 5471524f..12ae101e 100644 --- a/sdk/auth/kimi.go +++ b/sdk/auth/kimi.go @@ -3,6 +3,7 @@ package auth import ( "context" "fmt" + "strings" "time" "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/kimi" @@ -102,6 +103,9 @@ func (a KimiAuthenticator) Login(ctx context.Context, cfg *config.Config, opts * exp := time.Unix(authBundle.TokenData.ExpiresAt, 0).UTC().Format(time.RFC3339) metadata["expired"] = exp } + if strings.TrimSpace(authBundle.DeviceID) != "" { + metadata["device_id"] = strings.TrimSpace(authBundle.DeviceID) + } // Generate a unique filename fileName := fmt.Sprintf("kimi-%d.json", time.Now().UnixMilli())