fix(groups): merge main and resolve model allowlist conflicts

This commit is contained in:
Zero Clover
2026-09-07 18:33:39 +08:00
252 changed files with 11371 additions and 1598 deletions
+1
View File
@@ -1,6 +1,7 @@
.PHONY: build build-backend build-frontend test test-backend test-frontend test-frontend-critical
FRONTEND_CRITICAL_VITEST := \
src/i18n/__tests__/localeKeyCompleteness.spec.ts \
src/api/__tests__/client.spec.ts \
src/api/__tests__/tokenRefresh.spec.ts \
src/api/__tests__/channelMonitorV2.spec.ts \
+1 -1
View File
@@ -1 +1 @@
0.2.0
0.2.1
+3 -3
View File
@@ -189,10 +189,10 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
adminAccountRepository := repository.NewAdminAccountRepository(client, db, schedulerCache)
proxyExitInfoProber := repository.NewProxyExitInfoProber(configConfig)
proxyLatencyCache := repository.NewProxyLatencyCache(redisClient)
adminService := service.NewAdminService(userRepository, adminGroupRepository, adminAccountRepository, proxyRepository, apiKeyRepository, redeemCodeRepository, userGroupRateRepository, userRPMCache, billingCacheService, proxyExitInfoProber, proxyLatencyCache, apiKeyAuthCacheInvalidator, client, settingService, subscriptionService, userSubscriptionRepository, privacyClientFactory, openAIGatewayService, affiliateService, compositeModelRouteRepository, compositeRouteResolver, channelService)
adminService := service.NewAdminService(configConfig, userRepository, adminGroupRepository, adminAccountRepository, proxyRepository, apiKeyRepository, redeemCodeRepository, userGroupRateRepository, userRPMCache, billingCacheService, proxyExitInfoProber, proxyLatencyCache, apiKeyAuthCacheInvalidator, client, settingService, subscriptionService, userSubscriptionRepository, privacyClientFactory, openAIGatewayService, affiliateService, compositeModelRouteRepository, compositeRouteResolver, channelService)
adminUserHandler := admin.NewUserHandler(adminService, concurrencyService, serviceUserPlatformQuotaRepository, billingCache, totpService, userService, settingService)
groupCapacityService := service.NewGroupCapacityService(accountRepository, groupRepository, concurrencyService, sessionLimitCache, rpmCache)
groupHandler := admin.NewGroupHandler(adminService, dashboardService, groupCapacityService)
groupHandler := admin.NewGroupHandlerWithConfig(adminService, dashboardService, groupCapacityService, configConfig)
claudeUsageFetcher := repository.NewClaudeUsageFetcher(httpUpstream)
antigravityQuotaFetcher := service.NewAntigravityQuotaFetcher(proxyRepository, configConfig)
grokQuotaFetcher := service.NewGrokQuotaFetcher()
@@ -205,7 +205,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
pluginManager := service.NewPluginManager(pluginRepository, secretEncryptor, configConfig, pluginHostInfo)
accountTestService := service.ProvideAccountTestService(accountRepository, geminiTokenProvider, claudeTokenProvider, grokTokenProvider, antigravityGatewayService, httpUpstream, configConfig, tlsFingerprintProfileService, openAIGatewayService, settingService, pluginManager)
crsSyncService := service.NewCRSSyncService(accountRepository, proxyRepository, oAuthService, openAIOAuthService, geminiOAuthService, configConfig)
accountHandler := admin.ProvideAccountHandler(adminService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, grokOAuthService, rateLimitService, accountUsageService, accountTestService, concurrencyService, crsSyncService, sessionLimitCache, rpmCache, compositeTokenCacheInvalidator, grokQuotaService)
accountHandler := admin.ProvideAccountHandler(configConfig, adminService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, grokOAuthService, rateLimitService, accountUsageService, accountTestService, concurrencyService, crsSyncService, sessionLimitCache, rpmCache, compositeTokenCacheInvalidator, grokQuotaService)
adminAnnouncementHandler := admin.NewAnnouncementHandler(announcementService)
dataManagementService := service.NewDataManagementService()
dataManagementHandler := admin.NewDataManagementHandler(dataManagementService)
+200 -13
View File
@@ -17,6 +17,7 @@ import (
"sync"
"time"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/domain"
"github.com/Wei-Shaw/sub2api/internal/handler/dto"
"github.com/Wei-Shaw/sub2api/internal/pkg/antigravity"
@@ -64,6 +65,7 @@ type AccountHandler struct {
grokImportProber grokImportProber
upstreamBillingProbe *service.UpstreamBillingProbeService
ollamaCloudUsage *service.OllamaCloudUsageService
cfg *config.Config
}
// SetUpstreamBillingProbeService attaches the optional remote billing probe service.
@@ -190,6 +192,7 @@ type CheckMixedChannelRequest struct {
// AccountWithConcurrency extends Account with real-time concurrency info
type AccountWithConcurrency struct {
*dto.Account
simpleMode bool `json:"-"`
CurrentConcurrency int `json:"current_concurrency"`
SchedulerScore *AccountSchedulerScore `json:"scheduler_score,omitempty"`
SchedulerScores []AccountSchedulerGroupScore `json:"scheduler_scores,omitempty"`
@@ -199,6 +202,123 @@ type AccountWithConcurrency struct {
CurrentRPM *int `json:"current_rpm,omitempty"` // 当前分钟 RPM 计数
}
// AccountListItemWithConcurrency is the compact account-list envelope used
// for lite=1. It embeds dto.AccountListItem instead of the full dto.Account,
// so groups/account_groups never appear in the list payload.
type AccountListItemWithConcurrency struct {
*dto.AccountListItem
CurrentConcurrency int `json:"current_concurrency"`
SchedulerScore *AccountSchedulerScore `json:"scheduler_score,omitempty"`
SchedulerScores []AccountSchedulerGroupScore `json:"scheduler_scores,omitempty"`
CurrentWindowCost *float64 `json:"current_window_cost,omitempty"`
ActiveSessions *int `json:"active_sessions,omitempty"`
CurrentRPM *int `json:"current_rpm,omitempty"`
}
type simpleModeGroupReference struct {
ID int64 `json:"id"`
Name string `json:"name"`
Platform string `json:"platform"`
Status string `json:"status"`
}
type simpleModeAccountGroupReference struct {
AccountID int64 `json:"account_id"`
GroupID int64 `json:"group_id"`
Priority int `json:"priority"`
CreatedAt time.Time `json:"created_at"`
Group *simpleModeGroupReference `json:"group,omitempty"`
}
func simpleModeGroupReferenceFromDTO(group *dto.Group) *simpleModeGroupReference {
if group == nil {
return nil
}
return &simpleModeGroupReference{ID: group.ID, Name: group.Name, Platform: group.Platform, Status: group.Status}
}
func simpleModeCompositeGroupIDs(account *dto.Account) map[int64]struct{} {
hidden := make(map[int64]struct{})
if account == nil {
return hidden
}
for _, group := range account.Groups {
if group != nil && group.Platform == service.PlatformComposite {
hidden[group.ID] = struct{}{}
}
}
for _, accountGroup := range account.AccountGroups {
if accountGroup.Group != nil && accountGroup.Group.Platform == service.PlatformComposite {
hidden[accountGroup.GroupID] = struct{}{}
}
}
return hidden
}
func filterSimpleModeGroupIDs(groupIDs []int64, hidden map[int64]struct{}) []int64 {
visible := make([]int64, 0, len(groupIDs))
for _, groupID := range groupIDs {
if _, ok := hidden[groupID]; !ok {
visible = append(visible, groupID)
}
}
return visible
}
func simpleModeCompositeServiceGroupIDs(account *service.Account) map[int64]struct{} {
hidden := make(map[int64]struct{})
if account == nil {
return hidden
}
for _, group := range account.Groups {
if group != nil && group.Platform == service.PlatformComposite {
hidden[group.ID] = struct{}{}
}
}
for _, accountGroup := range account.AccountGroups {
if accountGroup.Group != nil && accountGroup.Group.Platform == service.PlatformComposite {
hidden[accountGroup.GroupID] = struct{}{}
}
}
return hidden
}
func (a AccountWithConcurrency) MarshalJSON() ([]byte, error) {
type alias AccountWithConcurrency
if !a.simpleMode || a.Account == nil {
return json.Marshal(alias(a))
}
groups := make([]simpleModeGroupReference, 0, len(a.Groups))
compositeIDs := simpleModeCompositeGroupIDs(a.Account)
for _, group := range a.Groups {
if group != nil && group.Platform == service.PlatformComposite {
continue
}
if ref := simpleModeGroupReferenceFromDTO(group); ref != nil {
groups = append(groups, *ref)
}
}
accountGroups := make([]simpleModeAccountGroupReference, 0, len(a.AccountGroups))
for _, accountGroup := range a.AccountGroups {
if accountGroup.Group != nil && accountGroup.Group.Platform == service.PlatformComposite {
continue
}
if _, hidden := compositeIDs[accountGroup.GroupID]; hidden {
continue
}
accountGroups = append(accountGroups, simpleModeAccountGroupReference{
AccountID: accountGroup.AccountID, GroupID: accountGroup.GroupID, Priority: accountGroup.Priority,
CreatedAt: accountGroup.CreatedAt, Group: simpleModeGroupReferenceFromDTO(accountGroup.Group),
})
}
return json.Marshal(struct {
alias
GroupIDs []int64 `json:"group_ids,omitempty"`
Groups []simpleModeGroupReference `json:"groups"`
AccountGroups []simpleModeAccountGroupReference `json:"account_groups"`
}{alias: alias(a), GroupIDs: filterSimpleModeGroupIDs(a.GroupIDs, compositeIDs), Groups: groups, AccountGroups: accountGroups})
}
type AccountSchedulerScore struct {
BaseScore float64 `json:"base_score"`
StickyScore float64 `json:"sticky_score"`
@@ -223,9 +343,25 @@ func (h *AccountHandler) accountResponseFromService(account *service.Account) *d
return out
}
func (h *AccountHandler) accountListResponseFromService(account *service.Account) *dto.Account {
out := dto.AccountFromServiceShallow(account)
if out != nil && account != nil {
out.Proxy = dto.ProxyFromService(account.Proxy)
}
if h != nil && h.ollamaCloudUsage != nil && out != nil {
h.ollamaCloudUsage.EnrichState(out.OllamaCloudUsage)
}
return out
}
func (h *AccountHandler) isSimpleMode() bool {
return h != nil && h.cfg != nil && h.cfg.RunMode == config.RunModeSimple
}
func (h *AccountHandler) buildAccountResponseWithRuntime(ctx context.Context, account *service.Account) AccountWithConcurrency {
item := AccountWithConcurrency{
Account: h.accountResponseFromService(account),
simpleMode: h.isSimpleMode(),
CurrentConcurrency: 0,
}
if account == nil {
@@ -650,8 +786,16 @@ func (h *AccountHandler) List(c *gin.Context) {
result := make([]AccountWithConcurrency, len(accounts))
for i := range accounts {
acc := &accounts[i]
accountResponse := h.accountResponseFromService(acc)
if lite {
accountResponse = h.accountListResponseFromService(acc)
if h.isSimpleMode() {
accountResponse.GroupIDs = filterSimpleModeGroupIDs(accountResponse.GroupIDs, simpleModeCompositeServiceGroupIDs(acc))
}
}
item := AccountWithConcurrency{
Account: h.accountResponseFromService(acc),
Account: accountResponse,
simpleMode: h.isSimpleMode(),
CurrentConcurrency: concurrencyCounts[acc.ID],
SchedulerScore: schedulerScores[acc.ID],
SchedulerScores: schedulerGroupScores[acc.ID],
@@ -683,7 +827,34 @@ func (h *AccountHandler) List(c *gin.Context) {
h.enrichShadowParents(c.Request.Context(), result)
etag := buildAccountsListETag(result, total, page, pageSize, platform, accountType, status, search, lite)
if lite {
compact := make([]AccountListItemWithConcurrency, len(result))
for i := range result {
item := result[i]
compact[i] = AccountListItemWithConcurrency{
AccountListItem: dto.AccountListItemFromAccount(item.Account),
CurrentConcurrency: item.CurrentConcurrency,
SchedulerScore: item.SchedulerScore,
SchedulerScores: item.SchedulerScores,
CurrentWindowCost: item.CurrentWindowCost,
ActiveSessions: item.ActiveSessions,
CurrentRPM: item.CurrentRPM,
}
}
etag := buildAccountsListETag(compact, total, page, pageSize, platform, accountType, status, search, true)
if etag != "" {
c.Header("ETag", etag)
c.Header("Vary", "If-None-Match")
if ifNoneMatchMatched(c.GetHeader("If-None-Match"), etag) {
c.Status(http.StatusNotModified)
return
}
}
response.Paginated(c, compact, total, page, pageSize)
return
}
etag := buildAccountsListETag(result, total, page, pageSize, platform, accountType, status, search, false)
if etag != "" {
c.Header("ETag", etag)
c.Header("Vary", "If-None-Match")
@@ -696,23 +867,23 @@ func (h *AccountHandler) List(c *gin.Context) {
response.Paginated(c, result, total, page, pageSize)
}
func buildAccountsListETag(
items []AccountWithConcurrency,
func buildAccountsListETag[T any](
items []T,
total int64,
page, pageSize int,
platform, accountType, status, search string,
lite bool,
) string {
payload := struct {
Total int64 `json:"total"`
Page int `json:"page"`
PageSize int `json:"page_size"`
Platform string `json:"platform"`
AccountType string `json:"type"`
Status string `json:"status"`
Search string `json:"search"`
Lite bool `json:"lite"`
Items []AccountWithConcurrency `json:"items"`
Total int64 `json:"total"`
Page int `json:"page"`
PageSize int `json:"page_size"`
Platform string `json:"platform"`
AccountType string `json:"type"`
Status string `json:"status"`
Search string `json:"search"`
Lite bool `json:"lite"`
Items []T `json:"items"`
}{
Total: total,
Page: page,
@@ -1887,6 +2058,14 @@ func (h *AccountHandler) BatchCreate(c *gin.Context) {
return
}
}
groupIDs := make([]int64, 0)
for _, item := range req.Accounts {
groupIDs = append(groupIDs, item.GroupIDs...)
}
if err := h.adminService.ValidateAccountGroupBindings(c.Request.Context(), groupIDs); err != nil {
response.ErrorFrom(c, err)
return
}
executeAdminIdempotentJSON(c, "admin.accounts.batch_create", req, service.DefaultWriteIdempotencyTTL(), func(ctx context.Context) (any, error) {
success := 0
@@ -2601,6 +2780,14 @@ func (h *AccountHandler) GetAvailableModels(c *gin.Context) {
// Handle OpenAI accounts
if account.IsOpenAI() {
// Prefer the shared, account-keyed upstream catalog. If discovery fails,
// retain the legacy local catalog below so the test dialog remains usable.
if h.accountTestService != nil {
if models, fetchErr := h.accountTestService.FetchOpenAIAccountModels(c.Request.Context(), account); fetchErr == nil {
response.Success(c, models)
return
}
}
// OpenAI 自动透传会绕过常规模型改写,测试/模型列表也应回落到默认模型集。
if account.IsOpenAIPassthroughEnabled() {
response.Success(c, openai.DefaultModels)
@@ -1,9 +1,12 @@
package admin
import (
"bytes"
"compress/gzip"
"encoding/json"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
"time"
@@ -13,6 +16,101 @@ import (
"github.com/stretchr/testify/require"
)
func TestAccountHandlerListLiteUsesCompactDTOAndETag(t *testing.T) {
router, adminSvc := setupAccountListRouter()
now := time.Now().UTC()
groupID := int64(77)
adminSvc.accounts = []service.Account{{
ID: 501, Name: "compact-account", Platform: service.PlatformOpenAI, Type: service.AccountTypeOAuth,
Credentials: map[string]any{"email": "compact@example.com", "access_token": strings.Repeat("x", 4096)},
Extra: map[string]any{"privacy_mode": "training_off"}, Status: service.StatusActive,
Schedulable: true, Concurrency: 4, GroupIDs: []int64{groupID},
Groups: []*service.Group{{ID: groupID, Name: "codex", Platform: service.PlatformOpenAI}},
AccountGroups: []service.AccountGroup{{AccountID: 501, GroupID: groupID, Priority: 2, Group: &service.Group{ID: groupID, Name: "codex", Platform: service.PlatformOpenAI}}},
CreatedAt: now, UpdatedAt: now,
}}
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/accounts?page=1&page_size=20&lite=1", nil)
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
require.NotEmpty(t, rec.Header().Get("ETag"))
var litePayload struct {
Data struct {
Items []map[string]any `json:"items"`
} `json:"data"`
}
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &litePayload))
require.Len(t, litePayload.Data.Items, 1)
liteItem := litePayload.Data.Items[0]
require.Equal(t, float64(501), liteItem["id"])
require.Equal(t, []any{float64(groupID)}, liteItem["group_ids"])
require.Equal(t, true, liteItem["schedulable"])
require.NotContains(t, liteItem, "groups")
require.NotContains(t, liteItem, "account_groups")
credentials, ok := liteItem["credentials"].(map[string]any)
require.True(t, ok)
require.Equal(t, "compact@example.com", credentials["email"])
require.NotContains(t, credentials, "access_token")
credentialsStatus, ok := liteItem["credentials_status"].(map[string]any)
require.True(t, ok)
require.Equal(t, true, credentialsStatus["has_access_token"])
// The ETag must represent the same compact body and return 304 on refresh.
rec304 := httptest.NewRecorder()
req304 := httptest.NewRequest(http.MethodGet, "/api/v1/admin/accounts?page=1&page_size=20&lite=1", nil)
req304.Header.Set("If-None-Match", rec.Header().Get("ETag"))
router.ServeHTTP(rec304, req304)
require.Equal(t, http.StatusNotModified, rec304.Code)
// Omitting lite preserves the legacy full response shape.
recFull := httptest.NewRecorder()
reqFull := httptest.NewRequest(http.MethodGet, "/api/v1/admin/accounts?page=1&page_size=20", nil)
router.ServeHTTP(recFull, reqFull)
require.Equal(t, http.StatusOK, recFull.Code)
var fullPayload struct {
Data struct {
Items []map[string]any `json:"items"`
} `json:"data"`
}
require.NoError(t, json.Unmarshal(recFull.Body.Bytes(), &fullPayload))
require.Contains(t, fullPayload.Data.Items[0], "groups")
require.Contains(t, fullPayload.Data.Items[0], "account_groups")
}
func TestAccountHandlerListLiteStaysBelowResponseBudget(t *testing.T) {
router, adminSvc := setupAccountListRouter()
now := time.Now().UTC()
accounts := make([]service.Account, 20)
for i := range accounts {
id := int64(600 + i)
groupID := int64(800 + i)
accounts[i] = service.Account{
ID: id, Name: "account-" + strconv.Itoa(i), Platform: service.PlatformOpenAI,
Type: service.AccountTypeOAuth, Status: service.StatusActive, Schedulable: true,
Concurrency: 4, GroupIDs: []int64{groupID},
Groups: []*service.Group{{ID: groupID, Name: "group-" + strconv.Itoa(i), Description: strings.Repeat("description ", 20)}},
AccountGroups: []service.AccountGroup{{AccountID: id, GroupID: groupID, Group: &service.Group{ID: groupID, Name: "group-" + strconv.Itoa(i), Description: strings.Repeat("description ", 20)}}},
CreatedAt: now, UpdatedAt: now,
}
}
adminSvc.accounts = accounts
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/accounts?page=1&page_size=20&lite=1", nil)
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
require.Less(t, rec.Body.Len(), 80*1024)
var compressed bytes.Buffer
zw := gzip.NewWriter(&compressed)
_, err := zw.Write(rec.Body.Bytes())
require.NoError(t, err)
require.NoError(t, zw.Close())
require.Less(t, compressed.Len(), 15*1024)
}
func setupAccountListRouter() (*gin.Engine, *stubAdminService) {
gin.SetMode(gin.TestMode)
router := gin.New()
@@ -0,0 +1,240 @@
package admin
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/Wei-Shaw/sub2api/internal/config"
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
type simpleModeAccountService struct {
*stubAdminService
account service.Account
createCalls int
updateCalls int
bulkCalls int
}
func (s *simpleModeAccountService) GetAccount(context.Context, int64) (*service.Account, error) {
return &s.account, nil
}
func (s *simpleModeAccountService) CreateAccount(ctx context.Context, input *service.CreateAccountInput) (*service.Account, error) {
if err := s.ValidateAccountGroupBindings(ctx, input.GroupIDs); err != nil {
return nil, err
}
s.createCalls++
return &s.account, nil
}
func (s *simpleModeAccountService) UpdateAccount(ctx context.Context, _ int64, input *service.UpdateAccountInput) (*service.Account, error) {
if input.GroupIDs != nil {
if err := s.ValidateAccountGroupBindings(ctx, *input.GroupIDs); err != nil {
return nil, err
}
}
s.updateCalls++
return &s.account, nil
}
func (s *simpleModeAccountService) BulkUpdateAccounts(ctx context.Context, input *service.BulkUpdateAccountsInput) (*service.BulkUpdateAccountsResult, error) {
if input.GroupIDs != nil {
if err := s.ValidateAccountGroupBindings(ctx, *input.GroupIDs); err != nil {
return nil, err
}
}
s.bulkCalls++
return &service.BulkUpdateAccountsResult{}, nil
}
func (s *simpleModeAccountService) ValidateAccountGroupBindings(_ context.Context, groupIDs []int64) error {
for _, id := range groupIDs {
for i := range s.groups {
if s.groups[i].ID == id && s.groups[i].Platform == service.PlatformComposite {
return infraerrors.BadRequest("SIMPLE_MODE_GROUP_NOT_BINDABLE", "composite groups cannot be bound in simple mode")
}
}
}
return nil
}
func TestAccountHandlerSimpleModeUsesMinimalGroupReferences(t *testing.T) {
gin.SetMode(gin.TestMode)
richGroup := &service.Group{ID: 7, Name: "basic", Platform: service.PlatformAnthropic, Status: service.StatusActive, RateMultiplier: 9, IsExclusive: true, SubscriptionType: service.SubscriptionTypeSubscription, RPMLimit: 99}
historicalComposite := &service.Group{ID: 9, Name: "historical composite", Platform: service.PlatformComposite, Status: service.StatusActive}
account := service.Account{
ID: 3, Name: "account", Platform: service.PlatformAnthropic, Type: service.AccountTypeAPIKey, Status: service.StatusActive,
GroupIDs: []int64{7, 9},
Groups: []*service.Group{richGroup, historicalComposite},
AccountGroups: []service.AccountGroup{
{AccountID: 3, GroupID: 7, Priority: 2, Group: richGroup, Account: &service.Account{ID: 3, Extra: map[string]any{"secret": true}}},
{AccountID: 3, GroupID: 9, Priority: 3, Group: historicalComposite},
},
}
svc := &simpleModeAccountService{stubAdminService: newStubAdminService(), account: account}
svc.accounts = []service.Account{account}
h := NewAccountHandler(svc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
h.cfg = &config.Config{RunMode: config.RunModeSimple}
r := gin.New()
r.GET("/accounts", h.List)
r.GET("/accounts/:id", h.GetByID)
r.POST("/accounts", h.Create)
r.PUT("/accounts/:id", h.Update)
tests := []struct {
method string
path string
body string
}{
{http.MethodGet, "/accounts", ""},
{http.MethodGet, "/accounts/3", ""},
{http.MethodPost, "/accounts", `{"name":"account","platform":"anthropic","type":"apikey","credentials":{"key":"x"}}`},
{http.MethodPut, "/accounts/3", `{"name":"account"}`},
}
for _, tt := range tests {
t.Run(tt.method+tt.path, func(t *testing.T) {
req := httptest.NewRequest(tt.method, tt.path, bytes.NewBufferString(tt.body))
req.Header.Set("Content-Type", "application/json")
res := httptest.NewRecorder()
r.ServeHTTP(res, req)
require.Equal(t, http.StatusOK, res.Code, res.Body.String())
var payload map[string]any
require.NoError(t, json.Unmarshal(res.Body.Bytes(), &payload))
data, ok := payload["data"].(map[string]any)
require.True(t, ok)
if items, ok := data["items"].([]any); ok {
require.Len(t, items, 1)
data, ok = items[0].(map[string]any)
require.True(t, ok)
}
groups, ok := data["groups"].([]any)
require.True(t, ok)
require.Len(t, groups, 1)
require.Equal(t, []any{float64(7)}, data["group_ids"])
group, ok := groups[0].(map[string]any)
require.True(t, ok)
require.ElementsMatch(t, []string{"id", "name", "platform", "status"}, mapKeys(group))
accountGroups, ok := data["account_groups"].([]any)
require.True(t, ok)
require.Len(t, accountGroups, 1)
accountGroup, ok := accountGroups[0].(map[string]any)
require.True(t, ok)
require.ElementsMatch(t, []string{"account_id", "group_id", "priority", "created_at", "group"}, mapKeys(accountGroup))
nestedGroup, ok := accountGroup["group"].(map[string]any)
require.True(t, ok)
require.ElementsMatch(t, []string{"id", "name", "platform", "status"}, mapKeys(nestedGroup))
})
}
}
func TestAccountHandlerSimpleModeLitePreservesCompactShapeAndETag(t *testing.T) {
richGroup := &service.Group{ID: 7, Name: "basic", Platform: service.PlatformAnthropic, Status: service.StatusActive, RateMultiplier: 9}
historicalComposite := &service.Group{ID: 9, Name: "historical composite", Platform: service.PlatformComposite, Status: service.StatusActive}
account := service.Account{
ID: 3, Name: "account", Platform: service.PlatformAnthropic, Type: service.AccountTypeAPIKey, Status: service.StatusActive,
GroupIDs: []int64{7, 9}, Groups: []*service.Group{richGroup, historicalComposite},
AccountGroups: []service.AccountGroup{{AccountID: 3, GroupID: 7, Group: richGroup}, {AccountID: 3, GroupID: 9, Group: historicalComposite}},
}
svc := &simpleModeAccountService{stubAdminService: newStubAdminService(), account: account}
svc.accounts = []service.Account{account}
h := NewAccountHandler(svc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
h.cfg = &config.Config{RunMode: config.RunModeSimple}
r := gin.New()
r.GET("/accounts", h.List)
res := httptest.NewRecorder()
r.ServeHTTP(res, httptest.NewRequest(http.MethodGet, "/accounts?lite=1", nil))
require.Equal(t, http.StatusOK, res.Code, res.Body.String())
require.NotEmpty(t, res.Header().Get("ETag"))
var payload struct {
Data struct {
Items []map[string]any `json:"items"`
} `json:"data"`
}
require.NoError(t, json.Unmarshal(res.Body.Bytes(), &payload))
require.Len(t, payload.Data.Items, 1)
require.Equal(t, []any{float64(7)}, payload.Data.Items[0]["group_ids"])
require.NotContains(t, payload.Data.Items[0], "groups")
require.NotContains(t, payload.Data.Items[0], "account_groups")
notModified := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/accounts?lite=1", nil)
req.Header.Set("If-None-Match", res.Header().Get("ETag"))
r.ServeHTTP(notModified, req)
require.Equal(t, http.StatusNotModified, notModified.Code)
}
func TestAccountHandlerSimpleModeRejectsCompositeGroupBindingsBeforeWrites(t *testing.T) {
gin.SetMode(gin.TestMode)
for _, tt := range []struct{ name, method, path, body string }{
{"create", http.MethodPost, "/accounts", `{"name":"account","platform":"anthropic","type":"apikey","credentials":{"key":"x"},"group_ids":[9]}`},
{"update", http.MethodPut, "/accounts/3", `{"group_ids":[9]}`},
{"bulk", http.MethodPost, "/accounts/bulk-update", `{"account_ids":[3],"group_ids":[9]}`},
} {
t.Run(tt.name, func(t *testing.T) {
svc := &simpleModeAccountService{stubAdminService: newStubAdminService()}
svc.groups = []service.Group{{ID: 7, Platform: service.PlatformAnthropic}, {ID: 9, Platform: service.PlatformComposite}}
h := NewAccountHandler(svc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
h.cfg = &config.Config{RunMode: config.RunModeSimple}
r := gin.New()
r.POST("/accounts", h.Create)
r.PUT("/accounts/:id", h.Update)
r.POST("/accounts/bulk-update", h.BulkUpdate)
res := httptest.NewRecorder()
req := httptest.NewRequest(tt.method, tt.path, bytes.NewBufferString(tt.body))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(res, req)
require.Equal(t, http.StatusBadRequest, res.Code, res.Body.String())
require.Zero(t, svc.createCalls)
require.Zero(t, svc.updateCalls)
require.Zero(t, svc.bulkCalls)
})
}
}
func TestAccountHandlerSimpleModePreservesBasicGroupBinding(t *testing.T) {
svc := &simpleModeAccountService{stubAdminService: newStubAdminService(), account: service.Account{ID: 3}}
svc.groups = []service.Group{{ID: 7, Platform: service.PlatformAnthropic}}
h := NewAccountHandler(svc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
h.cfg = &config.Config{RunMode: config.RunModeSimple}
r := gin.New()
r.POST("/accounts", h.Create)
res := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/accounts", bytes.NewBufferString(`{"name":"account","platform":"anthropic","type":"apikey","credentials":{"key":"x"},"group_ids":[7]}`))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(res, req)
require.Equal(t, http.StatusOK, res.Code, res.Body.String())
require.Equal(t, 1, svc.createCalls)
}
func TestAccountHandlerSimpleModeBatchPrevalidatesAllGroupsAtomically(t *testing.T) {
svc := &simpleModeAccountService{stubAdminService: newStubAdminService()}
svc.groups = []service.Group{{ID: 7, Platform: service.PlatformAnthropic}, {ID: 9, Platform: service.PlatformComposite}}
h := NewAccountHandler(svc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
h.cfg = &config.Config{RunMode: config.RunModeSimple}
r := gin.New()
r.POST("/accounts/batch", h.BatchCreate)
res := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/accounts/batch", bytes.NewBufferString(`{"accounts":[{"name":"first","platform":"anthropic","type":"apikey","credentials":{"key":"x"},"group_ids":[7]},{"name":"second","platform":"anthropic","type":"apikey","credentials":{"key":"y"},"group_ids":[9]}]}`))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(res, req)
require.Equal(t, http.StatusBadRequest, res.Code, res.Body.String())
require.Zero(t, svc.createCalls)
}
func TestAccountHandlerAdvancedModeKeepsFullGroupReferences(t *testing.T) {
group := &service.Group{ID: 7, Name: "advanced", RateMultiplier: 9, SubscriptionType: service.SubscriptionTypeSubscription}
h := NewAccountHandler(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
raw, err := json.Marshal(h.buildAccountResponseWithRuntime(context.Background(), &service.Account{Groups: []*service.Group{group}}))
require.NoError(t, err)
require.Contains(t, string(raw), `"rate_multiplier":9`)
require.Contains(t, string(raw), `"subscription_type":"subscription"`)
}
@@ -278,13 +278,15 @@ func TestOpenAIFastPolicySettingsFromDTO_NormalizesServiceTier(t *testing.T) {
Rules: []dto.OpenAIFastPolicyRule{
{ServiceTier: "priority", Action: "filter", Scope: "all"},
{ServiceTier: "flex", Action: "block", Scope: "oauth"},
{ServiceTier: "ultrafast", Action: "pass", Scope: "all"},
{ServiceTier: "all", Action: "pass", Scope: "apikey"},
},
}
out := openaiFastPolicySettingsFromDTO(in)
require.Len(t, out.Rules, 3)
require.Len(t, out.Rules, 4)
require.Equal(t, service.OpenAIFastTierPriority, out.Rules[0].ServiceTier)
require.Equal(t, service.OpenAIFastTierFlex, out.Rules[1].ServiceTier)
require.Equal(t, service.OpenAIFastTierAny, out.Rules[2].ServiceTier)
require.Equal(t, service.OpenAIFastTierUltrafast, out.Rules[2].ServiceTier)
require.Equal(t, service.OpenAIFastTierAny, out.Rules[3].ServiceTier)
})
}
@@ -24,6 +24,13 @@ type stubAdminService struct {
boundAuthIdentity *service.AdminBindAuthIdentityInput
boundAuthIdentityFor int64
createdAccounts []*service.CreateAccountInput
createdGroups []*service.CreateGroupInput
updatedGroups []*service.UpdateGroupInput
deletedGroupIDs []int64
guardedDeletedGroupIDs []int64
deleteGroupIfEmptyErr error
advancedGroupOperationCalls int
lastListGroupsIsExclusive *bool
createdProxies []*service.CreateProxyInput
updatedProxyIDs []int64
updatedProxies []*service.UpdateProxyInput
@@ -267,6 +274,7 @@ func (s *stubAdminService) BindUserAuthIdentity(ctx context.Context, userID int6
}
func (s *stubAdminService) ListGroups(ctx context.Context, page, pageSize int, platform, status, search string, isExclusive *bool, sortBy, sortOrder string) ([]service.Group, int64, error) {
s.lastListGroupsIsExclusive = isExclusive
return s.groups, int64(len(s.groups)), nil
}
@@ -295,6 +303,7 @@ func (s *stubAdminService) GetGroupModelsListCandidates(ctx context.Context, id
}
func (s *stubAdminService) ListCompositeRoutes(ctx context.Context, groupID int64) ([]service.CompositeModelRoute, error) {
s.advancedGroupOperationCalls++
return []service.CompositeModelRoute{
{
ID: 1,
@@ -311,6 +320,7 @@ func (s *stubAdminService) ListCompositeRoutes(ctx context.Context, groupID int6
}
func (s *stubAdminService) CreateCompositeRoute(ctx context.Context, groupID int64, input service.CompositeRouteInput) (*service.CompositeModelRoute, error) {
s.advancedGroupOperationCalls++
return &service.CompositeModelRoute{
ID: 1,
GroupID: groupID,
@@ -326,6 +336,7 @@ func (s *stubAdminService) CreateCompositeRoute(ctx context.Context, groupID int
}
func (s *stubAdminService) UpdateCompositeRoute(ctx context.Context, groupID, routeID int64, input service.CompositeRouteInput) (*service.CompositeModelRoute, error) {
s.advancedGroupOperationCalls++
return &service.CompositeModelRoute{
ID: routeID,
GroupID: groupID,
@@ -341,10 +352,12 @@ func (s *stubAdminService) UpdateCompositeRoute(ctx context.Context, groupID, ro
}
func (s *stubAdminService) DeleteCompositeRoute(ctx context.Context, groupID, routeID int64) error {
s.advancedGroupOperationCalls++
return nil
}
func (s *stubAdminService) PreviewCompositeRoute(ctx context.Context, groupID int64, input service.CompositeRoutePreviewRequest) (*service.CompositeRouteDecision, error) {
s.advancedGroupOperationCalls++
decision, err := service.NewCompositeRouteResolver(nil).Resolve(ctx, groupID, input.Model, input.Endpoint)
if err != nil {
return nil, err
@@ -353,11 +366,13 @@ func (s *stubAdminService) PreviewCompositeRoute(ctx context.Context, groupID in
}
func (s *stubAdminService) CreateGroup(ctx context.Context, input *service.CreateGroupInput) (*service.Group, error) {
s.createdGroups = append(s.createdGroups, input)
group := service.Group{ID: 200, Name: input.Name, Status: service.StatusActive}
return &group, nil
}
func (s *stubAdminService) DuplicateGroup(ctx context.Context, id int64, actorScope, operationKey string) (*service.Group, error) {
s.advancedGroupOperationCalls++
group := service.Group{ID: 201, Name: "group (Copy)", Status: "inactive"}
return &group, nil
}
@@ -367,14 +382,21 @@ func (s *stubAdminService) RecoverDuplicateGroup(ctx context.Context, id int64,
}
func (s *stubAdminService) UpdateGroup(ctx context.Context, id int64, input *service.UpdateGroupInput) (*service.Group, error) {
s.updatedGroups = append(s.updatedGroups, input)
group := service.Group{ID: id, Name: input.Name, Status: service.StatusActive}
return &group, nil
}
func (s *stubAdminService) DeleteGroup(ctx context.Context, id int64) error {
s.deletedGroupIDs = append(s.deletedGroupIDs, id)
return nil
}
func (s *stubAdminService) DeleteGroupIfEmpty(ctx context.Context, id int64) error {
s.guardedDeletedGroupIDs = append(s.guardedDeletedGroupIDs, id)
return s.deleteGroupIfEmptyErr
}
func (s *stubAdminService) GetGroupAPIKeys(ctx context.Context, groupID int64, page, pageSize int) ([]service.APIKey, int64, error) {
return s.apiKeys, int64(len(s.apiKeys)), nil
}
@@ -384,10 +406,12 @@ func (s *stubAdminService) GetGroupRateMultipliers(_ context.Context, _ int64) (
}
func (s *stubAdminService) ClearGroupRateMultipliers(_ context.Context, _ int64) error {
s.advancedGroupOperationCalls++
return nil
}
func (s *stubAdminService) BatchSetGroupRateMultipliers(_ context.Context, _ int64, _ []service.GroupRateMultiplierInput) error {
s.advancedGroupOperationCalls++
return nil
}
@@ -396,6 +420,7 @@ func (s *stubAdminService) ClearGroupRPMOverrides(_ context.Context, _ int64) er
}
func (s *stubAdminService) BatchSetGroupRPMOverrides(_ context.Context, _ int64, _ []service.GroupRPMOverrideInput) error {
s.advancedGroupOperationCalls++
return nil
}
@@ -491,6 +516,8 @@ func (s *stubAdminService) CreateAccount(ctx context.Context, input *service.Cre
return &account, nil
}
func (s *stubAdminService) ValidateAccountGroupBindings(context.Context, []int64) error { return nil }
func (s *stubAdminService) DuplicateAccount(ctx context.Context, id int64, actorScope, operationKey string) (*service.Account, error) {
account := service.Account{ID: 301, Name: "account (Copy)", Status: service.StatusActive, Schedulable: false}
return &account, nil
@@ -57,21 +57,22 @@ type updateChannelRequest struct {
}
type channelModelPricingRequest struct {
Platform string `json:"platform" binding:"omitempty,max=50"`
Models []string `json:"models" binding:"required,min=1,max=100"`
BillingMode string `json:"billing_mode" binding:"omitempty,oneof=token per_request image"`
InputPrice *float64 `json:"input_price" binding:"omitempty,min=0"`
OutputPrice *float64 `json:"output_price" binding:"omitempty,min=0"`
CacheWritePrice *float64 `json:"cache_write_price" binding:"omitempty,min=0"`
CacheWrite1hPrice *float64 `json:"cache_write_1h_price" binding:"omitempty,min=0"`
CacheReadPrice *float64 `json:"cache_read_price" binding:"omitempty,min=0"`
FastMultiplier *float64 `json:"fast_multiplier" binding:"omitempty,gt=0"`
FlexMultiplier *float64 `json:"flex_multiplier" binding:"omitempty,gt=0"`
ImageInputPrice *float64 `json:"image_input_price" binding:"omitempty,min=0"`
ImageOutputPrice *float64 `json:"image_output_price" binding:"omitempty,min=0"`
PerRequestPrice *float64 `json:"per_request_price" binding:"omitempty,min=0"`
Intervals []pricingIntervalRequest `json:"intervals"`
TimePricing *channelTimePricingRequest `json:"time_pricing"`
Platform string `json:"platform" binding:"omitempty,max=50"`
Models []string `json:"models" binding:"required,min=1,max=100"`
BillingMode string `json:"billing_mode" binding:"omitempty,oneof=token per_request image"`
InputPrice *float64 `json:"input_price" binding:"omitempty,min=0"`
OutputPrice *float64 `json:"output_price" binding:"omitempty,min=0"`
CacheWritePrice *float64 `json:"cache_write_price" binding:"omitempty,min=0"`
CacheWrite1hPrice *float64 `json:"cache_write_1h_price" binding:"omitempty,min=0"`
CacheReadPrice *float64 `json:"cache_read_price" binding:"omitempty,min=0"`
FastMultiplier *float64 `json:"fast_multiplier" binding:"omitempty,gt=0"`
FlexMultiplier *float64 `json:"flex_multiplier" binding:"omitempty,gt=0"`
MaxReasoningEffortMultiplier *float64 `json:"max_reasoning_effort_multiplier" binding:"omitempty,gt=0"`
ImageInputPrice *float64 `json:"image_input_price" binding:"omitempty,min=0"`
ImageOutputPrice *float64 `json:"image_output_price" binding:"omitempty,min=0"`
PerRequestPrice *float64 `json:"per_request_price" binding:"omitempty,min=0"`
Intervals []pricingIntervalRequest `json:"intervals"`
TimePricing *channelTimePricingRequest `json:"time_pricing"`
}
type channelTimePricingRequest struct {
@@ -129,22 +130,23 @@ type channelResponse struct {
}
type channelModelPricingResponse struct {
ID int64 `json:"id"`
Platform string `json:"platform"`
Models []string `json:"models"`
BillingMode string `json:"billing_mode"`
InputPrice *float64 `json:"input_price"`
OutputPrice *float64 `json:"output_price"`
CacheWritePrice *float64 `json:"cache_write_price"`
CacheWrite1hPrice *float64 `json:"cache_write_1h_price"`
CacheReadPrice *float64 `json:"cache_read_price"`
FastMultiplier *float64 `json:"fast_multiplier"`
FlexMultiplier *float64 `json:"flex_multiplier"`
ImageInputPrice *float64 `json:"image_input_price"`
ImageOutputPrice *float64 `json:"image_output_price"`
PerRequestPrice *float64 `json:"per_request_price"`
Intervals []pricingIntervalResponse `json:"intervals"`
TimePricing *channelTimePricingResponse `json:"time_pricing"`
ID int64 `json:"id"`
Platform string `json:"platform"`
Models []string `json:"models"`
BillingMode string `json:"billing_mode"`
InputPrice *float64 `json:"input_price"`
OutputPrice *float64 `json:"output_price"`
CacheWritePrice *float64 `json:"cache_write_price"`
CacheWrite1hPrice *float64 `json:"cache_write_1h_price"`
CacheReadPrice *float64 `json:"cache_read_price"`
FastMultiplier *float64 `json:"fast_multiplier"`
FlexMultiplier *float64 `json:"flex_multiplier"`
MaxReasoningEffortMultiplier *float64 `json:"max_reasoning_effort_multiplier"`
ImageInputPrice *float64 `json:"image_input_price"`
ImageOutputPrice *float64 `json:"image_output_price"`
PerRequestPrice *float64 `json:"per_request_price"`
Intervals []pricingIntervalResponse `json:"intervals"`
TimePricing *channelTimePricingResponse `json:"time_pricing"`
}
type channelTimePricingResponse struct {
@@ -258,22 +260,23 @@ func pricingToResponse(p *service.ChannelModelPricing) channelModelPricingRespon
intervals = append(intervals, intervalToResponse(iv))
}
return channelModelPricingResponse{
ID: p.ID,
Platform: platform,
Models: models,
BillingMode: billingMode,
InputPrice: p.InputPrice,
OutputPrice: p.OutputPrice,
CacheWritePrice: p.CacheWritePrice,
CacheWrite1hPrice: p.CacheWrite1hPrice,
CacheReadPrice: p.CacheReadPrice,
FastMultiplier: p.FastMultiplier,
FlexMultiplier: p.FlexMultiplier,
ImageInputPrice: p.ImageInputPrice,
ImageOutputPrice: p.ImageOutputPrice,
PerRequestPrice: p.PerRequestPrice,
Intervals: intervals,
TimePricing: timePricingToResponse(p.TimePricing),
ID: p.ID,
Platform: platform,
Models: models,
BillingMode: billingMode,
InputPrice: p.InputPrice,
OutputPrice: p.OutputPrice,
CacheWritePrice: p.CacheWritePrice,
CacheWrite1hPrice: p.CacheWrite1hPrice,
CacheReadPrice: p.CacheReadPrice,
FastMultiplier: p.FastMultiplier,
FlexMultiplier: p.FlexMultiplier,
MaxReasoningEffortMultiplier: p.MaxReasoningEffortMultiplier,
ImageInputPrice: p.ImageInputPrice,
ImageOutputPrice: p.ImageOutputPrice,
PerRequestPrice: p.PerRequestPrice,
Intervals: intervals,
TimePricing: timePricingToResponse(p.TimePricing),
}
}
@@ -350,27 +353,29 @@ func pricingRequestToService(reqs []channelModelPricingRequest, allowChannelMult
SortOrder: iv.SortOrder,
})
}
var fastMultiplier, flexMultiplier *float64
var fastMultiplier, flexMultiplier, maxReasoningEffortMultiplier *float64
if allowChannelMultipliers {
fastMultiplier = r.FastMultiplier
flexMultiplier = r.FlexMultiplier
maxReasoningEffortMultiplier = r.MaxReasoningEffortMultiplier
}
result = append(result, service.ChannelModelPricing{
Platform: platform,
Models: r.Models,
BillingMode: billingMode,
InputPrice: r.InputPrice,
OutputPrice: r.OutputPrice,
CacheWritePrice: r.CacheWritePrice,
CacheWrite1hPrice: r.CacheWrite1hPrice,
CacheReadPrice: r.CacheReadPrice,
FastMultiplier: fastMultiplier,
FlexMultiplier: flexMultiplier,
ImageInputPrice: r.ImageInputPrice,
ImageOutputPrice: r.ImageOutputPrice,
PerRequestPrice: r.PerRequestPrice,
Intervals: intervals,
TimePricing: timePricingRequestToService(r.TimePricing),
Platform: platform,
Models: r.Models,
BillingMode: billingMode,
InputPrice: r.InputPrice,
OutputPrice: r.OutputPrice,
CacheWritePrice: r.CacheWritePrice,
CacheWrite1hPrice: r.CacheWrite1hPrice,
CacheReadPrice: r.CacheReadPrice,
FastMultiplier: fastMultiplier,
FlexMultiplier: flexMultiplier,
MaxReasoningEffortMultiplier: maxReasoningEffortMultiplier,
ImageInputPrice: r.ImageInputPrice,
ImageOutputPrice: r.ImageOutputPrice,
PerRequestPrice: r.PerRequestPrice,
Intervals: intervals,
TimePricing: timePricingRequestToService(r.TimePricing),
})
}
return result
@@ -615,14 +620,15 @@ func (h *ChannelHandler) GetModelDefaultPricing(c *gin.Context) {
}
response.Success(c, gin.H{
"found": true,
"input_price": pricing.InputPricePerToken,
"output_price": pricing.OutputPricePerToken,
"cache_write_price": cacheWritePrice,
"cache_write_1h_price": cacheWrite1hPrice,
"cache_read_price": pricing.CacheReadPricePerToken,
"image_input_price": pricing.ImageInputPricePerToken,
"image_output_price": pricing.ImageOutputPricePerToken,
"found": true,
"input_price": pricing.InputPricePerToken,
"output_price": pricing.OutputPricePerToken,
"cache_write_price": cacheWritePrice,
"cache_write_1h_price": cacheWrite1hPrice,
"cache_read_price": pricing.CacheReadPricePerToken,
"max_reasoning_effort_multiplier": pricing.MaxReasoningEffortMultiplier,
"image_input_price": pricing.ImageInputPricePerToken,
"image_output_price": pricing.ImageOutputPricePerToken,
})
}
@@ -453,10 +453,11 @@ func TestPricingRequestToService_TimePricingNil(t *testing.T) {
// 避免渠道倍率意外污染账号成本口径。
func TestPricingRequestToService_MultipliersGatedByFlag(t *testing.T) {
req := channelModelPricingRequest{
Models: []string{"gpt-5"},
BillingMode: "token",
FastMultiplier: float64Ptr(2.5),
FlexMultiplier: float64Ptr(0.5),
Models: []string{"gpt-5"},
BillingMode: "token",
FastMultiplier: float64Ptr(2.5),
FlexMultiplier: float64Ptr(0.5),
MaxReasoningEffortMultiplier: float64Ptr(3),
Intervals: []pricingIntervalRequest{{
MinTokens: 272000,
InputMultiplier: float64Ptr(2),
@@ -469,6 +470,7 @@ func TestPricingRequestToService_MultipliersGatedByFlag(t *testing.T) {
allowed := pricingRequestToService([]channelModelPricingRequest{req}, true)
require.Equal(t, float64Ptr(2.5), allowed[0].FastMultiplier)
require.Equal(t, float64Ptr(0.5), allowed[0].FlexMultiplier)
require.Equal(t, float64Ptr(3), allowed[0].MaxReasoningEffortMultiplier)
require.Equal(t, float64Ptr(2), allowed[0].Intervals[0].InputMultiplier)
require.Equal(t, float64Ptr(1.5), allowed[0].Intervals[0].OutputMultiplier)
require.Equal(t, float64Ptr(2), allowed[0].Intervals[0].CacheWriteMultiplier)
@@ -477,6 +479,7 @@ func TestPricingRequestToService_MultipliersGatedByFlag(t *testing.T) {
dropped := pricingRequestToService([]channelModelPricingRequest{req}, false)
require.Nil(t, dropped[0].FastMultiplier)
require.Nil(t, dropped[0].FlexMultiplier)
require.Nil(t, dropped[0].MaxReasoningEffortMultiplier)
require.Nil(t, dropped[0].Intervals[0].InputMultiplier)
require.Nil(t, dropped[0].Intervals[0].OutputMultiplier)
require.Nil(t, dropped[0].Intervals[0].CacheWriteMultiplier)
@@ -580,9 +583,10 @@ func TestGetModelDefaultPricing_ReturnsFable51CacheTTLs(t *testing.T) {
require.Equal(t, http.StatusOK, w.Code)
var body struct {
Data struct {
Found bool `json:"found"`
CacheWritePrice float64 `json:"cache_write_price"`
CacheWrite1hPrice *float64 `json:"cache_write_1h_price"`
Found bool `json:"found"`
CacheWritePrice float64 `json:"cache_write_price"`
CacheWrite1hPrice *float64 `json:"cache_write_1h_price"`
MaxReasoningEffortMultiplier *float64 `json:"max_reasoning_effort_multiplier"`
} `json:"data"`
}
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
@@ -590,6 +594,8 @@ func TestGetModelDefaultPricing_ReturnsFable51CacheTTLs(t *testing.T) {
require.InDelta(t, 12.5e-6, body.Data.CacheWritePrice, 1e-12)
require.NotNil(t, body.Data.CacheWrite1hPrice)
require.InDelta(t, 20e-6, *body.Data.CacheWrite1hPrice, 1e-12)
require.NotNil(t, body.Data.MaxReasoningEffortMultiplier)
require.Equal(t, 3.0, *body.Data.MaxReasoningEffortMultiplier)
}
func TestGetModelDefaultPricing_OmitsUnsupportedCache1hPrice(t *testing.T) {
@@ -6,6 +6,7 @@ import (
"sync"
"time"
"github.com/Wei-Shaw/sub2api/internal/config"
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
"github.com/Wei-Shaw/sub2api/internal/service"
)
@@ -194,6 +195,7 @@ func (h *GrokOAuthHandler) scheduleGrokImportProbe(account *service.Account) {
// ProvideAccountHandler injects the Grok active prober for production while
// keeping NewAccountHandler convenient for focused unit tests.
func ProvideAccountHandler(
cfg *config.Config,
adminService service.AdminService,
oauthService *service.OAuthService,
openaiOAuthService *service.OpenAIOAuthService,
@@ -227,5 +229,6 @@ func ProvideAccountHandler(
tokenCacheInvalidator,
)
handler.grokImportProber = grokQuotaService
handler.cfg = cfg
return handler
}
+206 -5
View File
@@ -10,6 +10,7 @@ import (
"strings"
"time"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/handler/dto"
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
@@ -24,10 +25,14 @@ type GroupHandler struct {
adminService service.AdminService
dashboardService *service.DashboardService
groupCapacityService *service.GroupCapacityService
cfg *config.Config
}
// GetLiveCapability 返回当前服务端是否具备生成 Live attestation 的运行环境。
func (h *GroupHandler) GetLiveCapability(c *gin.Context) {
if h.rejectUnsupportedSimpleModeOperation(c, "live_capability") {
return
}
err := liveattestation.NewProvider().Check(c.Request.Context())
result := gin.H{"supported": err == nil}
if err != nil {
@@ -87,13 +92,94 @@ func (f optionalLimitField) ToServiceInput() *float64 {
// NewGroupHandler creates a new admin group handler
func NewGroupHandler(adminService service.AdminService, dashboardService *service.DashboardService, groupCapacityService *service.GroupCapacityService) *GroupHandler {
return NewGroupHandlerWithConfig(adminService, dashboardService, groupCapacityService, nil)
}
func NewGroupHandlerWithConfig(adminService service.AdminService, dashboardService *service.DashboardService, groupCapacityService *service.GroupCapacityService, cfg *config.Config) *GroupHandler {
return &GroupHandler{
adminService: adminService,
dashboardService: dashboardService,
groupCapacityService: groupCapacityService,
cfg: cfg,
}
}
func (h *GroupHandler) isSimpleMode() bool {
return h != nil && h.cfg != nil && h.cfg.RunMode == config.RunModeSimple
}
type simpleModeGroupOperation string
const (
simpleModeGroupList simpleModeGroupOperation = "list"
simpleModeGroupGetAll simpleModeGroupOperation = "get_all"
simpleModeGroupGet simpleModeGroupOperation = "get"
simpleModeGroupCreate simpleModeGroupOperation = "create"
simpleModeGroupUpdate simpleModeGroupOperation = "update"
simpleModeGroupDelete simpleModeGroupOperation = "delete"
)
var simpleModeGroupOperations = map[simpleModeGroupOperation]struct{}{
simpleModeGroupList: {}, simpleModeGroupGetAll: {}, simpleModeGroupGet: {},
simpleModeGroupCreate: {}, simpleModeGroupUpdate: {}, simpleModeGroupDelete: {},
}
func (h *GroupHandler) rejectUnsupportedSimpleModeOperation(c *gin.Context, operation simpleModeGroupOperation) bool {
if _, allowed := simpleModeGroupOperations[operation]; allowed {
return false
}
if err := service.ValidateSimpleModeGroupOperation(h.cfg, service.AdminGroupOperation(operation)); err != nil {
response.ErrorFrom(c, err)
return true
}
return false
}
type simpleModeGroupResponse struct {
ID int64 `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Platform string `json:"platform"`
Status string `json:"status"`
AccountCount int64 `json:"account_count,omitempty"`
ActiveAccountCount int64 `json:"active_account_count,omitempty"`
RateLimitedAccountCount int64 `json:"rate_limited_account_count,omitempty"`
SortOrder int `json:"sort_order"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func groupForSimpleMode(group *service.Group) *simpleModeGroupResponse {
if group == nil {
return nil
}
return &simpleModeGroupResponse{
ID: group.ID, Name: group.Name, Description: group.Description, Platform: group.Platform,
Status: group.Status,
AccountCount: group.AccountCount,
ActiveAccountCount: group.ActiveAccountCount, RateLimitedAccountCount: group.RateLimitedAccountCount,
SortOrder: group.SortOrder, CreatedAt: group.CreatedAt, UpdatedAt: group.UpdatedAt,
}
}
func sanitizeCreateGroupRequestForSimpleMode(req *CreateGroupRequest) {
if req == nil {
return
}
allowed := CreateGroupRequest{Name: req.Name, Description: req.Description, Platform: req.Platform}
allowed.RateMultiplier = 1
allowed.SubscriptionType = service.SubscriptionTypeStandard
*req = allowed
}
func sanitizeUpdateGroupRequestForSimpleMode(req *UpdateGroupRequest) {
if req == nil {
return
}
*req = UpdateGroupRequest{Name: req.Name, Description: req.Description}
}
// CreateGroupRequest represents create group request
type CreateGroupRequest struct {
Name string `json:"name" binding:"required"`
@@ -158,11 +244,11 @@ type CreateGroupRequest struct {
CodexModelsManifestConfig service.GroupCodexModelsManifestConfig `json:"codex_models_manifest_config"`
// 分组 RPM 上限(0 = 不限制)
RPMLimit int `json:"rpm_limit"`
// OpenAI/Codex 请求推理强度上限,空字符串表示不限制。
// Anthropic/OpenAI 请求推理强度上限,空字符串表示不限制。
MaxReasoningEffort string `json:"max_reasoning_effort"`
// 超过上限时的访问控制:downgrade(默认)或 deny。
MaxReasoningEffortOverLimit string `json:"max_reasoning_effort_over_limit"`
// OpenAI/Codex 推理强度映射,可按模型精确名、前缀或后缀限定。
// Anthropic/OpenAI 推理强度映射,可按模型精确名、前缀或后缀限定。
ReasoningEffortMappings []service.ReasoningEffortMapping `json:"reasoning_effort_mappings"`
// 从指定分组复制账号(创建后自动绑定)
CopyAccountsFromGroupIDs []int64 `json:"copy_accounts_from_group_ids"`
@@ -233,7 +319,7 @@ type UpdateGroupRequest struct {
CodexModelsManifestConfig *service.GroupCodexModelsManifestConfig `json:"codex_models_manifest_config"`
// 分组 RPM 上限(0 = 不限制);nil 表示未提供不改动
RPMLimit *int `json:"rpm_limit"`
// OpenAI/Codex 请求推理强度上限;空字符串清除,nil 不修改。
// Anthropic/OpenAI 请求推理强度上限;空字符串清除,nil 不修改。
MaxReasoningEffort *string `json:"max_reasoning_effort"`
// 超过上限时的访问控制;空字符串视为 downgrade,nil 不修改。
MaxReasoningEffortOverLimit *string `json:"max_reasoning_effort_over_limit"`
@@ -262,6 +348,9 @@ type CompositeRoutePreviewRequest struct {
// List handles listing all groups with pagination
// GET /api/v1/admin/groups
func (h *GroupHandler) List(c *gin.Context) {
if h.rejectUnsupportedSimpleModeOperation(c, simpleModeGroupList) {
return
}
page, pageSize := response.ParsePagination(c)
platform := c.Query("platform")
status := c.Query("status")
@@ -276,7 +365,7 @@ func (h *GroupHandler) List(c *gin.Context) {
sortOrder := c.DefaultQuery("sort_order", "asc")
var isExclusive *bool
if isExclusiveStr != "" {
if !h.isSimpleMode() && isExclusiveStr != "" {
val := isExclusiveStr == "true"
isExclusive = &val
}
@@ -287,6 +376,16 @@ func (h *GroupHandler) List(c *gin.Context) {
return
}
if h.isSimpleMode() {
simpleGroups := make([]simpleModeGroupResponse, 0, len(groups))
for i := range groups {
if service.IsGroupBindableInSimpleMode(&groups[i]) {
simpleGroups = append(simpleGroups, *groupForSimpleMode(&groups[i]))
}
}
response.Paginated(c, simpleGroups, total, page, pageSize)
return
}
outGroups := make([]dto.AdminGroup, 0, len(groups))
for i := range groups {
outGroups = append(outGroups, *dto.GroupFromServiceAdmin(&groups[i]))
@@ -297,6 +396,9 @@ func (h *GroupHandler) List(c *gin.Context) {
// ListCompositeRoutes handles listing composite model routes for one group.
// GET /api/v1/admin/groups/:id/composite-routes
func (h *GroupHandler) ListCompositeRoutes(c *gin.Context) {
if h.rejectUnsupportedSimpleModeOperation(c, "advanced") {
return
}
groupID, ok := parsePositiveIDParam(c, "id")
if !ok {
return
@@ -312,6 +414,9 @@ func (h *GroupHandler) ListCompositeRoutes(c *gin.Context) {
// CreateCompositeRoute handles creating one composite model route.
// POST /api/v1/admin/groups/:id/composite-routes
func (h *GroupHandler) CreateCompositeRoute(c *gin.Context) {
if h.rejectUnsupportedSimpleModeOperation(c, "advanced") {
return
}
groupID, ok := parsePositiveIDParam(c, "id")
if !ok {
return
@@ -332,6 +437,9 @@ func (h *GroupHandler) CreateCompositeRoute(c *gin.Context) {
// UpdateCompositeRoute handles replacing one composite model route.
// PUT /api/v1/admin/groups/:id/composite-routes/:route_id
func (h *GroupHandler) UpdateCompositeRoute(c *gin.Context) {
if h.rejectUnsupportedSimpleModeOperation(c, "advanced") {
return
}
groupID, ok := parsePositiveIDParam(c, "id")
if !ok {
return
@@ -356,6 +464,9 @@ func (h *GroupHandler) UpdateCompositeRoute(c *gin.Context) {
// DeleteCompositeRoute handles deleting one composite model route.
// DELETE /api/v1/admin/groups/:id/composite-routes/:route_id
func (h *GroupHandler) DeleteCompositeRoute(c *gin.Context) {
if h.rejectUnsupportedSimpleModeOperation(c, "advanced") {
return
}
groupID, ok := parsePositiveIDParam(c, "id")
if !ok {
return
@@ -374,6 +485,9 @@ func (h *GroupHandler) DeleteCompositeRoute(c *gin.Context) {
// PreviewCompositeRoute resolves a model without mutating routes.
// POST /api/v1/admin/groups/:id/composite-routes/preview
func (h *GroupHandler) PreviewCompositeRoute(c *gin.Context) {
if h.rejectUnsupportedSimpleModeOperation(c, "advanced") {
return
}
groupID, ok := parsePositiveIDParam(c, "id")
if !ok {
return
@@ -427,6 +541,9 @@ func parsePositiveIDParam(c *gin.Context, name string) (int64, bool) {
// bound to them even after the group is disabled).
// GET /api/v1/admin/groups/all
func (h *GroupHandler) GetAll(c *gin.Context) {
if h.rejectUnsupportedSimpleModeOperation(c, simpleModeGroupGetAll) {
return
}
platform := c.Query("platform")
includeInactive := c.Query("include_inactive") == "true"
@@ -446,6 +563,16 @@ func (h *GroupHandler) GetAll(c *gin.Context) {
return
}
if h.isSimpleMode() {
simpleGroups := make([]simpleModeGroupResponse, 0, len(groups))
for i := range groups {
if service.IsGroupBindableInSimpleMode(&groups[i]) {
simpleGroups = append(simpleGroups, *groupForSimpleMode(&groups[i]))
}
}
response.Success(c, simpleGroups)
return
}
outGroups := make([]dto.AdminGroup, 0, len(groups))
for i := range groups {
outGroups = append(outGroups, *dto.GroupFromServiceAdmin(&groups[i]))
@@ -456,6 +583,9 @@ func (h *GroupHandler) GetAll(c *gin.Context) {
// GetByID handles getting a group by ID
// GET /api/v1/admin/groups/:id
func (h *GroupHandler) GetByID(c *gin.Context) {
if h.rejectUnsupportedSimpleModeOperation(c, simpleModeGroupGet) {
return
}
groupID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
response.BadRequest(c, "Invalid group ID")
@@ -468,12 +598,19 @@ func (h *GroupHandler) GetByID(c *gin.Context) {
return
}
if h.isSimpleMode() {
response.Success(c, groupForSimpleMode(group))
return
}
response.Success(c, dto.GroupFromServiceAdmin(group))
}
// GetGroupModelAllowlistCandidates handles getting candidate model IDs for the group model allowlist.
// GET /api/v1/admin/groups/:id/model-allowlist-candidates
func (h *GroupHandler) GetGroupModelAllowlistCandidates(c *gin.Context) {
if h.rejectUnsupportedSimpleModeOperation(c, "advanced") {
return
}
groupID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil || groupID < 0 {
response.BadRequest(c, "Invalid group ID")
@@ -496,11 +633,21 @@ func (h *GroupHandler) GetGroupModelAllowlistCandidates(c *gin.Context) {
// Create handles creating a new group
// POST /api/v1/admin/groups
func (h *GroupHandler) Create(c *gin.Context) {
if h.rejectUnsupportedSimpleModeOperation(c, simpleModeGroupCreate) {
return
}
var req CreateGroupRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "Invalid request: "+err.Error())
return
}
if h.isSimpleMode() && req.Platform == service.PlatformComposite {
response.BadRequest(c, "Platform is not supported in simple mode")
return
}
if h.isSimpleMode() {
sanitizeCreateGroupRequestForSimpleMode(&req)
}
if err := service.ValidatePeakRateConfig(req.SubscriptionType, req.PeakRateEnabled, req.PeakStart, req.PeakEnd, float64ValueOrDefault(req.PeakRateMultiplier, 1.0)); err != nil {
response.BadRequest(c, err.Error())
@@ -581,12 +728,19 @@ func (h *GroupHandler) Create(c *gin.Context) {
return
}
if h.isSimpleMode() {
response.Success(c, groupForSimpleMode(group))
return
}
response.Success(c, dto.GroupFromServiceAdmin(group))
}
// Duplicate handles creating an inactive group copy with the source account bindings.
// POST /api/v1/admin/groups/:id/duplicate
func (h *GroupHandler) Duplicate(c *gin.Context) {
if h.rejectUnsupportedSimpleModeOperation(c, "advanced") {
return
}
groupID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil || groupID <= 0 {
response.BadRequest(c, "Invalid group ID")
@@ -634,6 +788,9 @@ func (h *GroupHandler) Duplicate(c *gin.Context) {
// Update handles updating a group
// PUT /api/v1/admin/groups/:id
func (h *GroupHandler) Update(c *gin.Context) {
if h.rejectUnsupportedSimpleModeOperation(c, simpleModeGroupUpdate) {
return
}
groupID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
response.BadRequest(c, "Invalid group ID")
@@ -645,6 +802,9 @@ func (h *GroupHandler) Update(c *gin.Context) {
response.BadRequest(c, "Invalid request: "+err.Error())
return
}
if h.isSimpleMode() {
sanitizeUpdateGroupRequestForSimpleMode(&req)
}
group, err := h.adminService.UpdateGroup(c.Request.Context(), groupID, &service.UpdateGroupInput{
Name: req.Name,
@@ -714,19 +874,30 @@ func (h *GroupHandler) Update(c *gin.Context) {
return
}
if h.isSimpleMode() {
response.Success(c, groupForSimpleMode(group))
return
}
response.Success(c, dto.GroupFromServiceAdmin(group))
}
// Delete handles deleting a group
// DELETE /api/v1/admin/groups/:id
func (h *GroupHandler) Delete(c *gin.Context) {
if h.rejectUnsupportedSimpleModeOperation(c, simpleModeGroupDelete) {
return
}
groupID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
response.BadRequest(c, "Invalid group ID")
return
}
err = h.adminService.DeleteGroup(c.Request.Context(), groupID)
if h.isSimpleMode() {
err = h.adminService.DeleteGroupIfEmpty(c.Request.Context(), groupID)
} else {
err = h.adminService.DeleteGroup(c.Request.Context(), groupID)
}
if err != nil {
response.ErrorFrom(c, err)
return
@@ -738,6 +909,9 @@ func (h *GroupHandler) Delete(c *gin.Context) {
// GetStats handles getting group statistics
// GET /api/v1/admin/groups/:id/stats
func (h *GroupHandler) GetStats(c *gin.Context) {
if h.rejectUnsupportedSimpleModeOperation(c, "stats") {
return
}
groupID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
response.BadRequest(c, "Invalid group ID")
@@ -757,6 +931,9 @@ func (h *GroupHandler) GetStats(c *gin.Context) {
// GetUsageSummary returns today's, yesterday's, and cumulative cost for all groups.
// GET /api/v1/admin/groups/usage-summary
func (h *GroupHandler) GetUsageSummary(c *gin.Context) {
if h.rejectUnsupportedSimpleModeOperation(c, "advanced") {
return
}
todayStart := service.GroupUsageTodayStart(time.Now())
results, err := h.dashboardService.GetGroupUsageSummary(c.Request.Context(), todayStart)
@@ -771,6 +948,9 @@ func (h *GroupHandler) GetUsageSummary(c *gin.Context) {
// GetCapacitySummary returns aggregated capacity (concurrency/sessions/RPM) for all active groups.
// GET /api/v1/admin/groups/capacity-summary
func (h *GroupHandler) GetCapacitySummary(c *gin.Context) {
if h.rejectUnsupportedSimpleModeOperation(c, "advanced") {
return
}
results, err := h.groupCapacityService.GetAllGroupCapacity(c.Request.Context())
if err != nil {
response.Error(c, 500, "Failed to get group capacity summary")
@@ -782,6 +962,9 @@ func (h *GroupHandler) GetCapacitySummary(c *gin.Context) {
// GetGroupAPIKeys handles getting API keys in a group
// GET /api/v1/admin/groups/:id/api-keys
func (h *GroupHandler) GetGroupAPIKeys(c *gin.Context) {
if h.rejectUnsupportedSimpleModeOperation(c, "api_keys") {
return
}
groupID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
response.BadRequest(c, "Invalid group ID")
@@ -806,6 +989,9 @@ func (h *GroupHandler) GetGroupAPIKeys(c *gin.Context) {
// GetGroupRateMultipliers handles getting rate multipliers for users in a group
// GET /api/v1/admin/groups/:id/rate-multipliers
func (h *GroupHandler) GetGroupRateMultipliers(c *gin.Context) {
if h.rejectUnsupportedSimpleModeOperation(c, "advanced") {
return
}
groupID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
response.BadRequest(c, "Invalid group ID")
@@ -827,6 +1013,9 @@ func (h *GroupHandler) GetGroupRateMultipliers(c *gin.Context) {
// ClearGroupRateMultipliers handles clearing all rate multipliers for a group
// DELETE /api/v1/admin/groups/:id/rate-multipliers
func (h *GroupHandler) ClearGroupRateMultipliers(c *gin.Context) {
if h.rejectUnsupportedSimpleModeOperation(c, "advanced") {
return
}
groupID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
response.BadRequest(c, "Invalid group ID")
@@ -849,6 +1038,9 @@ type BatchSetGroupRateMultipliersRequest struct {
// BatchSetGroupRateMultipliers handles batch setting rate multipliers for a group
// PUT /api/v1/admin/groups/:id/rate-multipliers
func (h *GroupHandler) BatchSetGroupRateMultipliers(c *gin.Context) {
if h.rejectUnsupportedSimpleModeOperation(c, "advanced") {
return
}
groupID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
response.BadRequest(c, "Invalid group ID")
@@ -877,6 +1069,9 @@ type BatchSetGroupRPMOverridesRequest struct {
// BatchSetGroupRPMOverrides handles batch setting rpm_override for users in a group
// PUT /api/v1/admin/groups/:id/rpm-overrides
func (h *GroupHandler) BatchSetGroupRPMOverrides(c *gin.Context) {
if h.rejectUnsupportedSimpleModeOperation(c, "advanced") {
return
}
groupID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
response.BadRequest(c, "Invalid group ID")
@@ -900,6 +1095,9 @@ func (h *GroupHandler) BatchSetGroupRPMOverrides(c *gin.Context) {
// ClearGroupRPMOverrides handles clearing all rpm_override for a group
// DELETE /api/v1/admin/groups/:id/rpm-overrides
func (h *GroupHandler) ClearGroupRPMOverrides(c *gin.Context) {
if h.rejectUnsupportedSimpleModeOperation(c, "advanced") {
return
}
groupID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
response.BadRequest(c, "Invalid group ID")
@@ -925,6 +1123,9 @@ type UpdateSortOrderRequest struct {
// UpdateSortOrder handles updating group sort orders
// PUT /api/v1/admin/groups/sort-order
func (h *GroupHandler) UpdateSortOrder(c *gin.Context) {
if h.rejectUnsupportedSimpleModeOperation(c, "advanced") {
return
}
var req UpdateSortOrderRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "Invalid request: "+err.Error())
@@ -0,0 +1,309 @@
package admin
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
func newSimpleModeGroupRouter(svc *stubAdminService) *gin.Engine {
h := NewGroupHandlerWithConfig(svc, nil, nil, &config.Config{RunMode: config.RunModeSimple})
r := gin.New()
r.GET("/groups", h.List)
r.GET("/groups/all", h.GetAll)
r.GET("/groups/live-capability", h.GetLiveCapability)
r.GET("/groups/usage-summary", h.GetUsageSummary)
r.GET("/groups/capacity-summary", h.GetCapacitySummary)
r.PUT("/groups/sort-order", h.UpdateSortOrder)
r.GET("/groups/:id", h.GetByID)
r.GET("/groups/:id/stats", h.GetStats)
r.GET("/groups/:id/api-keys", h.GetGroupAPIKeys)
r.GET("/groups/:id/model-allowlist-candidates", h.GetGroupModelAllowlistCandidates)
r.POST("/groups", h.Create)
r.PUT("/groups/:id", h.Update)
r.POST("/groups/:id/duplicate", h.Duplicate)
r.GET("/groups/:id/composite-routes", h.ListCompositeRoutes)
r.POST("/groups/:id/composite-routes", h.CreateCompositeRoute)
r.POST("/groups/:id/composite-routes/preview", h.PreviewCompositeRoute)
r.PUT("/groups/:id/composite-routes/:route_id", h.UpdateCompositeRoute)
r.DELETE("/groups/:id/composite-routes/:route_id", h.DeleteCompositeRoute)
r.GET("/groups/:id/rate-multipliers", h.GetGroupRateMultipliers)
r.PUT("/groups/:id/rate-multipliers", h.BatchSetGroupRateMultipliers)
r.DELETE("/groups/:id/rate-multipliers", h.ClearGroupRateMultipliers)
r.PUT("/groups/:id/rpm-overrides", h.BatchSetGroupRPMOverrides)
r.DELETE("/groups/:id/rpm-overrides", h.ClearGroupRPMOverrides)
r.DELETE("/groups/:id", h.Delete)
return r
}
func TestGroupHandlerSimpleModeSanitizesCommercialFields(t *testing.T) {
gin.SetMode(gin.TestMode)
svc := newStubAdminService()
r := newSimpleModeGroupRouter(svc)
create := `{"name":"simple","description":"basic grouping","platform":"anthropic","rate_multiplier":7,"is_exclusive":true,"subscription_type":"subscription","daily_limit_usd":10,"long_context_pricing_enabled":true,"model_pricing":[{"model":"claude","input_price":1}],"allow_image_generation":true,"allow_batch_image_generation":true,"video_price_720p":2,"web_search_price_per_call":3,"audio_realtime_price_per_min":4,"rpm_limit":99}`
req := httptest.NewRequest(http.MethodPost, "/groups", bytes.NewBufferString(create))
req.Header.Set("Content-Type", "application/json")
res := httptest.NewRecorder()
r.ServeHTTP(res, req)
require.Equal(t, http.StatusOK, res.Code)
require.Len(t, svc.createdGroups, 1)
created := svc.createdGroups[0]
require.Equal(t, "basic grouping", created.Description)
require.Equal(t, 1.0, created.RateMultiplier)
require.False(t, created.IsExclusive)
require.Equal(t, service.SubscriptionTypeStandard, created.SubscriptionType)
require.Nil(t, created.DailyLimitUSD)
require.False(t, created.AllowImageGeneration)
require.False(t, created.LongContextPricingEnabled)
require.Empty(t, created.ModelPricing)
require.False(t, created.AllowBatchImageGeneration)
require.Nil(t, created.VideoPrice720P)
require.Nil(t, created.WebSearchPricePerCall)
require.Nil(t, created.AudioRealtimePricePerMin)
require.Zero(t, created.RPMLimit)
update := `{"name":"renamed","description":"still basic","rate_multiplier":9,"is_exclusive":true,"subscription_type":"subscription","daily_limit_usd":12,"long_context_pricing_enabled":true,"model_pricing":[{"model":"claude","input_price":1}],"allow_image_generation":true,"allow_batch_image_generation":true,"video_price_720p":2,"web_search_price_per_call":3,"audio_realtime_price_per_min":4,"status":"inactive","rpm_limit":123}`
req = httptest.NewRequest(http.MethodPut, "/groups/2", bytes.NewBufferString(update))
req.Header.Set("Content-Type", "application/json")
res = httptest.NewRecorder()
r.ServeHTTP(res, req)
require.Equal(t, http.StatusOK, res.Code)
require.Len(t, svc.updatedGroups, 1)
updated := svc.updatedGroups[0]
require.NotNil(t, updated.Description)
require.Equal(t, "still basic", *updated.Description)
require.Nil(t, updated.RateMultiplier)
require.Nil(t, updated.IsExclusive)
require.Empty(t, updated.SubscriptionType)
require.Nil(t, updated.DailyLimitUSD)
require.Nil(t, updated.AllowImageGeneration)
require.Nil(t, updated.LongContextPricingEnabled)
require.Nil(t, updated.ModelPricing)
require.Nil(t, updated.AllowBatchImageGeneration)
require.Nil(t, updated.VideoPrice720P)
require.Nil(t, updated.WebSearchPricePerCall)
require.Nil(t, updated.AudioRealtimePricePerMin)
require.Empty(t, updated.Status)
require.Nil(t, updated.RPMLimit)
}
func TestGroupHandlerSimpleModeRejectsCompositeBeforeService(t *testing.T) {
svc := newStubAdminService()
r := newSimpleModeGroupRouter(svc)
req := httptest.NewRequest(http.MethodPost, "/groups", bytes.NewBufferString(`{"name":"composite","platform":"composite"}`))
req.Header.Set("Content-Type", "application/json")
res := httptest.NewRecorder()
r.ServeHTTP(res, req)
require.Equal(t, http.StatusBadRequest, res.Code)
require.Empty(t, svc.createdGroups)
}
func TestGroupHandlerSimpleModeDeleteRejectsNonEmptyGroupBeforeCascade(t *testing.T) {
svc := newStubAdminService()
svc.deleteGroupIfEmptyErr = service.ErrGroupNotEmpty
r := newSimpleModeGroupRouter(svc)
res := httptest.NewRecorder()
r.ServeHTTP(res, httptest.NewRequest(http.MethodDelete, "/groups/7", nil))
require.Equal(t, http.StatusConflict, res.Code)
require.Equal(t, []int64{7}, svc.guardedDeletedGroupIDs)
require.Empty(t, svc.deletedGroupIDs)
}
func TestGroupHandlerSimpleModeDeletePermitsEmptyGroup(t *testing.T) {
svc := newStubAdminService()
r := newSimpleModeGroupRouter(svc)
res := httptest.NewRecorder()
r.ServeHTTP(res, httptest.NewRequest(http.MethodDelete, "/groups/8", nil))
require.Equal(t, http.StatusOK, res.Code)
require.Equal(t, []int64{8}, svc.guardedDeletedGroupIDs)
require.Empty(t, svc.deletedGroupIDs)
}
func TestGroupHandlerSimpleModeIgnoresExclusiveFilter(t *testing.T) {
svc := newStubAdminService()
r := newSimpleModeGroupRouter(svc)
res := httptest.NewRecorder()
r.ServeHTTP(res, httptest.NewRequest(http.MethodGet, "/groups?is_exclusive=true", nil))
require.Equal(t, http.StatusOK, res.Code)
require.Nil(t, svc.lastListGroupsIsExclusive)
}
func float64PtrForSimpleModeTest(value float64) *float64 { return &value }
func TestGroupHandlerSimpleModeResponseUsesFieldAllowlist(t *testing.T) {
gin.SetMode(gin.TestMode)
svc := newStubAdminService()
svc.groups = []service.Group{{
ID: 1, Name: "basic", Description: "allowed", Platform: service.PlatformAnthropic,
AccountCount: 3, ActiveAccountCount: 2, RateLimitedAccountCount: 1,
Status: service.StatusActive, RateMultiplier: 9, RPMLimit: 42,
LongContextPricingEnabled: true,
ModelPricing: []service.ChannelModelPricing{{Models: []string{"claude"}}},
AllowBatchImageGeneration: true, VideoPrice720P: float64PtrForSimpleModeTest(2),
WebSearchPricePerCall: float64PtrForSimpleModeTest(3), AudioRealtimePricePerMin: float64PtrForSimpleModeTest(4),
ModelRouting: map[string][]int64{"claude": {2}},
}}
r := newSimpleModeGroupRouter(svc)
res := httptest.NewRecorder()
r.ServeHTTP(res, httptest.NewRequest(http.MethodGet, "/groups", nil))
require.Equal(t, http.StatusOK, res.Code)
var payload struct {
Data struct {
Items []map[string]any `json:"items"`
} `json:"data"`
}
require.NoError(t, json.Unmarshal(res.Body.Bytes(), &payload))
require.Len(t, payload.Data.Items, 1)
item := payload.Data.Items[0]
require.Equal(t, "basic", item["name"])
require.Equal(t, "allowed", item["description"])
require.Equal(t, "anthropic", item["platform"])
require.Equal(t, float64(3), item["account_count"])
require.ElementsMatch(t, []string{
"id", "name", "description", "platform", "status", "account_count",
"active_account_count", "rate_limited_account_count", "sort_order", "created_at", "updated_at",
}, mapKeys(item))
for _, forbidden := range []string{
"rate_multiplier", "rpm_limit", "long_context_pricing_enabled", "model_pricing",
"allow_batch_image_generation", "video_price_720p", "web_search_price_per_call",
"audio_realtime_price_per_min", "model_routing",
} {
_, exposed := item[forbidden]
require.Falsef(t, exposed, "simple mode exposed %s", forbidden)
}
}
func TestGroupHandlerSimpleModeListsOnlyBindableGroups(t *testing.T) {
svc := newStubAdminService()
svc.groups = []service.Group{{ID: 1, Name: "basic", Platform: service.PlatformAnthropic}, {ID: 2, Name: "composite", Platform: service.PlatformComposite}}
r := newSimpleModeGroupRouter(svc)
for _, path := range []string{"/groups", "/groups/all"} {
res := httptest.NewRecorder()
r.ServeHTTP(res, httptest.NewRequest(http.MethodGet, path, nil))
require.Equal(t, http.StatusOK, res.Code)
require.Contains(t, res.Body.String(), `"name":"basic"`)
require.NotContains(t, res.Body.String(), `"name":"composite"`)
}
}
func mapKeys(values map[string]any) []string {
keys := make([]string, 0, len(values))
for key := range values {
keys = append(keys, key)
}
return keys
}
func TestGroupHandlerSimpleModeAllReadAndWriteResponsesUseFieldAllowlist(t *testing.T) {
gin.SetMode(gin.TestMode)
svc := newStubAdminService()
svc.groups = []service.Group{{
ID: 1, Name: "basic", Description: "allowed", Platform: service.PlatformAnthropic, AccountCount: 3,
Status: service.StatusActive, RateMultiplier: 9, RPMLimit: 42,
LongContextPricingEnabled: true,
ModelPricing: []service.ChannelModelPricing{{Models: []string{"claude"}}},
}}
r := newSimpleModeGroupRouter(svc)
assertNoAdvancedFields := func(t *testing.T, item map[string]any) {
t.Helper()
for _, forbidden := range []string{
"rate_multiplier", "rpm_limit", "long_context_pricing_enabled", "model_pricing",
"allow_batch_image_generation", "video_price_720p", "web_search_price_per_call",
"audio_realtime_price_per_min", "model_routing",
} {
_, exposed := item[forbidden]
require.Falsef(t, exposed, "simple mode exposed %s", forbidden)
}
}
for _, tt := range []struct {
name string
method string
path string
body string
list bool
}{
{name: "get all", method: http.MethodGet, path: "/groups/all", list: true},
{name: "get by id", method: http.MethodGet, path: "/groups/1"},
{name: "create", method: http.MethodPost, path: "/groups", body: `{"name":"created","platform":"anthropic","model_pricing":[{"models":["claude"]}]}`},
{name: "update", method: http.MethodPut, path: "/groups/1", body: `{"name":"updated","model_pricing":[{"models":["claude"]}]}`},
} {
t.Run(tt.name, func(t *testing.T) {
res := httptest.NewRecorder()
req := httptest.NewRequest(tt.method, tt.path, bytes.NewBufferString(tt.body))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(res, req)
require.Equal(t, http.StatusOK, res.Code)
var payload struct {
Data json.RawMessage `json:"data"`
}
require.NoError(t, json.Unmarshal(res.Body.Bytes(), &payload))
if tt.list {
var items []map[string]any
require.NoError(t, json.Unmarshal(payload.Data, &items))
require.Len(t, items, 1)
assertNoAdvancedFields(t, items[0])
return
}
var item map[string]any
require.NoError(t, json.Unmarshal(payload.Data, &item))
assertNoAdvancedFields(t, item)
})
}
}
func TestGroupHandlerSimpleModeBlocksAdvancedOperations(t *testing.T) {
gin.SetMode(gin.TestMode)
tests := []struct {
method string
path string
body string
}{
{http.MethodPost, "/groups/1/duplicate", ""},
{http.MethodGet, "/groups/1/model-allowlist-candidates", ""},
{http.MethodGet, "/groups/1/composite-routes", ""},
{http.MethodPost, "/groups/1/composite-routes", `{"public_model":"x","target_platform":"openai"}`},
{http.MethodPost, "/groups/1/composite-routes/preview", `{"model":"x"}`},
{http.MethodPut, "/groups/1/composite-routes/2", `{"public_model":"x","target_platform":"openai"}`},
{http.MethodDelete, "/groups/1/composite-routes/2", ""},
{http.MethodGet, "/groups/1/rate-multipliers", ""},
{http.MethodPut, "/groups/1/rate-multipliers", `{"entries":[]}`},
{http.MethodDelete, "/groups/1/rate-multipliers", ""},
{http.MethodPut, "/groups/1/rpm-overrides", `{"entries":[]}`},
{http.MethodDelete, "/groups/1/rpm-overrides", ""},
{http.MethodGet, "/groups/1/stats", ""},
{http.MethodGet, "/groups/1/api-keys", ""},
{http.MethodGet, "/groups/live-capability", ""},
{http.MethodGet, "/groups/usage-summary", ""},
{http.MethodGet, "/groups/capacity-summary", ""},
{http.MethodPut, "/groups/sort-order", `{"updates":[{"id":1,"sort_order":1}]}`},
}
for _, tt := range tests {
t.Run(tt.method+" "+tt.path, func(t *testing.T) {
svc := newStubAdminService()
r := newSimpleModeGroupRouter(svc)
res := httptest.NewRecorder()
req := httptest.NewRequest(tt.method, tt.path, bytes.NewBufferString(tt.body))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(res, req)
require.Equal(t, http.StatusForbidden, res.Code)
require.Zero(t, svc.advancedGroupOperationCalls)
})
}
}
@@ -228,7 +228,7 @@ func (h *RedeemHandler) CreateAndRedeem(c *gin.Context) {
return nil, createErr
}
redeemed, redeemErr := h.redeemService.Redeem(ctx, req.UserID, req.Code)
redeemed, redeemErr := h.redeemService.RedeemForAdminFulfillment(ctx, req.UserID, req.Code)
if redeemErr != nil {
return nil, redeemErr
}
@@ -246,7 +246,7 @@ func (h *RedeemHandler) resolveCreateAndRedeemExisting(ctx context.Context, exis
return nil, service.ErrRedeemCodeExpired
}
if existing.CanUse() {
redeemed, err := h.redeemService.Redeem(ctx, userID, existing.Code)
redeemed, err := h.redeemService.RedeemForAdminFulfillment(ctx, userID, existing.Code)
if err == nil {
return gin.H{"redeem_code": dto.RedeemCodeFromServiceAdmin(redeemed)}, nil
}
@@ -66,16 +66,17 @@ type userAvailableGroup struct {
// userSupportedModelPricing 用户可见的定价字段白名单。
type userSupportedModelPricing struct {
BillingMode string `json:"billing_mode"`
InputPrice *float64 `json:"input_price"`
OutputPrice *float64 `json:"output_price"`
CacheWritePrice *float64 `json:"cache_write_price"`
CacheWrite1hPrice *float64 `json:"cache_write_1h_price"`
CacheReadPrice *float64 `json:"cache_read_price"`
ImageInputPrice *float64 `json:"image_input_price"`
ImageOutputPrice *float64 `json:"image_output_price"`
PerRequestPrice *float64 `json:"per_request_price"`
Intervals []userPricingIntervalDTO `json:"intervals"`
BillingMode string `json:"billing_mode"`
InputPrice *float64 `json:"input_price"`
OutputPrice *float64 `json:"output_price"`
CacheWritePrice *float64 `json:"cache_write_price"`
CacheWrite1hPrice *float64 `json:"cache_write_1h_price"`
CacheReadPrice *float64 `json:"cache_read_price"`
MaxReasoningEffortMultiplier *float64 `json:"max_reasoning_effort_multiplier,omitempty"`
ImageInputPrice *float64 `json:"image_input_price"`
ImageOutputPrice *float64 `json:"image_output_price"`
PerRequestPrice *float64 `json:"per_request_price"`
Intervals []userPricingIntervalDTO `json:"intervals"`
}
// userPricingIntervalDTO 定价区间白名单(去掉内部 ID、SortOrder 等前端不渲染的字段)。
@@ -323,15 +324,16 @@ func toUserPricing(p *service.ChannelModelPricing) *userSupportedModelPricing {
billingMode = string(service.BillingModeToken)
}
return &userSupportedModelPricing{
BillingMode: billingMode,
InputPrice: p.InputPrice,
OutputPrice: p.OutputPrice,
CacheWritePrice: p.CacheWritePrice,
CacheWrite1hPrice: p.CacheWrite1hPrice,
CacheReadPrice: p.CacheReadPrice,
ImageInputPrice: p.ImageInputPrice,
ImageOutputPrice: p.ImageOutputPrice,
PerRequestPrice: p.PerRequestPrice,
Intervals: intervals,
BillingMode: billingMode,
InputPrice: p.InputPrice,
OutputPrice: p.OutputPrice,
CacheWritePrice: p.CacheWritePrice,
CacheWrite1hPrice: p.CacheWrite1hPrice,
CacheReadPrice: p.CacheReadPrice,
MaxReasoningEffortMultiplier: p.MaxReasoningEffortMultiplier,
ImageInputPrice: p.ImageInputPrice,
ImageOutputPrice: p.ImageOutputPrice,
PerRequestPrice: p.PerRequestPrice,
Intervals: intervals,
}
}
+43 -3
View File
@@ -64,13 +64,45 @@ func openAIReasoningEffortPolicyForRequest(c *gin.Context, apiKey *service.APIKe
if apiKey == nil || apiKey.Group == nil {
return "", nil, "", false
}
if apiKey.Group.Platform != service.PlatformOpenAI && apiKey.Group.Platform != service.PlatformComposite {
if apiKey.Group.Platform != service.PlatformAnthropic && apiKey.Group.Platform != service.PlatformOpenAI && apiKey.Group.Platform != service.PlatformComposite {
return "", nil, "", false
}
if effectiveAPIKeyPlatform(c, apiKey) != service.PlatformOpenAI {
effectivePlatform := effectiveAPIKeyPlatform(c, apiKey)
if effectivePlatform != service.PlatformAnthropic && effectivePlatform != service.PlatformOpenAI {
return "", nil, "", false
}
return apiKey.Group.MaxReasoningEffort, apiKey.Group.ReasoningEffortMappings, apiKey.Group.MaxReasoningEffortOverLimit, true
maxEffort, mappings := apiKey.Group.MaxReasoningEffort, apiKey.Group.ReasoningEffortMappings
if effectivePlatform == service.PlatformAnthropic {
maxEffort, mappings = anthropicCompatibleReasoningEffortPolicy(maxEffort, mappings)
}
return maxEffort, mappings, apiKey.Group.MaxReasoningEffortOverLimit, true
}
func anthropicReasoningEffortPolicyForRequest(c *gin.Context, apiKey *service.APIKey) (string, []service.ReasoningEffortMapping, string, bool) {
if apiKey == nil || apiKey.Group == nil {
return "", nil, "", false
}
if apiKey.Group.Platform != service.PlatformAnthropic && apiKey.Group.Platform != service.PlatformComposite {
return "", nil, "", false
}
if effectiveAPIKeyPlatform(c, apiKey) != service.PlatformAnthropic {
return "", nil, "", false
}
maxEffort, mappings := anthropicCompatibleReasoningEffortPolicy(apiKey.Group.MaxReasoningEffort, apiKey.Group.ReasoningEffortMappings)
return maxEffort, mappings, apiKey.Group.MaxReasoningEffortOverLimit, true
}
func anthropicCompatibleReasoningEffortPolicy(maxEffort string, mappings []service.ReasoningEffortMapping) (string, []service.ReasoningEffortMapping) {
if service.NormalizeMaxReasoningEffort(maxEffort) == "minimal" {
maxEffort = "low"
}
normalizedMappings := append([]service.ReasoningEffortMapping(nil), mappings...)
for i := range normalizedMappings {
if service.NormalizeMaxReasoningEffort(normalizedMappings[i].To) == "minimal" {
normalizedMappings[i].To = "low"
}
}
return maxEffort, normalizedMappings
}
func bindRequestedReasoningEffort(c *gin.Context, body []byte, model string) {
@@ -118,6 +150,14 @@ func respondOpenAIReasoningEffortPolicyError(c *gin.Context, err error, write fu
write(c, http.StatusForbidden, "permission_error", err.Error())
}
func applyAnthropicReasoningEffortPolicyForRequest(c *gin.Context, apiKey *service.APIKey, body []byte) ([]byte, bool, error) {
maxEffort, mappings, overLimit, ok := anthropicReasoningEffortPolicyForRequest(c, apiKey)
if !ok {
return body, false, nil
}
return service.ApplyReasoningEffortPolicy(body, maxEffort, mappings, overLimit)
}
func bindOpenAIReasoningEffortPolicyForMessagesRequest(c *gin.Context, apiKey *service.APIKey, body []byte) {
if c == nil || c.Request == nil {
return
@@ -8,6 +8,7 @@ import (
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestCompositeTargetPlatformAllowedResolvesKnownAllowedModel(t *testing.T) {
@@ -160,6 +161,75 @@ func TestOpenAIReasoningEffortPolicyForCompositeTarget(t *testing.T) {
require.Equal(t, body, got)
}
func TestOpenAIShapedReasoningEffortPolicyForAnthropicGroup(t *testing.T) {
gin.SetMode(gin.TestMode)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest("POST", "/v1/responses", nil)
apiKey := &service.APIKey{Group: &service.Group{
Platform: service.PlatformAnthropic,
MaxReasoningEffort: "xhigh",
}}
got, changed, err := applyOpenAIReasoningEffortPolicyForRequest(c, apiKey, []byte(`{"reasoning":{"effort":"max"}}`))
require.NoError(t, err)
require.True(t, changed)
require.Equal(t, "xhigh", gjson.GetBytes(got, "reasoning.effort").String())
}
func TestAnthropicReasoningEffortPolicyForConcreteAndCompositeTargets(t *testing.T) {
gin.SetMode(gin.TestMode)
body := []byte(`{"model":"claude-fable-5-1","output_config":{"effort":"max"}}`)
policy := func(platform string) *service.APIKey {
return &service.APIKey{Group: &service.Group{
Platform: platform,
MaxReasoningEffort: "xhigh",
ReasoningEffortMappings: []service.ReasoningEffortMapping{
{From: "max", To: "xhigh"},
},
}}
}
concrete, _ := gin.CreateTestContext(httptest.NewRecorder())
concrete.Request = httptest.NewRequest("POST", "/v1/messages", nil)
got, changed, err := applyAnthropicReasoningEffortPolicyForRequest(concrete, policy(service.PlatformAnthropic), body)
require.NoError(t, err)
require.True(t, changed)
require.JSONEq(t, `{"model":"claude-fable-5-1","output_config":{"effort":"xhigh"}}`, string(got))
denyMax := policy(service.PlatformAnthropic)
denyMax.Group.ReasoningEffortMappings = nil
denyMax.Group.MaxReasoningEffortOverLimit = service.ReasoningEffortOverLimitDeny
_, changed, err = applyAnthropicReasoningEffortPolicyForRequest(concrete, denyMax, body)
require.Error(t, err)
require.False(t, changed)
var overLimit *service.ReasoningEffortOverLimitError
require.ErrorAs(t, err, &overLimit)
require.Equal(t, "max", overLimit.Requested)
require.Equal(t, "xhigh", overLimit.Max)
composite, _ := gin.CreateTestContext(httptest.NewRecorder())
composite.Request = httptest.NewRequest("POST", "/v1/messages", nil)
composite.Request = composite.Request.WithContext(service.WithResolvedTargetPlatform(composite.Request.Context(), service.PlatformAnthropic))
got, changed, err = applyAnthropicReasoningEffortPolicyForRequest(composite, policy(service.PlatformComposite), body)
require.NoError(t, err)
require.True(t, changed)
require.Equal(t, "xhigh", gjson.GetBytes(got, "output_config.effort").String())
openAI, _ := gin.CreateTestContext(httptest.NewRecorder())
openAI.Request = httptest.NewRequest("POST", "/v1/messages", nil)
openAI.Request = openAI.Request.WithContext(service.WithResolvedTargetPlatform(openAI.Request.Context(), service.PlatformOpenAI))
got, changed, err = applyAnthropicReasoningEffortPolicyForRequest(openAI, policy(service.PlatformComposite), body)
require.NoError(t, err)
require.False(t, changed)
require.Equal(t, body, got)
}
func TestAnthropicCompositePolicyClampsUnsupportedMinimalToLow(t *testing.T) {
maxEffort, mappings := anthropicCompatibleReasoningEffortPolicy("minimal", []service.ReasoningEffortMapping{{From: "max", To: "minimal"}})
require.Equal(t, "low", maxEffort)
require.Equal(t, []service.ReasoningEffortMapping{{From: "max", To: "low"}}, mappings)
}
func TestClientRequestedModelUsesCompositePublicModel(t *testing.T) {
gin.SetMode(gin.TestMode)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
@@ -9,10 +9,15 @@ import (
const statusClientClosedRequest = 499
func concurrencyErrorResponse(err error, slotType string) (int, string, string) {
const (
gatewayQueueFullCode = "gateway_queue_full"
gatewayConcurrencyLimitCode = "gateway_concurrency_limit"
)
func concurrencyErrorResponse(err error, slotType string) (int, string, string, string) {
var waitQueueFullErr *WaitQueueFullError
if errors.As(err, &waitQueueFullErr) {
return http.StatusTooManyRequests, "rate_limit_error",
return http.StatusTooManyRequests, "rate_limit_error", gatewayQueueFullCode,
"Too many pending requests, please retry later"
}
@@ -21,13 +26,13 @@ func concurrencyErrorResponse(err error, slotType string) (int, string, string)
if concurrencyErr.SlotType != "" {
slotType = concurrencyErr.SlotType
}
return http.StatusTooManyRequests, "rate_limit_error",
return http.StatusTooManyRequests, "rate_limit_error", gatewayConcurrencyLimitCode,
fmt.Sprintf("Concurrency limit exceeded for %s, please retry later", slotType)
}
if errors.Is(err, context.Canceled) {
return statusClientClosedRequest, "api_error", "context canceled"
return statusClientClosedRequest, "api_error", "", "context canceled"
}
return http.StatusServiceUnavailable, "api_error", "Service temporarily unavailable, please retry later"
return http.StatusServiceUnavailable, "api_error", "", "Service temporarily unavailable, please retry later"
}
@@ -16,6 +16,7 @@ func TestConcurrencyErrorResponse(t *testing.T) {
slotType string
wantStatus int
wantType string
wantCode string
wantMessage string
}{
{
@@ -24,8 +25,18 @@ func TestConcurrencyErrorResponse(t *testing.T) {
slotType: "user",
wantStatus: http.StatusTooManyRequests,
wantType: "rate_limit_error",
wantCode: gatewayConcurrencyLimitCode,
wantMessage: "Concurrency limit exceeded for account, please retry later",
},
{
name: "full local wait queue has gateway code",
err: &WaitQueueFullError{SlotType: "account"},
slotType: "account",
wantStatus: http.StatusTooManyRequests,
wantType: "rate_limit_error",
wantCode: gatewayQueueFullCode,
wantMessage: "Too many pending requests, please retry later",
},
{
name: "client cancellation is not classified as concurrency limit",
err: context.Canceled,
@@ -54,9 +65,10 @@ func TestConcurrencyErrorResponse(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
status, errType, message := concurrencyErrorResponse(tt.err, tt.slotType)
status, errType, code, message := concurrencyErrorResponse(tt.err, tt.slotType)
require.Equal(t, tt.wantStatus, status)
require.Equal(t, tt.wantType, errType)
require.Equal(t, tt.wantCode, code)
require.Equal(t, tt.wantMessage, message)
})
}
+41
View File
@@ -448,6 +448,47 @@ func AccountFromService(a *service.Account) *Account {
return out
}
// AccountListItemFromAccount projects a full account response into the
// compact shape used by the paginated admin account list. Keeping this
// projection separate from Account preserves the existing detail API.
func AccountListItemFromAccount(a *Account) *AccountListItem {
if a == nil {
return nil
}
return &AccountListItem{
ID: a.ID, Name: a.Name, Notes: a.Notes, Platform: a.Platform, Type: a.Type,
Credentials: a.Credentials, CredentialsStatus: a.CredentialsStatus, Extra: a.Extra,
OllamaCloudUsage: a.OllamaCloudUsage,
ProxyID: a.ProxyID, ProxyFallbackOriginID: a.ProxyFallbackOriginID, ProxyFallbackOriginName: a.ProxyFallbackOriginName,
Concurrency: a.Concurrency, LoadFactor: a.LoadFactor, Priority: a.Priority, RateMultiplier: a.RateMultiplier,
Status: a.Status, ErrorMessage: a.ErrorMessage, LastUsedAt: a.LastUsedAt, ExpiresAt: a.ExpiresAt,
AutoPauseOnExpired: a.AutoPauseOnExpired, CreatedAt: a.CreatedAt, UpdatedAt: a.UpdatedAt,
Schedulable: a.Schedulable, RateLimitedAt: a.RateLimitedAt, RateLimitResetAt: a.RateLimitResetAt,
OverloadUntil: a.OverloadUntil, TempUnschedulableUntil: a.TempUnschedulableUntil,
TempUnschedulableReason: a.TempUnschedulableReason, SessionWindowStart: a.SessionWindowStart,
SessionWindowEnd: a.SessionWindowEnd, SessionWindowStatus: a.SessionWindowStatus,
WindowCostLimit: a.WindowCostLimit, WindowCostStickyReserve: a.WindowCostStickyReserve,
MaxSessions: a.MaxSessions, SessionIdleTimeoutMin: a.SessionIdleTimeoutMin, BaseRPM: a.BaseRPM,
RPMStrategy: a.RPMStrategy, RPMStickyBuffer: a.RPMStickyBuffer, UserMsgQueueMode: a.UserMsgQueueMode,
EnableTLSFingerprint: a.EnableTLSFingerprint, TLSFingerprintProfileID: a.TLSFingerprintProfileID,
EnableSessionIDMasking: a.EnableSessionIDMasking, CacheTTLOverrideEnabled: a.CacheTTLOverrideEnabled,
CacheTTLOverrideTarget: a.CacheTTLOverrideTarget, CustomBaseURLEnabled: a.CustomBaseURLEnabled,
CustomBaseURL: a.CustomBaseURL, QuotaLimit: a.QuotaLimit, QuotaUsed: a.QuotaUsed,
QuotaDailyLimit: a.QuotaDailyLimit, QuotaDailyUsed: a.QuotaDailyUsed, QuotaWeeklyLimit: a.QuotaWeeklyLimit,
QuotaWeeklyUsed: a.QuotaWeeklyUsed, QuotaDailyResetMode: a.QuotaDailyResetMode,
QuotaDailyResetHour: a.QuotaDailyResetHour, QuotaWeeklyResetMode: a.QuotaWeeklyResetMode,
QuotaWeeklyResetDay: a.QuotaWeeklyResetDay, QuotaWeeklyResetHour: a.QuotaWeeklyResetHour,
QuotaResetTimezone: a.QuotaResetTimezone, QuotaDailyResetAt: a.QuotaDailyResetAt,
QuotaWeeklyResetAt: a.QuotaWeeklyResetAt, QuotaNotifyDailyEnabled: a.QuotaNotifyDailyEnabled,
QuotaNotifyDailyThreshold: a.QuotaNotifyDailyThreshold, QuotaNotifyWeeklyEnabled: a.QuotaNotifyWeeklyEnabled,
QuotaNotifyWeeklyThreshold: a.QuotaNotifyWeeklyThreshold, QuotaNotifyTotalEnabled: a.QuotaNotifyTotalEnabled,
QuotaNotifyTotalThreshold: a.QuotaNotifyTotalThreshold, ParentAccountID: a.ParentAccountID,
QuotaDimension: a.QuotaDimension, ParentEmail: a.ParentEmail, ParentPlanType: a.ParentPlanType,
ParentPrivacyMode: a.ParentPrivacyMode, ParentSubscriptionExpiresAt: a.ParentSubscriptionExpiresAt,
ParentChatGPTAccountID: a.ParentChatGPTAccountID, Proxy: a.Proxy, GroupIDs: a.GroupIDs,
}
}
func timeToUnixSeconds(value *time.Time) *int64 {
if value == nil {
return nil
+98 -2
View File
@@ -151,11 +151,11 @@ type Group struct {
// RPMLimit 分组级每分钟请求数上限(0 = 不限制),设置后覆盖用户级 rpm_limit。
RPMLimit int `json:"rpm_limit"`
// MaxReasoningEffort OpenAI/Codex 请求的推理强度上限,空字符串表示不限制。
// MaxReasoningEffort Anthropic/OpenAI 请求的推理强度上限,空字符串表示不限制。
MaxReasoningEffort string `json:"max_reasoning_effort"`
// MaxReasoningEffortOverLimit 超过上限时的访问控制:downgrade(默认)或 deny。
MaxReasoningEffortOverLimit string `json:"max_reasoning_effort_over_limit"`
// ReasoningEffortMappings OpenAI/Codex 推理强度映射,可按模型精确名、前缀或后缀限定。
// ReasoningEffortMappings Anthropic/OpenAI 推理强度映射,可按模型精确名、前缀或后缀限定。
ReasoningEffortMappings []domain.ReasoningEffortMapping `json:"reasoning_effort_mappings"`
CreatedAt time.Time `json:"created_at"`
@@ -324,6 +324,102 @@ type Account struct {
Groups []*Group `json:"groups,omitempty"`
}
// AccountListItem is the compact representation returned by the admin account
// list when the lite query parameter is enabled. It contains the fields used
// by the account table and runtime indicators, but intentionally omits the
// repeated account_groups and groups object graphs. Fetch /admin/accounts/:id
// for the complete Account DTO when editing or inspecting an account.
type AccountListItem struct {
ID int64 `json:"id"`
Name string `json:"name"`
Notes *string `json:"notes"`
Platform string `json:"platform"`
Type string `json:"type"`
Credentials map[string]any `json:"credentials,omitempty"`
CredentialsStatus map[string]bool `json:"credentials_status,omitempty"`
Extra map[string]any `json:"extra,omitempty"`
OllamaCloudUsage *service.OllamaCloudUsageState `json:"ollama_cloud_usage,omitempty"`
ProxyID *int64 `json:"proxy_id"`
ProxyFallbackOriginID *int64 `json:"proxy_fallback_origin_id"`
ProxyFallbackOriginName *string `json:"proxy_fallback_origin_name,omitempty"`
Concurrency int `json:"concurrency"`
LoadFactor *int `json:"load_factor,omitempty"`
Priority int `json:"priority"`
RateMultiplier float64 `json:"rate_multiplier"`
Status string `json:"status"`
ErrorMessage string `json:"error_message"`
LastUsedAt *time.Time `json:"last_used_at"`
ExpiresAt *int64 `json:"expires_at"`
AutoPauseOnExpired bool `json:"auto_pause_on_expired"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Schedulable bool `json:"schedulable"`
RateLimitedAt *time.Time `json:"rate_limited_at"`
RateLimitResetAt *time.Time `json:"rate_limit_reset_at"`
OverloadUntil *time.Time `json:"overload_until"`
TempUnschedulableUntil *time.Time `json:"temp_unschedulable_until"`
TempUnschedulableReason string `json:"temp_unschedulable_reason"`
SessionWindowStart *time.Time `json:"session_window_start"`
SessionWindowEnd *time.Time `json:"session_window_end"`
SessionWindowStatus string `json:"session_window_status"`
WindowCostLimit *float64 `json:"window_cost_limit,omitempty"`
WindowCostStickyReserve *float64 `json:"window_cost_sticky_reserve,omitempty"`
MaxSessions *int `json:"max_sessions,omitempty"`
SessionIdleTimeoutMin *int `json:"session_idle_timeout_minutes,omitempty"`
BaseRPM *int `json:"base_rpm,omitempty"`
RPMStrategy *string `json:"rpm_strategy,omitempty"`
RPMStickyBuffer *int `json:"rpm_sticky_buffer,omitempty"`
UserMsgQueueMode *string `json:"user_msg_queue_mode,omitempty"`
EnableTLSFingerprint *bool `json:"enable_tls_fingerprint,omitempty"`
TLSFingerprintProfileID *int64 `json:"tls_fingerprint_profile_id,omitempty"`
EnableSessionIDMasking *bool `json:"session_id_masking_enabled,omitempty"`
CacheTTLOverrideEnabled *bool `json:"cache_ttl_override_enabled,omitempty"`
CacheTTLOverrideTarget *string `json:"cache_ttl_override_target,omitempty"`
CustomBaseURLEnabled *bool `json:"custom_base_url_enabled,omitempty"`
CustomBaseURL *string `json:"custom_base_url,omitempty"`
QuotaLimit *float64 `json:"quota_limit,omitempty"`
QuotaUsed *float64 `json:"quota_used,omitempty"`
QuotaDailyLimit *float64 `json:"quota_daily_limit,omitempty"`
QuotaDailyUsed *float64 `json:"quota_daily_used,omitempty"`
QuotaWeeklyLimit *float64 `json:"quota_weekly_limit,omitempty"`
QuotaWeeklyUsed *float64 `json:"quota_weekly_used,omitempty"`
QuotaDailyResetMode *string `json:"quota_daily_reset_mode,omitempty"`
QuotaDailyResetHour *int `json:"quota_daily_reset_hour,omitempty"`
QuotaWeeklyResetMode *string `json:"quota_weekly_reset_mode,omitempty"`
QuotaWeeklyResetDay *int `json:"quota_weekly_reset_day,omitempty"`
QuotaWeeklyResetHour *int `json:"quota_weekly_reset_hour,omitempty"`
QuotaResetTimezone *string `json:"quota_reset_timezone,omitempty"`
QuotaDailyResetAt *string `json:"quota_daily_reset_at,omitempty"`
QuotaWeeklyResetAt *string `json:"quota_weekly_reset_at,omitempty"`
QuotaNotifyDailyEnabled *bool `json:"quota_notify_daily_enabled,omitempty"`
QuotaNotifyDailyThreshold *float64 `json:"quota_notify_daily_threshold,omitempty"`
QuotaNotifyWeeklyEnabled *bool `json:"quota_notify_weekly_enabled,omitempty"`
QuotaNotifyWeeklyThreshold *float64 `json:"quota_notify_weekly_threshold,omitempty"`
QuotaNotifyTotalEnabled *bool `json:"quota_notify_total_enabled,omitempty"`
QuotaNotifyTotalThreshold *float64 `json:"quota_notify_total_threshold,omitempty"`
ParentAccountID *int64 `json:"parent_account_id,omitempty"`
QuotaDimension string `json:"quota_dimension,omitempty"`
ParentEmail string `json:"parent_email,omitempty"`
ParentPlanType string `json:"parent_plan_type,omitempty"`
ParentPrivacyMode string `json:"parent_privacy_mode,omitempty"`
ParentSubscriptionExpiresAt string `json:"parent_subscription_expires_at,omitempty"`
ParentChatGPTAccountID string `json:"parent_chatgpt_account_id,omitempty"`
Proxy *Proxy `json:"proxy,omitempty"`
GroupIDs []int64 `json:"group_ids,omitempty"`
}
type AccountGroup struct {
AccountID int64 `json:"account_id"`
GroupID int64 `json:"group_id"`
+80 -12
View File
@@ -171,6 +171,19 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
reqStream := parsedReq.Stream
bindRequestedReasoningEffort(c, body, reqModel)
ensureCompositeTargetPlatform(c, apiKey, reqModel)
if policyBody, changed, err := applyAnthropicReasoningEffortPolicyForRequest(c, apiKey, body); err != nil {
respondOpenAIReasoningEffortPolicyError(c, err, h.errorResponse)
return
} else if changed {
if err := parsedReq.ReplaceBody(policyBody); err != nil {
reqLog.Warn("gateway.reasoning_effort_policy_parse_failed", zap.Error(err))
h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "Failed to apply reasoning effort policy")
return
}
body = parsedReq.Body.Bytes()
reqModel = parsedReq.Model
reqStream = parsedReq.Stream
}
reqLog = reqLog.With(zap.String("model", reqModel), zap.Bool("stream", reqStream))
// 解析渠道级模型映射
@@ -395,7 +408,7 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
zap.Int64("account_id", account.ID),
zap.Int("max_waiting", selection.WaitPlan.MaxWaiting),
)
h.handleStreamingAwareError(c, http.StatusTooManyRequests, "rate_limit_error", "Too many pending requests, please retry later", streamStarted)
h.handleStreamingAwareErrorWithCode(c, http.StatusTooManyRequests, "rate_limit_error", gatewayQueueFullCode, "Too many pending requests, please retry later", streamStarted)
return
}
if err == nil && canWait {
@@ -610,6 +623,23 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
c.Request = c.Request.WithContext(ctx)
}
// 会话槽失败释放:failover 链上每个选中的 Anthropic OAuth 账号都注册过同一会话
// (checkAndRegisterSession)。若请求最终失败(转发失败/客户端中断/换号耗尽),
// 须立即释放本次会话注册——上游从未真正服务该会话,继续占槽会让 max_sessions
// 受限的账号被失败请求的 session hash 卡满整个空闲窗口,后续新会话全部被拒。
// 成功请求保持既有空闲超时语义(会话按最后活动时间过期)。
sessionSlotAccounts := make(map[int64]*service.Account)
upstreamServedSession := false
defer func() {
if upstreamServedSession {
return
}
// 客户端可能已断开、请求 ctx 已取消,用独立 ctx 执行释放
for _, acc := range sessionSlotAccounts {
h.gatewayService.ReleaseAccountSession(context.Background(), acc, sessionKey)
}
}()
for {
fs := NewFailoverState(h.maxAccountSwitches, hasBoundSession)
retryWithFallback := false
@@ -719,7 +749,7 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
zap.Int64("account_id", account.ID),
zap.Int("max_waiting", selection.WaitPlan.MaxWaiting),
)
h.handleStreamingAwareError(c, http.StatusTooManyRequests, "rate_limit_error", "Too many pending requests, please retry later", streamStarted)
h.handleStreamingAwareErrorWithCode(c, http.StatusTooManyRequests, "rate_limit_error", gatewayQueueFullCode, "Too many pending requests, please retry later", streamStarted)
return
}
if err == nil && canWait {
@@ -763,10 +793,15 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
h.handleStreamingAwareError(c, http.StatusServiceUnavailable, "api_error", profitVetoExhaustedMessage, streamStarted)
return
}
// 尝试被否决(从未转发),立即释放该账号的会话注册
h.gatewayService.ReleaseAccountSession(context.Background(), account, sessionKey)
delete(sessionSlotAccounts, account.ID)
continue
}
account = latest
selection.Account = latest
// 记录本请求注册过会话槽的账号(profit 准入后账号已定)
sessionSlotAccounts[account.ID] = account
// 等待路径保持既有 eager 绑定(无门时 helper 直接绑定);调度器已
// 抢槽的直达路径无门时由选号内部绑定,这里只在门下补准入后绑定。
if selection.ProfitGateActive() || !selection.Acquired {
@@ -983,6 +1018,11 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
currentSubscription = nil
fallbackUsed = true
retryWithFallback = true
// 原分组账号已确定性失败(prompt too long),先释放其会话注册再走兜底分组
for _, acc := range sessionSlotAccounts {
h.gatewayService.ReleaseAccountSession(context.Background(), acc, sessionKey)
}
sessionSlotAccounts = make(map[int64]*service.Account)
break
}
_ = h.antigravityGatewayService.WriteMappedClaudeError(c, account, promptTooLongErr.StatusCode, promptTooLongErr.RequestID, promptTooLongErr.Body)
@@ -998,6 +1038,9 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
action := fs.HandleFailoverError(c.Request.Context(), h.gatewayService, account.ID, account.Platform, account.GetPoolModeRetryCount(), failoverErr)
switch action {
case FailoverContinue:
// 本次尝试已确定性失败,立即释放该账号的会话注册
h.gatewayService.ReleaseAccountSession(context.Background(), account, sessionKey)
delete(sessionSlotAccounts, account.ID)
continue
case FailoverExhausted:
h.handleFailoverExhausted(c, fs.LastFailoverErr, account.Platform, streamStarted)
@@ -1036,6 +1079,8 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
// 不会走到这里重复计费。
if result != nil {
submitForwardUsage(result)
// 上游已接受并计量本次会话(流中断),会话槽保持既有语义
upstreamServedSession = true
}
return
}
@@ -1061,6 +1106,8 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
}
submitForwardUsage(result)
// 转发成功,会话槽保持既有空闲超时语义
upstreamServedSession = true
return
}
if !retryWithFallback {
@@ -1087,6 +1134,12 @@ func (h *GatewayHandler) Models(c *gin.Context) {
platform = forcedPlatform
}
if platform == service.PlatformOpenAI && apiKey != nil && apiKey.Group != nil &&
apiKey.Group.Platform == service.PlatformOpenAI && apiKey.Group.CodexModelsManifestConfig.Enabled {
h.pinnedOpenAIModels(c, apiKey.Group)
return
}
if platform == service.PlatformComposite {
availableModels := h.compositeAvailableModels(c.Request.Context(), groupID)
if apiKey != nil && apiKey.Group != nil && apiKey.Group.ModelAllowlistEnabled() {
@@ -1813,8 +1866,8 @@ func (h *GatewayHandler) calculateSubscriptionRemaining(group *service.Group, su
// handleConcurrencyError handles concurrency-related acquire errors.
func (h *GatewayHandler) handleConcurrencyError(c *gin.Context, err error, slotType string, streamStarted bool) {
status, errType, message := concurrencyErrorResponse(err, slotType)
h.handleStreamingAwareError(c, status, errType, message, streamStarted)
status, errType, code, message := concurrencyErrorResponse(err, slotType)
h.handleStreamingAwareErrorWithCode(c, status, errType, code, message, streamStarted)
}
func (h *GatewayHandler) handleFailoverExhausted(c *gin.Context, failoverErr *service.UpstreamFailoverError, platform string, streamStarted bool) {
@@ -1885,6 +1938,10 @@ func (h *GatewayHandler) mapUpstreamError(statusCode int) (int, string, string)
// handleStreamingAwareError handles errors that may occur after streaming has started
func (h *GatewayHandler) handleStreamingAwareError(c *gin.Context, status int, errType, message string, streamStarted bool) {
h.handleStreamingAwareErrorWithCode(c, status, errType, "", message, streamStarted)
}
func (h *GatewayHandler) handleStreamingAwareErrorWithCode(c *gin.Context, status int, errType, code, message string, streamStarted bool) {
if streamStarted {
// 响应状态码已固化为 200(ping/部分数据已 flush),错误只能就地以 SSE 帧回传。
// 标记本次流内错误,供 ops_error_logger 补记——否则该中间件按 status>=400 采集,
@@ -1895,7 +1952,7 @@ func (h *GatewayHandler) handleStreamingAwareError(c *gin.Context, status int, e
// response.completed/failed/incomplete/cancelled 集合。
// Anthropic-backed Responses 路径同样会因为通用 error 帧被拒。
if inboundIsResponses(c) {
if writeResponsesFailedSSE(c, errType, message) {
if writeResponsesFailedSSE(c, errType, code, message) {
return
}
}
@@ -1903,7 +1960,11 @@ func (h *GatewayHandler) handleStreamingAwareError(c *gin.Context, status int, e
flusher, ok := c.Writer.(http.Flusher)
if ok {
// SSE 错误事件固定 schema,使用 Quote 直拼可避免额外 Marshal 分配。
errorEvent := `data: {"type":"error","error":{"type":` + strconv.Quote(errType) + `,"message":` + strconv.Quote(message) + `}}` + "\n\n"
errorCode := ""
if code != "" {
errorCode = `,"code":` + strconv.Quote(code)
}
errorEvent := `data: {"type":"error","error":{"type":` + strconv.Quote(errType) + errorCode + `,"message":` + strconv.Quote(message) + `}}` + "\n\n"
if _, err := fmt.Fprint(c.Writer, errorEvent); err != nil {
_ = c.Error(err)
}
@@ -1913,7 +1974,7 @@ func (h *GatewayHandler) handleStreamingAwareError(c *gin.Context, status int, e
}
// Normal case: return JSON response with proper status code
h.errorResponse(c, status, errType, message)
h.errorResponseWithCode(c, status, errType, code, message)
}
// ensureForwardErrorResponse 在 Forward 返回错误但尚未写响应时补写统一错误响应。
@@ -2005,12 +2066,17 @@ func (h *GatewayHandler) checkClaudeCodeVersion(c *gin.Context) bool {
// errorResponse 返回Claude API格式的错误响应
func (h *GatewayHandler) errorResponse(c *gin.Context, status int, errType, message string) {
h.errorResponseWithCode(c, status, errType, "", message)
}
func (h *GatewayHandler) errorResponseWithCode(c *gin.Context, status int, errType, code, message string) {
errorObject := gin.H{"type": errType, "message": message}
if code != "" {
errorObject["code"] = code
}
c.JSON(status, gin.H{
"type": "error",
"error": gin.H{
"type": errType,
"message": message,
},
"type": "error",
"error": errorObject,
})
}
@@ -2123,6 +2189,8 @@ func (h *GatewayHandler) CountTokens(c *gin.Context) {
if err := h.gatewayService.ForwardCountTokens(c.Request.Context(), c, account, parsedReq); err != nil {
reqLog.Error("gateway.count_tokens_forward_failed", zap.Int64("account_id", account.ID), zap.Error(err))
// 错误响应已在 ForwardCountTokens 中处理
// 上游未服务该会话,立即释放选号时注册的会话槽(客户端可能已断开,用独立 ctx)
h.gatewayService.ReleaseAccountSession(context.Background(), account, sessionHash)
return
}
}
@@ -394,7 +394,7 @@ func (h *GatewayHandler) handleResponsesFailoverExhausted(c *gin.Context, lastEr
// generic response.failed.
service.MarkOpsStreamError(c, code, message, status)
if c != nil && c.Writer != nil && (c.Writer.Size() <= 0 || gatewayStreamHasOnlyHeartbeats(c)) {
writeResponsesFailedSSE(c, code, message)
writeResponsesFailedSSE(c, code, "", message)
}
return
}
@@ -49,6 +49,20 @@ func TestNormalizeCodexAutomationBootstrapSupportedLastRunValues(t *testing.T) {
}
}
func TestNormalizeCodexAutomationBootstrapHeartbeat(t *testing.T) {
output := `<heartbeat><automation_id>wiki</automation_id></heartbeat>`
got, changed := normalizeCodexAutomationBootstrap(codexAutomationBootstrapBody(t, output, ""))
require.True(t, changed)
require.Equal(t, "message", gjson.GetBytes(got, "input.0.type").String())
require.Equal(t, "user", gjson.GetBytes(got, "input.0.role").String())
require.Equal(t, output, gjson.GetBytes(got, "input.0.content.0.text").String())
require.False(t, gjson.GetBytes(got, "input.0.call_id").Exists())
again, changedAgain := normalizeCodexAutomationBootstrap(got)
require.False(t, changedAgain)
require.Equal(t, got, again)
}
func TestNormalizeCodexAutomationBootstrapRejectsUnsafeShapes(t *testing.T) {
validOutput := codexAutomationBootstrap("wiki", "never", automationBootstrapPrompt)
tests := []struct {
@@ -75,10 +89,6 @@ func TestNormalizeCodexAutomationBootstrapRejectsUnsafeShapes(t *testing.T) {
name: "real call context",
body: []byte(`{"model":"gpt-5","input":[{"type":"function_call_output","namespace":"codex_app","name":"automation_update","output":` + mustJSON(t, validOutput) + `},{"type":"function_call","call_id":"call-1"}]}`),
},
{
name: "heartbeat output",
body: codexAutomationBootstrapBody(t, `<heartbeat><automation_id>wiki</automation_id></heartbeat>`, ""),
},
{
name: "mismatched memory id",
body: codexAutomationBootstrapBody(t, strings.Replace(validOutput, "/wiki/memory.md", "/other/memory.md", 1), ""),
@@ -117,6 +127,31 @@ func TestNormalizeCodexAutomationBootstrapRejectsUnsafeShapes(t *testing.T) {
}
}
func TestNormalizeCodexAutomationBootstrapRejectsUnsafeHeartbeatShapes(t *testing.T) {
tests := []struct {
name string
output string
}{
{name: "arbitrary tool output", output: `<result><automation_id>wiki</automation_id></result>`},
{name: "root attribute", output: `<heartbeat status="ok"><automation_id>wiki</automation_id></heartbeat>`},
{name: "namespaced root", output: `<heartbeat xmlns="urn:codex"><automation_id>wiki</automation_id></heartbeat>`},
{name: "extra child", output: `<heartbeat><automation_id>wiki</automation_id><status>ok</status></heartbeat>`},
{name: "nested id content", output: `<heartbeat><automation_id><value>wiki</value></automation_id></heartbeat>`},
{name: "padded id", output: `<heartbeat><automation_id> wiki </automation_id></heartbeat>`},
{name: "unsafe id", output: `<heartbeat><automation_id>../wiki</automation_id></heartbeat>`},
{name: "comment", output: `<heartbeat><!-- ok --><automation_id>wiki</automation_id></heartbeat>`},
{name: "trailing content", output: `<heartbeat><automation_id>wiki</automation_id></heartbeat>extra`},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
body := codexAutomationBootstrapBody(t, tt.output, "")
got, changed := normalizeCodexAutomationBootstrap(body)
require.False(t, changed)
require.Equal(t, body, got)
})
}
}
func TestNormalizeCodexAutomationBootstrapPreservesOrderAndIsIdempotent(t *testing.T) {
output := codexAutomationBootstrap("wiki", "never", automationBootstrapPrompt)
body := []byte(`{"model":"gpt-5","input":[{"type":"message","role":"user","content":"before"},{"type":"function_call_output","namespace":"codex_app","name":"automation_update","output":` + mustJSON(t, output) + `},{"type":"message","role":"user","content":"after"}]}`)
@@ -116,6 +116,7 @@ func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) {
// 解析渠道级模型映射
channelMapping, _ := h.gatewayService.ResolveChannelMappingAndRestrict(c.Request.Context(), apiKey.GroupID, reqModel)
forwardModel := openAIChannelForwardModel(channelMapping, reqModel)
if h.errorPassthroughService != nil {
service.BindErrorPassthroughService(c, h.errorPassthroughService)
@@ -170,7 +171,7 @@ func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) {
apiKey.GroupID,
"",
sessionHash,
reqModel,
forwardModel,
failedAccountIDs,
service.OpenAIUpstreamTransportAny,
service.OpenAIEndpointCapabilityChatCompletions,
@@ -16,7 +16,8 @@ import (
// Codex CLI and the Codex desktop app refresh their model picker from
// GET {base_url}/models?client_version=... (custom provider mode) or
// GET /backend-api/codex/models (chatgpt_base_url mode). Both routes land
// here. Groups with explicit account model mappings are generated locally;
// here. Pinned discovery takes precedence over local account model mappings;
// when disabled, groups with explicit mappings are generated locally;
// otherwise ChatGPT manifests are proxied verbatim and custom API key manifests
// receive provider-compatibility normalization plus short-lived caching.
func (h *OpenAIGatewayHandler) CodexModels(c *gin.Context) {
@@ -34,28 +35,10 @@ func (h *OpenAIGatewayHandler) CodexModels(c *gin.Context) {
}
ifNoneMatch := c.GetHeader("If-None-Match")
configuredManifest, configured, err := h.gatewayService.BuildGroupConfiguredCodexModelsManifest(
c.Request.Context(),
apiKey.Group,
ifNoneMatch,
)
if err != nil {
if c.Request.Context().Err() != nil {
return
}
h.errorResponse(c, http.StatusInternalServerError, "api_error", "Failed to build Codex models manifest")
return
}
if configured {
writeCodexModelsManifestResponse(c, configuredManifest)
return
}
// 固定账号分支:开启后只用选定账号拉取 manifest,不经过调度器;
// 全部不可用/全部失败时按 FallbackToScheduler 决定回退调度器或返回错误。
if apiKey.Group.Platform == service.PlatformOpenAI &&
apiKey.Group.CodexModelsManifestConfig.Enabled &&
len(apiKey.Group.CodexModelsManifestConfig.AccountIDs) > 0 {
apiKey.Group.CodexModelsManifestConfig.Enabled {
pinnedManifest, pinnedAccount, pinnedErr := h.gatewayService.FetchPinnedCodexModelsManifest(
c.Request.Context(),
apiKey.Group,
@@ -84,7 +67,26 @@ func (h *OpenAIGatewayHandler) CodexModels(c *gin.Context) {
if c.Request.Context().Err() != nil {
return
}
writeCodexModelsManifestResponse(c, pinnedManifest)
writeOpenAIModelsResponse(c, pinnedManifest)
return
}
}
if !apiKey.Group.CodexModelsManifestConfig.Enabled {
configuredManifest, configured, err := h.gatewayService.BuildGroupConfiguredCodexModelsManifest(
c.Request.Context(),
apiKey.Group,
ifNoneMatch,
)
if err != nil {
if c.Request.Context().Err() != nil {
return
}
h.errorResponse(c, http.StatusInternalServerError, "api_error", "Failed to build Codex models manifest")
return
}
if configured {
writeOpenAIModelsResponse(c, configuredManifest)
return
}
}
@@ -133,6 +135,10 @@ func (h *OpenAIGatewayHandler) CodexModels(c *gin.Context) {
h.errorResponse(c, http.StatusInternalServerError, "api_error", "Failed to complete Codex models manifest")
return
}
if err := service.ApplyPinnedCodexModelsMapping(manifest, account, apiKey.Group); err != nil {
h.errorResponse(c, http.StatusInternalServerError, "api_error", "Failed to apply model mappings")
return
}
if err := h.gatewayService.MergeGroupConfiguredCodexModels(c.Request.Context(), apiKey.Group, manifest, ifNoneMatch); err != nil {
h.errorResponse(c, http.StatusInternalServerError, "api_error", "Failed to build Codex models manifest")
return
@@ -141,19 +147,7 @@ func (h *OpenAIGatewayHandler) CodexModels(c *gin.Context) {
return
}
writeCodexModelsManifestResponse(c, manifest)
writeOpenAIModelsResponse(c, manifest)
return
}
}
func writeCodexModelsManifestResponse(c *gin.Context, manifest *service.CodexModelsManifest) {
if manifest.ETag != "" {
c.Header("ETag", manifest.ETag)
}
if manifest.NotModified {
c.Status(http.StatusNotModified)
c.Writer.WriteHeaderNow()
return
}
c.Data(http.StatusOK, "application/json", manifest.Body)
}
@@ -43,11 +43,7 @@ func TestNormalizeCodexDelegationBootstrapRejectsUnsafeShapes(t *testing.T) {
}{
{name: "ordinary missing call id", item: `{"type":"function_call_output","name":"other","namespace":"codex_app","output":"` + delegationEnvelope + `"}`},
{name: "valid call id", item: `{"type":"function_call_output","call_id":"call-1","name":"create_thread","namespace":"codex_app","output":"` + delegationEnvelope + `"}`},
{name: "previous response", item: `{"type":"function_call_output","name":"create_thread","namespace":"codex_app","output":"` + delegationEnvelope + `"}`, extra: `,"previous_response_id":"resp-1"`},
{name: "real call context", item: `{"type":"function_call_output","name":"create_thread","namespace":"codex_app","output":"` + delegationEnvelope + `"},{"type":"function_call","call_id":"call-1"}`},
{name: "ambiguous call context", item: `{"type":"function_call_output","name":"create_thread","namespace":"codex_app","output":"` + delegationEnvelope + `"},{"type":"function_call"}`},
{name: "item reference context", item: `{"type":"function_call_output","name":"create_thread","namespace":"codex_app","output":"` + delegationEnvelope + `"},{"type":"item_reference","id":"call-1"}`},
{name: "built-in call context", item: `{"type":"function_call_output","name":"create_thread","namespace":"codex_app","output":"` + delegationEnvelope + `"},{"type":"computer_call","call_id":"call-1"}`},
{name: "ambiguous built-in call context", item: `{"type":"function_call_output","name":"create_thread","namespace":"codex_app","output":"` + delegationEnvelope + `"},{"type":"computer_call"}`},
{name: "mixed missing call id output", item: `{"type":"function_call_output","name":"create_thread","namespace":"codex_app","output":"` + delegationEnvelope + `"},{"type":"computer_call_output","output":"done"}`},
{name: "mixed tool search output", item: `{"type":"function_call_output","name":"create_thread","namespace":"codex_app","output":"` + delegationEnvelope + `"},{"type":"tool_search_output","output":"done"}`},
@@ -67,6 +63,18 @@ func TestNormalizeCodexDelegationBootstrapRejectsUnsafeShapes(t *testing.T) {
}
}
func TestNormalizeCodexDelegationBootstrapWithHistoricalContext(t *testing.T) {
body := []byte(`{"model":"gpt-5","previous_response_id":"resp-1","input":[{"type":"function_call_output","namespace":"codex_app","name":"send_message_to_thread","output":"` + delegationEnvelope + `"},{"type":"function_call","call_id":"call-1"},{"type":"function_call_output","call_id":"call-1","output":"done"},{"type":"item_reference","id":"item-1"},{"type":"computer_call","call_id":"call-2"}]}`)
got, changed := normalizeCodexDelegationBootstrap(body)
require.True(t, changed)
require.Equal(t, "message", gjson.GetBytes(got, "input.0.type").String())
require.Equal(t, "user", gjson.GetBytes(got, "input.0.role").String())
require.Equal(t, "resp-1", gjson.GetBytes(got, "previous_response_id").String())
require.Equal(t, "call-1", gjson.GetBytes(got, "input.2.call_id").String())
require.Equal(t, "item-1", gjson.GetBytes(got, "input.3.id").String())
}
func TestNormalizeCodexDelegationBootstrapPreviousResponseID(t *testing.T) {
tests := []struct {
name string
@@ -75,7 +83,7 @@ func TestNormalizeCodexDelegationBootstrapPreviousResponseID(t *testing.T) {
}{
{name: "absent", changed: true},
{name: "blank string", value: `,"previous_response_id":" "`, changed: true},
{name: "non-empty string", value: `,"previous_response_id":"resp-1"`},
{name: "non-empty string", value: `,"previous_response_id":"resp-1"`, changed: true},
{name: "null", value: `,"previous_response_id":null`},
{name: "number", value: `,"previous_response_id":42`},
{name: "boolean", value: `,"previous_response_id":false`},
@@ -85,6 +85,7 @@ func (h *OpenAIGatewayHandler) Embeddings(c *gin.Context) {
}
channelMapping, _ := h.gatewayService.ResolveChannelMappingAndRestrict(c.Request.Context(), apiKey.GroupID, reqModel)
forwardModel := openAIChannelForwardModel(channelMapping, reqModel)
subscription, _ := middleware2.GetSubscriptionFromContext(c)
service.SetOpsLatencyMs(c, service.OpsAuthLatencyMsKey, time.Since(requestStart).Milliseconds())
@@ -127,7 +128,7 @@ func (h *OpenAIGatewayHandler) Embeddings(c *gin.Context) {
apiKey.GroupID,
"",
"",
reqModel,
forwardModel,
failedAccountIDs,
service.OpenAIUpstreamTransportHTTPSSE,
service.OpenAIEndpointCapabilityEmbeddings,
@@ -128,6 +128,44 @@ func TestOpenAICapacityFailoverExhaustionPreservesMessageAsServerError(t *testin
})
}
func TestOpenAIManagedSingleAccountModelNotFoundExhaustionPreservesStructured400(t *testing.T) {
gin.SetMode(gin.TestMode)
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
body := []byte(`{"error":{"type":"invalid_request_error","code":"model_not_found","param":"model","message":"The requested model is unavailable on this account"}}`)
(&OpenAIGatewayHandler{}).handleFailoverExhausted(c, &service.UpstreamFailoverError{
StatusCode: http.StatusBadRequest,
ResponseBody: body,
}, false)
require.Equal(t, http.StatusBadRequest, recorder.Code)
require.Equal(t, "invalid_request_error", gjson.Get(recorder.Body.String(), "error.type").String())
require.Equal(t, "model_not_found", gjson.Get(recorder.Body.String(), "error.code").String())
require.Equal(t, "model", gjson.Get(recorder.Body.String(), "error.param").String())
require.Equal(t, "The requested model is unavailable on this account", gjson.Get(recorder.Body.String(), "error.message").String())
}
func TestOpenAIManagedModelNotFoundExhaustionSanitizesMessage(t *testing.T) {
gin.SetMode(gin.TestMode)
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
body := []byte(`{"error":{"code":"model_not_found","message":"Model not found; inspect https://upstream.example/debug?access_token=super-secret-value&model=x"}}`)
(&OpenAIGatewayHandler{}).handleFailoverExhausted(c, &service.UpstreamFailoverError{
StatusCode: http.StatusBadRequest,
ResponseBody: body,
}, false)
require.Equal(t, http.StatusBadRequest, recorder.Code)
require.NotContains(t, recorder.Body.String(), "super-secret-value")
require.Contains(t, gjson.Get(recorder.Body.String(), "error.message").String(), "access_token=***")
recorded, ok := c.Get(service.OpsUpstreamErrorMessageKey)
require.True(t, ok)
require.NotContains(t, recorded, "super-secret-value")
require.Contains(t, recorded, "access_token=***")
}
func TestResponsesFailoverExhaustedAfterForwardedTerminalMarksOpsWithoutDuplicateFrame(t *testing.T) {
gin.SetMode(gin.TestMode)
recorder := httptest.NewRecorder()
@@ -1,6 +1,7 @@
package handler
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
@@ -102,6 +103,44 @@ func TestClearCyberPolicyTurnState(t *testing.T) {
require.Equal(t, "turn2", service.GetOpsCyberPolicy(c).Message)
}
func TestAdvanceOpenAIWSCyberBlockStateDefersBlockAcrossFailover(t *testing.T) {
failoverErr := &service.UpstreamFailoverError{StatusCode: http.StatusTooManyRequests}
blocked, pending := advanceOpenAIWSCyberBlockState(false, false, true, failoverErr)
require.False(t, blocked, "the replacement account must receive the current turn")
require.True(t, pending, "the cyber hit must still block later turns")
blocked, pending = advanceOpenAIWSCyberBlockState(blocked, pending, false, failoverErr)
require.False(t, blocked, "additional failover attempts must remain eligible")
require.True(t, pending)
blocked, pending = advanceOpenAIWSCyberBlockState(blocked, pending, false, nil)
require.True(t, blocked, "the next client turn must be blocked after failover finishes")
require.False(t, pending)
}
func TestClearCyberPolicyAttemptStatePreservesRecordedGuardDuringFailover(t *testing.T) {
c := newTestGinContext()
h := &OpenAIGatewayHandler{}
service.MarkOpsCyberPolicy(c, service.CyberPolicyMark{Message: "account-a", UpstreamStatus: http.StatusOK})
h.recordCyberPolicyIfMarked(c, nil, nil, nil, "gpt-5", true, nil, service.ChannelUsageFields{}, "")
require.True(t, c.GetBool(cyberPolicyRecordedKey))
clearCyberPolicyAttemptState(c, false)
require.Nil(t, service.GetOpsCyberPolicy(c))
require.True(t, c.GetBool(cyberPolicyRecordedKey), "the same logical turn must not record again after failover")
service.MarkOpsCyberPolicy(c, service.CyberPolicyMark{Message: "account-b", UpstreamStatus: http.StatusOK})
h.recordCyberPolicyIfMarked(c, nil, nil, nil, "gpt-5", true, nil, service.ChannelUsageFields{}, "")
require.Equal(t, "account-b", service.GetOpsCyberPolicy(c).Message)
require.True(t, c.GetBool(cyberPolicyRecordedKey))
clearCyberPolicyAttemptState(c, true)
require.Nil(t, service.GetOpsCyberPolicy(c))
require.False(t, c.GetBool(cyberPolicyRecordedKey), "a completed logical turn must reset the guard")
}
// TestBuildCyberSessionBlockedOpsEntry verifies the locally-rejected request is
// auditable: 403 / phase=request / type=cyber_policy_session_blocked — distinct
// from upstream cyber_policy hits, and it must NOT touch moderation/violation.
@@ -54,6 +54,21 @@ type openAIWSTurnChannelMappingSnapshot struct {
mapping service.ChannelMappingResult
}
func advanceOpenAIWSCyberBlockState(blocked, pending, marked bool, turnErr error) (bool, bool) {
var failoverErr *service.UpstreamFailoverError
isFailover := errors.As(turnErr, &failoverErr)
if marked {
if isFailover {
return false, true
}
return true, false
}
if pending && !isFailover {
return true, false
}
return blocked, pending
}
var errOpenAIWSUnsupportedModelSwitch = errors.New("selected account does not support websocket model switch")
func newOpenAIWSUnsupportedModelSwitchError(model string) error {
@@ -141,6 +156,18 @@ func openAIWSTurnBillingModel(result *service.OpenAIForwardResult, mapping servi
return billingModel
}
// openAIChannelForwardModel returns the model used for upstream capability
// routing. The client-requested model remains separate for logs, billing, and
// responses.
func openAIChannelForwardModel(mapping service.ChannelMappingResult, requestedModel string) string {
if mapping.Mapped {
if mappedModel := strings.TrimSpace(mapping.MappedModel); mappedModel != "" {
return mappedModel
}
}
return requestedModel
}
type grokMediaEligibilityProber interface {
ProbeMediaEligibility(ctx context.Context, accountID int64) (bool, string, error)
}
@@ -534,10 +561,7 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) {
channelMapping, _ := h.gatewayService.ResolveChannelMappingAndRestrict(c.Request.Context(), apiKey.GroupID, reqModel)
forwardBody := openAIModelMappedBody(body, channelMapping.Mapped, channelMapping.MappedModel, h.gatewayService.ReplaceModelInBody)
seedOpenAIForwardImageIntentHint(c, channelMapping.Mapped, imageIntent)
forwardModel := reqModel
if channelMapping.Mapped {
forwardModel = channelMapping.MappedModel
}
forwardModel := openAIChannelForwardModel(channelMapping, reqModel)
c.Request = c.Request.WithContext(service.WithOpenAIForwardModel(
c.Request.Context(),
forwardModel,
@@ -630,7 +654,7 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) {
apiKey.GroupID,
previousResponseID,
sessionHash,
reqModel,
forwardModel,
failedAccountIDs,
service.OpenAIUpstreamTransportAny,
requiredCapability,
@@ -1601,14 +1625,17 @@ func (h *OpenAIGatewayHandler) validateFunctionCallOutputRequest(c *gin.Context,
}
func normalizeCodexDelegationBootstrap(body []byte) ([]byte, bool) {
return normalizeCodexCallOutputBootstrap(body, isCodexDelegationCandidate)
// 已有任务通过 send_message_to_thread 唤醒时会携带 previous_response_id;
// 完整历史回放还会带有已配对的调用项。delegation 仍是客户端注入的用户输入,
// 不属于这些历史调用的结果,因此允许它与可明确配对的历史上下文共存。
return normalizeCodexCallOutputBootstrap(body, isCodexDelegationCandidate, true)
}
func normalizeCodexAutomationBootstrap(body []byte) ([]byte, bool) {
return normalizeCodexCallOutputBootstrap(body, isCodexAutomationCandidate)
return normalizeCodexCallOutputBootstrap(body, isCodexAutomationCandidate, false)
}
func normalizeCodexCallOutputBootstrap(body []byte, isCandidate func(map[string]any) bool) ([]byte, bool) {
func normalizeCodexCallOutputBootstrap(body []byte, isCandidate func(map[string]any) bool, allowHistoricalContext bool) ([]byte, bool) {
if !hasUniqueJSONMembers(body) {
return body, false
}
@@ -1620,7 +1647,7 @@ func normalizeCodexCallOutputBootstrap(body []byte, isCandidate func(map[string]
}
if previousResponseID, exists := request["previous_response_id"]; exists {
value, ok := previousResponseID.(string)
if !ok || strings.TrimSpace(value) != "" {
if !ok || (!allowHistoricalContext && strings.TrimSpace(value) != "") {
return body, false
}
}
@@ -1629,27 +1656,35 @@ func normalizeCodexCallOutputBootstrap(body []byte, isCandidate func(map[string]
return body, false
}
// Any call/reference anchor makes a call-less output ambiguous. Responses
// built-ins follow the *_call / *_call_output naming convention, so classify
// by the wire type shape instead of maintaining an incomplete allowlist.
// Responses built-ins follow the *_call / *_call_output naming convention,
// so classify by the wire type shape instead of maintaining an incomplete
// allowlist. Delegation may coexist with historical anchors only when their
// IDs make them unambiguous; automation retains the bootstrap-only boundary.
for _, raw := range input {
item, ok := raw.(map[string]any)
if !ok {
continue
}
typ := stringField(item, "type")
if typ == "item_reference" || strings.HasSuffix(typ, "_call") {
return body, false
}
if isResponsesCallOutputType(typ) {
if isCandidate(item) {
callIDValue, exists := item["call_id"]
callID, isString := callIDValue.(string)
if exists && (!isString || strings.TrimSpace(callID) != "") {
return body, false
}
if !isCandidate(item) {
return body, false
continue
}
if typ == "item_reference" {
if allowHistoricalContext && strings.TrimSpace(stringField(item, "id")) != "" {
continue
}
return body, false
}
if strings.HasSuffix(typ, "_call") || isResponsesCallOutputType(typ) {
if allowHistoricalContext && strings.TrimSpace(stringField(item, "call_id")) != "" {
continue
}
return body, false
}
}
@@ -1757,7 +1792,7 @@ func isCodexAutomationCandidate(item map[string]any) bool {
return false
}
output, ok := item["output"].(string)
return ok && validCodexAutomationBootstrap(output)
return ok && (validCodexAutomationBootstrap(output) || validCodexAutomationHeartbeat(output))
}
func stringField(item map[string]any, key string) string {
@@ -1835,6 +1870,56 @@ func validCodexAutomationLastRun(value string) bool {
return err == nil && runAt.UnixMilli() == epochMillis
}
func validCodexAutomationHeartbeat(value string) bool {
decoder := xml.NewDecoder(strings.NewReader(value))
var rootSeen, automationIDSeen bool
var automationID bytes.Buffer
depth := 0
for {
token, err := decoder.Token()
if err == io.EOF {
id := automationID.String()
return rootSeen && automationIDSeen && depth == 0 &&
strings.TrimSpace(id) == id && validCodexAutomationID(id)
}
if err != nil {
return false
}
switch current := token.(type) {
case xml.StartElement:
depth++
if current.Name.Space != "" || len(current.Attr) != 0 || depth > 2 {
return false
}
if depth == 1 {
if rootSeen || current.Name.Local != "heartbeat" {
return false
}
rootSeen = true
} else if automationIDSeen || current.Name.Local != "automation_id" {
return false
}
automationIDSeen = depth == 2
case xml.EndElement:
if current.Name.Space != "" {
return false
}
depth--
if depth < 0 {
return false
}
case xml.CharData:
if depth == 2 {
_, _ = automationID.Write(current)
} else if len(bytes.TrimSpace(current)) != 0 {
return false
}
case xml.Comment, xml.ProcInst, xml.Directive:
return false
}
}
}
func validCodexDelegationEnvelope(value string) bool {
decoder := xml.NewDecoder(strings.NewReader(value))
var rootSeen, sourceSeen, inputSeen bool
@@ -1941,10 +2026,10 @@ const (
// 由 BeforeTurn 在每个 turn 开始时冻结,AfterTurn 的用量提交读取它;turn 在
// 连接内串行推进,互斥锁只为跨用量提交 goroutine 的读取安全。
//
// ws_v2 passthrough ingress 没有 BeforeTurn,因此本值会保持零;AfterTurn 必须
// 以 TurnStarted 已记录的所属 turn 开始时刻为回退,而不是用建连或记录时刻。
// 这样每个 passthrough turn 都按自己的开始时刻计价,但不改变其仅在建连时执行
// 准入门、没有 turn 级利润复核的既有行为。
// 零值语义(重要):首轮准入由握手路径完成,不调用 BeforeTurn,因此首轮保持
// 零值并回退到 TurnStarted 记录的首轮开始时刻。后续 turn 在 response.create
// 写入上游前调用 BeforeTurn,按当时的利润门复核并冻结定价。绝不能用建连时刻
// 初始化,否则会把长连接的所有 turn 钉死在建连时的峰谷因子。
type openAIWSTurnPricing struct {
mu sync.Mutex
at time.Time
@@ -2005,7 +2090,7 @@ func (h *OpenAIGatewayHandler) acquireResponsesAccountSlot(
return h.acquireOpenAIAccountSlot(c, groupID, sessionHash, selection, reqStream, streamStarted, reqLog, nil)
}
type openAISlotErrorWriter func(status int, errType, message string)
type openAISlotErrorWriter func(status int, errType, code, message string)
// acquireOpenAIAccountSlot centralizes scheduler selection admission. The
// optional error writer lets non-Responses endpoints retain their wire format
@@ -2021,13 +2106,13 @@ func (h *OpenAIGatewayHandler) acquireOpenAIAccountSlot(
writeError openAISlotErrorWriter,
) (func(), openAISlotAcquireResult) {
if writeError == nil {
writeError = func(status int, errType, message string) {
h.handleStreamingAwareError(c, status, errType, message, *streamStarted)
writeError = func(status int, errType, code, message string) {
h.handleStreamingAwareErrorWithCode(c, status, errType, code, message, *streamStarted, false)
}
}
if selection == nil || selection.Account == nil {
markOpsRoutingCapacityLimited(c)
writeError(http.StatusServiceUnavailable, "api_error", "No available accounts")
writeError(http.StatusServiceUnavailable, "api_error", "", "No available accounts")
return nil, openAISlotAcquireFailed
}
@@ -2057,7 +2142,7 @@ func (h *OpenAIGatewayHandler) acquireOpenAIAccountSlot(
}
if selection.WaitPlan == nil {
markOpsRoutingCapacityLimited(c)
writeError(http.StatusServiceUnavailable, "api_error", "No available accounts")
writeError(http.StatusServiceUnavailable, "api_error", "", "No available accounts")
return nil, openAISlotAcquireFailed
}
@@ -2068,8 +2153,8 @@ func (h *OpenAIGatewayHandler) acquireOpenAIAccountSlot(
)
if err != nil {
reqLog.Warn("openai.account_slot_quick_acquire_failed", zap.Int64("account_id", account.ID), zap.Error(err))
status, errType, message := concurrencyErrorResponse(err, "account")
writeError(status, errType, message)
status, errType, code, message := concurrencyErrorResponse(err, "account")
writeError(status, errType, code, message)
return nil, openAISlotAcquireFailed
}
if fastAcquired {
@@ -2099,7 +2184,7 @@ func (h *OpenAIGatewayHandler) acquireOpenAIAccountSlot(
zap.Int64("account_id", account.ID),
zap.Int("max_waiting", selection.WaitPlan.MaxWaiting),
)
writeError(http.StatusTooManyRequests, "rate_limit_error", "Too many pending requests, please retry later")
writeError(http.StatusTooManyRequests, "rate_limit_error", gatewayQueueFullCode, "Too many pending requests, please retry later")
return nil, openAISlotAcquireFailed
}
@@ -2122,8 +2207,8 @@ func (h *OpenAIGatewayHandler) acquireOpenAIAccountSlot(
)
if err != nil {
reqLog.Warn("openai.account_slot_acquire_failed", zap.Int64("account_id", account.ID), zap.Error(err))
status, errType, message := concurrencyErrorResponse(err, "account")
writeError(status, errType, message)
status, errType, code, message := concurrencyErrorResponse(err, "account")
writeError(status, errType, code, message)
return nil, openAISlotAcquireFailed
}
@@ -2321,6 +2406,7 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
return
}
cyberBlockedThisConn := false
cyberBlockPendingAfterFailover := false
var cyberTurnBodiesMu sync.Mutex
cyberTurnBodies := map[int][]byte{1: append([]byte(nil), firstMessage...)}
setCyberTurnBody := func(turn int, payload []byte) {
@@ -2338,10 +2424,7 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
// 解析渠道级模型映射
channelMappingWS, _ := h.gatewayService.ResolveChannelMappingAndRestrict(ctx, apiKey.GroupID, reqModel)
wsForwardModel := reqModel
if channelMappingWS.Mapped && strings.TrimSpace(channelMappingWS.MappedModel) != "" {
wsForwardModel = strings.TrimSpace(channelMappingWS.MappedModel)
}
wsForwardModel := openAIChannelForwardModel(channelMappingWS, reqModel)
var currentUserRelease func()
var currentAccountRelease func()
@@ -2504,7 +2587,7 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
apiKey.GroupID,
previousResponseID,
sessionHash,
reqModel,
wsForwardModel,
failedAccountIDs,
requiredTransport,
requiredCapability,
@@ -2656,8 +2739,8 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
// turn-tagged slot preserves the exact mapping used for the in-flight request.
var turnChannelMapping atomic.Pointer[openAIWSTurnChannelMappingSnapshot]
turnChannelMapping.Store(&openAIWSTurnChannelMappingSnapshot{turn: 1, mapping: channelMappingWS})
// turn 级定价:BeforeTurn 重新冻结 pricingAt 并按最新门复核当前账号;
// passthrough 没有 BeforeTurn 时,AfterTurn 回退到 TurnStarted 的所属 turn 时刻。
// turn 级定价:首轮回退到 TurnStarted 的所属 turn 时刻;后续 turn 由
// BeforeTurn 重新冻结 pricingAt 并按最新门复核当前账号。
var turnPricing openAIWSTurnPricing
hooks := &service.OpenAIWSIngressHooks{
ClientLifecycleContext: clientLifecycleCtx,
@@ -2671,10 +2754,9 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
c.Set(securityAuditWSTurnContextKey, turn)
service.BeginOpsStreamTurn(c, turn)
setCyberTurnBody(turn, payload)
// Passthrough ingress intentionally skips BeforeTurn, so enforce only
// the connection-level cyber session gate here as well. Native ingress
// visits this hook first and gets the same side-effect-free close error;
// its original BeforeTurn guard remains as defense in depth.
// 连接级 cyber session gate 也在 BeforeRequest 先执行,使 native 与
// passthrough ingress 都能在 BeforeTurn 及上游写入前无副作用地拒绝。
// BeforeTurn 中保留同一检查作为防御式兜底。
if cyberBlockedThisConn {
return service.NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, cyberSessionBlockedClientMsg, nil)
}
@@ -2775,10 +2857,12 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
AfterTurn: func(turn int, result *service.OpenAIForwardResult, turnErr error) {
turnStart := getTurnStart(turn)
cyberBlockBody := takeCyberTurnBody(turn)
// F1: cyber 标记按 turn 生命周期清理——defer 保证任意早返回路径都执行;
// CyberBlocked 必须在 submit 前同步预捕获(task 闭包由 worker 池异步执行,
// 届时 defer 已清除标记)。
defer clearCyberPolicyTurnState(c)
// 每次 attempt 都清 cyber mark;failover 链结束前保留 recorded guard,
// 避免同一逻辑 turn 换号后重复落风控。CyberBlocked 必须在 submit 前
// 同步预捕获(task 闭包由 worker 池异步执行,届时 mark 已清除)。
defer func() {
clearCyberPolicyAttemptState(c, !cyberBlockPendingAfterFailover)
}()
releaseTurnSlots()
turnRequestedModel := reqModel
turnUpstreamModel := ""
@@ -2800,10 +2884,14 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
turnUpstreamModel = turnRequestedModel
}
turnUsageFields := turnMapping.ToUsageFields(turnRequestedModel, turnUpstreamModel)
cyberMarked := service.GetOpsCyberPolicy(c) != nil
h.recordCyberPolicyIfMarked(c, apiKey, account, subscription, turnRequestedModel, turnErr != nil, cyberBlockBody, turnUsageFields, requestPayloadHash)
if service.GetOpsCyberPolicy(c) != nil {
cyberBlockedThisConn = true
}
cyberBlockedThisConn, cyberBlockPendingAfterFailover = advanceOpenAIWSCyberBlockState(
cyberBlockedThisConn,
cyberBlockPendingAfterFailover,
cyberMarked,
turnErr,
)
if turnErr != nil {
if result == nil || result.ImageCount <= 0 {
return
@@ -3194,14 +3282,14 @@ func (h *OpenAIGatewayHandler) acquireImageGenerationSlot(c *gin.Context, stream
if acquired {
return release, true
}
h.handleStreamingAwareError(c, http.StatusTooManyRequests, "rate_limit_error", "Image generation concurrency limit exceeded, please retry later", streamStarted)
h.handleStreamingAwareErrorWithCode(c, http.StatusTooManyRequests, "rate_limit_error", gatewayConcurrencyLimitCode, "Image generation concurrency limit exceeded, please retry later", streamStarted, false)
return nil, false
}
// handleConcurrencyError handles concurrency-related acquire errors.
func (h *OpenAIGatewayHandler) handleConcurrencyError(c *gin.Context, err error, slotType string, streamStarted bool) {
status, errType, message := concurrencyErrorResponse(err, slotType)
h.handleStreamingAwareError(c, status, errType, message, streamStarted)
status, errType, code, message := concurrencyErrorResponse(err, slotType)
h.handleStreamingAwareErrorWithCode(c, status, errType, code, message, streamStarted, false)
}
func (h *OpenAIGatewayHandler) handleFailoverExhausted(c *gin.Context, failoverErr *service.UpstreamFailoverError, streamStarted bool) {
@@ -3244,6 +3332,12 @@ func (h *OpenAIGatewayHandler) handleFailoverExhausted(c *gin.Context, failoverE
}
statusCode := failoverErr.StatusCode
responseBody := failoverErr.ResponseBody
if statusCode == http.StatusBadRequest && service.IsOpenAICompatibleModelNotFound400(responseBody) && !streamStarted {
upstreamMsg := service.SanitizeUpstreamErrorMessage(service.ExtractUpstreamErrorMessage(responseBody))
service.SetOpsUpstreamError(c, statusCode, upstreamMsg, "")
service.WriteOpenAIUpstreamClientError(c, statusCode, responseBody, upstreamMsg)
return
}
if service.IsOpenAISilentRefusalErrorBody(responseBody) {
service.SetOpsUpstreamError(c, statusCode, service.OpenAISilentRefusalClientMessage(), "")
h.handleStreamingAwareError(c, http.StatusBadGateway, "upstream_error", service.OpenAISilentRefusalClientMessage(), streamStarted)
@@ -3381,7 +3475,7 @@ func (h *OpenAIGatewayHandler) handleStreamingAwareErrorWithCode(
// 通用 `event: error` 帧不被识别为终止事件,会导致
// "stream closed before response.completed"。
if inboundIsResponses(c) {
if writeResponsesFailedSSE(c, errType, message) {
if writeResponsesFailedSSE(c, errType, code, message) {
return
}
}
@@ -3548,7 +3642,7 @@ func (h *OpenAIGatewayHandler) errorResponse(c *gin.Context, status int, errType
// 提交的 SSE 流交错,必须降级为 response.failed 终止事件(#3887)。
if service.StopOpenAICompactSSEKeepaliveCommitted(c) {
service.MarkOpsStreamError(c, errType, message, status)
if writeResponsesFailedSSE(c, errType, message) {
if writeResponsesFailedSSE(c, errType, "", message) {
return
}
}
@@ -3881,7 +3975,7 @@ func (h *OpenAIGatewayHandler) rejectIfCyberSessionBlocked(c *gin.Context, apiKe
// 写 JSON(#3887)。
if service.StopOpenAICompactSSEKeepaliveCommitted(c) {
service.MarkOpsStreamError(c, "permission_error", cyberSessionBlockedClientMsg, http.StatusForbidden)
if writeResponsesFailedSSE(c, "permission_error", cyberSessionBlockedClientMsg) {
if writeResponsesFailedSSE(c, "permission_error", "", cyberSessionBlockedClientMsg) {
h.enqueueCyberSessionBlockedOpsEntry(c, apiKey, model, key)
return true
}
@@ -4125,15 +4219,20 @@ func (h *OpenAIGatewayHandler) recordCyberPolicyIfMarked(c *gin.Context, apiKey
}()
}
// clearCyberPolicyTurnState resets the cyber mark and the per-request recorded
// guard. WS-only: called at the END of AfterTurn, after recordCyberPolicyIfMarked
// and RecordUsage (which reads CyberBlocked) have both consumed the mark.
// clearCyberPolicyTurnState resets the cyber mark and recorded guard after a
// logical WS turn has finished.
func clearCyberPolicyTurnState(c *gin.Context) {
clearCyberPolicyAttemptState(c, true)
}
func clearCyberPolicyAttemptState(c *gin.Context, resetRecorded bool) {
if c == nil {
return
}
service.ClearOpsCyberPolicy(c)
c.Set(cyberPolicyRecordedKey, false)
if resetRecorded {
c.Set(cyberPolicyRecordedKey, false)
}
}
func summarizeWSCloseErrorForLog(err error) (string, string) {
@@ -1530,7 +1530,7 @@ func TestOpenAIResponsesWebSocket_PassthroughTracksModelPerTurn(t *testing.T) {
"each turn must be billed with its own channel-mapped model")
}
func TestOpenAIResponsesWebSocket_UnchangedChannelTargetOutsideAccountMappingKeysRemainsValid(t *testing.T) {
func TestOpenAIResponsesWebSocket_ChannelMappedTargetSelectsAccountWithoutRequestedAlias(t *testing.T) {
got := runOpenAIResponsesWebSocketUsageLogCase(t, openAIResponsesWSUsageLogCase{
firstPayload: `{"type":"response.create","model":"public-alias","stream":false}`,
secondPayload: `{"type":"response.create","stream":false}`,
@@ -1538,7 +1538,7 @@ func TestOpenAIResponsesWebSocket_UnchangedChannelTargetOutsideAccountMappingKey
"public-alias": "gpt-5.6-sol",
},
accountModelMapping: map[string]any{
"public-alias": "gpt-5.6-terra",
"gpt-5.6-sol": "gpt-5.6-sol",
},
})
@@ -1710,6 +1710,40 @@ func TestOpenAIAccountScheduleModelUsesActualOrSharedResolver(t *testing.T) {
require.Equal(t, "attempt-actual", openAIAccountScheduleModel(c, account, "public", true, nil))
}
func TestOpenAIChannelForwardModelForScheduler(t *testing.T) {
tests := []struct {
name string
mapping service.ChannelMappingResult
requested string
want string
}{
{
name: "mapped model is used for capability filtering",
mapping: service.ChannelMappingResult{Mapped: true, MappedModel: " gpt-forward "},
requested: "client-alias",
want: "gpt-forward",
},
{
name: "unmapped request preserves client model",
mapping: service.ChannelMappingResult{MappedModel: "client-model"},
requested: "client-model",
want: "client-model",
},
{
name: "empty mapped model safely preserves client model",
mapping: service.ChannelMappingResult{Mapped: true, MappedModel: " "},
requested: "client-model",
want: "client-model",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
require.Equal(t, tt.want, openAIChannelForwardModel(tt.mapping, tt.requested))
})
}
}
func TestShouldReportOpenAIWSProxyAccountFailure(t *testing.T) {
t.Run("unsupported client model switch does not penalize account", func(t *testing.T) {
err := fmt.Errorf("wrapped ingress turn: %w", newOpenAIWSUnsupportedModelSwitchError("gpt-unsupported"))
@@ -0,0 +1,52 @@
package handler
import (
"errors"
"net/http"
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
)
func (h *GatewayHandler) pinnedOpenAIModels(c *gin.Context, group *service.Group) {
if c.Request.Context().Err() != nil {
return
}
if h.openAIGatewayService == nil {
writeOpenAIModelsError(c, http.StatusInternalServerError, "api_error", "OpenAI model discovery is not configured")
return
}
response, account, err := h.openAIGatewayService.FetchPinnedOpenAIModelsList(
c.Request.Context(), group, h.maxAccountSwitches, c.GetHeader("If-None-Match"),
)
if c.Request.Context().Err() != nil {
return
}
if err != nil {
if errors.Is(err, service.ErrNoPinnedCodexModelsAccounts) {
writeOpenAIModelsError(c, http.StatusServiceUnavailable, "upstream_error", "No available OpenAI model discovery accounts")
return
}
writeOpenAIModelsError(c, infraerrors.Code(err), "upstream_error", infraerrors.Message(err))
return
}
setOpsSelectedAccount(c, account.ID, account.Platform)
writeOpenAIModelsResponse(c, response)
}
func writeOpenAIModelsError(c *gin.Context, status int, errorType, message string) {
c.JSON(status, gin.H{"error": gin.H{"type": errorType, "message": message}})
}
func writeOpenAIModelsResponse(c *gin.Context, manifest *service.OpenAIModelsResponse) {
if manifest.ETag != "" {
c.Header("ETag", manifest.ETag)
}
if manifest.NotModified {
c.Status(http.StatusNotModified)
c.Writer.WriteHeaderNow()
return
}
c.Data(http.StatusOK, "application/json", manifest.Body)
}
@@ -0,0 +1,217 @@
package handler
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
func performOrdinaryPinnedModelsRequest(t *testing.T, codex *OpenAIGatewayHandler, group *service.Group, path, etag string) *httptest.ResponseRecorder {
t.Helper()
h := &GatewayHandler{openAIGatewayService: codex.gatewayService, maxAccountSwitches: 3}
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodGet, path, nil)
c.Request.Header.Set("If-None-Match", etag)
c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{GroupID: &group.ID, Group: group})
h.Models(c)
return recorder
}
func ordinaryPinnedModelIDs(t *testing.T, recorder *httptest.ResponseRecorder) []string {
t.Helper()
var response gatewayModelsResponseForTest
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &response))
require.NotNil(t, response.Data, "an empty catalog must be an array, not null")
return modelIDsForTest(response.Data)
}
func TestOrdinaryPinnedModelsUsesSelectedAccountsAndFinalETag(t *testing.T) {
accounts := []service.Account{
newPinnedCodexAccount(1, service.StatusActive, true, false),
newPinnedCodexAccount(2, service.StatusActive, true, true),
newPinnedCodexAccount(3, service.StatusActive, true, false),
}
upstream := &codexModelsPinnedHTTPUpstream{bodies: map[int64]string{
1: `{"data":[{"id":"unselected"}]}`,
2: `{"data":[{"id":"special-model","owned_by":"first","created":123},{"id":"gpt-image-1"}]}`,
3: `{"data":[{"id":"special-model","owned_by":"second"},{"id":"text-embedding-3-large"}]}`,
}}
h := newPinnedCodexTestHandler(accounts, upstream, 3)
group := &service.Group{ID: 91, Platform: service.PlatformOpenAI,
CodexModelsManifestConfig: service.GroupCodexModelsManifestConfig{Enabled: true, AccountIDs: []int64{2, 3}}}
first := performOrdinaryPinnedModelsRequest(t, h, group, "/v1/models", "")
require.Equal(t, http.StatusOK, first.Code, first.Body.String())
require.Equal(t, []string{"special-model", "gpt-image-1", "text-embedding-3-large"}, ordinaryPinnedModelIDs(t, first))
var response gatewayModelsResponseForTest
require.NoError(t, json.Unmarshal(first.Body.Bytes(), &response))
require.Equal(t, "model", response.Data[0].Object)
require.Equal(t, "first", response.Data[0].OwnedBy)
require.EqualValues(t, 123, response.Data[0].Created)
require.Equal(t, []int64{2, 3}, upstream.accountIDs())
alias := performOrdinaryPinnedModelsRequest(t, h, group, "/models", first.Header().Get("ETag"))
require.Equal(t, http.StatusNotModified, alias.Code)
require.Empty(t, alias.Body.String())
require.Equal(t, []int64{2, 3}, upstream.accountIDs(), "fresh requests must not hit upstream")
otherGroup := *group
otherGroup.ID++
otherGroup.ModelAllowlist = service.GroupModelAllowlist{Enabled: true, Models: []string{"text-embedding-3-large", "special-model"}}
filtered := performOrdinaryPinnedModelsRequest(t, h, &otherGroup, "/v1/models", first.Header().Get("ETag"))
require.Equal(t, http.StatusOK, filtered.Code)
require.Equal(t, []string{"text-embedding-3-large", "special-model"}, ordinaryPinnedModelIDs(t, filtered))
require.NotEqual(t, first.Header().Get("ETag"), filtered.Header().Get("ETag"))
again := performOrdinaryPinnedModelsRequest(t, h, group, "/v1/models", "")
require.Equal(t, first.Body.String(), again.Body.String(), "group filters must not modify account cache")
require.Equal(t, []int64{2, 3}, upstream.accountIDs())
}
func TestOrdinaryPinnedModelsFailureAndEmptyPolicies(t *testing.T) {
for _, tc := range []struct {
name string
accountIDs []int64
bodies map[int64]string
statuses map[int64]int
fallback bool
selected []string
wantStatus int
wantIDs []string
}{
{name: "partial failure", accountIDs: []int64{2, 3}, bodies: map[int64]string{2: `{"data":[{"id":"ok"}]}`}, statuses: map[int64]int{3: 503}, wantStatus: 200, wantIDs: []string{"ok"}},
{name: "all failed", accountIDs: []int64{2, 3}, statuses: map[int64]int{2: 429, 3: 504}, wantStatus: 502},
{name: "missing members", accountIDs: []int64{99}, wantStatus: 503},
{name: "empty config", wantStatus: 503},
{name: "fallback from missing members", accountIDs: []int64{99}, fallback: true, wantStatus: 200, wantIDs: []string{"from-scheduler"}},
{name: "fallback from failure", accountIDs: []int64{2}, statuses: map[int64]int{2: 503}, fallback: true, wantStatus: 200, wantIDs: []string{"from-scheduler"}},
{name: "valid empty", accountIDs: []int64{2}, bodies: map[int64]string{2: `{"data":[]}`}, fallback: true, wantStatus: 200},
{name: "filtered empty", accountIDs: []int64{2}, bodies: map[int64]string{2: `{"data":[{"id":"ok"}]}`}, selected: []string{"absent"}, fallback: true, wantStatus: 200},
{name: "invalid envelope", accountIDs: []int64{2}, bodies: map[int64]string{2: `{"error":"denied"}`}, wantStatus: 502},
{name: "null data", accountIDs: []int64{2}, bodies: map[int64]string{2: `{"data":null}`}, wantStatus: 502},
} {
t.Run(tc.name, func(t *testing.T) {
accounts := []service.Account{
newPinnedCodexAccount(1, service.StatusActive, true, false),
newPinnedCodexAccount(2, service.StatusActive, true, false),
newPinnedCodexAccount(3, service.StatusActive, true, false),
}
bodies := map[int64]string{1: `{"data":[{"id":"from-scheduler"}]}`}
for id, body := range tc.bodies {
bodies[id] = body
}
upstream := &codexModelsPinnedHTTPUpstream{bodies: bodies, statuses: tc.statuses}
h := newPinnedCodexTestHandler(accounts, upstream, 3)
group := &service.Group{ID: 92, Platform: service.PlatformOpenAI,
ModelAllowlist: service.GroupModelAllowlist{Enabled: len(tc.selected) > 0, Models: tc.selected},
CodexModelsManifestConfig: service.GroupCodexModelsManifestConfig{Enabled: true, AccountIDs: tc.accountIDs, FallbackToScheduler: tc.fallback}}
recorder := performOrdinaryPinnedModelsRequest(t, h, group, "/v1/models", "")
require.Equal(t, tc.wantStatus, recorder.Code, recorder.Body.String())
if tc.wantStatus == 200 {
require.Equal(t, len(tc.wantIDs), len(ordinaryPinnedModelIDs(t, recorder)))
if len(tc.wantIDs) > 0 {
require.Equal(t, tc.wantIDs, ordinaryPinnedModelIDs(t, recorder))
} else {
require.NotContains(t, upstream.accountIDs(), int64(1), "empty success must not trigger fallback")
}
}
})
}
}
func TestOrdinaryPinnedModelsSkipsPersistentlyUnavailableAccounts(t *testing.T) {
accounts := []service.Account{
newPinnedCodexAccount(1, service.StatusActive, true, true),
newPinnedCodexAccount(2, service.StatusActive, false, false),
newPinnedCodexAccount(3, service.StatusDisabled, true, false),
newPinnedCodexAccount(4, service.StatusActive, true, false),
}
expired := time.Now().Add(-time.Hour)
accounts[3].ExpiresAt = &expired
accounts[3].AutoPauseOnExpired = true
overloaded := time.Now().Add(time.Hour)
accounts[0].OverloadUntil = &overloaded
accounts[0].TempUnschedulableUntil = &overloaded
upstream := &codexModelsPinnedHTTPUpstream{bodies: map[int64]string{1: `{"data":[{"id":"available"}]}`}}
h := newPinnedCodexTestHandler(accounts, upstream, 3)
group := &service.Group{ID: 93, Platform: service.PlatformOpenAI,
CodexModelsManifestConfig: service.GroupCodexModelsManifestConfig{Enabled: true, AccountIDs: []int64{1, 2, 3, 4, 99}}}
recorder := performOrdinaryPinnedModelsRequest(t, h, group, "/v1/models", "")
require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String())
require.Equal(t, []int64{1}, upstream.accountIDs())
}
func TestPinnedModelsAllowlistExpandsWildcardsForBothRepresentations(t *testing.T) {
for _, codex := range []bool{false, true} {
t.Run(map[bool]string{false: "ordinary", true: "codex"}[codex], func(t *testing.T) {
accounts := []service.Account{newPinnedCodexAccount(1, service.StatusActive, true, false)}
accounts[0].Credentials["model_mapping"] = map[string]any{
"public-a": "gpt-5.5", "public-b": "gpt-5.5", "blocked": "gpt-5.5",
}
body := `{"data":[{"id":"gpt-5.5","owned_by":"provider"}]}`
if codex {
body = `{"models":[{"slug":"gpt-5.5","context_window":424242}]}`
}
upstream := &codexModelsPinnedHTTPUpstream{bodies: map[int64]string{1: body}}
h := newPinnedCodexTestHandler(accounts, upstream, 3)
group := &service.Group{ID: 95, Platform: service.PlatformOpenAI,
ModelAllowlist: service.GroupModelAllowlist{Enabled: true, Models: []string{"public-b", "public-*"}},
CodexModelsManifestConfig: service.GroupCodexModelsManifestConfig{Enabled: true, AccountIDs: []int64{1}}}
if codex {
recorder := performPinnedCodexModelsRequest(t, h, group, "")
require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String())
require.Equal(t, []string{"public-b", "public-a"}, codexHandlerManifestSlugs(t, recorder))
require.Contains(t, recorder.Body.String(), `"context_window":424242`)
} else {
recorder := performOrdinaryPinnedModelsRequest(t, h, group, "/v1/models", "")
require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String())
require.Equal(t, []string{"public-b", "public-a"}, ordinaryPinnedModelIDs(t, recorder))
require.Contains(t, recorder.Body.String(), `"owned_by":"provider"`)
}
})
}
}
func TestPinnedModelsMappingFollowsUpstreamDiscoveryForBothRepresentations(t *testing.T) {
for _, codex := range []bool{false, true} {
t.Run(map[bool]string{false: "ordinary", true: "codex"}[codex], func(t *testing.T) {
accounts := []service.Account{
newPinnedCodexAccount(1, service.StatusActive, true, false),
newPinnedCodexAccount(2, service.StatusActive, true, false),
}
accounts[0].Credentials["model_mapping"] = map[string]any{"unselected-alias": "gpt-5.5"}
accounts[1].Credentials["model_mapping"] = map[string]any{
"public-alias": "gpt-5.5", "missing-alias": "missing-upstream", "custom-*": "gpt-5.5",
}
body := `{"data":[{"id":"gpt-5.5","owned_by":"provider","created":123},{"id":"unmapped-upstream"}]}`
if codex {
body = `{"models":[{"slug":"gpt-5.5","context_window":424242},{"slug":"unmapped-upstream"}]}`
}
upstream := &codexModelsPinnedHTTPUpstream{bodies: map[int64]string{2: body}}
h := newPinnedCodexTestHandler(accounts, upstream, 3)
group := &service.Group{ID: 94, Platform: service.PlatformOpenAI,
ModelAllowlist: service.GroupModelAllowlist{Enabled: true, Models: []string{"custom-concrete", "public-alias", "unselected-alias", "missing-alias"}},
CodexModelsManifestConfig: service.GroupCodexModelsManifestConfig{Enabled: true, AccountIDs: []int64{2}}}
var recorder *httptest.ResponseRecorder
if codex {
recorder = performPinnedCodexModelsRequest(t, h, group, "")
} else {
recorder = performOrdinaryPinnedModelsRequest(t, h, group, "/v1/models", "")
}
require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String())
require.Equal(t, []int64{2}, upstream.accountIDs(), "mappings must not bypass pinned discovery")
if codex {
require.Equal(t, []string{"custom-concrete", "public-alias"}, codexHandlerManifestSlugs(t, recorder))
require.Contains(t, recorder.Body.String(), `"context_window":424242`)
} else {
require.Equal(t, []string{"custom-concrete", "public-alias"}, ordinaryPinnedModelIDs(t, recorder))
require.Contains(t, recorder.Body.String(), `"owned_by":"provider"`)
}
})
}
}
@@ -17,7 +17,7 @@ import (
// 上以 OpenAI 兼容错误结构返回 HTTP 400。这些用例在 handler 的 service_tier
// 校验处短路,不会进入账号选择/重试。
//
// 合法值(fast/priority/flex/auto/default/scale)与省略/null 的接受语义由
// 合法值(fast/priority/flex/auto/default/scale/ultrafast)与省略/null 的接受语义由
// service 层纯校验函数 TestValidateOpenAIServiceTierField 覆盖,避免 handler
// 测试走入真实账号选择/重试路径。
@@ -56,7 +56,7 @@ type responsesFailedEvent struct {
// 返回 false 表示 writer 不支持 Flusher,无法以 SSE 形式回报错误;
// 此时 caller 也无法回退到 JSON(HTTP 200 已固化),通常意味着连接已经损坏,
// 应当让请求处理函数 return,由上层关闭连接。
func writeResponsesFailedSSE(c *gin.Context, errType, message string) bool {
func writeResponsesFailedSSE(c *gin.Context, errType, code, message string) bool {
flusher, ok := c.Writer.(http.Flusher)
if !ok {
return false
@@ -72,7 +72,7 @@ func writeResponsesFailedSSE(c *gin.Context, errType, message string) bool {
Status: "failed",
Output: []any{},
Error: responsesFailedError{
Code: mapResponsesErrorCode(errType),
Code: mapResponsesErrorCode(errType, code),
Message: message,
},
},
@@ -155,7 +155,10 @@ func requestModel(c *gin.Context) string {
// mapResponsesErrorCode 把内部 errType 映射为 Responses 协议常见的 error.code。
// 无明确映射时原样返回,保证至少可读。
func mapResponsesErrorCode(errType string) string {
func mapResponsesErrorCode(errType, code string) string {
if code != "" {
return code
}
switch errType {
case "rate_limit_error":
return "rate_limit_exceeded"
@@ -12,6 +12,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
// Regression for the production incident on 2026-05-24 around 9:13 CST:
@@ -77,6 +78,60 @@ func TestOpenAIHandleStreamingAwareError_ResponsesStreamingEmitsResponseFailed(t
assert.Equal(t, "Concurrency limit exceeded for user, please retry later", errObj["message"])
}
func TestOpenAIAdmissionError_SynchronousNonResponsesIncludesGatewayCode(t *testing.T) {
c, w := newGinContextForEndpoint(t, EndpointChatCompletions)
h := &OpenAIGatewayHandler{}
h.handleStreamingAwareErrorWithCode(c, http.StatusTooManyRequests, "rate_limit_error",
gatewayQueueFullCode, "Too many pending requests, please retry later", false, false)
assert.Equal(t, http.StatusTooManyRequests, w.Code)
assert.Equal(t, "rate_limit_error", gjson.GetBytes(w.Body.Bytes(), "error.type").String())
assert.Equal(t, gatewayQueueFullCode, gjson.GetBytes(w.Body.Bytes(), "error.code").String())
}
func TestOpenAIAdmissionError_SynchronousResponsesIncludesGatewayCode(t *testing.T) {
c, w := newGinContextForEndpoint(t, EndpointResponses)
h := &OpenAIGatewayHandler{}
h.handleStreamingAwareErrorWithCode(c, http.StatusTooManyRequests, "rate_limit_error",
gatewayConcurrencyLimitCode, "Concurrency limit exceeded for account, please retry later", false, false)
assert.Equal(t, http.StatusTooManyRequests, w.Code)
assert.Equal(t, "rate_limit_error", gjson.GetBytes(w.Body.Bytes(), "error.type").String())
assert.Equal(t, gatewayConcurrencyLimitCode, gjson.GetBytes(w.Body.Bytes(), "error.code").String())
}
func TestOpenAIAdmissionError_ResponsesFailedIncludesGatewayCode(t *testing.T) {
c, w := newGinContextForEndpoint(t, EndpointResponses)
h := &OpenAIGatewayHandler{}
h.handleStreamingAwareErrorWithCode(c, http.StatusTooManyRequests, "rate_limit_error",
gatewayConcurrencyLimitCode, "Concurrency limit exceeded for account, please retry later", true, false)
_, errObj := parseResponsesFailedSSE(t, w.Body.String())
assert.Equal(t, gatewayConcurrencyLimitCode, errObj["code"])
}
func TestOpenAIAdmissionError_StreamingNonResponsesIncludesGatewayCode(t *testing.T) {
c, w := newGinContextForEndpoint(t, EndpointChatCompletions)
h := &OpenAIGatewayHandler{}
h.handleStreamingAwareErrorWithCode(c, http.StatusTooManyRequests, "rate_limit_error",
gatewayQueueFullCode, "Too many pending requests, please retry later", true, false)
body := w.Body.String()
assert.True(t, strings.HasPrefix(body, "event: error\n"))
payload := body[strings.Index(body, "{"):]
assert.Equal(t, "rate_limit_error", gjson.Get(payload, "error.type").String())
assert.Equal(t, gatewayQueueFullCode, gjson.Get(payload, "error.code").String())
}
func TestOpenAIUpstreamRateLimitWithoutLocalCodeRemainsUnchanged(t *testing.T) {
c, w := newGinContextForEndpoint(t, EndpointChatCompletions)
h := &OpenAIGatewayHandler{}
h.handleStreamingAwareError(c, http.StatusTooManyRequests, "rate_limit_error", "upstream limited", false)
assert.Equal(t, "rate_limit_error", gjson.GetBytes(w.Body.Bytes(), "error.type").String())
assert.False(t, gjson.GetBytes(w.Body.Bytes(), "error.code").Exists())
}
// 当 setOpsRequestContext 写过 model,合成事件应回填该字段(与 codebase 已有 makeResponsesCompletedEvent 对齐)。
func TestOpenAIHandleStreamingAwareError_ResponsesStreamingIncludesModel(t *testing.T) {
c, w := newGinContextForEndpoint(t, EndpointResponses)
@@ -164,6 +219,41 @@ func TestGatewayHandleStreamingAwareError_ResponsesStreamingEmitsResponseFailed(
assert.Equal(t, "upstream gone", errObj["message"])
}
func TestGatewayAdmissionError_SynchronousIncludesGatewayCode(t *testing.T) {
c, w := newGinContextForEndpoint(t, EndpointMessages)
h := &GatewayHandler{}
h.handleStreamingAwareErrorWithCode(c, http.StatusTooManyRequests, "rate_limit_error",
gatewayQueueFullCode, "Too many pending requests, please retry later", false)
assert.Equal(t, http.StatusTooManyRequests, w.Code)
assert.Equal(t, "error", gjson.GetBytes(w.Body.Bytes(), "type").String())
assert.Equal(t, "rate_limit_error", gjson.GetBytes(w.Body.Bytes(), "error.type").String())
assert.Equal(t, gatewayQueueFullCode, gjson.GetBytes(w.Body.Bytes(), "error.code").String())
}
func TestGatewayAdmissionError_MessagesStreamingIncludesGatewayCode(t *testing.T) {
c, w := newGinContextForEndpoint(t, EndpointMessages)
h := &GatewayHandler{}
h.handleStreamingAwareErrorWithCode(c, http.StatusTooManyRequests, "rate_limit_error",
gatewayConcurrencyLimitCode, "Concurrency limit exceeded for account, please retry later", true)
body := w.Body.String()
assert.True(t, strings.HasPrefix(body, `data: {"type":"error"`))
payload := body[strings.Index(body, "{"):]
assert.Equal(t, "rate_limit_error", gjson.Get(payload, "error.type").String())
assert.Equal(t, gatewayConcurrencyLimitCode, gjson.Get(payload, "error.code").String())
}
func TestGatewayAdmissionError_BareResponsesFailedIncludesGatewayCode(t *testing.T) {
c, w := newGinContextForEndpoint(t, "/responses")
h := &GatewayHandler{}
h.handleStreamingAwareErrorWithCode(c, http.StatusTooManyRequests, "rate_limit_error",
gatewayQueueFullCode, "Too many pending requests, please retry later", true)
_, errObj := parseResponsesFailedSSE(t, w.Body.String())
assert.Equal(t, gatewayQueueFullCode, errObj["code"])
}
// Gateway handler: /v1/messages preserves the legacy data:{type:error,...} format
// (Anthropic spec accepts a type:"error" stream event).
func TestGatewayHandleStreamingAwareError_MessagesStreamingKeepsLegacy(t *testing.T) {
@@ -264,6 +354,7 @@ func TestMapResponsesErrorCode(t *testing.T) {
{"custom_thing", "custom_thing"},
}
for _, tc := range cases {
assert.Equal(t, tc.out, mapResponsesErrorCode(tc.in), "in=%q", tc.in)
assert.Equal(t, tc.out, mapResponsesErrorCode(tc.in, ""), "in=%q", tc.in)
}
assert.Equal(t, gatewayQueueFullCode, mapResponsesErrorCode("rate_limit_error", gatewayQueueFullCode))
}
+1 -1
View File
@@ -250,7 +250,7 @@ var ProviderSet = wire.NewSet(
// Admin handlers
admin.NewDashboardHandler,
admin.NewUserHandler,
admin.NewGroupHandler,
admin.NewGroupHandlerWithConfig,
admin.ProvideAccountHandler,
admin.NewAnnouncementHandler,
admin.NewDataManagementHandler,
+1 -1
View File
@@ -34,7 +34,7 @@ const (
AntigravityUserAgentVersionEnv = "ANTIGRAVITY_USER_AGENT_VERSION"
// DefaultUserAgentVersion 是未通过环境变量或后台设置覆盖时使用的默认版本号。
DefaultUserAgentVersion = "1.23.2"
DefaultUserAgentVersion = "2.9.1"
// 固定的 redirect_uri(用户需手动复制 code)
RedirectURI = "http://localhost:8085/callback"
@@ -693,7 +693,7 @@ func TestConstants_值正确(t *testing.T) {
if RedirectURI != "http://localhost:8085/callback" {
t.Errorf("RedirectURI 不匹配: got %s", RedirectURI)
}
if GetUserAgent() != "antigravity/1.23.2 windows/amd64" {
if GetUserAgent() != "antigravity/2.9.1 windows/amd64" {
t.Errorf("UserAgent 不匹配: got %s", GetUserAgent())
}
if SessionTTL != 30*time.Minute {
@@ -1621,6 +1621,108 @@ func TestBufferedResponseAccumulator_ToolCalls(t *testing.T) {
assert.Equal(t, `{"city":"NYC"}`, output[0].Arguments)
}
func TestResponsesEventToChatChunks_FunctionArgumentsDoneWithoutDeltas(t *testing.T) {
state := NewResponsesEventToChatState()
state.Model = "gpt-5-codex"
state.SentRole = true
chunks := ResponsesEventToChatChunks(&ResponsesStreamEvent{
Type: "response.output_item.added",
OutputIndex: 1,
Item: &ResponsesOutput{
Type: "function_call",
CallID: "call_lookup",
Name: "lookup_item",
Arguments: "",
},
}, state)
require.Len(t, chunks, 1)
chunks = ResponsesEventToChatChunks(&ResponsesStreamEvent{
Type: "response.function_call_arguments.done",
OutputIndex: 1,
CallID: "call_lookup",
Name: "lookup_item",
Arguments: `{"id":1}`,
}, state)
require.Len(t, chunks, 1)
require.Len(t, chunks[0].Choices[0].Delta.ToolCalls, 1)
assert.Equal(t, `{"id":1}`, chunks[0].Choices[0].Delta.ToolCalls[0].Function.Arguments)
}
func TestResponsesEventToChatChunks_FunctionArgumentsDoneDoesNotDuplicateDeltas(t *testing.T) {
state := NewResponsesEventToChatState()
state.Model = "gpt-5-codex"
state.SentRole = true
ResponsesEventToChatChunks(&ResponsesStreamEvent{
Type: "response.output_item.added",
OutputIndex: 0,
Item: &ResponsesOutput{Type: "function_call", CallID: "call_lookup", Name: "lookup_item"},
}, state)
chunks := ResponsesEventToChatChunks(&ResponsesStreamEvent{
Type: "response.function_call_arguments.delta",
OutputIndex: 0,
Delta: `{"id":1}`,
}, state)
require.Len(t, chunks, 1)
chunks = ResponsesEventToChatChunks(&ResponsesStreamEvent{
Type: "response.function_call_arguments.done",
OutputIndex: 0,
Arguments: `{"id":1}`,
}, state)
assert.Empty(t, chunks)
}
func TestBufferedResponseAccumulator_FunctionArgumentsDoneWithoutDeltas(t *testing.T) {
acc := NewBufferedResponseAccumulator()
acc.ProcessEvent(&ResponsesStreamEvent{
Type: "response.output_item.added",
OutputIndex: 1,
Item: &ResponsesOutput{Type: "function_call", CallID: "call_lookup", Name: "lookup_item"},
})
acc.ProcessEvent(&ResponsesStreamEvent{
Type: "response.function_call_arguments.done",
OutputIndex: 1,
Arguments: `{"id":1}`,
})
resp := &ResponsesResponse{Output: []ResponsesOutput{{
Type: "function_call",
CallID: "call_lookup",
Name: "lookup_item",
Arguments: "",
}}}
acc.SupplementResponseOutput(resp)
require.Len(t, resp.Output, 1)
assert.Equal(t, `{"id":1}`, resp.Output[0].Arguments)
}
func TestBufferedResponseAccumulator_FunctionArgumentsDoneReplacesDeltas(t *testing.T) {
acc := NewBufferedResponseAccumulator()
acc.ProcessEvent(&ResponsesStreamEvent{
Type: "response.output_item.added",
OutputIndex: 0,
Item: &ResponsesOutput{Type: "function_call", CallID: "call_lookup", Name: "lookup_item"},
})
acc.ProcessEvent(&ResponsesStreamEvent{
Type: "response.function_call_arguments.delta",
OutputIndex: 0,
Delta: `{"id":1}`,
})
acc.ProcessEvent(&ResponsesStreamEvent{
Type: "response.function_call_arguments.done",
OutputIndex: 0,
Arguments: `{"id":1}`,
})
output := acc.BuildOutput()
require.Len(t, output, 1)
assert.Equal(t, `{"id":1}`, output[0].Arguments)
}
func TestBufferedResponseAccumulator_Reasoning(t *testing.T) {
acc := NewBufferedResponseAccumulator()
@@ -126,6 +126,7 @@ type ResponsesEventToChatState struct {
Finalized bool // true after finish chunk has been emitted
NextToolCallIndex int // next sequential tool_call index to assign
OutputIndexToToolIndex map[int]int // Responses output_index → Chat tool_calls index
OutputIndexToArguments map[int]string
IncludeUsage bool
Usage *ChatUsage
}
@@ -136,6 +137,7 @@ func NewResponsesEventToChatState() *ResponsesEventToChatState {
ID: generateChatCmplID(),
Created: time.Now().Unix(),
OutputIndexToToolIndex: make(map[int]int),
OutputIndexToArguments: make(map[int]string),
}
}
@@ -154,6 +156,8 @@ func ResponsesEventToChatChunks(evt *ResponsesStreamEvent, state *ResponsesEvent
// 均按 OutputIndex 累加到对应工具调用。
"response.custom_tool_call_input.delta":
return resToChatHandleFuncArgsDelta(evt, state)
case "response.function_call_arguments.done", "response.custom_tool_call_input.done":
return resToChatHandleFuncArgsDone(evt, state)
case "response.reasoning_summary_text.delta",
// 原始推理文本增量(真实 Codex 客户端消费的 reasoning_text.delta),
// 与 reasoning summary 一样映射为 reasoning_content。
@@ -277,6 +281,7 @@ func resToChatHandleFuncArgsDelta(evt *ResponsesStreamEvent, state *ResponsesEve
if !ok {
return nil
}
state.OutputIndexToArguments[evt.OutputIndex] += evt.Delta
return []ChatCompletionsChunk{makeChatDeltaChunk(state, ChatDelta{
ToolCalls: []ChatToolCall{{
@@ -288,6 +293,33 @@ func resToChatHandleFuncArgsDelta(evt *ResponsesStreamEvent, state *ResponsesEve
})}
}
func resToChatHandleFuncArgsDone(evt *ResponsesStreamEvent, state *ResponsesEventToChatState) []ChatCompletionsChunk {
idx, ok := state.OutputIndexToToolIndex[evt.OutputIndex]
if !ok {
return nil
}
completed := evt.Arguments
if evt.Type == "response.custom_tool_call_input.done" {
completed = evt.Input
}
current := state.OutputIndexToArguments[evt.OutputIndex]
if completed == "" || !strings.HasPrefix(completed, current) || completed == current {
return nil
}
remainder := completed[len(current):]
state.OutputIndexToArguments[evt.OutputIndex] = completed
return []ChatCompletionsChunk{makeChatDeltaChunk(state, ChatDelta{
ToolCalls: []ChatToolCall{{
Index: &idx,
Function: ChatFunctionCall{
Arguments: remainder,
},
}},
})}
}
func resToChatHandleReasoningDelta(evt *ResponsesStreamEvent, state *ResponsesEventToChatState) []ChatCompletionsChunk {
if evt.Delta == "" {
return nil
@@ -453,9 +485,10 @@ func generateChatCmplID() string {
// ---------------------------------------------------------------------------
type bufferedFuncCall struct {
CallID string
Name string
Args strings.Builder
OutputIndex int
CallID string
Name string
Args strings.Builder
}
// BufferedResponseAccumulator collects content from Responses SSE delta events
@@ -489,8 +522,9 @@ func (a *BufferedResponseAccumulator) ProcessEvent(event *ResponsesStreamEvent)
idx := len(a.funcCalls)
a.outputIndexToFuncIdx[event.OutputIndex] = idx
a.funcCalls = append(a.funcCalls, bufferedFuncCall{
CallID: event.Item.CallID,
Name: event.Item.Name,
OutputIndex: event.OutputIndex,
CallID: event.Item.CallID,
Name: event.Item.Name,
})
}
case "response.function_call_arguments.delta", "response.custom_tool_call_input.delta":
@@ -499,6 +533,17 @@ func (a *BufferedResponseAccumulator) ProcessEvent(event *ResponsesStreamEvent)
_, _ = a.funcCalls[idx].Args.WriteString(event.Delta)
}
}
case "response.function_call_arguments.done", "response.custom_tool_call_input.done":
completed := event.Arguments
if event.Type == "response.custom_tool_call_input.done" {
completed = event.Input
}
if completed != "" {
if idx, ok := a.outputIndexToFuncIdx[event.OutputIndex]; ok {
a.funcCalls[idx].Args.Reset()
_, _ = a.funcCalls[idx].Args.WriteString(completed)
}
}
case "response.reasoning_summary_text.delta", "response.reasoning_text.delta":
if event.Delta != "" {
_, _ = a.reasoning.WriteString(event.Delta)
@@ -550,15 +595,35 @@ func (a *BufferedResponseAccumulator) BuildOutput() []ResponsesOutput {
return out
}
// SupplementResponseOutput fills resp.Output from accumulated delta content
// when the terminal event delivered an empty output array. If resp.Output is
// already populated, this is a no-op (preserves backward compatibility).
// SupplementResponseOutput fills resp.Output from accumulated stream content
// when the terminal event delivered an empty output array. It also fills empty
// function-call arguments from authoritative argument-done events.
func (a *BufferedResponseAccumulator) SupplementResponseOutput(resp *ResponsesResponse) {
if resp == nil || len(resp.Output) > 0 {
if resp == nil {
return
}
if !a.HasContent() {
if len(resp.Output) == 0 {
if a.HasContent() {
resp.Output = a.BuildOutput()
}
return
}
resp.Output = a.BuildOutput()
for outputIndex := range resp.Output {
item := &resp.Output[outputIndex]
if item.Type != "function_call" || item.Arguments != "" {
continue
}
for funcIndex := range a.funcCalls {
call := &a.funcCalls[funcIndex]
matchesCallID := item.CallID != "" && item.CallID == call.CallID
if !matchesCallID && call.OutputIndex != outputIndex {
continue
}
if call.Args.Len() > 0 {
item.Arguments = call.Args.String()
}
break
}
}
}
@@ -0,0 +1,79 @@
package claude
import (
"log/slog"
"os"
"strings"
"golang.org/x/mod/semver"
)
// CLIVersionEnv 是 CLICurrentVersion 的可选运维覆盖。
//
// 存在的理由:Anthropic 会对新模型设客户端版本下限(例如 claude-fable-5-1 要求
// claude-cli >= 2.1.251),命中时上游直接返回
// `Claude Code X.Y.Z does not support this model; version A.B.C or newer is required`。
// 在没有本开关之前,这类模型必须等 sub2api 发一个新版本才能使用,
// 而改动本身只是一个常量。xai 包的 XAI_GROK_CLI_VERSION 已经是同样的做法。
const CLIVersionEnv = "SUB2API_CLAUDE_CLI_VERSION"
// resolvedCLIVersion 在包初始化时解析一次。
//
// ⚠️ 故意不做成"每次调用读一次环境变量":伪装身份必须在一个进程的生命周期内保持恒定。
// User-Agent 头与请求体 billing attribution 块里的 cc_version 由不同代码路径写入,
// 若两次读到不同的值(例如进程运行中有人改了环境变量),同一个请求就会自相矛盾,
// 被上游判为非正版客户端。
var resolvedCLIVersion = resolveCLIVersion(os.Getenv(CLIVersionEnv))
// CLIVersion 返回对外伪装的 Claude Code CLI 版本号(三段 semver)。
//
// 所有需要该版本号的位置都必须走本函数,不要直接引用 CLICurrentVersion——
// 后者只是"没有覆盖时的内置基线"。
func CLIVersion() string {
return resolvedCLIVersion
}
// IsSupportedCLIVersion 判断运维给的覆盖值是否可用。
//
// 判据有两条,缺一不可:
// 1. 严格三段纯数字("2.1.251")。带 -local / -dev / +build 等后缀的版本号会被
// identity_service 的 fingerprintUserAgentPattern 拒绝,一旦漏进去,该账号的
// 持久指纹会被写成一个不存在的客户端版本,此后所有上游请求都声称这个版本,
// 被判非正版并持续 429——而系统内没有指纹重置入口。
// 2. 不低于内置基线 CLICurrentVersion。向下覆盖没有任何使用场景,
// 却会让 identity_service 的主版本超前检查基准跟着一起降。
func IsSupportedCLIVersion(version string) bool {
version = strings.TrimSpace(version)
if version == "" {
return false
}
// semver 允许 "v1.2" 与预发布/构建元数据,这里都不接受:
// Canonical 相等可排除省略段,再显式排除预发布与构建元数据。
canonical := "v" + version
if !semver.IsValid(canonical) || semver.Canonical(canonical) != canonical {
return false
}
if semver.Prerelease(canonical) != "" || semver.Build(canonical) != "" {
return false
}
return semver.Compare(canonical, "v"+CLICurrentVersion) >= 0
}
// resolveCLIVersion 把环境变量的原始值解析成可用的版本号。
// 空值静默回落(未配置是正常状态);非空但不合法则回落并告警——
// 静默忽略一个显式配置会让运维以为已经生效。
func resolveCLIVersion(raw string) string {
version := strings.TrimSpace(raw)
if version == "" {
return CLICurrentVersion
}
if !IsSupportedCLIVersion(version) {
slog.Warn("ignoring invalid Claude CLI version override; falling back to the built-in pin",
"env", CLIVersionEnv,
"value", version,
"builtin", CLICurrentVersion,
"requirement", "strict three-part semver, not older than the built-in pin")
return CLICurrentVersion
}
return version
}
@@ -0,0 +1,73 @@
package claude
import "testing"
func TestIsSupportedCLIVersion(t *testing.T) {
cases := []struct {
name string
version string
want bool
}{
{"内置基线本身", CLICurrentVersion, true},
{"高于基线的补丁位", "2.1.259", true},
{"高于基线的次版本", "2.2.0", true},
{"高于基线的主版本", "3.0.0", true},
{"低于基线", "2.1.219", false},
{"远低于基线", "1.0.0", false},
{"空值", "", false},
{"只有两段", "2.2", false},
{"带 v 前缀", "v2.2.0", false},
{"预发布后缀", "2.2.0-local", false},
{"构建元数据", "2.2.0+build1", false},
{"哨兵版本带后缀", "999.0.0-local", false},
{"纯数字哨兵仍然合法(形态没问题,运维自负)", "999.0.0", true},
{"非数字", "abc", false},
{"多余的段", "2.2.0.1", false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := IsSupportedCLIVersion(tc.version); got != tc.want {
t.Fatalf("IsSupportedCLIVersion(%q) = %v, want %v", tc.version, got, tc.want)
}
})
}
}
func TestResolveCLIVersion(t *testing.T) {
cases := []struct {
name string
raw string
want string
}{
{"未配置时用内置基线", "", CLICurrentVersion},
{"只有空白等同未配置", " ", CLICurrentVersion},
{"合法覆盖生效", "2.1.259", "2.1.259"},
{"两侧空白被裁掉", " 2.1.259 ", "2.1.259"},
{"非法值回落基线", "not-a-version", CLICurrentVersion},
{"向下覆盖被拒", "2.0.0", CLICurrentVersion},
{"预发布后缀被拒(会毒化账号指纹)", "2.2.0-local", CLICurrentVersion},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := resolveCLIVersion(tc.raw); got != tc.want {
t.Fatalf("resolveCLIVersion(%q) = %q, want %q", tc.raw, got, tc.want)
}
})
}
}
// 伪装身份的自洽性:User-Agent 头里的版本号必须与 CLIVersion() 一致。
// 两者由不同代码路径写入同一个请求,不一致会被上游判为非正版客户端。
func TestDefaultHeadersUserAgentMatchesCLIVersion(t *testing.T) {
want := "claude-cli/" + CLIVersion() + " (external, cli)"
if got := DefaultHeaders["User-Agent"]; got != want {
t.Fatalf("DefaultHeaders[User-Agent] = %q, want %q", got, want)
}
}
// 没有配置覆盖时,CLIVersion() 必须等于内置基线——保证本 PR 对既有部署零行为变化。
func TestCLIVersionDefaultsToBuiltinPin(t *testing.T) {
if got := CLIVersion(); got != CLICurrentVersion {
t.Fatalf("CLIVersion() = %q, want built-in pin %q (测试进程未设置 %s)", got, CLICurrentVersion, CLIVersionEnv)
}
}
+6 -3
View File
@@ -74,10 +74,13 @@ const APIKeyHaikuBetaHeader = BetaInterleavedThinking
// 客户端缺省时统一使用 5m",这样既不浪费 1h 缓存额度,也保留客户端自定义能力。
const DefaultCacheControlTTL = "5m"
// CLICurrentVersion 是 sub2api 当前对外伪装的 Claude Code CLI 版本号(三段 semver)。
// CLICurrentVersion 是内置的 Claude Code CLI 伪装版本号基线(三段 semver)。
// 用于 billing attribution block 中的 cc_version=X.Y.Z.{fp} 前缀以及 fingerprint 计算。
// 必须与 DefaultHeaders["User-Agent"] 中的版本号严格一致;不一致会被 Anthropic 判第三方。
const CLICurrentVersion = "2.1.220"
//
// ⚠️ 读取实际生效的版本号请用 CLIVersion(),它会叠加 SUB2API_CLAUDE_CLI_VERSION 覆盖。
// 直接引用本常量只在"表达内置基线"时才正确(例如覆盖值的下限校验)。
const CLICurrentVersion = "2.1.258"
// FullClaudeCodeMimicryBetas 返回最"像"真实 Claude Code CLI 的完整 beta 列表,
// 用于 OAuth 账号伪装成 Claude Code 时使用。
@@ -105,7 +108,7 @@ var DefaultHeaders = map[string]string{
// Keep these in sync with recent Claude CLI traffic to reduce the chance
// that Claude Code-scoped OAuth credentials are rejected as "non-CLI" usage.
// 版本参考:对齐 Parrot (src/transform/cc_mimicry.py:49) 的 CLI_USER_AGENT。
"User-Agent": "claude-cli/" + CLICurrentVersion + " (external, cli)",
"User-Agent": "claude-cli/" + CLIVersion() + " (external, cli)",
"X-Stainless-Lang": "js",
"X-Stainless-Package-Version": "0.94.0",
"X-Stainless-OS": "Linux",
@@ -0,0 +1,70 @@
package claude
import (
"strconv"
"strings"
"testing"
"github.com/stretchr/testify/require"
)
// fable51MinCLIVersion 是上游对 claude-fable-5-1 的客户端版本闸门下限。
// 低于该版本时上游直接 400:
//
// claude_code_version_too_old
// "Claude Code <ver> does not support this model; version 2.1.251 or newer is required."
const fable51MinCLIVersion = "2.1.251"
// TestCLICurrentVersionMatchesDefaultUserAgent 锁定"常量与 UA 同源"这条约束。
// 两者不一致会被 Anthropic 判为第三方客户端,且只改一处是历史上的常见疏漏,
// 所以这里从 UA 里反解版本号来比对,而不是并列两个字面量。
func TestCLICurrentVersionMatchesDefaultUserAgent(t *testing.T) {
t.Parallel()
ua := DefaultHeaders["User-Agent"]
const prefix = "claude-cli/"
require.True(t, strings.HasPrefix(ua, prefix), "unexpected User-Agent: %q", ua)
rest := strings.TrimPrefix(ua, prefix)
version := rest
if idx := strings.IndexByte(rest, ' '); idx >= 0 {
version = rest[:idx]
}
require.Equal(t, CLICurrentVersion, version,
"DefaultHeaders User-Agent version must match CLICurrentVersion")
}
// TestCLICurrentVersionSatisfiesFable51Gate 锁定伪装版本不低于 claude-fable-5-1
// 的闸门下限。DefaultModels 里登记了该模型却发着更旧的版本号,等于模型清单可见
// 但一调就 400。
func TestCLICurrentVersionSatisfiesFable51Gate(t *testing.T) {
t.Parallel()
require.GreaterOrEqual(t, compareSemver(t, CLICurrentVersion, fable51MinCLIVersion), 0,
"CLICurrentVersion %s is below the claude-fable-5-1 gate %s",
CLICurrentVersion, fable51MinCLIVersion)
}
// compareSemver 比较两个三段 semver,返回 -1 / 0 / 1。
func compareSemver(t *testing.T, a, b string) int {
t.Helper()
as, bs := strings.Split(a, "."), strings.Split(b, ".")
require.Len(t, as, 3, "not a three-part semver: %q", a)
require.Len(t, bs, 3, "not a three-part semver: %q", b)
for i := range as {
ai, err := strconv.Atoi(as[i])
require.NoError(t, err, "non-numeric segment in %q", a)
bi, err := strconv.Atoi(bs[i])
require.NoError(t, err, "non-numeric segment in %q", b)
switch {
case ai > bi:
return 1
case ai < bi:
return -1
}
}
return 0
}
+2
View File
@@ -19,9 +19,11 @@ type Model struct {
// DefaultModels OpenAI models list
var DefaultModels = []Model{
{ID: "gpt-5.6-sol", Object: "model", Created: 1780876800, OwnedBy: "openai", Type: "model", DisplayName: "GPT-5.6 Sol"},
{ID: "gpt-6", Object: "model", Created: 1788480000, OwnedBy: "openai", Type: "model", DisplayName: "GPT-6 (Astra)"},
{ID: "gpt-5.6", Object: "model", Created: 1780876800, OwnedBy: "openai", Type: "model", DisplayName: "GPT-5.6 (Sol)"},
{ID: "gpt-5.6-terra", Object: "model", Created: 1780876800, OwnedBy: "openai", Type: "model", DisplayName: "GPT-5.6 Terra"},
{ID: "gpt-5.6-luna", Object: "model", Created: 1780876800, OwnedBy: "openai", Type: "model", DisplayName: "GPT-5.6 Luna"},
{ID: "gpt-6-astra", Object: "model", Created: 1788480000, OwnedBy: "openai", Type: "model", DisplayName: "GPT-6 Astra"},
{ID: "gpt-5.5", Object: "model", Created: 1776873600, OwnedBy: "openai", Type: "model", DisplayName: "GPT-5.5"},
{ID: "gpt-5.4", Object: "model", Created: 1738368000, OwnedBy: "openai", Type: "model", DisplayName: "GPT-5.4"},
{ID: "gpt-5.4-mini", Object: "model", Created: 1738368000, OwnedBy: "openai", Type: "model", DisplayName: "GPT-5.4 Mini"},
@@ -10,6 +10,19 @@ func TestDefaultModelsIncludeBareGPT56Alias(t *testing.T) {
require.Contains(t, DefaultModelIDs(), "gpt-5.6")
}
func TestDefaultModelsIncludeGPT6Astra(t *testing.T) {
require.Contains(t, DefaultModelIDs(), "gpt-6-astra")
require.Contains(t, DefaultModelIDs(), "gpt-6")
var displayName string
for _, model := range DefaultModels {
if model.ID == "gpt-6-astra" {
displayName = model.DisplayName
break
}
}
require.Equal(t, "GPT-6 Astra", displayName)
}
func TestDefaultModelsPreferConcreteGPT56SolForAccountTests(t *testing.T) {
require.NotEmpty(t, DefaultModels)
require.Equal(t, "gpt-5.6-sol", DefaultModels[0].ID)
+28 -3
View File
@@ -221,16 +221,21 @@ func (r *accountRepository) CreateWithAccountGroups(ctx context.Context, account
// Reuse a caller-owned transaction when this repository is already transactional.
txClient = r.client
}
groupIDs := make([]int64, 0, len(groups))
for i := range groups {
groupIDs = append(groupIDs, groups[i].GroupID)
}
if err := lockLiveGroups(ctx, txClient, groupIDs); err != nil {
return err
}
if err := createAccountRecord(ctx, txClient, account); err != nil {
return err
}
groupIDs := make([]int64, 0, len(groups))
if len(groups) > 0 {
builders := make([]*dbent.AccountGroupCreate, 0, len(groups))
for i := range groups {
groups[i].AccountID = account.ID
groupIDs = append(groupIDs, groups[i].GroupID)
builders = append(builders, txClient.AccountGroup.Create().
SetAccountID(account.ID).
SetGroupID(groups[i].GroupID).
@@ -1759,7 +1764,19 @@ func (r *accountRepository) ClearError(ctx context.Context, id int64) error {
}
func (r *accountRepository) AddToGroup(ctx context.Context, accountID, groupID int64, priority int) error {
_, err := r.client.AccountGroup.Create().
tx, err := r.client.Tx(ctx)
if err != nil && !errors.Is(err, dbent.ErrTxStarted) {
return err
}
client := r.client
if tx != nil {
defer func() { _ = tx.Rollback() }()
client = tx.Client()
}
if err := lockLiveGroups(ctx, client, []int64{groupID}); err != nil {
return err
}
_, err = client.AccountGroup.Create().
SetAccountID(accountID).
SetGroupID(groupID).
SetPriority(priority).
@@ -1767,6 +1784,11 @@ func (r *accountRepository) AddToGroup(ctx context.Context, accountID, groupID i
if err != nil {
return err
}
if tx != nil {
if err := tx.Commit(); err != nil {
return err
}
}
payload := buildSchedulerGroupPayload([]int64{groupID})
if err := enqueueSchedulerOutbox(ctx, r.sql, service.SchedulerOutboxEventAccountGroupsChanged, &accountID, nil, payload); err != nil {
logger.LegacyPrintf("repository.account", "[SchedulerOutbox] enqueue add to group failed: account=%d group=%d err=%v", accountID, groupID, err)
@@ -1827,6 +1849,9 @@ func (r *accountRepository) BindGroups(ctx context.Context, accountID int64, gro
// 已处于外部事务中(ErrTxStarted),复用当前 client
txClient = r.client
}
if err := lockLiveGroups(ctx, txClient, groupIDs); err != nil {
return err
}
if _, err := txClient.AccountGroup.Delete().Where(dbaccountgroup.AccountIDEQ(accountID)).Exec(ctx); err != nil {
return err
@@ -16,7 +16,7 @@ import (
func (r *channelRepository) ListModelPricing(ctx context.Context, channelID int64) ([]service.ChannelModelPricing, error) {
rows, err := r.db.QueryContext(ctx,
`SELECT id, channel_id, platform, models, billing_mode, input_price, output_price, cache_write_price, cache_write_1h_price, cache_read_price, fast_multiplier, flex_multiplier, image_input_price, image_output_price, per_request_price, time_pricing, created_at, updated_at
`SELECT id, channel_id, platform, models, billing_mode, input_price, output_price, cache_write_price, cache_write_1h_price, cache_read_price, fast_multiplier, flex_multiplier, max_reasoning_effort_multiplier, image_input_price, image_output_price, per_request_price, time_pricing, created_at, updated_at
FROM channel_model_pricing WHERE channel_id = $1 ORDER BY id`, channelID,
)
if err != nil {
@@ -61,10 +61,10 @@ func (r *channelRepository) UpdateModelPricing(ctx context.Context, pricing *ser
}
result, err := r.db.ExecContext(ctx,
`UPDATE channel_model_pricing
SET models = $1, billing_mode = $2, input_price = $3, output_price = $4, cache_write_price = $5, cache_write_1h_price = $6, cache_read_price = $7, fast_multiplier = $8, flex_multiplier = $9, image_input_price = $10, image_output_price = $11, per_request_price = $12, time_pricing = $13, platform = $14, updated_at = NOW()
WHERE id = $15`,
SET models = $1, billing_mode = $2, input_price = $3, output_price = $4, cache_write_price = $5, cache_write_1h_price = $6, cache_read_price = $7, fast_multiplier = $8, flex_multiplier = $9, max_reasoning_effort_multiplier = $10, image_input_price = $11, image_output_price = $12, per_request_price = $13, time_pricing = $14, platform = $15, updated_at = NOW()
WHERE id = $16`,
modelsJSON, billingMode, pricing.InputPrice, pricing.OutputPrice, pricing.CacheWritePrice, pricing.CacheWrite1hPrice, pricing.CacheReadPrice,
pricing.FastMultiplier, pricing.FlexMultiplier, pricing.ImageInputPrice, pricing.ImageOutputPrice, pricing.PerRequestPrice,
pricing.FastMultiplier, pricing.FlexMultiplier, pricing.MaxReasoningEffortMultiplier, pricing.ImageInputPrice, pricing.ImageOutputPrice, pricing.PerRequestPrice,
timePricingJSON, pricing.Platform, pricing.ID,
)
if err != nil {
@@ -96,7 +96,7 @@ func (r *channelRepository) ReplaceModelPricing(ctx context.Context, channelID i
// batchLoadModelPricing 批量加载多个渠道的模型定价(含区间)
func (r *channelRepository) batchLoadModelPricing(ctx context.Context, channelIDs []int64) (map[int64][]service.ChannelModelPricing, error) {
rows, err := r.db.QueryContext(ctx,
`SELECT id, channel_id, platform, models, billing_mode, input_price, output_price, cache_write_price, cache_write_1h_price, cache_read_price, fast_multiplier, flex_multiplier, image_input_price, image_output_price, per_request_price, time_pricing, created_at, updated_at
`SELECT id, channel_id, platform, models, billing_mode, input_price, output_price, cache_write_price, cache_write_1h_price, cache_read_price, fast_multiplier, flex_multiplier, max_reasoning_effort_multiplier, image_input_price, image_output_price, per_request_price, time_pricing, created_at, updated_at
FROM channel_model_pricing WHERE channel_id = ANY($1) ORDER BY channel_id, id`,
pq.Array(channelIDs),
)
@@ -180,7 +180,7 @@ func scanModelPricingRows(rows *sql.Rows) ([]service.ChannelModelPricing, []int6
if err := rows.Scan(
&p.ID, &p.ChannelID, &p.Platform, &modelsJSON, &p.BillingMode,
&p.InputPrice, &p.OutputPrice, &p.CacheWritePrice, &p.CacheWrite1hPrice, &p.CacheReadPrice,
&p.FastMultiplier, &p.FlexMultiplier,
&p.FastMultiplier, &p.FlexMultiplier, &p.MaxReasoningEffortMultiplier,
&p.ImageInputPrice, &p.ImageOutputPrice, &p.PerRequestPrice, &timePricingJSON, &p.CreatedAt, &p.UpdatedAt,
); err != nil {
return nil, nil, fmt.Errorf("scan model pricing: %w", err)
@@ -247,11 +247,11 @@ func createModelPricingExec(ctx context.Context, exec dbExec, pricing *service.C
platform = "anthropic"
}
err = exec.QueryRowContext(ctx,
`INSERT INTO channel_model_pricing (channel_id, platform, models, billing_mode, input_price, output_price, cache_write_price, cache_write_1h_price, cache_read_price, fast_multiplier, flex_multiplier, image_input_price, image_output_price, per_request_price, time_pricing)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) RETURNING id, created_at, updated_at`,
`INSERT INTO channel_model_pricing (channel_id, platform, models, billing_mode, input_price, output_price, cache_write_price, cache_write_1h_price, cache_read_price, fast_multiplier, flex_multiplier, max_reasoning_effort_multiplier, image_input_price, image_output_price, per_request_price, time_pricing)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16) RETURNING id, created_at, updated_at`,
pricing.ChannelID, platform, modelsJSON, billingMode,
pricing.InputPrice, pricing.OutputPrice, pricing.CacheWritePrice, pricing.CacheWrite1hPrice, pricing.CacheReadPrice,
pricing.FastMultiplier, pricing.FlexMultiplier, pricing.ImageInputPrice, pricing.ImageOutputPrice,
pricing.FastMultiplier, pricing.FlexMultiplier, pricing.MaxReasoningEffortMultiplier, pricing.ImageInputPrice, pricing.ImageOutputPrice,
pricing.PerRequestPrice, timePricingJSON,
).Scan(&pricing.ID, &pricing.CreatedAt, &pricing.UpdatedAt)
if err != nil {
@@ -16,7 +16,7 @@ import (
var channelModelPricingTimePricingColumns = []string{
"id", "channel_id", "platform", "models", "billing_mode", "input_price", "output_price",
"cache_write_price", "cache_write_1h_price", "cache_read_price", "fast_multiplier", "flex_multiplier", "image_input_price", "image_output_price",
"cache_write_price", "cache_write_1h_price", "cache_read_price", "fast_multiplier", "flex_multiplier", "max_reasoning_effort_multiplier", "image_input_price", "image_output_price",
"per_request_price", "time_pricing", "created_at", "updated_at",
}
@@ -33,7 +33,7 @@ func newChannelModelPricingTimePricingRepo(t *testing.T) (*channelRepository, sq
func modelPricingTimePricingRow(timePricing any) *sqlmock.Rows {
return sqlmock.NewRows(channelModelPricingTimePricingColumns).AddRow(
int64(11), int64(7), "openai", `["gpt-5"]`, service.BillingModeToken,
nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, timePricing,
nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, timePricing,
time.Date(2026, 8, 17, 0, 0, 0, 0, time.UTC), time.Date(2026, 8, 17, 1, 0, 0, 0, time.UTC),
)
}
@@ -105,10 +105,10 @@ func TestChannelModelPricingTimePricingCreateAndUpdateRoundTrip(t *testing.T) {
t.Run("create writes JSON", func(t *testing.T) {
repo, mock := newChannelModelPricingTimePricingRepo(t)
mock.ExpectQuery(regexp.QuoteMeta("INSERT INTO channel_model_pricing (channel_id, platform, models, billing_mode, input_price, output_price, cache_write_price, cache_write_1h_price, cache_read_price, fast_multiplier, flex_multiplier, image_input_price, image_output_price, per_request_price, time_pricing)")).
mock.ExpectQuery(regexp.QuoteMeta("INSERT INTO channel_model_pricing (channel_id, platform, models, billing_mode, input_price, output_price, cache_write_price, cache_write_1h_price, cache_read_price, fast_multiplier, flex_multiplier, max_reasoning_effort_multiplier, image_input_price, image_output_price, per_request_price, time_pricing)")).
WithArgs(
int64(7), "openai", []byte(`["gpt-5"]`), service.BillingModeToken,
nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, channelModelPricingTimePricingJSON,
nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, channelModelPricingTimePricingJSON,
).
WillReturnRows(sqlmock.NewRows([]string{"id", "created_at", "updated_at"}).AddRow(int64(11), time.Time{}, time.Time{}))
@@ -118,10 +118,10 @@ func TestChannelModelPricingTimePricingCreateAndUpdateRoundTrip(t *testing.T) {
t.Run("update writes JSON and entry ID", func(t *testing.T) {
repo, mock := newChannelModelPricingTimePricingRepo(t)
mock.ExpectExec(`(?s)UPDATE channel_model_pricing.*per_request_price = \$12, time_pricing = \$13, platform = \$14.*WHERE id = \$15`).
mock.ExpectExec(`(?s)UPDATE channel_model_pricing.*per_request_price = \$13, time_pricing = \$14, platform = \$15.*WHERE id = \$16`).
WithArgs(
[]byte(`["gpt-5"]`), service.BillingModeToken,
nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, channelModelPricingTimePricingJSON, "openai", int64(11),
nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, channelModelPricingTimePricingJSON, "openai", int64(11),
).
WillReturnResult(sqlmock.NewResult(0, 1))
@@ -153,10 +153,10 @@ func TestChannelModelPricingTimePricingCreateAndUpdateWriteNullWhenDisabled(t *t
t.Run("create writes SQL NULL", func(t *testing.T) {
repo, mock := newChannelModelPricingTimePricingRepo(t)
mock.ExpectQuery(regexp.QuoteMeta("INSERT INTO channel_model_pricing (channel_id, platform, models, billing_mode, input_price, output_price, cache_write_price, cache_write_1h_price, cache_read_price, fast_multiplier, flex_multiplier, image_input_price, image_output_price, per_request_price, time_pricing)")).
mock.ExpectQuery(regexp.QuoteMeta("INSERT INTO channel_model_pricing (channel_id, platform, models, billing_mode, input_price, output_price, cache_write_price, cache_write_1h_price, cache_read_price, fast_multiplier, flex_multiplier, max_reasoning_effort_multiplier, image_input_price, image_output_price, per_request_price, time_pricing)")).
WithArgs(
int64(7), "openai", []byte(`["gpt-5"]`), service.BillingModeToken,
nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil,
nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil,
).
WillReturnRows(sqlmock.NewRows([]string{"id", "created_at", "updated_at"}).AddRow(int64(11), time.Time{}, time.Time{}))
@@ -166,10 +166,10 @@ func TestChannelModelPricingTimePricingCreateAndUpdateWriteNullWhenDisabled(t *t
t.Run("update writes SQL NULL", func(t *testing.T) {
repo, mock := newChannelModelPricingTimePricingRepo(t)
mock.ExpectExec(`(?s)UPDATE channel_model_pricing.*per_request_price = \$12, time_pricing = \$13, platform = \$14.*WHERE id = \$15`).
mock.ExpectExec(`(?s)UPDATE channel_model_pricing.*per_request_price = \$13, time_pricing = \$14, platform = \$15.*WHERE id = \$16`).
WithArgs(
[]byte(`["gpt-5"]`), service.BillingModeToken,
nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, "openai", int64(11),
nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, "openai", int64(11),
).
WillReturnResult(sqlmock.NewResult(0, 1))
+85 -9
View File
@@ -29,6 +29,39 @@ type groupRepository struct {
sql sqlExecutor
}
// lockLiveGroups makes account-group inserts participate in the same row-lock
// protocol as guarded group deletion. FOR SHARE conflicts with the deleter's
// FOR UPDATE lock, and READ COMMITTED rechecks deleted_at after any wait.
func lockLiveGroups(ctx context.Context, exec sqlExecutor, groupIDs []int64) error {
if len(groupIDs) == 0 {
return nil
}
unique := make(map[int64]struct{}, len(groupIDs))
for _, id := range groupIDs {
unique[id] = struct{}{}
}
rows, err := exec.QueryContext(ctx, `/* account_group_live_group_lock */
SELECT id FROM groups
WHERE id = ANY($1) AND deleted_at IS NULL
ORDER BY id
FOR SHARE`, pq.Array(groupIDs))
if err != nil {
return err
}
defer func() { _ = rows.Close() }()
locked := 0
for rows.Next() {
locked++
}
if err := rows.Err(); err != nil {
return err
}
if locked != len(unique) {
return service.ErrGroupNotFound
}
return nil
}
func NewGroupRepository(client *dbent.Client, sqlDB *sql.DB) service.GroupRepository {
return newGroupRepositoryWithSQL(client, sqlDB)
}
@@ -426,6 +459,15 @@ func (r *groupRepository) List(ctx context.Context, params pagination.Pagination
func (r *groupRepository) ListWithFilters(ctx context.Context, params pagination.PaginationParams, platform, status, search string, isExclusive *bool) ([]service.Group, *pagination.PaginationResult, error) {
q := r.client.Group.Query()
return r.listWithFiltersQuery(ctx, q, params, platform, status, search, isExclusive)
}
func (r *groupRepository) ListBindableWithFilters(ctx context.Context, params pagination.PaginationParams, platform, status, search string, isExclusive *bool) ([]service.Group, *pagination.PaginationResult, error) {
q := r.client.Group.Query().Where(group.PlatformNEQ(service.PlatformComposite))
return r.listWithFiltersQuery(ctx, q, params, platform, status, search, isExclusive)
}
func (r *groupRepository) listWithFiltersQuery(ctx context.Context, q *dbent.GroupQuery, params pagination.PaginationParams, platform, status, search string, isExclusive *bool) ([]service.Group, *pagination.PaginationResult, error) {
if platform != "" {
q = q.Where(group.PlatformEQ(platform))
@@ -807,12 +849,14 @@ func (r *groupRepository) DeleteAccountGroupsByGroupID(ctx context.Context, grou
}
func (r *groupRepository) DeleteCascade(ctx context.Context, id int64) ([]int64, error) {
g, err := r.client.Group.Query().Where(group.IDEQ(id)).Only(ctx)
if err != nil {
return nil, translatePersistenceError(err, service.ErrGroupNotFound, nil)
}
groupSvc := groupEntityToService(g)
return r.deleteCascade(ctx, id, false)
}
func (r *groupRepository) DeleteCascadeIfEmpty(ctx context.Context, id int64) ([]int64, error) {
return r.deleteCascade(ctx, id, true)
}
func (r *groupRepository) deleteCascade(ctx context.Context, id int64, requireEmpty bool) ([]int64, error) {
// 使用 ent 事务统一包裹:避免手工基于 *sql.Tx 构造 ent client 带来的驱动断言问题,
// 同时保证级联删除的原子性。
tx, err := r.client.Tx(ctx)
@@ -830,13 +874,14 @@ func (r *groupRepository) DeleteCascade(ctx context.Context, id int64) ([]int64,
// Lock the group row to avoid concurrent writes while we cascade.
// 这里使用 exec.QueryContext 手动扫描,确保同一事务内加锁并能区分"未找到"与其他错误。
rows, err := exec.QueryContext(ctx, "SELECT id FROM groups WHERE id = $1 AND deleted_at IS NULL FOR UPDATE", id)
rows, err := exec.QueryContext(ctx, "SELECT id, subscription_type FROM groups WHERE id = $1 AND deleted_at IS NULL FOR UPDATE", id)
if err != nil {
return nil, err
}
var lockedID int64
var subscriptionType string
if rows.Next() {
if err := rows.Scan(&lockedID); err != nil {
if err := rows.Scan(&lockedID, &subscriptionType); err != nil {
_ = rows.Close()
return nil, err
}
@@ -850,9 +895,22 @@ func (r *groupRepository) DeleteCascade(ctx context.Context, id int64) ([]int64,
if lockedID == 0 {
return nil, service.ErrGroupNotFound
}
if requireEmpty {
var hasAccount bool
if err := scanSingleRow(ctx, exec, `SELECT EXISTS (
SELECT 1 FROM account_groups ag
JOIN accounts a ON a.id = ag.account_id
WHERE ag.group_id = $1 AND a.deleted_at IS NULL
)`, []any{id}, &hasAccount); err != nil {
return nil, err
}
if hasAccount {
return nil, service.ErrGroupNotEmpty
}
}
var affectedUserIDs []int64
if groupSvc.IsSubscriptionType() {
if subscriptionType == service.SubscriptionTypeSubscription {
// 只查询未软删除的订阅,避免通知已取消订阅的用户
rows, err := exec.QueryContext(ctx, "SELECT user_id FROM user_subscriptions WHERE group_id = $1 AND deleted_at IS NULL", id)
if err != nil {
@@ -1020,8 +1078,21 @@ func (r *groupRepository) BindAccountsToGroup(ctx context.Context, groupID int64
return nil
}
tx, err := r.client.Tx(ctx)
if err != nil && !errors.Is(err, dbent.ErrTxStarted) {
return err
}
exec := sqlExecutor(r.client)
if tx != nil {
defer func() { _ = tx.Rollback() }()
exec = tx.Client()
}
if err := lockLiveGroups(ctx, exec, []int64{groupID}); err != nil {
return err
}
// 使用 INSERT ... ON CONFLICT DO NOTHING 忽略已存在的绑定
_, err := r.sql.ExecContext(
_, err = exec.ExecContext(
ctx,
`INSERT INTO account_groups (account_id, group_id, priority, created_at)
SELECT unnest($1::bigint[]), $2, 50, NOW()
@@ -1032,6 +1103,11 @@ func (r *groupRepository) BindAccountsToGroup(ctx context.Context, groupID int64
if err != nil {
return err
}
if tx != nil {
if err := tx.Commit(); err != nil {
return err
}
}
// 发送调度器事件
if err := enqueueSchedulerOutbox(ctx, r.sql, service.SchedulerOutboxEventGroupChanged, nil, &groupID, nil); err != nil {
@@ -8,10 +8,12 @@ import (
"errors"
"strings"
"testing"
"time"
dbent "github.com/Wei-Shaw/sub2api/ent"
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite"
)
@@ -245,6 +247,103 @@ func (s *GroupRepoSuite) TestDelete() {
s.Require().ErrorIs(err, service.ErrGroupNotFound)
}
func (s *GroupRepoSuite) TestDeleteCascadeIfEmptyRejectsGroupWithNonDeletedAccount() {
group := &service.Group{Name: "guarded-non-empty", Platform: service.PlatformAnthropic, RateMultiplier: 1, Status: service.StatusActive, SubscriptionType: service.SubscriptionTypeStandard}
s.Require().NoError(s.repo.Create(s.ctx, group))
var accountID int64
s.Require().NoError(scanSingleRow(s.ctx, s.tx,
"INSERT INTO accounts (name, platform, type) VALUES ($1, $2, $3) RETURNING id",
[]any{"guarded-account", service.PlatformAnthropic, service.AccountTypeOAuth}, &accountID))
_, err := s.tx.ExecContext(s.ctx, "INSERT INTO account_groups (account_id, group_id, priority, created_at) VALUES ($1, $2, 1, NOW())", accountID, group.ID)
s.Require().NoError(err)
_, err = s.repo.DeleteCascadeIfEmpty(s.ctx, group.ID)
s.Require().ErrorIs(err, service.ErrGroupNotEmpty)
_, err = s.repo.GetByID(s.ctx, group.ID)
s.Require().NoError(err)
var bindings int
s.Require().NoError(scanSingleRow(s.ctx, s.tx, "SELECT COUNT(*) FROM account_groups WHERE group_id = $1", []any{group.ID}, &bindings))
s.Require().Equal(1, bindings)
}
func (s *GroupRepoSuite) TestDeleteCascadeIfEmptyDeletesEmptyGroup() {
group := &service.Group{Name: "guarded-empty", Platform: service.PlatformAnthropic, RateMultiplier: 1, Status: service.StatusActive, SubscriptionType: service.SubscriptionTypeStandard}
s.Require().NoError(s.repo.Create(s.ctx, group))
_, err := s.repo.DeleteCascadeIfEmpty(s.ctx, group.ID)
s.Require().NoError(err)
_, err = s.repo.GetByID(s.ctx, group.ID)
s.Require().ErrorIs(err, service.ErrGroupNotFound)
}
func (s *GroupRepoSuite) TestDeleteCascadeIfEmptyIgnoresBindingsToSoftDeletedAccounts() {
group := &service.Group{Name: "guarded-soft-deleted-account", Platform: service.PlatformAnthropic, RateMultiplier: 1, Status: service.StatusActive, SubscriptionType: service.SubscriptionTypeStandard}
s.Require().NoError(s.repo.Create(s.ctx, group))
var accountID int64
s.Require().NoError(scanSingleRow(s.ctx, s.tx,
"INSERT INTO accounts (name, platform, type, deleted_at) VALUES ($1, $2, $3, NOW()) RETURNING id",
[]any{"guarded-deleted-account", service.PlatformAnthropic, service.AccountTypeOAuth}, &accountID))
_, err := s.tx.ExecContext(s.ctx, "INSERT INTO account_groups (account_id, group_id, priority, created_at) VALUES ($1, $2, 1, NOW())", accountID, group.ID)
s.Require().NoError(err)
_, err = s.repo.DeleteCascadeIfEmpty(s.ctx, group.ID)
s.Require().NoError(err)
_, err = s.repo.GetByID(s.ctx, group.ID)
s.Require().ErrorIs(err, service.ErrGroupNotFound)
}
func TestBindAccountsToGroupWaitingBehindGuardedDeleteCannotCommit(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
var groupID, accountID int64
require.NoError(t, scanSingleRow(ctx, integrationDB,
"INSERT INTO groups (name, platform, rate_multiplier, status, subscription_type) VALUES ($1, $2, 1, $3, $4) RETURNING id",
[]any{"guarded-delete-bind-race", service.PlatformAnthropic, service.StatusActive, service.SubscriptionTypeStandard}, &groupID))
require.NoError(t, scanSingleRow(ctx, integrationDB,
"INSERT INTO accounts (name, platform, type) VALUES ($1, $2, $3) RETURNING id",
[]any{"guarded-delete-bind-race-account", service.PlatformAnthropic, service.AccountTypeOAuth}, &accountID))
t.Cleanup(func() {
_, _ = integrationDB.ExecContext(context.Background(), "DELETE FROM account_groups WHERE group_id = $1 OR account_id = $2", groupID, accountID)
_, _ = integrationDB.ExecContext(context.Background(), "DELETE FROM accounts WHERE id = $1", accountID)
_, _ = integrationDB.ExecContext(context.Background(), "DELETE FROM groups WHERE id = $1", groupID)
})
deleteTx, err := integrationEntClient.Tx(ctx)
require.NoError(t, err)
defer func() { _ = deleteTx.Rollback() }()
rows, err := deleteTx.Client().QueryContext(ctx, "SELECT id FROM groups WHERE id = $1 FOR UPDATE", groupID)
require.NoError(t, err)
require.True(t, rows.Next())
require.NoError(t, rows.Close())
bindDone := make(chan error, 1)
go func() {
bindDone <- newGroupRepositoryWithSQL(integrationEntClient, integrationDB).
BindAccountsToGroup(ctx, groupID, []int64{accountID})
}()
require.Eventually(t, func() bool {
var waiting bool
err := scanSingleRow(ctx, integrationDB, `SELECT EXISTS (
SELECT 1 FROM pg_stat_activity
WHERE query LIKE '/* account_group_live_group_lock */%'
AND wait_event_type = 'Lock'
)`, nil, &waiting)
return err == nil && waiting
}, 5*time.Second, 10*time.Millisecond, "binder did not wait on the guarded deletion lock")
deleteRepo := newGroupRepositoryWithSQL(deleteTx.Client(), deleteTx)
_, err = deleteRepo.DeleteCascadeIfEmpty(ctx, groupID)
require.NoError(t, err)
require.NoError(t, deleteTx.Commit())
require.ErrorIs(t, <-bindDone, service.ErrGroupNotFound)
var bindings int
require.NoError(t, scanSingleRow(ctx, integrationDB, "SELECT COUNT(*) FROM account_groups WHERE group_id = $1", []any{groupID}, &bindings))
require.Zero(t, bindings)
}
// --- List / ListWithFilters ---
func (s *GroupRepoSuite) TestList() {
@@ -619,6 +718,22 @@ func (s *GroupRepoSuite) TestListActive() {
s.Require().True(found, "active1 group should be in results")
}
func (s *GroupRepoSuite) TestListBindableWithFiltersAppliesFilterBeforePagination() {
for _, g := range []*service.Group{
{Name: "bindable-a", Platform: service.PlatformAnthropic, RateMultiplier: 1, Status: service.StatusActive, SubscriptionType: service.SubscriptionTypeStandard},
{Name: "composite-hidden", Platform: service.PlatformComposite, RateMultiplier: 1, Status: service.StatusActive, SubscriptionType: service.SubscriptionTypeStandard},
{Name: "bindable-b", Platform: service.PlatformOpenAI, RateMultiplier: 1, Status: service.StatusActive, SubscriptionType: service.SubscriptionTypeStandard},
} {
s.Require().NoError(s.repo.Create(s.ctx, g))
}
groups, page, err := s.repo.ListBindableWithFilters(s.ctx, pagination.PaginationParams{Page: 2, PageSize: 1, SortBy: "name", SortOrder: "asc"}, "", service.StatusActive, "bindable-", nil)
s.Require().NoError(err)
s.Require().Equal(int64(2), page.Total)
s.Require().Len(groups, 1)
s.Require().NotEqual(service.PlatformComposite, groups[0].Platform)
}
func (s *GroupRepoSuite) TestListActiveByPlatform() {
s.Require().NoError(s.repo.Create(s.ctx, &service.Group{
Name: "g1",
+24 -9
View File
@@ -213,7 +213,7 @@ func (s *httpUpstreamService) Do(req *http.Request, proxyURL string, accountID i
}
// 执行请求
client := httpClientForUpstreamRequest(entry.client, req)
client := s.httpClientForUpstreamRequest(entry.client, req)
client = httpClientWithGrokAccessDeniedFallback(client)
resp, err := servertiming.Do(client, req)
if err != nil {
@@ -277,7 +277,7 @@ func (s *httpUpstreamService) DoWithTLS(req *http.Request, proxyURL string, acco
return nil, err
}
client := httpClientForUpstreamRequest(entry.client, req)
client := s.httpClientForUpstreamRequest(entry.client, req)
client = httpClientWithGrokAccessDeniedFallback(client)
resp, err := servertiming.Do(client, req)
if err != nil {
@@ -297,15 +297,27 @@ func (s *httpUpstreamService) DoWithTLS(req *http.Request, proxyURL string, acco
return resp, nil
}
func httpClientForUpstreamRequest(client *http.Client, req *http.Request) *http.Client {
if client == nil || req == nil || !service.HTTPUpstreamRedirectsDisabled(req.Context()) {
// httpClientForUpstreamRequest 按请求上下文的标记派生客户端:禁用重定向,或对重定向的每一跳做主机校验。
// 派生的克隆与缓存客户端共享 Transport;未打标记时原样返回。
func (s *httpUpstreamService) httpClientForUpstreamRequest(client *http.Client, req *http.Request) *http.Client {
if client == nil || req == nil {
return client
}
clone := *client
clone.CheckRedirect = func(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse
ctx := req.Context()
switch {
case service.HTTPUpstreamRedirectsDisabled(ctx):
clone := *client
clone.CheckRedirect = func(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse
}
return &clone
case service.HTTPUpstreamPublicHostsOnly(ctx) && client.CheckRedirect == nil:
clone := *client
clone.CheckRedirect = s.redirectChecker
return &clone
default:
return client
}
return &clone
}
// grokAccessDeniedFallbackTransport preserves the subscription CLI proxy as
@@ -587,8 +599,11 @@ func (s *httpUpstreamService) shouldValidateResolvedIP() bool {
return !s.cfg.Security.URLAllowlist.AllowPrivateHosts
}
// validateRequestHost 校验请求主机的解析结果不落在回环、私网、链路本地或未指定地址。
// 是否全局启用由 security.url_allowlist 决定;带 WithHTTPUpstreamPublicHostsOnly 标记的请求无论配置如何都校验。
func (s *httpUpstreamService) validateRequestHost(req *http.Request) error {
if !s.shouldValidateResolvedIP() {
publicHostsOnly := req != nil && service.HTTPUpstreamPublicHostsOnly(req.Context())
if !s.shouldValidateResolvedIP() && !publicHostsOnly {
return nil
}
if req == nil || req.URL == nil {
@@ -961,3 +961,63 @@ func hasEntry(svc *httpUpstreamService, target *upstreamClientEntry) bool {
}
return false
}
func TestHTTPUpstreamDoPublicHostsOnlyRejectsPrivateDestinationBeforeConnecting(t *testing.T) {
var calls atomic.Int64
target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
calls.Add(1)
w.WriteHeader(http.StatusOK)
}))
t.Cleanup(target.Close)
upstream := NewHTTPUpstream(nil)
plain, err := http.NewRequestWithContext(t.Context(), http.MethodGet, target.URL, nil)
require.NoError(t, err)
resp, err := upstream.Do(plain, "", 1, 1)
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
require.Equal(t, int64(1), calls.Load(), "loopback stays reachable for requests without the marker")
guarded, err := http.NewRequestWithContext(service.WithHTTPUpstreamPublicHostsOnly(t.Context()), http.MethodGet, target.URL, nil)
require.NoError(t, err)
resp, err = upstream.Do(guarded, "", 1, 1)
require.Error(t, err)
require.Nil(t, resp)
require.Contains(t, err.Error(), "not allowed")
require.Equal(t, int64(1), calls.Load(), "marked request must be rejected before any connection is made")
}
func TestHTTPUpstreamPublicHostsOnlyValidatesEveryRedirectHop(t *testing.T) {
upstream, ok := NewHTTPUpstream(nil).(*httpUpstreamService)
require.True(t, ok)
base := &http.Client{}
plain, err := http.NewRequestWithContext(t.Context(), http.MethodGet, "https://cdn.example.com/a.png", nil)
require.NoError(t, err)
require.Same(t, base, upstream.httpClientForUpstreamRequest(base, plain))
guarded, err := http.NewRequestWithContext(service.WithHTTPUpstreamPublicHostsOnly(t.Context()), http.MethodGet, "https://cdn.example.com/a.png", nil)
require.NoError(t, err)
client := upstream.httpClientForUpstreamRequest(base, guarded)
require.NotSame(t, base, client)
require.NotNil(t, client.CheckRedirect)
require.Nil(t, base.CheckRedirect, "the cached client must stay untouched")
via := []*http.Request{guarded}
for _, hop := range []string{
"http://127.0.0.1:8080/a.png",
"http://[::1]:8080/a.png",
"http://10.0.0.8/a.png",
"http://169.254.169.254/latest/meta-data/",
"http://0.0.0.0/a.png",
} {
hopReq, err := http.NewRequestWithContext(guarded.Context(), http.MethodGet, hop, nil)
require.NoError(t, err)
require.Error(t, client.CheckRedirect(hopReq, via), "hop=%s", hop)
}
publicHop, err := http.NewRequestWithContext(guarded.Context(), http.MethodGet, "http://93.184.216.34/a.png", nil)
require.NoError(t, err)
require.NoError(t, client.CheckRedirect(publicHop, via))
require.Error(t, client.CheckRedirect(publicHop, make([]*http.Request, 10)), "redirect chain stays capped")
}
@@ -216,6 +216,15 @@ func (c *sessionLimitCache) RegisterSession(ctx context.Context, accountID int64
return result == 1, nil
}
// UnregisterSession 立即移除会话注册(不等待空闲超时)
// 请求最终失败时调用:上游从未服务该会话,继续占槽会卡住 max_sessions 受限的账号
func (c *sessionLimitCache) UnregisterSession(ctx context.Context, accountID int64, sessionUUID string) error {
if sessionUUID == "" {
return nil
}
return c.rdb.ZRem(ctx, sessionLimitKey(accountID), sessionUUID).Err()
}
// RefreshSession 刷新会话时间戳
func (c *sessionLimitCache) RefreshSession(ctx context.Context, accountID int64, sessionUUID string, idleTimeout time.Duration) error {
if sessionUUID == "" {
@@ -0,0 +1,92 @@
//go:build integration
package repository
import (
"testing"
"time"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/stretchr/testify/suite"
)
type SessionLimitCacheSuite struct {
IntegrationRedisSuite
cache service.SessionLimitCache
}
func (s *SessionLimitCacheSuite) SetupTest() {
s.IntegrationRedisSuite.SetupTest()
s.cache = NewSessionLimitCache(s.rdb, 5)
}
// TestUnregisterSession_FreesSlotForNewSession 复现并验证修复场景:
// max_sessions=1 的账号上,会话 A 注册后若请求转发失败,会话槽会被 A 占住
// 整个空闲超时窗口(默认 5 分钟),导致窗口内新会话 B 全部被拒。
// UnregisterSession 必须立即释放槽位,让 B 无需等待空闲超时即可注册。
func (s *SessionLimitCacheSuite) TestUnregisterSession_FreesSlotForNewSession() {
const accountID = int64(101)
maxSessions := 1
idleTimeout := 5 * time.Minute
// 会话 A 注册成功(请求开始转发)
allowed, err := s.cache.RegisterSession(s.ctx, accountID, "session-A", maxSessions, idleTimeout)
s.RequireNoError(err, "RegisterSession A")
s.True(allowed, "首个会话应被允许")
// 转发失败后的新会话 B 在空闲超时窗口内被拒(修复前的行为)
allowed, err = s.cache.RegisterSession(s.ctx, accountID, "session-B", maxSessions, idleTimeout)
s.RequireNoError(err, "RegisterSession B before release")
s.False(allowed, "槽位被 A 占用时新会话应被拒绝")
// 请求最终失败:立即释放 A 的会话注册(不等待空闲超时)
s.RequireNoError(s.cache.UnregisterSession(s.ctx, accountID, "session-A"), "UnregisterSession A")
// A 释放后 B 立即可注册,无需等待空闲超时
allowed, err = s.cache.RegisterSession(s.ctx, accountID, "session-B", maxSessions, idleTimeout)
s.RequireNoError(err, "RegisterSession B after release")
s.True(allowed, "释放后新会话应立即可注册")
count, err := s.cache.GetActiveSessionCount(s.ctx, accountID)
s.RequireNoError(err, "GetActiveSessionCount")
s.Equal(1, count, "释放后活跃会话数应为 1(仅 B)")
}
// TestUnregisterSession_IdempotentAndMissing 验证释放操作幂等:
// 重复释放与释放不存在的会话均不应报错。
func (s *SessionLimitCacheSuite) TestUnregisterSession_IdempotentAndMissing() {
const accountID = int64(102)
s.RequireNoError(s.cache.UnregisterSession(s.ctx, accountID, "missing"), "释放不存在的会话")
s.RequireNoError(s.cache.UnregisterSession(s.ctx, accountID, "missing"), "重复释放")
s.RequireNoError(s.cache.UnregisterSession(s.ctx, accountID, ""), "空 sessionUUID 应为 no-op")
}
// TestUnregisterSession_OnlyTargetsSpecifiedSession 验证释放只影响目标会话,
// 不影响同账号上的其他活跃会话。
func (s *SessionLimitCacheSuite) TestUnregisterSession_OnlyTargetsSpecifiedSession() {
const accountID = int64(103)
maxSessions := 2
idleTimeout := 5 * time.Minute
for _, sid := range []string{"session-A", "session-B"} {
allowed, err := s.cache.RegisterSession(s.ctx, accountID, sid, maxSessions, idleTimeout)
s.RequireNoError(err, "RegisterSession "+sid)
s.True(allowed)
}
s.RequireNoError(s.cache.UnregisterSession(s.ctx, accountID, "session-A"), "UnregisterSession A")
active, err := s.cache.IsSessionActive(s.ctx, accountID, "session-A")
s.RequireNoError(err, "IsSessionActive A")
s.False(active, "A 应已释放")
active, err = s.cache.IsSessionActive(s.ctx, accountID, "session-B")
s.RequireNoError(err, "IsSessionActive B")
s.True(active, "B 不应受影响")
}
func TestSessionLimitCacheSuite(t *testing.T) {
suite.Run(t, new(SessionLimitCacheSuite))
}
+6 -2
View File
@@ -1476,7 +1476,7 @@ func newContractDeps(t *testing.T) *contractDeps {
settingRepo := newStubSettingRepo()
settingService := service.NewSettingService(settingRepo, cfg)
adminService := service.NewAdminService(userRepo, groupRepo, &accountRepo, proxyRepo, apiKeyRepo, redeemRepo, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
adminService := service.NewAdminService(nil, userRepo, groupRepo, &accountRepo, proxyRepo, apiKeyRepo, redeemRepo, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
authHandler := handler.NewAuthHandler(cfg, nil, userService, settingService, nil, redeemService, nil, nil)
apiKeyHandler := handler.NewAPIKeyHandler(apiKeyService)
usageHandler := handler.NewUsageHandler(usageService, apiKeyService, nil, nil)
@@ -1789,6 +1789,10 @@ func (stubGroupRepo) DeleteCascade(ctx context.Context, id int64) ([]int64, erro
return nil, errors.New("not implemented")
}
func (stubGroupRepo) DeleteCascadeIfEmpty(ctx context.Context, id int64) ([]int64, error) {
return nil, errors.New("not implemented")
}
func (stubGroupRepo) List(ctx context.Context, params pagination.PaginationParams) ([]service.Group, *pagination.PaginationResult, error) {
return nil, nil, errors.New("not implemented")
}
@@ -2941,7 +2945,7 @@ var (
_ service.UserRepository = (*stubUserRepo)(nil)
_ service.APIKeyRepository = (*stubApiKeyRepo)(nil)
_ service.APIKeyCache = (*stubApiKeyCache)(nil)
_ service.GroupRepository = (*stubGroupRepo)(nil)
_ service.AdminGroupRepository = (*stubGroupRepo)(nil)
_ service.UserSubscriptionRepository = (*stubUserSubscriptionRepo)(nil)
_ service.UsageLogRepository = (*stubUsageLogRepo)(nil)
_ service.SettingRepository = (*stubSettingRepo)(nil)
@@ -0,0 +1,94 @@
package routes
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/handler"
servermiddleware "github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
type pinnedModelsRoutesRepository struct {
service.AccountRepository
account service.Account
}
func (r *pinnedModelsRoutesRepository) ListByGroup(context.Context, int64) ([]service.Account, error) {
return []service.Account{r.account}, nil
}
type pinnedModelsRoutesUpstream struct {
service.HTTPUpstream
ordinaryCalls atomic.Int32
codexCalls atomic.Int32
}
func (u *pinnedModelsRoutesUpstream) Do(req *http.Request, _ string, _ int64, _ int) (*http.Response, error) {
body := `{"data":[{"id":"ordinary-upstream-model"}]}`
if req.URL.Query().Has("client_version") {
u.codexCalls.Add(1)
body = `{"models":[{"slug":"gpt-5.5"}]}`
} else {
u.ordinaryCalls.Add(1)
}
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(body))}, nil
}
func TestGatewayRoutesPinnedModelsDispatchesOrdinaryAndCodexRequests(t *testing.T) {
gin.SetMode(gin.TestMode)
repo := &pinnedModelsRoutesRepository{account: service.Account{
ID: 7, Platform: service.PlatformOpenAI, Type: service.AccountTypeAPIKey,
Status: service.StatusActive, Schedulable: true,
Credentials: map[string]any{"api_key": "test-models-key", "base_url": "https://models.example/v1"},
}}
upstream := &pinnedModelsRoutesUpstream{}
cfg := &config.Config{RunMode: config.RunModeSimple}
s := service.NewOpenAIGatewayService(repo, nil, nil, nil, nil, nil, nil, cfg,
nil, nil, nil, nil, nil, upstream, nil, nil, nil, nil, nil, nil, nil, nil)
h := &handler.Handlers{
Gateway: handler.NewGatewayHandler(nil, s, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, cfg, nil),
OpenAIGateway: handler.NewOpenAIGatewayHandler(s, nil, nil, nil, nil, nil, nil, nil, cfg),
AsyncImage: handler.NewAsyncImageHandler(nil, nil),
}
group := &service.Group{ID: 1, Platform: service.PlatformOpenAI,
CodexModelsManifestConfig: service.GroupCodexModelsManifestConfig{Enabled: true, AccountIDs: []int64{7}}}
router := gin.New()
RegisterGatewayRoutes(router, h, servermiddleware.APIKeyAuthMiddleware(func(c *gin.Context) {
c.Set(string(servermiddleware.ContextKeyAPIKey), &service.APIKey{GroupID: &group.ID, Group: group})
c.Next()
}), nil, nil, nil, nil, nil, cfg)
for _, path := range []string{"/v1/models", "/models", "/v1/models?client_version=", "/models?client_version="} {
w := httptest.NewRecorder()
router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, path, nil))
require.Equal(t, http.StatusOK, w.Code, "%s: %s", path, w.Body.String())
var response struct {
Object string `json:"object"`
Data []struct {
ID string `json:"id"`
} `json:"data"`
}
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response))
require.Equal(t, "list", response.Object)
require.Len(t, response.Data, 1)
require.Equal(t, "ordinary-upstream-model", response.Data[0].ID)
}
for _, path := range []string{"/v1/models?client_version=" + service.CodexCanonicalClientVersion(), "/models?client_version=" + service.CodexCanonicalClientVersion(), "/backend-api/codex/models"} {
w := httptest.NewRecorder()
router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, path, nil))
require.Equal(t, http.StatusOK, w.Code, "%s: %s", path, w.Body.String())
require.Contains(t, w.Body.String(), `"slug":"gpt-5.5"`)
require.NotContains(t, w.Body.String(), `"data"`)
}
require.EqualValues(t, 1, upstream.ordinaryCalls.Load())
require.EqualValues(t, 1, upstream.codexCalls.Load())
}
@@ -17,6 +17,7 @@ import (
// upstreamModel 是最终发往上游的模型 ID。
// totalCost 是本次请求的客户计费(倍率前),用于优先级 2。
// serviceTier 是最终参与用户计费的 OpenAI 服务层级,用于优先级 3。
// reasoningEffort 是最终转发等级;Fable 5.1 max 默认按 3 倍额度消耗。
func resolveAccountStatsCost(
ctx context.Context,
channelService *ChannelService,
@@ -28,7 +29,12 @@ func resolveAccountStatsCost(
requestCount int,
totalCost float64,
serviceTier string,
reasoningEfforts ...string,
) *float64 {
reasoningEffort := ""
if len(reasoningEfforts) > 0 {
reasoningEffort = reasoningEfforts[0]
}
if channelService == nil || upstreamModel == "" {
return nil
}
@@ -40,7 +46,7 @@ func resolveAccountStatsCost(
platform := channelService.GetGroupPlatform(ctx, groupID)
// 优先级 1:自定义规则(始终尝试)
if cost := tryCustomRules(channel, accountID, groupID, platform, upstreamModel, tokens, requestCount); cost != nil {
if cost := tryCustomRules(channel, accountID, groupID, platform, upstreamModel, tokens, requestCount, reasoningEffort); cost != nil {
return cost
}
@@ -55,7 +61,7 @@ func resolveAccountStatsCost(
// 优先级 3:模型定价文件(LiteLLM)默认价格
if billingService != nil {
return tryModelFilePricing(billingService, upstreamModel, tokens, serviceTier)
return tryModelFilePricing(billingService, upstreamModel, tokens, serviceTier, reasoningEffort)
}
return nil
@@ -65,13 +71,18 @@ func resolveAccountStatsCost(
// 与用户计费共用同一条定价管线,避免这里维护第二份"单价 × token 数"实现后,
// 每加一个定价特性都要手工镜像一次。channelPricing 为 nil,保持优先级 3 的
// 语义:只取模型定价文件,不引入渠道自定义定价。
func tryModelFilePricing(billingService *BillingService, model string, tokens UsageTokens, serviceTier string) *float64 {
func tryModelFilePricing(billingService *BillingService, model string, tokens UsageTokens, serviceTier string, reasoningEfforts ...string) *float64 {
reasoningEffort := ""
if len(reasoningEfforts) > 0 {
reasoningEffort = reasoningEfforts[0]
}
breakdown, err := billingService.CalculateCostWithServiceTier(
model, tokens, 1, normalizeBillingServiceTier(serviceTier),
)
if err != nil || breakdown == nil || breakdown.TotalCost <= 0 {
return nil
}
applyCostBreakdownMultiplier(breakdown, maxReasoningEffortBillingMultiplier(model, reasoningEffort, nil))
return &breakdown.TotalCost
}
@@ -79,7 +90,12 @@ func tryModelFilePricing(billingService *BillingService, model string, tokens Us
func tryCustomRules(
channel *Channel, accountID, groupID int64,
platform, model string, tokens UsageTokens, requestCount int,
reasoningEfforts ...string,
) *float64 {
reasoningEffort := ""
if len(reasoningEfforts) > 0 {
reasoningEffort = reasoningEfforts[0]
}
modelLower := strings.ToLower(model)
for _, rule := range channel.AccountStatsPricingRules {
if !matchAccountStatsRule(&rule, accountID, groupID) {
@@ -89,7 +105,11 @@ func tryCustomRules(
if pricing == nil {
continue // 规则匹配但模型不在规则定价中,继续下一条
}
return calculateStatsCost(pricing, tokens, requestCount)
cost := calculateStatsCost(pricing, tokens, requestCount)
if cost != nil {
*cost *= maxReasoningEffortBillingMultiplier(model, reasoningEffort, nil)
}
return cost
}
return nil
}
@@ -243,10 +263,14 @@ func applyAccountStatsCost(
requestCount = usageLog.ImageCount
}
serviceTier := ""
reasoningEffort := ""
if usageLog != nil && usageLog.ServiceTier != nil {
serviceTier = *usageLog.ServiceTier
}
if usageLog != nil && usageLog.ReasoningEffort != nil {
reasoningEffort = *usageLog.ReasoningEffort
}
usageLog.AccountStatsCost = resolveAccountStatsCost(
ctx, cs, bs, accountID, groupID, model, tokens, requestCount, totalCost, serviceTier,
ctx, cs, bs, accountID, groupID, model, tokens, requestCount, totalCost, serviceTier, reasoningEffort,
)
}
@@ -478,6 +478,18 @@ func TestTryModelFilePricing_Success(t *testing.T) {
require.InDelta(t, 0.2, *result, 1e-12)
}
func TestTryModelFilePricing_Fable51MaxEffortUsesTripleQuota(t *testing.T) {
bs := newTestBillingServiceWithPrices(map[string]*ModelPricing{
"claude-fable-5-1": {InputPricePerToken: 0.001},
})
tokens := UsageTokens{InputTokens: 100}
standard := tryModelFilePricing(bs, "claude-fable-5-1", tokens, "", "xhigh")
max := tryModelFilePricing(bs, "claude-fable-5-1", tokens, "", "max")
require.NotNil(t, standard)
require.NotNil(t, max)
require.InDelta(t, *standard*3, *max, 1e-12)
}
func TestTryModelFilePricing_AppliesLongContextPricing(t *testing.T) {
bs := newTestBillingServiceWithPrices(map[string]*ModelPricing{
"gpt-5.6-sol": {
@@ -151,6 +151,7 @@ type AccountTestService struct {
modelMetadataRegistry map[string]modelsDevProvider
modelMetadataRegistryAt time.Time
pluginManager *PluginManager
openaiGatewayService *OpenAIGatewayService
agentIdentityTaskMu sync.Mutex
agentIdentityWS agentIdentityWSConnectionInvalidator
// grokWSDialer is optional; realtime account tests use the default OpenAI-style
@@ -170,6 +171,30 @@ func (s *AccountTestService) SetPluginManager(pluginManager *PluginManager) {
}
}
func (s *AccountTestService) SetOpenAIGatewayService(gateway *OpenAIGatewayService) {
if s != nil {
s.openaiGatewayService = gateway
}
}
// FetchOpenAIAccountModels uses the shared cached discovery path for the test picker.
func (s *AccountTestService) FetchOpenAIAccountModels(ctx context.Context, account *Account) ([]openai.Model, error) {
if s == nil || s.openaiGatewayService == nil {
return nil, errors.New("OpenAI model discovery service is unavailable")
}
response, err := s.openaiGatewayService.FetchOpenAIModelsList(ctx, account)
if err != nil {
return nil, err
}
var payload struct {
Data []openai.Model `json:"data"`
}
if err := json.Unmarshal(response.Body, &payload); err != nil {
return nil, fmt.Errorf("decode OpenAI account models: %w", err)
}
return payload.Data, nil
}
// NewAccountTestService creates a new AccountTestService
func NewAccountTestService(
accountRepo AccountRepository,
+50
View File
@@ -14,6 +14,7 @@ import (
"strings"
"time"
"github.com/Wei-Shaw/sub2api/internal/config"
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
@@ -21,6 +22,11 @@ import (
// Account management implementations
func (s *adminServiceImpl) ListAccounts(ctx context.Context, page, pageSize int, platform, accountType, status, search string, groupID int64, privacyMode string, sortBy, sortOrder string) ([]Account, int64, error) {
if groupID > 0 {
if err := s.ValidateAccountGroupBindings(ctx, []int64{groupID}); err != nil {
return nil, 0, err
}
}
params := pagination.PaginationParams{Page: page, PageSize: pageSize, SortBy: sortBy, SortOrder: sortOrder}
accounts, result, err := s.accountRepo.ListWithFilters(ctx, params, platform, accountType, status, search, groupID, privacyMode)
if err != nil {
@@ -284,6 +290,9 @@ func (s *adminServiceImpl) DuplicateAccount(ctx context.Context, id int64, actor
}
autoPauseOnExpired := source.AutoPauseOnExpired
groups, groupIDs := duplicateAccountGroups(source)
if err := s.ValidateAccountGroupBindings(ctx, groupIDs); err != nil {
return nil, err
}
proxyID := source.ProxyID
if source.ProxyFallbackOriginID != nil {
// Proxy fallback is transient runtime state; duplicate the configured origin.
@@ -512,6 +521,9 @@ func (s *adminServiceImpl) CreateAccount(ctx context.Context, input *CreateAccou
if err != nil {
return nil, err
}
if err := s.ValidateAccountGroupBindings(ctx, groupIDs); err != nil {
return nil, err
}
if err := s.accountRepo.Create(ctx, account); err != nil {
return nil, err
}
@@ -804,6 +816,9 @@ func (s *adminServiceImpl) UpdateAccount(ctx context.Context, id int64, input *U
if err := s.validateGroupIDsExist(ctx, *input.GroupIDs); err != nil {
return nil, err
}
if err := s.ValidateAccountGroupBindings(ctx, *input.GroupIDs); err != nil {
return nil, err
}
// 检查混合渠道风险(除非用户已确认)
if !input.SkipMixedChannelCheck {
@@ -935,6 +950,9 @@ func (s *adminServiceImpl) BulkUpdateAccounts(ctx context.Context, input *BulkUp
if err := s.validateGroupIDsExist(ctx, *input.GroupIDs); err != nil {
return nil, err
}
if err := s.ValidateAccountGroupBindings(ctx, *input.GroupIDs); err != nil {
return nil, err
}
}
openAISettings, err := normalizeBulkOpenAISettings(input)
if err != nil {
@@ -1352,6 +1370,9 @@ func (s *adminServiceImpl) CreateShadow(ctx context.Context, parentID int64, opt
}
}
}
if err := s.ValidateAccountGroupBindings(ctx, groupIDs); err != nil {
return nil, err
}
// 4. 构造影子账号(安全不变量:Credentials 恒不含 auth token,仅含 model_mapping)。
// name 为空时默认 "<母账号名> (Spark)"——否则空 name 会在 ent(name NotEmpty)处变成裸 500
@@ -1524,6 +1545,35 @@ func (s *adminServiceImpl) validateGroupIDsExist(ctx context.Context, groupIDs [
return nil
}
// ValidateAccountGroupBindings is the shared fail-closed policy boundary for
// every account path that accepts explicit group bindings.
func (s *adminServiceImpl) ValidateAccountGroupBindings(ctx context.Context, groupIDs []int64) error {
if len(groupIDs) == 0 || s.cfg == nil || s.cfg.RunMode != config.RunModeSimple {
return nil
}
if s.groupRepo == nil {
return errors.New("group repository not configured")
}
seen := make(map[int64]struct{}, len(groupIDs))
for _, groupID := range groupIDs {
if groupID <= 0 {
return fmt.Errorf("get group: %w", ErrGroupNotFound)
}
if _, ok := seen[groupID]; ok {
continue
}
seen[groupID] = struct{}{}
group, err := s.groupRepo.GetByIDLite(ctx, groupID)
if err != nil {
return fmt.Errorf("get group: %w", err)
}
if !IsGroupBindableInSimpleMode(group) {
return infraerrors.BadRequest("SIMPLE_MODE_GROUP_NOT_BINDABLE", "composite groups cannot be bound in simple mode")
}
}
return nil
}
// CheckMixedChannelRisk checks whether target groups contain mixed channels for the current account platform.
func (s *adminServiceImpl) CheckMixedChannelRisk(ctx context.Context, currentAccountID int64, currentAccountPlatform string, groupIDs []int64) error {
return s.checkMixedChannelRisk(ctx, currentAccountID, currentAccountPlatform, groupIDs)
+127 -3
View File
@@ -9,6 +9,7 @@ import (
"time"
dbent "github.com/Wei-Shaw/sub2api/ent"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/pkg/antigravity"
"github.com/Wei-Shaw/sub2api/internal/pkg/claude"
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
@@ -20,9 +21,26 @@ import (
)
// Group management implementations
func (s *adminServiceImpl) ValidateSimpleModeGroupOperation(operation AdminGroupOperation) error {
return ValidateSimpleModeGroupOperation(s.cfg, operation)
}
func (s *adminServiceImpl) ListGroups(ctx context.Context, page, pageSize int, platform, status, search string, isExclusive *bool, sortBy, sortOrder string) ([]Group, int64, error) {
params := pagination.PaginationParams{Page: page, PageSize: pageSize, SortBy: sortBy, SortOrder: sortOrder}
groups, result, err := s.groupRepo.ListWithFilters(ctx, params, platform, status, search, isExclusive)
var groups []Group
var result *pagination.PaginationResult
var err error
if s.cfg != nil && s.cfg.RunMode == config.RunModeSimple {
repo, ok := s.groupRepo.(interface {
ListBindableWithFilters(context.Context, pagination.PaginationParams, string, string, string, *bool) ([]Group, *pagination.PaginationResult, error)
})
if !ok {
return nil, 0, errors.New("group repository does not support simple-mode filtering")
}
groups, result, err = repo.ListBindableWithFilters(ctx, params, platform, status, search, isExclusive)
} else {
groups, result, err = s.groupRepo.ListWithFilters(ctx, params, platform, status, search, isExclusive)
}
if err != nil {
return nil, 0, err
}
@@ -45,7 +63,21 @@ func (s *adminServiceImpl) GetAllGroupsIncludingInactive(ctx context.Context) ([
}
func (s *adminServiceImpl) GetGroup(ctx context.Context, id int64) (*Group, error) {
return s.groupRepo.GetByID(ctx, id)
group, err := s.groupRepo.GetByID(ctx, id)
if err != nil {
return nil, err
}
if err := s.validateSimpleModeGroupAccess(group); err != nil {
return nil, err
}
return group, nil
}
func (s *adminServiceImpl) validateSimpleModeGroupAccess(group *Group) error {
if s.cfg != nil && s.cfg.RunMode == config.RunModeSimple && !IsGroupBindableInSimpleMode(group) {
return infraerrors.BadRequest("SIMPLE_MODE_GROUP_NOT_BINDABLE", "composite groups are not supported in simple mode")
}
return nil
}
func (s *adminServiceImpl) GetGroupModelsListCandidates(ctx context.Context, id int64, platform string) ([]string, error) {
@@ -101,6 +133,9 @@ func (s *adminServiceImpl) GetGroupModelsListCandidates(ctx context.Context, id
}
func (s *adminServiceImpl) ListCompositeRoutes(ctx context.Context, groupID int64) ([]CompositeModelRoute, error) {
if err := s.ValidateSimpleModeGroupOperation(AdminGroupOperationCompositeRoute); err != nil {
return nil, err
}
if err := s.requireCompositeGroup(ctx, groupID); err != nil {
return nil, err
}
@@ -111,6 +146,9 @@ func (s *adminServiceImpl) ListCompositeRoutes(ctx context.Context, groupID int6
}
func (s *adminServiceImpl) CreateCompositeRoute(ctx context.Context, groupID int64, input CompositeRouteInput) (*CompositeModelRoute, error) {
if err := s.ValidateSimpleModeGroupOperation(AdminGroupOperationCompositeRoute); err != nil {
return nil, err
}
if err := s.requireCompositeGroup(ctx, groupID); err != nil {
return nil, err
}
@@ -128,6 +166,9 @@ func (s *adminServiceImpl) CreateCompositeRoute(ctx context.Context, groupID int
}
func (s *adminServiceImpl) UpdateCompositeRoute(ctx context.Context, groupID, routeID int64, input CompositeRouteInput) (*CompositeModelRoute, error) {
if err := s.ValidateSimpleModeGroupOperation(AdminGroupOperationCompositeRoute); err != nil {
return nil, err
}
if err := s.requireCompositeGroup(ctx, groupID); err != nil {
return nil, err
}
@@ -151,6 +192,9 @@ func (s *adminServiceImpl) UpdateCompositeRoute(ctx context.Context, groupID, ro
}
func (s *adminServiceImpl) DeleteCompositeRoute(ctx context.Context, groupID, routeID int64) error {
if err := s.ValidateSimpleModeGroupOperation(AdminGroupOperationCompositeRoute); err != nil {
return err
}
if err := s.requireCompositeGroup(ctx, groupID); err != nil {
return err
}
@@ -166,6 +210,9 @@ func (s *adminServiceImpl) DeleteCompositeRoute(ctx context.Context, groupID, ro
}
func (s *adminServiceImpl) PreviewCompositeRoute(ctx context.Context, groupID int64, input CompositeRoutePreviewRequest) (*CompositeRouteDecision, error) {
if err := s.ValidateSimpleModeGroupOperation(AdminGroupOperationCompositeRoute); err != nil {
return nil, err
}
if err := s.requireCompositeGroup(ctx, groupID); err != nil {
return nil, err
}
@@ -308,7 +355,30 @@ func sanitizeGroupOpenAIFast(group *Group) {
}
}
func normalizeCreateGroupInputForSimpleMode(input *CreateGroupInput) {
if input == nil {
return
}
*input = CreateGroupInput{
Name: input.Name, Description: input.Description, Platform: input.Platform,
RateMultiplier: 1, SubscriptionType: SubscriptionTypeStandard,
}
}
func normalizeUpdateGroupInputForSimpleMode(input *UpdateGroupInput) {
if input == nil {
return
}
*input = UpdateGroupInput{Name: input.Name, Description: input.Description}
}
func (s *adminServiceImpl) CreateGroup(ctx context.Context, input *CreateGroupInput) (*Group, error) {
if s.cfg != nil && s.cfg.RunMode == config.RunModeSimple && NormalizeGroupPlatform(input.Platform) == PlatformComposite {
return nil, infraerrors.BadRequest("SIMPLE_MODE_GROUP_NOT_BINDABLE", "composite groups are not supported in simple mode")
}
if s.cfg != nil && s.cfg.RunMode == config.RunModeSimple {
normalizeCreateGroupInputForSimpleMode(input)
}
if input.RateMultiplier <= 0 {
return nil, errors.New("rate_multiplier must be > 0")
}
@@ -675,6 +745,15 @@ func (s *adminServiceImpl) UpdateGroup(ctx context.Context, id int64, input *Upd
if err != nil {
return nil, err
}
if err := s.validateSimpleModeGroupAccess(group); err != nil {
return nil, err
}
if s.cfg != nil && s.cfg.RunMode == config.RunModeSimple && input.Platform == PlatformComposite {
return nil, infraerrors.BadRequest("SIMPLE_MODE_GROUP_NOT_BINDABLE", "composite groups are not supported in simple mode")
}
if s.cfg != nil && s.cfg.RunMode == config.RunModeSimple {
normalizeUpdateGroupInputForSimpleMode(input)
}
// 渠道缓存里存了 groupID → platform 的映射,改了平台要让它失效(见函数末尾)
previousPlatform := group.Platform
@@ -1080,6 +1159,27 @@ func normalizeGroupModelPricing(platform string, pricing []ChannelModelPricing)
}
func (s *adminServiceImpl) DeleteGroup(ctx context.Context, id int64) error {
return s.deleteGroup(ctx, id, false)
}
func (s *adminServiceImpl) DeleteGroupIfEmpty(ctx context.Context, id int64) error {
return s.deleteGroup(ctx, id, true)
}
func (s *adminServiceImpl) deleteGroup(ctx context.Context, id int64, requireEmpty bool) error {
if s.cfg != nil && s.cfg.RunMode == config.RunModeSimple {
group, err := s.groupRepo.GetByIDLite(ctx, id)
if err != nil {
return err
}
if err := s.validateSimpleModeGroupAccess(group); err != nil {
return err
}
}
if requireEmpty && s.emptyGroupDeleteRepo == nil {
return fmt.Errorf("guarded group deletion is unavailable")
}
var groupKeys []string
if s.authCacheInvalidator != nil {
keys, err := s.apiKeyRepo.ListKeysByGroupID(ctx, id)
@@ -1088,7 +1188,13 @@ func (s *adminServiceImpl) DeleteGroup(ctx context.Context, id int64) error {
}
}
affectedUserIDs, err := s.groupRepo.DeleteCascade(ctx, id)
var affectedUserIDs []int64
var err error
if requireEmpty {
affectedUserIDs, err = s.emptyGroupDeleteRepo.DeleteCascadeIfEmpty(ctx, id)
} else {
affectedUserIDs, err = s.groupRepo.DeleteCascade(ctx, id)
}
if err != nil {
return err
}
@@ -1126,6 +1232,9 @@ func (s *adminServiceImpl) GetGroupAPIKeys(ctx context.Context, groupID int64, p
}
func (s *adminServiceImpl) GetGroupRateMultipliers(ctx context.Context, groupID int64) ([]UserGroupRateEntry, error) {
if err := s.ValidateSimpleModeGroupOperation(AdminGroupOperationMultiplier); err != nil {
return nil, err
}
if s.userGroupRateRepo == nil {
return nil, nil
}
@@ -1133,6 +1242,9 @@ func (s *adminServiceImpl) GetGroupRateMultipliers(ctx context.Context, groupID
}
func (s *adminServiceImpl) ClearGroupRateMultipliers(ctx context.Context, groupID int64) error {
if err := s.ValidateSimpleModeGroupOperation(AdminGroupOperationMultiplier); err != nil {
return err
}
if s.userGroupRateRepo == nil {
return nil
}
@@ -1140,6 +1252,9 @@ func (s *adminServiceImpl) ClearGroupRateMultipliers(ctx context.Context, groupI
}
func (s *adminServiceImpl) BatchSetGroupRateMultipliers(ctx context.Context, groupID int64, entries []GroupRateMultiplierInput) error {
if err := s.ValidateSimpleModeGroupOperation(AdminGroupOperationMultiplier); err != nil {
return err
}
if s.userGroupRateRepo == nil {
return nil
}
@@ -1152,6 +1267,9 @@ func (s *adminServiceImpl) BatchSetGroupRateMultipliers(ctx context.Context, gro
}
func (s *adminServiceImpl) ClearGroupRPMOverrides(ctx context.Context, groupID int64) error {
if err := s.ValidateSimpleModeGroupOperation(AdminGroupOperationRPMOverride); err != nil {
return err
}
if s.userGroupRateRepo == nil {
return nil
}
@@ -1166,6 +1284,9 @@ func (s *adminServiceImpl) ClearGroupRPMOverrides(ctx context.Context, groupID i
}
func (s *adminServiceImpl) BatchSetGroupRPMOverrides(ctx context.Context, groupID int64, entries []GroupRPMOverrideInput) error {
if err := s.ValidateSimpleModeGroupOperation(AdminGroupOperationRPMOverride); err != nil {
return err
}
if s.userGroupRateRepo == nil {
return nil
}
@@ -1185,6 +1306,9 @@ func (s *adminServiceImpl) BatchSetGroupRPMOverrides(ctx context.Context, groupI
}
func (s *adminServiceImpl) UpdateGroupSortOrders(ctx context.Context, updates []GroupSortOrderUpdate) error {
if err := s.ValidateSimpleModeGroupOperation(AdminGroupOperationSort); err != nil {
return err
}
return s.groupRepo.UpdateSortOrders(ctx, updates)
}
@@ -166,6 +166,9 @@ func cloneGroupForDuplicate(source *Group, operationID string) *Group {
// RecoverDuplicateGroup performs a read-only lookup for a copy that was already
// committed for the same actor, source group, and idempotency key.
func (s *adminServiceImpl) RecoverDuplicateGroup(ctx context.Context, id int64, actorScope, operationKey string) (*Group, error) {
if err := s.ValidateSimpleModeGroupOperation(AdminGroupOperationDuplicate); err != nil {
return nil, err
}
operationID := duplicateGroupOperationID(id, actorScope, operationKey)
if operationID == "" {
return nil, nil
@@ -191,6 +194,9 @@ func (s *adminServiceImpl) RecoverDuplicateGroup(ctx context.Context, id int64,
// account priorities. The repository commits the group, bindings, and outbox
// event atomically so a failed binding never leaves an orphan group.
func (s *adminServiceImpl) DuplicateGroup(ctx context.Context, id int64, actorScope, operationKey string) (*Group, error) {
if err := s.ValidateSimpleModeGroupOperation(AdminGroupOperationDuplicate); err != nil {
return nil, err
}
existing, err := s.RecoverDuplicateGroup(ctx, id, actorScope, operationKey)
if err != nil {
return nil, err
+28 -2
View File
@@ -6,6 +6,7 @@ import (
"time"
dbent "github.com/Wei-Shaw/sub2api/ent"
"github.com/Wei-Shaw/sub2api/internal/config"
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
)
@@ -48,6 +49,7 @@ type AdminService interface {
RecoverDuplicateGroup(ctx context.Context, id int64, actorScope, operationKey string) (*Group, error)
UpdateGroup(ctx context.Context, id int64, input *UpdateGroupInput) (*Group, error)
DeleteGroup(ctx context.Context, id int64) error
DeleteGroupIfEmpty(ctx context.Context, id int64) error
ListCompositeRoutes(ctx context.Context, groupID int64) ([]CompositeModelRoute, error)
CreateCompositeRoute(ctx context.Context, groupID int64, input CompositeRouteInput) (*CompositeModelRoute, error)
UpdateCompositeRoute(ctx context.Context, groupID, routeID int64, input CompositeRouteInput) (*CompositeModelRoute, error)
@@ -79,6 +81,7 @@ type AdminService interface {
GetAccount(ctx context.Context, id int64) (*Account, error)
GetAccountsByIDs(ctx context.Context, ids []int64) ([]*Account, error)
CreateAccount(ctx context.Context, input *CreateAccountInput) (*Account, error)
ValidateAccountGroupBindings(ctx context.Context, groupIDs []int64) error
// DuplicateAccount creates an independent account from an existing account's configuration.
// First-class runtime columns are intentionally reset by the normal account creation path.
DuplicateAccount(ctx context.Context, id int64, actorScope, operationKey string) (*Account, error)
@@ -137,6 +140,24 @@ type AdminService interface {
ResetAccountQuota(ctx context.Context, id int64) error
}
type AdminGroupOperation string
const (
AdminGroupOperationBasic AdminGroupOperation = "basic"
AdminGroupOperationDuplicate AdminGroupOperation = "duplicate"
AdminGroupOperationCompositeRoute AdminGroupOperation = "composite_route"
AdminGroupOperationMultiplier AdminGroupOperation = "multiplier"
AdminGroupOperationRPMOverride AdminGroupOperation = "rpm_override"
AdminGroupOperationSort AdminGroupOperation = "sort"
)
func ValidateSimpleModeGroupOperation(cfg *config.Config, operation AdminGroupOperation) error {
if cfg != nil && cfg.RunMode == config.RunModeSimple && operation != AdminGroupOperationBasic {
return infraerrors.New(http.StatusForbidden, "SIMPLE_MODE_OPERATION_UNSUPPORTED", "This operation is not supported in simple mode")
}
return nil
}
// CreateUserInput represents input for creating a new user via admin operations.
type CreateUserInput struct {
Email string
@@ -277,11 +298,11 @@ type CreateGroupInput struct {
CodexModelsManifestConfig GroupCodexModelsManifestConfig
// RPMLimit 分组 RPM 上限(0 = 不限制)
RPMLimit int
// MaxReasoningEffort OpenAI/Codex 请求的推理强度上限,空字符串表示不限制。
// MaxReasoningEffort Anthropic/OpenAI 请求的推理强度上限,空字符串表示不限制。
MaxReasoningEffort string
// MaxReasoningEffortOverLimit 超过上限时的访问控制:downgrade(默认)或 deny。
MaxReasoningEffortOverLimit string
// ReasoningEffortMappings OpenAI/Codex 推理强度精确映射。
// ReasoningEffortMappings Anthropic/OpenAI 推理强度映射,可按模型精确名、前缀或后缀限定。
ReasoningEffortMappings []ReasoningEffortMapping
// 分组利润控制(五个 token 平台分组可启用;margin/buffer 为小数,nil 按 0 处理)
ProfitControlEnabled bool
@@ -658,9 +679,11 @@ var ErrRPMStatusUnavailable = infraerrors.New(http.StatusNotImplemented, "RPM_ST
// adminServiceImpl implements AdminService
type adminServiceImpl struct {
cfg *config.Config
userRepo UserRepository
groupRepo GroupRepository
groupDuplicateRepo GroupDuplicateRepository
emptyGroupDeleteRepo EmptyGroupDeleteRepository
accountRepo AccountRepository
accountDuplicateRepo AccountDuplicateRepository
accountBillingRepo AccountBillingSettingsRepository
@@ -702,6 +725,7 @@ type userGroupRateBatchReader interface {
// NewAdminService creates a new AdminService
func NewAdminService(
cfg *config.Config,
userRepo UserRepository,
groupRepo AdminGroupRepository,
accountRepo AdminAccountRepository,
@@ -726,9 +750,11 @@ func NewAdminService(
channelCacheInvalidator ChannelCacheInvalidator,
) AdminService {
return &adminServiceImpl{
cfg: cfg,
userRepo: userRepo,
groupRepo: groupRepo,
groupDuplicateRepo: groupRepo,
emptyGroupDeleteRepo: groupRepo,
accountRepo: accountRepo,
accountDuplicateRepo: accountRepo,
accountBillingRepo: accountRepo,
@@ -247,6 +247,7 @@ type groupRepoStub struct {
affectedUserIDs []int64
deleteErr error
deleteCalls []int64
guardedCalls []int64
}
func (s *groupRepoStub) Create(ctx context.Context, group *Group) error {
@@ -274,6 +275,11 @@ func (s *groupRepoStub) DeleteCascade(ctx context.Context, id int64) ([]int64, e
return s.affectedUserIDs, s.deleteErr
}
func (s *groupRepoStub) DeleteCascadeIfEmpty(ctx context.Context, id int64) ([]int64, error) {
s.guardedCalls = append(s.guardedCalls, id)
return s.affectedUserIDs, s.deleteErr
}
func (s *groupRepoStub) List(ctx context.Context, params pagination.PaginationParams) ([]Group, *pagination.PaginationResult, error) {
panic("unexpected List call")
}
@@ -703,6 +709,22 @@ func TestAdminService_DeleteGroup_Error(t *testing.T) {
require.ErrorIs(t, err, deleteErr)
}
func TestAdminService_DeleteGroupIfEmpty_UsesGuardedCascade(t *testing.T) {
repo := &groupRepoStub{deleteErr: ErrGroupNotEmpty}
svc := &adminServiceImpl{groupRepo: repo, emptyGroupDeleteRepo: repo}
err := svc.DeleteGroupIfEmpty(context.Background(), 42)
require.ErrorIs(t, err, ErrGroupNotEmpty)
require.Equal(t, []int64{42}, repo.guardedCalls)
require.Empty(t, repo.deleteCalls)
}
func TestAdminService_DeleteGroupIfEmpty_MissingCapabilityReturnsError(t *testing.T) {
svc := &adminServiceImpl{groupRepo: &groupRepoStub{}}
require.ErrorContains(t, svc.DeleteGroupIfEmpty(context.Background(), 42), "guarded group deletion is unavailable")
}
func TestAdminService_DeleteProxy_Success(t *testing.T) {
repo := &proxyRepoStub{}
svc := &adminServiceImpl{proxyRepo: repo}
@@ -11,6 +11,7 @@ import (
"time"
"unicode/utf8"
"github.com/Wei-Shaw/sub2api/internal/config"
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
"github.com/stretchr/testify/require"
)
@@ -266,6 +267,28 @@ func TestDuplicateAccountPreservesUngroupedState(t *testing.T) {
require.NotContains(t, repo.groupsOf, duplicate.ID)
}
func TestDuplicateAccountSimpleModeRejectsCompositeGroupBinding(t *testing.T) {
ctx := context.Background()
repo := newDuplicateAccountRepoStub()
groupRepo := &groupRepoStubForAdmin{getByIDByID: map[int64]*Group{
9: {ID: 9, Platform: PlatformComposite},
}}
svc := &adminServiceImpl{
cfg: &config.Config{RunMode: config.RunModeSimple}, groupRepo: groupRepo,
accountRepo: repo, accountDuplicateRepo: repo,
}
source := &Account{
Name: "composite-bound", Platform: PlatformAnthropic, Type: AccountTypeAPIKey,
Credentials: map[string]any{"api_key": "secret"}, GroupIDs: []int64{9},
}
require.NoError(t, repo.Create(ctx, source))
_, err := svc.DuplicateAccount(ctx, source.ID, "admin:1", "")
require.Equal(t, "SIMPLE_MODE_GROUP_NOT_BINDABLE", infraerrors.Reason(err))
require.Len(t, repo.accounts, 1)
}
func TestDuplicateAccountAtomicCreateFailureLeavesNoOrphan(t *testing.T) {
ctx := context.Background()
repo := newDuplicateAccountRepoStub()
@@ -7,6 +7,7 @@ import (
"net/http"
"testing"
"github.com/Wei-Shaw/sub2api/internal/config"
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
"github.com/stretchr/testify/require"
@@ -89,6 +90,10 @@ func (s *groupRepoStubForAdmin) DeleteCascade(_ context.Context, _ int64) ([]int
panic("unexpected DeleteCascade call")
}
func (s *groupRepoStubForAdmin) DeleteCascadeIfEmpty(_ context.Context, _ int64) ([]int64, error) {
panic("unexpected DeleteCascadeIfEmpty call")
}
func (s *groupRepoStubForAdmin) List(_ context.Context, _ pagination.PaginationParams) ([]Group, *pagination.PaginationResult, error) {
panic("unexpected List call")
}
@@ -117,6 +122,178 @@ func (s *groupRepoStubForAdmin) ListWithFilters(_ context.Context, params pagina
return s.listWithFiltersGroups, result, nil
}
func (s *groupRepoStubForAdmin) ListBindableWithFilters(ctx context.Context, params pagination.PaginationParams, platform, status, search string, isExclusive *bool) ([]Group, *pagination.PaginationResult, error) {
return s.ListWithFilters(ctx, params, platform, status, search, isExclusive)
}
func TestAdminServiceSimpleModeValidatesRequestedGroupIDsDirectly(t *testing.T) {
groups := make(map[int64]*Group, 1002)
for id := int64(1); id <= 1001; id++ {
groups[id] = &Group{ID: id, Platform: PlatformAnthropic}
}
groups[1002] = &Group{ID: 1002, Platform: PlatformComposite}
svc := &adminServiceImpl{cfg: &config.Config{RunMode: config.RunModeSimple}, groupRepo: &groupRepoStubForAdmin{getByIDByID: groups}}
require.Error(t, svc.ValidateAccountGroupBindings(context.Background(), []int64{1, 1002}))
require.ErrorIs(t, svc.ValidateAccountGroupBindings(context.Background(), []int64{1, 2000}), ErrGroupNotFound)
require.NoError(t, svc.ValidateAccountGroupBindings(context.Background(), []int64{1, 1001}))
}
func TestAdminServiceSimpleModeRejectsDirectCompositeGroupAccess(t *testing.T) {
repo := &groupRepoStubForAdmin{getByID: &Group{ID: 9, Platform: PlatformComposite}}
svc := &adminServiceImpl{cfg: &config.Config{RunMode: config.RunModeSimple}, groupRepo: repo, emptyGroupDeleteRepo: repo}
_, err := svc.GetGroup(context.Background(), 9)
require.Error(t, err)
_, err = svc.UpdateGroup(context.Background(), 9, &UpdateGroupInput{})
require.Error(t, err)
require.Nil(t, repo.updated)
require.Error(t, svc.DeleteGroupIfEmpty(context.Background(), 9))
}
func TestAdminServiceSimpleModeRejectsAccountListCompositeFilter(t *testing.T) {
repo := &groupRepoStubForAdmin{getByID: &Group{ID: 9, Platform: PlatformComposite}}
svc := &adminServiceImpl{cfg: &config.Config{RunMode: config.RunModeSimple}, groupRepo: repo}
_, _, err := svc.ListAccounts(context.Background(), 1, 20, "", "", "", "", 9, "", "", "")
require.Error(t, err)
}
func TestAdminServiceSimpleModeRejectsAdvancedGroupOperationsDirectly(t *testing.T) {
svc := &adminServiceImpl{cfg: &config.Config{RunMode: config.RunModeSimple}}
tests := []struct {
name string
call func() error
}{
{"duplicate", func() error { _, err := svc.DuplicateGroup(context.Background(), 1, "admin:1", "key"); return err }},
{"recover duplicate", func() error {
_, err := svc.RecoverDuplicateGroup(context.Background(), 1, "admin:1", "key")
return err
}},
{"list composite routes", func() error { _, err := svc.ListCompositeRoutes(context.Background(), 1); return err }},
{"create composite route", func() error {
_, err := svc.CreateCompositeRoute(context.Background(), 1, CompositeRouteInput{})
return err
}},
{"update composite route", func() error {
_, err := svc.UpdateCompositeRoute(context.Background(), 1, 2, CompositeRouteInput{})
return err
}},
{"delete composite route", func() error { return svc.DeleteCompositeRoute(context.Background(), 1, 2) }},
{"preview composite route", func() error {
_, err := svc.PreviewCompositeRoute(context.Background(), 1, CompositeRoutePreviewRequest{})
return err
}},
{"get multipliers", func() error { _, err := svc.GetGroupRateMultipliers(context.Background(), 1); return err }},
{"clear multipliers", func() error { return svc.ClearGroupRateMultipliers(context.Background(), 1) }},
{"set multipliers", func() error { return svc.BatchSetGroupRateMultipliers(context.Background(), 1, nil) }},
{"clear rpm overrides", func() error { return svc.ClearGroupRPMOverrides(context.Background(), 1) }},
{"set rpm overrides", func() error { return svc.BatchSetGroupRPMOverrides(context.Background(), 1, nil) }},
{"sort", func() error { return svc.UpdateGroupSortOrders(context.Background(), nil) }},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := tt.call()
require.Error(t, err)
require.Equal(t, "SIMPLE_MODE_OPERATION_UNSUPPORTED", infraerrors.Reason(err))
})
}
}
func TestAdminServiceSimpleModeRejectsCompositeCreateAndConversionDirectly(t *testing.T) {
svc := &adminServiceImpl{cfg: &config.Config{RunMode: config.RunModeSimple}}
_, err := svc.CreateGroup(context.Background(), &CreateGroupInput{Platform: PlatformComposite, RateMultiplier: 1})
require.Error(t, err)
repo := &groupRepoStubForAdmin{getByID: &Group{ID: 1, Platform: PlatformAnthropic}}
svc.groupRepo = repo
_, err = svc.UpdateGroup(context.Background(), 1, &UpdateGroupInput{Platform: PlatformComposite})
require.Error(t, err)
require.Nil(t, repo.updated)
}
func TestAdminServiceSimpleModeNormalizesAllUnsupportedCreateFieldsDirectly(t *testing.T) {
one := 1.0
fallbackID := int64(44)
input := &CreateGroupInput{
Name: "simple", Description: "allowed", Platform: PlatformAnthropic,
RateMultiplier: 9, IsExclusive: true, SubscriptionType: SubscriptionTypeSubscription,
DailyLimitUSD: &one, LongContextPricingEnabled: true,
ModelPricing: []ChannelModelPricing{{Models: []string{"claude"}}},
PeakRateEnabled: true, PeakStart: "00:00", PeakEnd: "01:00", PeakRateMultiplier: &one,
ImageRateIndependent: true, ImageRateMultiplier: &one, VideoRateIndependent: true, VideoRateMultiplier: &one,
ImagePrice1K: &one, VideoPrice720P: &one, WebSearchPricePerCall: &one, SearchPricePer1k: &one,
AudioRealtimePricePerMin: &one, ClaudeCodeOnly: true, FallbackGroupID: &fallbackID,
ModelRouting: map[string][]int64{"claude": {1}}, ModelRoutingEnabled: true,
AllowMessagesDispatch: true, AllowLive: true, ForceOpenAIFast: true, RequireOAuthOnly: true,
RPMLimit: 99, MaxReasoningEffort: "high", ProfitControlEnabled: true, ProfitMinMargin: &one,
CopyAccountsFromGroupIDs: []int64{2},
}
repo := &groupRepoStubForAdmin{}
svc := &adminServiceImpl{cfg: &config.Config{RunMode: config.RunModeSimple}, groupRepo: repo}
created, err := svc.CreateGroup(context.Background(), input)
require.NoError(t, err)
require.Same(t, repo.created, created)
require.Equal(t, CreateGroupInput{
Name: "simple", Description: "allowed", Platform: PlatformAnthropic,
RateMultiplier: 1, SubscriptionType: SubscriptionTypeStandard,
}, *input)
require.Equal(t, 1.0, created.RateMultiplier)
require.Equal(t, SubscriptionTypeStandard, created.SubscriptionType)
require.False(t, created.IsExclusive)
require.Nil(t, created.FallbackGroupID)
require.Empty(t, created.ModelPricing)
require.Zero(t, created.RPMLimit)
}
func TestAdminServiceSimpleModeNormalizesAllUnsupportedUpdateFieldsDirectly(t *testing.T) {
one := 1.0
truth := true
status := "inactive"
description := "allowed"
fallbackID := int64(44)
pricing := []ChannelModelPricing{{Models: []string{"claude"}}}
input := &UpdateGroupInput{
Name: "renamed", Description: &description, Platform: PlatformOpenAI, Status: status,
RateMultiplier: &one, IsExclusive: &truth, SubscriptionType: SubscriptionTypeSubscription,
DailyLimitUSD: &one, LongContextPricingEnabled: &truth, ModelPricing: &pricing,
PeakRateEnabled: &truth, PeakRateMultiplier: &one, ImageRateIndependent: &truth,
ImageRateMultiplier: &one, VideoRateIndependent: &truth, VideoRateMultiplier: &one,
ImagePrice1K: &one, VideoPrice720P: &one, WebSearchPricePerCall: &one, SearchPricePer1k: &one,
AudioRealtimePricePerMin: &one, ClaudeCodeOnly: &truth, FallbackGroupID: &fallbackID,
ModelRouting: map[string][]int64{"claude": {1}}, ModelRoutingEnabled: &truth,
AllowMessagesDispatch: &truth, AllowLive: &truth, ForceOpenAIFast: &truth, RequireOAuthOnly: &truth,
RPMLimit: new(int), MaxReasoningEffort: ptrString("high"), ProfitControlEnabled: &truth,
ProfitMinMargin: &one, CopyAccountsFromGroupIDs: []int64{2},
}
existing := &Group{ID: 1, Name: "old", Description: "old description", Platform: PlatformAnthropic, Status: StatusActive, RateMultiplier: 3, RPMLimit: 8, FallbackGroupID: &fallbackID}
repo := &groupRepoStubForAdmin{getByID: existing}
svc := &adminServiceImpl{cfg: &config.Config{RunMode: config.RunModeSimple}, groupRepo: repo}
updated, err := svc.UpdateGroup(context.Background(), 1, input)
require.NoError(t, err)
require.Equal(t, UpdateGroupInput{Name: "renamed", Description: &description}, *input)
require.Equal(t, "renamed", updated.Name)
require.Equal(t, description, updated.Description)
require.Equal(t, PlatformAnthropic, updated.Platform)
require.Equal(t, StatusActive, updated.Status)
require.Equal(t, 3.0, updated.RateMultiplier)
require.Equal(t, 8, updated.RPMLimit)
require.Equal(t, &fallbackID, updated.FallbackGroupID)
}
func TestAdminServiceSimpleModeListUsesRepositoryFilteredTotal(t *testing.T) {
repo := &groupRepoStubForAdmin{
listWithFiltersGroups: []Group{{ID: 2, Platform: PlatformAnthropic}},
listWithFiltersResult: &pagination.PaginationResult{Total: 11, Page: 2, PageSize: 1},
}
svc := &adminServiceImpl{cfg: &config.Config{RunMode: config.RunModeSimple}, groupRepo: repo}
groups, total, err := svc.ListGroups(context.Background(), 2, 1, "", "", "", nil, "id", "asc")
require.NoError(t, err)
require.Len(t, groups, 1)
require.EqualValues(t, 11, total)
}
func (s *groupRepoStubForAdmin) ListActive(_ context.Context) ([]Group, error) {
panic("unexpected ListActive call")
}
@@ -960,7 +1137,7 @@ func TestAdminService_UpdateGroup_ClearsReasoningPolicyForUnsupportedPlatform(t
repo := &groupRepoStubForAdmin{getByID: existing}
svc := &adminServiceImpl{groupRepo: repo}
_, err := svc.UpdateGroup(context.Background(), existing.ID, &UpdateGroupInput{Platform: PlatformAnthropic})
_, err := svc.UpdateGroup(context.Background(), existing.ID, &UpdateGroupInput{Platform: PlatformGemini})
require.NoError(t, err)
require.Empty(t, repo.updated.MaxReasoningEffort)
@@ -115,7 +115,7 @@ type APIKeyAuthGroupSnapshot struct {
// RPMLimit 分组级每分钟请求数上限(0 = 不限制);用于 billing_cache_service.checkRPM 级联判断。
RPMLimit int `json:"rpm_limit"`
// MaxReasoningEffort OpenAI/Codex 请求的推理强度上限,空字符串表示不限制。
// MaxReasoningEffort Anthropic/OpenAI 请求的推理强度上限,空字符串表示不限制。
MaxReasoningEffort string `json:"max_reasoning_effort,omitempty"`
// MaxReasoningEffortOverLimit 超过上限时的访问控制:downgrade(默认)或 deny。
MaxReasoningEffortOverLimit string `json:"max_reasoning_effort_over_limit,omitempty"`
+76 -8
View File
@@ -105,6 +105,7 @@ type ModelPricing struct {
CacheReadPricePerTokenPriority float64 // priority service tier 下缓存读取每token价格 (USD)
FastMultiplier *float64 // 渠道显式 Fast/priority 倍率;nil 时沿用模型目录行为
FlexMultiplier *float64 // 渠道显式 Flex 倍率;nil 时沿用默认行为
MaxReasoningEffortMultiplier *float64 // max 推理等级的额度/计费倍率;nil 时沿用模型默认行为
CacheCreation5mPrice float64 // 5分钟缓存创建每token价格 (USD)
CacheCreation1hPrice float64 // 1小时缓存创建每token价格 (USD)
SupportsCacheBreakdown bool // 是否支持详细的缓存分类
@@ -137,7 +138,7 @@ func usePriorityServiceTierPricing(serviceTier string, pricing *ModelPricing) bo
func serviceTierCostMultiplier(serviceTier string) float64 {
switch normalizeBillingServiceTier(serviceTier) {
case "priority", "fast":
case "priority", "fast", OpenAIFastTierUltrafast:
return 2.0
case "flex":
return 0.5
@@ -214,6 +215,42 @@ func applyCostBreakdownMultiplier(cost *CostBreakdown, multiplier float64) {
cost.ActualCost *= multiplier
}
const claudeFable51MaxReasoningEffortMultiplier = 3.0
func isClaudeFable51Model(model string) bool {
model = strings.ToLower(strings.TrimSpace(model))
for _, marker := range []string{"fable-5-1", "fable-5.1", "fable5.1", "fable51"} {
if at := strings.Index(model, marker); at >= 0 {
after := at + len(marker)
if after == len(model) || model[after] < '0' || model[after] > '9' {
return true
}
}
}
return false
}
func defaultMaxReasoningEffortMultiplier(model string) *float64 {
if !isClaudeFable51Model(model) {
return nil
}
multiplier := claudeFable51MaxReasoningEffortMultiplier
return &multiplier
}
func maxReasoningEffortBillingMultiplier(model, effort string, pricing *ModelPricing) float64 {
if NormalizeMaxReasoningEffort(effort) != "max" {
return 1
}
if pricing != nil && pricing.MaxReasoningEffortMultiplier != nil && *pricing.MaxReasoningEffortMultiplier > 0 {
return *pricing.MaxReasoningEffortMultiplier
}
if multiplier := defaultMaxReasoningEffortMultiplier(model); multiplier != nil {
return *multiplier
}
return 1
}
func resolvedChannelTimeMultiplier(resolved *ResolvedPricing, at time.Time) float64 {
if resolved == nil || resolved.Source != PricingSourceChannel || resolved.channelPricing == nil {
return 1
@@ -431,6 +468,20 @@ func (s *BillingService) initFallbackPricing() {
SupportsCacheBreakdown: false,
}
s.fallbackPrices["gpt-6-astra"] = &ModelPricing{
InputPricePerToken: 10e-6,
InputPricePerTokenPriority: 20e-6,
OutputPricePerToken: 50e-6,
OutputPricePerTokenPriority: 100e-6,
CacheCreationPricePerToken: 12.5e-6,
CacheCreationPricePerTokenPriority: 25e-6,
CacheReadPricePerToken: 1e-6,
CacheReadPricePerTokenPriority: 2e-6,
LongContextInputThreshold: 272_000,
LongContextInputMultiplier: 2,
LongContextOutputMultiplier: 1.5,
}
// OpenAI GPT-5.6 官方价格(USD/token)。缓存写入为输入价的 1.25 倍。
s.fallbackPrices["gpt-5.6-sol"] = &ModelPricing{
InputPricePerToken: 5e-6,
@@ -803,8 +854,7 @@ func (s *BillingService) getFallbackPricing(model string) *ModelPricing {
modelLower := strings.ToLower(model)
// 按模型系列匹配
if strings.Contains(modelLower, "fable-5-1") || strings.Contains(modelLower, "fable-5.1") ||
strings.Contains(modelLower, "fable5.1") || strings.Contains(modelLower, "fable51") {
if isClaudeFable51Model(modelLower) {
return s.fallbackPrices["claude-fable-5-1"]
}
if strings.Contains(modelLower, "fable-5") || strings.Contains(modelLower, "fable5") {
@@ -976,6 +1026,8 @@ func (s *BillingService) getFallbackPricing(model string) *ModelPricing {
// OpenAI(GPT-5 / Codex 族):仅匹配已知型号,避免未知 OpenAI 型号误计价。
if normalized := normalizeKnownOpenAICodexModel(modelLower); normalized != "" {
switch normalized {
case "gpt-6-astra":
return s.fallbackPrices["gpt-6-astra"]
case "gpt-5.6-sol":
return s.fallbackPrices["gpt-5.6-sol"]
case "gpt-5.6-terra":
@@ -1169,6 +1221,9 @@ func (s *BillingService) GetModelPricingWithChannel(model string, channelPricing
applyChannelTokenPriceOverrides(pricing, channelPricing)
pricing.FastMultiplier = channelPricing.FastMultiplier
pricing.FlexMultiplier = channelPricing.FlexMultiplier
if channelPricing.MaxReasoningEffortMultiplier != nil {
pricing.MaxReasoningEffortMultiplier = channelPricing.MaxReasoningEffortMultiplier
}
if channelPricing.ImageOutputPrice != nil {
pricing.ImageOutputPricePerToken = *channelPricing.ImageOutputPrice
} else {
@@ -1241,6 +1296,7 @@ type CostInput struct {
RateMultiplier float64
PricingAt time.Time // 渠道分时定价使用的计费时刻
ServiceTier string // "priority","flex","" 等
ReasoningEffort string // 最终转发的推理等级;max 可触发模型/渠道倍率
Resolver *ModelPricingResolver // 定价解析器
Resolved *ResolvedPricing // 可选:预解析的定价结果(避免重复 Resolve 调用)
LongContextBillingEnabled *bool
@@ -1255,7 +1311,7 @@ func (s *BillingService) CalculateCostUnified(input CostInput) (*CostBreakdown,
if input.LongContextBillingEnabled != nil {
applyLongContextBilling = *input.LongContextBillingEnabled
}
return s.calculateCostInternalWithPolicy(
breakdown, err := s.calculateCostInternalWithPolicy(
input.Model,
input.Tokens,
input.RateMultiplier,
@@ -1263,6 +1319,10 @@ func (s *BillingService) CalculateCostUnified(input CostInput) (*CostBreakdown,
nil,
applyLongContextBilling,
)
if err == nil {
applyCostBreakdownMultiplier(breakdown, maxReasoningEffortBillingMultiplier(input.Model, input.ReasoningEffort, nil))
}
return breakdown, err
}
// 优先使用预解析结果,避免重复 Resolve 调用
@@ -1346,6 +1406,7 @@ func (s *BillingService) calculateTokenCost(resolved *ResolvedPricing, input Cos
breakdown := s.computeTokenBreakdown(pricing, input.Tokens, input.RateMultiplier, input.ServiceTier, applyLongCtx)
applyCostBreakdownMultiplier(breakdown, resolvedChannelTimeMultiplier(resolved, input.PricingAt))
applyCostBreakdownMultiplier(breakdown, maxReasoningEffortBillingMultiplier(input.Model, input.ReasoningEffort, pricing))
return breakdown, nil
}
@@ -1627,13 +1688,17 @@ func (s *BillingService) applyModelSpecificPricingPolicyEx(model string, pricing
}
normalized := normalizeKnownOpenAICodexModel(model)
isGPT56 := isOpenAIGPT56Model(normalized)
needsMaxReasoningEffortMultiplier := isClaudeFable51Model(model) && pricing.MaxReasoningEffortMultiplier == nil
needsCacheCreationPolicy := isGPT56 && !pricing.CacheCreationPriceExplicit && (pricing.CacheCreationPricePerToken <= 0 ||
(pricing.InputPricePerTokenPriority > 0 && pricing.CacheCreationPricePerTokenPriority <= 0))
fastRatio := openAIModelFastPricingRatio(normalized)
if !needsCacheCreationPolicy && fastRatio <= 0 {
if !needsCacheCreationPolicy && fastRatio <= 0 && !needsMaxReasoningEffortMultiplier {
return pricing
}
cloned := *pricing
if needsMaxReasoningEffortMultiplier {
cloned.MaxReasoningEffortMultiplier = defaultMaxReasoningEffortMultiplier(model)
}
if isGPT56 && !cloned.CacheCreationPriceExplicit {
if cloned.CacheCreationPricePerToken <= 0 {
cloned.CacheCreationPricePerToken = cloned.InputPricePerToken * 1.25
@@ -1648,16 +1713,19 @@ func (s *BillingService) applyModelSpecificPricingPolicyEx(model string, pricing
return &cloned
}
// openAIModelFastPricingRatio 返回业务口径下 OpenAI GPT-5.x 模型 Fast/priority
// 的标准价倍率:gpt-5.6 系列与 gpt-5.4 为 2x,gpt-5.5 为 2.5x。未定义 Fast
// openAIModelFastPricingRatio 返回业务口径下 OpenAI GPT 模型 Fast/priority
// 的标准价倍率:gpt-5.6 / gpt-6-astra / gpt-5.4 为 2x,gpt-5.5 为 2.5x。未定义 Fast
// 档的模型(如 gpt-5.5-pro、gpt-5.4-mini/nano)返回 0。
func openAIModelFastPricingRatio(normalized string) float64 {
switch normalized {
case "gpt-5.4", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna":
case "gpt-5.4", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", "gpt-6-astra":
return 2.0
case "gpt-5.5":
return 2.5
default:
if isOpenAIGPT6AstraModel(normalized) {
return 2.0
}
return 0
}
}
@@ -1431,6 +1431,7 @@ func TestCalculateCost_LargeTokenCount(t *testing.T) {
func TestServiceTierCostMultiplier(t *testing.T) {
require.InDelta(t, 2.0, serviceTierCostMultiplier("priority"), 1e-12)
require.InDelta(t, 2.0, serviceTierCostMultiplier(" Priority "), 1e-12)
require.InDelta(t, 2.0, serviceTierCostMultiplier("ultrafast"), 1e-12)
require.InDelta(t, 0.5, serviceTierCostMultiplier("flex"), 1e-12)
require.InDelta(t, 1.0, serviceTierCostMultiplier(""), 1e-12)
require.InDelta(t, 1.0, serviceTierCostMultiplier("default"), 1e-12)
@@ -1770,6 +1771,8 @@ func TestGetModelPricing_Fable51FallbackPricing(t *testing.T) {
require.InDelta(t, 12.5e-6, pricing.CacheCreation5mPrice, 1e-12)
require.InDelta(t, 20e-6, pricing.CacheCreation1hPrice, 1e-12)
require.InDelta(t, 0.25e-6, pricing.CacheReadPricePerToken, 1e-12)
require.NotNil(t, pricing.MaxReasoningEffortMultiplier)
require.Equal(t, 3.0, *pricing.MaxReasoningEffortMultiplier)
}
func TestGetModelPricingWithChannel_CacheReadPriceAffectsPriority(t *testing.T) {
@@ -7,14 +7,15 @@ import (
// TokenCostRequest 通用网关 token 计费请求。
type TokenCostRequest struct {
Ctx context.Context
Model string
Group *Group
Tokens UsageTokens
RateMultiplier float64
PricingAt time.Time
ServiceTier string
Resolver *ModelPricingResolver
Ctx context.Context
Model string
Group *Group
Tokens UsageTokens
RateMultiplier float64
PricingAt time.Time
ServiceTier string
ReasoningEffort string
Resolver *ModelPricingResolver
// Resolved 为调用方预先解析的定价(Resolver.Resolve 的结果),nil 表示未解析。
Resolved *ResolvedPricing
}
@@ -33,21 +34,25 @@ func (s *BillingService) CalculateTokenCostForRequest(req TokenCostRequest) (*Co
if req.Resolver != nil && req.Group != nil {
return s.CalculateCostUnified(s.tokenCostInput(req, resolved))
}
if req.ReasoningEffort != "" {
return s.CalculateCostUnified(s.tokenCostInput(req, resolved))
}
return s.CalculateCost(req.Model, req.Tokens, req.RateMultiplier)
}
func (s *BillingService) tokenCostInput(req TokenCostRequest, resolved *ResolvedPricing) CostInput {
input := CostInput{
Ctx: req.Ctx,
Model: req.Model,
Group: req.Group,
Tokens: req.Tokens,
RequestCount: 1,
RateMultiplier: req.RateMultiplier,
PricingAt: req.PricingAt,
ServiceTier: req.ServiceTier,
Resolver: req.Resolver,
Resolved: resolved,
Ctx: req.Ctx,
Model: req.Model,
Group: req.Group,
Tokens: req.Tokens,
RequestCount: 1,
RateMultiplier: req.RateMultiplier,
PricingAt: req.PricingAt,
ServiceTier: req.ServiceTier,
ReasoningEffort: req.ReasoningEffort,
Resolver: req.Resolver,
Resolved: resolved,
}
if req.Group != nil {
gid := req.Group.ID
@@ -216,3 +216,41 @@ func TestCalculateTokenCostForRequest_NoResolverFallsBackToCatalog(t *testing.T)
require.NoError(t, err)
require.Equal(t, want, got)
}
func TestCalculateTokenCostForRequest_Fable51MaxEffortUsesDefaultMultiplier(t *testing.T) {
bs := NewBillingService(&config.Config{}, nil)
tokens := UsageTokens{InputTokens: 1000, OutputTokens: 10}
standard, err := bs.CalculateTokenCostForRequest(TokenCostRequest{
Model: "claude-fable-5-1", Tokens: tokens, RateMultiplier: 1, ReasoningEffort: "xhigh",
})
require.NoError(t, err)
max, err := bs.CalculateTokenCostForRequest(TokenCostRequest{
Model: "claude-fable-5-1", Tokens: tokens, RateMultiplier: 1, ReasoningEffort: "max",
})
require.NoError(t, err)
require.InDelta(t, standard.TotalCost*3, max.TotalCost, 1e-12)
require.InDelta(t, standard.ActualCost*3, max.ActualCost, 1e-12)
require.InDelta(t, standard.InputCost*3, max.InputCost, 1e-12)
require.InDelta(t, standard.OutputCost*3, max.OutputCost, 1e-12)
}
func TestCalculateTokenCostForRequest_ChannelOverridesFable51MaxEffortMultiplier(t *testing.T) {
configured := 1.5
bs, resolver := newTokenCostTestEnv(t, PlatformAnthropic, []ChannelModelPricing{{
Platform: PlatformAnthropic, Models: []string{"claude-fable-5-1"}, BillingMode: BillingModeToken,
InputPrice: testPtrFloat64(10e-6), OutputPrice: testPtrFloat64(50e-6),
MaxReasoningEffortMultiplier: &configured,
}}, nil)
group := &Group{ID: 100, Platform: PlatformAnthropic}
gid := group.ID
resolved := resolver.Resolve(context.Background(), PricingInput{Model: "claude-fable-5-1", GroupID: &gid, Group: group})
got, err := bs.CalculateTokenCostForRequest(TokenCostRequest{
Ctx: context.Background(), Model: "claude-fable-5-1", Group: group,
Tokens: UsageTokens{InputTokens: 1000}, RateMultiplier: 1, ReasoningEffort: "max",
Resolver: resolver, Resolved: resolved,
})
require.NoError(t, err)
require.InDelta(t, 1000*10e-6*configured, got.TotalCost, 1e-12)
require.InDelta(t, got.TotalCost, got.ActualCost, 1e-12)
}
+20 -19
View File
@@ -87,25 +87,26 @@ type AccountStatsPricingRule struct {
// ChannelModelPricing 渠道模型定价条目
type ChannelModelPricing struct {
ID int64 `json:"id,omitempty"`
ChannelID int64 `json:"channel_id,omitempty"`
Platform string `json:"platform"` // 所属平台(anthropic/openai/gemini/...)
Models []string `json:"models"`
BillingMode BillingMode `json:"billing_mode"`
InputPrice *float64 `json:"input_price"`
OutputPrice *float64 `json:"output_price"`
CacheWritePrice *float64 `json:"cache_write_price"`
CacheWrite1hPrice *float64 `json:"cache_write_1h_price"`
CacheReadPrice *float64 `json:"cache_read_price"`
FastMultiplier *float64 `json:"fast_multiplier"`
FlexMultiplier *float64 `json:"flex_multiplier"`
ImageInputPrice *float64 `json:"image_input_price"`
ImageOutputPrice *float64 `json:"image_output_price"`
PerRequestPrice *float64 `json:"per_request_price"`
Intervals []PricingInterval `json:"intervals"`
TimePricing *ChannelTimePricing `json:"time_pricing,omitempty"`
CreatedAt time.Time `json:"created_at,omitempty"`
UpdatedAt time.Time `json:"updated_at,omitempty"`
ID int64 `json:"id,omitempty"`
ChannelID int64 `json:"channel_id,omitempty"`
Platform string `json:"platform"` // 所属平台(anthropic/openai/gemini/...)
Models []string `json:"models"`
BillingMode BillingMode `json:"billing_mode"`
InputPrice *float64 `json:"input_price"`
OutputPrice *float64 `json:"output_price"`
CacheWritePrice *float64 `json:"cache_write_price"`
CacheWrite1hPrice *float64 `json:"cache_write_1h_price"`
CacheReadPrice *float64 `json:"cache_read_price"`
FastMultiplier *float64 `json:"fast_multiplier"`
FlexMultiplier *float64 `json:"flex_multiplier"`
MaxReasoningEffortMultiplier *float64 `json:"max_reasoning_effort_multiplier"`
ImageInputPrice *float64 `json:"image_input_price"`
ImageOutputPrice *float64 `json:"image_output_price"`
PerRequestPrice *float64 `json:"per_request_price"`
Intervals []PricingInterval `json:"intervals"`
TimePricing *ChannelTimePricing `json:"time_pricing,omitempty"`
CreatedAt time.Time `json:"created_at,omitempty"`
UpdatedAt time.Time `json:"updated_at,omitempty"`
}
// ChannelTimePricing 渠道模型定价的分时倍率配置。
+28 -23
View File
@@ -117,20 +117,16 @@ func (s *ChannelService) ListAvailable(ctx context.Context) ([]AvailableChannel,
// 1. Pricing == nil(渠道完全没声明该模型的定价条目)
// 2. Pricing 非 nil 但所有价格字段为空(admin UI 建了条目但没填价格)
//
// 当 pricingService 为 nil(测试场景),跳过回落。可用渠道与模型广场共用。
// 当 pricingService 为 nil(测试场景),跳过价格回落,但仍补充内置模型倍率。
// 可用渠道与模型广场共用。
func fillGlobalPricingFallback(pricingService *PricingService, models []SupportedModel) {
if pricingService == nil {
return
}
for i := range models {
if !pricingNeedsFallback(models[i].Pricing) {
continue
if pricingService != nil && pricingNeedsFallback(models[i].Pricing) {
if lp := pricingService.GetModelPricing(models[i].Name); lp != nil {
models[i].Pricing = synthesizePricingFromLiteLLM(lp, models[i].Pricing)
}
}
lp := pricingService.GetModelPricing(models[i].Name)
if lp == nil {
continue
}
models[i].Pricing = synthesizePricingFromLiteLLM(lp, models[i].Pricing)
models[i].Pricing = withDefaultMaxReasoningEffortMultiplier(models[i].Pricing, models[i].Name)
}
}
@@ -180,24 +176,33 @@ func synthesizePricingFromLiteLLM(lp *LiteLLMModelPricing, existing *ChannelMode
if mode == BillingModeImage || mode == BillingModePerRequest {
return &ChannelModelPricing{
BillingMode: mode,
PerRequestPrice: nonZeroPtr(lp.OutputCostPerImage),
ImageOutputPrice: nonZeroPtr(lp.OutputCostPerImageToken),
InputPrice: nonZeroPtr(lp.InputCostPerToken),
OutputPrice: nonZeroPtr(lp.OutputCostPerToken),
BillingMode: mode,
PerRequestPrice: nonZeroPtr(lp.OutputCostPerImage),
ImageOutputPrice: nonZeroPtr(lp.OutputCostPerImageToken),
InputPrice: nonZeroPtr(lp.InputCostPerToken),
OutputPrice: nonZeroPtr(lp.OutputCostPerToken),
MaxReasoningEffortMultiplier: maxReasoningEffortMultiplierFromPricing(existing),
}
}
return &ChannelModelPricing{
BillingMode: mode,
InputPrice: nonZeroPtr(lp.InputCostPerToken),
OutputPrice: nonZeroPtr(lp.OutputCostPerToken),
CacheWritePrice: nonZeroPtr(lp.CacheCreationInputTokenCost),
CacheWrite1hPrice: nonZeroPtr(lp.CacheCreationInputTokenCostAbove1hr),
CacheReadPrice: nonZeroPtr(lp.CacheReadInputTokenCost),
ImageOutputPrice: nonZeroPtr(lp.OutputCostPerImageToken),
BillingMode: mode,
InputPrice: nonZeroPtr(lp.InputCostPerToken),
OutputPrice: nonZeroPtr(lp.OutputCostPerToken),
CacheWritePrice: nonZeroPtr(lp.CacheCreationInputTokenCost),
CacheWrite1hPrice: nonZeroPtr(lp.CacheCreationInputTokenCostAbove1hr),
CacheReadPrice: nonZeroPtr(lp.CacheReadInputTokenCost),
ImageOutputPrice: nonZeroPtr(lp.OutputCostPerImageToken),
MaxReasoningEffortMultiplier: maxReasoningEffortMultiplierFromPricing(existing),
}
}
func maxReasoningEffortMultiplierFromPricing(pricing *ChannelModelPricing) *float64 {
if pricing == nil {
return nil
}
return pricing.MaxReasoningEffortMultiplier
}
func nonZeroPtr(v float64) *float64 {
if v == 0 {
return nil
@@ -198,7 +198,7 @@ var providerAdapters = map[string]providerAdapter{
buildBody: func(_, prompt string) ([]byte, error) {
return json.Marshal(map[string]any{
"contents": []map[string]any{
{"parts": []map[string]any{{"text": prompt}}},
{"role": "user", "parts": []map[string]any{{"text": prompt}}},
},
"generationConfig": map[string]any{"maxOutputTokens": monitorChallengeMaxTokens},
})
@@ -498,3 +498,26 @@ func TestValidateChallenge_AnthropicTextAfterThinking(t *testing.T) {
t.Fatalf("validateChallenge(%q, %q) = false, want true", respText, "2")
}
}
func TestGeminiMonitorBodyIncludesExplicitUserRole(t *testing.T) {
adapter := providerAdapters[MonitorProviderGemini]
body, err := adapter.buildBody("gemini-3.6-flash", "Reply with only 7.")
if err != nil {
t.Fatalf("buildBody() error = %v", err)
}
var payload struct {
Contents []struct {
Role string `json:"role"`
} `json:"contents"`
}
if err := json.Unmarshal(body, &payload); err != nil {
t.Fatalf("Unmarshal() error = %v", err)
}
if len(payload.Contents) != 1 {
t.Fatalf("contents length = %d, want 1", len(payload.Contents))
}
if payload.Contents[0].Role != "user" {
t.Fatalf("contents[0].role = %q, want user", payload.Contents[0].Role)
}
}
@@ -19,6 +19,8 @@ func TestConfiguredServiceTierMultiplier(t *testing.T) {
{name: "priority alias", serviceTier: "priority", pricing: &ModelPricing{FastMultiplier: pricingMultiplier(2)}, want: 2},
{name: "flex configured", serviceTier: "flex", pricing: &ModelPricing{FlexMultiplier: pricingMultiplier(0.4)}, want: 0.4},
{name: "legacy fast default", serviceTier: "fast", pricing: &ModelPricing{}, want: 2},
{name: "ultrafast default", serviceTier: "ultrafast", pricing: &ModelPricing{}, want: 2},
{name: "ultrafast ignores fast multiplier", serviceTier: "ultrafast", pricing: &ModelPricing{FastMultiplier: pricingMultiplier(2.5)}, want: 2},
{name: "legacy flex default", serviceTier: "flex", pricing: &ModelPricing{}, want: 0.5},
}
@@ -5,16 +5,30 @@ import (
"regexp"
"strings"
"github.com/Wei-Shaw/sub2api/internal/pkg/claude"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
// ccVersionInBillingRe matches the semver part of cc_version (X.Y.Z), preserving
// the trailing message-derived suffix (e.g. ".c02") if present.
// ccVersionInBillingRe matches the semver part of cc_version (X.Y.Z).
var ccVersionInBillingRe = regexp.MustCompile(`cc_version=\d+\.\d+\.\d+`)
var ccVersionWithFingerprintInBillingRe = regexp.MustCompile(`cc_version=\d+\.\d+\.\d+\.[0-9a-fA-F]{3}\b`)
// OAuth mimicry forces the built-in User-Agent after applying account fingerprints.
func effectiveBillingUserAgent(tokenType string, mimicClaudeCode bool, fingerprint *Fingerprint) string {
if tokenType == "oauth" && mimicClaudeCode {
return claude.DefaultHeaders["User-Agent"]
}
if fingerprint == nil {
return ""
}
return fingerprint.UserAgent
}
// syncBillingHeaderVersion rewrites cc_version in x-anthropic-billing-header
// system text blocks to match the version extracted from userAgent.
// Recompute any recognized fingerprint suffix because its input includes the version.
// Only touches system array blocks whose text starts with "x-anthropic-billing-header".
func syncBillingHeaderVersion(body []byte, userAgent string) []byte {
version := ExtractCLIVersion(userAgent)
@@ -33,7 +47,9 @@ func syncBillingHeaderVersion(body []byte, userAgent string) []byte {
text := item.Get("text")
if text.Exists() && text.Type == gjson.String &&
strings.HasPrefix(text.String(), "x-anthropic-billing-header") {
newText := ccVersionInBillingRe.ReplaceAllString(text.String(), replacement)
fingerprintedReplacement := replacement + "." + computeClaudeCodeFingerprint(body, version)
newText := ccVersionWithFingerprintInBillingRe.ReplaceAllString(text.String(), fingerprintedReplacement)
newText = ccVersionInBillingRe.ReplaceAllString(newText, replacement)
if newText != text.String() {
if updated, err := sjson.SetBytes(body, fmt.Sprintf("system.%d.text", idx), newText); err == nil {
body = updated
@@ -1,9 +1,20 @@
package service
import (
"context"
"io"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/pkg/claude"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
func TestSyncBillingHeaderVersion(t *testing.T) {
@@ -15,10 +26,10 @@ func TestSyncBillingHeaderVersion(t *testing.T) {
unchanged bool // expect body to remain the same
}{
{
name: "replaces cc_version preserving message-derived suffix",
name: "replaces cc_version and recomputes message-derived suffix",
body: `{"system":[{"type":"text","text":"x-anthropic-billing-header: cc_version=2.1.81.df2; cc_entrypoint=cli; cch=00000;"},{"type":"text","text":"You are Claude Code.","cache_control":{"type":"ephemeral"}}],"messages":[]}`,
userAgent: "claude-cli/2.1.22 (external, cli)",
wantSub: "cc_version=2.1.22.df2",
wantSub: "cc_version=2.1.22." + computeClaudeCodeFingerprint([]byte(`{"messages":[]}`), "2.1.22"),
},
{
name: "no billing header in system",
@@ -65,3 +76,78 @@ func TestSyncBillingHeaderVersion(t *testing.T) {
})
}
}
func TestSyncBillingHeaderVersion_RecomputesSuffixAndIsIdempotent(t *testing.T) {
body := []byte(`{"system":[{"type":"text","text":"x-anthropic-billing-header: cc_version=2.1.81.df2; cc_entrypoint=cli;"}],"messages":[{"role":"user","content":"hello world"}]}`)
version := "2.1.22"
result := syncBillingHeaderVersion(body, "claude-cli/"+version)
require.Contains(t, gjson.GetBytes(result, "system.0.text").String(),
"cc_version="+version+"."+computeClaudeCodeFingerprint(body, version)+";")
require.Equal(t, string(result), string(syncBillingHeaderVersion(result, "claude-cli/"+version)))
require.JSONEq(t, gjson.GetBytes(body, "messages").Raw, gjson.GetBytes(result, "messages").Raw)
}
func TestBuildOAuthRequest_BillingMatchesWireUserAgent(t *testing.T) {
gin.SetMode(gin.TestMode)
for _, endpoint := range []string{"messages", "count_tokens"} {
for _, tc := range []struct {
name string
mimic bool
identity bool
disableFP bool
}{
{name: "mimic_overrides_cached_version", mimic: true, identity: true},
{name: "mimic_without_identity", mimic: true},
{name: "mimic_with_fingerprint_disabled", mimic: true, identity: true, disableFP: true},
{name: "passthrough_uses_cached_version", identity: true},
} {
t.Run(endpoint+"/"+tc.name, func(t *testing.T) {
resetGatewayForwardingSettingsCacheForTest(t)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
body := []byte(`{"model":"claude-haiku-4-5","system":[{"type":"text","text":""}],"messages":[{"role":"user","content":"hello world"}]}`)
billing, err := buildBillingAttributionText(body, "2.1.81")
require.NoError(t, err)
body, err = sjson.SetBytes(body, "system.0.text", billing)
require.NoError(t, err)
cfg := &config.Config{}
svc := &GatewayService{cfg: cfg}
cachedUA := "claude-cli/2.9.0 (external, cli)"
if tc.identity {
svc.identityService = NewIdentityService(&stubIdentityCache{fingerprint: &Fingerprint{
UserAgent: cachedUA, ClientID: "test-client", UpdatedAt: time.Now().Unix(),
}})
}
if tc.disableFP {
svc.settingService = NewSettingService(&gatewayTTLSettingRepo{data: map[string]string{
SettingKeyEnableFingerprintUnification: "false",
}}, cfg)
}
account := &Account{ID: 1, Platform: PlatformAnthropic, Type: AccountTypeOAuth}
var req *http.Request
var wireBody []byte
if endpoint == "messages" {
req, wireBody, err = svc.buildUpstreamRequest(context.Background(), c, account,
body, "test-token", "oauth", "claude-haiku-4-5", false, tc.mimic)
} else {
req, wireBody, err = svc.buildCountTokensRequest(context.Background(), c, account,
body, "test-token", "oauth", "claude-haiku-4-5", tc.mimic)
}
require.NoError(t, err)
defer func() { require.NoError(t, req.Body.Close()) }()
wantUA := cachedUA
if tc.mimic {
wantUA = claude.DefaultHeaders["User-Agent"]
}
require.Equal(t, wantUA, getHeaderRaw(req.Header, "User-Agent"))
version := ExtractCLIVersion(wantUA)
require.Contains(t, gjson.GetBytes(wireBody, "system.0.text").String(),
"cc_version="+version+"."+computeClaudeCodeFingerprint(wireBody, version)+";")
actualBody, err := io.ReadAll(req.Body)
require.NoError(t, err)
require.Equal(t, wireBody, actualBody)
})
}
}
}
@@ -770,14 +770,14 @@ func expandClaudeOAuthSystemPromptTextTemplate(body []byte, text string, expansi
return "", nil
}
expansionPrompt = defaultClaudeOAuthExpansionPrompt(expansionPrompt)
billingText, err := buildBillingAttributionText(body, claude.CLICurrentVersion)
billingText, err := buildBillingAttributionText(body, claude.CLIVersion())
if err != nil {
return "", err
}
fp := computeClaudeCodeFingerprint(body, claude.CLICurrentVersion)
fp := computeClaudeCodeFingerprint(body, claude.CLIVersion())
replacer := strings.NewReplacer(
"{billing_header}", billingText,
"{cc_version}", claude.CLICurrentVersion,
"{cc_version}", claude.CLIVersion(),
"{fp}", fp,
"{claude_code_system_prompt}", claudeCodeSystemPrompt,
"{claude_code_expansion_prompt}", expansionPrompt,
@@ -485,9 +485,13 @@ func (s *GatewayService) buildCountTokensRequest(ctx context.Context, c *gin.Con
}
}
// 同步 billing header cc_version 与实际发送的 User-Agent 版本
if ctFingerprint != nil && ctEnableFP {
body = syncBillingHeaderVersion(body, ctFingerprint.UserAgent)
// Disabled fingerprint unification does not disable forced mimicry headers.
var billingFingerprint *Fingerprint
if ctEnableFP {
billingFingerprint = ctFingerprint
}
if billingUA := effectiveBillingUserAgent(tokenType, mimicClaudeCode, billingFingerprint); billingUA != "" {
body = syncBillingHeaderVersion(body, billingUA)
}
// === 计算最终 anthropic-beta header(先于 body sanitize 与 CCH 签名)===
@@ -2,6 +2,7 @@ package service
import (
"context"
"encoding/json"
"fmt"
"github.com/Wei-Shaw/sub2api/internal/pkg/claude"
@@ -157,5 +158,8 @@ func injectCacheControlOnLastContentBlock(body []byte, idx int, msg *gjson.Resul
// mustJSONString 把一个 Go string 序列化为合法 JSON string(含引号),
// 用于 sjson.SetRawBytes 场景下手工拼 JSON。
func mustJSONString(s string) string {
return fmt.Sprintf("%q", s)
// Go string quoting can emit non-JSON escapes such as \x7f or \a.
// Marshaling a string cannot fail.
encoded, _ := json.Marshal(s)
return string(encoded)
}
@@ -0,0 +1,59 @@
//go:build unit
package service
import (
"encoding/json"
"strings"
"testing"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestAddMessageCacheBreakpoints_JSONStringEscaping(t *testing.T) {
var controls strings.Builder
for r := rune(0); r <= 0x9f; r++ {
controls.WriteRune(r)
}
tests := []struct{ name, text string }{
{"delete_character", "before\x7fafter"},
{"bell_and_vertical_tab", "before\a\vafter"},
{"nul_and_escape", "before\x00\x1bafter"},
{"all_ascii_and_c1_controls", controls.String()},
{"supplementary_nonprinting_rune", "before\U000e0067after"},
{"unicode_and_json_metacharacters", "中文 😀 <>& \"quote\" \\slash\n\r\t\b\f"},
{"literal_backslash_sequences", `literal \x7f \a \v \U000e0067`},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Both the final message and the second-to-last user turn are promoted
// from strings to text blocks by the cache rewrite.
body, err := json.Marshal(map[string]any{
"messages": []map[string]any{
{"role": "user", "content": tt.text},
{"role": "assistant", "content": "ack"},
{"role": "user", "content": "next"},
{"role": "assistant", "content": tt.text},
},
})
require.NoError(t, err)
out := addMessageCacheBreakpoints(body)
// gjson accepts some malformed input, so validate with encoding/json
// before inspecting the rewritten content and cache metadata.
var decoded any
require.NoError(t, json.Unmarshal(out, &decoded))
for _, path := range []string{"messages.0.content", "messages.3.content"} {
content := gjson.GetBytes(out, path)
require.True(t, content.IsArray())
require.Len(t, content.Array(), 1)
block := content.Array()[0]
require.Equal(t, "text", block.Get("type").String())
require.Equal(t, tt.text, block.Get("text").String())
require.Equal(t, "ephemeral", block.Get("cache_control.type").String())
require.Equal(t, "5m", block.Get("cache_control.ttl").String())
}
require.JSONEq(t, string(out), string(addMessageCacheBreakpoints(out)))
})
}
}
@@ -3515,3 +3515,74 @@ func TestGatewayService_ResolveGatewayGroup_DetectsFallbackCycle(t *testing.T) {
require.Nil(t, gotID)
require.Contains(t, err.Error(), "fallback group cycle")
}
func TestModelRoutingAppliesToPlatform(t *testing.T) {
for _, tc := range []struct {
name string
targetPlatform string
groupPlatform string
want bool
}{
{"anthropic group", PlatformAnthropic, PlatformAnthropic, true},
{"openai group", PlatformOpenAI, PlatformOpenAI, true},
{"composite group resolved to anthropic", PlatformAnthropic, PlatformComposite, true},
{"composite group resolved to openai", PlatformOpenAI, PlatformComposite, true},
{"target platform outside the allowed set", PlatformGemini, PlatformGemini, false},
{"composite group resolved outside the allowed set", PlatformGemini, PlatformComposite, false},
{"group platform does not match target", PlatformOpenAI, PlatformAnthropic, false},
{"empty target platform", "", PlatformOpenAI, false},
} {
t.Run(tc.name, func(t *testing.T) {
require.Equal(t, tc.want, modelRoutingAppliesToPlatform(tc.targetPlatform, tc.groupPlatform))
})
}
}
// Model routing used to be read only for requests resolved to Anthropic, so an
// OpenAI group's rules were stored and displayed but never honored. Routing an
// OpenAI request must pick the routed account instead of the group-wide winner.
func TestGatewayService_SelectAccountForModelWithPlatform_RoutedOpenAIGroup(t *testing.T) {
ctx := context.Background()
groupID := int64(13)
requestedModel := "gpt-6-astra"
repo := &mockAccountRepoForPlatform{
accounts: []Account{
// Priority 1 would win any group-wide selection, but it is not routed.
{ID: 1, Platform: PlatformOpenAI, Priority: 1, Status: StatusActive, Schedulable: true},
{ID: 2, Platform: PlatformOpenAI, Priority: 5, Status: StatusActive, Schedulable: true},
},
accountsByID: map[int64]*Account{},
}
for i := range repo.accounts {
repo.accountsByID[repo.accounts[i].ID] = &repo.accounts[i]
}
groupRepo := &mockGroupRepoForGateway{
groups: map[int64]*Group{
groupID: {
ID: groupID,
Name: "openai-route-group",
Platform: PlatformOpenAI,
Status: StatusActive,
Hydrated: true,
ModelRoutingEnabled: true,
ModelRouting: map[string][]int64{
requestedModel: {2},
},
},
},
}
svc := &GatewayService{
accountRepo: repo,
cache: &mockGatewayCacheForPlatform{},
cfg: testConfig(),
groupRepo: groupRepo,
}
acc, err := svc.selectAccountForModelWithPlatform(ctx, &groupID, "", requestedModel, nil, PlatformOpenAI)
require.NoError(t, err)
require.NotNil(t, acc)
require.Equal(t, int64(2), acc.ID, "routed account must win over the higher-priority unrouted one")
}
+49 -2
View File
@@ -1388,6 +1388,9 @@ func NormalizeGLMOpenAIReasoningEffort(body []byte, mappedModel string) ([]byte,
}
mapped := normalizeGLMOpenAIReasoningEffort(raw)
if isGLM53Model(mappedModel) && mapped == "high" && normalizeEffortToken(raw) == "low" {
mapped = "low"
}
if mapped == "" || mapped == raw {
return body, false
}
@@ -1399,12 +1402,20 @@ func NormalizeGLMOpenAIReasoningEffort(body []byte, mappedModel string) ([]byte,
return modified, true
}
func normalizeGLMOpenAIReasoningEffort(raw string) string {
func normalizeEffortToken(raw string) string {
value := strings.ToLower(strings.TrimSpace(raw))
return strings.NewReplacer("-", "", "_", "", " ", "").Replace(value)
}
func isGLM53Model(model string) bool {
return strings.EqualFold(strings.TrimSpace(model), "glm-5.3")
}
func normalizeGLMOpenAIReasoningEffort(raw string) string {
value := normalizeEffortToken(raw)
if value == "" {
return ""
}
value = strings.NewReplacer("-", "", "_", "", " ", "").Replace(value)
switch value {
case "low", "medium", "high":
@@ -1416,6 +1427,42 @@ func normalizeGLMOpenAIReasoningEffort(raw string) string {
}
}
// NormalizeGLM53AnthropicThinking maps explicit client thinking effort onto the
// GLM-5.3 Anthropic-compatible scale. Requests without an effort or thinking
// preference are left unchanged so the upstream default remains in effect.
func NormalizeGLM53AnthropicThinking(body []byte, mappedModel string) ([]byte, bool) {
if !isGLM53Model(mappedModel) {
return body, false
}
raw := gjson.GetBytes(body, "output_config.effort").String()
if strings.TrimSpace(raw) == "" {
raw = gjson.GetBytes(body, "thinking.type").String()
}
var effort string
switch normalizeEffortToken(raw) {
case "disabled", "off", "none", "minimal", "low":
effort = "low"
case "enabled", "adaptive", "medium", "high":
effort = "high"
case "xhigh", "max", "ultra":
effort = "max"
default:
return body, false
}
modified, err := sjson.SetBytes(body, "thinking.type", "enabled")
if err != nil {
return body, false
}
modified, err = sjson.SetBytes(modified, "output_config.effort", effort)
if err != nil {
return body, false
}
return modified, true
}
// =========================
// Thinking Budget Rectifier
// =========================
@@ -1605,6 +1605,14 @@ func TestNormalizeGLMOpenAIReasoningEffort(t *testing.T) {
wantPath: "reasoning_effort",
wantValue: "high",
},
{
name: "glm 5.2 low maps to high",
model: "glm-5.2",
input: `{"model":"glm-5.2","reasoning_effort":"low","messages":[]}`,
wantApplied: true,
wantPath: "reasoning_effort",
wantValue: "high",
},
{
name: "nested high case-normalizes",
model: "glm-5.2",
@@ -1613,6 +1621,13 @@ func TestNormalizeGLMOpenAIReasoningEffort(t *testing.T) {
wantPath: "reasoning.effort",
wantValue: "high",
},
{
name: "glm 5.3 native low unchanged",
model: "glm-5.3",
input: `{"model":"glm-5.3","reasoning_effort":"low","messages":[]}`,
wantApplied: false,
wantUnchanged: true,
},
{
name: "native max unchanged",
model: "glm-5.2",
+58 -9
View File
@@ -216,7 +216,7 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro
return nil, err
}
preferOAuth := platform == PlatformGemini
if s.debugModelRoutingEnabled() && platform == PlatformAnthropic && requestedModel != "" {
if s.debugModelRoutingEnabled() && requestedModel != "" && modelRoutingAppliesToTargetPlatform(platform) {
logger.LegacyPrintf("service.gateway", "[ModelRoutingDebug] load-aware enabled: group_id=%v model=%s session=%s platform=%s", derefGroupID(groupID), requestedModel, shortSessionHash(sessionHash), platform)
}
@@ -250,10 +250,10 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro
return needsUpstreamCheck && s.isUpstreamModelRestrictedByChannel(ctx, *groupID, account, requestedModel)
}
// 获取模型路由配置(anthropic 目标平台;composite 分组按目标平台判断)
// 获取模型路由配置(anthropic / openai 目标平台;composite 分组按目标平台判断)
var routingAccountIDs []int64
if group != nil && requestedModel != "" && platform == PlatformAnthropic &&
(group.Platform == PlatformAnthropic || group.Platform == PlatformComposite) {
if group != nil && requestedModel != "" &&
modelRoutingAppliesToPlatform(platform, group.Platform) {
routingAccountIDs = group.GetRoutingAccountIDs(requestedModel)
if s.debugModelRoutingEnabled() {
logger.LegacyPrintf("service.gateway", "[ModelRoutingDebug] context group routing: group_id=%d model=%s enabled=%v rules=%d matched_ids=%v session=%s sticky_account=%d",
@@ -865,8 +865,35 @@ func (s *GatewayService) ResolveGroupByID(ctx context.Context, groupID int64) (*
return s.resolveGroupByID(ctx, groupID)
}
// modelRoutingAppliesToPlatform 判定模型路由规则是否适用于本次请求。
//
// 路由规则的存储与查表(Group.ModelRouting / GetRoutingAccountIDs)本身与平台无关,
// 早期只有 Anthropic 目标平台会读取它。OpenAI 分组同样存在“同一个公开别名由不同账号
// 映射到不同上游模型”的故障转移写法,需要显式路由来指定谁是主、谁是备,因此这里把
// Anthropic 与 OpenAI 一并放行。
//
// targetPlatform 是请求解析后的目标平台;groupPlatform 是分组自身的平台。composite
// 分组不限定自身平台,只要目标平台落在放行集合内即可复用其规则。
func modelRoutingAppliesToPlatform(targetPlatform, groupPlatform string) bool {
if !modelRoutingAppliesToTargetPlatform(targetPlatform) {
return false
}
return groupPlatform == targetPlatform || groupPlatform == PlatformComposite
}
// modelRoutingAppliesToTargetPlatform 是放行平台集合的唯一定义处:新增平台只改这里。
// 在只知道目标平台、还没取到分组的位置(调试日志、取分组前的短路)单独判定用。
func modelRoutingAppliesToTargetPlatform(targetPlatform string) bool {
switch targetPlatform {
case PlatformAnthropic, PlatformOpenAI:
return true
default:
return false
}
}
func (s *GatewayService) routingAccountIDsForRequest(ctx context.Context, groupID *int64, requestedModel string, platform string) []int64 {
if groupID == nil || requestedModel == "" || platform != PlatformAnthropic {
if groupID == nil || requestedModel == "" || !modelRoutingAppliesToTargetPlatform(platform) {
return nil
}
group, err := s.resolveGroupByID(ctx, *groupID)
@@ -876,11 +903,11 @@ func (s *GatewayService) routingAccountIDsForRequest(ctx context.Context, groupI
}
return nil
}
// Model routing applies only to requests resolved to Anthropic. Composite
// groups may still use those rules once their model resolved to Anthropic.
if group.Platform != PlatformAnthropic && group.Platform != PlatformComposite {
// 路由规则适用于解析到 Anthropic 或 OpenAI 的请求;composite 分组在其模型解析到
// 上述平台后同样可以复用这些规则。
if !modelRoutingAppliesToPlatform(platform, group.Platform) {
if s.debugModelRoutingEnabled() {
logger.LegacyPrintf("service.gateway", "[ModelRoutingDebug] skip: non-anthropic group platform: group_id=%d group_platform=%s model=%s", group.ID, group.Platform, requestedModel)
logger.LegacyPrintf("service.gateway", "[ModelRoutingDebug] skip: group platform not eligible: group_id=%d group_platform=%s target_platform=%s model=%s", group.ID, group.Platform, platform, requestedModel)
}
return nil
}
@@ -1462,6 +1489,28 @@ func (s *GatewayService) checkAndRegisterSession(ctx context.Context, account *A
return allowed
}
// ReleaseAccountSession 立即释放会话槽(不等待空闲超时)
// 供 handler 在请求最终失败(选号成功但转发失败/客户端中断)时调用:
// 上游从未真正服务该会话,若继续占槽,max_sessions 受限的账号会被失败请求的
// session hash 卡满整个空闲窗口,后续新会话全部被拒。
// 适用条件与 checkAndRegisterSession 对齐;不适用账号为 no-op,幂等可安全重复调用。
func (s *GatewayService) ReleaseAccountSession(ctx context.Context, account *Account, sessionID string) {
if s == nil || s.sessionLimitCache == nil || account == nil || sessionID == "" {
return
}
if !account.IsAnthropicOAuthOrSetupToken() {
return
}
if account.GetMaxSessions() <= 0 {
return
}
if err := s.sessionLimitCache.UnregisterSession(ctx, account.ID, sessionID); err != nil {
slog.Debug("session_limit.release_failed",
"account_id", account.ID,
"error", err)
}
}
func (s *GatewayService) getSchedulableAccount(ctx context.Context, accountID int64) (*Account, error) {
var (
account *Account
@@ -84,9 +84,9 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex
}
}
// 同步 billing header cc_version 与实际发送的 User-Agent 版本
if fingerprint != nil {
body = syncBillingHeaderVersion(body, fingerprint.UserAgent)
// Mimicry may override the cached User-Agent later, even without a fingerprint.
if billingUA := effectiveBillingUserAgent(tokenType, mimicClaudeCode, fingerprint); billingUA != "" {
body = syncBillingHeaderVersion(body, billingUA)
}
// === 计算最终 anthropic-beta header(先于 body sanitize 与 CCH 签名)===
@@ -285,6 +285,12 @@ func ExtractUpstreamErrorMessage(body []byte) string {
return extractUpstreamErrorMessage(body)
}
// SanitizeUpstreamErrorMessage redacts sensitive query parameter values before
// an upstream error message is recorded or returned to a client.
func SanitizeUpstreamErrorMessage(message string) string {
return sanitizeUpstreamErrorMessage(message)
}
func extractUpstreamErrorMessage(body []byte) string {
// Claude 风格:{"type":"error","error":{"type":"...","message":"..."}}
if m := gjson.GetBytes(body, "error.message").String(); strings.TrimSpace(m) != "" {
@@ -1092,15 +1092,16 @@ func (s *GatewayService) calculateTokenCost(
}
cost, err := s.billingService.CalculateTokenCostForRequest(TokenCostRequest{
Ctx: ctx,
Model: billingModel,
Group: apiKey.Group,
Tokens: tokens,
RateMultiplier: multiplier,
PricingAt: pricingAt,
ServiceTier: optionalStringValue(result.ServiceTier),
Resolver: s.resolver,
Resolved: resolved,
Ctx: ctx,
Model: billingModel,
Group: apiKey.Group,
Tokens: tokens,
RateMultiplier: multiplier,
PricingAt: pricingAt,
ServiceTier: optionalStringValue(result.ServiceTier),
ReasoningEffort: optionalStringValue(result.ReasoningEffort),
Resolver: s.resolver,
Resolved: resolved,
})
if err != nil {
logger.LegacyPrintf("service.gateway", "Calculate cost failed: %v", err)
+8 -2
View File
@@ -107,7 +107,7 @@ type Group struct {
DefaultMappedModel string
MessagesDispatchModelConfig OpenAIMessagesDispatchModelConfig
ModelAllowlist GroupModelAllowlist
// CodexModelsManifestConfig 开启后,该分组的 Codex /models manifest 请求只用
// CodexModelsManifestConfig 开启后,普通模型列表与 Codex manifest 优先使用
// 固定账号列表拉取并合并,不经过调度器(仅 openai 平台)。
CodexModelsManifestConfig GroupCodexModelsManifestConfig
@@ -115,7 +115,7 @@ type Group struct {
// 一旦设置即接管该分组用户的限流(覆盖用户级 rpm_limit),可被 user-group rpm_override 进一步覆盖。
RPMLimit int
// MaxReasoningEffort limits the effective OpenAI/Codex reasoning effort.
// MaxReasoningEffort limits the effective Anthropic/OpenAI reasoning effort.
// Empty means unlimited; supported values are minimal/low/medium/high/xhigh/max.
MaxReasoningEffort string
// MaxReasoningEffortOverLimit is the access control when an explicit effort
@@ -141,6 +141,12 @@ type Group struct {
RateLimitedAccountCount int64
}
// IsGroupBindableInSimpleMode is the shared policy for groups that may be
// surfaced and bound to accounts while running in simple mode.
func IsGroupBindableInSimpleMode(group *Group) bool {
return group != nil && group.Platform != PlatformComposite
}
func (g *Group) IsActive() bool {
return g.Status == StatusActive
}
@@ -11,6 +11,7 @@ import (
var (
ErrGroupNotFound = infraerrors.NotFound("GROUP_NOT_FOUND", "group not found")
ErrGroupExists = infraerrors.Conflict("GROUP_EXISTS", "group name already exists")
ErrGroupNotEmpty = infraerrors.Conflict("GROUP_NOT_EMPTY", "group contains accounts")
)
type GroupRepository interface {
@@ -51,6 +52,12 @@ type GroupDuplicateRepository interface {
type AdminGroupRepository interface {
GroupRepository
GroupDuplicateRepository
EmptyGroupDeleteRepository
}
// EmptyGroupDeleteRepository provides the guarded cascade used by simple mode.
type EmptyGroupDeleteRepository interface {
DeleteCascadeIfEmpty(ctx context.Context, id int64) ([]int64, error)
}
// GroupSortOrderUpdate 分组排序更新
@@ -14,6 +14,7 @@ const (
type httpUpstreamProfileContextKey struct{}
type httpUpstreamDisableRedirectsContextKey struct{}
type httpUpstreamPublicHostsOnlyContextKey struct{}
// WithHTTPUpstreamProfile injects an upstream transport profile into ctx.
func WithHTTPUpstreamProfile(ctx context.Context, profile HTTPUpstreamProfile) context.Context {
@@ -55,3 +56,18 @@ func WithHTTPUpstreamRedirectsDisabled(ctx context.Context) context.Context {
func HTTPUpstreamRedirectsDisabled(ctx context.Context) bool {
return ctx != nil && ctx.Value(httpUpstreamDisableRedirectsContextKey{}) == true
}
// WithHTTPUpstreamPublicHostsOnly marks a request whose destination, and every
// redirect hop after it, must resolve to a public address. The shared upstream
// client enforces it regardless of the security.url_allowlist configuration;
// use it for fetches whose URL comes from an untrusted upstream response.
func WithHTTPUpstreamPublicHostsOnly(ctx context.Context) context.Context {
if ctx == nil {
ctx = context.Background()
}
return context.WithValue(ctx, httpUpstreamPublicHostsOnlyContextKey{}, true)
}
func HTTPUpstreamPublicHostsOnly(ctx context.Context) bool {
return ctx != nil && ctx.Value(httpUpstreamPublicHostsOnlyContextKey{}) == true
}
@@ -30,3 +30,17 @@ func TestWithHTTPUpstreamRedirectsDisabled(t *testing.T) {
t.Fatal("redirects should remain enabled by default")
}
}
func TestWithHTTPUpstreamPublicHostsOnly(t *testing.T) {
//nolint:staticcheck // Exercises the defensive nil-context fallback.
ctx := WithHTTPUpstreamPublicHostsOnly(nil)
if !HTTPUpstreamPublicHostsOnly(ctx) {
t.Fatal("expected public-hosts-only marker to be set")
}
if HTTPUpstreamPublicHostsOnly(context.Background()) {
t.Fatal("marker must be absent by default")
}
if HTTPUpstreamRedirectsDisabled(ctx) {
t.Fatal("public-hosts-only must not disable redirects")
}
}
+64 -9
View File
@@ -28,6 +28,10 @@ var (
// <product>/<major>.<minor>.<patch> 之后必须紧跟空白或字符串结束。
// 版本号带 -local / -dev / +build 等后缀的本地构建一律不接受。
fingerprintUserAgentPattern = regexp.MustCompile(`^[A-Za-z0-9._-]+/\d+\.\d+\.\d+(\s|$)`)
// claudeCLIUAVersionPrefixRegex 匹配 claude-cli UA 开头的 "claude-cli/x.y.z" 版本号段,
// 供版本下限抬升时就地替换版本号使用;UA 其余部分(括号内的真实客户端形态等)原样保留。
claudeCLIUAVersionPrefixRegex = regexp.MustCompile(`(?i)^(claude-cli)/\d+\.\d+\.\d+`)
)
const (
@@ -36,7 +40,7 @@ const (
// maxFingerprintUserAgentLength 限制写入缓存的 User-Agent 长度。
maxFingerprintUserAgentLength = 256
// maxClaudeCLIMajorVersionSkew 是 claude-cli 主版本号相对 sub2api 自身伪装
// 版本(claude.CLICurrentVersion)允许的最大超前量。给足两个大版本的升级
// 版本(claude.CLIVersion())允许的最大超前量。给足两个大版本的升级
// 窗口,同时挡掉 999 这类哨兵版本号。
maxClaudeCLIMajorVersionSkew = 2
)
@@ -68,16 +72,48 @@ func isAcceptableFingerprintUserAgent(ua string) bool {
if !ok {
return false
}
currentMajor, _, _, currentOK := parseUserAgentVersion(claudeCLIUserAgentProduct + "/" + claude.CLICurrentVersion)
currentMajor, _, _, currentOK := parseUserAgentVersion(claudeCLIUserAgentProduct + "/" + claude.CLIVersion())
if !currentOK {
return true
}
return major <= currentMajor+maxClaudeCLIMajorVersionSkew
}
// floorClaudeCLIUserAgentVersion 把 claude-cli UA 中的版本号抬升到 claude.CLICurrentVersion
// 下限:低于下限时就地升到下限并返回 changed=true,否则原样返回。
//
// 为什么需要这条:账号级指纹"只升不降"、活跃账号懒续期后近乎永不过期,且系统内没有重置
// 入口,defaultFingerprint 只在首次创建指纹时使用。存量账号缓存里的 claude-cli 版本停留在
// 历史值(如 claude-cli/2.1.220),客户端送来更旧的版本时 isNewerVersion 不会触发升级——
// 仅升 CLICurrentVersion 常量对所有已有账号完全无效,上游按指纹 UA 做客户端版本闸门
// (如 Fable 5.1 要求 >= 2.1.251)时旧指纹永远过不去。
//
// 约束:
// - 只对 claude-cli 产品生效,其它产品一律不动,避免误伤别的合法客户端;
// - 只升不降:版本等于或高于 CLICurrentVersion(含客户端上报的更新版本)时不做任何改动;
// - 只替换 claude-cli/ 后的版本号段,UA 其余部分(如 "(external, claude-desktop-3p,
// agent-sdk/0.3.100)")原样保留,不重建整个字符串、不退化为 defaultFingerprint.UserAgent;
// - X-Stainless-* 字段不在此处理,维持调用方的既有 merge 语义。
func floorClaudeCLIUserAgentVersion(ua string) (string, bool) {
if extractProduct(ua) != claudeCLIUserAgentProduct {
return ua, false
}
floorUA := claudeCLIUserAgentProduct + "/" + claude.CLICurrentVersion
// isNewerVersion(floor, ua) 为 true 当且仅当下限版本严格高于 ua:
// ua 等于或高于下限、产品名不一致、或版本无法解析时都不做改动。
if !isNewerVersion(floorUA, ua) {
return ua, false
}
floored := claudeCLIUAVersionPrefixRegex.ReplaceAllString(ua, "${1}/"+claude.CLICurrentVersion)
if floored == ua {
return ua, false
}
return floored, true
}
// 默认指纹值(当客户端未提供时使用)
var defaultFingerprint = Fingerprint{
UserAgent: "claude-cli/" + claude.CLICurrentVersion + " (external, cli)",
UserAgent: "claude-cli/" + claude.CLIVersion() + " (external, cli)",
StainlessLang: "js",
StainlessPackageVersion: "0.94.0",
StainlessOS: "Linux",
@@ -157,12 +193,29 @@ func (s *IdentityService) GetOrCreateFingerprint(ctx context.Context, accountID
logger.LegacyPrintf("service.identity",
"Replaced malformed cached fingerprint for account %d: %q -> %q",
accountID, poisoned, cached.UserAgent)
} else if uaAcceptable && isNewerVersion(clientUA, cached.UserAgent) {
// 版本升级:merge 语义 — 仅更新请求中实际携带的字段,保留缓存值
// 避免缺失的头被硬编码默认值覆盖(如新 CLI 版本 + 旧 SDK 默认值的不一致)
mergeHeadersIntoFingerprint(cached, headers)
} else {
// 客户端送来更新版本时的常规升级:merge 语义 — 仅更新请求中实际携带的字段,
// 保留缓存值,避免缺失的头被硬编码默认值覆盖(如新 CLI 版本 + 旧 SDK 默认值的不一致)
if uaAcceptable && isNewerVersion(clientUA, cached.UserAgent) {
mergeHeadersIntoFingerprint(cached, headers)
needWrite = true
logger.LegacyPrintf("service.identity", "Updated fingerprint for account %d: %s (merge update)", accountID, clientUA)
}
}
// 版本下限抬升(floor):heal 与 healthy 两条缓存命中路径共同的后置条件,与
// 上面的客户端常规升级相互独立、二者取更新者。客户端送来更旧版本时
// isNewerVersion 不触发,此处仍能把低于 CLICurrentVersion 的存量指纹就地抬到
// 下限并持久化,否则升 CLICurrentVersion 对所有已有账号无效,新模型的客户端
// 版本闸门(如 Fable 5.1 要求 >= 2.1.251)永远过不去。自愈路径同样必须经过:
// 畸形缓存 + 合法但过旧的客户端 UA 首次读取就要落到下限,不能先落库一个
// 过旧版本、等下一次读取才纠正。
if flooredUA, changed := floorClaudeCLIUserAgentVersion(cached.UserAgent); changed {
cached.UserAgent = flooredUA
needWrite = true
logger.LegacyPrintf("service.identity", "Updated fingerprint for account %d: %s (merge update)", accountID, clientUA)
logger.LegacyPrintf("service.identity",
"Floored cached fingerprint claude-cli version for account %d: %s", accountID, flooredUA)
}
if !needWrite && time.Since(time.Unix(cached.UpdatedAt, 0)) > 24*time.Hour {
@@ -208,7 +261,9 @@ func (s *IdentityService) createFingerprintFromHeaders(headers http.Header) *Fin
// 获取User-Agent:只接受形态合法且版本合理的值,否则回退默认指纹。
// 首次创建同样是持久化写入,必须与升级路径共用同一套校验。
if ua := strings.TrimSpace(headers.Get("User-Agent")); isAcceptableFingerprintUserAgent(ua) {
fp.UserAgent = ua
// 首次创建与缓存命中路径共用同一个版本下限:合法但过旧的 claude-cli UA
// 落库时同样不能低于 CLICurrentVersion,否则新账号一开始就带着过旧的持久身份。
fp.UserAgent, _ = floorClaudeCLIUserAgentVersion(ua)
} else {
fp.UserAgent = defaultFingerprint.UserAgent
}
@@ -105,8 +105,9 @@ func TestGetOrCreateFingerprintRejectsMalformedUserAgentOnCreate(t *testing.T) {
// 升级路径:哨兵版本号不得覆盖已缓存的真实指纹。
// isNewerVersion 是纯数值比较,999.0.0 恒大于任何真实版本,一旦写入永远无法夺回。
func TestGetOrCreateFingerprintRejectsSentinelVersionOnUpgrade(t *testing.T) {
// 缓存版本取 CLICurrentVersion(>= 下限),使本用例只验证哨兵拒绝,不与版本下限抬升耦合。
cached := &Fingerprint{
UserAgent: "claude-cli/2.1.22 (external, cli)",
UserAgent: "claude-cli/" + claude.CLICurrentVersion + " (external, cli)",
ClientID: "cid-1",
UpdatedAt: time.Now().Unix(),
}
@@ -119,7 +120,7 @@ func TestGetOrCreateFingerprintRejectsSentinelVersionOnUpgrade(t *testing.T) {
)
require.NoError(t, err)
require.Equal(t, "claude-cli/2.1.22 (external, cli)", fp.UserAgent,
require.Equal(t, "claude-cli/"+claude.CLICurrentVersion+" (external, cli)", fp.UserAgent,
"真实指纹不得被哨兵版本覆盖")
require.Zero(t, cache.setCalls, "被拒的 UA 不应触发任何写入")
}
@@ -133,7 +134,9 @@ func TestGetOrCreateFingerprintStillUpgradesOnValidNewerVersion(t *testing.T) {
}}
svc := NewIdentityService(cache)
newUA := "claude-cli/2.1.223 (external, cli)"
// 客户端版本取高于 CLICurrentVersion 的合法值,使本用例只验证常规升级,
// 不与版本下限抬升耦合(低于下限的客户端升级由 version_floor 测试覆盖)。
newUA := "claude-cli/2.9.0 (external, cli)"
fp, err := svc.GetOrCreateFingerprint(context.Background(), 1, headersWithUA(newUA))
require.NoError(t, err)
@@ -176,8 +179,10 @@ func TestGetOrCreateFingerprintHealsPoisonedCacheUsingValidClientUA(t *testing.T
fp, err := svc.GetOrCreateFingerprint(context.Background(), 1, headersWithUA(realUA))
require.NoError(t, err)
require.Equal(t, realUA, fp.UserAgent,
"真实客户端必须能从被毒化的指纹手中夺回账号身份")
// 真实客户端从被毒化的指纹手中夺回账号身份,但版本下限在自愈的同一读取内生效:
// 合法而过旧的 UA 不能先以过旧版本落库、等下次读取才被抬升。
require.Equal(t, "claude-cli/"+claude.CLICurrentVersion+" (external, cli)", fp.UserAgent)
require.Equal(t, fp.UserAgent, cache.lastSet.UserAgent, "自愈与下限抬升应合并为同一次落库")
require.Equal(t, 1, cache.setCalls)
require.NotContains(t, cache.lastSet.UserAgent, "999.0.0")
require.Equal(t, "cid-1", fp.ClientID, "自愈不应重置 ClientID")
@@ -203,9 +208,10 @@ func TestGetOrCreateFingerprintHealsPoisonedCacheWithoutValidClientUA(t *testing
}
// 自愈只针对畸形缓存:合法缓存 + 非更新版本的合法 UA 不得触发额外写入。
// 缓存版本取 CLICurrentVersion(>= 下限),低于下限的缓存抬升由 version_floor 测试覆盖。
func TestGetOrCreateFingerprintDoesNotRewriteHealthyCache(t *testing.T) {
cache := &stubIdentityCache{fingerprint: &Fingerprint{
UserAgent: "claude-cli/2.1.220 (external, cli)",
UserAgent: "claude-cli/" + claude.CLICurrentVersion + " (external, cli)",
ClientID: "cid-1",
UpdatedAt: time.Now().Unix(),
}}
@@ -217,7 +223,7 @@ func TestGetOrCreateFingerprintDoesNotRewriteHealthyCache(t *testing.T) {
)
require.NoError(t, err)
require.Equal(t, "claude-cli/2.1.220 (external, cli)", fp.UserAgent)
require.Equal(t, "claude-cli/"+claude.CLICurrentVersion+" (external, cli)", fp.UserAgent)
require.Zero(t, cache.setCalls)
}
@@ -0,0 +1,146 @@
package service
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/Wei-Shaw/sub2api/internal/pkg/claude"
)
// floorClaudeCLIUserAgentVersion 单元测试:版本下限抬升的各种形态。
func TestFloorClaudeCLIUserAgentVersion(t *testing.T) {
floorUA := "claude-cli/" + claude.CLICurrentVersion
cases := []struct {
name string
ua string
want string
wantChanged bool
}{
// 线上故障形态:历史版本低于 CLICurrentVersion,就地抬到下限。
{"old_version_upgraded", "claude-cli/2.1.220 (external, cli)", floorUA + " (external, cli)", true},
// 只替换版本号段,括号内的真实客户端形态原样保留。
{"old_version_with_desktop_3p_suffix",
"claude-cli/2.1.100 (external, claude-desktop-3p, agent-sdk/0.3.100)",
floorUA + " (external, claude-desktop-3p, agent-sdk/0.3.100)", true},
// 等于下限:不得改动。
{"equal_to_floor", floorUA + " (external, cli)", floorUA + " (external, cli)", false},
// 高于下限:只升不降,不得把客户端上报的更新版本压回去。
{"newer_than_floor_not_downgraded", "claude-cli/2.9.0 (external, cli)", "claude-cli/2.9.0 (external, cli)", false},
// 非 claude-cli 产品:一律不动。
{"other_product_untouched", "opencode/1.2.3 (external, cli)", "opencode/1.2.3 (external, cli)", false},
// 空串 / 畸形:一律不动。
{"empty", "", "", false},
{"no_version", "claude-cli (external, cli)", "claude-cli (external, cli)", false},
{"unparseable_version", "claude-cli/abc (external, cli)", "claude-cli/abc (external, cli)", false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, changed := floorClaudeCLIUserAgentVersion(tc.ua)
require.Equal(t, tc.want, got)
require.Equal(t, tc.wantChanged, changed)
})
}
}
// GetOrCreateFingerprint 集成行为,直接对应线上故障:缓存指纹停留在历史版本
// 2.1.220(生产 Redis 中账号 147 的实际值),客户端送来更旧的 2.1.75。
// 修复前:isNewerVersion 不触发、UA 形态合法不触发自愈,旧指纹被原样返回并
// 近乎永不过期——上游按指纹 UA 做客户端版本闸门(Fable 5.1 要求 >= 2.1.251),
// 仅升 CLICurrentVersion 对存量账号完全无效。
func TestGetOrCreateFingerprintFloorsStaleCachedUserAgent(t *testing.T) {
cache := &stubIdentityCache{fingerprint: &Fingerprint{
UserAgent: "claude-cli/2.1.220 (external, cli)",
ClientID: "cid-1",
StainlessPackageVersion: "0.91.1",
UpdatedAt: time.Now().Unix(),
}}
svc := NewIdentityService(cache)
fp, err := svc.GetOrCreateFingerprint(
context.Background(), 147,
headersWithUA("claude-cli/2.1.75 (external, cli)"),
)
require.NoError(t, err)
require.Equal(t, "claude-cli/"+claude.CLICurrentVersion+" (external, cli)", fp.UserAgent)
require.Equal(t, 1, cache.setCalls, "下限抬升必须持久化写回缓存")
require.Equal(t, "claude-cli/"+claude.CLICurrentVersion+" (external, cli)", cache.lastSet.UserAgent)
// X-Stainless-* 维持既有 merge 语义:客户端未携带时保留缓存中的真实值,不被下限逻辑覆盖。
require.Equal(t, "0.91.1", fp.StainlessPackageVersion)
}
// 缓存版本高于下限:不得被降级,也不得触发多余写入。
func TestGetOrCreateFingerprintDoesNotTouchCacheAboveFloor(t *testing.T) {
ua := "claude-cli/2.9.0 (external, cli)"
cache := &stubIdentityCache{fingerprint: &Fingerprint{
UserAgent: ua,
ClientID: "cid-1",
StainlessPackageVersion: "0.91.1",
UpdatedAt: time.Now().Unix(),
}}
svc := NewIdentityService(cache)
fp, err := svc.GetOrCreateFingerprint(
context.Background(), 1,
headersWithUA("claude-cli/2.1.75 (external, cli)"),
)
require.NoError(t, err)
require.Equal(t, ua, fp.UserAgent, "高于下限的缓存版本不得被降级")
require.Zero(t, cache.setCalls)
}
// 客户端送来高于下限的更新版本:常规升级照常生效,且不被下限压回。
func TestGetOrCreateFingerprintClientNewerThanFloorStillWins(t *testing.T) {
newUA := "claude-cli/2.9.0 (external, cli)"
cache := &stubIdentityCache{fingerprint: &Fingerprint{
UserAgent: "claude-cli/2.1.220 (external, cli)",
ClientID: "cid-1",
UpdatedAt: time.Now().Unix(),
}}
svc := NewIdentityService(cache)
fp, err := svc.GetOrCreateFingerprint(context.Background(), 1, headersWithUA(newUA))
require.NoError(t, err)
require.Equal(t, newUA, fp.UserAgent, "客户端更新版本优先于下限,不得被压回")
require.Equal(t, 1, cache.setCalls)
}
// 客户端版本高于缓存但低于下限:merge 升级后仍被抬到下限(二者取更新者)。
func TestGetOrCreateFingerprintFloorWinsOverStaleClientUpgrade(t *testing.T) {
cache := &stubIdentityCache{fingerprint: &Fingerprint{
UserAgent: "claude-cli/2.1.22 (external, cli)",
ClientID: "cid-1",
UpdatedAt: time.Now().Unix(),
}}
svc := NewIdentityService(cache)
fp, err := svc.GetOrCreateFingerprint(
context.Background(), 1,
headersWithUA("claude-cli/2.1.223 (external, cli)"),
)
require.NoError(t, err)
require.Equal(t, "claude-cli/"+claude.CLICurrentVersion+" (external, cli)", fp.UserAgent)
require.Equal(t, 1, cache.setCalls)
}
// 首次创建路径同样受下限约束:合法但过旧的 claude-cli UA 落库时必须抬到 CLICurrentVersion。
func TestCreateFingerprintFromHeadersFloorsOldClientUserAgent(t *testing.T) {
cache := &stubIdentityCache{}
svc := NewIdentityService(cache)
fp, err := svc.GetOrCreateFingerprint(
context.Background(), 1,
headersWithUA("claude-cli/2.1.75 (external, claude-desktop-3p)"),
)
require.NoError(t, err)
require.Equal(t, "claude-cli/"+claude.CLICurrentVersion+" (external, claude-desktop-3p)", fp.UserAgent)
require.Equal(t, 1, cache.setCalls)
}
@@ -112,3 +112,29 @@ func TestIsOpenAICodexPlanGatedModelError(t *testing.T) {
})
}
}
func TestIsOpenAICompatibleModelNotFound400(t *testing.T) {
tests := []struct {
name string
body string
want bool
}{
{name: "structured code", body: `{"error":{"code":"model_not_found","message":"No such deployment"}}`, want: true},
{name: "unknown provider", body: `{"error":{"message":"Unknown provider for model claude-x"}}`, want: true},
{name: "model not found", body: `{"error":{"message":"Model not found: claude-x"}}`, want: true},
{name: "model unsupported", body: `{"error":{"message":"The requested model is not supported"}}`, want: true},
{name: "plain text compatible gateway", body: `unknown provider for model claude-x`, want: true},
{name: "invalid parameter remains terminal", body: `{"error":{"code":"invalid_request_error","message":"Invalid value for temperature"}}`, want: false},
{name: "structured non-matching code overrides model not found message", body: `{"error":{"code":"invalid_request_error","message":"Model not found: claude-x"}}`, want: false},
{name: "structured non-matching error code overrides unsupported message", body: `{"error":{"code":"upstream_error","message":"The requested model is not supported"}}`, want: false},
{name: "echoed phrase is ignored", body: `{"error":{"message":"Invalid request"},"echo":"model not found"}`, want: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := isOpenAICompatibleModelNotFound400([]byte(tt.body)); got != tt.want {
t.Fatalf("isOpenAICompatibleModelNotFound400() = %v, want %v", got, tt.want)
}
})
}
}

Some files were not shown because too many files have changed in this diff Show More