Compare commits
4 Commits
main
...
feat/kimi-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
88d687715d | ||
|
|
55ca0ad687 | ||
|
|
e746e82c39 | ||
|
|
af3efbc525 |
@@ -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
|
||||
|
||||
@@ -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
181
backend/config.test.yaml
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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"`
|
||||
|
||||
182
backend/internal/handler/admin/kimi_oauth_handler.go
Normal file
182
backend/internal/handler/admin/kimi_oauth_handler.go
Normal 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,
|
||||
})
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 验证规则配置的有效性
|
||||
|
||||
@@ -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")
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 有自己的 TokenProvider,setup-token 类型等)直接从账号读取
|
||||
accessToken := account.GetCredential("access_token")
|
||||
if accessToken == "" {
|
||||
|
||||
446
backend/internal/service/kimi_cli_gateway.go
Normal file
446
backend/internal/service/kimi_cli_gateway.go
Normal 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
|
||||
}
|
||||
258
backend/internal/service/kimi_gateway_service.go
Normal file
258
backend/internal/service/kimi_gateway_service.go
Normal 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
|
||||
}
|
||||
|
||||
306
backend/internal/service/kimi_oauth_service.go
Normal file
306
backend/internal/service/kimi_oauth_service.go
Normal 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)
|
||||
}
|
||||
198
backend/internal/service/kimi_token_provider.go
Normal file
198
backend/internal/service/kimi_token_provider.go
Normal 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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
28
deploy/docker-compose.test.yml
Normal file
28
deploy/docker-compose.test.yml
Normal 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
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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') },
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
196
frontend/src/composables/useKimiOAuth.ts
Normal file
196
frontend/src/composables/useKimiOAuth.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(() => [
|
||||
|
||||
Reference in New Issue
Block a user