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.
This commit is contained in:
Luis Pater
2026-02-06 20:43:30 +08:00
parent f5f26f0cbe
commit 68cb81a258
7 changed files with 157 additions and 38 deletions

View File

@@ -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()

View File

@@ -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)

View File

@@ -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
}

View File

@@ -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.

View File

@@ -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"
}

View File

@@ -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)
}

View File

@@ -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())