package executor import ( "context" "fmt" "net/http" "os" "path/filepath" "runtime" "strings" "time" kimiauth "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/kimi" "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" log "github.com/sirupsen/logrus" ) // KimiExecutor is a stateless executor for Kimi API using OpenAI-compatible chat completions. type KimiExecutorRefactored struct { claude *ClaudeExecutorRefactored cfg *config.Config base *BaseExecutor } // NewKimiExecutor creates a new Kimi executor. func NewKimiExecutorRefactored(cfg *config.Config) *KimiExecutorRefactored { provider := &KimiProvider{} return &KimiExecutorRefactored{ claude: NewClaudeExecutorRefactored(cfg), cfg: cfg, base: NewBaseExecutor(cfg, provider), } } // Identifier returns the executor identifier. func (e *KimiExecutorRefactored) Identifier() string { return "kimi" } // PrepareRequest injects Kimi credentials into the outgoing HTTP request. func (e *KimiExecutorRefactored) PrepareRequest(req *http.Request, auth *cliproxyauth.Auth) error { if req == nil { return nil } token := kimiCreds(auth) if strings.TrimSpace(token) != "" { req.Header.Set("Authorization", "Bearer "+token) } return nil } // HttpRequest injects Kimi credentials into the request and executes it. func (e *KimiExecutorRefactored) HttpRequest(ctx context.Context, auth *cliproxyauth.Auth, req *http.Request) (*http.Response, error) { if req == nil { return nil, fmt.Errorf("kimi executor: request is nil") } if ctx == nil { ctx = req.Context() } httpReq := req.WithContext(ctx) if err := e.PrepareRequest(httpReq, auth); err != nil { return nil, err } httpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0) return httpClient.Do(httpReq) } // Execute performs a non-streaming chat completion request to Kimi. func (e *KimiExecutorRefactored) Execute(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (resp cliproxyexecutor.Response, err error) { from := opts.SourceFormat if from.String() == "claude" { if auth == nil { return resp, fmt.Errorf("kimi executor: auth is nil") } if auth.Attributes == nil { auth.Attributes = make(map[string]string) } auth.Attributes["base_url"] = kimiauth.KimiAPIBaseURL return e.claude.Execute(ctx, auth, req, opts) } return e.base.Execute(ctx, auth, req, opts) } // ExecuteStream performs a streaming chat completion request to Kimi. func (e *KimiExecutorRefactored) ExecuteStream(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (*cliproxyexecutor.StreamResult, error) { from := opts.SourceFormat if from.String() == "claude" { if auth == nil { return nil, fmt.Errorf("kimi executor: auth is nil") } if auth.Attributes == nil { auth.Attributes = make(map[string]string) } auth.Attributes["base_url"] = kimiauth.KimiAPIBaseURL return e.claude.ExecuteStream(ctx, auth, req, opts) } return e.base.ExecuteStream(ctx, auth, req, opts) } // CountTokens estimates token count for Kimi requests. func (e *KimiExecutorRefactored) CountTokens(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) { if auth == nil { return cliproxyexecutor.Response{}, fmt.Errorf("kimi executor: auth is nil") } if auth.Attributes == nil { auth.Attributes = make(map[string]string) } auth.Attributes["base_url"] = kimiauth.KimiAPIBaseURL return e.claude.CountTokens(ctx, auth, req, opts) } // Refresh refreshes the Kimi token using the refresh token. func (e *KimiExecutorRefactored) Refresh(ctx context.Context, auth *cliproxyauth.Auth) (*cliproxyauth.Auth, error) { log.Debugf("kimi executor: refresh called") if auth == nil { return nil, fmt.Errorf("kimi executor: auth is nil") } // Expect refresh_token in metadata for OAuth-based accounts var refreshToken string if auth.Metadata != nil { if v, ok := auth.Metadata["refresh_token"].(string); ok && strings.TrimSpace(v) != "" { refreshToken = v } } if strings.TrimSpace(refreshToken) == "" { // Nothing to refresh return auth, nil } client := kimiauth.NewDeviceFlowClientWithDeviceID(e.cfg, resolveKimiDeviceID(auth)) td, err := client.RefreshToken(ctx, refreshToken) if err != nil { return nil, err } if auth.Metadata == nil { auth.Metadata = make(map[string]any) } auth.Metadata["access_token"] = td.AccessToken if td.RefreshToken != "" { auth.Metadata["refresh_token"] = td.RefreshToken } if td.ExpiresAt > 0 { exp := time.Unix(td.ExpiresAt, 0).UTC().Format(time.RFC3339) auth.Metadata["expired"] = exp } auth.Metadata["type"] = "kimi" now := time.Now().Format(time.RFC3339) auth.Metadata["last_refresh"] = now return auth, nil } // applyKimiHeaders sets required headers for Kimi API requests. // Headers match kimi-cli client for compatibility. func applyKimiHeaders(r *http.Request, token string, stream bool) { r.Header.Set("Content-Type", "application/json") r.Header.Set("Authorization", "Bearer "+token) // Match kimi-cli headers exactly r.Header.Set("User-Agent", "KimiCLI/1.10.6") r.Header.Set("X-Msh-Platform", "kimi_cli") r.Header.Set("X-Msh-Version", "1.10.6") r.Header.Set("X-Msh-Device-Name", getKimiHostname()) r.Header.Set("X-Msh-Device-Model", getKimiDeviceModel()) r.Header.Set("X-Msh-Device-Id", getKimiDeviceID()) if stream { r.Header.Set("Accept", "text/event-stream") return } 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() if err != nil { return "unknown" } return hostname } // getKimiDeviceModel returns a device model string matching kimi-cli format. func getKimiDeviceModel() string { return fmt.Sprintf("%s %s", runtime.GOOS, runtime.GOARCH) } // getKimiDeviceID returns a stable device ID, matching kimi-cli storage location. func getKimiDeviceID() string { homeDir, err := os.UserHomeDir() if err != nil { return "cli-proxy-api-device" } // Check kimi-cli's device_id location first (platform-specific) var kimiShareDir string switch runtime.GOOS { case "darwin": kimiShareDir = filepath.Join(homeDir, "Library", "Application Support", "kimi") case "windows": appData := os.Getenv("APPDATA") if appData == "" { appData = filepath.Join(homeDir, "AppData", "Roaming") } kimiShareDir = filepath.Join(appData, "kimi") default: // linux and other unix-like kimiShareDir = filepath.Join(homeDir, ".local", "share", "kimi") } deviceIDPath := filepath.Join(kimiShareDir, "device_id") if data, err := os.ReadFile(deviceIDPath); err == nil { return strings.TrimSpace(string(data)) } return "cli-proxy-api-device" } // kimiCreds extracts the access token from auth. func kimiCreds(a *cliproxyauth.Auth) (token string) { if a == nil { return "" } // Check metadata first (OAuth flow stores tokens here) if a.Metadata != nil { if v, ok := a.Metadata["access_token"].(string); ok && strings.TrimSpace(v) != "" { return v } } // Fallback to attributes (API key style) if a.Attributes != nil { if v := a.Attributes["access_token"]; v != "" { return v } if v := a.Attributes["api_key"]; v != "" { return v } } return "" }