added kilocode auth, needs adjusting

This commit is contained in:
DetroitTommy
2026-02-15 13:44:26 -05:00
parent d560c20c26
commit 1dbeb0827a
15 changed files with 755 additions and 12 deletions

2
.gitignore vendored
View File

@@ -3,8 +3,10 @@ cli-proxy-api
cliproxy cliproxy
*.exe *.exe
# Configuration # Configuration
config.yaml config.yaml
my-config.yaml
.env .env
.mcp.json .mcp.json
# Generated content # Generated content

View File

@@ -72,6 +72,7 @@ func main() {
var codexLogin bool var codexLogin bool
var claudeLogin bool var claudeLogin bool
var qwenLogin bool var qwenLogin bool
var kiloLogin bool
var iflowLogin bool var iflowLogin bool
var iflowCookie bool var iflowCookie bool
var noBrowser bool var noBrowser bool
@@ -96,6 +97,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(&kiloLogin, "kilo-login", false, "Login to Kilo AI using device flow")
flag.BoolVar(&iflowLogin, "iflow-login", false, "Login to iFlow using OAuth") flag.BoolVar(&iflowLogin, "iflow-login", false, "Login to iFlow using OAuth")
flag.BoolVar(&iflowCookie, "iflow-cookie", false, "Login to iFlow using Cookie") flag.BoolVar(&iflowCookie, "iflow-cookie", false, "Login to iFlow using Cookie")
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")
@@ -499,6 +501,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 kiloLogin {
cmd.DoKiloLogin(cfg, options)
} else if iflowLogin { } else if iflowLogin {
cmd.DoIFlowLogin(cfg, options) cmd.DoIFlowLogin(cfg, options)
} else if iflowCookie { } else if iflowCookie {

View File

@@ -1,6 +1,6 @@
# Server host/interface to bind to. Default is empty ("") to bind all interfaces (IPv4 + IPv6). # 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. # Use "127.0.0.1" or "localhost" to restrict access to local machine only.
host: "" host: ''
# Server port # Server port
port: 8317 port: 8317
@@ -8,8 +8,8 @@ port: 8317
# TLS settings for HTTPS. When enabled, the server listens with the provided certificate and key. # TLS settings for HTTPS. When enabled, the server listens with the provided certificate and key.
tls: tls:
enable: false enable: false
cert: "" cert: ''
key: "" key: ''
# Management API settings # Management API settings
remote-management: remote-management:
@@ -20,22 +20,22 @@ remote-management:
# Management key. If a plaintext value is provided here, it will be hashed on startup. # Management key. If a plaintext value is provided here, it will be hashed on startup.
# All management requests (even from localhost) require this key. # All management requests (even from localhost) require this key.
# Leave empty to disable the Management API entirely (404 for all /v0/management routes). # 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 the bundled management control panel asset download and HTTP route when true.
disable-control-panel: false disable-control-panel: false
# GitHub repository for the management control panel. Accepts a repository URL or releases API URL. # 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) # Authentication directory (supports ~ for home directory)
auth-dir: "~/.cli-proxy-api" auth-dir: '~/.cli-proxy-api'
# API keys for authentication # API keys for authentication
api-keys: api-keys:
- "your-api-key-1" - 'your-api-key-1'
- "your-api-key-2" - 'your-api-key-2'
- "your-api-key-3" - 'your-api-key-3'
# Enable debug logging # Enable debug logging
debug: false debug: false
@@ -43,7 +43,7 @@ debug: false
# Enable pprof HTTP debug server (host:port). Keep it bound to localhost for safety. # Enable pprof HTTP debug server (host:port). Keep it bound to localhost for safety.
pprof: pprof:
enable: false 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. # When true, disable high-overhead HTTP middleware features to reduce per-request memory usage under high concurrency.
commercial-mode: false commercial-mode: false
@@ -68,7 +68,7 @@ error-logs-max-files: 10
usage-statistics-enabled: false usage-statistics-enabled: false
# Proxy URL. Supports socks5/http/https protocols. Example: socks5://user:pass@192.168.1.1:1080/ # 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). # When true, unprefixed model requests only use credentials without a prefix (except when prefix == model name).
force-model-prefix: false force-model-prefix: false
@@ -86,7 +86,7 @@ quota-exceeded:
# Routing strategy for selecting credentials when multiple match. # Routing strategy for selecting credentials when multiple match.
routing: 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). # When true, enable authentication for the WebSocket API (/v1/ws).
ws-auth: false ws-auth: false
@@ -171,6 +171,21 @@ nonstream-keepalive-interval: 0
# profile-arn: "arn:aws:codewhisperer:us-east-1:..." # profile-arn: "arn:aws:codewhisperer:us-east-1:..."
# proxy-url: "socks5://proxy.example.com:1080" # optional: proxy override # 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 providers
# openai-compatibility: # openai-compatibility:
# - name: "openrouter" # The name of the provider; it will be used in the user agent and other places. # - name: "openrouter" # The name of the provider; it will be used in the user agent and other places.

View File

@@ -29,6 +29,7 @@ import (
"github.com/router-for-me/CLIProxyAPI/v6/internal/auth/copilot" "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/copilot"
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" 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" "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/kimi"
kiroauth "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/kiro" kiroauth "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/kiro"
"github.com/router-for-me/CLIProxyAPI/v6/internal/auth/qwen" "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 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,
})
}

View File

@@ -649,6 +649,7 @@ func (s *Server) registerManagementRoutes() {
mgmt.GET("/gemini-cli-auth-url", s.mgmt.RequestGeminiCLIToken) mgmt.GET("/gemini-cli-auth-url", s.mgmt.RequestGeminiCLIToken)
mgmt.GET("/antigravity-auth-url", s.mgmt.RequestAntigravityToken) mgmt.GET("/antigravity-auth-url", s.mgmt.RequestAntigravityToken)
mgmt.GET("/qwen-auth-url", s.mgmt.RequestQwenToken) 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("/kimi-auth-url", s.mgmt.RequestKimiToken)
mgmt.GET("/iflow-auth-url", s.mgmt.RequestIFlowToken) mgmt.GET("/iflow-auth-url", s.mgmt.RequestIFlowToken)
mgmt.POST("/iflow-auth-url", s.mgmt.RequestIFlowCookieToken) mgmt.POST("/iflow-auth-url", s.mgmt.RequestIFlowCookieToken)

View File

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

View File

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

View File

@@ -22,6 +22,7 @@ func newAuthManager() *sdkAuth.Manager {
sdkAuth.NewKimiAuthenticator(), sdkAuth.NewKimiAuthenticator(),
sdkAuth.NewKiroAuthenticator(), sdkAuth.NewKiroAuthenticator(),
sdkAuth.NewGitHubCopilotAuthenticator(), sdkAuth.NewGitHubCopilotAuthenticator(),
sdkAuth.NewKiloAuthenticator(),
) )
return manager return manager
} }

View File

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

View File

@@ -27,4 +27,7 @@ const (
// Kiro represents the AWS CodeWhisperer (Kiro) provider identifier. // Kiro represents the AWS CodeWhisperer (Kiro) provider identifier.
Kiro = "kiro" Kiro = "kiro"
// Kilo represents the Kilo AI provider identifier.
Kilo = "kilo"
) )

View File

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

View File

@@ -20,6 +20,7 @@ import (
// - qwen // - qwen
// - iflow // - iflow
// - kiro // - kiro
// - kilo
// - github-copilot // - github-copilot
// - kiro // - kiro
// - amazonq // - amazonq
@@ -47,6 +48,8 @@ func GetStaticModelDefinitionsByChannel(channel string) []*ModelInfo {
return GetGitHubCopilotModels() return GetGitHubCopilotModels()
case "kiro": case "kiro":
return GetKiroModels() return GetKiroModels()
case "kilo":
return GetKiloModels()
case "amazonq": case "amazonq":
return GetAmazonQModels() return GetAmazonQModels()
case "antigravity": case "antigravity":
@@ -95,6 +98,7 @@ func LookupStaticModelInfo(modelID string) *ModelInfo {
GetIFlowModels(), GetIFlowModels(),
GetGitHubCopilotModels(), GetGitHubCopilotModels(),
GetKiroModels(), GetKiroModels(),
GetKiloModels(),
GetAmazonQModels(), GetAmazonQModels(),
} }
for _, models := range allModels { for _, models := range allModels {

View File

@@ -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, &registry.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
}

121
sdk/auth/kilo.go Normal file
View File

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

View File

@@ -413,6 +413,8 @@ func (s *Service) ensureExecutorsForAuth(a *coreauth.Auth) {
s.coreManager.RegisterExecutor(executor.NewKimiExecutor(s.cfg)) s.coreManager.RegisterExecutor(executor.NewKimiExecutor(s.cfg))
case "kiro": case "kiro":
s.coreManager.RegisterExecutor(executor.NewKiroExecutor(s.cfg)) s.coreManager.RegisterExecutor(executor.NewKiroExecutor(s.cfg))
case "kilo":
s.coreManager.RegisterExecutor(executor.NewKiloExecutor(s.cfg))
case "github-copilot": case "github-copilot":
s.coreManager.RegisterExecutor(executor.NewGitHubCopilotExecutor(s.cfg)) s.coreManager.RegisterExecutor(executor.NewGitHubCopilotExecutor(s.cfg))
default: default:
@@ -844,6 +846,9 @@ func (s *Service) registerModelsForAuth(a *coreauth.Auth) {
case "kiro": case "kiro":
models = s.fetchKiroModels(a) models = s.fetchKiroModels(a)
models = applyExcludedModels(models, excluded) models = applyExcludedModels(models, excluded)
case "kilo":
models = executor.FetchKiloModels(context.Background(), a, s.cfg)
models = applyExcludedModels(models, excluded)
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 {