Files
sub2api/backend/internal/handler/admin/kimi_oauth_handler.go
openclaw 91521e6d7e
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
feat(kimi): add Kimi Code OAuth device flow support
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🐾]
2026-04-23 02:49:39 +08:00

182 lines
5.4 KiB
Go

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