diff --git a/backend/config.test.yaml b/backend/config.test.yaml
new file mode 100644
index 00000000..5d36e04b
--- /dev/null
+++ b/backend/config.test.yaml
@@ -0,0 +1,181 @@
+server:
+ host: "0.0.0.0"
+ port: 3004
+ mode: "debug"
+ frontend_url: "http://localhost:3003"
+
+database:
+ host: "localhost"
+ port: 5432
+ user: "sub2api"
+ password: "sub2api"
+ dbname: "sub2api"
+ sslmode: "disable"
+ max_open_conns: 50
+ max_idle_conns: 10
+
+redis:
+ host: "localhost"
+ port: 6379
+ password: ""
+ db: 0
+ pool_size: 100
+ min_idle_conns: 10
+
+jwt:
+ secret: "test-secret-do-not-use-in-production"
+ expire_hour: 24
+
+totp:
+ encryption_key: ""
+
+default:
+ admin_email: "admin@test.com"
+ admin_password: "admin123"
+ user_concurrency: 5
+ user_balance: 0
+ api_key_prefix: "sk-"
+ rate_multiplier: 1.0
+
+gateway:
+ response_header_timeout: 600
+ max_body_size: 268435456
+ upstream_response_read_max_bytes: 8388608
+ connection_pool_isolation: "account_proxy"
+ max_idle_conns: 2560
+ max_idle_conns_per_host: 120
+ max_conns_per_host: 1024
+ idle_conn_timeout_seconds: 90
+ max_upstream_clients: 5000
+ client_idle_ttl_seconds: 900
+ concurrency_slot_ttl_minutes: 30
+ stream_data_interval_timeout: 180
+ stream_keepalive_interval: 10
+ max_line_size: 41943040
+ log_upstream_error_body: true
+ log_upstream_error_body_max_bytes: 2048
+ inject_beta_for_apikey: false
+ failover_on_400: false
+ scheduling:
+ sticky_session_max_waiting: 3
+ sticky_session_wait_timeout: 120s
+ fallback_wait_timeout: 30s
+ fallback_max_waiting: 100
+ load_batch_enabled: true
+ slot_cleanup_interval: 30s
+ db_fallback_enabled: true
+ db_fallback_timeout_seconds: 0
+ db_fallback_max_qps: 0
+ outbox_poll_interval_seconds: 1
+ outbox_lag_warn_seconds: 5
+ outbox_lag_rebuild_seconds: 10
+ outbox_lag_rebuild_failures: 3
+ outbox_backlog_rebuild_rows: 10000
+ full_rebuild_interval_seconds: 300
+ tls_fingerprint:
+ enabled: false
+
+log:
+ level: "debug"
+ format: "console"
+ service_name: "sub2api"
+ env: "dev"
+ caller: true
+ stacktrace_level: "error"
+ output:
+ to_stdout: true
+ to_file: false
+ rotation:
+ max_size_mb: 100
+ max_backups: 10
+ max_age_days: 7
+ compress: true
+ local_time: true
+ sampling:
+ enabled: false
+ initial: 100
+ thereafter: 100
+
+api_key_auth_cache:
+ l1_size: 65535
+ l1_ttl_seconds: 15
+ l2_ttl_seconds: 300
+ negative_ttl_seconds: 30
+ jitter_percent: 10
+ singleflight: true
+
+dashboard_cache:
+ enabled: true
+ key_prefix: "sub2api:"
+ stats_fresh_ttl_seconds: 15
+ stats_ttl_seconds: 30
+ stats_refresh_timeout_seconds: 30
+
+dashboard_aggregation:
+ enabled: false
+ interval_seconds: 60
+ lookback_seconds: 120
+ backfill_enabled: false
+ backfill_max_days: 31
+ recompute_days: 2
+ retention:
+ usage_logs_days: 90
+ hourly_days: 180
+ daily_days: 730
+
+usage_cleanup:
+ enabled: false
+ max_range_days: 31
+ batch_size: 5000
+ worker_interval_seconds: 10
+ task_timeout_seconds: 1800
+
+idempotency:
+ observe_only: true
+ default_ttl_seconds: 86400
+ system_operation_ttl_seconds: 3600
+ processing_timeout_seconds: 30
+ failed_retry_backoff_seconds: 5
+ max_stored_response_len: 65536
+ cleanup_interval_seconds: 60
+ cleanup_batch_size: 500
+
+concurrency:
+ ping_interval: 10
+
+token_refresh:
+ sync_linked_sora_accounts: false
+
+ops:
+ enabled: false
+
+rate_limit:
+ overload_cooldown_minutes: 10
+
+security:
+ url_allowlist:
+ enabled: false
+ upstream_hosts: []
+ pricing_hosts: []
+ crs_hosts: []
+ allow_private_hosts: true
+ allow_insecure_http: true
+ response_headers:
+ enabled: true
+ additional_allowed: []
+ force_remove: []
+ csp:
+ enabled: false
+ proxy_probe:
+ insecure_skip_verify: false
+ proxy_fallback:
+ allow_direct_on_error: false
+
+turnstile:
+ required: false
+
+linuxdo_connect:
+ enabled: false
+
+oidc_connect:
+ enabled: false
diff --git a/backend/internal/domain/constants.go b/backend/internal/domain/constants.go
index 8863ffb5..c9e7518b 100644
--- a/backend/internal/domain/constants.go
+++ b/backend/internal/domain/constants.go
@@ -32,6 +32,7 @@ const (
AccountTypeAPIKey = "apikey" // API Key类型账号
AccountTypeUpstream = "upstream" // 上游透传类型账号(通过 Base URL + API Key 连接上游)
AccountTypeBedrock = "bedrock" // AWS Bedrock 类型账号(通过 SigV4 签名或 API Key 连接 Bedrock,由 credentials.auth_mode 区分)
+ AccountTypeCLI = "cli" // 本地CLI代理类型账号(通过本地安装的CLI工具转发请求)
)
// Redeem type constants
diff --git a/backend/internal/handler/admin/account_data.go b/backend/internal/handler/admin/account_data.go
index 00da4821..45ff3df6 100644
--- a/backend/internal/handler/admin/account_data.go
+++ b/backend/internal/handler/admin/account_data.go
@@ -560,7 +560,7 @@ func validateDataAccount(item DataAccount) error {
return errors.New("account credentials is required")
}
switch item.Type {
- case service.AccountTypeOAuth, service.AccountTypeSetupToken, service.AccountTypeAPIKey, service.AccountTypeUpstream:
+ case service.AccountTypeOAuth, service.AccountTypeSetupToken, service.AccountTypeAPIKey, service.AccountTypeUpstream, service.AccountTypeBedrock, service.AccountTypeCLI:
default:
return fmt.Errorf("account type is invalid: %s", item.Type)
}
diff --git a/backend/internal/handler/admin/account_handler.go b/backend/internal/handler/admin/account_handler.go
index 9883d007..99e36699 100644
--- a/backend/internal/handler/admin/account_handler.go
+++ b/backend/internal/handler/admin/account_handler.go
@@ -98,7 +98,7 @@ type CreateAccountRequest struct {
Name string `json:"name" binding:"required"`
Notes *string `json:"notes"`
Platform string `json:"platform" binding:"required"`
- Type string `json:"type" binding:"required,oneof=oauth setup-token apikey upstream bedrock"`
+ Type string `json:"type" binding:"required,oneof=oauth setup-token apikey upstream bedrock cli"`
Credentials map[string]any `json:"credentials" binding:"required"`
Extra map[string]any `json:"extra"`
ProxyID *int64 `json:"proxy_id"`
@@ -117,7 +117,7 @@ type CreateAccountRequest struct {
type UpdateAccountRequest struct {
Name string `json:"name"`
Notes *string `json:"notes"`
- Type string `json:"type" binding:"omitempty,oneof=oauth setup-token apikey upstream bedrock"`
+ Type string `json:"type" binding:"omitempty,oneof=oauth setup-token apikey upstream bedrock cli"`
Credentials map[string]any `json:"credentials"`
Extra map[string]any `json:"extra"`
ProxyID *int64 `json:"proxy_id"`
diff --git a/backend/internal/service/domain_constants.go b/backend/internal/service/domain_constants.go
index 870d8f7a..6bae1337 100644
--- a/backend/internal/service/domain_constants.go
+++ b/backend/internal/service/domain_constants.go
@@ -34,6 +34,7 @@ const (
AccountTypeAPIKey = domain.AccountTypeAPIKey // API Key类型账号
AccountTypeUpstream = domain.AccountTypeUpstream // 上游透传类型账号(通过 Base URL + API Key 连接上游)
AccountTypeBedrock = domain.AccountTypeBedrock // AWS Bedrock 类型账号(通过 SigV4 签名或 API Key 连接 Bedrock,由 credentials.auth_mode 区分)
+ AccountTypeCLI = domain.AccountTypeCLI // 本地CLI代理类型账号(通过本地安装的CLI工具转发请求)
)
// Redeem type constants
diff --git a/backend/internal/service/gateway_service.go b/backend/internal/service/gateway_service.go
index 8b854cb3..909ad40f 100644
--- a/backend/internal/service/gateway_service.go
+++ b/backend/internal/service/gateway_service.go
@@ -3454,6 +3454,8 @@ func (s *GatewayService) GetAccessToken(ctx context.Context, account *Account) (
return apiKey, "apikey", nil
case AccountTypeBedrock:
return "", "bedrock", nil // Bedrock 使用 SigV4 签名或 API Key,由 forwardBedrock 处理
+ case AccountTypeCLI:
+ return "", "cli", nil // CLI 类型由 CLI gateway 自行管理认证
default:
return "", "", fmt.Errorf("unsupported account type: %s", account.Type)
}
diff --git a/backend/internal/service/kimi_cli_gateway.go b/backend/internal/service/kimi_cli_gateway.go
new file mode 100644
index 00000000..55da5bd4
--- /dev/null
+++ b/backend/internal/service/kimi_cli_gateway.go
@@ -0,0 +1,442 @@
+package service
+
+import (
+ "bufio"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "os/exec"
+ "strings"
+ "time"
+
+ "github.com/Wei-Shaw/sub2api/internal/pkg/logger"
+ "github.com/gin-gonic/gin"
+ "github.com/tidwall/gjson"
+)
+
+const (
+ kimiCLICommand = "kimi"
+ kimiCLITimeout = 10 * time.Minute
+ kimiCLIStreamJSONFlag = "stream-json"
+)
+
+// KimiCLIGateway handles forwarding chat completions requests through the local kimi-cli.
+type KimiCLIGateway struct {
+ // cliPath is the resolved path to the kimi binary. If empty, "kimi" is used.
+ cliPath string
+}
+
+// NewKimiCLIGateway creates a new CLI gateway, attempting to resolve the kimi binary.
+func NewKimiCLIGateway() *KimiCLIGateway {
+ path := resolveKimiCLI()
+ return &KimiCLIGateway{cliPath: path}
+}
+
+// IsAvailable returns true if the kimi CLI is found on the system.
+func (g *KimiCLIGateway) IsAvailable() bool {
+ return g.cliPath != ""
+}
+
+// ForwardChatCompletions forwards an OpenAI Chat Completions request through kimi-cli.
+func (g *KimiCLIGateway) ForwardChatCompletions(
+ ctx context.Context,
+ c *gin.Context,
+ account *Account,
+ body []byte,
+) (*ForwardResult, error) {
+ startTime := time.Now()
+
+ if !g.IsAvailable() {
+ return nil, errors.New("kimi CLI not found. Please install it: uv tool install --python 3.13 kimi-cli")
+ }
+
+ // 1. Parse request
+ reqStream := gjson.GetBytes(body, "stream").Bool()
+ originalModel := gjson.GetBytes(body, "model").String()
+ mappedModel := account.GetMappedModel(originalModel)
+ if mappedModel == "" {
+ mappedModel = originalModel
+ }
+
+ // 2. Build NDJSON messages for stdin
+ ndjsonInput, err := buildKimiNDJSONMessages(body)
+ if err != nil {
+ return nil, fmt.Errorf("build messages: %w", err)
+ }
+
+ // 3. Build CLI args
+ // Note: kimi-cli does not support --model; it uses the default model.
+ // The API only exposes K2.6, but the actual model is determined by the CLI.
+ args := []string{
+ "--print",
+ "--output-format", kimiCLIStreamJSONFlag,
+ "--input-format", "stream-json",
+ }
+
+ // 4. Run CLI (CLI manages its own OAuth auth via `kimi login`)
+ cmd := exec.CommandContext(ctx, g.cliPath, args...)
+ cmd.Stdin = strings.NewReader(ndjsonInput)
+
+ stdout, err := cmd.StdoutPipe()
+ if err != nil {
+ return nil, fmt.Errorf("stdout pipe: %w", err)
+ }
+ stderr, err := cmd.StderrPipe()
+ if err != nil {
+ return nil, fmt.Errorf("stderr pipe: %w", err)
+ }
+
+ if err := cmd.Start(); err != nil {
+ return nil, fmt.Errorf("start cli: %w", err)
+ }
+
+ // Drain stderr in background for debugging
+ go func() {
+ slurp, _ := io.ReadAll(stderr)
+ if len(slurp) > 0 {
+ logger.LegacyPrintf("service.kimi_cli", "stderr: %s", string(slurp))
+ }
+ }()
+
+ // 5. Parse output and forward
+ var result *ForwardResult
+ if reqStream {
+ result, err = g.handleStreamingResponse(stdout, c, originalModel, mappedModel, startTime)
+ } else {
+ result, err = g.handleNonStreamingResponse(stdout, c, originalModel, mappedModel, startTime)
+ }
+
+ // Wait for process to finish
+ if waitErr := cmd.Wait(); waitErr != nil && result == nil {
+ return nil, fmt.Errorf("cli exited: %w", waitErr)
+ }
+
+ return result, err
+}
+
+// handleStreamingResponse reads the single NDJSON line from kimi-cli and sends it as SSE.
+func (g *KimiCLIGateway) handleStreamingResponse(
+ stdout io.Reader,
+ c *gin.Context,
+ originalModel, mappedModel string,
+ startTime time.Time,
+) (*ForwardResult, error) {
+ if c == nil || c.Writer == nil {
+ return nil, errors.New("gin context or writer is nil")
+ }
+
+ c.Writer.Header().Set("Content-Type", "text/event-stream; charset=utf-8")
+ c.Writer.Header().Set("Cache-Control", "no-cache")
+ c.Writer.Header().Set("Connection", "keep-alive")
+ c.Writer.WriteHeader(http.StatusOK)
+
+ flusher, ok := c.Writer.(http.Flusher)
+ if !ok {
+ return nil, errors.New("streaming not supported")
+ }
+
+ msg, err := parseKimiCLIMessage(stdout)
+ if err != nil {
+ return nil, fmt.Errorf("parse cli output: %w", err)
+ }
+
+ var firstTokenTime *time.Duration
+ chatID := generateChatCompletionID()
+ created := time.Now().Unix()
+
+ // Extract text content
+ text := extractKimiTextContent(msg)
+ thinking := extractKimiThinkingContent(msg)
+
+ // Build full content (prepend thinking if present)
+ fullContent := text
+ if thinking != "" {
+ fullContent = fmt.Sprintf("\n%s\n\n\n%s", thinking, text)
+ }
+
+ // Send role delta
+ if firstTokenTime == nil {
+ elapsed := time.Since(startTime)
+ firstTokenTime = &elapsed
+ }
+ chunk := buildSSEChunk(chatID, created, mappedModel, 0, &kimiDelta{Role: "assistant"}, nil)
+ fmt.Fprintf(c.Writer, "data: %s\n\n", chunk)
+ flusher.Flush()
+
+ // Send content delta (simplified: send all at once since CLI gives full response)
+ chunk = buildSSEChunk(chatID, created, mappedModel, 0, &kimiDelta{Content: fullContent}, nil)
+ fmt.Fprintf(c.Writer, "data: %s\n\n", chunk)
+ flusher.Flush()
+
+ // Send finish
+ chunk = buildSSEChunk(chatID, created, mappedModel, 0, &kimiDelta{}, stringPtr("stop"))
+ fmt.Fprintf(c.Writer, "data: %s\n\n", chunk)
+ flusher.Flush()
+
+ // [DONE]
+ fmt.Fprintf(c.Writer, "data: [DONE]\n\n")
+ flusher.Flush()
+
+ var firstTokenMs *int
+ if firstTokenTime != nil {
+ ms := int(firstTokenTime.Milliseconds())
+ firstTokenMs = &ms
+ }
+
+ return &ForwardResult{
+ UpstreamModel: mappedModel,
+ FirstTokenMs: firstTokenMs,
+ }, nil
+}
+
+// handleNonStreamingResponse reads the single NDJSON line and returns a complete OpenAI response.
+func (g *KimiCLIGateway) handleNonStreamingResponse(
+ stdout io.Reader,
+ c *gin.Context,
+ originalModel, mappedModel string,
+ startTime time.Time,
+) (*ForwardResult, error) {
+ msg, err := parseKimiCLIMessage(stdout)
+ if err != nil {
+ return nil, fmt.Errorf("parse cli output: %w", err)
+ }
+
+ text := extractKimiTextContent(msg)
+ thinking := extractKimiThinkingContent(msg)
+
+ fullContent := text
+ if thinking != "" {
+ fullContent = fmt.Sprintf("\n%s\n\n\n%s", thinking, text)
+ }
+
+ resp := kimiChatCompletionResponse{
+ ID: generateChatCompletionID(),
+ Object: "chat.completion",
+ Created: time.Now().Unix(),
+ Model: mappedModel,
+ Choices: []kimiChoice{
+ {
+ Index: 0,
+ Message: kimiMessage{
+ Role: "assistant",
+ Content: fullContent,
+ },
+ FinishReason: stringPtr("stop"),
+ },
+ },
+ }
+
+ respBytes, _ := json.Marshal(resp)
+
+ if c != nil && c.Writer != nil {
+ c.Writer.Header().Set("Content-Type", "application/json")
+ c.Writer.WriteHeader(http.StatusOK)
+ _, _ = c.Writer.Write(respBytes)
+ }
+
+ return &ForwardResult{
+ UpstreamModel: mappedModel,
+ }, nil
+}
+
+// --- Helpers ---
+
+// resolveKimiCLI attempts to find the kimi binary in PATH.
+func resolveKimiCLI() string {
+ if path, err := exec.LookPath(kimiCLICommand); err == nil && path != "" {
+ return path
+ }
+ return ""
+}
+
+// buildKimiNDJSONMessages converts an OpenAI Chat Completions request body into
+// NDJSON lines suitable for kimi-cli --input-format stream-json.
+func buildKimiNDJSONMessages(body []byte) (string, error) {
+ messagesResult := gjson.GetBytes(body, "messages")
+ if !messagesResult.Exists() || !messagesResult.IsArray() {
+ return "", errors.New("missing or invalid messages array")
+ }
+
+ var lines []string
+ messagesResult.ForEach(func(_, msg gjson.Result) bool {
+ role := msg.Get("role").String()
+ content := msg.Get("content").String()
+
+ // Handle array content (e.g. vision messages with multiple parts)
+ if content == "" && msg.Get("content").IsArray() {
+ var texts []string
+ msg.Get("content").ForEach(func(_, item gjson.Result) bool {
+ if item.Get("type").String() == "text" {
+ texts = append(texts, item.Get("text").String())
+ }
+ return true
+ })
+ content = strings.Join(texts, "\n")
+ }
+
+ if role == "" || content == "" {
+ return true // skip empty
+ }
+
+ line := fmt.Sprintf(`{"role":%q,"content":%q}`, role, content)
+ lines = append(lines, line)
+ return true
+ })
+
+ if len(lines) == 0 {
+ return "", errors.New("no valid messages found")
+ }
+
+ return strings.Join(lines, "\n") + "\n", nil
+}
+
+// parseKimiCLIMessage reads all NDJSON lines from stdout and returns the last valid one.
+// When multiple messages are piped via stdin, kimi-cli outputs one response per turn;
+// we only need the last one (the response to the final user message).
+func parseKimiCLIMessage(stdout io.Reader) (map[string]interface{}, error) {
+ scanner := bufio.NewScanner(stdout)
+ scanner.Buffer(make([]byte, 4096), 1024*1024)
+
+ var lastMsg map[string]interface{}
+ for scanner.Scan() {
+ line := strings.TrimSpace(scanner.Text())
+ if line == "" {
+ continue
+ }
+ // Skip the "To resume this session" line
+ if strings.HasPrefix(line, "To resume this session") {
+ continue
+ }
+
+ var msg map[string]interface{}
+ if err := json.Unmarshal([]byte(line), &msg); err != nil {
+ continue // skip non-JSON lines
+ }
+ lastMsg = msg
+ }
+
+ if err := scanner.Err(); err != nil {
+ return nil, fmt.Errorf("scan cli output: %w", err)
+ }
+
+ if lastMsg == nil {
+ return nil, errors.New("no valid JSON output from kimi-cli")
+ }
+ return lastMsg, nil
+}
+
+// extractKimiTextContent extracts text from a kimi-cli message.
+func extractKimiTextContent(msg map[string]interface{}) string {
+ content, ok := msg["content"]
+ if !ok {
+ return ""
+ }
+
+ // String content
+ if s, ok := content.(string); ok {
+ return s
+ }
+
+ // Array content
+ arr, ok := content.([]interface{})
+ if !ok {
+ return ""
+ }
+
+ var texts []string
+ for _, item := range arr {
+ block, ok := item.(map[string]interface{})
+ if !ok {
+ continue
+ }
+ if block["type"] == "text" {
+ if t, ok := block["text"].(string); ok {
+ texts = append(texts, t)
+ }
+ }
+ }
+ return strings.Join(texts, "\n")
+}
+
+// extractKimiThinkingContent extracts thinking content from a kimi-cli message.
+func extractKimiThinkingContent(msg map[string]interface{}) string {
+ content, ok := msg["content"]
+ if !ok {
+ return ""
+ }
+
+ arr, ok := content.([]interface{})
+ if !ok {
+ return ""
+ }
+
+ var thinks []string
+ for _, item := range arr {
+ block, ok := item.(map[string]interface{})
+ if !ok {
+ continue
+ }
+ if block["type"] == "think" {
+ if t, ok := block["think"].(string); ok {
+ thinks = append(thinks, t)
+ }
+ }
+ }
+ return strings.Join(thinks, "\n")
+}
+
+// --- OpenAI-compatible response structs ---
+
+type kimiDelta struct {
+ Role string `json:"role,omitempty"`
+ Content string `json:"content,omitempty"`
+}
+
+type kimiChoice struct {
+ Index int `json:"index"`
+ Message kimiMessage `json:"message,omitempty"`
+ Delta *kimiDelta `json:"delta,omitempty"`
+ FinishReason *string `json:"finish_reason"`
+}
+
+type kimiMessage struct {
+ Role string `json:"role"`
+ Content string `json:"content"`
+}
+
+type kimiChatCompletionResponse struct {
+ ID string `json:"id"`
+ Object string `json:"object"`
+ Created int64 `json:"created"`
+ Model string `json:"model"`
+ Choices []kimiChoice `json:"choices"`
+}
+
+func buildSSEChunk(id string, created int64, model string, index int, delta *kimiDelta, finishReason *string) string {
+ chunk := kimiChatCompletionResponse{
+ ID: id,
+ Object: "chat.completion.chunk",
+ Created: created,
+ Model: model,
+ Choices: []kimiChoice{
+ {
+ Index: index,
+ Delta: delta,
+ FinishReason: finishReason,
+ },
+ },
+ }
+ b, _ := json.Marshal(chunk)
+ return string(b)
+}
+
+func generateChatCompletionID() string {
+ return fmt.Sprintf("chatcmpl-%d", time.Now().UnixNano())
+}
+
+func stringPtr(s string) *string {
+ return &s
+}
diff --git a/backend/internal/service/kimi_gateway_service.go b/backend/internal/service/kimi_gateway_service.go
index 2ac13a71..0478922b 100644
--- a/backend/internal/service/kimi_gateway_service.go
+++ b/backend/internal/service/kimi_gateway_service.go
@@ -24,12 +24,19 @@ const (
// ForwardKimiChatCompletions forwards an OpenAI Chat Completions request directly to Kimi API.
// Kimi API is OpenAI Chat Completions compatible, so the body is forwarded as-is.
+// For CLI-type accounts, requests are forwarded through the local kimi-cli binary.
func (s *GatewayService) ForwardKimiChatCompletions(
ctx context.Context,
c *gin.Context,
account *Account,
body []byte,
) (*ForwardResult, error) {
+ // If account type is CLI, use local kimi-cli (CLI manages its own auth)
+ if account.Type == AccountTypeCLI {
+ cliGateway := NewKimiCLIGateway()
+ return cliGateway.ForwardChatCompletions(ctx, c, account, body)
+ }
+
startTime := time.Now()
// 1. Get access token
diff --git a/deploy/docker-compose.test.yml b/deploy/docker-compose.test.yml
new file mode 100644
index 00000000..7f212f8d
--- /dev/null
+++ b/deploy/docker-compose.test.yml
@@ -0,0 +1,28 @@
+services:
+ postgres:
+ image: postgres:16-alpine
+ container_name: sub2api-test-postgres
+ environment:
+ POSTGRES_USER: sub2api
+ POSTGRES_PASSWORD: sub2api
+ POSTGRES_DB: sub2api
+ ports:
+ - "127.0.0.1:5432:5432"
+ volumes:
+ - ./postgres_test_data:/var/lib/postgresql/data
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U sub2api -d sub2api"]
+ interval: 5s
+ timeout: 5s
+ retries: 5
+
+ redis:
+ image: redis:7-alpine
+ container_name: sub2api-test-redis
+ ports:
+ - "127.0.0.1:6379:6379"
+ healthcheck:
+ test: ["CMD", "redis-cli", "ping"]
+ interval: 5s
+ timeout: 5s
+ retries: 5
diff --git a/frontend/src/components/account/CreateAccountModal.vue b/frontend/src/components/account/CreateAccountModal.vue
index 26177a9b..41e29908 100644
--- a/frontend/src/components/account/CreateAccountModal.vue
+++ b/frontend/src/components/account/CreateAccountModal.vue
@@ -744,10 +744,10 @@
-
+
-
+
+
@@ -3260,7 +3286,7 @@ interface TempUnschedRuleForm {
// State
const step = ref(1)
const submitting = ref(false)
-const accountCategory = ref<'oauth-based' | 'apikey' | 'bedrock'>('oauth-based') // UI selection for account category
+const accountCategory = ref<'oauth-based' | 'apikey' | 'bedrock' | 'cli'>('oauth-based') // UI selection for account category
const addMethod = ref('oauth') // For oauth-based: 'oauth' or 'setup-token'
const apiKeyBaseUrl = ref('https://api.anthropic.com')
const apiKeyValue = ref('')
@@ -3502,6 +3528,10 @@ const isOAuthFlow = computed(() => {
if (form.platform === 'anthropic' && accountCategory.value === 'bedrock') {
return false
}
+ // CLI 类型不需要 OAuth 流程
+ if (accountCategory.value === 'cli') {
+ return false
+ }
return accountCategory.value === 'oauth-based'
})
@@ -3577,6 +3607,11 @@ watch(
form.type = 'bedrock' as AccountType
return
}
+ // CLI 类型
+ if (category === 'cli') {
+ form.type = 'cli'
+ return
+ }
if (category === 'oauth-based') {
form.type = method as AccountType // 'oauth' or 'setup-token'
} else {
@@ -4179,6 +4214,36 @@ const handleSubmit = async () => {
return
}
+ // For CLI type, create directly (CLI manages its own auth)
+ if (accountCategory.value === 'cli') {
+ if (!form.name.trim()) {
+ appStore.showError(t('admin.accounts.pleaseEnterAccountName'))
+ return
+ }
+
+ const credentials: Record = {}
+
+ // Add model mapping if configured
+ const modelMapping = buildModelMappingObject(modelRestrictionMode.value, allowedModels.value, modelMappings.value)
+ if (modelMapping) {
+ credentials.model_mapping = modelMapping
+ }
+
+ // Add pool mode if enabled
+ if (poolModeEnabled.value) {
+ credentials.pool_mode = true
+ credentials.pool_mode_retry_count = normalizePoolModeRetryCount(poolModeRetryCount.value)
+ }
+
+ applyInterceptWarmup(credentials, interceptWarmupRequests.value, 'create')
+ if (!applyTempUnschedConfig(credentials)) {
+ return
+ }
+
+ await createAccountAndFinish(form.platform, 'cli', credentials)
+ return
+ }
+
// For Bedrock type, create directly
if (form.platform === 'anthropic' && accountCategory.value === 'bedrock') {
if (!form.name.trim()) {
diff --git a/frontend/src/components/account/EditAccountModal.vue b/frontend/src/components/account/EditAccountModal.vue
index 59ca0b9c..dff45929 100644
--- a/frontend/src/components/account/EditAccountModal.vue
+++ b/frontend/src/components/account/EditAccountModal.vue
@@ -407,6 +407,144 @@
+
+
+
+
+
+
+
Local kimi-cli
+
This account uses your locally installed kimi-cli. Authentication is managed by the CLI itself.
+
+
+
+
+
+
+
+
+
+
+ {{ t('admin.accounts.openai.modelRestrictionDisabledByPassthrough') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('admin.accounts.selectedModels', { count: allowedModels.length }) }}
+ {{
+ t('admin.accounts.allModelsAllowed')
+ }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{
modelMappings.value = []
allowedModels.value = []
}
+ } else if (newAccount.type === 'cli' && newAccount.credentials) {
+ const credentials = newAccount.credentials as Record
+
+ // Load model mappings and detect mode
+ const existingMappings = credentials.model_mapping as Record | undefined
+ if (existingMappings && typeof existingMappings === 'object') {
+ const entries = Object.entries(existingMappings)
+ const isWhitelistMode = entries.length > 0 && entries.every(([from, to]) => from === to)
+ if (isWhitelistMode) {
+ modelRestrictionMode.value = 'whitelist'
+ allowedModels.value = entries.map(([from]) => from)
+ modelMappings.value = []
+ } else {
+ modelRestrictionMode.value = 'mapping'
+ modelMappings.value = entries.map(([from, to]) => ({ from, to }))
+ allowedModels.value = []
+ }
+ } else {
+ modelRestrictionMode.value = 'whitelist'
+ modelMappings.value = []
+ allowedModels.value = []
+ }
+
+ // Load pool mode
+ poolModeEnabled.value = credentials.pool_mode === true
+ poolModeRetryCount.value = normalizePoolModeRetryCount(
+ Number(credentials.pool_mode_retry_count ?? DEFAULT_POOL_MODE_RETRY_COUNT)
+ )
+
+ // Load custom error codes
+ customErrorCodesEnabled.value = credentials.custom_error_codes_enabled === true
+ const existingErrorCodes = credentials.custom_error_codes as number[] | undefined
+ if (existingErrorCodes && Array.isArray(existingErrorCodes)) {
+ selectedErrorCodes.value = [...existingErrorCodes]
+ } else {
+ selectedErrorCodes.value = []
+ }
} else if (newAccount.type === 'upstream' && newAccount.credentials) {
const credentials = newAccount.credentials as Record
editBaseUrl.value = (credentials.base_url as string) || ''
@@ -2954,6 +3129,48 @@ const handleSubmit = async () => {
return
}
+ updatePayload.credentials = newCredentials
+ } else if (props.account.type === 'cli') {
+ const currentCredentials = (props.account.credentials as Record) || {}
+ const newCredentials: Record = { ...currentCredentials }
+
+ // Add model mapping if configured
+ const shouldApplyModelMapping = !(props.account.platform === 'openai' && openaiPassthroughEnabled.value)
+ if (shouldApplyModelMapping) {
+ const modelMapping = buildModelMappingObject(modelRestrictionMode.value, allowedModels.value, modelMappings.value)
+ if (modelMapping) {
+ newCredentials.model_mapping = modelMapping
+ } else {
+ delete newCredentials.model_mapping
+ }
+ } else if (currentCredentials.model_mapping) {
+ newCredentials.model_mapping = currentCredentials.model_mapping
+ }
+
+ // Add pool mode if enabled
+ if (poolModeEnabled.value) {
+ newCredentials.pool_mode = true
+ newCredentials.pool_mode_retry_count = normalizePoolModeRetryCount(poolModeRetryCount.value)
+ } else {
+ delete newCredentials.pool_mode
+ delete newCredentials.pool_mode_retry_count
+ }
+
+ // Add custom error codes if enabled
+ if (customErrorCodesEnabled.value) {
+ newCredentials.custom_error_codes_enabled = true
+ newCredentials.custom_error_codes = [...selectedErrorCodes.value]
+ } else {
+ delete newCredentials.custom_error_codes_enabled
+ delete newCredentials.custom_error_codes
+ }
+
+ // Add intercept warmup requests setting
+ applyInterceptWarmup(newCredentials, interceptWarmupRequests.value, 'edit')
+ if (!applyTempUnschedConfig(newCredentials)) {
+ return
+ }
+
updatePayload.credentials = newCredentials
} else if (props.account.type === 'bedrock') {
const currentCredentials = (props.account.credentials as Record) || {}
diff --git a/frontend/src/components/admin/account/AccountTableFilters.vue b/frontend/src/components/admin/account/AccountTableFilters.vue
index b6c7a603..c5900e2b 100644
--- a/frontend/src/components/admin/account/AccountTableFilters.vue
+++ b/frontend/src/components/admin/account/AccountTableFilters.vue
@@ -26,7 +26,7 @@ const updateStatus = (value: string | number | boolean | null) => { emit('update
const updatePrivacyMode = (value: string | number | boolean | null) => { emit('update:filters', { ...props.filters, privacy_mode: value }) }
const updateGroup = (value: string | number | boolean | null) => { emit('update:filters', { ...props.filters, group: value }) }
const pOpts = computed(() => [{ value: '', label: t('admin.accounts.allPlatforms') }, { value: 'anthropic', label: 'Anthropic' }, { value: 'openai', label: 'OpenAI' }, { value: 'gemini', label: 'Gemini' }, { value: 'antigravity', label: 'Antigravity' }, { value: 'kimi', label: 'Kimi' }])
-const tOpts = computed(() => [{ value: '', label: t('admin.accounts.allTypes') }, { value: 'oauth', label: t('admin.accounts.oauthType') }, { value: 'setup-token', label: t('admin.accounts.setupToken') }, { value: 'apikey', label: t('admin.accounts.apiKey') }, { value: 'bedrock', label: 'AWS Bedrock' }])
+const tOpts = computed(() => [{ value: '', label: t('admin.accounts.allTypes') }, { value: 'oauth', label: t('admin.accounts.oauthType') }, { value: 'setup-token', label: t('admin.accounts.setupToken') }, { value: 'apikey', label: t('admin.accounts.apiKey') }, { value: 'bedrock', label: 'AWS Bedrock' }, { value: 'cli', label: t('admin.accounts.cliType') }])
const sOpts = computed(() => [{ value: '', label: t('admin.accounts.allStatus') }, { value: 'active', label: t('admin.accounts.status.active') }, { value: 'inactive', label: t('admin.accounts.status.inactive') }, { value: 'error', label: t('admin.accounts.status.error') }, { value: 'rate_limited', label: t('admin.accounts.status.rateLimited') }, { value: 'temp_unschedulable', label: t('admin.accounts.status.tempUnschedulable') }, { value: 'unschedulable', label: t('admin.accounts.status.unschedulable') }])
const privacyOpts = computed(() => [
{ value: '', label: t('admin.accounts.allPrivacyModes') },
diff --git a/frontend/src/components/common/PlatformTypeBadge.vue b/frontend/src/components/common/PlatformTypeBadge.vue
index 1ebc8892..26a45053 100644
--- a/frontend/src/components/common/PlatformTypeBadge.vue
+++ b/frontend/src/components/common/PlatformTypeBadge.vue
@@ -24,6 +24,8 @@
+
+
{{ typeLabel }}
@@ -75,6 +77,7 @@ const platformLabel = computed(() => {
if (props.platform === 'anthropic') return 'Anthropic'
if (props.platform === 'openai') return 'OpenAI'
if (props.platform === 'antigravity') return 'Antigravity'
+ if (props.platform === 'kimi') return 'Kimi'
return 'Gemini'
})
@@ -88,6 +91,8 @@ const typeLabel = computed(() => {
return 'Key'
case 'bedrock':
return 'AWS'
+ case 'cli':
+ return 'CLI'
default:
return props.type
}
diff --git a/frontend/src/components/keys/UseKeyModal.vue b/frontend/src/components/keys/UseKeyModal.vue
index b3679107..2c24d4fe 100644
--- a/frontend/src/components/keys/UseKeyModal.vue
+++ b/frontend/src/components/keys/UseKeyModal.vue
@@ -181,6 +181,8 @@ const defaultClientTab = computed(() => {
switch (props.platform) {
case 'openai':
return 'codex'
+ case 'kimi':
+ return 'codex'
case 'gemini':
return 'gemini'
case 'antigravity':
@@ -266,7 +268,8 @@ const SparkleIcon = {
const clientTabs = computed((): TabConfig[] => {
if (!props.platform) return []
switch (props.platform) {
- case 'openai': {
+ case 'openai':
+ case 'kimi': {
const tabs: TabConfig[] = [
{ id: 'codex', label: t('keys.useKeyModal.cliTabs.codexCli'), icon: TerminalIcon },
{ id: 'codex-ws', label: t('keys.useKeyModal.cliTabs.codexCliWs'), icon: TerminalIcon },
@@ -322,6 +325,7 @@ const currentTabs = computed(() => {
const platformDescription = computed(() => {
switch (props.platform) {
case 'openai':
+ case 'kimi':
if (activeClientTab.value === 'claude') {
return t('keys.useKeyModal.description')
}
@@ -338,6 +342,7 @@ const platformDescription = computed(() => {
const platformNote = computed(() => {
switch (props.platform) {
case 'openai':
+ case 'kimi':
if (activeClientTab.value === 'claude') {
return t('keys.useKeyModal.note')
}
@@ -399,6 +404,7 @@ const currentFiles = computed((): FileConfig[] => {
case 'anthropic':
return [generateOpenCodeConfig('anthropic', apiBase, apiKey)]
case 'openai':
+ case 'kimi':
return [generateOpenCodeConfig('openai', apiBase, apiKey)]
case 'gemini':
return [generateOpenCodeConfig('gemini', geminiBase, apiKey)]
@@ -414,6 +420,7 @@ const currentFiles = computed((): FileConfig[] => {
switch (props.platform) {
case 'openai':
+ case 'kimi':
if (activeClientTab.value === 'claude') {
return generateAnthropicFiles(baseUrl, apiKey)
}
diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts
index 6b88e78b..bbbccd4f 100644
--- a/frontend/src/i18n/locales/en.ts
+++ b/frontend/src/i18n/locales/en.ts
@@ -2205,6 +2205,7 @@ export default {
oauthType: 'OAuth',
setupToken: 'Setup Token',
apiKey: 'API Key',
+ cliType: 'CLI',
// Schedulable toggle
schedulable: 'Schedulable',
schedulableHint: 'Enable to include this account in API request scheduling',
diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts
index 938f66f2..cc8acecf 100644
--- a/frontend/src/i18n/locales/zh.ts
+++ b/frontend/src/i18n/locales/zh.ts
@@ -2284,6 +2284,7 @@ export default {
allGroups: '全部分组',
ungroupedGroup: '未分配分组',
oauthType: 'OAuth',
+ cliType: 'CLI',
// Schedulable toggle
schedulable: '参与调度',
schedulableHint: '开启后账号参与API请求调度',
diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts
index c3821999..90cfcf0e 100644
--- a/frontend/src/types/index.ts
+++ b/frontend/src/types/index.ts
@@ -610,7 +610,7 @@ export interface UpdateGroupRequest {
// ==================== Account & Proxy Types ====================
export type AccountPlatform = 'anthropic' | 'openai' | 'gemini' | 'antigravity' | 'kimi'
-export type AccountType = 'oauth' | 'setup-token' | 'apikey' | 'upstream' | 'bedrock'
+export type AccountType = 'oauth' | 'setup-token' | 'apikey' | 'upstream' | 'bedrock' | 'cli'
export type OAuthAddMethod = 'oauth' | 'setup-token'
export type ProxyProtocol = 'http' | 'https' | 'socks5' | 'socks5h'