From 1dbeb0827aacb10f2db636331ef084a507c61925 Mon Sep 17 00:00:00 2001 From: DetroitTommy <45469533+detroittommy879@users.noreply.github.com> Date: Sun, 15 Feb 2026 13:44:26 -0500 Subject: [PATCH] added kilocode auth, needs adjusting --- .gitignore | 2 + cmd/server/main.go | 4 + config.example.yaml | 39 ++-- .../api/handlers/management/auth_files.go | 86 ++++++++ internal/api/server.go | 1 + internal/auth/kilo/kilo_auth.go | 162 ++++++++++++++ internal/auth/kilo/kilo_token.go | 60 ++++++ internal/cmd/auth_manager.go | 1 + internal/cmd/kilo_login.go | 54 +++++ internal/constant/constant.go | 3 + internal/registry/kilo_models.go | 21 ++ internal/registry/model_definitions.go | 4 + internal/runtime/executor/kilo_executor.go | 204 ++++++++++++++++++ sdk/auth/kilo.go | 121 +++++++++++ sdk/cliproxy/service.go | 5 + 15 files changed, 755 insertions(+), 12 deletions(-) create mode 100644 internal/auth/kilo/kilo_auth.go create mode 100644 internal/auth/kilo/kilo_token.go create mode 100644 internal/cmd/kilo_login.go create mode 100644 internal/registry/kilo_models.go create mode 100644 internal/runtime/executor/kilo_executor.go create mode 100644 sdk/auth/kilo.go diff --git a/.gitignore b/.gitignore index 02493d24..aaba42f8 100644 --- a/.gitignore +++ b/.gitignore @@ -3,8 +3,10 @@ cli-proxy-api cliproxy *.exe + # Configuration config.yaml +my-config.yaml .env .mcp.json # Generated content diff --git a/cmd/server/main.go b/cmd/server/main.go index fa9e9003..7ab9c21a 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -72,6 +72,7 @@ func main() { var codexLogin bool var claudeLogin bool var qwenLogin bool + var kiloLogin bool var iflowLogin bool var iflowCookie bool var noBrowser bool @@ -96,6 +97,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(&kiloLogin, "kilo-login", false, "Login to Kilo AI using device flow") flag.BoolVar(&iflowLogin, "iflow-login", false, "Login to iFlow using OAuth") flag.BoolVar(&iflowCookie, "iflow-cookie", false, "Login to iFlow using Cookie") flag.BoolVar(&noBrowser, "no-browser", false, "Don't open browser automatically for OAuth") @@ -499,6 +501,8 @@ func main() { cmd.DoClaudeLogin(cfg, options) } else if qwenLogin { cmd.DoQwenLogin(cfg, options) + } else if kiloLogin { + cmd.DoKiloLogin(cfg, options) } else if iflowLogin { cmd.DoIFlowLogin(cfg, options) } else if iflowCookie { diff --git a/config.example.yaml b/config.example.yaml index 94ba38ce..3ec05b2f 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -1,6 +1,6 @@ # Server host/interface to bind to. Default is empty ("") to bind all interfaces (IPv4 + IPv6). # Use "127.0.0.1" or "localhost" to restrict access to local machine only. -host: "" +host: '' # Server port port: 8317 @@ -8,8 +8,8 @@ port: 8317 # TLS settings for HTTPS. When enabled, the server listens with the provided certificate and key. tls: enable: false - cert: "" - key: "" + cert: '' + key: '' # Management API settings remote-management: @@ -20,22 +20,22 @@ remote-management: # Management key. If a plaintext value is provided here, it will be hashed on startup. # All management requests (even from localhost) require this key. # Leave empty to disable the Management API entirely (404 for all /v0/management routes). - secret-key: "" + secret-key: '' # Disable the bundled management control panel asset download and HTTP route when true. disable-control-panel: false # GitHub repository for the management control panel. Accepts a repository URL or releases API URL. - panel-github-repository: "https://github.com/router-for-me/Cli-Proxy-API-Management-Center" + panel-github-repository: 'https://github.com/router-for-me/Cli-Proxy-API-Management-Center' # Authentication directory (supports ~ for home directory) -auth-dir: "~/.cli-proxy-api" +auth-dir: '~/.cli-proxy-api' # API keys for authentication api-keys: - - "your-api-key-1" - - "your-api-key-2" - - "your-api-key-3" + - 'your-api-key-1' + - 'your-api-key-2' + - 'your-api-key-3' # Enable debug logging debug: false @@ -43,7 +43,7 @@ debug: false # Enable pprof HTTP debug server (host:port). Keep it bound to localhost for safety. pprof: enable: false - addr: "127.0.0.1:8316" + addr: '127.0.0.1:8316' # When true, disable high-overhead HTTP middleware features to reduce per-request memory usage under high concurrency. commercial-mode: false @@ -68,7 +68,7 @@ error-logs-max-files: 10 usage-statistics-enabled: false # Proxy URL. Supports socks5/http/https protocols. Example: socks5://user:pass@192.168.1.1:1080/ -proxy-url: "" +proxy-url: '' # When true, unprefixed model requests only use credentials without a prefix (except when prefix == model name). force-model-prefix: false @@ -86,7 +86,7 @@ quota-exceeded: # Routing strategy for selecting credentials when multiple match. routing: - strategy: "round-robin" # round-robin (default), fill-first + strategy: 'round-robin' # round-robin (default), fill-first # When true, enable authentication for the WebSocket API (/v1/ws). ws-auth: false @@ -171,6 +171,21 @@ nonstream-keepalive-interval: 0 # profile-arn: "arn:aws:codewhisperer:us-east-1:..." # proxy-url: "socks5://proxy.example.com:1080" # optional: proxy override +# Kilocode (OAuth-based code assistant) +# Note: Kilocode uses OAuth device flow authentication. +# Use the CLI command: ./server --kilo-login +# This will save credentials to the auth directory (default: ~/.cli-proxy-api/) +# oauth-model-alias: +# kilo: +# - name: "minimax/minimax-m2.5:free" +# alias: "minimax-m2.5" +# - name: "z-ai/glm-5:free" +# alias: "glm-5" +# oauth-excluded-models: +# kilo: +# - "kilo-claude-opus-4-6" # exclude specific models (exact match) +# - "*:free" # wildcard matching suffix (e.g. all free models) + # OpenAI compatibility providers # openai-compatibility: # - name: "openrouter" # The name of the provider; it will be used in the user agent and other places. diff --git a/internal/api/handlers/management/auth_files.go b/internal/api/handlers/management/auth_files.go index 49a6e780..373c7a33 100644 --- a/internal/api/handlers/management/auth_files.go +++ b/internal/api/handlers/management/auth_files.go @@ -29,6 +29,7 @@ import ( "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/copilot" 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/kilo" "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/kimi" kiroauth "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/kiro" "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/qwen" @@ -2733,3 +2734,88 @@ func generateKiroPKCE() (verifier, challenge string, err error) { return verifier, challenge, nil } + +func (h *Handler) RequestKiloToken(c *gin.Context) { + ctx := context.Background() + + fmt.Println("Initializing Kilo authentication...") + + state := fmt.Sprintf("kil-%d", time.Now().UnixNano()) + kilocodeAuth := kilo.NewKiloAuth() + + resp, err := kilocodeAuth.InitiateDeviceFlow(ctx) + if err != nil { + log.Errorf("Failed to initiate device flow: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to initiate device flow"}) + return + } + + RegisterOAuthSession(state, "kilo") + + go func() { + fmt.Printf("Please visit %s and enter code: %s\n", resp.VerificationURL, resp.Code) + + status, err := kilocodeAuth.PollForToken(ctx, resp.Code) + if err != nil { + SetOAuthSessionError(state, "Authentication failed") + fmt.Printf("Authentication failed: %v\n", err) + return + } + + profile, err := kilocodeAuth.GetProfile(ctx, status.Token) + if err != nil { + log.Warnf("Failed to fetch profile: %v", err) + profile = &kilo.Profile{Email: status.UserEmail} + } + + var orgID string + if len(profile.Orgs) > 0 { + orgID = profile.Orgs[0].ID + } + + defaults, err := kilocodeAuth.GetDefaults(ctx, status.Token, orgID) + if err != nil { + defaults = &kilo.Defaults{} + } + + ts := &kilo.KiloTokenStorage{ + Token: status.Token, + OrganizationID: orgID, + Model: defaults.Model, + Email: status.UserEmail, + Type: "kilo", + } + + fileName := kilo.CredentialFileName(status.UserEmail) + record := &coreauth.Auth{ + ID: fileName, + Provider: "kilo", + FileName: fileName, + Storage: ts, + Metadata: map[string]any{ + "email": status.UserEmail, + "organization_id": orgID, + "model": defaults.Model, + }, + } + + 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) + CompleteOAuthSession(state) + CompleteOAuthSessionsByProvider("kilo") + }() + + c.JSON(200, gin.H{ + "status": "ok", + "url": resp.VerificationURL, + "state": state, + "user_code": resp.Code, + "verification_uri": resp.VerificationURL, + }) +} diff --git a/internal/api/server.go b/internal/api/server.go index 90509175..c4e6accd 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -649,6 +649,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("/kilo-auth-url", s.mgmt.RequestKiloToken) mgmt.GET("/kimi-auth-url", s.mgmt.RequestKimiToken) mgmt.GET("/iflow-auth-url", s.mgmt.RequestIFlowToken) mgmt.POST("/iflow-auth-url", s.mgmt.RequestIFlowCookieToken) diff --git a/internal/auth/kilo/kilo_auth.go b/internal/auth/kilo/kilo_auth.go new file mode 100644 index 00000000..7886ffbf --- /dev/null +++ b/internal/auth/kilo/kilo_auth.go @@ -0,0 +1,162 @@ +// Package kilo provides authentication and token management functionality +// for Kilo AI services. +package kilo + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "time" +) + +const ( + // BaseURL is the base URL for the Kilo AI API. + BaseURL = "https://api.kilo.ai/api" +) + +// DeviceAuthResponse represents the response from initiating device flow. +type DeviceAuthResponse struct { + Code string `json:"code"` + VerificationURL string `json:"verificationUrl"` + ExpiresIn int `json:"expiresIn"` +} + +// DeviceStatusResponse represents the response when polling for device flow status. +type DeviceStatusResponse struct { + Status string `json:"status"` + Token string `json:"token"` + UserEmail string `json:"userEmail"` +} + +// Profile represents the user profile from Kilo AI. +type Profile struct { + Email string `json:"email"` + Orgs []Organization `json:"organizations"` +} + +// Organization represents a Kilo AI organization. +type Organization struct { + ID string `json:"id"` + Name string `json:"name"` +} + +// Defaults represents default settings for an organization or user. +type Defaults struct { + Model string `json:"model"` +} + +// KiloAuth provides methods for handling the Kilo AI authentication flow. +type KiloAuth struct { + client *http.Client +} + +// NewKiloAuth creates a new instance of KiloAuth. +func NewKiloAuth() *KiloAuth { + return &KiloAuth{ + client: &http.Client{Timeout: 30 * time.Second}, + } +} + +// InitiateDeviceFlow starts the device authentication flow. +func (k *KiloAuth) InitiateDeviceFlow(ctx context.Context) (*DeviceAuthResponse, error) { + resp, err := k.client.Post(BaseURL+"/device-auth/codes", "application/json", nil) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("failed to initiate device flow: status %d", resp.StatusCode) + } + + var data DeviceAuthResponse + if err := json.NewDecoder(resp.Body).Decode(&data); err != nil { + return nil, err + } + return &data, nil +} + +// PollForToken polls for the device flow completion. +func (k *KiloAuth) PollForToken(ctx context.Context, code string) (*DeviceStatusResponse, error) { + ticker := time.NewTicker(5 * time.Second) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-ticker.C: + resp, err := k.client.Get(BaseURL + "/device-auth/codes/" + code) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + var data DeviceStatusResponse + if err := json.NewDecoder(resp.Body).Decode(&data); err != nil { + return nil, err + } + + switch data.Status { + case "approved": + return &data, nil + case "denied", "expired": + return nil, fmt.Errorf("device flow %s", data.Status) + case "pending": + continue + default: + return nil, fmt.Errorf("unknown status: %s", data.Status) + } + } + } +} + +// GetProfile fetches the user's profile. +func (k *KiloAuth) GetProfile(ctx context.Context, token string) (*Profile, error) { + req, _ := http.NewRequestWithContext(ctx, "GET", BaseURL+"/profile", nil) + req.Header.Set("Authorization", "Bearer "+token) + + resp, err := k.client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("failed to get profile: status %d", resp.StatusCode) + } + + var profile Profile + if err := json.NewDecoder(resp.Body).Decode(&profile); err != nil { + return nil, err + } + return &profile, nil +} + +// GetDefaults fetches default settings for an organization. +func (k *KiloAuth) GetDefaults(ctx context.Context, token, orgID string) (*Defaults, error) { + url := BaseURL + "/defaults" + if orgID != "" { + url = BaseURL + "/organizations/" + orgID + "/defaults" + } + + req, _ := http.NewRequestWithContext(ctx, "GET", url, nil) + req.Header.Set("Authorization", "Bearer "+token) + + resp, err := k.client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("failed to get defaults: status %d", resp.StatusCode) + } + + var defaults Defaults + if err := json.NewDecoder(resp.Body).Decode(&defaults); err != nil { + return nil, err + } + return &defaults, nil +} diff --git a/internal/auth/kilo/kilo_token.go b/internal/auth/kilo/kilo_token.go new file mode 100644 index 00000000..5d1646e7 --- /dev/null +++ b/internal/auth/kilo/kilo_token.go @@ -0,0 +1,60 @@ +// Package kilo provides authentication and token management functionality +// for Kilo AI services. +package kilo + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + + "github.com/router-for-me/CLIProxyAPI/v6/internal/misc" + log "github.com/sirupsen/logrus" +) + +// KiloTokenStorage stores token information for Kilo AI authentication. +type KiloTokenStorage struct { + // Token is the Kilo access token. + Token string `json:"kilocodeToken"` + + // OrganizationID is the Kilo organization ID. + OrganizationID string `json:"kilocodeOrganizationId"` + + // Model is the default model to use. + Model string `json:"kilocodeModel"` + + // Email is the email address of the authenticated user. + Email string `json:"email"` + + // Type indicates the authentication provider type, always "kilo" for this storage. + Type string `json:"type"` +} + +// SaveTokenToFile serializes the Kilo token storage to a JSON file. +func (ts *KiloTokenStorage) SaveTokenToFile(authFilePath string) error { + misc.LogSavingCredentials(authFilePath) + ts.Type = "kilo" + if err := os.MkdirAll(filepath.Dir(authFilePath), 0700); err != nil { + return fmt.Errorf("failed to create directory: %v", err) + } + + f, err := os.Create(authFilePath) + if err != nil { + return fmt.Errorf("failed to create token file: %w", err) + } + defer func() { + if errClose := f.Close(); errClose != nil { + log.Errorf("failed to close file: %v", errClose) + } + }() + + if err = json.NewEncoder(f).Encode(ts); err != nil { + return fmt.Errorf("failed to write token to file: %w", err) + } + return nil +} + +// CredentialFileName returns the filename used to persist Kilo credentials. +func CredentialFileName(email string) string { + return fmt.Sprintf("kilo-%s.json", email) +} diff --git a/internal/cmd/auth_manager.go b/internal/cmd/auth_manager.go index 70adc037..2a3407be 100644 --- a/internal/cmd/auth_manager.go +++ b/internal/cmd/auth_manager.go @@ -22,6 +22,7 @@ func newAuthManager() *sdkAuth.Manager { sdkAuth.NewKimiAuthenticator(), sdkAuth.NewKiroAuthenticator(), sdkAuth.NewGitHubCopilotAuthenticator(), + sdkAuth.NewKiloAuthenticator(), ) return manager } diff --git a/internal/cmd/kilo_login.go b/internal/cmd/kilo_login.go new file mode 100644 index 00000000..7e9ed3b9 --- /dev/null +++ b/internal/cmd/kilo_login.go @@ -0,0 +1,54 @@ +package cmd + +import ( + "context" + "fmt" + "strings" + + "github.com/router-for-me/CLIProxyAPI/v6/internal/config" + sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth" +) + +// DoKiloLogin handles the Kilo device flow using the shared authentication manager. +// It initiates the device-based authentication process for Kilo AI services and saves +// the authentication tokens to the configured auth directory. +// +// Parameters: +// - cfg: The application configuration +// - options: Login options including browser behavior and prompts +func DoKiloLogin(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.Print(prompt) + var value string + fmt.Scanln(&value) + return strings.TrimSpace(value), nil + } + } + + authOpts := &sdkAuth.LoginOptions{ + NoBrowser: options.NoBrowser, + CallbackPort: options.CallbackPort, + Metadata: map[string]string{}, + Prompt: promptFn, + } + + _, savedPath, err := manager.Login(context.Background(), "kilo", cfg, authOpts) + if err != nil { + fmt.Printf("Kilo authentication failed: %v\n", err) + return + } + + if savedPath != "" { + fmt.Printf("Authentication saved to %s\n", savedPath) + } + + fmt.Println("Kilo authentication successful!") +} diff --git a/internal/constant/constant.go b/internal/constant/constant.go index 1dbeecde..9b7d31aa 100644 --- a/internal/constant/constant.go +++ b/internal/constant/constant.go @@ -27,4 +27,7 @@ const ( // Kiro represents the AWS CodeWhisperer (Kiro) provider identifier. Kiro = "kiro" + + // Kilo represents the Kilo AI provider identifier. + Kilo = "kilo" ) diff --git a/internal/registry/kilo_models.go b/internal/registry/kilo_models.go new file mode 100644 index 00000000..379d7ff5 --- /dev/null +++ b/internal/registry/kilo_models.go @@ -0,0 +1,21 @@ +// Package registry provides model definitions for various AI service providers. +package registry + +// GetKiloModels returns the Kilo model definitions +func GetKiloModels() []*ModelInfo { + return []*ModelInfo{ + // --- Base Models --- + { + ID: "kilo-auto", + Object: "model", + Created: 1732752000, + OwnedBy: "kilo", + Type: "kilo", + DisplayName: "Kilo Auto", + Description: "Automatic model selection by Kilo", + ContextLength: 200000, + MaxCompletionTokens: 64000, + Thinking: &ThinkingSupport{Min: 1024, Max: 32000, ZeroAllowed: true, DynamicAllowed: true}, + }, + } +} diff --git a/internal/registry/model_definitions.go b/internal/registry/model_definitions.go index 12464094..14d5ade4 100644 --- a/internal/registry/model_definitions.go +++ b/internal/registry/model_definitions.go @@ -20,6 +20,7 @@ import ( // - qwen // - iflow // - kiro +// - kilo // - github-copilot // - kiro // - amazonq @@ -47,6 +48,8 @@ func GetStaticModelDefinitionsByChannel(channel string) []*ModelInfo { return GetGitHubCopilotModels() case "kiro": return GetKiroModels() + case "kilo": + return GetKiloModels() case "amazonq": return GetAmazonQModels() case "antigravity": @@ -95,6 +98,7 @@ func LookupStaticModelInfo(modelID string) *ModelInfo { GetIFlowModels(), GetGitHubCopilotModels(), GetKiroModels(), + GetKiloModels(), GetAmazonQModels(), } for _, models := range allModels { diff --git a/internal/runtime/executor/kilo_executor.go b/internal/runtime/executor/kilo_executor.go new file mode 100644 index 00000000..65d76a6f --- /dev/null +++ b/internal/runtime/executor/kilo_executor.go @@ -0,0 +1,204 @@ +package executor + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/router-for-me/CLIProxyAPI/v6/internal/config" + "github.com/router-for-me/CLIProxyAPI/v6/internal/registry" + "github.com/router-for-me/CLIProxyAPI/v6/internal/util" + 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" + "github.com/tidwall/gjson" +) + +// KiloExecutor handles requests to Kilo API. +type KiloExecutor struct { + cfg *config.Config +} + +// NewKiloExecutor creates a new Kilo executor instance. +func NewKiloExecutor(cfg *config.Config) *KiloExecutor { + return &KiloExecutor{cfg: cfg} +} + +// Identifier returns the unique identifier for this executor. +func (e *KiloExecutor) Identifier() string { return "kilo" } + +// PrepareRequest prepares the HTTP request before execution. +func (e *KiloExecutor) PrepareRequest(req *http.Request, auth *cliproxyauth.Auth) error { + if req == nil { + return nil + } + accessToken, _ := kiloCredentials(auth) + if strings.TrimSpace(accessToken) == "" { + return fmt.Errorf("kilo: missing access token") + } + + req.Header.Set("Authorization", "Bearer "+accessToken) + var attrs map[string]string + if auth != nil { + attrs = auth.Attributes + } + util.ApplyCustomHeadersFromAttrs(req, attrs) + return nil +} + +// HttpRequest executes a raw HTTP request. +func (e *KiloExecutor) HttpRequest(ctx context.Context, auth *cliproxyauth.Auth, req *http.Request) (*http.Response, error) { + if req == nil { + return nil, fmt.Errorf("kilo 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 request. +func (e *KiloExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) { + return cliproxyexecutor.Response{}, fmt.Errorf("kilo: execution not fully implemented yet") +} + +// ExecuteStream performs a streaming request. +func (e *KiloExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (<-chan cliproxyexecutor.StreamChunk, error) { + return nil, fmt.Errorf("kilo: streaming execution not fully implemented yet") +} + +// Refresh validates the Kilo token. +func (e *KiloExecutor) Refresh(ctx context.Context, auth *cliproxyauth.Auth) (*cliproxyauth.Auth, error) { + if auth == nil { + return nil, fmt.Errorf("missing auth") + } + return auth, nil +} + +// CountTokens returns the token count for the given request. +func (e *KiloExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) { + return cliproxyexecutor.Response{}, fmt.Errorf("kilo: count tokens not supported") +} + +// kiloCredentials extracts access token and other info from auth. +func kiloCredentials(auth *cliproxyauth.Auth) (accessToken, orgID string) { + if auth == nil { + return "", "" + } + if auth.Metadata != nil { + if token, ok := auth.Metadata["access_token"].(string); ok { + accessToken = token + } + if org, ok := auth.Metadata["organization_id"].(string); ok { + orgID = org + } + } + if accessToken == "" && auth.Attributes != nil { + accessToken = auth.Attributes["access_token"] + orgID = auth.Attributes["organization_id"] + } + return accessToken, orgID +} + +// FetchKiloModels fetches models from Kilo API. +func FetchKiloModels(ctx context.Context, auth *cliproxyauth.Auth, cfg *config.Config) []*registry.ModelInfo { + accessToken, orgID := kiloCredentials(auth) + if accessToken == "" { + log.Infof("kilo: no access token found, skipping dynamic model fetch (using static kilo-auto)") + return registry.GetKiloModels() + } + + httpClient := newProxyAwareHTTPClient(ctx, cfg, auth, 0) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.kilo.ai/api/openrouter/models", nil) + if err != nil { + log.Warnf("kilo: failed to create model fetch request: %v", err) + return registry.GetKiloModels() + } + + req.Header.Set("Authorization", "Bearer "+accessToken) + if orgID != "" { + req.Header.Set("X-Kilocode-OrganizationID", orgID) + } + + resp, err := httpClient.Do(req) + if err != nil { + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + log.Warnf("kilo: fetch models canceled: %v", err) + } else { + log.Warnf("kilo: using static models (API fetch failed: %v)", err) + } + return registry.GetKiloModels() + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + log.Warnf("kilo: failed to read models response: %v", err) + return registry.GetKiloModels() + } + + if resp.StatusCode != http.StatusOK { + log.Warnf("kilo: fetch models failed: status %d, body: %s", resp.StatusCode, string(body)) + return registry.GetKiloModels() + } + + result := gjson.GetBytes(body, "data") + if !result.Exists() { + // Try root if data field is missing + result = gjson.ParseBytes(body) + if !result.IsArray() { + log.Debugf("kilo: response body: %s", string(body)) + log.Warn("kilo: invalid API response format (expected array or data field with array)") + return registry.GetKiloModels() + } + } + + var dynamicModels []*registry.ModelInfo + now := time.Now().Unix() + count := 0 + totalCount := 0 + + result.ForEach(func(key, value gjson.Result) bool { + totalCount++ + pIdxResult := value.Get("preferredIndex") + preferredIndex := pIdxResult.Int() + + // Filter models where preferredIndex > 0 (Kilo-curated models) + if preferredIndex <= 0 { + return true + } + + dynamicModels = append(dynamicModels, ®istry.ModelInfo{ + ID: value.Get("id").String(), + DisplayName: value.Get("name").String(), + ContextLength: int(value.Get("context_length").Int()), + OwnedBy: "kilo", + Type: "kilo", + Object: "model", + Created: now, + }) + count++ + return true + }) + + log.Infof("kilo: fetched %d models from API, %d curated (preferredIndex > 0)", totalCount, count) + if count == 0 && totalCount > 0 { + log.Warn("kilo: no curated models found (all preferredIndex <= 0). Check API response.") + } + + staticModels := registry.GetKiloModels() + // Always include kilo-auto (first static model) + allModels := append(staticModels[:1], dynamicModels...) + + return allModels +} + diff --git a/sdk/auth/kilo.go b/sdk/auth/kilo.go new file mode 100644 index 00000000..205e37fb --- /dev/null +++ b/sdk/auth/kilo.go @@ -0,0 +1,121 @@ +package auth + +import ( + "context" + "fmt" + "time" + + "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/kilo" + "github.com/router-for-me/CLIProxyAPI/v6/internal/config" + coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" +) + +// KiloAuthenticator implements the login flow for Kilo AI accounts. +type KiloAuthenticator struct{} + +// NewKiloAuthenticator constructs a Kilo authenticator. +func NewKiloAuthenticator() *KiloAuthenticator { + return &KiloAuthenticator{} +} + +func (a *KiloAuthenticator) Provider() string { + return "kilo" +} + +func (a *KiloAuthenticator) RefreshLead() *time.Duration { + return nil +} + +// Login manages the device flow authentication for Kilo AI. +func (a *KiloAuthenticator) 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{} + } + + kilocodeAuth := kilo.NewKiloAuth() + + fmt.Println("Initiating Kilo device authentication...") + resp, err := kilocodeAuth.InitiateDeviceFlow(ctx) + if err != nil { + return nil, fmt.Errorf("failed to initiate device flow: %w", err) + } + + fmt.Printf("Please visit: %s\n", resp.VerificationURL) + fmt.Printf("And enter code: %s\n", resp.Code) + + fmt.Println("Waiting for authorization...") + status, err := kilocodeAuth.PollForToken(ctx, resp.Code) + if err != nil { + return nil, fmt.Errorf("authentication failed: %w", err) + } + + fmt.Printf("Authentication successful for %s\n", status.UserEmail) + + profile, err := kilocodeAuth.GetProfile(ctx, status.Token) + if err != nil { + return nil, fmt.Errorf("failed to fetch profile: %w", err) + } + + var orgID string + if len(profile.Orgs) > 1 { + fmt.Println("Multiple organizations found. Please select one:") + for i, org := range profile.Orgs { + fmt.Printf("[%d] %s (%s)\n", i+1, org.Name, org.ID) + } + + if opts.Prompt != nil { + input, err := opts.Prompt("Enter the number of the organization: ") + if err != nil { + return nil, err + } + var choice int + fmt.Sscanf(input, "%d", &choice) + if choice > 0 && choice <= len(profile.Orgs) { + orgID = profile.Orgs[choice-1].ID + } else { + orgID = profile.Orgs[0].ID + fmt.Printf("Invalid choice, defaulting to %s\n", profile.Orgs[0].Name) + } + } else { + orgID = profile.Orgs[0].ID + fmt.Printf("Non-interactive mode, defaulting to organization: %s\n", profile.Orgs[0].Name) + } + } else if len(profile.Orgs) == 1 { + orgID = profile.Orgs[0].ID + } + + defaults, err := kilocodeAuth.GetDefaults(ctx, status.Token, orgID) + if err != nil { + fmt.Printf("Warning: failed to fetch defaults: %v\n", err) + defaults = &kilo.Defaults{} + } + + ts := &kilo.KiloTokenStorage{ + Token: status.Token, + OrganizationID: orgID, + Model: defaults.Model, + Email: status.UserEmail, + Type: "kilo", + } + + fileName := kilo.CredentialFileName(status.UserEmail) + metadata := map[string]any{ + "email": status.UserEmail, + "organization_id": orgID, + "model": defaults.Model, + } + + return &coreauth.Auth{ + ID: fileName, + Provider: a.Provider(), + FileName: fileName, + Storage: ts, + Metadata: metadata, + }, nil +} diff --git a/sdk/cliproxy/service.go b/sdk/cliproxy/service.go index aef0ca5f..1110cf96 100644 --- a/sdk/cliproxy/service.go +++ b/sdk/cliproxy/service.go @@ -413,6 +413,8 @@ func (s *Service) ensureExecutorsForAuth(a *coreauth.Auth) { s.coreManager.RegisterExecutor(executor.NewKimiExecutor(s.cfg)) case "kiro": s.coreManager.RegisterExecutor(executor.NewKiroExecutor(s.cfg)) + case "kilo": + s.coreManager.RegisterExecutor(executor.NewKiloExecutor(s.cfg)) case "github-copilot": s.coreManager.RegisterExecutor(executor.NewGitHubCopilotExecutor(s.cfg)) default: @@ -844,6 +846,9 @@ func (s *Service) registerModelsForAuth(a *coreauth.Auth) { case "kiro": models = s.fetchKiroModels(a) models = applyExcludedModels(models, excluded) + case "kilo": + models = executor.FetchKiloModels(context.Background(), a, s.cfg) + models = applyExcludedModels(models, excluded) default: // Handle OpenAI-compatibility providers by name using config if s.cfg != nil {