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') }} +

+
+ + +
+ + +
+ +
+ + +
+ +
+
+
{ 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'