Compare commits

4 Commits

Author SHA1 Message Date
openclaw
88d687715d fix(kimi-cli): isolate context by running CLI in /tmp to prevent session resume
Some checks failed
CI / test (push) Has been cancelled
CI / frontend (push) Has been cancelled
CI / golangci-lint (push) Has been cancelled
Security Scan / backend-security (push) Has been cancelled
Security Scan / frontend-security (push) Has been cancelled
2026-04-24 20:24:23 +08:00
openclaw
55ca0ad687 fix(cli): enable group selection, quota billing and custom error codes for CLI accounts
Some checks failed
CI / test (push) Has been cancelled
CI / frontend (push) Has been cancelled
CI / golangci-lint (push) Has been cancelled
Security Scan / backend-security (push) Has been cancelled
Security Scan / frontend-security (push) Has been cancelled
- GroupsView: add 'kimi' to platformOptions/platformFilterOptions so users
can create kimi platform groups
- CreateAccountModal: show quota controls for cli type; inject quota limits
on submit; add custom_error_codes in CLI creation flow
- EditAccountModal: show quota controls for cli type; save quota limits on
submit
- Remove redundant duplicate quota control block that caused TS build error

[布偶猫/Opus🐾]
2026-04-24 02:02:29 +08:00
openclaw
e746e82c39 feat(kimi): add Kimi CLI forward mode support
Some checks failed
CI / test (push) Has been cancelled
CI / frontend (push) Has been cancelled
CI / golangci-lint (push) Has been cancelled
Security Scan / backend-security (push) Has been cancelled
Security Scan / frontend-security (push) Has been cancelled
- Add AccountTypeCLI domain constant
- Add KimiCLIGateway to forward requests through local kimi-cli binary
- Route CLI accounts in ForwardKimiChatCompletions to cli gateway
- Handle CLI type in GetAccessToken (no token needed)
- Fix Gin oneof binding to accept 'cli' type (Create/Update Account)
- Fix validateDataAccount to accept bedrock and cli types
- Remove unsupported --model arg from kimi-cli invocation
- Frontend: CLI account creation UI with model mapping, pool mode
- Frontend: CLI edit modal support
- Frontend: UseKeyModal shows OpenAI examples for kimi platform
- Add i18n strings for CLI account type

[缅因猫/Codex🐾]
2026-04-24 01:54:59 +08:00
openclaw
af3efbc525 feat(kimi): add Kimi Code OAuth device flow support
Some checks failed
CI / test (push) Has been cancelled
CI / frontend (push) Has been cancelled
CI / golangci-lint (push) Has been cancelled
Security Scan / backend-security (push) Has been cancelled
Security Scan / frontend-security (push) Has been cancelled
Backend:
- Add Kimi OAuth service (InitiateDeviceAuth, PollToken, RefreshToken)
- Add Kimi token provider with caching and auto-refresh
- Add Kimi gateway service for OpenAI-compatible chat completions
- Add admin routes for /kimi/oauth/* endpoints
- Wire up all new services
- Add PlatformKimi to error passthrough rule constants
- Add 'kimi' to group handler platform validation tags
- Fix httpclient usage in Kimi OAuth service (use GetClient with opts)
- Add CanRefresh method to KimiTokenRefresher
- Remove duplicate ProvideKimiTokenProvider from wire ProviderSet
- Remove unused imports
- Lower go.mod requirement to 1.26.1 for local toolchain compatibility
- Add verification_uri_complete support for seamless device auth

Frontend:
- Add useKimiOAuth composable for device flow
- Integrate Kimi device flow UI in CreateAccountModal Step 2
- Add kimi to GroupPlatform and AccountPlatform types
- Add i18n translations for device flow (zh/en)
- Fix Icon name lock-closed -> key
- Add kimi platform colors/styles to platformColors.ts
- Add kimi to AccountTableFilters platform options
- Add kimi to ChannelsView platformOrder
- Use verification_uri_complete for auto-filled user_code

[宪宪/Kimi-1.6🐾]
2026-04-23 12:57:28 +08:00
36 changed files with 2592 additions and 88 deletions

View File

@@ -92,6 +92,7 @@ func provideCleanup(
oauth *service.OAuthService,
openaiOAuth *service.OpenAIOAuthService,
geminiOAuth *service.GeminiOAuthService,
kimiOAuth *service.KimiOAuthService,
antigravityOAuth *service.AntigravityOAuthService,
openAIGateway *service.OpenAIGatewayService,
scheduledTestRunner *service.ScheduledTestRunnerService,
@@ -211,6 +212,10 @@ func provideCleanup(
geminiOAuth.Stop()
return nil
}},
{"KimiOAuthService", func() error {
kimiOAuth.Stop()
return nil
}},
{"AntigravityOAuthService", func() error {
antigravityOAuth.Stop()
return nil

View File

@@ -121,6 +121,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
driveClient := repository.NewGeminiDriveClient()
geminiOAuthService := service.NewGeminiOAuthService(proxyRepository, geminiOAuthClient, geminiCliCodeAssistClient, driveClient, configConfig)
antigravityOAuthService := service.NewAntigravityOAuthService(proxyRepository)
kimiOAuthService := service.NewKimiOAuthService()
geminiQuotaService := service.NewGeminiQuotaService(configConfig, settingRepository)
tempUnschedCache := repository.NewTempUnschedCache(redisClient)
timeoutCounterCache := repository.NewTimeoutCounterCache(redisClient)
@@ -158,6 +159,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
openAIOAuthHandler := admin.NewOpenAIOAuthHandler(openAIOAuthService, adminService)
geminiOAuthHandler := admin.NewGeminiOAuthHandler(geminiOAuthService)
antigravityOAuthHandler := admin.NewAntigravityOAuthHandler(antigravityOAuthService)
kimiOAuthHandler := admin.NewKimiOAuthHandler(kimiOAuthService, proxyRepository)
proxyHandler := admin.NewProxyHandler(adminService)
adminRedeemHandler := admin.NewRedeemHandler(adminService, redeemService)
promoHandler := admin.NewPromoHandler(promoService)
@@ -172,12 +174,13 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
identityService := service.NewIdentityService(identityCache)
deferredService := service.ProvideDeferredService(accountRepository, timingWheelService)
claudeTokenProvider := service.ProvideClaudeTokenProvider(accountRepository, geminiTokenCache, oAuthService, oAuthRefreshAPI)
kimiTokenProvider := service.ProvideKimiTokenProvider(accountRepository, geminiTokenCache, kimiOAuthService, oAuthRefreshAPI)
digestSessionStore := service.NewDigestSessionStore()
channelRepository := repository.NewChannelRepository(db)
channelService := service.NewChannelService(channelRepository, apiKeyAuthCacheInvalidator)
modelPricingResolver := service.NewModelPricingResolver(channelService, billingService)
balanceNotifyService := service.ProvideBalanceNotifyService(emailService, settingRepository, accountRepository)
gatewayService := service.NewGatewayService(accountRepository, groupRepository, usageLogRepository, usageBillingRepository, userRepository, userSubscriptionRepository, userGroupRateRepository, gatewayCache, configConfig, schedulerSnapshotService, concurrencyService, billingService, rateLimitService, billingCacheService, identityService, httpUpstream, deferredService, claudeTokenProvider, sessionLimitCache, rpmCache, digestSessionStore, settingService, tlsFingerprintProfileService, channelService, modelPricingResolver, balanceNotifyService)
gatewayService := service.NewGatewayService(accountRepository, groupRepository, usageLogRepository, usageBillingRepository, userRepository, userSubscriptionRepository, userGroupRateRepository, gatewayCache, configConfig, schedulerSnapshotService, concurrencyService, billingService, rateLimitService, billingCacheService, identityService, httpUpstream, deferredService, claudeTokenProvider, kimiTokenProvider, sessionLimitCache, rpmCache, digestSessionStore, settingService, tlsFingerprintProfileService, channelService, modelPricingResolver, balanceNotifyService)
openAITokenProvider := service.ProvideOpenAITokenProvider(accountRepository, geminiTokenCache, openAIOAuthService, oAuthRefreshAPI)
openAIGatewayService := service.NewOpenAIGatewayService(accountRepository, usageLogRepository, usageBillingRepository, userRepository, userSubscriptionRepository, userGroupRateRepository, gatewayCache, configConfig, schedulerSnapshotService, concurrencyService, billingService, rateLimitService, billingCacheService, httpUpstream, deferredService, openAITokenProvider, modelPricingResolver, channelService, balanceNotifyService)
geminiMessagesCompatService := service.NewGeminiMessagesCompatService(accountRepository, groupRepository, gatewayCache, schedulerSnapshotService, geminiTokenProvider, rateLimitService, httpUpstream, antigravityGatewayService, configConfig)
@@ -221,7 +224,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
settingHandler := admin.NewSettingHandler(settingService, emailService, turnstileService, opsService, paymentConfigService, paymentService)
paymentOrderExpiryService := service.ProvidePaymentOrderExpiryService(paymentService)
paymentHandler := admin.NewPaymentHandler(paymentService, paymentConfigService)
adminHandlers := handler.ProvideAdminHandlers(dashboardHandler, adminUserHandler, groupHandler, accountHandler, adminAnnouncementHandler, dataManagementHandler, backupHandler, oAuthHandler, openAIOAuthHandler, geminiOAuthHandler, antigravityOAuthHandler, proxyHandler, adminRedeemHandler, promoHandler, settingHandler, opsHandler, systemHandler, adminSubscriptionHandler, adminUsageHandler, userAttributeHandler, errorPassthroughHandler, tlsFingerprintProfileHandler, adminAPIKeyHandler, scheduledTestHandler, channelHandler, paymentHandler)
adminHandlers := handler.ProvideAdminHandlers(dashboardHandler, adminUserHandler, groupHandler, accountHandler, adminAnnouncementHandler, dataManagementHandler, backupHandler, oAuthHandler, openAIOAuthHandler, geminiOAuthHandler, antigravityOAuthHandler, kimiOAuthHandler, proxyHandler, adminRedeemHandler, promoHandler, settingHandler, opsHandler, systemHandler, adminSubscriptionHandler, adminUsageHandler, userAttributeHandler, errorPassthroughHandler, tlsFingerprintProfileHandler, adminAPIKeyHandler, scheduledTestHandler, channelHandler, paymentHandler)
usageRecordWorkerPool := service.NewUsageRecordWorkerPool(configConfig)
userMsgQueueCache := repository.NewUserMsgQueueCache(redisClient)
userMessageQueueService := service.ProvideUserMessageQueueService(userMsgQueueCache, rpmCache, configConfig)
@@ -244,7 +247,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
opsAlertEvaluatorService := service.ProvideOpsAlertEvaluatorService(opsService, opsRepository, emailService, redisClient, configConfig)
opsCleanupService := service.ProvideOpsCleanupService(opsRepository, db, redisClient, configConfig)
opsScheduledReportService := service.ProvideOpsScheduledReportService(opsService, userService, emailService, redisClient, configConfig)
tokenRefreshService := service.ProvideTokenRefreshService(accountRepository, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, compositeTokenCacheInvalidator, schedulerCache, configConfig, tempUnschedCache, privacyClientFactory, proxyRepository, oAuthRefreshAPI)
tokenRefreshService := service.ProvideTokenRefreshService(accountRepository, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, kimiOAuthService, compositeTokenCacheInvalidator, schedulerCache, configConfig, tempUnschedCache, privacyClientFactory, proxyRepository, oAuthRefreshAPI)
accountExpiryService := service.ProvideAccountExpiryService(accountRepository)
subscriptionExpiryService := service.ProvideSubscriptionExpiryService(userSubscriptionRepository)
scheduledTestRunnerService := service.ProvideScheduledTestRunnerService(scheduledTestPlanRepository, scheduledTestService, accountTestService, rateLimitService, configConfig)

181
backend/config.test.yaml Normal file
View File

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

View File

@@ -1,6 +1,6 @@
module github.com/Wei-Shaw/sub2api
go 1.26.2
go 1.26.1
require (
entgo.io/ent v0.14.5

View File

@@ -22,6 +22,7 @@ const (
PlatformOpenAI = "openai"
PlatformGemini = "gemini"
PlatformAntigravity = "antigravity"
PlatformKimi = "kimi"
)
// Account type constants
@@ -31,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

View File

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

View File

@@ -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"`

View File

@@ -84,7 +84,7 @@ func NewGroupHandler(adminService service.AdminService, dashboardService *servic
type CreateGroupRequest struct {
Name string `json:"name" binding:"required"`
Description string `json:"description"`
Platform string `json:"platform" binding:"omitempty,oneof=anthropic openai gemini antigravity"`
Platform string `json:"platform" binding:"omitempty,oneof=anthropic openai gemini antigravity kimi"`
RateMultiplier float64 `json:"rate_multiplier"`
IsExclusive bool `json:"is_exclusive"`
SubscriptionType string `json:"subscription_type" binding:"omitempty,oneof=standard subscription"`
@@ -118,7 +118,7 @@ type CreateGroupRequest struct {
type UpdateGroupRequest struct {
Name string `json:"name"`
Description string `json:"description"`
Platform string `json:"platform" binding:"omitempty,oneof=anthropic openai gemini antigravity"`
Platform string `json:"platform" binding:"omitempty,oneof=anthropic openai gemini antigravity kimi"`
RateMultiplier *float64 `json:"rate_multiplier"`
IsExclusive *bool `json:"is_exclusive"`
Status string `json:"status" binding:"omitempty,oneof=active inactive"`

View File

@@ -0,0 +1,182 @@
package admin
import (
"encoding/json"
"strings"
"time"
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
)
// KimiOAuthHandler handles Kimi OAuth admin endpoints.
type KimiOAuthHandler struct {
kimiOAuthService *service.KimiOAuthService
proxyRepo service.ProxyRepository
}
// NewKimiOAuthHandler creates a new KimiOAuthHandler.
func NewKimiOAuthHandler(kimiOAuthService *service.KimiOAuthService, proxyRepo service.ProxyRepository) *KimiOAuthHandler {
return &KimiOAuthHandler{
kimiOAuthService: kimiOAuthService,
proxyRepo: proxyRepo,
}
}
type kimiDeviceAuthRequest struct {
ProxyID *int64 `json:"proxy_id"`
}
// InitiateDeviceAuth starts the Kimi OAuth device authorization flow.
// POST /api/v1/admin/kimi/oauth/device-auth
func (h *KimiOAuthHandler) InitiateDeviceAuth(c *gin.Context) {
var req kimiDeviceAuthRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "Invalid request: "+err.Error())
return
}
result, err := h.kimiOAuthService.InitiateDeviceAuth(c.Request.Context(), req.ProxyID, h.proxyRepo)
if err != nil {
response.InternalError(c, "Failed to initiate device auth: "+err.Error())
return
}
response.Success(c, gin.H{
"device_code": result.DeviceCode,
"user_code": result.UserCode,
"verification_uri": result.VerificationURI,
"verification_uri_complete": result.VerificationURIComplete,
"expires_in": result.ExpiresIn,
"interval": result.Interval,
})
}
type kimiPollTokenRequest struct {
DeviceCode string `json:"device_code" binding:"required"`
}
// PollToken polls for the OAuth token after device authorization.
// POST /api/v1/admin/kimi/oauth/token
func (h *KimiOAuthHandler) PollToken(c *gin.Context) {
var req kimiPollTokenRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "Invalid request: "+err.Error())
return
}
tokenInfo, err := h.kimiOAuthService.PollToken(c.Request.Context(), strings.TrimSpace(req.DeviceCode))
if err != nil {
msg := err.Error()
if strings.Contains(msg, "authorization_pending") {
response.Success(c, gin.H{
"status": "pending",
"message": "Authorization pending, please complete authorization in the browser",
})
return
}
if strings.Contains(msg, "expired_token") || strings.Contains(msg, "invalid_grant") {
response.BadRequest(c, "Token request failed: "+msg)
return
}
response.InternalError(c, "Failed to poll token: "+msg)
return
}
response.Success(c, gin.H{
"status": "completed",
"access_token": tokenInfo.AccessToken,
"refresh_token": tokenInfo.RefreshToken,
"expires_in": tokenInfo.ExpiresIn,
"expires_at": tokenInfo.ExpiresAt,
"scope": tokenInfo.Scope,
"token_type": tokenInfo.TokenType,
})
}
type kimiRefreshTokenRequest struct {
RefreshToken string `json:"refresh_token" binding:"required"`
ProxyID *int64 `json:"proxy_id"`
}
// RefreshToken refreshes the Kimi access token.
// POST /api/v1/admin/kimi/oauth/refresh
func (h *KimiOAuthHandler) RefreshToken(c *gin.Context) {
var req kimiRefreshTokenRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "Invalid request: "+err.Error())
return
}
var proxyURL string
if req.ProxyID != nil && h.proxyRepo != nil {
if proxy, err := h.proxyRepo.GetByID(c.Request.Context(), *req.ProxyID); err == nil && proxy != nil {
proxyURL = proxy.URL()
}
}
tokenInfo, err := h.kimiOAuthService.RefreshToken(c.Request.Context(), strings.TrimSpace(req.RefreshToken), proxyURL)
if err != nil {
response.BadRequest(c, "Failed to refresh token: "+err.Error())
return
}
response.Success(c, gin.H{
"access_token": tokenInfo.AccessToken,
"refresh_token": tokenInfo.RefreshToken,
"expires_in": tokenInfo.ExpiresIn,
"expires_at": tokenInfo.ExpiresAt,
"scope": tokenInfo.Scope,
"token_type": tokenInfo.TokenType,
})
}
// ImportLocalToken imports token from local kimi-cli credentials.
// POST /api/v1/admin/kimi/oauth/import-local
func (h *KimiOAuthHandler) ImportLocalToken(c *gin.Context) {
var req struct {
CredentialsJSON string `json:"credentials_json" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "Invalid request: "+err.Error())
return
}
// Parse the local credentials JSON format
var localCreds struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpiresAt float64 `json:"expires_at"`
Scope string `json:"scope"`
TokenType string `json:"token_type"`
ExpiresIn float64 `json:"expires_in"`
}
if err := json.Unmarshal([]byte(req.CredentialsJSON), &localCreds); err != nil {
response.BadRequest(c, "Invalid credentials JSON: "+err.Error())
return
}
if strings.TrimSpace(localCreds.AccessToken) == "" {
response.BadRequest(c, "access_token is required in credentials")
return
}
expiresAt := int64(localCreds.ExpiresAt)
if expiresAt == 0 && localCreds.ExpiresIn > 0 {
expiresAt = time.Now().Unix() + int64(localCreds.ExpiresIn) - service.KimiTokenSafetyWindow
}
minExpiresAt := time.Now().Unix() + service.KimiTokenMinTTL
if expiresAt < minExpiresAt {
expiresAt = minExpiresAt
}
response.Success(c, gin.H{
"access_token": localCreds.AccessToken,
"refresh_token": localCreds.RefreshToken,
"expires_at": expiresAt,
"scope": localCreds.Scope,
"token_type": localCreds.TokenType,
})
}

View File

@@ -210,7 +210,12 @@ func (h *GatewayHandler) ChatCompletions(c *gin.Context) {
if channelMapping.Mapped {
forwardBody = h.gatewayService.ReplaceModelInBody(body, channelMapping.MappedModel)
}
result, err := h.gatewayService.ForwardAsChatCompletions(c.Request.Context(), c, account, forwardBody, parsedReq)
var result *service.ForwardResult
if account.Platform == service.PlatformKimi {
result, err = h.gatewayService.ForwardKimiChatCompletions(c.Request.Context(), c, account, forwardBody)
} else {
result, err = h.gatewayService.ForwardAsChatCompletions(c.Request.Context(), c, account, forwardBody, parsedReq)
}
if accountReleaseFunc != nil {
accountReleaseFunc()

View File

@@ -17,6 +17,7 @@ type AdminHandlers struct {
OpenAIOAuth *admin.OpenAIOAuthHandler
GeminiOAuth *admin.GeminiOAuthHandler
AntigravityOAuth *admin.AntigravityOAuthHandler
KimiOAuth *admin.KimiOAuthHandler
Proxy *admin.ProxyHandler
Redeem *admin.RedeemHandler
Promo *admin.PromoHandler

View File

@@ -20,6 +20,7 @@ func ProvideAdminHandlers(
openaiOAuthHandler *admin.OpenAIOAuthHandler,
geminiOAuthHandler *admin.GeminiOAuthHandler,
antigravityOAuthHandler *admin.AntigravityOAuthHandler,
kimiOAuthHandler *admin.KimiOAuthHandler,
proxyHandler *admin.ProxyHandler,
redeemHandler *admin.RedeemHandler,
promoHandler *admin.PromoHandler,
@@ -48,6 +49,7 @@ func ProvideAdminHandlers(
OpenAIOAuth: openaiOAuthHandler,
GeminiOAuth: geminiOAuthHandler,
AntigravityOAuth: antigravityOAuthHandler,
KimiOAuth: kimiOAuthHandler,
Proxy: proxyHandler,
Redeem: redeemHandler,
Promo: promoHandler,
@@ -142,6 +144,7 @@ var ProviderSet = wire.NewSet(
admin.NewOpenAIOAuthHandler,
admin.NewGeminiOAuthHandler,
admin.NewAntigravityOAuthHandler,
admin.NewKimiOAuthHandler,
admin.NewProxyHandler,
admin.NewRedeemHandler,
admin.NewPromoHandler,

View File

@@ -36,11 +36,12 @@ const (
PlatformOpenAI = "openai"
PlatformGemini = "gemini"
PlatformAntigravity = "antigravity"
PlatformKimi = "kimi"
)
// AllPlatforms 返回所有支持的平台列表
func AllPlatforms() []string {
return []string{PlatformAnthropic, PlatformOpenAI, PlatformGemini, PlatformAntigravity}
return []string{PlatformAnthropic, PlatformOpenAI, PlatformGemini, PlatformAntigravity, PlatformKimi}
}
// Validate 验证规则配置的有效性

View File

@@ -41,6 +41,9 @@ func RegisterAdminRoutes(
// Antigravity OAuth
registerAntigravityOAuthRoutes(admin, h)
// Kimi OAuth
registerKimiOAuthRoutes(admin, h)
// 代理管理
registerProxyRoutes(admin, h)
@@ -338,6 +341,16 @@ func registerAntigravityOAuthRoutes(admin *gin.RouterGroup, h *handler.Handlers)
}
}
func registerKimiOAuthRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
kimi := admin.Group("/kimi")
{
kimi.POST("/oauth/device-auth", h.Admin.KimiOAuth.InitiateDeviceAuth)
kimi.POST("/oauth/token", h.Admin.KimiOAuth.PollToken)
kimi.POST("/oauth/refresh", h.Admin.KimiOAuth.RefreshToken)
kimi.POST("/oauth/import-local", h.Admin.KimiOAuth.ImportLocalToken)
}
}
func registerProxyRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
proxies := admin.Group("/proxies")
{

View File

@@ -24,6 +24,7 @@ const (
PlatformOpenAI = domain.PlatformOpenAI
PlatformGemini = domain.PlatformGemini
PlatformAntigravity = domain.PlatformAntigravity
PlatformKimi = domain.PlatformKimi
)
// Account type constants
@@ -33,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

View File

@@ -547,6 +547,7 @@ type GatewayService struct {
deferredService *DeferredService
concurrencyService *ConcurrencyService
claudeTokenProvider *ClaudeTokenProvider
kimiTokenProvider *KimiTokenProvider
sessionLimitCache SessionLimitCache // 会话数量限制缓存(仅 Anthropic OAuth/SetupToken
rpmCache RPMCache // RPM 计数缓存(仅 Anthropic OAuth/SetupToken
userGroupRateResolver *userGroupRateResolver
@@ -585,6 +586,7 @@ func NewGatewayService(
httpUpstream HTTPUpstream,
deferredService *DeferredService,
claudeTokenProvider *ClaudeTokenProvider,
kimiTokenProvider *KimiTokenProvider,
sessionLimitCache SessionLimitCache,
rpmCache RPMCache,
digestStore *DigestSessionStore,
@@ -617,6 +619,7 @@ func NewGatewayService(
httpUpstream: httpUpstream,
deferredService: deferredService,
claudeTokenProvider: claudeTokenProvider,
kimiTokenProvider: kimiTokenProvider,
sessionLimitCache: sessionLimitCache,
rpmCache: rpmCache,
userGroupRateCache: gocache.New(userGroupRateTTL, time.Minute),
@@ -3451,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)
}
@@ -3466,6 +3471,15 @@ func (s *GatewayService) getOAuthToken(ctx context.Context, account *Account) (s
return accessToken, "oauth", nil
}
// 对于 Kimi OAuth 账号,使用 KimiTokenProvider 获取缓存的 token
if account.Platform == PlatformKimi && account.Type == AccountTypeOAuth && s.kimiTokenProvider != nil {
accessToken, err := s.kimiTokenProvider.GetAccessToken(ctx, account)
if err != nil {
return "", "", err
}
return accessToken, "oauth", nil
}
// 其他情况Gemini 有自己的 TokenProvidersetup-token 类型等)直接从账号读取
accessToken := account.GetCredential("access_token")
if accessToken == "" {

View File

@@ -0,0 +1,446 @@
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`)
// Use a temp working directory to prevent kimi-cli from auto-resuming
// the last session associated with the backend's working directory.
// This ensures each request is stateless and context-isolated.
cmd := exec.CommandContext(ctx, g.cliPath, args...)
cmd.Dir = "/tmp"
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("<think>\n%s\n</think>\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("<think>\n%s\n</think>\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
}

View File

@@ -0,0 +1,258 @@
package service
import (
"bufio"
"bytes"
"context"
"errors"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
"github.com/gin-gonic/gin"
"github.com/tidwall/gjson"
)
const (
kimiAPIBaseURL = "https://api.kimi.com/coding/v1"
kimiChatCompletionsURL = kimiAPIBaseURL + "/chat/completions"
kimiDefaultUserAgent = "kimi-cli/1.0"
)
// 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
token, tokenType, err := s.GetAccessToken(ctx, account)
if err != nil {
return nil, fmt.Errorf("get access token: %w", err)
}
// 2. Get proxy URL
proxyURL := ""
if account.ProxyID != nil && account.Proxy != nil {
proxyURL = account.Proxy.URL()
}
// 3. Parse request to determine if streaming
reqStream := gjson.GetBytes(body, "stream").Bool()
// 4. Build upstream request
req, err := http.NewRequestWithContext(ctx, "POST", kimiChatCompletionsURL, bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("build upstream request: %w", err)
}
// Set auth header
if tokenType == "oauth" {
setHeaderRaw(req.Header, "authorization", "Bearer "+token)
} else {
setHeaderRaw(req.Header, "x-api-key", token)
}
// Set required headers for Kimi API
setHeaderRaw(req.Header, "content-type", "application/json")
setHeaderRaw(req.Header, "accept", "application/json")
// Kimi Coding API requires a known coding agent User-Agent pattern
setHeaderRaw(req.Header, "user-agent", kimiDefaultUserAgent)
// Passthrough allowed client headers
var clientHeaders http.Header
if c != nil && c.Request != nil {
clientHeaders = c.Request.Header
}
for key, values := range clientHeaders {
lowerKey := strings.ToLower(key)
if allowedHeaders[lowerKey] {
wireKey := resolveWireCasing(key)
for _, v := range values {
addHeaderRaw(req.Header, wireKey, v)
}
}
}
// 5. Resolve TLS fingerprint profile
tlsProfile := s.tlsFPProfileService.ResolveTLSProfile(account)
// 6. Send request
resp, err := s.httpUpstream.DoWithTLS(req, proxyURL, account.ID, account.Concurrency, tlsProfile)
if err != nil {
if resp != nil && resp.Body != nil {
_ = resp.Body.Close()
}
safeErr := sanitizeUpstreamErrorMessage(err.Error())
setOpsUpstreamError(c, 0, safeErr, "")
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
Platform: account.Platform,
AccountID: account.ID,
AccountName: account.Name,
UpstreamStatusCode: 0,
Kind: "request_error",
Message: safeErr,
})
writeGatewayCCError(c, http.StatusBadGateway, "server_error", "Upstream request failed")
return nil, fmt.Errorf("upstream request failed: %s", safeErr)
}
defer func() { _ = resp.Body.Close() }()
// 7. Handle error response
if resp.StatusCode >= 400 {
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 2<<20))
upstreamMsg := strings.TrimSpace(extractUpstreamErrorMessage(respBody))
upstreamMsg = sanitizeUpstreamErrorMessage(upstreamMsg)
if s.shouldFailoverUpstreamError(resp.StatusCode) {
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
Platform: account.Platform,
AccountID: account.ID,
AccountName: account.Name,
UpstreamStatusCode: resp.StatusCode,
UpstreamRequestID: resp.Header.Get("x-request-id"),
Kind: "failover",
Message: upstreamMsg,
})
if s.rateLimitService != nil {
s.rateLimitService.HandleUpstreamError(ctx, account, resp.StatusCode, resp.Header, respBody)
}
return nil, &UpstreamFailoverError{
StatusCode: resp.StatusCode,
ResponseBody: respBody,
}
}
writeGatewayCCError(c, mapUpstreamStatusCode(resp.StatusCode), "server_error", upstreamMsg)
return nil, fmt.Errorf("upstream error: %d %s", resp.StatusCode, upstreamMsg)
}
// 8. Handle successful response
originalModel := gjson.GetBytes(body, "model").String()
mappedModel := originalModel
if account.Type == AccountTypeAPIKey {
mappedModel = account.GetMappedModel(originalModel)
}
var result *ForwardResult
if reqStream {
result, err = s.handleKimiStreamingResponse(resp, c, originalModel, mappedModel, startTime)
} else {
result, err = s.handleKimiNonStreamingResponse(resp, c, originalModel, mappedModel, startTime)
}
return result, err
}
func (s *GatewayService) handleKimiStreamingResponse(
resp *http.Response,
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")
}
scanner := bufio.NewScanner(resp.Body)
scanner.Buffer(make([]byte, 4096), defaultMaxLineSize)
var firstTokenTime *time.Duration
var upstreamModel string
for scanner.Scan() {
line := scanner.Text()
if line == "" {
continue
}
// Detect first token
if firstTokenTime == nil {
elapsed := time.Since(startTime)
firstTokenTime = &elapsed
}
// Write the SSE line as-is
fmt.Fprintf(c.Writer, "%s\n", line) //nolint:errcheck
flusher.Flush()
// Try to extract upstream model from the first data line
if upstreamModel == "" && strings.HasPrefix(line, "data: {") {
upstreamModel = gjson.Get(line[6:], "model").String()
}
}
if err := scanner.Err(); err != nil && !errors.Is(err, io.EOF) {
logger.LegacyPrintf("service.gateway", "Kimi stream scanner error: %v", err)
}
// Send final [DONE] if not already sent by upstream
fmt.Fprintf(c.Writer, "data: [DONE]\n\n") //nolint:errcheck
flusher.Flush()
var firstTokenMs *int
if firstTokenTime != nil {
ms := int(firstTokenTime.Milliseconds())
firstTokenMs = &ms
}
return &ForwardResult{
UpstreamModel: upstreamModel,
FirstTokenMs: firstTokenMs,
}, nil
}
func (s *GatewayService) handleKimiNonStreamingResponse(
resp *http.Response,
c *gin.Context,
originalModel, mappedModel string,
startTime time.Time,
) (*ForwardResult, error) {
body, err := io.ReadAll(io.LimitReader(resp.Body, 50<<20))
if err != nil {
return nil, fmt.Errorf("read upstream response: %w", err)
}
if c != nil && c.Writer != nil {
// Copy upstream headers
for key, values := range resp.Header {
for _, v := range values {
c.Writer.Header().Add(key, v)
}
}
c.Writer.Header().Set("Content-Type", "application/json")
c.Writer.WriteHeader(resp.StatusCode)
_, _ = c.Writer.Write(body)
}
upstreamModel := gjson.GetBytes(body, "model").String()
return &ForwardResult{
UpstreamModel: upstreamModel,
}, nil
}

View File

@@ -0,0 +1,306 @@
package service
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"sync"
"time"
"github.com/Wei-Shaw/sub2api/internal/pkg/httpclient"
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
)
const (
kimiAuthBaseURL = "https://auth.kimi.com"
kimiDeviceAuthURL = kimiAuthBaseURL + "/api/oauth/device_authorization"
kimiTokenURL = kimiAuthBaseURL + "/api/oauth/token"
kimiClientID = "17e5f671-d194-4dfb-9706-5516cb48c098"
kimiDefaultScope = "kimi-code"
KimiTokenSafetyWindow = 300 // 5 minutes
KimiTokenMinTTL = 30 // 30 seconds
)
// KimiDeviceAuthResponse represents the response from the device authorization endpoint.
type KimiDeviceAuthResponse struct {
DeviceCode string `json:"device_code"`
UserCode string `json:"user_code"`
VerificationURI string `json:"verification_uri"`
VerificationURIComplete string `json:"verification_uri_complete"`
ExpiresIn int64 `json:"expires_in"`
Interval int64 `json:"interval"`
}
// KimiTokenResponse represents the response from the token endpoint.
type KimiTokenResponse struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpiresIn int64 `json:"expires_in"`
Scope string `json:"scope"`
TokenType string `json:"token_type"`
}
// KimiTokenInfo holds token information for an account.
type KimiTokenInfo struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpiresIn int64 `json:"expires_in"`
ExpiresAt int64 `json:"expires_at"`
Scope string `json:"scope,omitempty"`
TokenType string `json:"token_type,omitempty"`
}
// KimiOAuthService handles Kimi OAuth device flow and token refresh.
type KimiOAuthService struct {
sessions sync.Map // device_code -> *kimiOAuthSession
}
type kimiOAuthSession struct {
DeviceCode string
UserCode string
VerificationURI string
VerificationURIComplete string
ExpiresAt time.Time
Interval time.Duration
ProxyURL string
}
// NewKimiOAuthService creates a new KimiOAuthService.
func NewKimiOAuthService() *KimiOAuthService {
return &KimiOAuthService{}
}
// InitiateDeviceAuth starts the OAuth 2.0 Device Authorization Grant flow.
func (s *KimiOAuthService) InitiateDeviceAuth(ctx context.Context, proxyID *int64, proxyRepo ProxyRepository) (*KimiDeviceAuthResponse, error) {
data := url.Values{}
data.Set("client_id", kimiClientID)
data.Set("scope", kimiDefaultScope)
var proxyURL string
if proxyID != nil && proxyRepo != nil {
if proxy, err := proxyRepo.GetByID(ctx, *proxyID); err == nil && proxy != nil {
proxyURL = proxy.URL()
}
}
req, err := http.NewRequestWithContext(ctx, "POST", kimiDeviceAuthURL, strings.NewReader(data.Encode()))
if err != nil {
return nil, fmt.Errorf("failed to create device auth request: %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Accept", "application/json")
resp, err := s.doRequest(req, proxyURL)
if err != nil {
return nil, fmt.Errorf("device auth request failed: %w", err)
}
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return nil, fmt.Errorf("failed to read device auth response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("device auth failed: status=%d body=%s", resp.StatusCode, string(body))
}
var result KimiDeviceAuthResponse
if err := json.Unmarshal(body, &result); err != nil {
return nil, fmt.Errorf("failed to parse device auth response: %w", err)
}
interval := time.Duration(result.Interval) * time.Second
if interval <= 0 {
interval = 5 * time.Second
}
session := &kimiOAuthSession{
DeviceCode: result.DeviceCode,
UserCode: result.UserCode,
VerificationURI: result.VerificationURI,
VerificationURIComplete: result.VerificationURIComplete,
ExpiresAt: time.Now().Add(time.Duration(result.ExpiresIn) * time.Second),
Interval: interval,
ProxyURL: proxyURL,
}
s.sessions.Store(result.DeviceCode, session)
logger.LegacyPrintf("service.kimi_oauth", "[KimiOAuth] Device auth initiated: user_code=%s", result.UserCode)
return &result, nil
}
// PollToken polls the token endpoint for a device_code.
func (s *KimiOAuthService) PollToken(ctx context.Context, deviceCode string) (*KimiTokenInfo, error) {
sessionVal, ok := s.sessions.Load(deviceCode)
if !ok {
return nil, fmt.Errorf("session not found or expired")
}
session := sessionVal.(*kimiOAuthSession)
if time.Now().After(session.ExpiresAt) {
s.sessions.Delete(deviceCode)
return nil, fmt.Errorf("device auth session expired")
}
data := url.Values{}
data.Set("client_id", kimiClientID)
data.Set("grant_type", "urn:ietf:params:oauth:grant-type:device_code")
data.Set("device_code", deviceCode)
req, err := http.NewRequestWithContext(ctx, "POST", kimiTokenURL, strings.NewReader(data.Encode()))
if err != nil {
return nil, fmt.Errorf("failed to create token request: %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Accept", "application/json")
resp, err := s.doRequest(req, session.ProxyURL)
if err != nil {
return nil, fmt.Errorf("token request failed: %w", err)
}
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return nil, fmt.Errorf("failed to read token response: %w", err)
}
if resp.StatusCode == http.StatusBadRequest || resp.StatusCode == http.StatusForbidden {
// Check for authorization_pending
var errResp struct {
Error string `json:"error"`
}
if json.Unmarshal(body, &errResp) == nil && errResp.Error == "authorization_pending" {
return nil, fmt.Errorf("authorization_pending")
}
if errResp.Error == "expired_token" || errResp.Error == "invalid_grant" {
s.sessions.Delete(deviceCode)
}
return nil, fmt.Errorf("token poll failed: %s", errResp.Error)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("token poll failed: status=%d body=%s", resp.StatusCode, string(body))
}
var tokenResp KimiTokenResponse
if err := json.Unmarshal(body, &tokenResp); err != nil {
return nil, fmt.Errorf("failed to parse token response: %w", err)
}
s.sessions.Delete(deviceCode)
expiresAt := time.Now().Unix() + tokenResp.ExpiresIn - KimiTokenSafetyWindow
minExpiresAt := time.Now().Unix() + KimiTokenMinTTL
if expiresAt < minExpiresAt {
expiresAt = minExpiresAt
}
logger.LegacyPrintf("service.kimi_oauth", "[KimiOAuth] Token obtained successfully")
return &KimiTokenInfo{
AccessToken: tokenResp.AccessToken,
RefreshToken: tokenResp.RefreshToken,
ExpiresIn: tokenResp.ExpiresIn,
ExpiresAt: expiresAt,
Scope: tokenResp.Scope,
TokenType: tokenResp.TokenType,
}, nil
}
// RefreshToken refreshes the access token using a refresh token.
func (s *KimiOAuthService) RefreshToken(ctx context.Context, refreshToken, proxyURL string) (*KimiTokenInfo, error) {
if strings.TrimSpace(refreshToken) == "" {
return nil, fmt.Errorf("refresh_token is empty")
}
data := url.Values{}
data.Set("client_id", kimiClientID)
data.Set("grant_type", "refresh_token")
data.Set("refresh_token", refreshToken)
req, err := http.NewRequestWithContext(ctx, "POST", kimiTokenURL, strings.NewReader(data.Encode()))
if err != nil {
return nil, fmt.Errorf("failed to create refresh request: %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Accept", "application/json")
resp, err := s.doRequest(req, proxyURL)
if err != nil {
return nil, fmt.Errorf("refresh request failed: %w", err)
}
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return nil, fmt.Errorf("failed to read refresh response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("refresh failed: status=%d body=%s", resp.StatusCode, string(body))
}
var tokenResp KimiTokenResponse
if err := json.Unmarshal(body, &tokenResp); err != nil {
return nil, fmt.Errorf("failed to parse refresh response: %w", err)
}
expiresAt := time.Now().Unix() + tokenResp.ExpiresIn - KimiTokenSafetyWindow
minExpiresAt := time.Now().Unix() + KimiTokenMinTTL
if expiresAt < minExpiresAt {
expiresAt = minExpiresAt
}
return &KimiTokenInfo{
AccessToken: tokenResp.AccessToken,
RefreshToken: tokenResp.RefreshToken,
ExpiresIn: tokenResp.ExpiresIn,
ExpiresAt: expiresAt,
Scope: tokenResp.Scope,
TokenType: tokenResp.TokenType,
}, nil
}
// BuildAccountCredentials builds the credentials map for a Kimi account.
func (s *KimiOAuthService) BuildAccountCredentials(tokenInfo *KimiTokenInfo) map[string]any {
creds := map[string]any{
"access_token": tokenInfo.AccessToken,
"expires_at": strconv.FormatInt(tokenInfo.ExpiresAt, 10),
}
if tokenInfo.RefreshToken != "" {
creds["refresh_token"] = tokenInfo.RefreshToken
}
if tokenInfo.TokenType != "" {
creds["token_type"] = tokenInfo.TokenType
}
if tokenInfo.Scope != "" {
creds["scope"] = tokenInfo.Scope
}
return creds
}
// Stop cleans up the service.
func (s *KimiOAuthService) Stop() {
s.sessions.Range(func(key, value any) bool {
s.sessions.Delete(key)
return true
})
}
func (s *KimiOAuthService) doRequest(req *http.Request, proxyURL string) (*http.Response, error) {
opts := httpclient.Options{
ProxyURL: proxyURL,
Timeout: 30 * time.Second,
}
client, err := httpclient.GetClient(opts)
if err != nil {
return nil, err
}
return client.Do(req)
}

View File

@@ -0,0 +1,198 @@
package service
import (
"context"
"errors"
"log/slog"
"strconv"
"strings"
"time"
)
const (
kimiTokenRefreshSkew = 3 * time.Minute
kimiTokenCacheSkew = 5 * time.Minute
)
// KimiTokenProvider manages access_token for Kimi OAuth accounts.
type KimiTokenProvider struct {
accountRepo AccountRepository
tokenCache GeminiTokenCache
kimiOAuthService *KimiOAuthService
refreshAPI *OAuthRefreshAPI
executor OAuthRefreshExecutor
refreshPolicy ProviderRefreshPolicy
}
// NewKimiTokenProvider creates a new KimiTokenProvider.
func NewKimiTokenProvider(
accountRepo AccountRepository,
tokenCache GeminiTokenCache,
kimiOAuthService *KimiOAuthService,
) *KimiTokenProvider {
return &KimiTokenProvider{
accountRepo: accountRepo,
tokenCache: tokenCache,
kimiOAuthService: kimiOAuthService,
refreshPolicy: KimiProviderRefreshPolicy(),
}
}
// SetRefreshAPI injects unified OAuth refresh API and executor.
func (p *KimiTokenProvider) SetRefreshAPI(api *OAuthRefreshAPI, executor OAuthRefreshExecutor) {
p.refreshAPI = api
p.executor = executor
}
// SetRefreshPolicy injects caller-side refresh policy.
func (p *KimiTokenProvider) SetRefreshPolicy(policy ProviderRefreshPolicy) {
p.refreshPolicy = policy
}
// GetAccessToken returns a valid access token for the given Kimi account.
func (p *KimiTokenProvider) GetAccessToken(ctx context.Context, account *Account) (string, error) {
if account == nil {
return "", errors.New("account is nil")
}
if account.Platform != PlatformKimi || account.Type != AccountTypeOAuth {
return "", errors.New("not a kimi oauth account")
}
cacheKey := KimiTokenCacheKey(account)
// 1) Try cache first.
if p.tokenCache != nil {
if token, err := p.tokenCache.GetAccessToken(ctx, cacheKey); err == nil && strings.TrimSpace(token) != "" {
return token, nil
}
}
// 2) Refresh if needed (pre-expiry skew).
expiresAt := account.GetCredentialAsTime("expires_at")
needsRefresh := expiresAt == nil || time.Until(*expiresAt) <= kimiTokenRefreshSkew
if needsRefresh && p.refreshAPI != nil && p.executor != nil {
result, err := p.refreshAPI.RefreshIfNeeded(ctx, account, p.executor, kimiTokenRefreshSkew)
if err != nil {
if p.refreshPolicy.OnRefreshError == ProviderRefreshErrorReturn {
return "", err
}
} else if result.LockHeld {
if p.refreshPolicy.OnLockHeld == ProviderLockHeldWaitForCache && p.tokenCache != nil {
if token, cacheErr := p.tokenCache.GetAccessToken(ctx, cacheKey); cacheErr == nil && strings.TrimSpace(token) != "" {
return token, nil
}
}
slog.Debug("kimi_token_lock_held_use_old", "account_id", account.ID)
} else {
account = result.Account
expiresAt = account.GetCredentialAsTime("expires_at")
}
} else if needsRefresh && p.tokenCache != nil {
locked, lockErr := p.tokenCache.AcquireRefreshLock(ctx, cacheKey, 30*time.Second)
if lockErr == nil && locked {
defer func() { _ = p.tokenCache.ReleaseRefreshLock(ctx, cacheKey) }()
} else if lockErr != nil {
slog.Warn("kimi_token_lock_failed", "account_id", account.ID, "error", lockErr)
}
}
accessToken := account.GetCredential("access_token")
if strings.TrimSpace(accessToken) == "" {
return "", errors.New("access_token not found in credentials")
}
// 3) Populate cache with TTL.
if p.tokenCache != nil {
latestAccount, isStale := CheckTokenVersion(ctx, account, p.accountRepo)
if isStale && latestAccount != nil {
slog.Debug("kimi_token_version_stale_use_latest", "account_id", account.ID)
accessToken = latestAccount.GetCredential("access_token")
if strings.TrimSpace(accessToken) == "" {
return "", errors.New("access_token not found after version check")
}
} else {
ttl := 30 * time.Minute
if expiresAt != nil {
until := time.Until(*expiresAt)
switch {
case until > kimiTokenCacheSkew:
ttl = until - kimiTokenCacheSkew
case until > 0:
ttl = until
default:
ttl = time.Minute
}
}
_ = p.tokenCache.SetAccessToken(ctx, cacheKey, accessToken, ttl)
}
}
return accessToken, nil
}
// KimiTokenCacheKey returns the cache key for a Kimi account.
func KimiTokenCacheKey(account *Account) string {
return "kimi:account:" + strconv.FormatInt(account.ID, 10)
}
// KimiProviderRefreshPolicy returns the default refresh policy for Kimi.
func KimiProviderRefreshPolicy() ProviderRefreshPolicy {
return ProviderRefreshPolicy{
OnRefreshError: ProviderRefreshErrorReturn,
OnLockHeld: ProviderLockHeldWaitForCache,
}
}
// KimiTokenRefresher implements OAuthRefreshExecutor for Kimi.
type KimiTokenRefresher struct {
kimiOAuthService *KimiOAuthService
}
// NewKimiTokenRefresher creates a new KimiTokenRefresher.
func NewKimiTokenRefresher(kimiOAuthService *KimiOAuthService) *KimiTokenRefresher {
return &KimiTokenRefresher{kimiOAuthService: kimiOAuthService}
}
// CanRefresh checks if this refresher can handle the given account.
func (r *KimiTokenRefresher) CanRefresh(account *Account) bool {
return account != nil && account.Platform == PlatformKimi && account.Type == AccountTypeOAuth
}
// NeedsRefresh checks if the account needs token refresh.
func (r *KimiTokenRefresher) NeedsRefresh(account *Account, refreshWindow time.Duration) bool {
if account == nil {
return false
}
expiresAt := account.GetCredentialAsTime("expires_at")
return expiresAt == nil || time.Until(*expiresAt) <= refreshWindow
}
// Refresh performs the token refresh for a Kimi account.
func (r *KimiTokenRefresher) Refresh(ctx context.Context, account *Account) (map[string]any, error) {
if account == nil || r.kimiOAuthService == nil {
return nil, errors.New("kimi token refresher not initialized")
}
refreshToken := account.GetCredential("refresh_token")
if strings.TrimSpace(refreshToken) == "" {
return nil, errors.New("no refresh token available")
}
var proxyURL string
if account.ProxyID != nil && account.Proxy != nil {
proxyURL = account.Proxy.URL()
}
tokenInfo, err := r.kimiOAuthService.RefreshToken(ctx, refreshToken, proxyURL)
if err != nil {
return nil, err
}
return r.kimiOAuthService.BuildAccountCredentials(tokenInfo), nil
}
// CacheKey returns the cache key for distributed locking.
func (r *KimiTokenRefresher) CacheKey(account *Account) string {
return KimiTokenCacheKey(account)
}

View File

@@ -46,6 +46,9 @@ func (c *CompositeTokenCacheInvalidator) InvalidateToken(ctx context.Context, ac
keysToDelete = append(keysToDelete, OpenAITokenCacheKey(account))
case PlatformAnthropic:
keysToDelete = append(keysToDelete, ClaudeTokenCacheKey(account))
case PlatformKimi:
keysToDelete = append(keysToDelete, KimiTokenCacheKey(account))
keysToDelete = append(keysToDelete, "kimi:"+accountIDKey)
default:
return nil
}

View File

@@ -84,6 +84,16 @@ func NewTokenRefreshService(
return s
}
// SetKimiOAuthService 注入 Kimi OAuth 服务并注册刷新器
func (s *TokenRefreshService) SetKimiOAuthService(kimiOAuthService *KimiOAuthService) {
if kimiOAuthService == nil {
return
}
kimiRefresher := NewKimiTokenRefresher(kimiOAuthService)
s.refreshers = append(s.refreshers, kimiRefresher)
s.executors = append(s.executors, kimiRefresher)
}
// SetPrivacyDeps 注入 OpenAI privacy opt-out 所需依赖
func (s *TokenRefreshService) SetPrivacyDeps(factory PrivacyClientFactory, proxyRepo ProxyRepository) {
s.privacyClientFactory = factory

View File

@@ -46,6 +46,7 @@ func ProvideTokenRefreshService(
openaiOAuthService *OpenAIOAuthService,
geminiOAuthService *GeminiOAuthService,
antigravityOAuthService *AntigravityOAuthService,
kimiOAuthService *KimiOAuthService,
cacheInvalidator TokenCacheInvalidator,
schedulerCache SchedulerCache,
cfg *config.Config,
@@ -55,6 +56,8 @@ func ProvideTokenRefreshService(
refreshAPI *OAuthRefreshAPI,
) *TokenRefreshService {
svc := NewTokenRefreshService(accountRepo, oauthService, openaiOAuthService, geminiOAuthService, antigravityOAuthService, cacheInvalidator, schedulerCache, cfg, tempUnschedCache)
// 注入 Kimi OAuth 服务
svc.SetKimiOAuthService(kimiOAuthService)
// 注入 OpenAI privacy opt-out 依赖
svc.SetPrivacyDeps(privacyClientFactory, proxyRepo)
// 注入统一 OAuth 刷新 API消除 TokenRefreshService 与 TokenProvider 之间的竞争条件)
@@ -123,6 +126,20 @@ func ProvideAntigravityTokenProvider(
return p
}
// ProvideKimiTokenProvider creates KimiTokenProvider with OAuthRefreshAPI injection
func ProvideKimiTokenProvider(
accountRepo AccountRepository,
tokenCache GeminiTokenCache,
kimiOAuthService *KimiOAuthService,
refreshAPI *OAuthRefreshAPI,
) *KimiTokenProvider {
p := NewKimiTokenProvider(accountRepo, tokenCache, kimiOAuthService)
executor := NewKimiTokenRefresher(kimiOAuthService)
p.SetRefreshAPI(refreshAPI, executor)
p.SetRefreshPolicy(KimiProviderRefreshPolicy())
return p
}
// ProvideDashboardAggregationService 创建并启动仪表盘聚合服务
func ProvideDashboardAggregationService(repo DashboardAggregationRepository, timingWheel *TimingWheelService, cfg *config.Config) *DashboardAggregationService {
svc := NewDashboardAggregationService(repo, timingWheel, cfg)
@@ -409,8 +426,10 @@ var ProviderSet = wire.NewSet(
NewCompositeTokenCacheInvalidator,
wire.Bind(new(TokenCacheInvalidator), new(*CompositeTokenCacheInvalidator)),
NewAntigravityOAuthService,
NewKimiOAuthService,
NewOAuthRefreshAPI,
ProvideGeminiTokenProvider,
ProvideKimiTokenProvider,
NewGeminiMessagesCompatService,
ProvideAntigravityTokenProvider,
ProvideOpenAITokenProvider,

View File

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

View File

@@ -147,6 +147,21 @@
<Icon name="cloud" size="sm" />
Antigravity
</button>
<button
type="button"
@click="form.platform = 'kimi'"
:class="[
'flex flex-1 items-center justify-center gap-2 rounded-md px-4 py-2.5 text-sm font-medium transition-all',
form.platform === 'kimi'
? 'bg-white text-teal-600 shadow-sm dark:bg-dark-600 dark:text-teal-400'
: 'text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-200'
]"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.455 2.456L21.75 6l-1.036.259a3.375 3.375 0 00-2.455 2.456z" />
</svg>
Kimi
</button>
</div>
</div>
@@ -729,6 +744,89 @@
</div>
</div>
<!-- Account Type Selection (Kimi - OAuth, API Key, or CLI) -->
<div v-if="form.platform === 'kimi'">
<label class="input-label">{{ t('admin.accounts.accountType') }}</label>
<div class="mt-2 grid grid-cols-3 gap-3">
<button
type="button"
@click="accountCategory = 'oauth-based'"
:class="[
'flex items-center gap-3 rounded-lg border-2 p-3 text-left transition-all',
accountCategory === 'oauth-based'
? 'border-teal-500 bg-teal-50 dark:bg-teal-900/20'
: 'border-gray-200 hover:border-teal-300 dark:border-dark-600 dark:hover:border-teal-700'
]"
>
<div
:class="[
'flex h-8 w-8 shrink-0 items-center justify-center rounded-lg',
accountCategory === 'oauth-based'
? 'bg-teal-100 text-teal-600 dark:bg-teal-800'
: 'bg-gray-100 text-gray-500 dark:bg-dark-700'
]"
>
<Icon name="key" size="sm" />
</div>
<div>
<div class="text-sm font-medium">OAuth</div>
<div class="text-xs text-gray-500">Kimi Code OAuth</div>
</div>
</button>
<button
type="button"
@click="accountCategory = 'apikey'"
:class="[
'flex items-center gap-3 rounded-lg border-2 p-3 text-left transition-all',
accountCategory === 'apikey'
? 'border-teal-500 bg-teal-50 dark:bg-teal-900/20'
: 'border-gray-200 hover:border-teal-300 dark:border-dark-600 dark:hover:border-teal-700'
]"
>
<div
:class="[
'flex h-8 w-8 shrink-0 items-center justify-center rounded-lg',
accountCategory === 'apikey'
? 'bg-teal-100 text-teal-600 dark:bg-teal-800'
: 'bg-gray-100 text-gray-500 dark:bg-dark-700'
]"
>
<Icon name="key" size="sm" />
</div>
<div>
<div class="text-sm font-medium">API Key</div>
<div class="text-xs text-gray-500">Kimi API Key</div>
</div>
</button>
<!-- CLI Option -->
<button
type="button"
@click="accountCategory = 'cli'"
:class="[
'flex items-center gap-3 rounded-lg border-2 p-3 text-left transition-all',
accountCategory === 'cli'
? 'border-teal-500 bg-teal-50 dark:bg-teal-900/20'
: 'border-gray-200 hover:border-teal-300 dark:border-dark-600 dark:hover:border-teal-700'
]"
>
<div
:class="[
'flex h-8 w-8 shrink-0 items-center justify-center rounded-lg',
accountCategory === 'cli'
? 'bg-teal-100 text-teal-600 dark:bg-teal-800'
: 'bg-gray-100 text-gray-500 dark:bg-dark-700'
]"
>
<Icon name="terminal" size="sm" />
</div>
<div>
<div class="text-sm font-medium">CLI</div>
<div class="text-xs text-gray-500">Local kimi-cli</div>
</div>
</button>
</div>
</div>
<!-- Antigravity model restriction (applies to OAuth + Upstream) -->
<!-- Antigravity 只支持模型映射模式不支持白名单模式 -->
<div v-if="form.platform === 'antigravity'" class="border-t border-gray-200 pt-4 dark:border-dark-600">
@@ -1477,9 +1575,9 @@
</div>
</div>
<!-- 配额控制 (Anthropic apikey/bedrock: 配额限制 + 亲和) -->
<!-- 配额控制 (apikey/bedrock/cli: 配额限制 + 亲和) -->
<div
v-if="form.platform === 'anthropic' && (form.type === 'apikey' || form.type === 'bedrock')"
v-if="form.type === 'apikey' || form.type === 'bedrock' || form.type === 'cli'"
class="border-t border-gray-200 pt-4 dark:border-dark-600 space-y-4"
>
<div class="mb-3">
@@ -1529,58 +1627,6 @@
/>
</div>
<!-- 配额控制 ( Anthropic apikey/bedrock) -->
<div
v-else-if="form.type === 'apikey' || form.type === 'bedrock'"
class="border-t border-gray-200 pt-4 dark:border-dark-600 space-y-4"
>
<div class="mb-3">
<h3 class="input-label mb-0 text-base font-semibold">{{ t('admin.accounts.quotaControl.title') }}</h3>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.quotaLimitHint') }}
</p>
</div>
<QuotaLimitCard
:totalLimit="editQuotaLimit"
:dailyLimit="editQuotaDailyLimit"
:weeklyLimit="editQuotaWeeklyLimit"
:quotaNotifyGlobalEnabled="quotaNotifyGlobalEnabled"
:quotaNotifyDailyEnabled="quotaNotifyState.daily.enabled"
:quotaNotifyDailyThreshold="quotaNotifyState.daily.threshold"
:quotaNotifyDailyThresholdType="quotaNotifyState.daily.thresholdType"
:quotaNotifyWeeklyEnabled="quotaNotifyState.weekly.enabled"
:quotaNotifyWeeklyThreshold="quotaNotifyState.weekly.threshold"
:quotaNotifyWeeklyThresholdType="quotaNotifyState.weekly.thresholdType"
:quotaNotifyTotalEnabled="quotaNotifyState.total.enabled"
:quotaNotifyTotalThreshold="quotaNotifyState.total.threshold"
:quotaNotifyTotalThresholdType="quotaNotifyState.total.thresholdType"
:dailyResetMode="editDailyResetMode"
:dailyResetHour="editDailyResetHour"
:weeklyResetMode="editWeeklyResetMode"
:weeklyResetDay="editWeeklyResetDay"
:weeklyResetHour="editWeeklyResetHour"
:resetTimezone="editResetTimezone"
@update:totalLimit="editQuotaLimit = $event"
@update:dailyLimit="editQuotaDailyLimit = $event"
@update:weeklyLimit="editQuotaWeeklyLimit = $event"
@update:quotaNotifyDailyEnabled="quotaNotifyState.daily.enabled = $event"
@update:quotaNotifyDailyThreshold="quotaNotifyState.daily.threshold = $event"
@update:quotaNotifyDailyThresholdType="quotaNotifyState.daily.thresholdType = $event"
@update:quotaNotifyWeeklyEnabled="quotaNotifyState.weekly.enabled = $event"
@update:quotaNotifyWeeklyThreshold="quotaNotifyState.weekly.threshold = $event"
@update:quotaNotifyWeeklyThresholdType="quotaNotifyState.weekly.thresholdType = $event"
@update:quotaNotifyTotalEnabled="quotaNotifyState.total.enabled = $event"
@update:quotaNotifyTotalThreshold="quotaNotifyState.total.threshold = $event"
@update:quotaNotifyTotalThresholdType="quotaNotifyState.total.thresholdType = $event"
@update:dailyResetMode="editDailyResetMode = $event"
@update:dailyResetHour="editDailyResetHour = $event"
@update:weeklyResetMode="editWeeklyResetMode = $event"
@update:weeklyResetDay="editWeeklyResetDay = $event"
@update:weeklyResetHour="editWeeklyResetHour = $event"
@update:resetTimezone="editResetTimezone = $event"
/>
</div>
<!-- OpenAI OAuth Model Mapping (OAuth 类型没有 apikey 容器需要独立的模型映射区域) -->
<div
v-if="form.platform === 'openai' && accountCategory === 'oauth-based'"
@@ -2550,7 +2596,145 @@
<!-- Step 2: OAuth Authorization -->
<div v-else class="space-y-5">
<!-- Kimi Device Flow -->
<div v-if="form.platform === 'kimi'" class="space-y-4">
<div
class="rounded-lg border border-teal-200 bg-teal-50 p-4 dark:border-teal-700 dark:bg-teal-900/30"
>
<div class="flex items-start gap-4">
<div
class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg bg-teal-500"
>
<Icon name="link" size="md" class="text-white" />
</div>
<div class="flex-1">
<h4 class="mb-3 font-semibold text-teal-900 dark:text-teal-200">
Kimi Code OAuth
</h4>
<!-- Step 1: Initiate device auth -->
<div v-if="!kimiOAuth.userCode.value" class="space-y-3">
<p class="text-sm text-teal-800 dark:text-teal-300">
{{ t('admin.accounts.oauth.kimi.deviceFlowDesc') }}
</p>
<button
type="button"
:disabled="kimiOAuth.loading.value"
class="btn btn-primary text-sm"
@click="handleKimiDeviceAuth"
>
<svg
v-if="kimiOAuth.loading.value"
class="-ml-1 mr-2 h-4 w-4 animate-spin"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
<Icon v-else name="link" size="sm" class="mr-2" />
{{
kimiOAuth.loading.value
? t('admin.accounts.oauth.generating')
: t('admin.accounts.oauth.kimi.startDeviceAuth')
}}
</button>
<!-- Error -->
<div
v-if="kimiOAuth.error.value"
class="rounded-lg border border-red-200 bg-red-50 p-3 dark:border-red-700 dark:bg-red-900/30"
>
<p class="whitespace-pre-line text-sm text-red-600 dark:text-red-400">
{{ kimiOAuth.error.value }}
</p>
</div>
</div>
<!-- Step 2: Show user_code and verification_uri -->
<div v-else class="space-y-4">
<div
class="rounded-lg border border-teal-300 bg-white/80 p-4 dark:border-teal-600 dark:bg-gray-800/80"
>
<p class="mb-2 text-sm font-medium text-teal-900 dark:text-teal-200">
{{ t('admin.accounts.oauth.kimi.step1OpenLink') }}
</p>
<div class="mb-3 flex items-center gap-2">
<code
class="rounded bg-gray-100 px-3 py-2 font-mono text-lg font-bold dark:bg-gray-700"
>
{{ kimiOAuth.userCode.value }}
</code>
</div>
<a
:href="kimiOAuth.verificationUriComplete.value || kimiOAuth.verificationUri.value"
target="_blank"
rel="noopener noreferrer"
class="btn btn-primary inline-flex items-center gap-2 text-sm"
>
<Icon name="externalLink" size="sm" />
{{ t('admin.accounts.oauth.kimi.openVerificationPage') }}
</a>
</div>
<div
class="rounded-lg border border-teal-300 bg-white/80 p-4 dark:border-teal-600 dark:bg-gray-800/80"
>
<p class="mb-2 text-sm font-medium text-teal-900 dark:text-teal-200">
{{ t('admin.accounts.oauth.kimi.step2Wait') }}
</p>
<div class="flex items-center gap-2 text-sm text-teal-700 dark:text-teal-300">
<svg
class="h-4 w-4 animate-spin"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
{{ t('admin.accounts.oauth.kimi.pollingForToken') }}
</div>
</div>
<!-- Error -->
<div
v-if="kimiOAuth.error.value"
class="rounded-lg border border-red-200 bg-red-50 p-3 dark:border-red-700 dark:bg-red-900/30"
>
<p class="whitespace-pre-line text-sm text-red-600 dark:text-red-400">
{{ kimiOAuth.error.value }}
</p>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Standard OAuth Flow for other platforms -->
<OAuthAuthorizationFlow
v-else
ref="oauthFlowRef"
:add-method="form.platform === 'anthropic' ? addMethod : 'oauth'"
:auth-url="currentAuthUrl"
@@ -2573,7 +2757,6 @@
@validate-mobile-refresh-token="handleOpenAIValidateMobileRT"
@validate-session-token="handleValidateSessionToken"
/>
</div>
<template #footer>
@@ -2889,7 +3072,7 @@
</template>
<script setup lang="ts">
import { ref, reactive, computed, watch } from 'vue'
import { ref, reactive, computed, watch, onUnmounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import {
@@ -2912,6 +3095,7 @@ import {
import { useOpenAIOAuth } from '@/composables/useOpenAIOAuth'
import { useGeminiOAuth } from '@/composables/useGeminiOAuth'
import { useAntigravityOAuth } from '@/composables/useAntigravityOAuth'
import { useKimiOAuth } from '@/composables/useKimiOAuth'
import type {
Proxy,
AdminGroup,
@@ -2996,12 +3180,14 @@ const oauth = useAccountOAuth() // For Anthropic OAuth
const openaiOAuth = useOpenAIOAuth() // For OpenAI OAuth
const geminiOAuth = useGeminiOAuth() // For Gemini OAuth
const antigravityOAuth = useAntigravityOAuth() // For Antigravity OAuth
const kimiOAuth = useKimiOAuth() // For Kimi OAuth
// Computed: current OAuth state for template binding
const currentAuthUrl = computed(() => {
if (form.platform === 'openai') return openaiOAuth.authUrl.value
if (form.platform === 'gemini') return geminiOAuth.authUrl.value
if (form.platform === 'antigravity') return antigravityOAuth.authUrl.value
if (form.platform === 'kimi') return '' // Device flow doesn't use auth URL
return oauth.authUrl.value
})
@@ -3009,6 +3195,7 @@ const currentSessionId = computed(() => {
if (form.platform === 'openai') return openaiOAuth.sessionId.value
if (form.platform === 'gemini') return geminiOAuth.sessionId.value
if (form.platform === 'antigravity') return antigravityOAuth.sessionId.value
if (form.platform === 'kimi') return kimiOAuth.sessionId.value
return oauth.sessionId.value
})
@@ -3016,6 +3203,7 @@ const currentOAuthLoading = computed(() => {
if (form.platform === 'openai') return openaiOAuth.loading.value
if (form.platform === 'gemini') return geminiOAuth.loading.value
if (form.platform === 'antigravity') return antigravityOAuth.loading.value
if (form.platform === 'kimi') return kimiOAuth.loading.value
return oauth.loading.value
})
@@ -3023,6 +3211,7 @@ const currentOAuthError = computed(() => {
if (form.platform === 'openai') return openaiOAuth.error.value
if (form.platform === 'gemini') return geminiOAuth.error.value
if (form.platform === 'antigravity') return antigravityOAuth.error.value
if (form.platform === 'kimi') return kimiOAuth.error.value
return oauth.error.value
})
@@ -3045,7 +3234,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<AddMethod>('oauth') // For oauth-based: 'oauth' or 'setup-token'
const apiKeyBaseUrl = ref('https://api.anthropic.com')
const apiKeyValue = ref('')
@@ -3287,6 +3476,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'
})
@@ -3312,6 +3505,10 @@ const canExchangeCode = computed(() => {
if (form.platform === 'antigravity') {
return authCode.trim() && antigravityOAuth.sessionId.value && !antigravityOAuth.loading.value
}
if (form.platform === 'kimi') {
// Device flow: auto-polling handles completion, no manual exchange needed
return false
}
return authCode.trim() && oauth.sessionId.value && !oauth.loading.value
})
@@ -3358,6 +3555,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 {
@@ -3960,6 +4162,42 @@ 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<string, unknown> = {}
// 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)
}
// Add custom error codes if enabled
if (customErrorCodesEnabled.value) {
credentials.custom_error_codes_enabled = true
credentials.custom_error_codes = [...selectedErrorCodes.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()) {
@@ -4068,7 +4306,9 @@ const handleSubmit = async () => {
? 'https://api.openai.com'
: form.platform === 'gemini'
? 'https://generativelanguage.googleapis.com'
: 'https://api.anthropic.com'
: form.platform === 'kimi'
? 'https://api.kimi.com'
: 'https://api.anthropic.com'
// Build credentials with optional model mapping
const credentials: Record<string, unknown> = {
@@ -4121,9 +4361,129 @@ const goBackToBasicInfo = () => {
openaiOAuth.resetState()
geminiOAuth.resetState()
antigravityOAuth.resetState()
kimiOAuth.resetState()
stopKimiPolling()
oauthFlowRef.value?.reset()
}
// Kimi device flow polling
let kimiPollInterval: ReturnType<typeof setInterval> | null = null
const stopKimiPolling = () => {
if (kimiPollInterval) {
clearInterval(kimiPollInterval)
kimiPollInterval = null
}
}
const handleKimiPoll = async () => {
const tokenInfo = await kimiOAuth.pollToken()
if (tokenInfo === null) {
// Still pending, continue polling
return
}
// Got token, stop polling
stopKimiPolling()
if (!tokenInfo.access_token) {
appStore.showError(t('admin.accounts.oauth.authFailed'))
return
}
// Build credentials
const credentials: Record<string, unknown> = {
access_token: tokenInfo.access_token,
expires_at: tokenInfo.expires_at
}
if (tokenInfo.refresh_token) {
credentials.refresh_token = tokenInfo.refresh_token
}
if (tokenInfo.token_type) {
credentials.token_type = tokenInfo.token_type
}
if (tokenInfo.scope) {
credentials.scope = tokenInfo.scope
}
// Build extra
const extra: Record<string, unknown> = {}
// Add window cost limit settings
if (windowCostEnabled.value && windowCostLimit.value != null && windowCostLimit.value > 0) {
extra.window_cost_limit = windowCostLimit.value
extra.window_cost_sticky_reserve = windowCostStickyReserve.value ?? 10
}
// Add session limit settings
if (sessionLimitEnabled.value && maxSessions.value != null && maxSessions.value > 0) {
extra.max_sessions = maxSessions.value
extra.session_idle_timeout_minutes = sessionIdleTimeout.value ?? 5
}
// Add RPM limit settings
if (rpmLimitEnabled.value) {
const DEFAULT_BASE_RPM = 15
extra.base_rpm = (baseRpm.value != null && baseRpm.value > 0)
? baseRpm.value
: DEFAULT_BASE_RPM
extra.rpm_strategy = rpmStrategy.value
if (rpmStickyBuffer.value != null && rpmStickyBuffer.value > 0) {
extra.rpm_sticky_buffer = rpmStickyBuffer.value
}
}
// UMQ mode
if (userMsgQueueMode.value) {
extra.user_msg_queue_mode = userMsgQueueMode.value
}
// Add TLS fingerprint settings
if (tlsFingerprintEnabled.value) {
extra.enable_tls_fingerprint = true
if (tlsFingerprintProfileId.value) {
extra.tls_fingerprint_profile_id = tlsFingerprintProfileId.value
}
}
// Add session ID masking settings
if (sessionIdMaskingEnabled.value) {
extra.session_id_masking_enabled = true
}
// Add cache TTL override settings
if (cacheTTLOverrideEnabled.value) {
extra.cache_ttl_override_enabled = true
extra.cache_ttl_override_target = cacheTTLOverrideTarget.value
}
// Add custom base URL settings
if (customBaseUrlEnabled.value && customBaseUrl.value.trim()) {
extra.custom_base_url_enabled = true
extra.custom_base_url = customBaseUrl.value.trim()
}
await createAccountAndFinish('kimi', 'oauth', credentials, extra)
}
const handleKimiDeviceAuth = async () => {
stopKimiPolling()
const success = await kimiOAuth.initiateDeviceAuth(form.proxy_id)
if (!success) return
// Start polling every 5 seconds
kimiPollInterval = setInterval(() => {
handleKimiPoll().catch((err: any) => {
console.error('Kimi poll error:', err)
stopKimiPolling()
})
}, 5000)
}
onUnmounted(() => {
stopKimiPolling()
})
const handleGenerateUrl = async () => {
if (form.platform === 'openai') {
await openaiOAuth.generateAuthUrl(form.proxy_id)
@@ -4136,6 +4496,8 @@ const handleGenerateUrl = async () => {
)
} else if (form.platform === 'antigravity') {
await antigravityOAuth.generateAuthUrl(form.proxy_id)
} else if (form.platform === 'kimi') {
await handleKimiDeviceAuth()
} else {
await oauth.generateAuthUrl(addMethod.value, form.proxy_id)
}
@@ -4166,9 +4528,9 @@ const createAccountAndFinish = async (
if (!applyTempUnschedConfig(credentials)) {
return
}
// Inject quota limits for apikey/bedrock accounts
// Inject quota limits for apikey/bedrock/cli accounts
let finalExtra = extra
if (type === 'apikey' || type === 'bedrock') {
if (type === 'apikey' || type === 'bedrock' || type === 'cli') {
const quotaExtra: Record<string, unknown> = { ...(extra || {}) }
if (editQuotaLimit.value != null && editQuotaLimit.value > 0) {
quotaExtra.quota_limit = editQuotaLimit.value
@@ -4679,6 +5041,9 @@ const handleExchangeCode = async () => {
return handleGeminiExchange(authCode)
case 'antigravity':
return handleAntigravityExchange(authCode)
case 'kimi':
// Kimi uses device flow with auto-polling, no manual exchange needed
return
default:
return handleAnthropicExchange(authCode)
}

View File

@@ -407,6 +407,144 @@
</div>
<!-- CLI Account Settings (model restriction, pool mode, etc.) -->
<div v-if="account.type === 'cli'" class="space-y-4">
<div class="rounded-lg border border-teal-200 bg-teal-50 p-4 dark:border-teal-700 dark:bg-teal-900/30">
<div class="flex items-start gap-3">
<Icon name="terminal" size="sm" class="mt-0.5 text-teal-600 dark:text-teal-400" />
<div>
<p class="text-sm font-medium text-teal-900 dark:text-teal-200">Local kimi-cli</p>
<p class="text-xs text-teal-700 dark:text-teal-300">This account uses your locally installed kimi-cli. Authentication is managed by the CLI itself.</p>
</div>
</div>
</div>
<!-- Model Restriction Section (same as apikey) -->
<div v-if="account.platform !== 'antigravity'" class="border-t border-gray-200 pt-4 dark:border-dark-600">
<label class="input-label">{{ t('admin.accounts.modelRestriction') }}</label>
<div
v-if="isOpenAIModelRestrictionDisabled"
class="mb-3 rounded-lg bg-amber-50 p-3 dark:bg-amber-900/20"
>
<p class="text-xs text-amber-700 dark:text-amber-400">
{{ t('admin.accounts.openai.modelRestrictionDisabledByPassthrough') }}
</p>
</div>
<template v-else>
<!-- Mode Toggle -->
<div class="mb-4 flex gap-2">
<button
type="button"
@click="modelRestrictionMode = 'whitelist'"
:class="[
'flex-1 rounded-lg px-4 py-2 text-sm font-medium transition-all',
modelRestrictionMode === 'whitelist'
? 'bg-primary-100 text-primary-700 dark:bg-primary-900/30 dark:text-primary-400'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-700 dark:text-gray-400 dark:hover:bg-dark-600'
]"
>
{{ t('admin.accounts.whitelistMode') }}
</button>
<button
type="button"
@click="modelRestrictionMode = 'mapping'"
:class="[
'flex-1 rounded-lg px-4 py-2 text-sm font-medium transition-all',
modelRestrictionMode === 'mapping'
? 'bg-primary-100 text-primary-700 dark:bg-primary-900/30 dark:text-primary-400'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-700 dark:text-gray-400 dark:hover:bg-dark-600'
]"
>
{{ t('admin.accounts.mappingMode') }}
</button>
</div>
<div v-if="modelRestrictionMode === 'whitelist'">
<ModelWhitelistSelector v-model="allowedModels" :platform="account?.platform || 'anthropic'" />
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
{{ t('admin.accounts.selectedModels', { count: allowedModels.length }) }}
<span v-if="allowedModels.length === 0">{{
t('admin.accounts.allModelsAllowed')
}}</span>
</p>
</div>
<div v-else>
<div v-if="modelMappings.length > 0" class="mb-3 space-y-2">
<div
v-for="(mapping, index) in modelMappings"
:key="getModelMappingKey(mapping)"
class="flex items-center gap-2"
>
<input
v-model="mapping.from"
type="text"
class="input flex-1"
:placeholder="t('admin.accounts.requestModel')"
/>
<svg class="h-4 w-4 flex-shrink-0 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14 5l7 7m0 0l-7 7m7-7H3" />
</svg>
<input
v-model="mapping.to"
type="text"
class="input flex-1"
:placeholder="t('admin.accounts.actualModel')"
/>
<button
type="button"
@click="removeModelMapping(index)"
class="rounded-lg p-2 text-red-500 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
</div>
</div>
<button
type="button"
@click="addModelMapping"
class="mb-3 w-full rounded-lg border-2 border-dashed border-gray-300 px-4 py-2 text-gray-600 transition-colors hover:border-gray-400 hover:text-gray-700 dark:border-dark-500 dark:text-gray-400 dark:hover:border-dark-400 dark:hover:text-gray-300"
>
+ {{ t('admin.accounts.addMapping') }}
</button>
<!-- Quick Add Buttons -->
<div class="flex flex-wrap gap-2">
<button
v-for="preset in presetMappings"
:key="'cli-' + preset.label"
type="button"
@click="addPresetMapping(preset.from, preset.to)"
:class="['rounded-lg px-3 py-1 text-xs transition-colors', preset.color]"
>
+ {{ preset.label }}
</button>
</div>
</div>
</template>
</div>
<!-- Pool Mode -->
<div class="border-t border-gray-200 pt-4 dark:border-dark-600">
<PoolModeConfig />
</div>
<!-- Custom Error Codes -->
<div class="border-t border-gray-200 pt-4 dark:border-dark-600">
<CustomErrorCodesConfig />
</div>
</div>
<!-- OpenAI OAuth Model Mapping (OAuth 类型没有 apikey 容器需要独立的模型映射区域) -->
<div
v-if="account.platform === 'openai' && account.type === 'oauth'"
@@ -1173,9 +1311,9 @@
</div>
</div>
<!-- 配额控制 (Anthropic apikey/bedrock: 配额限制 + 亲和) -->
<!-- 配额控制 (Anthropic apikey/bedrock/cli: 配额限制 + 亲和) -->
<div
v-if="account?.platform === 'anthropic' && (account?.type === 'apikey' || account?.type === 'bedrock')"
v-if="account?.platform === 'anthropic' && (account?.type === 'apikey' || account?.type === 'bedrock' || account?.type === 'cli')"
class="border-t border-gray-200 pt-4 dark:border-dark-600 space-y-4"
>
<div class="mb-3">
@@ -1224,9 +1362,9 @@
@update:quotaNotifyTotalThresholdType="quotaNotifyState.total.thresholdType = $event"
/>
</div>
<!-- 配额控制 ( Anthropic apikey/bedrock) -->
<!-- 配额控制 ( Anthropic apikey/bedrock/cli) -->
<div
v-else-if="account?.type === 'apikey' || account?.type === 'bedrock'"
v-else-if="account?.type === 'apikey' || account?.type === 'bedrock' || account?.type === 'cli'"
class="border-t border-gray-200 pt-4 dark:border-dark-600 space-y-4"
>
<div class="mb-3">
@@ -2213,8 +2351,8 @@ const syncFormFromAccount = (newAccount: Account | null) => {
}
}
// Load quota limit for apikey/bedrock accounts (bedrock quota is also loaded in its own branch above)
if (newAccount.type === 'apikey' || newAccount.type === 'bedrock') {
// Load quota limit for apikey/bedrock/cli accounts
if (newAccount.type === 'apikey' || newAccount.type === 'bedrock' || newAccount.type === 'cli') {
const quotaVal = extra?.quota_limit as number | undefined
editQuotaLimit.value = (quotaVal && quotaVal > 0) ? quotaVal : null
const dailyVal = extra?.quota_daily_limit as number | undefined
@@ -2377,6 +2515,43 @@ const syncFormFromAccount = (newAccount: Account | null) => {
modelMappings.value = []
allowedModels.value = []
}
} else if (newAccount.type === 'cli' && newAccount.credentials) {
const credentials = newAccount.credentials as Record<string, unknown>
// Load model mappings and detect mode
const existingMappings = credentials.model_mapping as Record<string, string> | 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<string, unknown>
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<string, unknown>) || {}
const newCredentials: Record<string, unknown> = { ...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<string, unknown>) || {}
@@ -3223,8 +3440,8 @@ const handleSubmit = async () => {
updatePayload.extra = newExtra
}
// For apikey/bedrock accounts, handle quota_limit in extra
if (props.account.type === 'apikey' || props.account.type === 'bedrock') {
// For apikey/bedrock/cli accounts, handle quota_limit in extra
if (props.account.type === 'apikey' || props.account.type === 'bedrock' || props.account.type === 'cli') {
const currentExtra = (updatePayload.extra as Record<string, unknown>) ||
(props.account.extra as Record<string, unknown>) || {}
const newExtra: Record<string, unknown> = { ...currentExtra }

View File

@@ -25,8 +25,8 @@ const updateType = (value: string | number | boolean | null) => { emit('update:f
const updateStatus = (value: string | number | boolean | null) => { emit('update:filters', { ...props.filters, status: value }) }
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' }])
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 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' }, { 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') },

View File

@@ -24,6 +24,8 @@
</svg>
<!-- Setup Token icon -->
<Icon v-else-if="type === 'setup-token'" name="shield" size="xs" />
<!-- CLI icon -->
<Icon v-else-if="type === 'cli'" name="terminal" size="xs" />
<!-- API Key icon -->
<Icon v-else name="key" size="xs" />
<span>{{ typeLabel }}</span>
@@ -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
}

View File

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

View File

@@ -0,0 +1,196 @@
import { ref } from 'vue'
import { useAppStore } from '@/stores/app'
import { apiClient } from '@/api/client'
export interface KimiTokenInfo {
access_token?: string
refresh_token?: string
expires_in?: number
expires_at?: number
scope?: string
token_type?: string
[key: string]: unknown
}
export function useKimiOAuth() {
const appStore = useAppStore()
const endpointPrefix = '/admin/kimi'
// State
const deviceCode = ref('')
const userCode = ref('')
const verificationUri = ref('')
const verificationUriComplete = ref('')
const loading = ref(false)
const error = ref('')
const sessionId = ref('') // reuse for device_code
// Reset state
const resetState = () => {
deviceCode.value = ''
userCode.value = ''
verificationUri.value = ''
verificationUriComplete.value = ''
loading.value = false
error.value = ''
sessionId.value = ''
}
// Initiate device authorization
const initiateDeviceAuth = async (
proxyId?: number | null
): Promise<boolean> => {
loading.value = true
deviceCode.value = ''
userCode.value = ''
verificationUri.value = ''
error.value = ''
try {
const payload: Record<string, unknown> = {}
if (proxyId) {
payload.proxy_id = proxyId
}
const { data } = await apiClient.post<{
device_code: string
user_code: string
verification_uri: string
verification_uri_complete: string
expires_in: number
interval: number
}>(`${endpointPrefix}/oauth/device-auth`, payload)
deviceCode.value = data.device_code
userCode.value = data.user_code
verificationUri.value = data.verification_uri
verificationUriComplete.value = data.verification_uri_complete
sessionId.value = data.device_code
return true
} catch (err: any) {
error.value = err.response?.data?.detail || 'Failed to initiate Kimi device auth'
appStore.showError(error.value)
return false
} finally {
loading.value = false
}
}
// Poll for token
const pollToken = async (): Promise<KimiTokenInfo | null> => {
if (!deviceCode.value) {
error.value = 'No device code available'
return null
}
loading.value = true
error.value = ''
try {
const { data } = await apiClient.post<{
status: string
access_token?: string
refresh_token?: string
expires_in?: number
expires_at?: number
scope?: string
token_type?: string
message?: string
}>(`${endpointPrefix}/oauth/token`, {
device_code: deviceCode.value
})
if (data.status === 'pending') {
return null // Still pending
}
return {
access_token: data.access_token,
refresh_token: data.refresh_token,
expires_in: data.expires_in,
expires_at: data.expires_at,
scope: data.scope,
token_type: data.token_type
}
} catch (err: any) {
error.value = err.response?.data?.detail || 'Failed to poll Kimi token'
appStore.showError(error.value)
return null
} finally {
loading.value = false
}
}
// Build credentials for Kimi OAuth account
const buildCredentials = (tokenInfo: KimiTokenInfo): Record<string, unknown> => {
const creds: Record<string, unknown> = {
access_token: tokenInfo.access_token,
expires_at: tokenInfo.expires_at
}
if (tokenInfo.refresh_token) {
creds.refresh_token = tokenInfo.refresh_token
}
if (tokenInfo.token_type) {
creds.token_type = tokenInfo.token_type
}
if (tokenInfo.scope) {
creds.scope = tokenInfo.scope
}
return creds
}
// Build extra info from token response
const buildExtraInfo = (_tokenInfo: KimiTokenInfo): Record<string, string> | undefined => {
return undefined
}
// Import local credentials from kimi-cli
const importLocalCredentials = async (
credentialsJson: string
): Promise<KimiTokenInfo | null> => {
loading.value = true
error.value = ''
try {
const { data } = await apiClient.post<{
access_token: string
refresh_token?: string
expires_at: number
scope?: string
token_type?: string
}>(`${endpointPrefix}/oauth/import-local`, {
credentials_json: credentialsJson
})
return {
access_token: data.access_token,
refresh_token: data.refresh_token,
expires_at: data.expires_at,
scope: data.scope,
token_type: data.token_type
}
} catch (err: any) {
error.value = err.response?.data?.detail || 'Failed to import local credentials'
appStore.showError(error.value)
return null
} finally {
loading.value = false
}
}
return {
deviceCode,
userCode,
verificationUri,
verificationUriComplete,
loading,
error,
sessionId,
resetState,
initiateDeviceAuth,
pollToken,
buildCredentials,
buildExtraInfo,
importLocalCredentials
}
}

View File

@@ -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',
@@ -2872,6 +2873,15 @@ export default {
validateAndCreate: 'Validate & Create',
pleaseEnterRefreshToken: 'Please enter Refresh Token',
failedToValidateRT: 'Failed to validate Refresh Token'
},
// Kimi specific
kimi: {
deviceFlowDesc: 'Click the button below to start the device authorization flow. You will receive a user code to enter on the Kimi verification page.',
startDeviceAuth: 'Start Device Auth',
step1OpenLink: '1. Open the link below and enter the user code:',
openVerificationPage: 'Open Verification Page',
step2Wait: '2. Waiting for authorization...',
pollingForToken: 'Polling for token...'
}
}, // Gemini specific (platform-wide)
gemini: {

View File

@@ -2284,6 +2284,7 @@ export default {
allGroups: '全部分组',
ungroupedGroup: '未分配分组',
oauthType: 'OAuth',
cliType: 'CLI',
// Schedulable toggle
schedulable: '参与调度',
schedulableHint: '开启后账号参与API请求调度',
@@ -3005,6 +3006,15 @@ export default {
validateAndCreate: '验证并创建账号',
pleaseEnterRefreshToken: '请输入 Refresh Token',
failedToValidateRT: '验证 Refresh Token 失败'
},
// Kimi specific
kimi: {
deviceFlowDesc: '点击下方按钮启动设备授权流程。您将收到一个用户码,需要在 Kimi 验证页面输入。',
startDeviceAuth: '启动设备授权',
step1OpenLink: '1. 打开下方链接并输入用户码:',
openVerificationPage: '打开验证页面',
step2Wait: '2. 等待授权完成...',
pollingForToken: '正在轮询令牌...'
}
},
// Gemini specific (platform-wide)

View File

@@ -436,7 +436,7 @@ export interface PaginationConfig {
// ==================== API Key & Group Types ====================
export type GroupPlatform = 'anthropic' | 'openai' | 'gemini' | 'antigravity'
export type GroupPlatform = 'anthropic' | 'openai' | 'gemini' | 'antigravity' | 'kimi'
export type SubscriptionType = 'standard' | 'subscription'
@@ -609,8 +609,8 @@ export interface UpdateGroupRequest {
// ==================== Account & Proxy Types ====================
export type AccountPlatform = 'anthropic' | 'openai' | 'gemini' | 'antigravity'
export type AccountType = 'oauth' | 'setup-token' | 'apikey' | 'upstream' | 'bedrock'
export type AccountPlatform = 'anthropic' | 'openai' | 'gemini' | 'antigravity' | 'kimi'
export type AccountType = 'oauth' | 'setup-token' | 'apikey' | 'upstream' | 'bedrock' | 'cli'
export type OAuthAddMethod = 'oauth' | 'setup-token'
export type ProxyProtocol = 'http' | 'https' | 'socks5' | 'socks5h'

View File

@@ -5,7 +5,7 @@
* instead of defining their own color mappings.
*/
export type Platform = 'anthropic' | 'openai' | 'antigravity' | 'gemini'
export type Platform = 'anthropic' | 'openai' | 'antigravity' | 'gemini' | 'kimi'
// ── Badge (bg + text + border, for inline badges with border) ───────
const BADGE: Record<Platform, string> = {
@@ -13,6 +13,7 @@ const BADGE: Record<Platform, string> = {
openai: 'bg-green-500/10 text-green-600 border-green-500/30 dark:text-green-400',
antigravity: 'bg-purple-500/10 text-purple-600 border-purple-500/30 dark:text-purple-400',
gemini: 'bg-blue-500/10 text-blue-600 border-blue-500/30 dark:text-blue-400',
kimi: 'bg-violet-500/10 text-violet-600 border-violet-500/30 dark:text-violet-400',
}
const BADGE_DEFAULT = 'bg-slate-500/10 text-slate-600 border-slate-500/30 dark:text-slate-400'
@@ -22,6 +23,7 @@ const BADGE_LIGHT: Record<Platform, string> = {
openai: 'bg-green-500/10 text-green-600 dark:bg-green-500/10 dark:text-green-300',
antigravity: 'bg-purple-500/10 text-purple-600 dark:bg-purple-500/10 dark:text-purple-300',
gemini: 'bg-blue-500/10 text-blue-600 dark:bg-blue-500/10 dark:text-blue-300',
kimi: 'bg-violet-500/10 text-violet-600 dark:bg-violet-500/10 dark:text-violet-300',
}
// ── Border ──────────────────────────────────────────────────────────
@@ -30,6 +32,7 @@ const BORDER: Record<Platform, string> = {
openai: 'border-green-500/20 dark:border-green-500/20',
antigravity: 'border-purple-500/20 dark:border-purple-500/20',
gemini: 'border-blue-500/20 dark:border-blue-500/20',
kimi: 'border-violet-500/20 dark:border-violet-500/20',
}
const BORDER_DEFAULT = 'border-gray-200 dark:border-dark-700'
@@ -39,6 +42,7 @@ const ACCENT_BAR: Record<Platform, string> = {
openai: 'bg-gradient-to-r from-emerald-400 to-emerald-500',
antigravity: 'bg-gradient-to-r from-purple-400 to-purple-500',
gemini: 'bg-gradient-to-r from-blue-400 to-blue-500',
kimi: 'bg-gradient-to-r from-violet-400 to-violet-500',
}
const ACCENT_BAR_DEFAULT = 'bg-gradient-to-r from-primary-400 to-primary-500'
@@ -48,6 +52,7 @@ const TEXT: Record<Platform, string> = {
openai: 'text-emerald-600 dark:text-emerald-400',
antigravity: 'text-purple-600 dark:text-purple-400',
gemini: 'text-blue-600 dark:text-blue-400',
kimi: 'text-violet-600 dark:text-violet-400',
}
const TEXT_DEFAULT = 'text-primary-600 dark:text-primary-400'
@@ -57,6 +62,7 @@ const ICON: Record<Platform, string> = {
openai: 'text-emerald-500 dark:text-emerald-400',
antigravity: 'text-purple-500 dark:text-purple-400',
gemini: 'text-blue-500 dark:text-blue-400',
kimi: 'text-violet-500 dark:text-violet-400',
}
const ICON_DEFAULT = 'text-primary-500 dark:text-primary-400'
@@ -66,6 +72,7 @@ const BUTTON: Record<Platform, string> = {
openai: 'bg-green-600 text-white hover:bg-green-700 active:bg-green-800 dark:bg-green-600/80 dark:hover:bg-green-600',
antigravity: 'bg-purple-500 text-white hover:bg-purple-600 active:bg-purple-700 dark:bg-purple-500/80 dark:hover:bg-purple-500',
gemini: 'bg-blue-500 text-white hover:bg-blue-600 active:bg-blue-700 dark:bg-blue-500/80 dark:hover:bg-blue-500',
kimi: 'bg-violet-500 text-white hover:bg-violet-600 active:bg-violet-700 dark:bg-violet-500/80 dark:hover:bg-violet-500',
}
const BUTTON_DEFAULT = 'bg-primary-500 text-white hover:bg-primary-600 dark:bg-primary-600 dark:hover:bg-primary-500'
@@ -75,6 +82,7 @@ const DISCOUNT: Record<Platform, string> = {
openai: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300',
antigravity: 'bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300',
gemini: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300',
kimi: 'bg-violet-100 text-violet-700 dark:bg-violet-900/40 dark:text-violet-300',
}
const DISCOUNT_DEFAULT = 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300'
@@ -84,6 +92,7 @@ const GRADIENT: Record<Platform, string> = {
openai: 'from-emerald-500 to-emerald-600',
antigravity: 'from-purple-500 to-purple-600',
gemini: 'from-blue-500 to-blue-600',
kimi: 'from-violet-500 to-violet-600',
}
const GRADIENT_DEFAULT = 'from-primary-500 to-primary-600'
@@ -93,6 +102,7 @@ const GRADIENT_TEXT: Record<Platform, string> = {
openai: 'text-emerald-100',
antigravity: 'text-purple-100',
gemini: 'text-blue-100',
kimi: 'text-violet-100',
}
const GRADIENT_TEXT_DEFAULT = 'text-primary-100'
@@ -101,13 +111,14 @@ const GRADIENT_SUBTEXT: Record<Platform, string> = {
openai: 'text-emerald-200',
antigravity: 'text-purple-200',
gemini: 'text-blue-200',
kimi: 'text-violet-200',
}
const GRADIENT_SUBTEXT_DEFAULT = 'text-primary-200'
// ── Public API ──────────────────────────────────────────────────────
function isPlatform(p: string): p is Platform {
return p === 'anthropic' || p === 'openai' || p === 'antigravity' || p === 'gemini'
return p === 'anthropic' || p === 'openai' || p === 'antigravity' || p === 'gemini' || p === 'kimi'
}
export function platformBadgeClass(p: string): string {
@@ -160,6 +171,7 @@ export function platformLabel(p: string): string {
case 'openai': return 'OpenAI'
case 'antigravity': return 'Antigravity'
case 'gemini': return 'Gemini'
case 'kimi': return 'Kimi'
default: return p || 'API'
}
}

View File

@@ -718,7 +718,7 @@ const form = reactive({
let abortController: AbortController | null = null
// ── Platform config ──
const platformOrder: GroupPlatform[] = ['anthropic', 'openai', 'gemini', 'antigravity']
const platformOrder: GroupPlatform[] = ['anthropic', 'openai', 'gemini', 'antigravity', 'kimi']
// ── Helpers ──
function formatDate(value: string): string {

View File

@@ -2783,6 +2783,7 @@ const platformOptions = computed(() => [
{ value: "openai", label: "OpenAI" },
{ value: "gemini", label: "Gemini" },
{ value: "antigravity", label: "Antigravity" },
{ value: "kimi", label: "Kimi" },
]);
const platformFilterOptions = computed(() => [
@@ -2791,6 +2792,7 @@ const platformFilterOptions = computed(() => [
{ value: "openai", label: "OpenAI" },
{ value: "gemini", label: "Gemini" },
{ value: "antigravity", label: "Antigravity" },
{ value: "kimi", label: "Kimi" },
]);
const editStatusOptions = computed(() => [