Backend:
- Add Kimi OAuth service (InitiateDeviceAuth, PollToken, RefreshToken)
- Add Kimi token provider with caching and auto-refresh
- Add Kimi gateway service for OpenAI-compatible chat completions
- Add admin routes for /kimi/oauth/* endpoints
- Wire up all new services
- Add PlatformKimi to error passthrough rule constants
- Add 'kimi' to group handler platform validation tags
- Fix httpclient usage in Kimi OAuth service (use GetClient with opts)
- Add CanRefresh method to KimiTokenRefresher
- Remove duplicate ProvideKimiTokenProvider from wire ProviderSet
- Remove unused imports
- Lower go.mod requirement to 1.26.1 for local toolchain compatibility
- Add verification_uri_complete support for seamless device auth
Frontend:
- Add useKimiOAuth composable for device flow
- Integrate Kimi device flow UI in CreateAccountModal Step 2
- Add kimi to GroupPlatform and AccountPlatform types
- Add i18n translations for device flow (zh/en)
- Fix Icon name lock-closed -> key
- Add kimi platform colors/styles to platformColors.ts
- Add kimi to AccountTableFilters platform options
- Add kimi to ChannelsView platformOrder
- Use verification_uri_complete for auto-filled user_code
[宪宪/Kimi-1.6🐾]
183 lines
5.5 KiB
Go
183 lines
5.5 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,
|
|
"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,
|
|
})
|
|
}
|