Merge remote-tracking branch 'pr/2131' into release/v0.1.133

# Conflicts:
#	backend/cmd/server/wire_gen.go
#	backend/internal/config/config.go
#	backend/internal/service/gateway_service.go
#	backend/internal/service/pricing_service.go
#	backend/internal/service/wire.go
#	deploy/config.example.yaml
#	frontend/src/views/admin/AccountsView.vue
This commit is contained in:
kone
2026-05-16 01:55:39 +08:00
111 changed files with 16343 additions and 439 deletions
@@ -22,6 +22,7 @@ import (
"github.com/Wei-Shaw/sub2api/internal/pkg/claude"
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
"github.com/Wei-Shaw/sub2api/internal/pkg/geminicli"
kiropkg "github.com/Wei-Shaw/sub2api/internal/pkg/kiro"
"github.com/Wei-Shaw/sub2api/internal/pkg/openai"
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
"github.com/Wei-Shaw/sub2api/internal/pkg/timezone"
@@ -179,6 +180,9 @@ type AccountWithConcurrency struct {
const accountListGroupUngroupedQueryValue = "ungrouped"
func (h *AccountHandler) buildAccountResponseWithRuntime(ctx context.Context, account *service.Account) AccountWithConcurrency {
if h.accountUsageService != nil {
h.accountUsageService.EnrichAccountWithKiroRuntimeState(ctx, account)
}
item := AccountWithConcurrency{
Account: dto.AccountFromService(account),
CurrentConcurrency: 0,
@@ -351,6 +355,9 @@ func (h *AccountHandler) List(c *gin.Context) {
result := make([]AccountWithConcurrency, len(accounts))
for i := range accounts {
acc := &accounts[i]
if h.accountUsageService != nil {
h.accountUsageService.EnrichAccountWithKiroRuntimeState(c.Request.Context(), acc)
}
item := AccountWithConcurrency{
Account: dto.AccountFromService(acc),
CurrentConcurrency: concurrencyCounts[acc.ID],
@@ -1953,6 +1960,18 @@ func (h *AccountHandler) GetAvailableModels(c *gin.Context) {
return
}
// Handle Kiro accounts
if account.Platform == service.PlatformKiro {
mapping := account.GetModelMapping()
if len(mapping) == 0 {
response.Success(c, kiropkg.DefaultModels)
return
}
response.Success(c, buildMappedKiroModels(mapping))
return
}
// Handle Claude/Anthropic accounts
// For OAuth and Setup-Token accounts: return default models
if account.IsOAuth() {
@@ -1994,6 +2013,28 @@ func (h *AccountHandler) GetAvailableModels(c *gin.Context) {
response.Success(c, models)
}
func buildMappedKiroModels(mapping map[string]string) []kiropkg.Model {
models := make([]kiropkg.Model, 0, len(mapping))
for requestedModel := range mapping {
var found bool
for _, dm := range kiropkg.DefaultModels {
if dm.ID == requestedModel {
models = append(models, dm)
found = true
break
}
}
if !found {
models = append(models, kiropkg.Model{
ID: requestedModel,
Type: "model",
DisplayName: requestedModel,
})
}
}
return models
}
// SetPrivacy handles setting privacy for a single OpenAI/Antigravity OAuth account
// POST /api/v1/admin/accounts/:id/set-privacy
func (h *AccountHandler) SetPrivacy(c *gin.Context) {
@@ -2206,6 +2247,12 @@ func (h *AccountHandler) GetAntigravityDefaultModelMapping(c *gin.Context) {
response.Success(c, domain.DefaultAntigravityModelMapping)
}
// GetKiroDefaultModelMapping 获取 Kiro 平台的默认模型映射
// GET /api/v1/admin/accounts/kiro/default-model-mapping
func (h *AccountHandler) GetKiroDefaultModelMapping(c *gin.Context) {
response.Success(c, domain.DefaultKiroModelMapping)
}
// sanitizeExtraBaseRPM 对 extra map 中的 base_rpm 值进行范围校验和归一化。
// 负值归零,超过 10000 截断为 10000。extra 为 nil 或不含 base_rpm 时无操作。
func sanitizeExtraBaseRPM(extra map[string]any) {
@@ -5,6 +5,7 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
"slices"
"testing"
"github.com/Wei-Shaw/sub2api/internal/service"
@@ -103,3 +104,156 @@ func TestAccountHandlerGetAvailableModels_OpenAIOAuthPassthroughFallsBackToDefau
require.NotEmpty(t, resp.Data)
require.NotEqual(t, "gpt-5", resp.Data[0].ID)
}
func TestAccountHandlerGetAvailableModels_KiroOAuthFallsBackToDefaults(t *testing.T) {
svc := &availableModelsAdminService{
stubAdminService: newStubAdminService(),
account: service.Account{
ID: 44,
Name: "kiro-oauth",
Platform: service.PlatformKiro,
Type: service.AccountTypeOAuth,
Status: service.StatusActive,
},
}
router := setupAvailableModelsRouter(svc)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/accounts/44/models", nil)
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
var resp struct {
Data []struct {
ID string `json:"id"`
} `json:"data"`
}
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
require.NotEmpty(t, resp.Data)
ids := make([]string, 0, len(resp.Data))
for _, model := range resp.Data {
ids = append(ids, model.ID)
}
require.True(t, slices.Contains(ids, "claude-opus-4-6"))
require.False(t, slices.Contains(ids, "claude-opus-4-7"))
require.False(t, slices.Contains(ids, "kiro-claude-opus-4-7"))
}
func TestAccountHandlerGetAvailableModels_KiroOAuthUsesExplicitModelMapping(t *testing.T) {
svc := &availableModelsAdminService{
stubAdminService: newStubAdminService(),
account: service.Account{
ID: 47,
Name: "kiro-oauth-mapped",
Platform: service.PlatformKiro,
Type: service.AccountTypeOAuth,
Status: service.StatusActive,
Credentials: map[string]any{
"model_mapping": map[string]any{
"claude-sonnet-4-6": "claude-sonnet-4.6",
"custom-model": "custom-upstream-model",
},
},
},
}
router := setupAvailableModelsRouter(svc)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/accounts/47/models", nil)
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
var resp struct {
Data []struct {
ID string `json:"id"`
} `json:"data"`
}
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
require.Len(t, resp.Data, 2)
ids := make([]string, 0, len(resp.Data))
for _, model := range resp.Data {
ids = append(ids, model.ID)
}
require.True(t, slices.Contains(ids, "claude-sonnet-4-6"))
require.True(t, slices.Contains(ids, "custom-model"))
require.False(t, slices.Contains(ids, "claude-opus-4-7"))
}
func TestAccountHandlerGetAvailableModels_KiroAPIKeyUsesExplicitModelMapping(t *testing.T) {
svc := &availableModelsAdminService{
stubAdminService: newStubAdminService(),
account: service.Account{
ID: 45,
Name: "kiro-apikey",
Platform: service.PlatformKiro,
Type: service.AccountTypeAPIKey,
Status: service.StatusActive,
Credentials: map[string]any{
"model_mapping": map[string]any{
"claude-sonnet-4-6": "claude-sonnet-4.6",
"custom-model": "custom-upstream-model",
},
},
},
}
router := setupAvailableModelsRouter(svc)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/accounts/45/models", nil)
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
var resp struct {
Data []struct {
ID string `json:"id"`
} `json:"data"`
}
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
require.Len(t, resp.Data, 2)
ids := make([]string, 0, len(resp.Data))
for _, model := range resp.Data {
ids = append(ids, model.ID)
}
require.True(t, slices.Contains(ids, "claude-sonnet-4-6"))
require.True(t, slices.Contains(ids, "custom-model"))
}
func TestAccountHandlerGetAvailableModels_KiroAPIKeyWithoutMappingFallsBackToDefaults(t *testing.T) {
svc := &availableModelsAdminService{
stubAdminService: newStubAdminService(),
account: service.Account{
ID: 46,
Name: "kiro-apikey-defaults",
Platform: service.PlatformKiro,
Type: service.AccountTypeAPIKey,
Status: service.StatusActive,
},
}
router := setupAvailableModelsRouter(svc)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/accounts/46/models", nil)
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
var resp struct {
Data []struct {
ID string `json:"id"`
} `json:"data"`
}
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
require.NotEmpty(t, resp.Data)
ids := make([]string, 0, len(resp.Data))
for _, model := range resp.Data {
ids = append(ids, model.ID)
}
require.True(t, slices.Contains(ids, "claude-opus-4-6"))
require.False(t, slices.Contains(ids, "claude-opus-4-7"))
require.False(t, slices.Contains(ids, "kiro-claude-opus-4-7"))
}
@@ -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 kiro"`
RateMultiplier float64 `json:"rate_multiplier"`
IsExclusive bool `json:"is_exclusive"`
SubscriptionType string `json:"subscription_type" binding:"omitempty,oneof=standard subscription"`
@@ -123,7 +123,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 kiro"`
RateMultiplier *float64 `json:"rate_multiplier"`
IsExclusive *bool `json:"is_exclusive"`
Status string `json:"status" binding:"omitempty,oneof=active inactive"`
@@ -0,0 +1,16 @@
package admin
import (
"testing"
"github.com/gin-gonic/gin/binding"
"github.com/stretchr/testify/require"
)
func TestGroupRequestValidationAcceptsKiroPlatform(t *testing.T) {
createReq := CreateGroupRequest{Name: "kiro-default", Platform: "kiro"}
require.NoError(t, binding.Validator.ValidateStruct(createReq))
updateReq := UpdateGroupRequest{Platform: "kiro"}
require.NoError(t, binding.Validator.ValidateStruct(updateReq))
}
@@ -0,0 +1,149 @@
package admin
import (
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
)
type KiroOAuthHandler struct {
kiroOAuthService *service.KiroOAuthService
}
func NewKiroOAuthHandler(kiroOAuthService *service.KiroOAuthService) *KiroOAuthHandler {
return &KiroOAuthHandler{kiroOAuthService: kiroOAuthService}
}
type KiroGenerateAuthURLRequest struct {
ProxyID *int64 `json:"proxy_id"`
Provider string `json:"provider"`
}
func (h *KiroOAuthHandler) GenerateAuthURL(c *gin.Context) {
var req KiroGenerateAuthURLRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "请求无效: "+err.Error())
return
}
result, err := h.kiroOAuthService.GenerateAuthURL(c.Request.Context(), &service.KiroGenerateAuthURLInput{
ProxyID: req.ProxyID,
Provider: req.Provider,
})
if err != nil {
response.BadRequest(c, "生成授权链接失败: "+err.Error())
return
}
response.Success(c, result)
}
type KiroGenerateIDCAuthURLRequest struct {
ProxyID *int64 `json:"proxy_id"`
StartURL string `json:"start_url"`
Region string `json:"region"`
}
func (h *KiroOAuthHandler) GenerateIDCAuthURL(c *gin.Context) {
var req KiroGenerateIDCAuthURLRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "请求无效: "+err.Error())
return
}
result, err := h.kiroOAuthService.GenerateIDCAuthURL(c.Request.Context(), &service.KiroGenerateIDCAuthURLInput{
ProxyID: req.ProxyID,
StartURL: req.StartURL,
Region: req.Region,
})
if err != nil {
response.BadRequest(c, "生成 IDC 授权链接失败: "+err.Error())
return
}
response.Success(c, result)
}
type KiroExchangeCodeRequest struct {
SessionID string `json:"session_id" binding:"required"`
State string `json:"state" binding:"required"`
Code string `json:"code" binding:"required"`
CallbackPath string `json:"callback_path"`
LoginOption string `json:"login_option"`
ProxyID *int64 `json:"proxy_id"`
}
func (h *KiroOAuthHandler) ExchangeCode(c *gin.Context) {
var req KiroExchangeCodeRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "请求无效: "+err.Error())
return
}
tokenInfo, err := h.kiroOAuthService.ExchangeCode(c.Request.Context(), &service.KiroExchangeCodeInput{
SessionID: req.SessionID,
State: req.State,
Code: req.Code,
CallbackPath: req.CallbackPath,
LoginOption: req.LoginOption,
ProxyID: req.ProxyID,
})
if err != nil {
response.BadRequest(c, "Token 交换失败: "+err.Error())
return
}
response.Success(c, tokenInfo)
}
type KiroRefreshTokenRequest struct {
RefreshToken string `json:"refresh_token" binding:"required"`
AuthMethod string `json:"auth_method"`
Provider string `json:"provider"`
ClientID string `json:"client_id"`
ClientSecret string `json:"client_secret"`
StartURL string `json:"start_url"`
Region string `json:"region"`
ProfileArn string `json:"profile_arn"`
ProxyID *int64 `json:"proxy_id"`
}
func (h *KiroOAuthHandler) RefreshToken(c *gin.Context) {
var req KiroRefreshTokenRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "请求无效: "+err.Error())
return
}
tokenInfo, err := h.kiroOAuthService.RefreshToken(c.Request.Context(), &service.KiroRefreshTokenInput{
RefreshToken: req.RefreshToken,
AuthMethod: req.AuthMethod,
Provider: req.Provider,
ClientID: req.ClientID,
ClientSecret: req.ClientSecret,
StartURL: req.StartURL,
Region: req.Region,
ProfileArn: req.ProfileArn,
ProxyID: req.ProxyID,
})
if err != nil {
response.BadRequest(c, "刷新 Kiro Token 失败: "+err.Error())
return
}
response.Success(c, tokenInfo)
}
type KiroImportTokenRequest struct {
TokenJSON string `json:"token_json" binding:"required"`
DeviceRegistrationJSON string `json:"device_registration_json"`
}
func (h *KiroOAuthHandler) ImportToken(c *gin.Context) {
var req KiroImportTokenRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "请求无效: "+err.Error())
return
}
tokenInfo, err := h.kiroOAuthService.ImportToken(&service.KiroImportTokenInput{
TokenJSON: req.TokenJSON,
DeviceRegistrationJSON: req.DeviceRegistrationJSON,
})
if err != nil {
response.BadRequest(c, "导入 Kiro Token 失败: "+err.Error())
return
}
response.Success(c, tokenInfo)
}