release: prepare v0.1.134
This commit is contained in:
@@ -1 +1 @@
|
|||||||
0.1.133
|
0.1.134
|
||||||
|
|||||||
@@ -146,6 +146,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
|||||||
oAuthRefreshAPI := service.ProvideOAuthRefreshAPI(accountRepository, geminiTokenCache)
|
oAuthRefreshAPI := service.ProvideOAuthRefreshAPI(accountRepository, geminiTokenCache)
|
||||||
geminiTokenProvider := service.ProvideGeminiTokenProvider(accountRepository, geminiTokenCache, geminiOAuthService, oAuthRefreshAPI)
|
geminiTokenProvider := service.ProvideGeminiTokenProvider(accountRepository, geminiTokenCache, geminiOAuthService, oAuthRefreshAPI)
|
||||||
claudeTokenProvider := service.ProvideClaudeTokenProvider(accountRepository, geminiTokenCache, oAuthService, oAuthRefreshAPI)
|
claudeTokenProvider := service.ProvideClaudeTokenProvider(accountRepository, geminiTokenCache, oAuthService, oAuthRefreshAPI)
|
||||||
|
openAITokenProvider := service.ProvideOpenAITokenProvider(accountRepository, geminiTokenCache, openAIOAuthService, oAuthRefreshAPI)
|
||||||
kiroOAuthService := service.NewKiroOAuthService(proxyRepository)
|
kiroOAuthService := service.NewKiroOAuthService(proxyRepository)
|
||||||
kiroTokenProvider := service.ProvideKiroTokenProvider(accountRepository, geminiTokenCache, kiroOAuthService, oAuthRefreshAPI)
|
kiroTokenProvider := service.ProvideKiroTokenProvider(accountRepository, geminiTokenCache, kiroOAuthService, oAuthRefreshAPI)
|
||||||
gatewayCache := repository.NewGatewayCache(redisClient)
|
gatewayCache := repository.NewGatewayCache(redisClient)
|
||||||
@@ -154,7 +155,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
|||||||
antigravityTokenProvider := service.ProvideAntigravityTokenProvider(accountRepository, geminiTokenCache, antigravityOAuthService, oAuthRefreshAPI, tempUnschedCache)
|
antigravityTokenProvider := service.ProvideAntigravityTokenProvider(accountRepository, geminiTokenCache, antigravityOAuthService, oAuthRefreshAPI, tempUnschedCache)
|
||||||
internal500CounterCache := repository.NewInternal500CounterCache(redisClient)
|
internal500CounterCache := repository.NewInternal500CounterCache(redisClient)
|
||||||
antigravityGatewayService := service.NewAntigravityGatewayService(accountRepository, gatewayCache, schedulerSnapshotService, antigravityTokenProvider, rateLimitService, httpUpstream, settingService, internal500CounterCache)
|
antigravityGatewayService := service.NewAntigravityGatewayService(accountRepository, gatewayCache, schedulerSnapshotService, antigravityTokenProvider, rateLimitService, httpUpstream, settingService, internal500CounterCache)
|
||||||
accountTestService := service.NewAccountTestService(accountRepository, geminiTokenProvider, claudeTokenProvider, kiroTokenProvider, antigravityGatewayService, httpUpstream, configConfig, tlsFingerprintProfileService)
|
accountTestService := service.NewAccountTestService(accountRepository, geminiTokenProvider, claudeTokenProvider, openAITokenProvider, kiroTokenProvider, antigravityGatewayService, httpUpstream, configConfig, tlsFingerprintProfileService)
|
||||||
crsSyncService := service.NewCRSSyncService(accountRepository, proxyRepository, oAuthService, openAIOAuthService, geminiOAuthService, configConfig)
|
crsSyncService := service.NewCRSSyncService(accountRepository, proxyRepository, oAuthService, openAIOAuthService, geminiOAuthService, configConfig)
|
||||||
accountHandler := admin.NewAccountHandler(adminService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, rateLimitService, accountUsageService, accountTestService, concurrencyService, crsSyncService, sessionLimitCache, rpmCache, compositeTokenCacheInvalidator)
|
accountHandler := admin.NewAccountHandler(adminService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, rateLimitService, accountUsageService, accountTestService, concurrencyService, crsSyncService, sessionLimitCache, rpmCache, compositeTokenCacheInvalidator)
|
||||||
adminAnnouncementHandler := admin.NewAnnouncementHandler(announcementService)
|
adminAnnouncementHandler := admin.NewAnnouncementHandler(announcementService)
|
||||||
@@ -189,7 +190,6 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
|||||||
modelPricingResolver := service.NewModelPricingResolver(channelService, billingService)
|
modelPricingResolver := service.NewModelPricingResolver(channelService, billingService)
|
||||||
balanceNotifyService := service.ProvideBalanceNotifyService(emailService, settingRepository, accountRepository)
|
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, kiroTokenProvider, kiroCooldownStore, 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, kiroTokenProvider, kiroCooldownStore, 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, settingService)
|
openAIGatewayService := service.NewOpenAIGatewayService(accountRepository, usageLogRepository, usageBillingRepository, userRepository, userSubscriptionRepository, userGroupRateRepository, gatewayCache, configConfig, schedulerSnapshotService, concurrencyService, billingService, rateLimitService, billingCacheService, httpUpstream, deferredService, openAITokenProvider, modelPricingResolver, channelService, balanceNotifyService, settingService)
|
||||||
geminiMessagesCompatService := service.NewGeminiMessagesCompatService(accountRepository, groupRepository, gatewayCache, schedulerSnapshotService, geminiTokenProvider, rateLimitService, httpUpstream, antigravityGatewayService, configConfig)
|
geminiMessagesCompatService := service.NewGeminiMessagesCompatService(accountRepository, groupRepository, gatewayCache, schedulerSnapshotService, geminiTokenProvider, rateLimitService, httpUpstream, antigravityGatewayService, configConfig)
|
||||||
opsSystemLogSink := service.ProvideOpsSystemLogSink(opsRepository)
|
opsSystemLogSink := service.ProvideOpsSystemLogSink(opsRepository)
|
||||||
|
|||||||
@@ -1862,6 +1862,76 @@ func (h *AccountHandler) SetSchedulable(c *gin.Context) {
|
|||||||
response.Success(c, h.buildAccountResponseWithRuntime(c.Request.Context(), account))
|
response.Success(c, h.buildAccountResponseWithRuntime(c.Request.Context(), account))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetKiroUpstreamModels handles getting upstream Kiro models with the account credentials/proxy.
|
||||||
|
// GET /api/v1/admin/accounts/:id/kiro/upstream-models
|
||||||
|
func (h *AccountHandler) GetKiroUpstreamModels(c *gin.Context) {
|
||||||
|
accountID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
response.BadRequest(c, "Invalid account ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
account, err := h.adminService.GetAccount(c.Request.Context(), accountID)
|
||||||
|
if err != nil {
|
||||||
|
response.NotFound(c, "Account not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if account.Platform != service.PlatformKiro {
|
||||||
|
response.BadRequest(c, "Account is not a Kiro account")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if h.accountTestService == nil {
|
||||||
|
response.InternalError(c, "Kiro account service not configured")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
models, err := h.accountTestService.FetchKiroUpstreamModels(c.Request.Context(), account)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, service.ErrKiroModelListUnsupported) {
|
||||||
|
response.BadRequest(c, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.InternalError(c, "Failed to fetch Kiro upstream model list: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.Success(c, models)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetOpenAIUpstreamModels handles getting upstream OpenAI models with the account credentials/proxy.
|
||||||
|
// GET /api/v1/admin/accounts/:id/openai/upstream-models
|
||||||
|
func (h *AccountHandler) GetOpenAIUpstreamModels(c *gin.Context) {
|
||||||
|
accountID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
response.BadRequest(c, "Invalid account ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
account, err := h.adminService.GetAccount(c.Request.Context(), accountID)
|
||||||
|
if err != nil {
|
||||||
|
response.NotFound(c, "Account not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if account.Platform != service.PlatformOpenAI {
|
||||||
|
response.BadRequest(c, "Account is not an OpenAI account")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if h.accountTestService == nil {
|
||||||
|
response.InternalError(c, "OpenAI account service not configured")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
models, err := h.accountTestService.FetchOpenAIUpstreamModels(c.Request.Context(), account)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, service.ErrOpenAIModelListUnsupported) {
|
||||||
|
response.BadRequest(c, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.InternalError(c, "Failed to fetch OpenAI upstream model list: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.Success(c, models)
|
||||||
|
}
|
||||||
|
|
||||||
// GetAvailableModels handles getting available models for an account
|
// GetAvailableModels handles getting available models for an account
|
||||||
// GET /api/v1/admin/accounts/:id/models
|
// GET /api/v1/admin/accounts/:id/models
|
||||||
func (h *AccountHandler) GetAvailableModels(c *gin.Context) {
|
func (h *AccountHandler) GetAvailableModels(c *gin.Context) {
|
||||||
|
|||||||
@@ -129,6 +129,7 @@ func (h *KiroOAuthHandler) RefreshToken(c *gin.Context) {
|
|||||||
type KiroImportTokenRequest struct {
|
type KiroImportTokenRequest struct {
|
||||||
TokenJSON string `json:"token_json" binding:"required"`
|
TokenJSON string `json:"token_json" binding:"required"`
|
||||||
DeviceRegistrationJSON string `json:"device_registration_json"`
|
DeviceRegistrationJSON string `json:"device_registration_json"`
|
||||||
|
ProxyID *int64 `json:"proxy_id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *KiroOAuthHandler) ImportToken(c *gin.Context) {
|
func (h *KiroOAuthHandler) ImportToken(c *gin.Context) {
|
||||||
@@ -140,6 +141,7 @@ func (h *KiroOAuthHandler) ImportToken(c *gin.Context) {
|
|||||||
tokenInfo, err := h.kiroOAuthService.ImportToken(&service.KiroImportTokenInput{
|
tokenInfo, err := h.kiroOAuthService.ImportToken(&service.KiroImportTokenInput{
|
||||||
TokenJSON: req.TokenJSON,
|
TokenJSON: req.TokenJSON,
|
||||||
DeviceRegistrationJSON: req.DeviceRegistrationJSON,
|
DeviceRegistrationJSON: req.DeviceRegistrationJSON,
|
||||||
|
ProxyID: req.ProxyID,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.BadRequest(c, "导入 Kiro Token 失败: "+err.Error())
|
response.BadRequest(c, "导入 Kiro Token 失败: "+err.Error())
|
||||||
|
|||||||
@@ -424,6 +424,24 @@ func ParseImportedToken(tokenJSON string, deviceRegistrationJSON string) (*Token
|
|||||||
return &token, nil
|
return &token, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ParseImportedRefreshToken(tokenJSON string) (*TokenData, bool, error) {
|
||||||
|
var token TokenData
|
||||||
|
if err := json.Unmarshal([]byte(tokenJSON), &token); err != nil {
|
||||||
|
return nil, false, fmt.Errorf("failed to parse kiro token: %w", err)
|
||||||
|
}
|
||||||
|
token.AuthMethod = strings.ToLower(strings.TrimSpace(token.AuthMethod))
|
||||||
|
if token.AuthMethod == "" {
|
||||||
|
token.AuthMethod = "social"
|
||||||
|
}
|
||||||
|
if token.Provider == "" && token.AuthMethod == "social" {
|
||||||
|
token.Provider = string(SocialProviderGoogle)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(token.RefreshToken) == "" || strings.TrimSpace(token.AccessToken) != "" {
|
||||||
|
return &token, false, nil
|
||||||
|
}
|
||||||
|
return &token, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
func getOIDCEndpoint(region string) string {
|
func getOIDCEndpoint(region string) string {
|
||||||
if strings.TrimSpace(oidcEndpointOverride) != "" {
|
if strings.TrimSpace(oidcEndpointOverride) != "" {
|
||||||
return strings.TrimRight(strings.TrimSpace(oidcEndpointOverride), "/")
|
return strings.TrimRight(strings.TrimSpace(oidcEndpointOverride), "/")
|
||||||
|
|||||||
@@ -54,3 +54,35 @@ func TestSessionStoreSetPrunesExpiredSessions(t *testing.T) {
|
|||||||
t.Fatalf("fresh session should remain after pruning")
|
t.Fatalf("fresh session should remain after pruning")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestParseImportedRefreshTokenAcceptsRefreshTokenOnlyPayload(t *testing.T) {
|
||||||
|
token, refreshOnly, err := ParseImportedRefreshToken(`{"refreshToken":"rt","provider":"Google"}`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseImportedRefreshToken() error = %v", err)
|
||||||
|
}
|
||||||
|
if !refreshOnly {
|
||||||
|
t.Fatalf("refreshOnly = false, want true")
|
||||||
|
}
|
||||||
|
if token.RefreshToken != "rt" {
|
||||||
|
t.Fatalf("refresh token = %q, want rt", token.RefreshToken)
|
||||||
|
}
|
||||||
|
if token.Provider != "Google" {
|
||||||
|
t.Fatalf("provider = %q, want Google", token.Provider)
|
||||||
|
}
|
||||||
|
if token.AuthMethod != "social" {
|
||||||
|
t.Fatalf("auth method = %q, want social", token.AuthMethod)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseImportedRefreshTokenKeepsFullTokenAsNonRefreshOnly(t *testing.T) {
|
||||||
|
token, refreshOnly, err := ParseImportedRefreshToken(`{"accessToken":"at","refreshToken":"rt"}`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseImportedRefreshToken() error = %v", err)
|
||||||
|
}
|
||||||
|
if refreshOnly {
|
||||||
|
t.Fatalf("refreshOnly = true, want false")
|
||||||
|
}
|
||||||
|
if token.Provider != "Google" {
|
||||||
|
t.Fatalf("provider = %q, want default Google", token.Provider)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -408,7 +408,7 @@ LEFT JOIN users u ON u.id = ua.user_id
|
|||||||
LEFT JOIN user_affiliate_ledger ual
|
LEFT JOIN user_affiliate_ledger ual
|
||||||
ON ual.user_id = $1
|
ON ual.user_id = $1
|
||||||
AND ual.source_user_id = ua.user_id
|
AND ual.source_user_id = ua.user_id
|
||||||
AND ual.action = 'accrue'
|
AND ual.action IN ('accrue', 'signup_reward')
|
||||||
WHERE ua.inviter_id = $1
|
WHERE ua.inviter_id = $1
|
||||||
GROUP BY ua.user_id, u.email, u.username, ua.created_at
|
GROUP BY ua.user_id, u.email, u.username, ua.created_at
|
||||||
ORDER BY ua.created_at DESC
|
ORDER BY ua.created_at DESC
|
||||||
|
|||||||
@@ -306,6 +306,8 @@ func registerAccountRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
|
|||||||
accounts.GET("/:id/temp-unschedulable", h.Admin.Account.GetTempUnschedulable)
|
accounts.GET("/:id/temp-unschedulable", h.Admin.Account.GetTempUnschedulable)
|
||||||
accounts.DELETE("/:id/temp-unschedulable", h.Admin.Account.ClearTempUnschedulable)
|
accounts.DELETE("/:id/temp-unschedulable", h.Admin.Account.ClearTempUnschedulable)
|
||||||
accounts.POST("/:id/schedulable", h.Admin.Account.SetSchedulable)
|
accounts.POST("/:id/schedulable", h.Admin.Account.SetSchedulable)
|
||||||
|
accounts.GET("/:id/openai/upstream-models", h.Admin.Account.GetOpenAIUpstreamModels)
|
||||||
|
accounts.GET("/:id/kiro/upstream-models", h.Admin.Account.GetKiroUpstreamModels)
|
||||||
accounts.GET("/:id/models", h.Admin.Account.GetAvailableModels)
|
accounts.GET("/:id/models", h.Admin.Account.GetAvailableModels)
|
||||||
accounts.POST("/batch", h.Admin.Account.BatchCreate)
|
accounts.POST("/batch", h.Admin.Account.BatchCreate)
|
||||||
accounts.GET("/data", h.Admin.Account.ExportData)
|
accounts.GET("/data", h.Admin.Account.ExportData)
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ type AccountTestService struct {
|
|||||||
accountRepo AccountRepository
|
accountRepo AccountRepository
|
||||||
geminiTokenProvider *GeminiTokenProvider
|
geminiTokenProvider *GeminiTokenProvider
|
||||||
claudeTokenProvider *ClaudeTokenProvider
|
claudeTokenProvider *ClaudeTokenProvider
|
||||||
|
openAITokenProvider *OpenAITokenProvider
|
||||||
kiroTokenProvider *KiroTokenProvider
|
kiroTokenProvider *KiroTokenProvider
|
||||||
antigravityGatewayService *AntigravityGatewayService
|
antigravityGatewayService *AntigravityGatewayService
|
||||||
httpUpstream HTTPUpstream
|
httpUpstream HTTPUpstream
|
||||||
@@ -79,6 +80,7 @@ func NewAccountTestService(
|
|||||||
accountRepo AccountRepository,
|
accountRepo AccountRepository,
|
||||||
geminiTokenProvider *GeminiTokenProvider,
|
geminiTokenProvider *GeminiTokenProvider,
|
||||||
claudeTokenProvider *ClaudeTokenProvider,
|
claudeTokenProvider *ClaudeTokenProvider,
|
||||||
|
openAITokenProvider *OpenAITokenProvider,
|
||||||
kiroTokenProvider *KiroTokenProvider,
|
kiroTokenProvider *KiroTokenProvider,
|
||||||
antigravityGatewayService *AntigravityGatewayService,
|
antigravityGatewayService *AntigravityGatewayService,
|
||||||
httpUpstream HTTPUpstream,
|
httpUpstream HTTPUpstream,
|
||||||
@@ -89,6 +91,7 @@ func NewAccountTestService(
|
|||||||
accountRepo: accountRepo,
|
accountRepo: accountRepo,
|
||||||
geminiTokenProvider: geminiTokenProvider,
|
geminiTokenProvider: geminiTokenProvider,
|
||||||
claudeTokenProvider: claudeTokenProvider,
|
claudeTokenProvider: claudeTokenProvider,
|
||||||
|
openAITokenProvider: openAITokenProvider,
|
||||||
kiroTokenProvider: kiroTokenProvider,
|
kiroTokenProvider: kiroTokenProvider,
|
||||||
antigravityGatewayService: antigravityGatewayService,
|
antigravityGatewayService: antigravityGatewayService,
|
||||||
httpUpstream: httpUpstream,
|
httpUpstream: httpUpstream,
|
||||||
|
|||||||
@@ -0,0 +1,201 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/Wei-Shaw/sub2api/internal/pkg/httpclient"
|
||||||
|
kiropkg "github.com/Wei-Shaw/sub2api/internal/pkg/kiro"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
var ErrKiroModelListUnsupported = errors.New("kiro upstream model list requires an OAuth/access-token account")
|
||||||
|
|
||||||
|
type kiroAvailableModelsResponse struct {
|
||||||
|
AvailableModels []kiroAvailableModelItem `json:"availableModels"`
|
||||||
|
AvailableModelsSnake []kiroAvailableModelItem `json:"available_models"`
|
||||||
|
Models []kiroAvailableModelItem `json:"models"`
|
||||||
|
NextToken string `json:"nextToken"`
|
||||||
|
NextTokenSnake string `json:"next_token"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type kiroAvailableModelItem struct {
|
||||||
|
ModelID string `json:"modelId"`
|
||||||
|
ModelIDSnake string `json:"model_id"`
|
||||||
|
ID string `json:"id"`
|
||||||
|
ModelName string `json:"modelName"`
|
||||||
|
ModelNameSnake string `json:"model_name"`
|
||||||
|
DisplayName string `json:"displayName"`
|
||||||
|
DisplayNameSnake string `json:"display_name"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AccountTestService) FetchKiroUpstreamModels(ctx context.Context, account *Account) ([]kiropkg.Model, error) {
|
||||||
|
if account == nil {
|
||||||
|
return nil, errors.New("account is nil")
|
||||||
|
}
|
||||||
|
if account.Platform != PlatformKiro {
|
||||||
|
return nil, fmt.Errorf("not a kiro account")
|
||||||
|
}
|
||||||
|
|
||||||
|
token := strings.TrimSpace(account.GetCredential("access_token"))
|
||||||
|
if account.Type == AccountTypeOAuth {
|
||||||
|
if s == nil || s.kiroTokenProvider == nil {
|
||||||
|
return nil, errors.New("kiro token provider not configured")
|
||||||
|
}
|
||||||
|
accessToken, err := s.kiroTokenProvider.GetAccessToken(ctx, account)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("get kiro access token failed: %w", err)
|
||||||
|
}
|
||||||
|
token = strings.TrimSpace(accessToken)
|
||||||
|
}
|
||||||
|
if token == "" {
|
||||||
|
return nil, ErrKiroModelListUnsupported
|
||||||
|
}
|
||||||
|
|
||||||
|
return requestKiroAvailableModels(ctx, account, kiroAPIRegion(account), strings.TrimSpace(account.GetCredential("profile_arn")), token)
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestKiroAvailableModels(ctx context.Context, account *Account, region, profileArn, token string) ([]kiropkg.Model, error) {
|
||||||
|
endpoint := resolveKiroRuntimeEndpoint(region)
|
||||||
|
client, err := httpclient.GetClient(httpclient.Options{
|
||||||
|
ProxyURL: accountProxyURL(account),
|
||||||
|
Timeout: 30 * time.Second,
|
||||||
|
ValidateResolvedIP: true,
|
||||||
|
AllowPrivateHosts: isLoopbackEndpoint(endpoint),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create kiro model list client failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var all []kiroAvailableModelItem
|
||||||
|
nextToken := ""
|
||||||
|
for {
|
||||||
|
resp, err := requestKiroAvailableModelsPage(ctx, client, account, endpoint, profileArn, token, nextToken)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
all = append(all, resp.items()...)
|
||||||
|
|
||||||
|
nextToken = strings.TrimSpace(resp.NextToken)
|
||||||
|
if nextToken == "" {
|
||||||
|
nextToken = strings.TrimSpace(resp.NextTokenSnake)
|
||||||
|
}
|
||||||
|
if nextToken == "" {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return mapKiroAvailableModels(all), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestKiroAvailableModelsPage(ctx context.Context, client *http.Client, account *Account, endpoint, profileArn, token, nextToken string) (*kiroAvailableModelsResponse, error) {
|
||||||
|
reqURL, err := url.Parse(endpoint + "/ListAvailableModels")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("build kiro model list url failed: %w", err)
|
||||||
|
}
|
||||||
|
q := reqURL.Query()
|
||||||
|
q.Set("origin", kiroUsageOrigin)
|
||||||
|
q.Set("maxResults", "50")
|
||||||
|
if profileArn != "" {
|
||||||
|
q.Set("profileArn", profileArn)
|
||||||
|
}
|
||||||
|
if nextToken != "" {
|
||||||
|
q.Set("nextToken", nextToken)
|
||||||
|
}
|
||||||
|
reqURL.RawQuery = q.Encode()
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL.String(), nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create kiro model list request failed: %w", err)
|
||||||
|
}
|
||||||
|
applyKiroModelListHeaders(req, account, token)
|
||||||
|
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("kiro model list 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("read kiro model list response failed: %w", err)
|
||||||
|
}
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
return nil, &kiroUsageHTTPError{StatusCode: resp.StatusCode, Body: strings.TrimSpace(string(body))}
|
||||||
|
}
|
||||||
|
|
||||||
|
var parsed kiroAvailableModelsResponse
|
||||||
|
if err := json.Unmarshal(body, &parsed); err != nil {
|
||||||
|
return nil, fmt.Errorf("decode kiro model list response failed: %w", err)
|
||||||
|
}
|
||||||
|
return &parsed, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyKiroModelListHeaders(req *http.Request, account *Account, token string) {
|
||||||
|
if req == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
accountKey := buildKiroAccountKey(account)
|
||||||
|
machineID := buildKiroMachineID(account)
|
||||||
|
req.Header.Set("Accept", "*/*")
|
||||||
|
req.Header.Set("Authorization", "Bearer "+strings.TrimSpace(token))
|
||||||
|
req.Header.Set("User-Agent", kiropkg.BuildRuntimeUserAgent(accountKey, machineID))
|
||||||
|
req.Header.Set("X-Amz-User-Agent", kiropkg.BuildRuntimeAmzUserAgent(accountKey, machineID))
|
||||||
|
req.Header.Set("x-amzn-codewhisperer-optout", "true")
|
||||||
|
req.Header.Set("Amz-Sdk-Request", "attempt=1; max=3")
|
||||||
|
req.Header.Set("Amz-Sdk-Invocation-Id", uuid.NewString())
|
||||||
|
if account != nil {
|
||||||
|
applyKiroConditionalHeaders(req, account)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *kiroAvailableModelsResponse) items() []kiroAvailableModelItem {
|
||||||
|
if r == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case len(r.AvailableModels) > 0:
|
||||||
|
return r.AvailableModels
|
||||||
|
case len(r.AvailableModelsSnake) > 0:
|
||||||
|
return r.AvailableModelsSnake
|
||||||
|
default:
|
||||||
|
return r.Models
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapKiroAvailableModels(items []kiroAvailableModelItem) []kiropkg.Model {
|
||||||
|
seen := make(map[string]struct{}, len(items))
|
||||||
|
models := make([]kiropkg.Model, 0, len(items))
|
||||||
|
for _, item := range items {
|
||||||
|
id := firstNonEmptyKiroModelField(item.ModelID, item.ModelIDSnake, item.ID)
|
||||||
|
if id == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := seen[id]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[id] = struct{}{}
|
||||||
|
displayName := firstNonEmptyKiroModelField(item.ModelName, item.ModelNameSnake, item.DisplayName, item.DisplayNameSnake, item.Name, id)
|
||||||
|
models = append(models, kiropkg.Model{ID: id, Type: "model", DisplayName: displayName})
|
||||||
|
}
|
||||||
|
sort.Slice(models, func(i, j int) bool { return models[i].ID < models[j].ID })
|
||||||
|
return models
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstNonEmptyKiroModelField(values ...string) string {
|
||||||
|
for _, value := range values {
|
||||||
|
if trimmed := strings.TrimSpace(value); trimmed != "" {
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
@@ -96,6 +96,7 @@ type KiroRefreshTokenInput struct {
|
|||||||
type KiroImportTokenInput struct {
|
type KiroImportTokenInput struct {
|
||||||
TokenJSON string
|
TokenJSON string
|
||||||
DeviceRegistrationJSON string
|
DeviceRegistrationJSON string
|
||||||
|
ProxyID *int64
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *KiroOAuthService) GenerateAuthURL(ctx context.Context, input *KiroGenerateAuthURLInput) (*KiroAuthURLResult, error) {
|
func (s *KiroOAuthService) GenerateAuthURL(ctx context.Context, input *KiroGenerateAuthURLInput) (*KiroAuthURLResult, error) {
|
||||||
@@ -284,6 +285,28 @@ func (s *KiroOAuthService) RefreshAccountToken(ctx context.Context, account *Acc
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *KiroOAuthService) ImportToken(input *KiroImportTokenInput) (*KiroTokenInfo, error) {
|
func (s *KiroOAuthService) ImportToken(input *KiroImportTokenInput) (*KiroTokenInfo, error) {
|
||||||
|
tokenFromRefresh, refreshOnly, err := kiropkg.ParseImportedRefreshToken(input.TokenJSON)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if refreshOnly {
|
||||||
|
token, err := s.RefreshToken(context.Background(), &KiroRefreshTokenInput{
|
||||||
|
RefreshToken: tokenFromRefresh.RefreshToken,
|
||||||
|
AuthMethod: tokenFromRefresh.AuthMethod,
|
||||||
|
Provider: tokenFromRefresh.Provider,
|
||||||
|
ClientID: tokenFromRefresh.ClientID,
|
||||||
|
ClientSecret: tokenFromRefresh.ClientSecret,
|
||||||
|
StartURL: tokenFromRefresh.StartURL,
|
||||||
|
Region: tokenFromRefresh.Region,
|
||||||
|
ProfileArn: tokenFromRefresh.ProfileArn,
|
||||||
|
ProxyID: input.ProxyID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return token, nil
|
||||||
|
}
|
||||||
|
|
||||||
token, err := kiropkg.ParseImportedToken(input.TokenJSON, input.DeviceRegistrationJSON)
|
token, err := kiropkg.ParseImportedToken(input.TokenJSON, input.DeviceRegistrationJSON)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/Wei-Shaw/sub2api/internal/pkg/httpclient"
|
||||||
|
openaipkg "github.com/Wei-Shaw/sub2api/internal/pkg/openai"
|
||||||
|
)
|
||||||
|
|
||||||
|
var ErrOpenAIModelListUnsupported = errors.New("openai upstream model list requires an OAuth access token or API key")
|
||||||
|
|
||||||
|
type openAIModelsResponse struct {
|
||||||
|
Data []openaiModelListItem `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type openaiModelListItem struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Object string `json:"object"`
|
||||||
|
Created int64 `json:"created"`
|
||||||
|
OwnedBy string `json:"owned_by"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AccountTestService) FetchOpenAIUpstreamModels(ctx context.Context, account *Account) ([]openaipkg.Model, error) {
|
||||||
|
if account == nil {
|
||||||
|
return nil, errors.New("account is nil")
|
||||||
|
}
|
||||||
|
if account.Platform != PlatformOpenAI {
|
||||||
|
return nil, fmt.Errorf("not an openai account")
|
||||||
|
}
|
||||||
|
|
||||||
|
token, baseURL, err := s.resolveOpenAIModelListAuth(ctx, account)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return requestOpenAIAvailableModels(ctx, account, baseURL, token)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AccountTestService) resolveOpenAIModelListAuth(ctx context.Context, account *Account) (token, baseURL string, err error) {
|
||||||
|
if account.IsOpenAIOAuth() {
|
||||||
|
if s == nil || s.openAITokenProvider == nil {
|
||||||
|
token = strings.TrimSpace(account.GetOpenAIAccessToken())
|
||||||
|
} else {
|
||||||
|
token, err = s.openAITokenProvider.GetAccessToken(ctx, account)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", fmt.Errorf("get openai access token failed: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(token) == "" {
|
||||||
|
return "", "", ErrOpenAIModelListUnsupported
|
||||||
|
}
|
||||||
|
return token, "https://api.openai.com", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if account.IsOpenAIApiKey() {
|
||||||
|
token = strings.TrimSpace(account.GetOpenAIApiKey())
|
||||||
|
if token == "" {
|
||||||
|
return "", "", ErrOpenAIModelListUnsupported
|
||||||
|
}
|
||||||
|
baseURL = account.GetOpenAIBaseURL()
|
||||||
|
if baseURL == "" {
|
||||||
|
baseURL = "https://api.openai.com"
|
||||||
|
}
|
||||||
|
if s != nil {
|
||||||
|
normalized, err := s.validateUpstreamBaseURL(baseURL)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", fmt.Errorf("invalid base_url: %w", err)
|
||||||
|
}
|
||||||
|
baseURL = normalized
|
||||||
|
}
|
||||||
|
return token, baseURL, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", "", ErrOpenAIModelListUnsupported
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestOpenAIAvailableModels(ctx context.Context, account *Account, baseURL, token string) ([]openaipkg.Model, error) {
|
||||||
|
baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/")
|
||||||
|
if baseURL == "" {
|
||||||
|
baseURL = "https://api.openai.com"
|
||||||
|
}
|
||||||
|
modelsURL := baseURL
|
||||||
|
if !strings.HasSuffix(modelsURL, "/models") {
|
||||||
|
if strings.HasSuffix(modelsURL, "/v1") {
|
||||||
|
modelsURL += "/models"
|
||||||
|
} else {
|
||||||
|
modelsURL += "/v1/models"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, modelsURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create openai model list request failed: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
req.Header.Set("Authorization", "Bearer "+strings.TrimSpace(token))
|
||||||
|
if userAgent := strings.TrimSpace(account.GetOpenAIUserAgent()); userAgent != "" {
|
||||||
|
req.Header.Set("User-Agent", userAgent)
|
||||||
|
}
|
||||||
|
|
||||||
|
client, err := httpclient.GetClient(httpclient.Options{
|
||||||
|
ProxyURL: accountProxyURL(account),
|
||||||
|
Timeout: 30 * time.Second,
|
||||||
|
ValidateResolvedIP: true,
|
||||||
|
AllowPrivateHosts: isLoopbackEndpoint(modelsURL),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create openai model list client failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("openai model list 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("read openai model list response failed: %w", err)
|
||||||
|
}
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
return nil, &kiroUsageHTTPError{StatusCode: resp.StatusCode, Body: strings.TrimSpace(string(body))}
|
||||||
|
}
|
||||||
|
|
||||||
|
var parsed openAIModelsResponse
|
||||||
|
if err := json.Unmarshal(body, &parsed); err != nil {
|
||||||
|
return nil, fmt.Errorf("decode openai model list response failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
models := make([]openaipkg.Model, 0, len(parsed.Data))
|
||||||
|
for _, item := range parsed.Data {
|
||||||
|
id := strings.TrimSpace(item.ID)
|
||||||
|
if id == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
models = append(models, openaipkg.Model{
|
||||||
|
ID: id,
|
||||||
|
Object: firstNonEmptyOpenAIModelField(item.Object, "model"),
|
||||||
|
Created: item.Created,
|
||||||
|
OwnedBy: item.OwnedBy,
|
||||||
|
Type: "model",
|
||||||
|
DisplayName: id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return models, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstNonEmptyOpenAIModelField(values ...string) string {
|
||||||
|
for _, value := range values {
|
||||||
|
if trimmed := strings.TrimSpace(value); trimmed != "" {
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
@@ -227,6 +227,116 @@ func TestForwardAsChatCompletions_RequestErrorRetriesBeforeSuccess(t *testing.T)
|
|||||||
require.Contains(t, events[0].Message, "connection reset by peer")
|
require.Contains(t, events[0].Message, "connection reset by peer")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestForwardAsChatCompletions_ClosedNetworkConnectionRetriesBeforeSuccess(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
c, _ := gin.CreateTestContext(rec)
|
||||||
|
body := []byte(`{"model":"gpt-5.4","messages":[{"role":"user","content":"hello"}],"stream":false}`)
|
||||||
|
c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body))
|
||||||
|
c.Request.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
upstreamBody := strings.Join([]string{
|
||||||
|
`data: {"type":"response.completed","response":{"id":"resp_closed_network_retry","object":"response","model":"gpt-5.4","status":"completed","output":[{"type":"message","id":"msg_1","role":"assistant","status":"completed","content":[{"type":"output_text","text":"ok"}]}],"usage":{"input_tokens":3,"output_tokens":2,"total_tokens":5}}}`,
|
||||||
|
"",
|
||||||
|
"data: [DONE]",
|
||||||
|
"",
|
||||||
|
}, "\n")
|
||||||
|
upstream := &sequentialHTTPUpstreamRecorder{
|
||||||
|
errs: []error{
|
||||||
|
errors.New("Post \"https://chatgpt.com/backend-api/codex/responses\": use of closed network connection"),
|
||||||
|
nil,
|
||||||
|
},
|
||||||
|
responses: []*http.Response{{
|
||||||
|
StatusCode: http.StatusOK,
|
||||||
|
Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid_closed_network_retry"}},
|
||||||
|
Body: io.NopCloser(strings.NewReader(upstreamBody)),
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
|
||||||
|
svc := &OpenAIGatewayService{httpUpstream: upstream}
|
||||||
|
account := &Account{
|
||||||
|
ID: 1,
|
||||||
|
Name: "openai-oauth",
|
||||||
|
Platform: PlatformOpenAI,
|
||||||
|
Type: AccountTypeOAuth,
|
||||||
|
Concurrency: 1,
|
||||||
|
Credentials: map[string]any{
|
||||||
|
"access_token": "oauth-token",
|
||||||
|
"chatgpt_account_id": "chatgpt-acc",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := svc.ForwardAsChatCompletions(context.Background(), c, account, body, "", "gpt-5.4")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, result)
|
||||||
|
require.Equal(t, http.StatusOK, rec.Code)
|
||||||
|
require.Len(t, upstream.requests, 2)
|
||||||
|
|
||||||
|
rawEvents, ok := c.Get(OpsUpstreamErrorsKey)
|
||||||
|
require.True(t, ok)
|
||||||
|
events, ok := rawEvents.([]*OpsUpstreamErrorEvent)
|
||||||
|
require.True(t, ok)
|
||||||
|
require.Len(t, events, 1)
|
||||||
|
require.Equal(t, "request_error", events[0].Kind)
|
||||||
|
require.Contains(t, events[0].Message, "use of closed network connection")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestForwardAsChatCompletions_TLSBadRecordMACRetriesBeforeSuccess(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
c, _ := gin.CreateTestContext(rec)
|
||||||
|
body := []byte(`{"model":"gpt-5.4","messages":[{"role":"user","content":"hello"}],"stream":false}`)
|
||||||
|
c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body))
|
||||||
|
c.Request.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
upstreamBody := strings.Join([]string{
|
||||||
|
`data: {"type":"response.completed","response":{"id":"resp_tls_retry","object":"response","model":"gpt-5.4","status":"completed","output":[{"type":"message","id":"msg_1","role":"assistant","status":"completed","content":[{"type":"output_text","text":"ok"}]}],"usage":{"input_tokens":3,"output_tokens":2,"total_tokens":5}}}`,
|
||||||
|
"",
|
||||||
|
"data: [DONE]",
|
||||||
|
"",
|
||||||
|
}, "\n")
|
||||||
|
upstream := &sequentialHTTPUpstreamRecorder{
|
||||||
|
errs: []error{
|
||||||
|
errors.New("Post \"https://chatgpt.com/backend-api/codex/responses\": local error: tls: bad record MAC"),
|
||||||
|
nil,
|
||||||
|
},
|
||||||
|
responses: []*http.Response{{
|
||||||
|
StatusCode: http.StatusOK,
|
||||||
|
Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid_tls_retry"}},
|
||||||
|
Body: io.NopCloser(strings.NewReader(upstreamBody)),
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
|
||||||
|
svc := &OpenAIGatewayService{httpUpstream: upstream}
|
||||||
|
account := &Account{
|
||||||
|
ID: 1,
|
||||||
|
Name: "openai-oauth",
|
||||||
|
Platform: PlatformOpenAI,
|
||||||
|
Type: AccountTypeOAuth,
|
||||||
|
Concurrency: 1,
|
||||||
|
Credentials: map[string]any{
|
||||||
|
"access_token": "oauth-token",
|
||||||
|
"chatgpt_account_id": "chatgpt-acc",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := svc.ForwardAsChatCompletions(context.Background(), c, account, body, "", "gpt-5.4")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, result)
|
||||||
|
require.Equal(t, http.StatusOK, rec.Code)
|
||||||
|
require.Len(t, upstream.requests, 2)
|
||||||
|
|
||||||
|
rawEvents, ok := c.Get(OpsUpstreamErrorsKey)
|
||||||
|
require.True(t, ok)
|
||||||
|
events, ok := rawEvents.([]*OpsUpstreamErrorEvent)
|
||||||
|
require.True(t, ok)
|
||||||
|
require.Len(t, events, 1)
|
||||||
|
require.Equal(t, "request_error", events[0].Kind)
|
||||||
|
require.Contains(t, strings.ToLower(events[0].Message), "tls: bad record mac")
|
||||||
|
}
|
||||||
|
|
||||||
func TestForwardAsChatCompletions_RequestErrorExhaustionReturnsFailover(t *testing.T) {
|
func TestForwardAsChatCompletions_RequestErrorExhaustionReturnsFailover(t *testing.T) {
|
||||||
gin.SetMode(gin.TestMode)
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
|||||||
@@ -121,8 +121,10 @@ func isRetryableOpenAIHTTPRequestError(err error) bool {
|
|||||||
"connection refused",
|
"connection refused",
|
||||||
"unexpected eof",
|
"unexpected eof",
|
||||||
"server closed idle connection",
|
"server closed idle connection",
|
||||||
|
"use of closed network connection",
|
||||||
"broken pipe",
|
"broken pipe",
|
||||||
"connection aborted",
|
"connection aborted",
|
||||||
|
"tls: bad record mac",
|
||||||
"tls: use of closed connection",
|
"tls: use of closed connection",
|
||||||
"http2: client connection lost",
|
"http2: client connection lost",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -446,6 +446,26 @@ export async function getAvailableModels(id: number): Promise<ClaudeModel[]> {
|
|||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get Kiro models from the upstream Kiro ListAvailableModels API using the account proxy.
|
||||||
|
* @param id - Account ID
|
||||||
|
* @returns List of upstream Kiro models
|
||||||
|
*/
|
||||||
|
export async function getKiroUpstreamModels(id: number): Promise<ClaudeModel[]> {
|
||||||
|
const { data } = await apiClient.get<ClaudeModel[]>(`/admin/accounts/${id}/kiro/upstream-models`)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get OpenAI models from the upstream /v1/models API using the account proxy.
|
||||||
|
* @param id - Account ID
|
||||||
|
* @returns List of upstream OpenAI models
|
||||||
|
*/
|
||||||
|
export async function getOpenAIUpstreamModels(id: number): Promise<ClaudeModel[]> {
|
||||||
|
const { data } = await apiClient.get<ClaudeModel[]>(`/admin/accounts/${id}/openai/upstream-models`)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
export interface CRSPreviewAccount {
|
export interface CRSPreviewAccount {
|
||||||
crs_account_id: string
|
crs_account_id: string
|
||||||
kind: string
|
kind: string
|
||||||
@@ -667,6 +687,8 @@ export const accountsAPI = {
|
|||||||
resetTempUnschedulable,
|
resetTempUnschedulable,
|
||||||
setSchedulable,
|
setSchedulable,
|
||||||
getAvailableModels,
|
getAvailableModels,
|
||||||
|
getOpenAIUpstreamModels,
|
||||||
|
getKiroUpstreamModels,
|
||||||
generateAuthUrl,
|
generateAuthUrl,
|
||||||
exchangeCode,
|
exchangeCode,
|
||||||
refreshOpenAIToken,
|
refreshOpenAIToken,
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ export async function refreshToken(payload: {
|
|||||||
export async function importToken(payload: {
|
export async function importToken(payload: {
|
||||||
token_json: string
|
token_json: string
|
||||||
device_registration_json?: string
|
device_registration_json?: string
|
||||||
|
proxy_id?: number
|
||||||
}): Promise<KiroTokenInfo> {
|
}): Promise<KiroTokenInfo> {
|
||||||
const { data } = await apiClient.post<KiroTokenInfo>('/admin/kiro/oauth/import-token', payload)
|
const { data } = await apiClient.post<KiroTokenInfo>('/admin/kiro/oauth/import-token', payload)
|
||||||
return data
|
return data
|
||||||
|
|||||||
@@ -3217,7 +3217,7 @@
|
|||||||
<div v-if="isKiroImportMode" class="space-y-4 rounded-lg border border-amber-200 bg-amber-50 p-4 dark:border-amber-700 dark:bg-amber-900/20">
|
<div v-if="isKiroImportMode" class="space-y-4 rounded-lg border border-amber-200 bg-amber-50 p-4 dark:border-amber-700 dark:bg-amber-900/20">
|
||||||
<div>
|
<div>
|
||||||
<label class="input-label">{{ t('admin.accounts.oauth.kiro.tokenJsonLabel') }}</label>
|
<label class="input-label">{{ t('admin.accounts.oauth.kiro.tokenJsonLabel') }}</label>
|
||||||
<textarea v-model="kiroTokenJson" rows="8" class="input font-mono text-xs" placeholder='{"accessToken":"...","refreshToken":"..."}'></textarea>
|
<textarea v-model="kiroTokenJson" rows="8" class="input font-mono text-xs" placeholder='{"refreshToken":"...","provider":"Google"}'></textarea>
|
||||||
<p class="input-hint">{{ t('admin.accounts.oauth.kiro.tokenJsonHint') }}</p>
|
<p class="input-hint">{{ t('admin.accounts.oauth.kiro.tokenJsonHint') }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -5811,7 +5811,8 @@ const handleKiroImport = async () => {
|
|||||||
|
|
||||||
const tokenInfo = await kiroOAuth.importToken(
|
const tokenInfo = await kiroOAuth.importToken(
|
||||||
kiroTokenJson.value,
|
kiroTokenJson.value,
|
||||||
kiroDeviceRegistrationJson.value || undefined
|
kiroDeviceRegistrationJson.value || undefined,
|
||||||
|
form.proxy_id
|
||||||
)
|
)
|
||||||
if (!tokenInfo) return
|
if (!tokenInfo) return
|
||||||
|
|
||||||
|
|||||||
@@ -74,7 +74,18 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="account.platform === 'kiro'" class="border-t border-gray-200 pt-4 dark:border-dark-600">
|
<div v-if="account.platform === 'kiro'" class="border-t border-gray-200 pt-4 dark:border-dark-600">
|
||||||
<label class="input-label">{{ t('admin.accounts.modelRestriction') }}</label>
|
<div class="mb-2 flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<label class="input-label mb-0">{{ t('admin.accounts.modelRestriction') }}</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-secondary btn-sm"
|
||||||
|
:disabled="kiroModelListLoading"
|
||||||
|
@click="handleFetchKiroModelMappings"
|
||||||
|
>
|
||||||
|
<Icon name="refresh" size="sm" :class="kiroModelListLoading ? 'animate-spin' : ''" />
|
||||||
|
{{ kiroModelListLoading ? t('common.loading') : t('admin.accounts.fetchModelList') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="mb-3 rounded-lg bg-purple-50 p-3 dark:bg-purple-900/20">
|
<div class="mb-3 rounded-lg bg-purple-50 p-3 dark:bg-purple-900/20">
|
||||||
<p class="text-xs text-purple-700 dark:text-purple-400">
|
<p class="text-xs text-purple-700 dark:text-purple-400">
|
||||||
@@ -160,7 +171,19 @@
|
|||||||
|
|
||||||
<!-- Model Restriction Section (不适用于 Antigravity / Kiro) -->
|
<!-- Model Restriction Section (不适用于 Antigravity / Kiro) -->
|
||||||
<div v-else-if="account.platform !== 'antigravity'" class="border-t border-gray-200 pt-4 dark:border-dark-600">
|
<div v-else-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 class="mb-2 flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<label class="input-label mb-0">{{ t('admin.accounts.modelRestriction') }}</label>
|
||||||
|
<button
|
||||||
|
v-if="account.platform === 'openai'"
|
||||||
|
type="button"
|
||||||
|
class="btn btn-secondary btn-sm"
|
||||||
|
:disabled="openAIModelListLoading || isOpenAIModelRestrictionDisabled"
|
||||||
|
@click="handleFetchOpenAIModels"
|
||||||
|
>
|
||||||
|
<Icon name="refresh" size="sm" :class="openAIModelListLoading ? 'animate-spin' : ''" />
|
||||||
|
{{ openAIModelListLoading ? t('common.loading') : t('admin.accounts.fetchUpstreamModelList') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="isOpenAIModelRestrictionDisabled"
|
v-if="isOpenAIModelRestrictionDisabled"
|
||||||
@@ -501,7 +524,29 @@
|
|||||||
v-if="(account.platform === 'openai' || account.platform === 'kiro') && account.type === 'oauth'"
|
v-if="(account.platform === 'openai' || account.platform === 'kiro') && account.type === 'oauth'"
|
||||||
class="border-t border-gray-200 pt-4 dark:border-dark-600"
|
class="border-t border-gray-200 pt-4 dark:border-dark-600"
|
||||||
>
|
>
|
||||||
<label class="input-label">{{ t('admin.accounts.modelRestriction') }}</label>
|
<div class="mb-2 flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<label class="input-label mb-0">{{ t('admin.accounts.modelRestriction') }}</label>
|
||||||
|
<button
|
||||||
|
v-if="account.platform === 'openai'"
|
||||||
|
type="button"
|
||||||
|
class="btn btn-secondary btn-sm"
|
||||||
|
:disabled="openAIModelListLoading || isOpenAIModelRestrictionDisabled"
|
||||||
|
@click="handleFetchOpenAIModels"
|
||||||
|
>
|
||||||
|
<Icon name="refresh" size="sm" :class="openAIModelListLoading ? 'animate-spin' : ''" />
|
||||||
|
{{ openAIModelListLoading ? t('common.loading') : t('admin.accounts.fetchUpstreamModelList') }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="account.platform === 'kiro'"
|
||||||
|
type="button"
|
||||||
|
class="btn btn-secondary btn-sm"
|
||||||
|
:disabled="kiroModelListLoading"
|
||||||
|
@click="handleFetchKiroModelMappings"
|
||||||
|
>
|
||||||
|
<Icon name="refresh" size="sm" :class="kiroModelListLoading ? 'animate-spin' : ''" />
|
||||||
|
{{ kiroModelListLoading ? t('common.loading') : t('admin.accounts.fetchModelList') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="isOpenAIModelRestrictionDisabled"
|
v-if="isOpenAIModelRestrictionDisabled"
|
||||||
@@ -2442,6 +2487,8 @@ const modelMappings = ref<ModelMapping[]>([])
|
|||||||
const openAICompactModelMappings = ref<ModelMapping[]>([])
|
const openAICompactModelMappings = ref<ModelMapping[]>([])
|
||||||
const modelRestrictionMode = ref<'whitelist' | 'mapping'>('whitelist')
|
const modelRestrictionMode = ref<'whitelist' | 'mapping'>('whitelist')
|
||||||
const allowedModels = ref<string[]>([])
|
const allowedModels = ref<string[]>([])
|
||||||
|
const openAIModelListLoading = ref(false)
|
||||||
|
const kiroModelListLoading = ref(false)
|
||||||
const DEFAULT_POOL_MODE_RETRY_COUNT = 3
|
const DEFAULT_POOL_MODE_RETRY_COUNT = 3
|
||||||
const MAX_POOL_MODE_RETRY_COUNT = 10
|
const MAX_POOL_MODE_RETRY_COUNT = 10
|
||||||
const poolModeEnabled = ref(false)
|
const poolModeEnabled = ref(false)
|
||||||
@@ -2478,6 +2525,75 @@ const loadDefaultKiroModelMappings = () => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const extractModelID = (model: unknown): string => {
|
||||||
|
if (typeof model === 'string') return model.trim()
|
||||||
|
if (!model || typeof model !== 'object') return ''
|
||||||
|
|
||||||
|
const record = model as Record<string, unknown>
|
||||||
|
const rawID = record.modelId ?? record.model_id ?? record.id ?? record.name ?? record.model
|
||||||
|
return typeof rawID === 'string' ? rawID.trim() : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleFetchKiroModelMappings = async () => {
|
||||||
|
if (!props.account || props.account.platform !== 'kiro') return
|
||||||
|
|
||||||
|
kiroModelListLoading.value = true
|
||||||
|
try {
|
||||||
|
const models = await adminAPI.accounts.getKiroUpstreamModels(props.account.id)
|
||||||
|
const modelIDs = Array.from(
|
||||||
|
new Set(
|
||||||
|
models
|
||||||
|
.map(extractModelID)
|
||||||
|
.filter((id) => id.length > 0)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if (modelIDs.length === 0) {
|
||||||
|
appStore.showError(t('admin.accounts.noModelsFetched'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
modelRestrictionMode.value = 'mapping'
|
||||||
|
modelMappings.value = modelIDs.map((model) => ({ from: model, to: model }))
|
||||||
|
allowedModels.value = []
|
||||||
|
appStore.showSuccess(t('admin.accounts.modelListApplied', { count: modelIDs.length }))
|
||||||
|
} catch (error: any) {
|
||||||
|
appStore.showError(error.response?.data?.message || error.response?.data?.detail || t('admin.accounts.failedToFetchModelList'))
|
||||||
|
} finally {
|
||||||
|
kiroModelListLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleFetchOpenAIModels = async () => {
|
||||||
|
if (!props.account || props.account.platform !== 'openai') return
|
||||||
|
|
||||||
|
openAIModelListLoading.value = true
|
||||||
|
try {
|
||||||
|
const models = await adminAPI.accounts.getOpenAIUpstreamModels(props.account.id)
|
||||||
|
const modelIDs = Array.from(
|
||||||
|
new Set(
|
||||||
|
models
|
||||||
|
.map(extractModelID)
|
||||||
|
.filter((id) => id.length > 0)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if (modelIDs.length === 0) {
|
||||||
|
appStore.showError(t('admin.accounts.noModelsFetched'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
modelRestrictionMode.value = 'whitelist'
|
||||||
|
allowedModels.value = modelIDs
|
||||||
|
modelMappings.value = []
|
||||||
|
appStore.showSuccess(t('admin.accounts.modelListApplied', { count: modelIDs.length }))
|
||||||
|
} catch (error: any) {
|
||||||
|
appStore.showError(error.response?.data?.message || error.response?.data?.detail || t('admin.accounts.failedToFetchModelList'))
|
||||||
|
} finally {
|
||||||
|
openAIModelListLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const showMixedChannelWarning = ref(false)
|
const showMixedChannelWarning = ref(false)
|
||||||
const mixedChannelWarningDetails = ref<{ groupName: string; currentPlatform: string; otherPlatform: string } | null>(
|
const mixedChannelWarningDetails = ref<{ groupName: string; currentPlatform: string; otherPlatform: string } | null>(
|
||||||
null
|
null
|
||||||
|
|||||||
@@ -775,7 +775,8 @@ const handleKiroImport = async () => {
|
|||||||
|
|
||||||
const tokenInfo = await kiroOAuth.importToken(
|
const tokenInfo = await kiroOAuth.importToken(
|
||||||
kiroTokenJson.value,
|
kiroTokenJson.value,
|
||||||
kiroDeviceRegistrationJson.value || undefined
|
kiroDeviceRegistrationJson.value || undefined,
|
||||||
|
props.account.proxy_id
|
||||||
)
|
)
|
||||||
if (!tokenInfo) return
|
if (!tokenInfo) return
|
||||||
|
|
||||||
|
|||||||
@@ -141,14 +141,16 @@ export function useKiroOAuth() {
|
|||||||
|
|
||||||
const importToken = async (
|
const importToken = async (
|
||||||
tokenJSON: string,
|
tokenJSON: string,
|
||||||
deviceRegistrationJSON?: string
|
deviceRegistrationJSON?: string,
|
||||||
|
proxyId?: number | null
|
||||||
): Promise<KiroTokenInfo | null> => {
|
): Promise<KiroTokenInfo | null> => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = ''
|
error.value = ''
|
||||||
try {
|
try {
|
||||||
return await adminAPI.kiro.importToken({
|
return await adminAPI.kiro.importToken({
|
||||||
token_json: tokenJSON,
|
token_json: tokenJSON,
|
||||||
device_registration_json: deviceRegistrationJSON
|
device_registration_json: deviceRegistrationJSON,
|
||||||
|
proxy_id: proxyId || undefined
|
||||||
})
|
})
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
error.value = err.response?.data?.detail || t('admin.accounts.oauth.authFailed')
|
error.value = err.response?.data?.detail || t('admin.accounts.oauth.authFailed')
|
||||||
|
|||||||
@@ -1028,7 +1028,7 @@ export default {
|
|||||||
columns: {
|
columns: {
|
||||||
email: 'Email',
|
email: 'Email',
|
||||||
username: 'Username',
|
username: 'Username',
|
||||||
rebate: 'Rebate',
|
rebate: 'Total Earnings',
|
||||||
joinedAt: 'Joined At'
|
joinedAt: 'Joined At'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -3234,6 +3234,11 @@ export default {
|
|||||||
requestModel: 'Request model',
|
requestModel: 'Request model',
|
||||||
actualModel: 'Actual model',
|
actualModel: 'Actual model',
|
||||||
addMapping: 'Add Mapping',
|
addMapping: 'Add Mapping',
|
||||||
|
fetchModelList: 'Fetch Models',
|
||||||
|
fetchUpstreamModelList: 'Fetch Upstream Models',
|
||||||
|
modelListApplied: 'Replaced with {count} model(s)',
|
||||||
|
noModelsFetched: 'No available models were returned',
|
||||||
|
failedToFetchModelList: 'Failed to fetch model list',
|
||||||
mappingExists: 'Mapping for {model} already exists',
|
mappingExists: 'Mapping for {model} already exists',
|
||||||
wildcardOnlyAtEnd: 'Wildcard * can only be at the end',
|
wildcardOnlyAtEnd: 'Wildcard * can only be at the end',
|
||||||
targetNoWildcard: 'Target model cannot contain wildcard *',
|
targetNoWildcard: 'Target model cannot contain wildcard *',
|
||||||
@@ -3643,7 +3648,7 @@ export default {
|
|||||||
regionLabel: 'Region',
|
regionLabel: 'Region',
|
||||||
regionPlaceholder: 'us-east-1',
|
regionPlaceholder: 'us-east-1',
|
||||||
tokenJsonLabel: 'Kiro Token JSON',
|
tokenJsonLabel: 'Kiro Token JSON',
|
||||||
tokenJsonHint: 'Sign in through Kiro IDE first, then paste the contents of `~/.aws/sso/cache/kiro-auth-token.json` here.',
|
tokenJsonHint: 'Supports full Kiro token JSON, or a refresh-token-only payload with refreshToken + provider. The server will refresh it into usable credentials.',
|
||||||
deviceRegistrationLabel: 'Device Registration JSON',
|
deviceRegistrationLabel: 'Device Registration JSON',
|
||||||
deviceRegistrationHint: 'Optional. Only needed when the token file does not include full client details and only has `clientIdHash`.',
|
deviceRegistrationHint: 'Optional. Only needed when the token file does not include full client details and only has `clientIdHash`.',
|
||||||
importAndUpdate: 'Import and Update'
|
importAndUpdate: 'Import and Update'
|
||||||
|
|||||||
@@ -1032,7 +1032,7 @@ export default {
|
|||||||
columns: {
|
columns: {
|
||||||
email: '邮箱',
|
email: '邮箱',
|
||||||
username: '用户名',
|
username: '用户名',
|
||||||
rebate: '返利明细',
|
rebate: '累计收益',
|
||||||
joinedAt: '注册时间'
|
joinedAt: '注册时间'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -3391,6 +3391,11 @@ export default {
|
|||||||
requestModel: '请求模型',
|
requestModel: '请求模型',
|
||||||
actualModel: '实际模型',
|
actualModel: '实际模型',
|
||||||
addMapping: '添加映射',
|
addMapping: '添加映射',
|
||||||
|
fetchModelList: '获取模型列表',
|
||||||
|
fetchUpstreamModelList: '从上游获取模型',
|
||||||
|
modelListApplied: '已覆盖为 {count} 个模型',
|
||||||
|
noModelsFetched: '未获取到可用模型',
|
||||||
|
failedToFetchModelList: '获取模型列表失败',
|
||||||
mappingExists: '模型 {model} 的映射已存在',
|
mappingExists: '模型 {model} 的映射已存在',
|
||||||
wildcardOnlyAtEnd: '通配符 * 只能放在末尾',
|
wildcardOnlyAtEnd: '通配符 * 只能放在末尾',
|
||||||
targetNoWildcard: '目标模型不能包含通配符 *',
|
targetNoWildcard: '目标模型不能包含通配符 *',
|
||||||
@@ -3787,7 +3792,7 @@ export default {
|
|||||||
regionLabel: 'Region',
|
regionLabel: 'Region',
|
||||||
regionPlaceholder: 'us-east-1',
|
regionPlaceholder: 'us-east-1',
|
||||||
tokenJsonLabel: 'Kiro Token JSON',
|
tokenJsonLabel: 'Kiro Token JSON',
|
||||||
tokenJsonHint: '先在 Kiro IDE 完成登录,再粘贴 `~/.aws/sso/cache/kiro-auth-token.json` 的内容。',
|
tokenJsonHint: '支持完整 Kiro token JSON,也支持仅粘贴 refreshToken + provider 格式,系统会自动刷新为可用凭据。',
|
||||||
deviceRegistrationLabel: 'Device Registration JSON',
|
deviceRegistrationLabel: 'Device Registration JSON',
|
||||||
deviceRegistrationHint: '可选。只有 token 文件里缺少完整客户端信息、只剩 `clientIdHash` 时才需要补充。',
|
deviceRegistrationHint: '可选。只有 token 文件里缺少完整客户端信息、只剩 `clientIdHash` 时才需要补充。',
|
||||||
importAndUpdate: '导入并更新'
|
importAndUpdate: '导入并更新'
|
||||||
|
|||||||
Reference in New Issue
Block a user