feat(kimi): add Kimi Code OAuth device flow support
Some checks failed
CI / test (push) Has been cancelled
CI / frontend (push) Has been cancelled
CI / golangci-lint (push) Has been cancelled
Security Scan / backend-security (push) Has been cancelled
Security Scan / frontend-security (push) Has been cancelled

Backend:
- Add Kimi OAuth service (InitiateDeviceAuth, PollToken, RefreshToken)
- Add Kimi token provider with caching and auto-refresh
- Add Kimi gateway service for OpenAI-compatible chat completions
- Add admin routes for /kimi/oauth/* endpoints
- Wire up all new services
- Add PlatformKimi to error passthrough rule constants
- Add 'kimi' to group handler platform validation tags
- Fix httpclient usage in Kimi OAuth service (use GetClient with opts)
- Add CanRefresh method to KimiTokenRefresher
- Remove duplicate ProvideKimiTokenProvider from wire ProviderSet
- Remove unused imports
- Lower go.mod requirement to 1.26.1 for local toolchain compatibility

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

[宪宪/Kimi-1.6🐾]
This commit is contained in:
openclaw
2026-04-23 02:48:44 +08:00
parent 6449da6c8d
commit 91521e6d7e
27 changed files with 1594 additions and 17 deletions

View File

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

View File

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

View File

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

View File

@@ -22,6 +22,7 @@ const (
PlatformOpenAI = "openai"
PlatformGemini = "gemini"
PlatformAntigravity = "antigravity"
PlatformKimi = "kimi"
)
// Account type constants

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -24,6 +24,7 @@ const (
PlatformOpenAI = domain.PlatformOpenAI
PlatformGemini = domain.PlatformGemini
PlatformAntigravity = domain.PlatformAntigravity
PlatformKimi = domain.PlatformKimi
)
// Account type constants

View File

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

View File

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

View File

@@ -0,0 +1,303 @@
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"`
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
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,
ExpiresAt: time.Now().Add(time.Duration(result.ExpiresIn) * time.Second),
Interval: interval,
ProxyURL: proxyURL,
}
s.sessions.Store(result.DeviceCode, session)
logger.LegacyPrintf("service.kimi_oauth", "[KimiOAuth] Device auth initiated: user_code=%s", result.UserCode)
return &result, nil
}
// PollToken polls the token endpoint for a device_code.
func (s *KimiOAuthService) PollToken(ctx context.Context, deviceCode string) (*KimiTokenInfo, error) {
sessionVal, ok := s.sessions.Load(deviceCode)
if !ok {
return nil, fmt.Errorf("session not found or expired")
}
session := sessionVal.(*kimiOAuthSession)
if time.Now().After(session.ExpiresAt) {
s.sessions.Delete(deviceCode)
return nil, fmt.Errorf("device auth session expired")
}
data := url.Values{}
data.Set("client_id", kimiClientID)
data.Set("grant_type", "urn:ietf:params:oauth:grant-type:device_code")
data.Set("device_code", deviceCode)
req, err := http.NewRequestWithContext(ctx, "POST", kimiTokenURL, strings.NewReader(data.Encode()))
if err != nil {
return nil, fmt.Errorf("failed to create token request: %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Accept", "application/json")
resp, err := s.doRequest(req, session.ProxyURL)
if err != nil {
return nil, fmt.Errorf("token request failed: %w", err)
}
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return nil, fmt.Errorf("failed to read token response: %w", err)
}
if resp.StatusCode == http.StatusBadRequest || resp.StatusCode == http.StatusForbidden {
// Check for authorization_pending
var errResp struct {
Error string `json:"error"`
}
if json.Unmarshal(body, &errResp) == nil && errResp.Error == "authorization_pending" {
return nil, fmt.Errorf("authorization_pending")
}
if errResp.Error == "expired_token" || errResp.Error == "invalid_grant" {
s.sessions.Delete(deviceCode)
}
return nil, fmt.Errorf("token poll failed: %s", errResp.Error)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("token poll failed: status=%d body=%s", resp.StatusCode, string(body))
}
var tokenResp KimiTokenResponse
if err := json.Unmarshal(body, &tokenResp); err != nil {
return nil, fmt.Errorf("failed to parse token response: %w", err)
}
s.sessions.Delete(deviceCode)
expiresAt := time.Now().Unix() + tokenResp.ExpiresIn - KimiTokenSafetyWindow
minExpiresAt := time.Now().Unix() + KimiTokenMinTTL
if expiresAt < minExpiresAt {
expiresAt = minExpiresAt
}
logger.LegacyPrintf("service.kimi_oauth", "[KimiOAuth] Token obtained successfully")
return &KimiTokenInfo{
AccessToken: tokenResp.AccessToken,
RefreshToken: tokenResp.RefreshToken,
ExpiresIn: tokenResp.ExpiresIn,
ExpiresAt: expiresAt,
Scope: tokenResp.Scope,
TokenType: tokenResp.TokenType,
}, nil
}
// RefreshToken refreshes the access token using a refresh token.
func (s *KimiOAuthService) RefreshToken(ctx context.Context, refreshToken, proxyURL string) (*KimiTokenInfo, error) {
if strings.TrimSpace(refreshToken) == "" {
return nil, fmt.Errorf("refresh_token is empty")
}
data := url.Values{}
data.Set("client_id", kimiClientID)
data.Set("grant_type", "refresh_token")
data.Set("refresh_token", refreshToken)
req, err := http.NewRequestWithContext(ctx, "POST", kimiTokenURL, strings.NewReader(data.Encode()))
if err != nil {
return nil, fmt.Errorf("failed to create refresh request: %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Accept", "application/json")
resp, err := s.doRequest(req, proxyURL)
if err != nil {
return nil, fmt.Errorf("refresh request failed: %w", err)
}
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return nil, fmt.Errorf("failed to read refresh response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("refresh failed: status=%d body=%s", resp.StatusCode, string(body))
}
var tokenResp KimiTokenResponse
if err := json.Unmarshal(body, &tokenResp); err != nil {
return nil, fmt.Errorf("failed to parse refresh response: %w", err)
}
expiresAt := time.Now().Unix() + tokenResp.ExpiresIn - KimiTokenSafetyWindow
minExpiresAt := time.Now().Unix() + KimiTokenMinTTL
if expiresAt < minExpiresAt {
expiresAt = minExpiresAt
}
return &KimiTokenInfo{
AccessToken: tokenResp.AccessToken,
RefreshToken: tokenResp.RefreshToken,
ExpiresIn: tokenResp.ExpiresIn,
ExpiresAt: expiresAt,
Scope: tokenResp.Scope,
TokenType: tokenResp.TokenType,
}, nil
}
// BuildAccountCredentials builds the credentials map for a Kimi account.
func (s *KimiOAuthService) BuildAccountCredentials(tokenInfo *KimiTokenInfo) map[string]any {
creds := map[string]any{
"access_token": tokenInfo.AccessToken,
"expires_at": strconv.FormatInt(tokenInfo.ExpiresAt, 10),
}
if tokenInfo.RefreshToken != "" {
creds["refresh_token"] = tokenInfo.RefreshToken
}
if tokenInfo.TokenType != "" {
creds["token_type"] = tokenInfo.TokenType
}
if tokenInfo.Scope != "" {
creds["scope"] = tokenInfo.Scope
}
return creds
}
// Stop cleans up the service.
func (s *KimiOAuthService) Stop() {
s.sessions.Range(func(key, value any) bool {
s.sessions.Delete(key)
return true
})
}
func (s *KimiOAuthService) doRequest(req *http.Request, proxyURL string) (*http.Response, error) {
opts := httpclient.Options{
ProxyURL: proxyURL,
Timeout: 30 * time.Second,
}
client, err := httpclient.GetClient(opts)
if err != nil {
return nil, err
}
return client.Do(req)
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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,63 @@
</div>
</div>
<!-- Account Type Selection (Kimi - OAuth or API Key) -->
<div v-if="form.platform === 'kimi'">
<label class="input-label">{{ t('admin.accounts.accountType') }}</label>
<div class="mt-2 grid grid-cols-2 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>
</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">
@@ -2550,7 +2622,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.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 +2783,6 @@
@validate-mobile-refresh-token="handleOpenAIValidateMobileRT"
@validate-session-token="handleValidateSessionToken"
/>
</div>
<template #footer>
@@ -2889,7 +3098,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 +3121,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 +3206,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 +3221,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 +3229,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 +3237,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
})
@@ -3312,6 +3527,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
})
@@ -4068,6 +4287,8 @@ const handleSubmit = async () => {
? 'https://api.openai.com'
: form.platform === 'gemini'
? 'https://generativelanguage.googleapis.com'
: form.platform === 'kimi'
? 'https://api.kimi.com'
: 'https://api.anthropic.com'
// Build credentials with optional model mapping
@@ -4121,9 +4342,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 +4477,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)
}
@@ -4679,6 +5022,9 @@ const handleExchangeCode = async () => {
return handleGeminiExchange(authCode)
case 'antigravity':
return handleAntigravityExchange(authCode)
case 'kimi':
// Kimi uses device flow with auto-polling, no manual exchange needed
return
default:
return handleAnthropicExchange(authCode)
}

View File

@@ -25,7 +25,7 @@ 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 pOpts = computed(() => [{ value: '', label: t('admin.accounts.allPlatforms') }, { value: 'anthropic', label: 'Anthropic' }, { value: 'openai', label: 'OpenAI' }, { value: 'gemini', label: 'Gemini' }, { value: 'antigravity', label: 'Antigravity' }, { value: 'kimi', label: 'Kimi' }])
const tOpts = computed(() => [{ value: '', label: t('admin.accounts.allTypes') }, { value: 'oauth', label: t('admin.accounts.oauthType') }, { value: 'setup-token', label: t('admin.accounts.setupToken') }, { value: 'apikey', label: t('admin.accounts.apiKey') }, { value: 'bedrock', label: 'AWS Bedrock' }])
const 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(() => [

View File

@@ -0,0 +1,191 @@
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 loading = ref(false)
const error = ref('')
const sessionId = ref('') // reuse for device_code
// Reset state
const resetState = () => {
deviceCode.value = ''
userCode.value = ''
verificationUri.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
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
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,
loading,
error,
sessionId,
resetState,
initiateDeviceAuth,
pollToken,
buildCredentials,
buildExtraInfo,
importLocalCredentials
}
}

View File

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

View File

@@ -3005,6 +3005,15 @@ export default {
validateAndCreate: '验证并创建账号',
pleaseEnterRefreshToken: '请输入 Refresh Token',
failedToValidateRT: '验证 Refresh Token 失败'
},
// Kimi specific
kimi: {
deviceFlowDesc: '点击下方按钮启动设备授权流程。您将收到一个用户码,需要在 Kimi 验证页面输入。',
startDeviceAuth: '启动设备授权',
step1OpenLink: '1. 打开下方链接并输入用户码:',
openVerificationPage: '打开验证页面',
step2Wait: '2. 等待授权完成...',
pollingForToken: '正在轮询令牌...'
}
},
// Gemini specific (platform-wide)

View File

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

View File

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

View File

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