diff --git a/Makefile b/Makefile index b0bc1b99f6..86cdaa1711 100644 --- a/Makefile +++ b/Makefile @@ -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 \ diff --git a/backend/cmd/server/VERSION b/backend/cmd/server/VERSION index 0ea3a944b3..0c62199f16 100644 --- a/backend/cmd/server/VERSION +++ b/backend/cmd/server/VERSION @@ -1 +1 @@ -0.2.0 +0.2.1 diff --git a/backend/cmd/server/wire_gen.go b/backend/cmd/server/wire_gen.go index 5ec4892e16..c21b25f9d0 100644 --- a/backend/cmd/server/wire_gen.go +++ b/backend/cmd/server/wire_gen.go @@ -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) diff --git a/backend/internal/handler/admin/account_handler.go b/backend/internal/handler/admin/account_handler.go index 495b8e7f0e..754105c46b 100644 --- a/backend/internal/handler/admin/account_handler.go +++ b/backend/internal/handler/admin/account_handler.go @@ -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) diff --git a/backend/internal/handler/admin/account_handler_list_test.go b/backend/internal/handler/admin/account_handler_list_test.go index 6a10bb4dcc..f4304eb93d 100644 --- a/backend/internal/handler/admin/account_handler_list_test.go +++ b/backend/internal/handler/admin/account_handler_list_test.go @@ -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() diff --git a/backend/internal/handler/admin/account_handler_simple_mode_test.go b/backend/internal/handler/admin/account_handler_simple_mode_test.go new file mode 100644 index 0000000000..4326176d15 --- /dev/null +++ b/backend/internal/handler/admin/account_handler_simple_mode_test.go @@ -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"`) +} diff --git a/backend/internal/handler/admin/admin_helpers_test.go b/backend/internal/handler/admin/admin_helpers_test.go index c0775db6b6..980c26bab9 100644 --- a/backend/internal/handler/admin/admin_helpers_test.go +++ b/backend/internal/handler/admin/admin_helpers_test.go @@ -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) }) } diff --git a/backend/internal/handler/admin/admin_service_stub_test.go b/backend/internal/handler/admin/admin_service_stub_test.go index 4c3beb9b8a..c808d1a18c 100644 --- a/backend/internal/handler/admin/admin_service_stub_test.go +++ b/backend/internal/handler/admin/admin_service_stub_test.go @@ -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 diff --git a/backend/internal/handler/admin/channel_handler.go b/backend/internal/handler/admin/channel_handler.go index 9723e3595b..2a7052c745 100644 --- a/backend/internal/handler/admin/channel_handler.go +++ b/backend/internal/handler/admin/channel_handler.go @@ -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, }) } diff --git a/backend/internal/handler/admin/channel_handler_test.go b/backend/internal/handler/admin/channel_handler_test.go index cca67c9e47..ef0d5a9c8f 100644 --- a/backend/internal/handler/admin/channel_handler_test.go +++ b/backend/internal/handler/admin/channel_handler_test.go @@ -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) { diff --git a/backend/internal/handler/admin/grok_import_probe.go b/backend/internal/handler/admin/grok_import_probe.go index da547b82f8..8dad7160c2 100644 --- a/backend/internal/handler/admin/grok_import_probe.go +++ b/backend/internal/handler/admin/grok_import_probe.go @@ -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 } diff --git a/backend/internal/handler/admin/group_handler.go b/backend/internal/handler/admin/group_handler.go index 011518b92a..5a1b352473 100644 --- a/backend/internal/handler/admin/group_handler.go +++ b/backend/internal/handler/admin/group_handler.go @@ -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()) diff --git a/backend/internal/handler/admin/group_handler_simple_mode_test.go b/backend/internal/handler/admin/group_handler_simple_mode_test.go new file mode 100644 index 0000000000..76c3ce321f --- /dev/null +++ b/backend/internal/handler/admin/group_handler_simple_mode_test.go @@ -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) + }) + } +} diff --git a/backend/internal/handler/admin/redeem_handler.go b/backend/internal/handler/admin/redeem_handler.go index 5dca01d5ec..fcf0b6dcdf 100644 --- a/backend/internal/handler/admin/redeem_handler.go +++ b/backend/internal/handler/admin/redeem_handler.go @@ -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 } diff --git a/backend/internal/handler/available_channel_handler.go b/backend/internal/handler/available_channel_handler.go index 5d35ff389e..ffa2408bcd 100644 --- a/backend/internal/handler/available_channel_handler.go +++ b/backend/internal/handler/available_channel_handler.go @@ -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, } } diff --git a/backend/internal/handler/composite_platform.go b/backend/internal/handler/composite_platform.go index 49e87cb332..6e613aa3e4 100644 --- a/backend/internal/handler/composite_platform.go +++ b/backend/internal/handler/composite_platform.go @@ -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 diff --git a/backend/internal/handler/composite_platform_test.go b/backend/internal/handler/composite_platform_test.go index 73db844e7e..d9a642ce06 100644 --- a/backend/internal/handler/composite_platform_test.go +++ b/backend/internal/handler/composite_platform_test.go @@ -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()) diff --git a/backend/internal/handler/concurrency_error_response.go b/backend/internal/handler/concurrency_error_response.go index 911f5a54d7..cd631a47b4 100644 --- a/backend/internal/handler/concurrency_error_response.go +++ b/backend/internal/handler/concurrency_error_response.go @@ -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" } diff --git a/backend/internal/handler/concurrency_error_response_test.go b/backend/internal/handler/concurrency_error_response_test.go index a2e6b9ab93..2d1b3d6ad2 100644 --- a/backend/internal/handler/concurrency_error_response_test.go +++ b/backend/internal/handler/concurrency_error_response_test.go @@ -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) }) } diff --git a/backend/internal/handler/dto/mappers.go b/backend/internal/handler/dto/mappers.go index b1883815af..bb29600c67 100644 --- a/backend/internal/handler/dto/mappers.go +++ b/backend/internal/handler/dto/mappers.go @@ -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 diff --git a/backend/internal/handler/dto/types.go b/backend/internal/handler/dto/types.go index 2a804412af..7c952af6d2 100644 --- a/backend/internal/handler/dto/types.go +++ b/backend/internal/handler/dto/types.go @@ -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"` diff --git a/backend/internal/handler/gateway_handler.go b/backend/internal/handler/gateway_handler.go index 697c1d7845..686231bf32 100644 --- a/backend/internal/handler/gateway_handler.go +++ b/backend/internal/handler/gateway_handler.go @@ -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 } } diff --git a/backend/internal/handler/gateway_handler_responses.go b/backend/internal/handler/gateway_handler_responses.go index 04a61cb16e..a170877315 100644 --- a/backend/internal/handler/gateway_handler_responses.go +++ b/backend/internal/handler/gateway_handler_responses.go @@ -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 } diff --git a/backend/internal/handler/openai_automation_bootstrap_test.go b/backend/internal/handler/openai_automation_bootstrap_test.go index 7bb7fd68b1..698a3bbd5e 100644 --- a/backend/internal/handler/openai_automation_bootstrap_test.go +++ b/backend/internal/handler/openai_automation_bootstrap_test.go @@ -49,6 +49,20 @@ func TestNormalizeCodexAutomationBootstrapSupportedLastRunValues(t *testing.T) { } } +func TestNormalizeCodexAutomationBootstrapHeartbeat(t *testing.T) { + output := `wiki` + 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, `wiki`, ""), - }, { 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: `wiki`}, + {name: "root attribute", output: `wiki`}, + {name: "namespaced root", output: `wiki`}, + {name: "extra child", output: `wikiok`}, + {name: "nested id content", output: `wiki`}, + {name: "padded id", output: ` wiki `}, + {name: "unsafe id", output: `../wiki`}, + {name: "comment", output: `wiki`}, + {name: "trailing content", output: `wikiextra`}, + } + 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"}]}`) diff --git a/backend/internal/handler/openai_chat_completions.go b/backend/internal/handler/openai_chat_completions.go index e74b11b9b6..147f4460a8 100644 --- a/backend/internal/handler/openai_chat_completions.go +++ b/backend/internal/handler/openai_chat_completions.go @@ -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, diff --git a/backend/internal/handler/openai_codex_models_handler.go b/backend/internal/handler/openai_codex_models_handler.go index 73d8951ca9..777eeebdb1 100644 --- a/backend/internal/handler/openai_codex_models_handler.go +++ b/backend/internal/handler/openai_codex_models_handler.go @@ -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) -} diff --git a/backend/internal/handler/openai_delegation_bootstrap_test.go b/backend/internal/handler/openai_delegation_bootstrap_test.go index 9e30752aa6..38854c0781 100644 --- a/backend/internal/handler/openai_delegation_bootstrap_test.go +++ b/backend/internal/handler/openai_delegation_bootstrap_test.go @@ -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`}, diff --git a/backend/internal/handler/openai_embeddings.go b/backend/internal/handler/openai_embeddings.go index 913131114e..8dda9faf3f 100644 --- a/backend/internal/handler/openai_embeddings.go +++ b/backend/internal/handler/openai_embeddings.go @@ -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, diff --git a/backend/internal/handler/openai_gateway_credential_failover_test.go b/backend/internal/handler/openai_gateway_credential_failover_test.go index d94eae7fe9..4fd5df01d0 100644 --- a/backend/internal/handler/openai_gateway_credential_failover_test.go +++ b/backend/internal/handler/openai_gateway_credential_failover_test.go @@ -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() diff --git a/backend/internal/handler/openai_gateway_cyber_test.go b/backend/internal/handler/openai_gateway_cyber_test.go index 1b9fc53a9d..4be214cdc9 100644 --- a/backend/internal/handler/openai_gateway_cyber_test.go +++ b/backend/internal/handler/openai_gateway_cyber_test.go @@ -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. diff --git a/backend/internal/handler/openai_gateway_handler.go b/backend/internal/handler/openai_gateway_handler.go index 466aff4cac..741379e631 100644 --- a/backend/internal/handler/openai_gateway_handler.go +++ b/backend/internal/handler/openai_gateway_handler.go @@ -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) { diff --git a/backend/internal/handler/openai_gateway_handler_test.go b/backend/internal/handler/openai_gateway_handler_test.go index 92addad57a..d86672dd1e 100644 --- a/backend/internal/handler/openai_gateway_handler_test.go +++ b/backend/internal/handler/openai_gateway_handler_test.go @@ -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")) diff --git a/backend/internal/handler/openai_models_handler.go b/backend/internal/handler/openai_models_handler.go new file mode 100644 index 0000000000..c20a49d0cb --- /dev/null +++ b/backend/internal/handler/openai_models_handler.go @@ -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) +} diff --git a/backend/internal/handler/openai_models_handler_test.go b/backend/internal/handler/openai_models_handler_test.go new file mode 100644 index 0000000000..9277d1573d --- /dev/null +++ b/backend/internal/handler/openai_models_handler_test.go @@ -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"`) + } + }) + } +} diff --git a/backend/internal/handler/openai_service_tier_validation_test.go b/backend/internal/handler/openai_service_tier_validation_test.go index 97da6f682c..bcd032c3ac 100644 --- a/backend/internal/handler/openai_service_tier_validation_test.go +++ b/backend/internal/handler/openai_service_tier_validation_test.go @@ -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 // 测试走入真实账号选择/重试路径。 diff --git a/backend/internal/handler/stream_error_event.go b/backend/internal/handler/stream_error_event.go index 8ba6095cfb..f60e6c7834 100644 --- a/backend/internal/handler/stream_error_event.go +++ b/backend/internal/handler/stream_error_event.go @@ -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" diff --git a/backend/internal/handler/stream_error_event_test.go b/backend/internal/handler/stream_error_event_test.go index 187deda19d..94089e9fae 100644 --- a/backend/internal/handler/stream_error_event_test.go +++ b/backend/internal/handler/stream_error_event_test.go @@ -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)) } diff --git a/backend/internal/handler/wire.go b/backend/internal/handler/wire.go index 0e75dc9258..d35245ecbf 100644 --- a/backend/internal/handler/wire.go +++ b/backend/internal/handler/wire.go @@ -250,7 +250,7 @@ var ProviderSet = wire.NewSet( // Admin handlers admin.NewDashboardHandler, admin.NewUserHandler, - admin.NewGroupHandler, + admin.NewGroupHandlerWithConfig, admin.ProvideAccountHandler, admin.NewAnnouncementHandler, admin.NewDataManagementHandler, diff --git a/backend/internal/pkg/antigravity/oauth.go b/backend/internal/pkg/antigravity/oauth.go index 26b307bbf7..625dcaea69 100644 --- a/backend/internal/pkg/antigravity/oauth.go +++ b/backend/internal/pkg/antigravity/oauth.go @@ -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" diff --git a/backend/internal/pkg/antigravity/oauth_test.go b/backend/internal/pkg/antigravity/oauth_test.go index 7849492c7c..4ebc7e5434 100644 --- a/backend/internal/pkg/antigravity/oauth_test.go +++ b/backend/internal/pkg/antigravity/oauth_test.go @@ -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 { diff --git a/backend/internal/pkg/apicompat/chatcompletions_responses_test.go b/backend/internal/pkg/apicompat/chatcompletions_responses_test.go index a57bd73f91..1731892735 100644 --- a/backend/internal/pkg/apicompat/chatcompletions_responses_test.go +++ b/backend/internal/pkg/apicompat/chatcompletions_responses_test.go @@ -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() diff --git a/backend/internal/pkg/apicompat/responses_to_chatcompletions.go b/backend/internal/pkg/apicompat/responses_to_chatcompletions.go index d288b31bea..e649a261df 100644 --- a/backend/internal/pkg/apicompat/responses_to_chatcompletions.go +++ b/backend/internal/pkg/apicompat/responses_to_chatcompletions.go @@ -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 + } + } } diff --git a/backend/internal/pkg/claude/cli_version.go b/backend/internal/pkg/claude/cli_version.go new file mode 100644 index 0000000000..c55b9d34b2 --- /dev/null +++ b/backend/internal/pkg/claude/cli_version.go @@ -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 +} diff --git a/backend/internal/pkg/claude/cli_version_test.go b/backend/internal/pkg/claude/cli_version_test.go new file mode 100644 index 0000000000..89f8db33a8 --- /dev/null +++ b/backend/internal/pkg/claude/cli_version_test.go @@ -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) + } +} diff --git a/backend/internal/pkg/claude/constants.go b/backend/internal/pkg/claude/constants.go index c3c6854a58..319eb84dd3 100644 --- a/backend/internal/pkg/claude/constants.go +++ b/backend/internal/pkg/claude/constants.go @@ -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", diff --git a/backend/internal/pkg/claude/constants_cli_version_test.go b/backend/internal/pkg/claude/constants_cli_version_test.go new file mode 100644 index 0000000000..91bd12833f --- /dev/null +++ b/backend/internal/pkg/claude/constants_cli_version_test.go @@ -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 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 +} diff --git a/backend/internal/pkg/openai/constants.go b/backend/internal/pkg/openai/constants.go index 772143ac92..5ee5a3dc7a 100644 --- a/backend/internal/pkg/openai/constants.go +++ b/backend/internal/pkg/openai/constants.go @@ -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"}, diff --git a/backend/internal/pkg/openai/constants_test.go b/backend/internal/pkg/openai/constants_test.go index c8f69efb66..a62050d693 100644 --- a/backend/internal/pkg/openai/constants_test.go +++ b/backend/internal/pkg/openai/constants_test.go @@ -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) diff --git a/backend/internal/repository/account_repo.go b/backend/internal/repository/account_repo.go index ff0335e807..2e3961423a 100644 --- a/backend/internal/repository/account_repo.go +++ b/backend/internal/repository/account_repo.go @@ -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 diff --git a/backend/internal/repository/channel_repo_pricing.go b/backend/internal/repository/channel_repo_pricing.go index 1a88eeb977..8053d4453f 100644 --- a/backend/internal/repository/channel_repo_pricing.go +++ b/backend/internal/repository/channel_repo_pricing.go @@ -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 { diff --git a/backend/internal/repository/channel_repo_pricing_time_test.go b/backend/internal/repository/channel_repo_pricing_time_test.go index 9532cdf162..c22630abcf 100644 --- a/backend/internal/repository/channel_repo_pricing_time_test.go +++ b/backend/internal/repository/channel_repo_pricing_time_test.go @@ -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)) diff --git a/backend/internal/repository/group_repo.go b/backend/internal/repository/group_repo.go index c7f7581cba..8b2bbb827d 100644 --- a/backend/internal/repository/group_repo.go +++ b/backend/internal/repository/group_repo.go @@ -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 { diff --git a/backend/internal/repository/group_repo_integration_test.go b/backend/internal/repository/group_repo_integration_test.go index 8c14504776..177fbe7f17 100644 --- a/backend/internal/repository/group_repo_integration_test.go +++ b/backend/internal/repository/group_repo_integration_test.go @@ -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", diff --git a/backend/internal/repository/http_upstream.go b/backend/internal/repository/http_upstream.go index bdd6478cff..001e597e0f 100644 --- a/backend/internal/repository/http_upstream.go +++ b/backend/internal/repository/http_upstream.go @@ -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 { diff --git a/backend/internal/repository/http_upstream_test.go b/backend/internal/repository/http_upstream_test.go index 3c7840dc47..8750771d04 100644 --- a/backend/internal/repository/http_upstream_test.go +++ b/backend/internal/repository/http_upstream_test.go @@ -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") +} diff --git a/backend/internal/repository/session_limit_cache.go b/backend/internal/repository/session_limit_cache.go index 1711256937..4b38e58f50 100644 --- a/backend/internal/repository/session_limit_cache.go +++ b/backend/internal/repository/session_limit_cache.go @@ -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 == "" { diff --git a/backend/internal/repository/session_limit_cache_integration_test.go b/backend/internal/repository/session_limit_cache_integration_test.go new file mode 100644 index 0000000000..7f07300cb3 --- /dev/null +++ b/backend/internal/repository/session_limit_cache_integration_test.go @@ -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)) +} diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go index d6a96e5ecf..2fe0a1c227 100644 --- a/backend/internal/server/api_contract_test.go +++ b/backend/internal/server/api_contract_test.go @@ -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) diff --git a/backend/internal/server/routes/gateway_models_pinned_test.go b/backend/internal/server/routes/gateway_models_pinned_test.go new file mode 100644 index 0000000000..5fab905059 --- /dev/null +++ b/backend/internal/server/routes/gateway_models_pinned_test.go @@ -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()) +} diff --git a/backend/internal/service/account_stats_pricing.go b/backend/internal/service/account_stats_pricing.go index 8ac3dba383..c25a66f674 100644 --- a/backend/internal/service/account_stats_pricing.go +++ b/backend/internal/service/account_stats_pricing.go @@ -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, ) } diff --git a/backend/internal/service/account_stats_pricing_test.go b/backend/internal/service/account_stats_pricing_test.go index 97628f58c0..a346f4405f 100644 --- a/backend/internal/service/account_stats_pricing_test.go +++ b/backend/internal/service/account_stats_pricing_test.go @@ -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": { diff --git a/backend/internal/service/account_test_service.go b/backend/internal/service/account_test_service.go index 4e304eca10..ffae4ce8b7 100644 --- a/backend/internal/service/account_test_service.go +++ b/backend/internal/service/account_test_service.go @@ -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, diff --git a/backend/internal/service/admin_account.go b/backend/internal/service/admin_account.go index 4e91fa40bc..d7c02e3f50 100644 --- a/backend/internal/service/admin_account.go +++ b/backend/internal/service/admin_account.go @@ -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) diff --git a/backend/internal/service/admin_group.go b/backend/internal/service/admin_group.go index 2af9316104..1123a47619 100644 --- a/backend/internal/service/admin_group.go +++ b/backend/internal/service/admin_group.go @@ -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) } diff --git a/backend/internal/service/admin_group_duplicate.go b/backend/internal/service/admin_group_duplicate.go index b44d3d583e..859d19d6f2 100644 --- a/backend/internal/service/admin_group_duplicate.go +++ b/backend/internal/service/admin_group_duplicate.go @@ -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 diff --git a/backend/internal/service/admin_service.go b/backend/internal/service/admin_service.go index 37abcd414c..47c45701fd 100644 --- a/backend/internal/service/admin_service.go +++ b/backend/internal/service/admin_service.go @@ -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, diff --git a/backend/internal/service/admin_service_delete_test.go b/backend/internal/service/admin_service_delete_test.go index 5925a59dda..316505d537 100644 --- a/backend/internal/service/admin_service_delete_test.go +++ b/backend/internal/service/admin_service_delete_test.go @@ -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} diff --git a/backend/internal/service/admin_service_duplicate_account_test.go b/backend/internal/service/admin_service_duplicate_account_test.go index 7bd31778d7..b04e7d7e6b 100644 --- a/backend/internal/service/admin_service_duplicate_account_test.go +++ b/backend/internal/service/admin_service_duplicate_account_test.go @@ -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() diff --git a/backend/internal/service/admin_service_group_test.go b/backend/internal/service/admin_service_group_test.go index 32b8b5f0d9..7d5774d794 100644 --- a/backend/internal/service/admin_service_group_test.go +++ b/backend/internal/service/admin_service_group_test.go @@ -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) diff --git a/backend/internal/service/api_key_auth_cache.go b/backend/internal/service/api_key_auth_cache.go index 7f30ec9583..474c0d85f1 100644 --- a/backend/internal/service/api_key_auth_cache.go +++ b/backend/internal/service/api_key_auth_cache.go @@ -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"` diff --git a/backend/internal/service/billing_service.go b/backend/internal/service/billing_service.go index 1f3a346f64..bc558420a6 100644 --- a/backend/internal/service/billing_service.go +++ b/backend/internal/service/billing_service.go @@ -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 } } diff --git a/backend/internal/service/billing_service_test.go b/backend/internal/service/billing_service_test.go index 956fe73d33..94ee14ca2a 100644 --- a/backend/internal/service/billing_service_test.go +++ b/backend/internal/service/billing_service_test.go @@ -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) { diff --git a/backend/internal/service/billing_token_cost_request.go b/backend/internal/service/billing_token_cost_request.go index 6a59be16a5..0794864dd0 100644 --- a/backend/internal/service/billing_token_cost_request.go +++ b/backend/internal/service/billing_token_cost_request.go @@ -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 diff --git a/backend/internal/service/billing_token_cost_request_test.go b/backend/internal/service/billing_token_cost_request_test.go index 9d282de061..7376d8de19 100644 --- a/backend/internal/service/billing_token_cost_request_test.go +++ b/backend/internal/service/billing_token_cost_request_test.go @@ -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) +} diff --git a/backend/internal/service/channel.go b/backend/internal/service/channel.go index df3b5f892a..f9fea3bcc9 100644 --- a/backend/internal/service/channel.go +++ b/backend/internal/service/channel.go @@ -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 渠道模型定价的分时倍率配置。 diff --git a/backend/internal/service/channel_available.go b/backend/internal/service/channel_available.go index 08b02eb423..46eb15d6bb 100644 --- a/backend/internal/service/channel_available.go +++ b/backend/internal/service/channel_available.go @@ -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 diff --git a/backend/internal/service/channel_monitor_checker.go b/backend/internal/service/channel_monitor_checker.go index 71177db0bd..23388405a0 100644 --- a/backend/internal/service/channel_monitor_checker.go +++ b/backend/internal/service/channel_monitor_checker.go @@ -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}, }) diff --git a/backend/internal/service/channel_monitor_checker_body_test.go b/backend/internal/service/channel_monitor_checker_body_test.go index 2357646337..922eb86732 100644 --- a/backend/internal/service/channel_monitor_checker_body_test.go +++ b/backend/internal/service/channel_monitor_checker_body_test.go @@ -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) + } +} diff --git a/backend/internal/service/channel_pricing_multipliers_test.go b/backend/internal/service/channel_pricing_multipliers_test.go index 5b38fddec2..11aea750ff 100644 --- a/backend/internal/service/channel_pricing_multipliers_test.go +++ b/backend/internal/service/channel_pricing_multipliers_test.go @@ -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}, } diff --git a/backend/internal/service/gateway_billing_header.go b/backend/internal/service/gateway_billing_header.go index 5dd0474780..dd1f16b2c7 100644 --- a/backend/internal/service/gateway_billing_header.go +++ b/backend/internal/service/gateway_billing_header.go @@ -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 diff --git a/backend/internal/service/gateway_billing_header_test.go b/backend/internal/service/gateway_billing_header_test.go index 0d5930996a..6de6b4bfec 100644 --- a/backend/internal/service/gateway_billing_header_test.go +++ b/backend/internal/service/gateway_billing_header_test.go @@ -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) + }) + } + } +} diff --git a/backend/internal/service/gateway_claude_oauth_body.go b/backend/internal/service/gateway_claude_oauth_body.go index 79f9a7c438..e98b967e2a 100644 --- a/backend/internal/service/gateway_claude_oauth_body.go +++ b/backend/internal/service/gateway_claude_oauth_body.go @@ -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, diff --git a/backend/internal/service/gateway_count_tokens.go b/backend/internal/service/gateway_count_tokens.go index b3c7286c3f..fb745d69b3 100644 --- a/backend/internal/service/gateway_count_tokens.go +++ b/backend/internal/service/gateway_count_tokens.go @@ -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 签名)=== diff --git a/backend/internal/service/gateway_messages_cache.go b/backend/internal/service/gateway_messages_cache.go index 8f7e6b5b7d..646f8b2177 100644 --- a/backend/internal/service/gateway_messages_cache.go +++ b/backend/internal/service/gateway_messages_cache.go @@ -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) } diff --git a/backend/internal/service/gateway_messages_cache_json_test.go b/backend/internal/service/gateway_messages_cache_json_test.go new file mode 100644 index 0000000000..26759c4d72 --- /dev/null +++ b/backend/internal/service/gateway_messages_cache_json_test.go @@ -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))) + }) + } +} diff --git a/backend/internal/service/gateway_multiplatform_test.go b/backend/internal/service/gateway_multiplatform_test.go index ec1bf901ad..6568b3f6b4 100644 --- a/backend/internal/service/gateway_multiplatform_test.go +++ b/backend/internal/service/gateway_multiplatform_test.go @@ -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") +} diff --git a/backend/internal/service/gateway_request.go b/backend/internal/service/gateway_request.go index f5e4a4d500..9a3ca9fb88 100644 --- a/backend/internal/service/gateway_request.go +++ b/backend/internal/service/gateway_request.go @@ -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 // ========================= diff --git a/backend/internal/service/gateway_request_test.go b/backend/internal/service/gateway_request_test.go index 6074c02991..27b71c31fe 100644 --- a/backend/internal/service/gateway_request_test.go +++ b/backend/internal/service/gateway_request_test.go @@ -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", diff --git a/backend/internal/service/gateway_scheduling.go b/backend/internal/service/gateway_scheduling.go index 4842d8488a..f5dd160c79 100644 --- a/backend/internal/service/gateway_scheduling.go +++ b/backend/internal/service/gateway_scheduling.go @@ -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 diff --git a/backend/internal/service/gateway_upstream_request.go b/backend/internal/service/gateway_upstream_request.go index 0a8efbdd5e..675cf40001 100644 --- a/backend/internal/service/gateway_upstream_request.go +++ b/backend/internal/service/gateway_upstream_request.go @@ -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 签名)=== diff --git a/backend/internal/service/gateway_upstream_response.go b/backend/internal/service/gateway_upstream_response.go index e99c545694..5b1307b40e 100644 --- a/backend/internal/service/gateway_upstream_response.go +++ b/backend/internal/service/gateway_upstream_response.go @@ -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) != "" { diff --git a/backend/internal/service/gateway_usage_billing.go b/backend/internal/service/gateway_usage_billing.go index e134fbdb17..0cc8467523 100644 --- a/backend/internal/service/gateway_usage_billing.go +++ b/backend/internal/service/gateway_usage_billing.go @@ -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) diff --git a/backend/internal/service/group.go b/backend/internal/service/group.go index e930ecd9ae..bb6804a823 100644 --- a/backend/internal/service/group.go +++ b/backend/internal/service/group.go @@ -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 } diff --git a/backend/internal/service/group_service.go b/backend/internal/service/group_service.go index a78b5975be..c46cbf4db4 100644 --- a/backend/internal/service/group_service.go +++ b/backend/internal/service/group_service.go @@ -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 分组排序更新 diff --git a/backend/internal/service/http_upstream_profile.go b/backend/internal/service/http_upstream_profile.go index b2de6002cf..327813de19 100644 --- a/backend/internal/service/http_upstream_profile.go +++ b/backend/internal/service/http_upstream_profile.go @@ -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 +} diff --git a/backend/internal/service/http_upstream_profile_test.go b/backend/internal/service/http_upstream_profile_test.go index 9cd4bf4ff9..ea95b92611 100644 --- a/backend/internal/service/http_upstream_profile_test.go +++ b/backend/internal/service/http_upstream_profile_test.go @@ -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") + } +} diff --git a/backend/internal/service/identity_service.go b/backend/internal/service/identity_service.go index afa882a9c9..d117f720cc 100644 --- a/backend/internal/service/identity_service.go +++ b/backend/internal/service/identity_service.go @@ -28,6 +28,10 @@ var ( // /.. 之后必须紧跟空白或字符串结束。 // 版本号带 -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 } diff --git a/backend/internal/service/identity_service_user_agent_validation_test.go b/backend/internal/service/identity_service_user_agent_validation_test.go index 5206eb04f0..1a52d12554 100644 --- a/backend/internal/service/identity_service_user_agent_validation_test.go +++ b/backend/internal/service/identity_service_user_agent_validation_test.go @@ -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) } diff --git a/backend/internal/service/identity_service_version_floor_test.go b/backend/internal/service/identity_service_version_floor_test.go new file mode 100644 index 0000000000..78a15627c4 --- /dev/null +++ b/backend/internal/service/identity_service_version_floor_test.go @@ -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) +} diff --git a/backend/internal/service/model_not_found_error_test.go b/backend/internal/service/model_not_found_error_test.go index 2f8c83466f..d76d71d0f1 100644 --- a/backend/internal/service/model_not_found_error_test.go +++ b/backend/internal/service/model_not_found_error_test.go @@ -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) + } + }) + } +} diff --git a/backend/internal/service/model_plaza_service.go b/backend/internal/service/model_plaza_service.go index fe2252c594..6b2a87b263 100644 --- a/backend/internal/service/model_plaza_service.go +++ b/backend/internal/service/model_plaza_service.go @@ -231,7 +231,7 @@ func (s *ModelPlazaService) fillDisplayPricing(ctx context.Context, m *PlazaMode Platform: m.Platform, }) if err == nil && sched != nil && len(sched.Tiers) > 0 { - m.Pricing = plazaPricingFromSchedule(m.Pricing, sched) + m.Pricing = withDefaultMaxReasoningEffortMultiplier(plazaPricingFromSchedule(m.Pricing, sched), m.Name) if len(sched.Tiers) > 1 { m.LongContextBasis = sched.Basis } @@ -239,7 +239,20 @@ func (s *ModelPlazaService) fillDisplayPricing(ctx context.Context, m *PlazaMode return } } - m.Pricing = plazaImageDisplayPricing(m.Pricing, g) + m.Pricing = withDefaultMaxReasoningEffortMultiplier(plazaImageDisplayPricing(m.Pricing, g), m.Name) +} + +func withDefaultMaxReasoningEffortMultiplier(pricing *ChannelModelPricing, model string) *ChannelModelPricing { + if pricing == nil || pricing.MaxReasoningEffortMultiplier != nil { + return pricing + } + multiplier := defaultMaxReasoningEffortMultiplier(model) + if multiplier == nil { + return pricing + } + cloned := pricing.Clone() + cloned.MaxReasoningEffortMultiplier = multiplier + return &cloned } // plazaPricingFromSchedule 把阶梯表压成展示用的 ChannelModelPricing: @@ -250,6 +263,7 @@ func plazaPricingFromSchedule(raw *ChannelModelPricing, sched *ContextPricingSch out.ImageInputPrice = raw.ImageInputPrice out.ImageOutputPrice = raw.ImageOutputPrice out.PerRequestPrice = raw.PerRequestPrice + out.MaxReasoningEffortMultiplier = raw.MaxReasoningEffortMultiplier } first := sched.Tiers[0] out.InputPrice = first.Input diff --git a/backend/internal/service/model_plaza_service_test.go b/backend/internal/service/model_plaza_service_test.go index 4a588ecb0f..e093a9def9 100644 --- a/backend/internal/service/model_plaza_service_test.go +++ b/backend/internal/service/model_plaza_service_test.go @@ -57,6 +57,19 @@ func TestListPlazaGroups_GroupCentricAggregation(t *testing.T) { require.Equal(t, "claude-sonnet", out[0].Models[1].Name) } +func TestWithDefaultMaxReasoningEffortMultiplier_Fable51(t *testing.T) { + base := &ChannelModelPricing{BillingMode: BillingModeToken} + got := withDefaultMaxReasoningEffortMultiplier(base, "claude-fable-5-1") + require.NotSame(t, base, got) + require.NotNil(t, got.MaxReasoningEffortMultiplier) + require.Equal(t, 3.0, *got.MaxReasoningEffortMultiplier) + require.Nil(t, base.MaxReasoningEffortMultiplier) + + configured := 1.25 + custom := &ChannelModelPricing{MaxReasoningEffortMultiplier: &configured} + require.Same(t, custom, withDefaultMaxReasoningEffortMultiplier(custom, "claude-fable-5-1")) +} + func TestListPlazaGroups_DedupFirstWinsWithPricingUpgrade(t *testing.T) { // 同名模型:先见者胜;仅当已存条目无定价而新条目有定价时升级替换。 unpriced := Channel{ diff --git a/backend/internal/service/model_pricing_resolver.go b/backend/internal/service/model_pricing_resolver.go index 1a5382e7c3..ecd3d45209 100644 --- a/backend/internal/service/model_pricing_resolver.go +++ b/backend/internal/service/model_pricing_resolver.go @@ -247,6 +247,9 @@ func (r *ModelPricingResolver) applyTokenOverrides(chPricing *ChannelModelPricin } resolved.BasePricing.FastMultiplier = chPricing.FastMultiplier resolved.BasePricing.FlexMultiplier = chPricing.FlexMultiplier + if chPricing.MaxReasoningEffortMultiplier != nil { + resolved.BasePricing.MaxReasoningEffortMultiplier = chPricing.MaxReasoningEffortMultiplier + } // 渠道定价覆盖一切:显式配置则用配置值,未配置则归零(不回退到 LiteLLM) if chPricing.ImageOutputPrice != nil { resolved.BasePricing.ImageOutputPricePerToken = *chPricing.ImageOutputPrice diff --git a/backend/internal/service/openai_access_state_failover_test.go b/backend/internal/service/openai_access_state_failover_test.go index a46f9ca571..48c8fb617e 100644 --- a/backend/internal/service/openai_access_state_failover_test.go +++ b/backend/internal/service/openai_access_state_failover_test.go @@ -77,7 +77,7 @@ func TestOpenAIUpstreamAccessStateClassification(t *testing.T) { if !tt.want { return } - require.True(t, (&OpenAIGatewayService{}).shouldFailoverOpenAIUpstreamResponse(http.StatusForbidden, "", body)) + require.True(t, (&OpenAIGatewayService{}).shouldFailoverOpenAIUpstreamResponse(newOpenAIUpstreamErrorTestAccount(), http.StatusForbidden, "", body)) require.True(t, shouldFailoverOpenAIPassthroughResponse(&Account{Type: AccountTypeOAuth}, http.StatusForbidden, body)) err := newOpenAIUpstreamFailoverError(http.StatusForbidden, nil, body, "", true) @@ -105,7 +105,7 @@ func TestOpenAIHTTPAccessStateDoesNotTrustBadRequestMessage(t *testing.T) { require.False(t, isOpenAIUpstreamAccessStateError("", body), "free-form stream messages are not durable account evidence") require.False(t, isOpenAIHTTPUpstreamAccessStateError(http.StatusBadRequest, "", body)) - require.False(t, svc.shouldFailoverOpenAIUpstreamResponse(http.StatusBadRequest, "", body)) + require.False(t, svc.shouldFailoverOpenAIUpstreamResponse(newOpenAIUpstreamErrorTestAccount(), http.StatusBadRequest, "", body)) require.False(t, shouldFailoverOpenAIPassthroughResponse(&Account{Type: AccountTypeOAuth}, http.StatusBadRequest, body)) err := newOpenAIUpstreamFailoverError(http.StatusBadRequest, nil, body, "", false) @@ -148,7 +148,7 @@ func TestOpenAIHTTPAccessStateTrustsStructuredCode(t *testing.T) { body := []byte(`{"error":{"code":"organization_deactivated","message":"request rejected"}}`) require.True(t, isOpenAIHTTPUpstreamAccessStateError(http.StatusBadRequest, "", body)) - require.True(t, svc.shouldFailoverOpenAIUpstreamResponse(http.StatusBadRequest, "", body)) + require.True(t, svc.shouldFailoverOpenAIUpstreamResponse(newOpenAIUpstreamErrorTestAccount(), http.StatusBadRequest, "", body)) require.True(t, svc.handleOpenAIAccountUpstreamError(context.Background(), account, http.StatusBadRequest, nil, body)) require.Equal(t, 1, repo.setErrorCalls) require.True(t, svc.isOpenAIAccountRuntimeBlocked(account)) @@ -189,7 +189,7 @@ func TestOpenAIHTTPAuthMessagesUseExistingStatusPolicies(t *testing.T) { func TestOpenAICyberPolicyWrapped5xxNeverFailsOver(t *testing.T) { body := []byte(`{"error":{"code":"cyber_policy","message":"blocked"}}`) svc := &OpenAIGatewayService{} - require.False(t, svc.shouldFailoverOpenAIUpstreamResponse(http.StatusBadGateway, "wrapped upstream failure", body)) + require.False(t, svc.shouldFailoverOpenAIUpstreamResponse(newOpenAIUpstreamErrorTestAccount(), http.StatusBadGateway, "wrapped upstream failure", body)) require.False(t, shouldFailoverOpenAIPassthroughResponse(&Account{Type: AccountTypeOAuth}, http.StatusBadGateway, body)) } diff --git a/backend/internal/service/openai_alpha_search.go b/backend/internal/service/openai_alpha_search.go index caff28208b..c80673405f 100644 --- a/backend/internal/service/openai_alpha_search.go +++ b/backend/internal/service/openai_alpha_search.go @@ -91,7 +91,7 @@ func (s *OpenAIGatewayService) ForwardAlphaSearch(ctx context.Context, c *gin.Co if resp.StatusCode >= http.StatusBadRequest { upstreamMessage := sanitizeUpstreamErrorMessage(strings.TrimSpace(extractUpstreamErrorMessage(respBody))) - if s.shouldFailoverOpenAIUpstreamResponse(resp.StatusCode, upstreamMessage, respBody) || + if s.shouldFailoverOpenAIUpstreamResponse(account, resp.StatusCode, upstreamMessage, respBody) || isOpenAIAlphaSearchEndpointUnsupported(account, resp.StatusCode) { resp.Body = io.NopCloser(bytes.NewReader(respBody)) // alpha/search 是独立的工具端点,单次 401 不能证明账号的模型调用 @@ -175,7 +175,7 @@ func (s *OpenAIGatewayService) forwardAlphaSearchViaResponsesWebSearch( if resp.StatusCode >= http.StatusBadRequest { upstreamMessage := sanitizeUpstreamErrorMessage(strings.TrimSpace(extractUpstreamErrorMessage(respBody))) - if s.shouldFailoverOpenAIUpstreamResponse(resp.StatusCode, upstreamMessage, respBody) { + if s.shouldFailoverOpenAIUpstreamResponse(account, resp.StatusCode, upstreamMessage, respBody) { resp.Body = io.NopCloser(bytes.NewReader(respBody)) // 仍按 alpha/search 工具请求处理:PAT 的工具链路失败不能直接永久置错。 shouldDisable := false diff --git a/backend/internal/service/openai_codex_model_metadata.go b/backend/internal/service/openai_codex_model_metadata.go index c779a05ab9..63a819e1e0 100644 --- a/backend/internal/service/openai_codex_model_metadata.go +++ b/backend/internal/service/openai_codex_model_metadata.go @@ -1,11 +1,111 @@ package service -import "strings" +import ( + "bytes" + "encoding/json" + "net/url" + "strings" +) + +var codexToolCapabilityFields = []string{ + "supports_search_tool", "apply_patch_tool_type", "comp_hash", "tool_mode", "use_responses_lite", +} + +func applyCodexToolCapabilities(dst, src map[string]json.RawMessage, overwrite bool) bool { + changed := false + for _, field := range codexToolCapabilityFields { + value := bytes.TrimSpace(src[field]) + if len(value) == 0 { + continue + } + // These five Codex fields are nullable booleans or strings, never arbitrary objects. + if !bytes.Equal(value, []byte("null")) { + if field == "supports_search_tool" || field == "use_responses_lite" { + if !bytes.Equal(value, []byte("true")) && !bytes.Equal(value, []byte("false")) { + continue + } + } else { + var text string + if json.Unmarshal(value, &text) != nil { + continue + } + } + } + current, exists := dst[field] + if (exists && !overwrite) || bytes.Equal(current, value) { + continue + } + dst[field] = append(json.RawMessage(nil), value...) + changed = true + } + return changed +} + +func accountCodexToolCapabilities(account *Account, modelID string) map[string]json.RawMessage { + capabilities := make(map[string]json.RawMessage) + if account == nil { + return capabilities + } + if metadata, ok := account.GetUpstreamModelMetadata(modelID); ok { + applyCodexToolCapabilities(capabilities, metadata.CodexToolCapabilities, true) + } + if account.IsOpenAI() && shouldForwardOpenAIResponsesViaRawChatCompletions(account) { + // This bridge implements client-side tool discovery, even without a native manifest. + applyCodexToolCapabilities(capabilities, map[string]json.RawMessage{"supports_search_tool": json.RawMessage("true")}, false) + } + // Codex 0.153's bundled Astra catalog verifies these values. API-key routes + // use standard Responses, not the ChatGPT-only Responses Lite wire. + baseURL := strings.TrimSpace(account.GetCredential("base_url")) + if baseURL == "" { + baseURL = account.GetOpenAIBaseURL() + } + parsed, err := url.Parse(baseURL) + official := err == nil && (strings.EqualFold(parsed.Hostname(), "api.openai.com") || + (account.IsOpenAIOAuth() && strings.EqualFold(parsed.Hostname(), "chatgpt.com"))) + if account.IsOpenAI() && isOpenAIGPT6AstraModel(modelID) && official { + defaults := map[string]json.RawMessage{ + "supports_search_tool": json.RawMessage("true"), + "apply_patch_tool_type": json.RawMessage(`"freeform"`), + "comp_hash": json.RawMessage(`"3000"`), + "tool_mode": json.RawMessage("null"), + "use_responses_lite": json.RawMessage("false"), + } + if account.IsOpenAIOAuth() { + defaults["tool_mode"] = json.RawMessage(`"code_mode_only"`) + defaults["use_responses_lite"] = json.RawMessage("true") + } + applyCodexToolCapabilities(capabilities, defaults, false) + } + if account.IsOpenAIApiKey() { + target := modelID + if isOpenAIGPT6AstraModel(target) { + target = "gpt-6-astra" + } + _, disabled := apiKeyCodexModelsWithoutResponsesLite[target] + if disabled && bytes.Equal(capabilities["use_responses_lite"], []byte("true")) { + capabilities["use_responses_lite"] = json.RawMessage("false") + } + } + return capabilities +} + +// codexModelRoutingAccountIDs 返回分组显式为该公开别名声明的账号集合。 +// +// 返回非空表示运营者已经用 model_routing 指明“这个别名由这些账号服务”,此时别名的 +// 归属不再是需要推断的未知量:能力声明只看这些账号,且不再因为它们映射到不同上游而 +// 判定为冲突。返回空表示没有相关规则,保持原有的全量推断与失败即关闭行为。 +func codexModelRoutingAccountIDs(group *Group, modelID string) []int64 { + if group == nil { + return nil + } + return group.GetRoutingAccountIDs(strings.TrimSpace(modelID)) +} func groupCodexModelMetadata( platform string, modelID string, accounts []Account, + group *Group, compositeRoutes []CompositeModelRoute, compositeRoutesAvailable bool, ) (codexModelMetadataOverride, bool) { @@ -13,6 +113,9 @@ func groupCodexModelMetadata( if modelID == "" { return codexModelMetadataOverride{}, false } + // 显式路由规则本身与平台无关,先取出来供后续两处判定共用。 + routedAccountIDs := codexModelRoutingAccountIDs(group, modelID) + routed := len(routedAccountIDs) > 0 upstreamModel := modelID if platform == PlatformComposite { var resolved bool @@ -23,7 +126,7 @@ func groupCodexModelMetadata( compositeRoutesAvailable, ) if !resolved { - if codexExplicitModelTargetsConflict(accounts, modelID) { + if !routed && codexExplicitModelTargetsConflict(accounts, modelID) { return codexModelMetadataOverride{ reasoningConflict: true, inputModalitiesConflict: true, @@ -38,21 +141,32 @@ func groupCodexModelMetadata( explicitClaims := false if upstreamModel == modelID { - for _, account := range accounts { - if account.Platform == platform && codexExplicitModelMappingClaims(account, modelID) { + for i := range accounts { + account := &accounts[i] + if routed && !containsInt64(routedAccountIDs, account.ID) { + continue + } + if account.Platform == platform && codexExplicitModelMappingClaims(*account, modelID) { explicitClaims = true break } } } - explicitTargetsConflict := explicitClaims && codexExplicitModelTargetsConflictForPlatform(accounts, platform, modelID) + // 别名已被显式路由声明时,"多个账号映射到不同上游"是故障转移的正常写法,不是歧义, + // 因此不再失败即关闭;能力回落到与单账号无快照时相同的按名推导。 + explicitTargetsConflict := explicitClaims && !routed && + codexExplicitModelTargetsConflictForPlatform(accounts, platform, modelID) publicAlias := upstreamModel != modelID candidates := make([]UpstreamModelMetadata, 0) + missingMetadata := false for i := range accounts { account := &accounts[i] if account.Platform != platform { continue } + if routed && !containsInt64(routedAccountIDs, account.ID) { + continue + } var lookupModel string if explicitClaims { if !codexExplicitModelMappingClaims(*account, modelID) { @@ -76,14 +190,20 @@ func groupCodexModelMetadata( inputModalitiesConflict: true, }, true } - return codexModelMetadataOverride{}, false + missingMetadata = true } + metadata.CodexToolCapabilities = accountCodexToolCapabilities(account, lookupModel) candidates = append(candidates, metadata) } if len(candidates) == 0 { return codexModelMetadataOverride{}, false } metadata := intersectUpstreamModelMetadata(modelID, candidates) + if missingMetadata { + metadata = codexModelMetadataOverride{UpstreamModelMetadata: UpstreamModelMetadata{ + CodexToolCapabilities: metadata.CodexToolCapabilities, + }} + } if publicAlias { metadata.DisplayName = modelID metadata.Description = configuredCodexCustomDescription @@ -124,6 +244,27 @@ func codexExplicitModelTargetsConflictForPlatform(accounts []Account, platform, func intersectUpstreamModelMetadata(modelID string, candidates []UpstreamModelMetadata) codexModelMetadataOverride { result := codexModelMetadataOverride{UpstreamModelMetadata: UpstreamModelMetadata{ID: strings.TrimSpace(modelID)}} + result.CodexToolCapabilities = make(map[string]json.RawMessage) + for _, field := range codexToolCapabilityFields { + value := bytes.TrimSpace(candidates[0].CodexToolCapabilities[field]) + shared := len(value) > 0 + declared := shared + for _, candidate := range candidates[1:] { + declared = declared || len(candidate.CodexToolCapabilities[field]) > 0 + if !bytes.Equal(value, bytes.TrimSpace(candidate.CodexToolCapabilities[field])) { + shared = false + } + } + if shared { + result.CodexToolCapabilities[field] = value + } else if declared { + fallback := json.RawMessage("null") + if field == "supports_search_tool" || field == "use_responses_lite" { + fallback = json.RawMessage("false") + } + result.CodexToolCapabilities[field] = fallback + } + } for _, candidate := range candidates { if result.DisplayName == "" && strings.TrimSpace(candidate.DisplayName) != "" { result.DisplayName = strings.TrimSpace(candidate.DisplayName) diff --git a/backend/internal/service/openai_codex_model_metadata_test.go b/backend/internal/service/openai_codex_model_metadata_test.go index 558c621ce5..616ff88606 100644 --- a/backend/internal/service/openai_codex_model_metadata_test.go +++ b/backend/internal/service/openai_codex_model_metadata_test.go @@ -2,12 +2,92 @@ package service import ( "context" + "encoding/json" "errors" "testing" "github.com/stretchr/testify/require" ) +func TestAstraCodexToolCapabilitiesUseAccountScopeAndSharedDeclarations(t *testing.T) { + newAccount := func(baseURL string) Account { + return Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Credentials: map[string]any{ + "base_url": baseURL, "model_mapping": map[string]any{"public-astra": "gpt-6-astra"}, + }} + } + official := newAccount("https://api.openai.com/v1") + custom := newAccount("https://relay.example/v1") + bridge := newAccount("https://bridge.example/v1") + bridge.Extra = map[string]any{"openai_responses_supported": false} + for _, tt := range []struct { + name string + accounts []Account + search bool + }{ + {"official fallback", []Account{official}, true}, + {"custom host has no guessed capability", []Account{custom}, false}, + {"missing peer capability", []Account{official, custom}, false}, + {"implemented chat bridge", []Account{bridge}, true}, + } { + t.Run(tt.name, func(t *testing.T) { + body, err := buildCodexModelsManifestForAccounts(PlatformOpenAI, []string{"public-astra"}, tt.accounts, nil, nil, true) + require.NoError(t, err) + model := decodeCodexManifestModels(t, body)[0] + require.Equal(t, "public-astra", model["slug"]) + require.Equal(t, tt.search, model["supports_search_tool"]) + require.Equal(t, false, model["use_responses_lite"]) + if tt.name == "official fallback" { + require.Equal(t, "freeform", model["apply_patch_tool_type"]) + require.Equal(t, "3000", model["comp_hash"]) + } + }) + } + custom.SetUpstreamModelMetadataSnapshot(UpstreamModelMetadataSnapshot{Models: map[string]UpstreamModelMetadata{ + "gpt-6-astra": {CodexToolCapabilities: map[string]json.RawMessage{ + "supports_search_tool": json.RawMessage("true"), "apply_patch_tool_type": json.RawMessage("null"), + "comp_hash": json.RawMessage(`"3000"`), "tool_mode": json.RawMessage("null"), "use_responses_lite": json.RawMessage("false"), + }}, + }}) + body, err := buildCodexModelsManifestForAccounts(PlatformOpenAI, []string{"public-astra"}, []Account{official, custom}, nil, nil, true) + require.NoError(t, err) + model := decodeCodexManifestModels(t, body)[0] + require.Equal(t, true, model["supports_search_tool"]) + require.Nil(t, model["apply_patch_tool_type"], "conflicting tool protocols must not be advertised") + require.Equal(t, "3000", model["comp_hash"]) +} + +func TestAstraCodexToolCapabilitiesPreserveLiveNullAndFalse(t *testing.T) { + account := &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey, + Credentials: map[string]any{"base_url": "https://api.openai.com/v1"}} + account.SetUpstreamModelMetadataSnapshot(UpstreamModelMetadataSnapshot{Models: map[string]UpstreamModelMetadata{ + "gpt-6-astra": {CodexToolCapabilities: map[string]json.RawMessage{ + "supports_search_tool": json.RawMessage("true"), "apply_patch_tool_type": json.RawMessage(`"freeform"`), + "comp_hash": json.RawMessage(`"3000"`), "tool_mode": json.RawMessage(`"code_mode_only"`), + }}, + }}) + for _, source := range []string{ + `{"models":[{"slug":"gpt-6-astra","supports_search_tool":false,"apply_patch_tool_type":null,"tool_mode":null}]}`, + `{"data":[{"id":"gpt-6-astra","supports_search_tool":false,"apply_patch_tool_type":null,"tool_mode":null}]}`, + } { + converted := convertOpenAIModelListToCodexManifestForAccount([]byte(source), account) + body, err := applySyncedAPIKeyCodexModelMetadata(converted, account, source[2:6] == "data") + require.NoError(t, err) + body, err = completeAPIKeyCodexModelsManifestMetadata(body, true, account) + require.NoError(t, err) + model := decodeCodexManifestModels(t, body)[0] + require.Equal(t, false, model["supports_search_tool"]) + require.Nil(t, model["apply_patch_tool_type"]) + require.Nil(t, model["tool_mode"]) + require.Equal(t, "3000", model["comp_hash"]) + } + fields := make(map[string]json.RawMessage) + applyCodexToolCapabilities(fields, map[string]json.RawMessage{ + "supports_search_tool": json.RawMessage(`"true"`), "comp_hash": json.RawMessage(`{"unexpected":true}`), + "model_messages": json.RawMessage(`"do not persist prompts"`), + }, true) + require.Empty(t, fields) +} + // Scenario: mixed groups prefer capability metadata synced for the routed account. func TestBuildCodexModelsManifestForGroupUsesSyncedAccountMetadata(t *testing.T) { t.Parallel() @@ -120,7 +200,7 @@ func TestBuildCodexModelsManifestForGroupAdvertisesSearchOnlyForChatBridgeRoutes t.Run(tc.name, func(t *testing.T) { t.Parallel() body, err := buildCodexModelsManifestForAccounts( - PlatformOpenAI, []string{"company-coding-model"}, tc.accounts, nil, true, + PlatformOpenAI, []string{"company-coding-model"}, tc.accounts, nil, nil, true, ) require.NoError(t, err) models := decodeCodexManifestModels(t, body) @@ -472,3 +552,140 @@ func TestBuildCodexModelsManifestForGroupDoesNotAdvertiseNoneWhenAccountReasonin require.False(t, hasDefault) require.Empty(t, models[0]["supported_reasoning_levels"]) } + +func TestAstraCodexToolCapabilitiesFollowAPIKeyAlias(t *testing.T) { + account := &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Credentials: map[string]any{ + "base_url": "https://relay.example/v1", "model_mapping": map[string]any{"my-astra": "gpt-6-astra"}, + }} + account.SetUpstreamModelMetadataSnapshot(UpstreamModelMetadataSnapshot{Models: map[string]UpstreamModelMetadata{ + "gpt-6-astra": {CodexToolCapabilities: map[string]json.RawMessage{ + "supports_search_tool": json.RawMessage("true"), "apply_patch_tool_type": json.RawMessage(`"freeform"`), + "use_responses_lite": json.RawMessage("false"), + }}, + }}) + for _, source := range []string{`{"data":[{"id":"my-astra"}]}`, `{"models":[{"slug":"my-astra"}]}`} { + converted := convertOpenAIModelListToCodexManifestForAccount([]byte(source), account) + body, err := completeAPIKeyCodexModelsManifestMetadata(converted, true, account) + require.NoError(t, err) + models := decodeCodexManifestModels(t, body) + require.Len(t, models, 1) + model := models[0] + require.Equal(t, "my-astra", model["slug"]) + require.Equal(t, "my-astra", model["display_name"]) + require.Equal(t, true, model["supports_search_tool"]) + require.Equal(t, "freeform", model["apply_patch_tool_type"]) + require.Equal(t, false, model["use_responses_lite"]) + } +} + +func TestAstraCodexToolCapabilitiesKeepAPIKeyResponsesLiteGuard(t *testing.T) { + account := Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Credentials: map[string]any{ + "base_url": "https://relay.example/v1", "model_mapping": map[string]any{"my-astra": "gpt-6-astra"}, + }} + account.SetUpstreamModelMetadataSnapshot(UpstreamModelMetadataSnapshot{Models: map[string]UpstreamModelMetadata{ + "gpt-6-astra": {CodexToolCapabilities: map[string]json.RawMessage{"use_responses_lite": json.RawMessage("true")}}, + }}) + body, err := buildCodexModelsManifestForAccounts(PlatformOpenAI, []string{"my-astra"}, []Account{account}, nil, nil, true) + require.NoError(t, err) + require.Equal(t, false, decodeCodexManifestModels(t, body)[0]["use_responses_lite"]) + body, err = adjustAPIKeyCodexModelsManifest([]byte(`{"models":[{"slug":"my-astra","use_responses_lite":true}]}`), &account) + require.NoError(t, err) + require.Equal(t, false, decodeCodexManifestModels(t, body)[0]["use_responses_lite"]) +} + +// Scenario: an OpenAI group keeps a failover account whose mapping points the +// shared public alias at a different upstream model. Without an explicit routing +// rule the alias target is ambiguous and capabilities must fail closed; with one, +// the operator has declared who serves the alias, so the reasoning slider and +// image input must survive. +func TestCodexAliasFailoverMappingHonorsModelRouting(t *testing.T) { + newAccount := func(id int64, target string, priority int) Account { + return Account{ + ID: id, + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Priority: priority, + Credentials: map[string]any{ + "base_url": "https://relay.example/v1", + "model_mapping": map[string]any{"gpt-6-astra": target}, + }, + } + } + // Account 16-alike serves the alias natively; account 1-alike is the failover + // and can only reach a different upstream model. + primary := newAccount(16, "gpt-6-astra", 0) + failover := newAccount(1, "gpt-5.6-sol", 10) + accounts := []Account{primary, failover} + + // supported_reasoning_levels entries are {effort, description} objects; the + // slider only cares about the effort names. + manifestFieldsOf := func(t *testing.T, group *Group) ([]string, any, any) { + t.Helper() + body, err := buildCodexModelsManifestForAccounts( + PlatformOpenAI, []string{"gpt-6-astra"}, accounts, group, nil, true, + ) + require.NoError(t, err) + models := decodeCodexManifestModels(t, body) + require.Len(t, models, 1) + model := models[0] + require.Equal(t, "gpt-6-astra", model["slug"]) + var efforts []string + levels, _ := model["supported_reasoning_levels"].([]any) + for _, level := range levels { + entry, ok := level.(map[string]any) + require.True(t, ok, "reasoning level entry must be an object") + effort, ok := entry["effort"].(string) + require.True(t, ok, "reasoning level entry must carry an effort name") + efforts = append(efforts, effort) + } + return efforts, model["default_reasoning_level"], model["input_modalities"] + } + + t.Run("no routing rule still fails closed", func(t *testing.T) { + levels, def, modalities := manifestFieldsOf(t, nil) + require.Empty(t, levels, "ambiguous alias target must not advertise reasoning levels") + require.Nil(t, def) + require.Equal(t, []any{"text"}, modalities) + }) + + t.Run("routing rule resolves the alias", func(t *testing.T) { + group := &Group{ + ID: 2, + Platform: PlatformOpenAI, + ModelRoutingEnabled: true, + ModelRouting: map[string][]int64{"gpt-6-astra": {16, 1}}, + } + levels, def, modalities := manifestFieldsOf(t, group) + require.Equal(t, + []string{"low", "medium", "high", "xhigh", "max"}, + levels, + "routed alias must keep a movable reasoning slider", + ) + require.Equal(t, "medium", def) + require.Contains(t, modalities, "image") + }) + + t.Run("routing disabled falls back to failing closed", func(t *testing.T) { + group := &Group{ + ID: 2, + Platform: PlatformOpenAI, + ModelRoutingEnabled: false, + ModelRouting: map[string][]int64{"gpt-6-astra": {16, 1}}, + } + levels, _, _ := manifestFieldsOf(t, group) + require.Empty(t, levels, "a disabled routing rule must not resolve the alias") + }) + + t.Run("routing rule for another alias does not leak", func(t *testing.T) { + group := &Group{ + ID: 2, + Platform: PlatformOpenAI, + ModelRoutingEnabled: true, + ModelRouting: map[string][]int64{"gpt-5.6-sol": {1}}, + } + levels, _, _ := manifestFieldsOf(t, group) + require.Empty(t, levels, "unrelated routing rules must not resolve this alias") + }) +} + +// Scenario: mixed groups prefer capability metadata synced for the routed account. diff --git a/backend/internal/service/openai_codex_models_pinned.go b/backend/internal/service/openai_codex_models_pinned.go index 4f45f3a1e8..b74b3eafdc 100644 --- a/backend/internal/service/openai_codex_models_pinned.go +++ b/backend/internal/service/openai_codex_models_pinned.go @@ -114,18 +114,53 @@ func mergeCodexModelsManifestBodies(bodies [][]byte) ([]byte, error) { // - every fetch failed → the last upstream error // - partial failure → successful accounts are still merged and a warning // naming the failed account IDs is logged. -func (s *OpenAIGatewayService) FetchPinnedCodexModelsManifest(ctx context.Context, group *Group, clientVersion string) (*CodexModelsManifest, *Account, error) { +func (s *OpenAIGatewayService) FetchPinnedCodexModelsManifest(ctx context.Context, group *Group, clientVersion string) (*OpenAIModelsResponse, *Account, error) { + results, err := s.fetchPinnedOpenAIModels(ctx, group, func(ctx context.Context, account *Account) (*OpenAIModelsResponse, error) { + manifest, err := s.FetchCodexModelsManifest(ctx, account, clientVersion, "") + if err != nil { + return nil, err + } + if err := s.CompleteAPIKeyCodexModelsManifestForClient(manifest, account); err != nil { + return nil, err + } + if err := ApplyPinnedCodexModelsMapping(manifest, account, group); err != nil { + return nil, err + } + return manifest, nil + }) + if err != nil { + return nil, nil, err + } + bodies := make([][]byte, 0, len(results)) + for _, result := range results { + bodies = append(bodies, result.response.Body) + } + merged, err := mergeCodexModelsManifestBodies(bodies) + if err != nil { + return nil, nil, fmt.Errorf("merge pinned codex models manifests: %w", err) + } + return &OpenAIModelsResponse{Body: merged, ETag: codexModelsManifestBodyETag(merged)}, results[0].account, nil +} + +type pinnedOpenAIModelsResult struct { + account *Account + response *OpenAIModelsResponse +} + +// fetchPinnedOpenAIModels shares membership, eligibility, fanout and partial +// failure policy between ordinary model lists and Codex manifests. +func (s *OpenAIGatewayService) fetchPinnedOpenAIModels(ctx context.Context, group *Group, fetch func(context.Context, *Account) (*OpenAIModelsResponse, error)) ([]pinnedOpenAIModelsResult, error) { if s == nil || s.accountRepo == nil || group == nil { - return nil, nil, ErrNoPinnedCodexModelsAccounts + return nil, ErrNoPinnedCodexModelsAccounts } cfg := group.CodexModelsManifestConfig if group.Platform != PlatformOpenAI || !cfg.Enabled || len(cfg.AccountIDs) == 0 { - return nil, nil, ErrNoPinnedCodexModelsAccounts + return nil, ErrNoPinnedCodexModelsAccounts } members, err := s.accountRepo.ListByGroup(ctx, group.ID) if err != nil { - return nil, nil, fmt.Errorf("load pinned codex models manifest accounts: %w", err) + return nil, fmt.Errorf("load pinned codex models manifest accounts: %w", err) } memberByID := make(map[int64]Account, len(members)) for _, member := range members { @@ -145,70 +180,46 @@ func (s *OpenAIGatewayService) FetchPinnedCodexModelsManifest(ctx context.Contex usable = append(usable, member) } if len(usable) == 0 { - return nil, nil, ErrNoPinnedCodexModelsAccounts + return nil, ErrNoPinnedCodexModelsAccounts } - bodies := make([][]byte, len(usable)) + results := make([]pinnedOpenAIModelsResult, len(usable)) fetchErrs := make([]error, len(usable)) - // 各自独立完成、不取消兄弟请求,错误按下标收集,普通 WaitGroup 足够。 var fetchGroup sync.WaitGroup for i := range usable { - account := usable[i] - index := i fetchGroup.Add(1) go func() { defer fetchGroup.Done() - manifest, fetchErr := s.FetchCodexModelsManifest(ctx, &account, clientVersion, "") - if fetchErr != nil { - fetchErrs[index] = fetchErr - return - } - if completeErr := s.CompleteAPIKeyCodexModelsManifestForClient(manifest, &account); completeErr != nil { - fetchErrs[index] = completeErr - return - } - bodies[index] = manifest.Body + response, err := fetch(ctx, &usable[i]) + results[i] = pinnedOpenAIModelsResult{account: &usable[i], response: response} + fetchErrs[i] = err }() } fetchGroup.Wait() - - successBodies := make([][]byte, 0, len(usable)) - successAccounts := make([]*Account, 0, len(usable)) - failedIDs := make([]int64, 0) - for i := range usable { - if fetchErrs[i] != nil || bodies[i] == nil { - failedIDs = append(failedIDs, usable[i].ID) - continue - } - successBodies = append(successBodies, bodies[i]) - successAccounts = append(successAccounts, &usable[i]) + if err := ctx.Err(); err != nil { + return nil, err } - if len(successBodies) == 0 { - var lastErr error - for i := range fetchErrs { + successes := make([]pinnedOpenAIModelsResult, 0, len(results)) + failedIDs := make([]int64, 0) + var lastErr error + for i, result := range results { + if fetchErrs[i] != nil || result.response == nil { + failedIDs = append(failedIDs, usable[i].ID) if fetchErrs[i] != nil { lastErr = fetchErrs[i] } + continue } + successes = append(successes, result) + } + if len(successes) == 0 { if lastErr == nil { - lastErr = infraerrors.New(http.StatusBadGateway, "OPENAI_CODEX_MODELS_UPSTREAM_FAILED", "pinned codex models manifest accounts all failed") + lastErr = infraerrors.New(http.StatusBadGateway, "OPENAI_MODELS_UPSTREAM_FAILED", "pinned model discovery accounts all failed") } - return nil, nil, lastErr + return nil, lastErr } if len(failedIDs) > 0 { - slog.Warn("codex_models_manifest_pinned_partial_failure", - "group_id", group.ID, - "failed_account_ids", failedIDs, - ) + slog.Warn("openai_models_pinned_partial_failure", "group_id", group.ID, "failed_account_ids", failedIDs) } - - merged, err := mergeCodexModelsManifestBodies(successBodies) - if err != nil { - return nil, nil, fmt.Errorf("merge pinned codex models manifests: %w", err) - } - firstAccount := *successAccounts[0] - return &CodexModelsManifest{ - Body: merged, - ETag: codexModelsManifestBodyETag(merged), - }, &firstAccount, nil + return successes, nil } diff --git a/backend/internal/service/openai_codex_models_service.go b/backend/internal/service/openai_codex_models_service.go index 36800f166e..f1a59bbca2 100644 --- a/backend/internal/service/openai_codex_models_service.go +++ b/backend/internal/service/openai_codex_models_service.go @@ -30,18 +30,18 @@ import ( var chatgptCodexModelsURL = "https://chatgpt.com/backend-api/codex/models" const ( - codexModelsManifestCacheBodyLimit = 1 << 20 - // codexModelsManifestCacheMaxEntries 上限按「账号数 × 客户端版本数 × 代理形态」 + openAIModelsCacheBodyLimit = 1 << 20 + // openAIModelsCacheMaxEntries 上限按「账号数 × 客户端版本数 × 代理形态」 // 估算:缓存同时覆盖 OAuth 与 API Key 账号,且缓存键含 Authorization 与 // Version 头,不同客户端版本各自占一条;64 条在大规模部署下会被淘汰导致 // 额外上游请求。单条清单通常几十 KB,512 条最坏内存占用在几十 MB 量级。 - codexModelsManifestCacheMaxEntries = 512 + openAIModelsCacheMaxEntries = 512 // 三段时效:≤TTL 为新鲜(直接返回缓存,零上游请求);TTL 到 StaleTTL 之间 // 乐观返回旧值并后台单飞刷新(携带上游 ETag,304 续期);超过 StaleTTL 丢弃 // 缓存同步等待刷新。TTL 取 1 分钟:manifest 变化低频,1 分钟内同账号重复 // 请求完全吸收;StaleTTL 取 5 分钟,控制旧内容最长可见时间在分钟级。 - codexModelsManifestCacheTTL = 60 * time.Second - codexModelsManifestCacheStaleTTL = 5 * time.Minute + openAIModelsCacheTTL = 60 * time.Second + openAIModelsCacheStaleTTL = 5 * time.Minute codexModelsManifestRequestTimeout = 15 * time.Second codexAutoModelPrefix = "codex-auto-" ) @@ -105,8 +105,9 @@ func codexProviderQualifiedModelID(modelID string) string { return strings.TrimPrefix(modelID, "models/") } -// CodexModelsManifest carries the client representation plus caching metadata. -type CodexModelsManifest struct { +// OpenAIModelsResponse carries either an OpenAI model list or a Codex manifest +// together with upstream and client caching metadata. +type OpenAIModelsResponse struct { Body []byte ETag string upstreamETag string @@ -123,7 +124,7 @@ func (s *OpenAIGatewayService) BuildGroupConfiguredCodexModelsManifest( ctx context.Context, group *Group, ifNoneMatch string, -) (*CodexModelsManifest, bool, error) { +) (*OpenAIModelsResponse, bool, error) { if s == nil || s.accountRepo == nil || group == nil || group.Platform != PlatformOpenAI { return nil, false, nil } @@ -141,6 +142,7 @@ func (s *OpenAIGatewayService) BuildGroupConfiguredCodexModelsManifest( PlatformOpenAI, configuredModels, catalog, + group, nil, true, ) @@ -156,7 +158,7 @@ func (s *OpenAIGatewayService) BuildGroupConfiguredCodexModelsManifest( if err != nil { return nil, false, fmt.Errorf("build group configured Codex models: %w", err) } - manifest := &CodexModelsManifest{ + manifest := &OpenAIModelsResponse{ Body: body, ETag: codexModelsManifestBodyETag(body), } @@ -174,7 +176,7 @@ func (s *OpenAIGatewayService) BuildGroupConfiguredCodexModelsManifest( func (s *OpenAIGatewayService) MergeGroupConfiguredCodexModels( ctx context.Context, group *Group, - manifest *CodexModelsManifest, + manifest *OpenAIModelsResponse, ifNoneMatch string, ) error { if s == nil || s.accountRepo == nil || group == nil || manifest == nil || manifest.NotModified { @@ -184,9 +186,13 @@ func (s *OpenAIGatewayService) MergeGroupConfiguredCodexModels( return nil } - configuredModels, err := s.groupConfiguredCodexModelIDs(ctx, group) - if err != nil { - return fmt.Errorf("load group configured Codex models: %w", err) + var configuredModels []string + if !group.CodexModelsManifestConfig.Enabled { + var err error + configuredModels, err = s.groupConfiguredCodexModelIDs(ctx, group) + if err != nil { + return fmt.Errorf("load group configured Codex models: %w", err) + } } body, changed, err := mergeConfiguredCodexModelsManifest( manifest.Body, @@ -197,6 +203,13 @@ func (s *OpenAIGatewayService) MergeGroupConfiguredCodexModels( if err != nil { return fmt.Errorf("merge group configured Codex models: %w", err) } + if group.CodexModelsManifestConfig.Enabled && group.ModelAllowlistEnabled() { + body, err = orderPinnedCodexModelsBySelection(body, group.ModelAllowlist) + if err != nil { + return fmt.Errorf("order pinned Codex models: %w", err) + } + changed = true + } if changed { manifest.Body = body manifest.ETag = codexModelsManifestBodyETag(body) @@ -324,6 +337,7 @@ const ( configuredCodexGrokContext = 500_000 configuredCodexGrokBuildContext = 256_000 configuredCodexGPT56MaxContext = 872_000 + configuredCodexGPT6AstraContext = 1_050_000 configuredCodexToolOutputMaxTokens = 10_000 ) @@ -482,15 +496,7 @@ func newConfiguredCodexModelDescriptor(modelID string) configuredCodexModelDescr descriptor.DisplayName = openaiCodexDisplayName(modelID) descriptor.Description = "OpenAI GPT coding model routed through Sub2API." descriptor.SupportsParallelToolCalls = true - if configuredCodexSupportsPriorityServiceTier(modelID) { - descriptor.ServiceTiers = []configuredCodexServiceTier{ - { - ID: "priority", - Name: "Fast", - Description: "Priority processing for lower latency.", - }, - } - } + descriptor.ServiceTiers = configuredCodexServiceTiersForModel(modelID) if isOpenAICodexReasoningGPTModel(modelID) { defaultReasoningLevel := "medium" if getNormalizedCodexModel(modelID) == "gpt-5.6-sol" { @@ -503,6 +509,10 @@ func newConfiguredCodexModelDescriptor(modelID string) configuredCodexModelDescr if isOpenAIGPT56Model(modelID) { descriptor.MaxContextWindow = configuredCodexGPT56MaxContext } + if isOpenAIGPT6AstraModel(modelID) { + descriptor.ContextWindow = configuredCodexGPT6AstraContext + descriptor.MaxContextWindow = configuredCodexGPT6AstraContext + } } if SupportsVerbosity(modelID) { defaultVerbosity := "low" @@ -514,6 +524,25 @@ func newConfiguredCodexModelDescriptor(modelID string) configuredCodexModelDescr return descriptor } +func configuredCodexServiceTiersForModel(modelID string) []configuredCodexServiceTier { + tiers := make([]configuredCodexServiceTier, 0, 2) + if configuredCodexSupportsPriorityServiceTier(modelID) { + tiers = append(tiers, configuredCodexServiceTier{ + ID: OpenAIFastTierPriority, + Name: "Fast", + Description: "Priority processing for lower latency.", + }) + } + if configuredCodexSupportsUltrafastServiceTier(modelID) { + tiers = append(tiers, configuredCodexServiceTier{ + ID: OpenAIFastTierUltrafast, + Name: "Ultrafast", + Description: "Ultra-low latency processing.", + }) + } + return tiers +} + func configuredCodexSupportsPriorityServiceTier(modelID string) bool { normalized := canonicalizeOpenAIModelAliasSpelling(modelID) for _, family := range []string{"gpt-5.4", "gpt-5.5", "gpt-5.6"} { @@ -521,7 +550,12 @@ func configuredCodexSupportsPriorityServiceTier(modelID string) bool { return true } } - return false + // GPT-6 Astra advertises Fast via service_tier=priority in public model metadata. + return isOpenAIGPT6AstraModel(modelID) +} + +func configuredCodexSupportsUltrafastServiceTier(modelID string) bool { + return normalizeKnownOpenAICodexModel(modelID) == "gpt-5.6-sol" } func configuredCodexGrokReasoningLevels(modelID string) []configuredCodexReasoningLevel { @@ -580,7 +614,7 @@ func configuredCodexGPTReasoningLevels(modelID string) []configuredCodexReasonin {Effort: "xhigh", Description: "Extra-high reasoning depth for difficult tasks"}, } normalized := getNormalizedCodexModel(modelID) - if isOpenAIGPT56Model(modelID) { + if isOpenAIGPT56Model(modelID) || isOpenAIGPT6AstraModel(modelID) { levels = append(levels, configuredCodexReasoningLevel{ Effort: "max", Description: "Maximum reasoning depth for complex tasks", @@ -605,12 +639,13 @@ func isOpenAICodexGPTModel(modelID string) bool { func isOpenAICodexReasoningGPTModel(modelID string) bool { normalized := canonicalizeOpenAIModelAliasSpelling(modelID) - return strings.HasPrefix(normalized, "gpt-5") + return isOpenAIGPT6AstraModel(normalized) || strings.HasPrefix(normalized, "gpt-5") } func isOpenAICodexImageInputModel(modelID string) bool { normalized := canonicalizeOpenAIModelAliasSpelling(modelID) - return strings.HasPrefix(normalized, "gpt-5") || + return isOpenAIGPT6AstraModel(normalized) || + strings.HasPrefix(normalized, "gpt-5") || strings.HasPrefix(normalized, "gpt-4o") || strings.HasPrefix(normalized, "gpt-4.1") || strings.HasPrefix(normalized, "gpt-4.5") || @@ -789,6 +824,7 @@ func (s *GatewayService) BuildCodexModelsManifestForGroup( effectivePlatform, modelIDs, catalog, + group, compositeRoutes, compositeRoutesAvailable, ) @@ -798,6 +834,7 @@ func buildCodexModelsManifestForAccounts( effectivePlatform string, modelIDs []string, accounts []Account, + group *Group, compositeRoutes []CompositeModelRoute, compositeRoutesAvailable bool, ) ([]byte, error) { @@ -835,6 +872,7 @@ func buildCodexModelsManifestForAccounts( effectivePlatform, modelID, accounts, + group, compositeRoutes, compositeRoutesAvailable, ); ok { @@ -852,7 +890,7 @@ func buildCodexModelsManifest( modelMetadata map[string]codexModelMetadataOverride, ) ([]byte, error) { seen := make(map[string]struct{}, len(modelIDs)) - models := make([]configuredCodexModelDescriptor, 0, len(modelIDs)) + models := make([]json.RawMessage, 0, len(modelIDs)) for _, modelID := range modelIDs { modelID = strings.TrimSpace(modelID) if modelID == "" { @@ -882,10 +920,25 @@ func buildCodexModelsManifest( descriptor.DisplayName = modelID descriptor.Description = configuredCodexCustomDescription } - models = append(models, descriptor) + encoded, err := json.Marshal(descriptor) + if err != nil { + return nil, err + } + if capabilities := modelMetadata[modelID].CodexToolCapabilities; len(capabilities) > 0 { + var fields map[string]json.RawMessage + if err := json.Unmarshal(encoded, &fields); err != nil { + return nil, err + } + applyCodexToolCapabilities(fields, capabilities, true) + encoded, err = json.Marshal(fields) + if err != nil { + return nil, err + } + } + models = append(models, encoded) } return json.Marshal(struct { - Models []configuredCodexModelDescriptor `json:"models"` + Models []json.RawMessage `json:"models"` }{Models: models}) } @@ -1443,7 +1496,7 @@ func isRetryableCodexModelsManifestTransportError(err error) bool { return false } -type codexModelsManifestRequest struct { +type openAIModelsRequest struct { url string headers http.Header proxyURL string @@ -1452,61 +1505,63 @@ type codexModelsManifestRequest struct { credentialAccount *Account accountConcurrency int useAPIKeyUpstream bool + // Cached bodies have already been converted to their requested format. + standardModelsList bool } -type codexModelsManifestCacheEntry struct { - manifest *CodexModelsManifest +type openAIModelsCacheEntry struct { + manifest *OpenAIModelsResponse order uint64 expiresAt time.Time staleUntil time.Time } -type codexModelsManifestCacheState uint8 +type openAIModelsCacheState uint8 const ( - codexModelsManifestCacheMiss codexModelsManifestCacheState = iota - codexModelsManifestCacheFresh - codexModelsManifestCacheStale + openAIModelsCacheMiss openAIModelsCacheState = iota + openAIModelsCacheFresh + openAIModelsCacheStale ) -type codexModelsManifestCache struct { +type openAIModelsCache struct { mu sync.Mutex - entries map[string]codexModelsManifestCacheEntry + entries map[string]openAIModelsCacheEntry nextOrder uint64 refresh singleflight.Group } -func (c *codexModelsManifestCache) get(key string, now time.Time) (*CodexModelsManifest, codexModelsManifestCacheState) { +func (c *openAIModelsCache) get(key string, now time.Time) (*OpenAIModelsResponse, openAIModelsCacheState) { c.mu.Lock() defer c.mu.Unlock() entry, ok := c.entries[key] if !ok { - return nil, codexModelsManifestCacheMiss + return nil, openAIModelsCacheMiss } if !now.Before(entry.staleUntil) { delete(c.entries, key) - return nil, codexModelsManifestCacheMiss + return nil, openAIModelsCacheMiss } if now.Before(entry.expiresAt) { - return entry.manifest, codexModelsManifestCacheFresh + return entry.manifest, openAIModelsCacheFresh } - return entry.manifest, codexModelsManifestCacheStale + return entry.manifest, openAIModelsCacheStale } -func (c *codexModelsManifestCache) set(key string, manifest *CodexModelsManifest, now time.Time) { - if manifest == nil || len(manifest.Body) > codexModelsManifestCacheBodyLimit { +func (c *openAIModelsCache) set(key string, manifest *OpenAIModelsResponse, now time.Time) { + if manifest == nil || len(manifest.Body) > openAIModelsCacheBodyLimit { return } - remainingBodyBudget := codexModelsManifestCacheBodyLimit - len(manifest.Body) + remainingBodyBudget := openAIModelsCacheBodyLimit - len(manifest.Body) if len(manifest.upstreamSourceBody) > remainingBodyBudget { return } c.mu.Lock() defer c.mu.Unlock() if c.entries == nil { - c.entries = make(map[string]codexModelsManifestCacheEntry) + c.entries = make(map[string]openAIModelsCacheEntry) } - if _, exists := c.entries[key]; !exists && len(c.entries) >= codexModelsManifestCacheMaxEntries { + if _, exists := c.entries[key]; !exists && len(c.entries) >= openAIModelsCacheMaxEntries { oldestKey := "" var oldestOrder uint64 for candidateKey, entry := range c.entries { @@ -1519,16 +1574,16 @@ func (c *codexModelsManifestCache) set(key string, manifest *CodexModelsManifest oldestOrder = entry.order } } - if len(c.entries) >= codexModelsManifestCacheMaxEntries && oldestKey != "" { + if len(c.entries) >= openAIModelsCacheMaxEntries && oldestKey != "" { delete(c.entries, oldestKey) } } c.nextOrder++ - c.entries[key] = codexModelsManifestCacheEntry{ + c.entries[key] = openAIModelsCacheEntry{ manifest: manifest, order: c.nextOrder, - expiresAt: now.Add(codexModelsManifestCacheTTL), - staleUntil: now.Add(codexModelsManifestCacheStaleTTL), + expiresAt: now.Add(openAIModelsCacheTTL), + staleUntil: now.Add(openAIModelsCacheStaleTTL), } } @@ -1538,7 +1593,7 @@ func (c *codexModelsManifestCache) set(key string, manifest *CodexModelsManifest // After validating the stable top-level envelope, OAuth response bodies are // passed through verbatim. Custom API key manifests receive only the narrowly // scoped compatibility adjustments required by custom-provider Codex clients. -func (s *OpenAIGatewayService) FetchCodexModelsManifest(ctx context.Context, account *Account, clientVersion, ifNoneMatch string) (*CodexModelsManifest, error) { +func (s *OpenAIGatewayService) FetchCodexModelsManifest(ctx context.Context, account *Account, clientVersion, ifNoneMatch string) (*OpenAIModelsResponse, error) { if account == nil { return nil, infraerrors.New(http.StatusInternalServerError, "OPENAI_CODEX_MODELS_ACCOUNT_REQUIRED", "account is required") } @@ -1626,7 +1681,7 @@ func (s *OpenAIGatewayService) FetchCodexModelsManifest(ctx context.Context, acc proxyURL = account.Proxy.URL() } - request := codexModelsManifestRequest{ + request := openAIModelsRequest{ url: requestURL.String(), headers: headers, proxyURL: proxyURL, @@ -1637,11 +1692,11 @@ func (s *OpenAIGatewayService) FetchCodexModelsManifest(ctx context.Context, acc useAPIKeyUpstream: useAPIKeyUpstream, } if useAPIKeyUpstream { - return s.fetchCachedCodexModelsManifest(ctx, request, s.fetchCodexModelsManifestUpstreamForRequest(request), ifNoneMatch) + return s.fetchCachedOpenAIModels(ctx, request, s.fetchCodexModelsManifestUpstreamForRequest(request), ifNoneMatch) } // OAuth 账号同样经过账号级缓存;闭包保留 agent identity 任务恢复逻辑, // 错误时仍交给 handleCodexModelsManifestAccountAuthError 处理账号状态。 - oauthFetch := func(fetchCtx context.Context, ifNoneMatch string) (*CodexModelsManifest, error) { + oauthFetch := func(fetchCtx context.Context, ifNoneMatch string) (*OpenAIModelsResponse, error) { manifest, fetchErr := s.fetchCodexModelsManifestUpstream(fetchCtx, request, ifNoneMatch) if !credAccount.IsOpenAIAgentIdentity() || !isAgentIdentityTaskInvalidCodexModelsError(fetchErr) { s.handleCodexModelsManifestAccountAuthError(fetchCtx, account, credAccount, fetchErr) @@ -1665,7 +1720,7 @@ func (s *OpenAIGatewayService) FetchCodexModelsManifest(ctx context.Context, acc setOpenAIChatGPTAccountHeaders(request.headers, credAccount) return s.fetchCodexModelsManifestUpstream(fetchCtx, request, ifNoneMatch) } - return s.fetchCachedCodexModelsManifest(ctx, request, oauthFetch, ifNoneMatch) + return s.fetchCachedOpenAIModels(ctx, request, oauthFetch, ifNoneMatch) } func isAgentIdentityTaskInvalidCodexModelsError(err error) bool { @@ -1705,18 +1760,18 @@ func (s *OpenAIGatewayService) handleCodexModelsManifestAccountAuthError(ctx con s.handleOpenAIAccountUpstreamError(ctx, account, upstreamErr.statusCode, headers, upstreamErr.body) } -func (s *OpenAIGatewayService) fetchCachedCodexModelsManifest(ctx context.Context, request codexModelsManifestRequest, fetch func(ctx context.Context, ifNoneMatch string) (*CodexModelsManifest, error), ifNoneMatch string) (*CodexModelsManifest, error) { +func (s *OpenAIGatewayService) fetchCachedOpenAIModels(ctx context.Context, request openAIModelsRequest, fetch func(ctx context.Context, ifNoneMatch string) (*OpenAIModelsResponse, error), ifNoneMatch string) (*OpenAIModelsResponse, error) { if err := ctx.Err(); err != nil { return nil, err } - cacheKey := buildCodexModelsManifestCacheKey(request) - manifest, state := s.codexModelsManifestCache.get(cacheKey, time.Now()) - if state == codexModelsManifestCacheFresh { - return codexModelsManifestForClient(manifest, ifNoneMatch), nil + cacheKey := buildOpenAIModelsCacheKey(request) + manifest, state := s.openAIModelsCache.get(cacheKey, time.Now()) + if state == openAIModelsCacheFresh { + return openAIModelsResponseForClient(manifest, ifNoneMatch), nil } - resultCh := s.refreshCachedCodexModelsManifest(cacheKey, request, fetch) - if state == codexModelsManifestCacheStale { - return codexModelsManifestForClient(manifest, ifNoneMatch), nil + resultCh := s.refreshCachedOpenAIModels(cacheKey, request, fetch) + if state == openAIModelsCacheStale { + return openAIModelsResponseForClient(manifest, ifNoneMatch), nil } select { case <-ctx.Done(): @@ -1725,19 +1780,19 @@ func (s *OpenAIGatewayService) fetchCachedCodexModelsManifest(ctx context.Contex if result.Err != nil { return nil, result.Err } - manifest, ok := result.Val.(*CodexModelsManifest) + manifest, ok := result.Val.(*OpenAIModelsResponse) if !ok || manifest == nil { return nil, infraerrors.New(http.StatusInternalServerError, "OPENAI_CODEX_MODELS_REQUEST_FAILED", "invalid shared Codex models manifest result") } - return codexModelsManifestForClient(manifest, ifNoneMatch), nil + return openAIModelsResponseForClient(manifest, ifNoneMatch), nil } } -func (s *OpenAIGatewayService) refreshCachedCodexModelsManifest(cacheKey string, request codexModelsManifestRequest, fetch func(ctx context.Context, ifNoneMatch string) (*CodexModelsManifest, error)) <-chan singleflight.Result { - return s.codexModelsManifestCache.refresh.DoChan(cacheKey, func() (any, error) { +func (s *OpenAIGatewayService) refreshCachedOpenAIModels(cacheKey string, request openAIModelsRequest, fetch func(ctx context.Context, ifNoneMatch string) (*OpenAIModelsResponse, error)) <-chan singleflight.Result { + return s.openAIModelsCache.refresh.DoChan(cacheKey, func() (any, error) { ctx, cancel := context.WithTimeout(context.Background(), codexModelsManifestRequestTimeout) defer cancel() - cached, _ := s.codexModelsManifestCache.get(cacheKey, time.Now()) + cached, _ := s.openAIModelsCache.get(cacheKey, time.Now()) ifNoneMatch := "" if cached != nil { ifNoneMatch = cached.upstreamETag @@ -1747,23 +1802,23 @@ func (s *OpenAIGatewayService) refreshCachedCodexModelsManifest(cacheKey string, return nil, err } if manifest.NotModified && cached != nil { - s.codexModelsManifestCache.set(cacheKey, cached, time.Now()) + s.openAIModelsCache.set(cacheKey, cached, time.Now()) return cached, nil } if !manifest.NotModified { - s.codexModelsManifestCache.set(cacheKey, manifest, time.Now()) + s.openAIModelsCache.set(cacheKey, manifest, time.Now()) } return manifest, nil }) } -func (s *OpenAIGatewayService) fetchCodexModelsManifestUpstreamForRequest(request codexModelsManifestRequest) func(ctx context.Context, ifNoneMatch string) (*CodexModelsManifest, error) { - return func(ctx context.Context, ifNoneMatch string) (*CodexModelsManifest, error) { +func (s *OpenAIGatewayService) fetchCodexModelsManifestUpstreamForRequest(request openAIModelsRequest) func(ctx context.Context, ifNoneMatch string) (*OpenAIModelsResponse, error) { + return func(ctx context.Context, ifNoneMatch string) (*OpenAIModelsResponse, error) { return s.fetchCodexModelsManifestUpstream(ctx, request, ifNoneMatch) } } -func (s *OpenAIGatewayService) fetchCodexModelsManifestUpstream(ctx context.Context, request codexModelsManifestRequest, ifNoneMatch string) (*CodexModelsManifest, error) { +func (s *OpenAIGatewayService) fetchOpenAIModelsUpstream(ctx context.Context, request openAIModelsRequest, ifNoneMatch string) (*OpenAIModelsResponse, error) { reqCtx, cancel := context.WithTimeout(ctx, codexModelsManifestRequestTimeout) defer cancel() req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, request.url, nil) @@ -1808,7 +1863,7 @@ func (s *OpenAIGatewayService) fetchCodexModelsManifestUpstream(ctx context.Cont defer func() { _ = resp.Body.Close() }() if resp.StatusCode == http.StatusNotModified { - return &CodexModelsManifest{ETag: resp.Header.Get("ETag"), NotModified: true}, nil + return &OpenAIModelsResponse{ETag: resp.Header.Get("ETag"), NotModified: true}, nil } if resp.StatusCode < 200 || resp.StatusCode >= 300 { body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048)) @@ -1840,6 +1895,17 @@ func (s *OpenAIGatewayService) fetchCodexModelsManifestUpstream(ctx context.Cont retryable: true, } } + etag := resp.Header.Get("ETag") + return &OpenAIModelsResponse{Body: body, ETag: etag, upstreamETag: etag}, nil +} + +func (s *OpenAIGatewayService) fetchCodexModelsManifestUpstream(ctx context.Context, request openAIModelsRequest, ifNoneMatch string) (*OpenAIModelsResponse, error) { + response, err := s.fetchOpenAIModelsUpstream(ctx, request, ifNoneMatch) + if err != nil || response.NotModified { + return response, err + } + body := response.Body + upstreamBody := body convertedFromOpenAIModelList := false if request.useAPIKeyUpstream { @@ -1875,7 +1941,7 @@ func (s *OpenAIGatewayService) fetchCodexModelsManifestUpstream(ctx context.Cont retryable: true, } } - body, err = adjustAPIKeyCodexModelsManifest(body) + body, err = adjustAPIKeyCodexModelsManifest(body, request.credentialAccount) if err != nil { return nil, &codexModelsManifestUpstreamError{ err: infraerrors.Newf( @@ -1888,8 +1954,8 @@ func (s *OpenAIGatewayService) fetchCodexModelsManifestUpstream(ctx context.Cont } } } - etag := resp.Header.Get("ETag") - manifest := &CodexModelsManifest{ + etag := response.upstreamETag + manifest := &OpenAIModelsResponse{ Body: body, ETag: etag, upstreamETag: etag, @@ -1914,6 +1980,7 @@ func CodexModelsManifestETag(body []byte) string { } var apiKeyCodexModelsWithoutResponsesLite = map[string]struct{}{ + "gpt-6-astra": {}, "gpt-5.6-sol": {}, "gpt-5.6-terra": {}, "gpt-5.6-luna": {}, @@ -1923,7 +1990,7 @@ var apiKeyCodexModelsWithoutResponsesLite = map[string]struct{}{ // Lite for custom API key providers. Those clients do not install web.run in // Lite mode, so the affected model manifests must advertise the full Responses // path. Return the original body when no targeted true value is present. -func adjustAPIKeyCodexModelsManifest(body []byte) ([]byte, error) { +func adjustAPIKeyCodexModelsManifest(body []byte, account *Account) ([]byte, error) { var envelope map[string]json.RawMessage if err := json.Unmarshal(body, &envelope); err != nil { return nil, fmt.Errorf("decode JSON object: %w", err) @@ -1943,7 +2010,14 @@ func adjustAPIKeyCodexModelsManifest(body []byte) ([]byte, error) { if err := json.Unmarshal(model["slug"], &slug); err != nil { continue } - if _, targeted := apiKeyCodexModelsWithoutResponsesLite[slug]; !targeted { + target := slug + if account != nil { + target = account.GetMappedModel(slug) + } + if isOpenAIGPT6AstraModel(target) { + target = "gpt-6-astra" + } + if _, targeted := apiKeyCodexModelsWithoutResponsesLite[target]; !targeted { continue } var useResponsesLite bool @@ -1996,19 +2070,33 @@ func convertOpenAIModelListToCodexManifestForAccount(body []byte, account *Accou if !ok { return body } - var entries []struct { - ID string `json:"id"` - } + var entries []map[string]json.RawMessage if err := json.Unmarshal(data, &entries); err != nil { return body } modelIDs := make([]string, 0, len(entries)) + modelMetadata := make(map[string]codexModelMetadataOverride, len(entries)) + metadataModels := make(map[string]string, len(entries)) for _, entry := range entries { - id := strings.TrimSpace(entry.ID) + var id string + if err := json.Unmarshal(entry["id"], &id); err != nil { + continue + } + id = strings.TrimSpace(id) if id == "" { continue } modelIDs = append(modelIDs, id) + capabilityModel := id + if account != nil { + capabilityModel = account.GetMappedModel(id) + } + metadataModels[id] = capabilityModel + capabilities := accountCodexToolCapabilities(account, capabilityModel) + applyCodexToolCapabilities(capabilities, entry, true) + modelMetadata[id] = codexModelMetadataOverride{UpstreamModelMetadata: UpstreamModelMetadata{ + CodexToolCapabilities: capabilities, + }} } if len(modelIDs) == 0 { return body @@ -2025,7 +2113,7 @@ func convertOpenAIModelListToCodexManifestForAccount(body []byte, account *Accou searchToolModels[modelID] = true } } - converted, err := buildCodexModelsManifest(modelIDs, imageInputModels, searchToolModels, nil, nil) + converted, err := buildCodexModelsManifest(modelIDs, imageInputModels, searchToolModels, metadataModels, modelMetadata) if err != nil { return body } @@ -2038,7 +2126,7 @@ func convertOpenAIModelListToCodexManifestForAccount(body []byte, account *Accou // CompleteAPIKeyCodexModelsManifestForClient fills the complete ModelInfo // contract immediately before a group-specific API key manifest is returned. // The shared upstream cache remains independent from local group policy. -func (s *OpenAIGatewayService) CompleteAPIKeyCodexModelsManifestForClient(manifest *CodexModelsManifest, account *Account) error { +func (s *OpenAIGatewayService) CompleteAPIKeyCodexModelsManifestForClient(manifest *OpenAIModelsResponse, account *Account) error { if manifest == nil || account == nil || !account.IsOpenAIApiKey() || manifest.NotModified || len(manifest.Body) == 0 { return nil } @@ -2062,7 +2150,7 @@ func (s *OpenAIGatewayService) CompleteAPIKeyCodexModelsManifestForClient(manife if err != nil { return err } - body, err = adjustAPIKeyCodexModelsManifest(body) + body, err = adjustAPIKeyCodexModelsManifest(body, account) if err != nil { return err } @@ -2097,10 +2185,15 @@ func applySyncedAPIKeyCodexModelMetadata(body []byte, account *Account, overwrit continue } slug = strings.TrimSpace(slug) - metadata, ok := snapshot.Models[slug] + lookupModel := account.GetMappedModel(slug) + metadata, ok := snapshot.Models[lookupModel] if !ok { continue } + if lookupModel != slug { + metadata.DisplayName = "" + metadata.Description = "" + } descriptor := newConfiguredCodexModelDescriptor(slug) applyUpstreamModelMetadataToCodexDescriptor( @@ -2133,7 +2226,8 @@ func applySyncedAPIKeyCodexModelMetadata(body []byte, account *Account, overwrit fields = append(fields, "context_window", "max_context_window") } - modelChanged := false + // List conversion has already applied live fields over account capabilities. + modelChanged := applyCodexToolCapabilities(model, metadata.CodexToolCapabilities, false) for _, field := range fields { value, exists := syncedFields[field] if !exists { @@ -2244,13 +2338,18 @@ func completeAPIKeyCodexModelsManifestMetadata(body []byte, completeAll bool, ac return nil, fmt.Errorf("decode default model %q: %w", slug, err) } - modelChanged := false + capabilityModel := slug + if account != nil { + capabilityModel = account.GetMappedModel(slug) + } + capabilities := accountCodexToolCapabilities(account, capabilityModel) + modelChanged := applyCodexToolCapabilities(model, capabilities, false) if completeDescriptor { merged, err := mergeMissingCodexModelFields(model, defaults) if err != nil { return nil, fmt.Errorf("complete model %q: %w", slug, err) } - modelChanged = merged + modelChanged = merged || modelChanged } if forceOfficialImage { modalities, err := json.Marshal([]string{"text", "image"}) @@ -2297,6 +2396,9 @@ func mergeMissingCodexModelFields(current, defaults map[string]json.RawMessage) changed := false for key, defaultValue := range defaults { currentValue, exists := current[key] + if exists && stringSliceContains(codexToolCapabilityFields, key) { + continue + } if !exists || (bytes.Equal(bytes.TrimSpace(currentValue), []byte("null")) && !bytes.Equal(bytes.TrimSpace(defaultValue), []byte("null"))) { current[key] = defaultValue @@ -2352,9 +2454,9 @@ func validateCodexModelsManifestEnvelope(body []byte) error { return nil } -func buildCodexModelsManifestCacheKey(request codexModelsManifestRequest) string { +func buildOpenAIModelsCacheKey(request openAIModelsRequest) string { hasher := sha256.New() - _, _ = fmt.Fprintf(hasher, "%d\n%d\n%s\n%s\n", request.accountID, request.credentialAccountID, request.proxyURL, request.url) + _, _ = fmt.Fprintf(hasher, "%d\n%d\n%t\n%s\n%s\n", request.accountID, request.credentialAccountID, request.standardModelsList, request.proxyURL, request.url) headerNames := make([]string, 0, len(request.headers)) for name := range request.headers { headerNames = append(headerNames, name) @@ -2369,7 +2471,7 @@ func buildCodexModelsManifestCacheKey(request codexModelsManifestRequest) string return fmt.Sprintf("%x", hasher.Sum(nil)) } -func cloneCodexModelsManifest(manifest *CodexModelsManifest) *CodexModelsManifest { +func cloneOpenAIModelsResponse(manifest *OpenAIModelsResponse) *OpenAIModelsResponse { if manifest == nil { return nil } @@ -2383,14 +2485,14 @@ func cloneCodexModelsManifest(manifest *CodexModelsManifest) *CodexModelsManifes return &cloned } -func codexModelsManifestForClient(manifest *CodexModelsManifest, ifNoneMatch string) *CodexModelsManifest { +func openAIModelsResponseForClient(manifest *OpenAIModelsResponse, ifNoneMatch string) *OpenAIModelsResponse { if manifest == nil { return nil } if codexModelsManifestETagMatches(ifNoneMatch, manifest.ETag) { - return &CodexModelsManifest{ETag: manifest.ETag, NotModified: true} + return &OpenAIModelsResponse{ETag: manifest.ETag, NotModified: true} } - return cloneCodexModelsManifest(manifest) + return cloneOpenAIModelsResponse(manifest) } func codexModelsManifestETagMatches(ifNoneMatch, etag string) bool { diff --git a/backend/internal/service/openai_codex_models_service_test.go b/backend/internal/service/openai_codex_models_service_test.go index 2d286fb49f..c215e077ef 100644 --- a/backend/internal/service/openai_codex_models_service_test.go +++ b/backend/internal/service/openai_codex_models_service_test.go @@ -349,6 +349,30 @@ func TestNewConfiguredCodexModelDescriptorUsesProviderMetadataAndSafeFallback(t require.Equal(t, []string{"low", "medium", "high", "xhigh", "max"}, effortsFromConfiguredCodexLevels(gpt56Luna.SupportedReasoningLevels)) require.Equal(t, "medium", *gpt56Luna.DefaultReasoningLevel) + gpt6Astra := newConfiguredCodexModelDescriptor("gpt-6-astra") + require.Equal(t, "GPT-6 Astra", gpt6Astra.DisplayName) + require.NotNil(t, gpt6Astra.DefaultReasoningLevel) + require.Equal(t, "medium", *gpt6Astra.DefaultReasoningLevel) + require.Equal(t, []string{"low", "medium", "high", "xhigh", "max"}, effortsFromConfiguredCodexLevels(gpt6Astra.SupportedReasoningLevels)) + require.NotContains(t, gpt6Astra.SupportedReasoningLevels, configuredCodexReasoningLevel{Effort: "ultra"}) + require.NotContains(t, gpt6Astra.SupportedReasoningLevels, configuredCodexReasoningLevel{Effort: "none"}) + require.True(t, configuredCodexSupportsPriorityServiceTier("gpt-6-astra")) + require.Equal(t, []configuredCodexServiceTier{{ + ID: "priority", + Name: "Fast", + Description: "Priority processing for lower latency.", + }}, gpt6Astra.ServiceTiers) + require.True(t, isOpenAICodexImageInputModel("gpt-6-astra")) + require.True(t, isOpenAICodexReasoningGPTModel("openai/gpt-6-astra")) + require.True(t, isOpenAIGPT6AstraModel("gpt-6-astra-2026-09-01")) + require.False(t, isOpenAIGPT6AstraModel("gpt-6-other")) + require.Equal(t, int64(1_050_000), gpt6Astra.ContextWindow) + require.Equal(t, int64(1_050_000), gpt6Astra.MaxContextWindow) + gpt6 := newConfiguredCodexModelDescriptor("gpt-6") + require.Equal(t, "GPT-6 (Astra)", gpt6.DisplayName) + require.Equal(t, []string{"low", "medium", "high", "xhigh", "max"}, effortsFromConfiguredCodexLevels(gpt6.SupportedReasoningLevels)) + require.Equal(t, int64(1_050_000), gpt6.ContextWindow) + gpt55 := newConfiguredCodexModelDescriptor("gpt-5.5") require.Equal(t, "GPT-5.5", gpt55.DisplayName) require.NotNil(t, gpt55.DefaultReasoningLevel) @@ -434,7 +458,7 @@ func TestBuildCodexModelsManifestAdvertisesPriorityServiceTierForFastGPTModels(t body, err := BuildCodexModelsManifest([]string{ "gpt-5.4-mini", "gpt-5.5", - "gpt-5.6-sol", + "gpt-5.6-terra", }) require.NoError(t, err) models := decodeCodexManifestModels(t, body) @@ -452,6 +476,36 @@ func TestBuildCodexModelsManifestAdvertisesPriorityServiceTierForFastGPTModels(t } } +// Scenario: GPT-5.6 Sol 在 Fast 之外额外声明 ultrafast service tier。 +func TestBuildCodexModelsManifestAdvertisesUltrafastServiceTierForSol(t *testing.T) { + t.Parallel() + + body, err := BuildCodexModelsManifest([]string{ + "gpt-5.6-sol", + "gpt-5.6", + }) + require.NoError(t, err) + models := decodeCodexManifestModels(t, body) + require.Len(t, models, 2) + + wantTiers := []any{ + map[string]any{ + "id": "priority", + "name": "Fast", + "description": "Priority processing for lower latency.", + }, + map[string]any{ + "id": "ultrafast", + "name": "Ultrafast", + "description": "Ultra-low latency processing.", + }, + } + for _, model := range models { + require.Equal(t, wantTiers, model["service_tiers"]) + require.Nil(t, model["default_service_tier"]) + } +} + // Scenario: 未明确支持 Fast 的型号不推测 service tier。 func TestBuildCodexModelsManifestLeavesServiceTiersEmptyForOtherModels(t *testing.T) { t.Parallel() @@ -1027,7 +1081,7 @@ func TestMergeGroupConfiguredCodexModelsInjectsCurrentGroupAliases(t *testing.T) }, }, }} - manifest := &CodexModelsManifest{ + manifest := &OpenAIModelsResponse{ Body: []byte(`{"models":[{"slug":"gpt-5.6","display_name":"GPT-5.6","unknown":{"kept":true}}],"metadata":{"version":1}}`), } @@ -1268,7 +1322,7 @@ func TestMergeGroupConfiguredCodexModelsFiltersAutoReviewByDefault(t *testing.T) const groupID int64 = 74 svc := &OpenAIGatewayService{accountRepo: codexModelsVisibilityAccountRepo{}} - manifest := &CodexModelsManifest{ + manifest := &OpenAIModelsResponse{ Body: []byte(`{"models":[{"slug":"codex-auto-review","visibility":"list"},{"slug":"codex-auto-future","visibility":"list"},{"slug":"gpt-image-2","visibility":"list"},{"slug":"gpt-5.6","visibility":"list"}]}`), } @@ -1303,7 +1357,7 @@ func TestMergeGroupConfiguredCodexModelsFiltersAccountMappedAutoReviewByDefault( }, }, }} - manifest := &CodexModelsManifest{ + manifest := &OpenAIModelsResponse{ Body: []byte(`{"models":[{"slug":"codex-auto-review","visibility":"hide","model_messages":{"auto_review":{"enabled":true}}},{"slug":"gpt-5.6","visibility":"list"}]}`), } @@ -1322,7 +1376,7 @@ func TestMergeGroupConfiguredCodexModelsKeepsExplicitAutoReviewSelection(t *test const groupID int64 = 76 svc := &OpenAIGatewayService{accountRepo: codexModelsVisibilityAccountRepo{}} - manifest := &CodexModelsManifest{ + manifest := &OpenAIModelsResponse{ Body: []byte(`{"models":[{"slug":"codex-auto-review","visibility":"list"},{"slug":"gpt-5.6","visibility":"list"}]}`), } group := &Group{ @@ -1366,7 +1420,7 @@ func TestMergeGroupConfiguredCodexModelsHonorsCustomListAndFinalETag(t *testing. }, } upstreamBody := []byte(`{"models":[{"slug":"gpt-5.6","display_name":"GPT-5.6"}]}`) - manifest := &CodexModelsManifest{Body: upstreamBody} + manifest := &OpenAIModelsResponse{Body: upstreamBody} require.NoError(t, svc.MergeGroupConfiguredCodexModels(context.Background(), group, manifest, "")) models := decodeCodexManifestModels(t, manifest.Body) @@ -1374,7 +1428,7 @@ func TestMergeGroupConfiguredCodexModelsHonorsCustomListAndFinalETag(t *testing. requireCompleteConfiguredCodexModel(t, models[0], "deepseek-4-pro") finalETag := manifest.ETag - second := &CodexModelsManifest{Body: upstreamBody} + second := &OpenAIModelsResponse{Body: upstreamBody} require.NoError(t, svc.MergeGroupConfiguredCodexModels(context.Background(), group, second, finalETag)) require.True(t, second.NotModified) require.Empty(t, second.Body) @@ -1957,7 +2011,7 @@ func TestCompleteAPIKeyCodexModelsManifestForClientPreservesProviderMetadata(t * t.Parallel() svc := &OpenAIGatewayService{} - manifest := &CodexModelsManifest{ + manifest := &OpenAIModelsResponse{ Body: []byte(`{"models":[{"slug":"grok-4.6","description":"Provider supplied","service_tiers":[{"id":"provider-priority","name":"Provider Fast","description":"Provider supplied tier."}],"model_messages":{"auto_review":{"enabled":true}},"truncation_policy":{"mode":"tokens"},"unknown":{"kept":true}}],"metadata":{"source":"upstream"}}`), } account := newCodexModelsAPIKeyTestAccount("https://upstream.example/v1") @@ -1993,7 +2047,7 @@ func TestCompleteAPIKeyCodexModelsManifestForClientUsesKnownGPTImageFallback(t * t.Parallel() svc := &OpenAIGatewayService{} - manifest := &CodexModelsManifest{Body: []byte(`{"models":[ + manifest := &OpenAIModelsResponse{Body: []byte(`{"models":[ {"slug":"gpt-5.6-sol"}, {"slug":"company-coding-model"}, {"slug":"gpt-4o","input_modalities":["text"]} @@ -2068,7 +2122,7 @@ func TestCompleteAPIKeyCodexModelsManifestForClientFillsMissingProviderFieldsWit ContextWindow: 999_000, }, }}) - manifest := &CodexModelsManifest{Body: []byte(`{"models":[{ + manifest := &OpenAIModelsResponse{Body: []byte(`{"models":[{ "slug":"provider-model", "description":"Provider supplied", "context_window":64000, @@ -2147,12 +2201,12 @@ func TestCompleteAPIKeyCodexModelsManifestForClientMarksOnlyOfficialVisionGPTIma t.Parallel() svc := &OpenAIGatewayService{} - manifest := &CodexModelsManifest{Body: []byte(`{"models":[{"slug":"gpt-5.6-sol"},{"slug":"gpt-4o"},{"slug":"gpt-3.5-turbo"},{"slug":"gpt-4"}]}`)} + manifest := &OpenAIModelsResponse{Body: []byte(`{"models":[{"slug":"gpt-6-astra"},{"slug":"gpt-5.6-sol"},{"slug":"gpt-4o"},{"slug":"gpt-3.5-turbo"},{"slug":"gpt-4"}]}`)} account := newCodexModelsAPIKeyTestAccount("") require.NoError(t, svc.CompleteAPIKeyCodexModelsManifestForClient(manifest, account)) models := decodeCodexManifestModels(t, manifest.Body) - require.Len(t, models, 4) + require.Len(t, models, 5) bySlug := make(map[string]map[string]any, len(models)) for _, model := range models { @@ -2160,7 +2214,7 @@ func TestCompleteAPIKeyCodexModelsManifestForClientMarksOnlyOfficialVisionGPTIma require.True(t, ok) bySlug[slug] = model } - for _, slug := range []string{"gpt-5.6-sol", "gpt-4o"} { + for _, slug := range []string{"gpt-6-astra", "gpt-5.6-sol", "gpt-4o"} { require.Equal(t, []any{"text", "image"}, bySlug[slug]["input_modalities"]) require.Equal(t, true, bySlug[slug]["supports_image_detail_original"]) } @@ -2175,7 +2229,7 @@ func TestCompleteAPIKeyCodexModelsManifestForClientFiltersOfficialNonAgentModels t.Parallel() svc := &OpenAIGatewayService{} - manifest := &CodexModelsManifest{Body: []byte(`{"models":[{"slug":"gpt-5.6-sol"},{"slug":"gpt-4o-realtime-preview"},{"slug":"gpt-4o-mini-tts"},{"slug":"text-embedding-3-large"},{"slug":"omni-moderation-latest"},{"slug":"o4-mini"},{"slug":"codex-mini-latest"}]}`)} + manifest := &OpenAIModelsResponse{Body: []byte(`{"models":[{"slug":"gpt-5.6-sol"},{"slug":"gpt-4o-realtime-preview"},{"slug":"gpt-4o-mini-tts"},{"slug":"text-embedding-3-large"},{"slug":"omni-moderation-latest"},{"slug":"o4-mini"},{"slug":"codex-mini-latest"}]}`)} account := newCodexModelsAPIKeyTestAccount("") require.NoError(t, svc.CompleteAPIKeyCodexModelsManifestForClient(manifest, account)) @@ -2191,8 +2245,8 @@ func TestAdjustAPIKeyCodexModelsManifest(t *testing.T) { }{ { name: "affected models disable responses lite and preserve unknown fields", - body: `{"models":[{"slug":"gpt-5.6-sol","use_responses_lite":true,"unknown_model":{"enabled":true}},{"slug":"gpt-5.6-terra","use_responses_lite":true},{"slug":"gpt-5.6-luna","use_responses_lite":true}],"unknown_top":{"version":1}}`, - want: `{"models":[{"slug":"gpt-5.6-sol","unknown_model":{"enabled":true},"use_responses_lite":false},{"slug":"gpt-5.6-terra","use_responses_lite":false},{"slug":"gpt-5.6-luna","use_responses_lite":false}],"unknown_top":{"version":1}}`, + body: `{"models":[{"slug":"gpt-6-astra","use_responses_lite":true},{"slug":"gpt-5.6-sol","use_responses_lite":true,"unknown_model":{"enabled":true}},{"slug":"gpt-5.6-terra","use_responses_lite":true},{"slug":"gpt-5.6-luna","use_responses_lite":true}],"unknown_top":{"version":1}}`, + want: `{"models":[{"slug":"gpt-6-astra","use_responses_lite":false},{"slug":"gpt-5.6-sol","unknown_model":{"enabled":true},"use_responses_lite":false},{"slug":"gpt-5.6-terra","use_responses_lite":false},{"slug":"gpt-5.6-luna","use_responses_lite":false}],"unknown_top":{"version":1}}`, }, { name: "unaffected model unchanged", @@ -2208,7 +2262,7 @@ func TestAdjustAPIKeyCodexModelsManifest(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, err := adjustAPIKeyCodexModelsManifest([]byte(tt.body)) + got, err := adjustAPIKeyCodexModelsManifest([]byte(tt.body), nil) require.NoError(t, err) require.Equal(t, tt.want, string(got)) }) @@ -2493,13 +2547,13 @@ func TestFetchCodexModelsManifestAPIKeySharedRefreshSurvivesCallerCancellation(t } secondResult := make(chan struct { - manifest *CodexModelsManifest + manifest *OpenAIModelsResponse err error }, 1) go func() { manifest, err := s.FetchCodexModelsManifest(context.Background(), account, "0.144.0", "") secondResult <- struct { - manifest *CodexModelsManifest + manifest *OpenAIModelsResponse err error }{manifest: manifest, err: err} }() @@ -2809,21 +2863,21 @@ func TestFetchCodexModelsManifestAPIKeyServesStaleWhileRefreshing(t *testing.T) t.Fatalf("initial fetch returned error: %v", err) } - s.codexModelsManifestCache.mu.Lock() - for key, entry := range s.codexModelsManifestCache.entries { + s.openAIModelsCache.mu.Lock() + for key, entry := range s.openAIModelsCache.entries { entry.expiresAt = time.Now().Add(-time.Second) - s.codexModelsManifestCache.entries[key] = entry + s.openAIModelsCache.entries[key] = entry } - s.codexModelsManifestCache.mu.Unlock() + s.openAIModelsCache.mu.Unlock() resultCh := make(chan struct { - manifest *CodexModelsManifest + manifest *OpenAIModelsResponse err error }, 1) go func() { manifest, err := s.FetchCodexModelsManifest(context.Background(), account, "0.144.0", "") resultCh <- struct { - manifest *CodexModelsManifest + manifest *OpenAIModelsResponse err error }{manifest: manifest, err: err} }() @@ -2834,7 +2888,7 @@ func TestFetchCodexModelsManifestAPIKeyServesStaleWhileRefreshing(t *testing.T) } var staleResult struct { - manifest *CodexModelsManifest + manifest *OpenAIModelsResponse err error } select { @@ -2902,12 +2956,12 @@ func TestFetchCodexModelsManifestAPIKeyRevalidatesStaleETag(t *testing.T) { if _, err := s.FetchCodexModelsManifest(context.Background(), account, "0.144.0", ""); err != nil { t.Fatalf("initial fetch returned error: %v", err) } - s.codexModelsManifestCache.mu.Lock() - for key, entry := range s.codexModelsManifestCache.entries { + s.openAIModelsCache.mu.Lock() + for key, entry := range s.openAIModelsCache.entries { entry.expiresAt = time.Now().Add(-time.Second) - s.codexModelsManifestCache.entries[key] = entry + s.openAIModelsCache.entries[key] = entry } - s.codexModelsManifestCache.mu.Unlock() + s.openAIModelsCache.mu.Unlock() manifest, err := s.FetchCodexModelsManifest(context.Background(), account, "0.144.0", "") if err != nil { @@ -2924,12 +2978,12 @@ func TestFetchCodexModelsManifestAPIKeyRevalidatesStaleETag(t *testing.T) { deadline := time.Now().Add(time.Second) for { - s.codexModelsManifestCache.mu.Lock() + s.openAIModelsCache.mu.Lock() fresh := false - for _, entry := range s.codexModelsManifestCache.entries { + for _, entry := range s.openAIModelsCache.entries { fresh = time.Now().Before(entry.expiresAt) } - s.codexModelsManifestCache.mu.Unlock() + s.openAIModelsCache.mu.Unlock() if fresh { break } @@ -3294,13 +3348,13 @@ func newCodexModelsOAuthCacheServer(t *testing.T, body string) (*httptest.Server } func expireCodexModelsManifestCache(s *OpenAIGatewayService, age time.Duration) { - s.codexModelsManifestCache.mu.Lock() - for key, entry := range s.codexModelsManifestCache.entries { + s.openAIModelsCache.mu.Lock() + for key, entry := range s.openAIModelsCache.entries { entry.expiresAt = time.Now().Add(-age) - entry.staleUntil = time.Now().Add(codexModelsManifestCacheStaleTTL - age) - s.codexModelsManifestCache.entries[key] = entry + entry.staleUntil = time.Now().Add(openAIModelsCacheStaleTTL - age) + s.openAIModelsCache.entries[key] = entry } - s.codexModelsManifestCache.mu.Unlock() + s.openAIModelsCache.mu.Unlock() } func TestFetchCodexModelsManifestOAuthFreshWindowZeroUpstreamRequests(t *testing.T) { @@ -3343,7 +3397,7 @@ func TestFetchCodexModelsManifestOAuthStaleServesOldValueAndRefreshesInBackgroun expireCodexModelsManifestCache(s, 2*time.Minute) - resultCh := make(chan *CodexModelsManifest, 1) + resultCh := make(chan *OpenAIModelsResponse, 1) errCh := make(chan error, 1) go func() { manifest, fetchErr := s.FetchCodexModelsManifest(context.Background(), account, "0.137.0", "") @@ -3373,9 +3427,9 @@ func TestFetchCodexModelsManifestOAuthStaleServesOldValueAndRefreshesInBackgroun if fetchErr == nil && manifest != nil && !manifest.NotModified { require.Contains(t, string(manifest.Body), `"new"`) } - s.codexModelsManifestCache.mu.Lock() - fresh := len(s.codexModelsManifestCache.entries) > 0 - s.codexModelsManifestCache.mu.Unlock() + s.openAIModelsCache.mu.Lock() + fresh := len(s.openAIModelsCache.entries) > 0 + s.openAIModelsCache.mu.Unlock() if fresh { break } diff --git a/backend/internal/service/openai_codex_transform.go b/backend/internal/service/openai_codex_transform.go index fbd14ba967..ef722a93c1 100644 --- a/backend/internal/service/openai_codex_transform.go +++ b/backend/internal/service/openai_codex_transform.go @@ -11,6 +11,7 @@ import ( ) var codexModelMap = map[string]string{ + "gpt-6-astra": "gpt-6-astra", "gpt-5.6-sol": "gpt-5.6-sol", "gpt-5.6-terra": "gpt-5.6-terra", "gpt-5.6-luna": "gpt-5.6-luna", diff --git a/backend/internal/service/openai_codex_transform_test.go b/backend/internal/service/openai_codex_transform_test.go index 96e7715d5d..899ba7d933 100644 --- a/backend/internal/service/openai_codex_transform_test.go +++ b/backend/internal/service/openai_codex_transform_test.go @@ -1361,6 +1361,10 @@ func TestApplyCodexOAuthTransform_EmptyInput(t *testing.T) { func TestNormalizeCodexModel_Gpt53(t *testing.T) { cases := map[string]string{ + "gpt-6-astra": "gpt-6-astra", + "openai/gpt-6-astra": "gpt-6-astra", + "gpt-6": "gpt-6-astra", + "openai/gpt-6": "gpt-6-astra", "gpt-5.4": "gpt-5.4", "gpt5.5": "gpt-5.5", "openai/gpt5.5": "gpt-5.5", diff --git a/backend/internal/service/openai_compat_model_test.go b/backend/internal/service/openai_compat_model_test.go index 23b90bebb6..945615bf57 100644 --- a/backend/internal/service/openai_compat_model_test.go +++ b/backend/internal/service/openai_compat_model_test.go @@ -2271,3 +2271,43 @@ func TestForwardAsAnthropic_UpstreamRequestIgnoresClientCancel(t *testing.T) { require.NotNil(t, upstream.lastReq) require.NoError(t, upstream.lastReq.Context().Err()) } + +func TestForwardAsAnthropic_AstraContinuationRestoresHistoryAndDisablesUnsupportedSession(t *testing.T) { + for _, message := range []string{ + "previous_response_id is not available for this user", + "previous_response_id requires an OpenAI API-key account for HTTP requests", + } { + t.Run(message, func(t *testing.T) { + encodedError, err := json.Marshal(map[string]any{"error": map[string]string{"message": message}}) + require.NoError(t, err) + upstream := &httpUpstreamRecorder{responses: []*http.Response{ + {StatusCode: http.StatusBadRequest, Body: io.NopCloser(bytes.NewReader(encodedError))}, + openAICompatSSECompletedResponse("resp_replayed", "gpt-6-astra"), + openAICompatSSECompletedResponse("resp_later", "gpt-6-astra"), + }} + svc := &OpenAIGatewayService{httpUpstream: upstream, cfg: &config.Config{}} + account := rawGPT56ResponsesAPIKeyAccount("gpt-6-astra", "gpt-6-astra") + svc.bindOpenAICompatSessionResponseID(context.Background(), nil, account, "astra-session", "resp_old") + body := []byte(`{"model":"gpt-6-astra","max_tokens":16,"messages":[{"role":"user","content":"first"},{"role":"assistant","content":"ok"},{"role":"user","content":"second"}]}`) + for i := 0; i < 2; i++ { + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body)) + result, err := svc.ForwardAsAnthropic(context.Background(), c, account, body, "astra-session", "gpt-6-astra") + require.NoError(t, err) + require.NotNil(t, result) + } + require.Len(t, upstream.bodies, 3) + require.Equal(t, "resp_old", gjson.GetBytes(upstream.bodies[0], "previous_response_id").String()) + for _, sent := range upstream.bodies[1:] { + require.False(t, gjson.GetBytes(sent, "previous_response_id").Exists()) + require.Equal(t, "astra-session", gjson.GetBytes(sent, "prompt_cache_key").String()) + require.Equal(t, int64(4), gjson.GetBytes(sent, "input.#").Int()) + require.Contains(t, gjson.GetBytes(sent, "input.0.content.0.text").String(), "") + require.Equal(t, "first", gjson.GetBytes(sent, "input.1.content.0.text").String()) + require.Equal(t, "second", gjson.GetBytes(sent, "input.3.content.0.text").String()) + } + }) + } + require.False(t, isOpenAICompatPreviousResponseUnsupported(http.StatusUnauthorized, "previous_response_id is not available for this user", nil)) + require.False(t, isOpenAICompatPreviousResponseUnsupported(http.StatusBadRequest, "The model is not available for this user", nil)) +} diff --git a/backend/internal/service/openai_cyber_policy.go b/backend/internal/service/openai_cyber_policy.go index 524bade30c..9e51e72483 100644 --- a/backend/internal/service/openai_cyber_policy.go +++ b/backend/internal/service/openai_cyber_policy.go @@ -86,3 +86,22 @@ func detectOpenAICyberPolicy(payload []byte) (bool, string, string) { } return true, "cyber_policy", strings.TrimSpace(msg) } + +func markOpenAICyberPolicyEvent(c *gin.Context, payload []byte, upstreamStatus int, usage *OpenAIUsage) bool { + hit, code, message := detectOpenAICyberPolicy(payload) + if !hit { + return false + } + mark := CyberPolicyMark{ + Code: code, + Message: message, + Body: truncateString(string(payload), 4096), + UpstreamStatus: upstreamStatus, + } + if usage != nil { + mark.UpstreamInTok = usage.InputTokens + mark.UpstreamOutTok = usage.OutputTokens + } + MarkOpsCyberPolicy(c, mark) + return true +} diff --git a/backend/internal/service/openai_embeddings.go b/backend/internal/service/openai_embeddings.go index 0d1fcbd1e1..c4b8f79bda 100644 --- a/backend/internal/service/openai_embeddings.go +++ b/backend/internal/service/openai_embeddings.go @@ -118,7 +118,7 @@ func (s *OpenAIGatewayService) ForwardEmbeddings( upstreamMsg := strings.TrimSpace(extractUpstreamErrorMessage(respBody)) upstreamMsg = sanitizeUpstreamErrorMessage(upstreamMsg) - if s.shouldFailoverOpenAIUpstreamResponse(resp.StatusCode, upstreamMsg, respBody) { + if s.shouldFailoverOpenAIUpstreamResponse(account, resp.StatusCode, upstreamMsg, respBody) { upstreamDetail := "" if s.cfg != nil && s.cfg.Gateway.LogUpstreamErrorBody { maxBytes := s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes diff --git a/backend/internal/service/openai_fast_policy_test.go b/backend/internal/service/openai_fast_policy_test.go index 23486ba2f8..a29e7ffd21 100644 --- a/backend/internal/service/openai_fast_policy_test.go +++ b/backend/internal/service/openai_fast_policy_test.go @@ -100,6 +100,9 @@ func TestEvaluateOpenAIFastPolicy_DefaultPassesKnownTiers(t *testing.T) { action, _ = svc.evaluateOpenAIFastPolicy(context.Background(), account, "gpt-5.5", OpenAIFastTierFlex) require.Equal(t, BetaPolicyActionPass, action) + action, _ = svc.evaluateOpenAIFastPolicy(context.Background(), account, "gpt-5.6-sol", OpenAIFastTierUltrafast) + require.Equal(t, BetaPolicyActionPass, action) + // empty tier → pass action, _ = svc.evaluateOpenAIFastPolicy(context.Background(), account, "gpt-5.5", "") require.Equal(t, BetaPolicyActionPass, action) @@ -248,6 +251,16 @@ func TestApplyOpenAIFastPolicyToBody_UserScopedRuleOverridesGlobalRule(t *testin require.NotContains(t, string(updated), `"service_tier"`) } +func TestApplyOpenAIFastPolicyToBody_PriorityFilterLeavesUltrafast(t *testing.T) { + svc := newOpenAIGatewayServiceWithSettings(t, openAIFastFilterPriorityPolicy()) + account := &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey} + body := []byte(`{"model":"gpt-5.6-sol","service_tier":"ultrafast"}`) + + updated, err := svc.applyOpenAIFastPolicyToBody(context.Background(), account, "gpt-5.6-sol", body) + require.NoError(t, err) + require.Equal(t, OpenAIFastTierUltrafast, gjson.GetBytes(updated, "service_tier").String()) +} + func TestApplyOpenAIFastPolicyToBody_ForcePriorityRewritesKnownTier(t *testing.T) { settings := &OpenAIFastPolicySettings{ Rules: []OpenAIFastPolicyRule{{ @@ -259,7 +272,7 @@ func TestApplyOpenAIFastPolicyToBody_ForcePriorityRewritesKnownTier(t *testing.T svc := newOpenAIGatewayServiceWithSettings(t, settings) account := &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey} - for _, tier := range []string{"flex", "auto", "default", "scale", "fast", "priority"} { + for _, tier := range []string{"flex", "auto", "default", "scale", "fast", "priority", "ultrafast"} { body := []byte(`{"model":"gpt-5.5","service_tier":"` + tier + `"}`) updated, err := svc.applyOpenAIFastPolicyToBody(context.Background(), account, "gpt-5.5", body) require.NoError(t, err) @@ -480,19 +493,28 @@ func TestSetOpenAIFastPolicySettings_Validation(t *testing.T) { // Valid settings persisted err = svc.SetOpenAIFastPolicySettings(context.Background(), &OpenAIFastPolicySettings{ - Rules: []OpenAIFastPolicyRule{{ - ServiceTier: OpenAIFastTierPriority, - Action: OpenAIFastPolicyActionForcePriority, - Scope: BetaPolicyScopeAll, - UserIDs: []int64{42, 43}, - }}, + Rules: []OpenAIFastPolicyRule{ + { + ServiceTier: OpenAIFastTierPriority, + Action: OpenAIFastPolicyActionForcePriority, + Scope: BetaPolicyScopeAll, + UserIDs: []int64{42, 43}, + }, + { + ServiceTier: OpenAIFastTierUltrafast, + Action: BetaPolicyActionPass, + Scope: BetaPolicyScopeAll, + }, + }, }) require.NoError(t, err) got, err := svc.GetOpenAIFastPolicySettings(context.Background()) require.NoError(t, err) - require.Len(t, got.Rules, 1) + require.Len(t, got.Rules, 2) require.Equal(t, OpenAIFastTierPriority, got.Rules[0].ServiceTier) require.Equal(t, OpenAIFastPolicyActionForcePriority, got.Rules[0].Action) require.Equal(t, []int64{42, 43}, got.Rules[0].UserIDs) + require.Equal(t, OpenAIFastTierUltrafast, got.Rules[1].ServiceTier) + require.Equal(t, BetaPolicyActionPass, got.Rules[1].Action) } diff --git a/backend/internal/service/openai_fast_policy_ws_test.go b/backend/internal/service/openai_fast_policy_ws_test.go index 607d32a22a..c154f32abf 100644 --- a/backend/internal/service/openai_fast_policy_ws_test.go +++ b/backend/internal/service/openai_fast_policy_ws_test.go @@ -112,7 +112,7 @@ func TestWSResponseCreate_ForcePriorityRewritesKnownTier(t *testing.T) { svc := newOpenAIGatewayServiceWithSettings(t, settings) account := &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey} - for _, tier := range []string{"flex", "auto", "default", "scale", "fast", "priority"} { + for _, tier := range []string{"flex", "auto", "default", "scale", "fast", "priority", "ultrafast"} { frame := []byte(`{"type":"response.create","model":"gpt-5.5","service_tier":"` + tier + `"}`) updated, blocked, err := svc.applyOpenAIFastPolicyToWSResponseCreate(context.Background(), account, "gpt-5.5", frame) require.NoError(t, err) diff --git a/backend/internal/service/openai_fast_service_tier_test.go b/backend/internal/service/openai_fast_service_tier_test.go index 69ea1372ee..96a4f8ddfa 100644 --- a/backend/internal/service/openai_fast_service_tier_test.go +++ b/backend/internal/service/openai_fast_service_tier_test.go @@ -43,7 +43,7 @@ func TestValidateOpenAIServiceTierField(t *testing.T) { }) t.Run("official tiers pass through", func(t *testing.T) { - for _, tier := range []string{"flex", "auto", "default", "scale"} { + for _, tier := range []string{"flex", "auto", "default", "scale", "ultrafast"} { norm, err := ValidateOpenAIServiceTierField([]byte(`{"model":"gpt-5.5","service_tier":"` + tier + `"}`)) require.NoError(t, err, "tier %q must be accepted", tier) require.Equal(t, tier, norm) diff --git a/backend/internal/service/openai_gateway_cc_pipeline.go b/backend/internal/service/openai_gateway_cc_pipeline.go index 325081d9da..68640507df 100644 --- a/backend/internal/service/openai_gateway_cc_pipeline.go +++ b/backend/internal/service/openai_gateway_cc_pipeline.go @@ -88,7 +88,7 @@ func (s *OpenAIGatewayService) failoverOpenAIUpstreamHTTPError( upstreamMsg string, upstreamModel string, ) *UpstreamFailoverError { - shouldFailover := s.shouldFailoverOpenAIUpstreamResponse(resp.StatusCode, upstreamMsg, respBody) + shouldFailover := s.shouldFailoverOpenAIUpstreamResponse(account, resp.StatusCode, upstreamMsg, respBody) tempUnscheduled := false if c != nil && account != nil && account.Platform != PlatformGrok && !shouldFailover && !IsResponseCommitted(c) && s.rateLimitService != nil { tempUnscheduled = s.rateLimitService.CheckErrorPolicy(ctx, account, resp.StatusCode, respBody, upstreamModel) == ErrorPolicyTempUnscheduled @@ -223,6 +223,7 @@ func (s *OpenAIGatewayService) sendCCUpstreamRequest( // 账号级请求头覆写:放在所有内置默认头(含 Grok CLI 身份头)之后应用, // 使配置值获得除共享传输层强制头之外的最高优先级。 account.ApplyHeaderOverrides(upstreamReq.Header) + applyOpenCodeSessionHeader(c, account, targetURL, upstreamReq.Header) proxyURL := "" if account.ProxyID != nil && account.Proxy != nil { diff --git a/backend/internal/service/openai_gateway_chat_completions_test.go b/backend/internal/service/openai_gateway_chat_completions_test.go index 7225e11cf5..35f6de0ed1 100644 --- a/backend/internal/service/openai_gateway_chat_completions_test.go +++ b/backend/internal/service/openai_gateway_chat_completions_test.go @@ -148,6 +148,11 @@ func TestNormalizeResponsesBodyServiceTier(t *testing.T) { require.Equal(t, "scale", tier) require.Equal(t, "scale", gjson.GetBytes(body, "service_tier").String()) + body, tier, err = normalizeResponsesBodyServiceTier([]byte(`{"model":"gpt-5.6-sol","service_tier":"ultrafast"}`)) + require.NoError(t, err) + require.Equal(t, "ultrafast", tier) + require.Equal(t, "ultrafast", gjson.GetBytes(body, "service_tier").String()) + // 真未知值才会被删除。 body, tier, err = normalizeResponsesBodyServiceTier([]byte(`{"model":"gpt-5.1","service_tier":"turbo"}`)) require.NoError(t, err) diff --git a/backend/internal/service/openai_gateway_count_tokens_test.go b/backend/internal/service/openai_gateway_count_tokens_test.go index 6aa8825c9e..37bfa91e00 100644 --- a/backend/internal/service/openai_gateway_count_tokens_test.go +++ b/backend/internal/service/openai_gateway_count_tokens_test.go @@ -4,8 +4,10 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" "io" + "net" "net/http" "net/http/httptest" "os" @@ -350,9 +352,13 @@ func TestEstimateOpenAIInputTokens_CompareWithOpenAIAPI(t *testing.T) { actual, err := callOpenAIInputTokensAPIForTest(client, apiKey, prepared.Request) if err != nil { - // Live-API comparison only; invalid/expired local keys should skip, not fail CI. - if strings.Contains(err.Error(), "status=401") || strings.Contains(err.Error(), "invalid_api_key") { - t.Skipf("OPENAI_API_KEY rejected by OpenAI: %v", err) + // This is an optional live-API comparison. Credential and transient + // network failures must not make the deterministic unit suite fail. + var netErr net.Error + if strings.Contains(err.Error(), "status=401") || + strings.Contains(err.Error(), "invalid_api_key") || + errors.As(err, &netErr) { + t.Skipf("OpenAI live comparison unavailable: %v", err) } require.NoError(t, err) } diff --git a/backend/internal/service/openai_gateway_forward.go b/backend/internal/service/openai_gateway_forward.go index 131e8e1477..d389c6c7f8 100644 --- a/backend/internal/service/openai_gateway_forward.go +++ b/backend/internal/service/openai_gateway_forward.go @@ -1136,7 +1136,7 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco ) continue } - if s.shouldFailoverOpenAIUpstreamResponse(resp.StatusCode, upstreamMsg, respBody) { + if s.shouldFailoverOpenAIUpstreamResponse(account, resp.StatusCode, upstreamMsg, respBody) { upstreamDetail := "" if s.cfg != nil && s.cfg.Gateway.LogUpstreamErrorBody { maxBytes := s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes @@ -1211,7 +1211,7 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco _ = resp.Body.Close() } compactResp, compactBody := openAICompactFallbackErrorResponse(resp, signal) - if s.shouldFailoverOpenAIUpstreamResponse(compactResp.StatusCode, signal.message, compactBody) { + if s.shouldFailoverOpenAIUpstreamResponse(account, compactResp.StatusCode, signal.message, compactBody) { appendOpsUpstreamError(c, OpsUpstreamErrorEvent{ ProxyID: opsUpstreamProxyID(account), ProxyName: opsUpstreamProxyName(account), @@ -1478,6 +1478,7 @@ func (s *OpenAIGatewayService) buildUpstreamRequest(ctx context.Context, c *gin. // 账号级请求头覆写(仅 openai api_key 账号启用时生效;OAuth 路径 no-op) account.ApplyHeaderOverrides(req.Header) + applyOpenCodeSessionHeader(c, account, targetURL, req.Header) // x-codex-beta-features:按真实 Codex 的会话级行为补注(在账号级覆写之后, // 保证不被覆盖丢失)。 applyOpenAICodexBetaFeatures(c, account, req.Header) diff --git a/backend/internal/service/openai_gateway_messages_anthropic_native.go b/backend/internal/service/openai_gateway_messages_anthropic_native.go index 9c1d37a661..d5daef8442 100644 --- a/backend/internal/service/openai_gateway_messages_anthropic_native.go +++ b/backend/internal/service/openai_gateway_messages_anthropic_native.go @@ -57,6 +57,9 @@ func (s *OpenAIGatewayService) forwardAnthropicViaNativeAnthropicEndpoint( } body = rewritten } + if normalized, changed := NormalizeGLM53AnthropicThinking(body, upstreamModel); changed { + body = normalized + } // 记录客户端请求的推理强度:优先 Claude 协议的 output_config.effort; // 缺失且 thinking 已启用时,按国产 passback-required 模型兜底为 high diff --git a/backend/internal/service/openai_gateway_messages_anthropic_native_test.go b/backend/internal/service/openai_gateway_messages_anthropic_native_test.go index 2f68982094..bea8ab1cef 100644 --- a/backend/internal/service/openai_gateway_messages_anthropic_native_test.go +++ b/backend/internal/service/openai_gateway_messages_anthropic_native_test.go @@ -4,6 +4,7 @@ package service import ( "context" + "fmt" "io" "net/http" "strings" @@ -11,6 +12,7 @@ import ( "github.com/gin-gonic/gin" "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" ) // 国产供应商原生 Anthropic 直通路径(api_protocol=anthropic)的 reasoning_effort 记录。 @@ -35,6 +37,13 @@ func nativeAnthropicTestAccount() *Account { } } +func nativeAnthropicGLMTestAccount() *Account { + account := nativeAnthropicTestAccount() + account.Name = "zhipu-native" + account.Platform = PlatformZhipu + return account +} + func nativeAnthropicBufferedResponse() *http.Response { return &http.Response{ StatusCode: http.StatusOK, @@ -137,3 +146,70 @@ func TestNativeAnthropicPassthroughNoEffortStaysNil(t *testing.T) { require.NotNil(t, result) require.Nil(t, result.ReasoningEffort) } + +func TestNativeAnthropicPassthroughNormalizesGLM53Thinking(t *testing.T) { + gin.SetMode(gin.TestMode) + tests := []struct { + name string + stream bool + preference string + wantEffort string + }{ + {name: "disabled buffered", preference: `"thinking":{"type":"disabled"},`, wantEffort: "low"}, + {name: "off buffered", preference: `"thinking":{"type":"off"},`, wantEffort: "low"}, + {name: "none buffered", preference: `"thinking":{"type":"none"},`, wantEffort: "low"}, + {name: "enabled buffered", preference: `"thinking":{"type":"enabled"},`, wantEffort: "high"}, + {name: "enabled streaming", stream: true, preference: `"thinking":{"type":"enabled"},`, wantEffort: "high"}, + {name: "adaptive buffered", preference: `"thinking":{"type":"adaptive"},`, wantEffort: "high"}, + {name: "adaptive streaming", stream: true, preference: `"thinking":{"type":"adaptive"},`, wantEffort: "high"}, + {name: "minimal buffered", preference: `"output_config":{"effort":"minimal"},`, wantEffort: "low"}, + {name: "low buffered", preference: `"output_config":{"effort":"low"},`, wantEffort: "low"}, + {name: "medium streaming", stream: true, preference: `"output_config":{"effort":"medium"},`, wantEffort: "high"}, + {name: "high buffered", preference: `"output_config":{"effort":"high"},`, wantEffort: "high"}, + {name: "xhigh buffered", preference: `"output_config":{"effort":"xhigh"},`, wantEffort: "max"}, + {name: "max buffered", preference: `"output_config":{"effort":"max"},`, wantEffort: "max"}, + {name: "ultra buffered", preference: `"output_config":{"effort":"ultra"},`, wantEffort: "max"}, + {name: "output effort wins over thinking", preference: `"thinking":{"type":"adaptive"},"output_config":{"effort":"low"},`, wantEffort: "low"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + body := []byte(fmt.Sprintf(`{"model":"glm-5.3","max_tokens":32,"stream":%t,%s"messages":[{"role":"user","content":"hi"}]}`, tt.stream, tt.preference)) + response := nativeAnthropicBufferedResponse() + if tt.stream { + response = nativeAnthropicStreamResponse() + } + upstream := &httpUpstreamRecorder{resp: response} + svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream} + + _, err := svc.ForwardAsAnthropic(context.Background(), + adaptiveProtocolTestContext("/v1/messages", body), nativeAnthropicGLMTestAccount(), body, "", "") + require.NoError(t, err) + require.Equal(t, "enabled", gjson.GetBytes(upstream.lastBody, "thinking.type").String()) + require.Equal(t, tt.wantEffort, gjson.GetBytes(upstream.lastBody, "output_config.effort").String()) + }) + } +} + +func TestNativeAnthropicPassthroughLeavesOtherThinkingUntouched(t *testing.T) { + gin.SetMode(gin.TestMode) + tests := []struct { + name string + body string + }{ + {name: "glm 5.3 unspecified", body: `{"model":"glm-5.3","max_tokens":32,"stream":false,"messages":[]}`}, + {name: "glm 5.2 disabled", body: `{"model":"glm-5.2","max_tokens":32,"stream":false,"thinking":{"type":"disabled"},"messages":[]}`}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + body := []byte(tt.body) + upstream := &httpUpstreamRecorder{resp: nativeAnthropicBufferedResponse()} + svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream} + _, err := svc.ForwardAsAnthropic(context.Background(), + adaptiveProtocolTestContext("/v1/messages", body), nativeAnthropicGLMTestAccount(), body, "", "") + require.NoError(t, err) + require.JSONEq(t, tt.body, string(upstream.lastBody)) + }) + } +} diff --git a/backend/internal/service/openai_gateway_passthrough.go b/backend/internal/service/openai_gateway_passthrough.go index d343497f68..7a949209b9 100644 --- a/backend/internal/service/openai_gateway_passthrough.go +++ b/backend/internal/service/openai_gateway_passthrough.go @@ -721,6 +721,7 @@ func (s *OpenAIGatewayService) buildUpstreamRequestOpenAIPassthrough( // 账号级请求头覆写(仅 openai api_key 账号启用时生效;OAuth 路径 no-op) account.ApplyHeaderOverrides(req.Header) + applyOpenCodeSessionHeader(c, account, targetURL, req.Header) // x-codex-beta-features:按真实 Codex 的会话级行为补注(在账号级覆写之后, // 保证不被覆盖丢失)。 applyOpenAICodexBetaFeatures(c, account, req.Header) diff --git a/backend/internal/service/openai_gateway_record_usage_test.go b/backend/internal/service/openai_gateway_record_usage_test.go index 1973bba56e..b3f4153784 100644 --- a/backend/internal/service/openai_gateway_record_usage_test.go +++ b/backend/internal/service/openai_gateway_record_usage_test.go @@ -1466,9 +1466,8 @@ func TestNormalizeOpenAIServiceTier(t *testing.T) { t.Run("openai official tiers preserved", func(t *testing.T) { // OpenAI 官方文档定义的合法 tier 值都应被透传保留,避免因白名单过窄 - // 静默剥离客户端显式发送的合法字段。Codex 客户端只发 priority/flex, - // 所以扩大白名单对 Codex 流量零影响(见 codex-rs/core/src/client.rs)。 - for _, tier := range []string{"priority", "flex", "auto", "default", "scale"} { + // 静默剥离客户端显式发送的合法字段。Codex 会发 priority/flex/ultrafast。 + for _, tier := range []string{"priority", "flex", "auto", "default", "scale", "ultrafast"} { got := normalizeOpenAIServiceTier(tier) require.NotNil(t, got, "tier %q should not be normalized to nil", tier) require.Equal(t, tier, *got) @@ -1487,6 +1486,7 @@ func TestExtractOpenAIServiceTier(t *testing.T) { require.Equal(t, "auto", *extractOpenAIServiceTier(map[string]any{"service_tier": "auto"})) require.Equal(t, "default", *extractOpenAIServiceTier(map[string]any{"service_tier": "default"})) require.Equal(t, "scale", *extractOpenAIServiceTier(map[string]any{"service_tier": "scale"})) + require.Equal(t, "ultrafast", *extractOpenAIServiceTier(map[string]any{"service_tier": "ultrafast"})) require.Nil(t, extractOpenAIServiceTier(map[string]any{"service_tier": 1})) require.Nil(t, extractOpenAIServiceTier(nil)) } @@ -1497,6 +1497,7 @@ func TestExtractOpenAIServiceTierFromBody(t *testing.T) { require.Equal(t, "auto", *extractOpenAIServiceTierFromBody([]byte(`{"service_tier":"auto"}`))) require.Equal(t, "default", *extractOpenAIServiceTierFromBody([]byte(`{"service_tier":"default"}`))) require.Equal(t, "scale", *extractOpenAIServiceTierFromBody([]byte(`{"service_tier":"scale"}`))) + require.Equal(t, "ultrafast", *extractOpenAIServiceTierFromBody([]byte(`{"service_tier":"ultrafast"}`))) require.Nil(t, extractOpenAIServiceTierFromBody([]byte(`{"service_tier":"turbo"}`))) require.Nil(t, extractOpenAIServiceTierFromBody(nil)) } diff --git a/backend/internal/service/openai_gateway_request_body.go b/backend/internal/service/openai_gateway_request_body.go index 46f69b54d6..9defec9e65 100644 --- a/backend/internal/service/openai_gateway_request_body.go +++ b/backend/internal/service/openai_gateway_request_body.go @@ -18,24 +18,28 @@ import ( ) func (s *OpenAIGatewayService) validateUpstreamBaseURL(raw string) (string, error) { - if s.cfg != nil && !s.cfg.Security.URLAllowlist.Enabled { - normalized, err := urlvalidator.ValidateURLFormat(raw, s.cfg.Security.URLAllowlist.AllowInsecureHTTP) - if err != nil { - return "", fmt.Errorf("invalid base_url: %w", err) - } - return normalized, nil - } - normalized, err := urlvalidator.ValidateHTTPSURL(raw, urlvalidator.ValidationOptions{ - AllowedHosts: s.cfg.Security.URLAllowlist.UpstreamHosts, - RequireAllowlist: true, - AllowPrivate: s.cfg.Security.URLAllowlist.AllowPrivateHosts, - }) + normalized, err := s.validateOutboundURL(raw) if err != nil { return "", fmt.Errorf("invalid base_url: %w", err) } return normalized, nil } +// validateOutboundURL 按 security.url_allowlist 策略校验网关主动连接的出站 URL。 +func (s *OpenAIGatewayService) validateOutboundURL(raw string) (string, error) { + if s.cfg == nil { + return urlvalidator.ValidateURLFormat(raw, false) + } + if !s.cfg.Security.URLAllowlist.Enabled { + return urlvalidator.ValidateURLFormat(raw, s.cfg.Security.URLAllowlist.AllowInsecureHTTP) + } + return urlvalidator.ValidateHTTPSURL(raw, urlvalidator.ValidationOptions{ + AllowedHosts: s.cfg.Security.URLAllowlist.UpstreamHosts, + RequireAllowlist: true, + AllowPrivate: s.cfg.Security.URLAllowlist.AllowPrivateHosts, + }) +} + // buildOpenAIResponsesURL 组装 OpenAI Responses 端点。 // - base 以 /v1 结尾:追加 /responses // - base 以其他版本段结尾(如 /v4):追加 /responses @@ -59,6 +63,9 @@ func shouldPreserveOpenAIResponsesNoneReasoningEffort(account *Account) bool { if account == nil { return false } + if account.IsOpenAIPassthroughEnabled() { + return true + } if account.IsOpenAIOAuthLike() { return true } @@ -1435,12 +1442,12 @@ func normalizeOpenAIServiceTier(raw string) *string { if value == "fast" { value = "priority" } - // 放过 OpenAI 官方文档定义的所有合法 tier 值:priority/flex/auto/default/scale。 - // 对 Codex 客户端零影响(Codex 只发 priority 或 flex,见 codex-rs/core/src/client.rs), - // 但能让直连 OpenAI SDK 的用户透传 auto/default/scale 以便抓包/调试。 - // 真未知值仍返回 nil,由 normalizeResponsesBodyServiceTier 从 body 中删除。 + // 放过 OpenAI 官方文档定义的合法 tier 值,以及 Codex/API 新增的 ultrafast。 + // Codex 客户端会发 priority、flex 或 ultrafast;直连 OpenAI SDK 的用户还会 + // 透传 auto/default/scale。真未知值仍返回 nil,由 + // normalizeResponsesBodyServiceTier 从 body 中删除。 switch value { - case "priority", "flex", "auto", "default", "scale": + case "priority", "flex", "auto", "default", "scale", OpenAIFastTierUltrafast: return &value default: return nil @@ -1457,7 +1464,7 @@ type ErrInvalidOpenAIServiceTier struct { } func (e *ErrInvalidOpenAIServiceTier) Error() string { - return fmt.Sprintf("invalid service_tier %q: must be one of auto, default, fast, flex, priority, scale", e.Value) + return fmt.Sprintf("invalid service_tier %q: must be one of auto, default, fast, flex, priority, scale, ultrafast", e.Value) } const invalidOpenAIServiceTierValueMaxLen = 64 @@ -1475,7 +1482,7 @@ func boundInvalidOpenAIServiceTierValue(raw string) string { // - absent / null → valid, returns "" (field omitted keeps current behavior) // - "fast" → normalized to "priority" (the two are equivalent; the canonical // value is what reaches the OpenAI upstream) -// - "priority" / "flex" / "auto" / "default" / "scale" → valid, returned as-is +// - "priority" / "flex" / "auto" / "default" / "scale" / "ultrafast" → valid, returned as-is // - an explicitly present non-string value, an empty string, or any other // unknown value → *ErrInvalidOpenAIServiceTier (handler maps to HTTP 400), // matching OpenAI's enum validation semantics @@ -2162,7 +2169,7 @@ func normalizeOpenAIReasoningEffortForModel(raw, model string) string { // supportsOpenAIReasoningEffortMax reports model families whose upstream scale // has a distinct max level. Other models keep the legacy max -> xhigh behavior. func supportsOpenAIReasoningEffortMax(model string) bool { - if isOpenAIGPT56Model(model) { + if isOpenAIGPT6AstraModel(model) || isOpenAIGPT56Model(model) { return true } diff --git a/backend/internal/service/openai_gateway_request_body_reasoning_test.go b/backend/internal/service/openai_gateway_request_body_reasoning_test.go index 4805eeeb24..013aaa2531 100644 --- a/backend/internal/service/openai_gateway_request_body_reasoning_test.go +++ b/backend/internal/service/openai_gateway_request_body_reasoning_test.go @@ -331,6 +331,23 @@ func TestFilterOpenAIResponsesNoneReasoningEffortForAccount(t *testing.T) { } } +func TestFilterOpenAIResponsesNoneReasoningEffortForAccount_APIKeyAutomaticPassthroughPreservesRequest(t *testing.T) { + body := []byte(`{"model":"qwen3.8-27b","input":"hi","max_output_tokens":20,"reasoning":{"effort":"none"},"presence_penalty":1.5}`) + account := &Account{ + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Credentials: map[string]any{ + "base_url": "https://compat.example/v1", + }, + Extra: map[string]any{"openai_passthrough": true}, + } + + got, err := filterOpenAIResponsesNoneReasoningEffortForAccount(account, body) + + require.NoError(t, err) + require.JSONEq(t, string(body), string(got)) +} + // Lite 工具迁移到 input[].additional_tools 后,仍应按有工具请求处理。 func TestNormalizeOpenAIParallelToolCallsWithoutTools_KeepsResponsesLiteAdditionalTools(t *testing.T) { liteBody := []byte(`{"input":[{"type":"message","role":"user","content":"hi"},{"type":"additional_tools","tools":[{"type":"function","name":"spawn_agent"}]}],"parallel_tool_calls":false}`) diff --git a/backend/internal/service/openai_gateway_service.go b/backend/internal/service/openai_gateway_service.go index 01ea7b5f48..df7e66eb87 100644 --- a/backend/internal/service/openai_gateway_service.go +++ b/backend/internal/service/openai_gateway_service.go @@ -483,7 +483,7 @@ type OpenAIGatewayService struct { openaiWSRetryMetrics openAIWSRetryMetrics responseHeaderFilter *responseheaders.CompiledHeaderFilter codexSnapshotThrottle *accountWriteThrottle - codexModelsManifestCache codexModelsManifestCache + openAIModelsCache openAIModelsCache openaiCompatSessionResponses sync.Map openaiCompatAnthropicDigestSessions sync.Map // openaiCodexTurnStateOrigins: 下游会话 seed → openAICodexTurnStateOrigin, diff --git a/backend/internal/service/openai_gateway_service_codex_cli_only_test.go b/backend/internal/service/openai_gateway_service_codex_cli_only_test.go index 850c5d0ce8..079153b2a6 100644 --- a/backend/internal/service/openai_gateway_service_codex_cli_only_test.go +++ b/backend/internal/service/openai_gateway_service_codex_cli_only_test.go @@ -356,9 +356,9 @@ func TestShouldFailoverOpenAIUpstreamResponseContextWindow502(t *testing.T) { svc := &OpenAIGatewayService{} body := []byte(`{"error":{"message":"Your input exceeds the context window of this model. Please adjust your input and try again.","type":"upstream_error","code":null}}`) - require.False(t, svc.shouldFailoverOpenAIUpstreamResponse(http.StatusBadGateway, "", body)) - require.True(t, svc.shouldFailoverOpenAIUpstreamResponse(http.StatusBadGateway, "temporary upstream outage", []byte(`{"error":{"message":"temporary upstream outage"}}`))) - require.True(t, svc.shouldFailoverOpenAIUpstreamResponse( + require.False(t, svc.shouldFailoverOpenAIUpstreamResponse(newOpenAIUpstreamErrorTestAccount(), http.StatusBadGateway, "", body)) + require.True(t, svc.shouldFailoverOpenAIUpstreamResponse(newOpenAIUpstreamErrorTestAccount(), http.StatusBadGateway, "temporary upstream outage", []byte(`{"error":{"message":"temporary upstream outage"}}`))) + require.True(t, svc.shouldFailoverOpenAIUpstreamResponse(newOpenAIUpstreamErrorTestAccount(), http.StatusBadGateway, "temporary upstream outage", []byte(`{"error":{"message":"temporary upstream outage"},"echo":"context_length_exceeded"}`), diff --git a/backend/internal/service/openai_gateway_upstream_errors.go b/backend/internal/service/openai_gateway_upstream_errors.go index db2cd5d9c0..b5985a226e 100644 --- a/backend/internal/service/openai_gateway_upstream_errors.go +++ b/backend/internal/service/openai_gateway_upstream_errors.go @@ -250,7 +250,7 @@ func (s *OpenAIGatewayService) shouldFailoverUpstreamError(statusCode int) bool } } -func (s *OpenAIGatewayService) shouldFailoverOpenAIUpstreamResponse(statusCode int, upstreamMsg string, upstreamBody []byte) bool { +func (s *OpenAIGatewayService) shouldFailoverOpenAIUpstreamResponse(account *Account, statusCode int, upstreamMsg string, upstreamBody []byte) bool { // cyber_policy is request-scoped even when an intermediary wraps the // provider response in a retryable 5xx status. Never punish or rotate the // selected credential for it. @@ -266,12 +266,46 @@ func (s *OpenAIGatewayService) shouldFailoverOpenAIUpstreamResponse(statusCode i if isOpenAIRequestBodyTooLargeError(statusCode, upstreamMsg, upstreamBody) { return true } + // A missing model is account/provider availability, not a malformed client + // request. Keep this unconditional exception inside the OpenAI-compatible + // gateway and require an eligible account so Anthropic/Gemini paths retain + // their existing opt-in 400 behavior. + // A bare forwarding service has no account-selection owner to consume a + // failover sentinel. In that mode (used by direct/single-account callers), + // preserve the deterministic upstream 400 instead of returning an unwritten + // retry signal. Managed gateway instances always have an account repository; + // their handler can exclude this account and actually select another one. + if s != nil && s.accountRepo != nil && account != nil && account.IsOpenAICompatible() && statusCode == http.StatusBadRequest && + isOpenAICompatibleModelNotFound400(upstreamBody) { + return true + } if s.shouldFailoverUpstreamError(statusCode) { return true } return isOpenAITransientProcessingError(statusCode, upstreamMsg, upstreamBody) } +func isOpenAICompatibleModelNotFound400(respBody []byte) bool { + code := strings.TrimSpace(extractUpstreamErrorCode(respBody)) + if code != "" { + return strings.EqualFold(code, "model_not_found") + } + + msg := strings.ToLower(strings.TrimSpace(extractUpstreamErrorMessage(respBody))) + if msg == "" && !gjson.ValidBytes(respBody) { + msg = strings.ToLower(strings.TrimSpace(string(respBody))) + } + return strings.Contains(msg, "unknown provider for model") || + strings.Contains(msg, "model not found") || + strings.Contains(msg, "model is not supported") +} + +// IsOpenAICompatibleModelNotFound400 reports whether an OpenAI-compatible 400 +// is an account-specific missing-model response eligible for failover. +func IsOpenAICompatibleModelNotFound400(respBody []byte) bool { + return isOpenAICompatibleModelNotFound400(respBody) +} + // OpenAIRequestBodyTooLargeClientMessage is the fixed downstream message used // after all account-specific request body limit failovers are exhausted. const OpenAIRequestBodyTooLargeClientMessage = "Request payload is too large" diff --git a/backend/internal/service/openai_gateway_usage.go b/backend/internal/service/openai_gateway_usage.go index ea5345514e..79a7999799 100644 --- a/backend/internal/service/openai_gateway_usage.go +++ b/backend/internal/service/openai_gateway_usage.go @@ -611,6 +611,7 @@ func (s *OpenAIGatewayService) calculateOpenAIRecordUsageCost( pricingAt, tokens, serviceTier, + optionalStringValue(result.ReasoningEffort), longContextBillingGate, ) if err == nil { @@ -704,6 +705,7 @@ func (s *OpenAIGatewayService) calculateOpenAIRecordUsageTokenCost( pricingAt time.Time, tokens UsageTokens, serviceTier string, + reasoningEffort string, longContextBillingGate *bool, ) (*CostBreakdown, error) { if s.resolver != nil && apiKey.Group != nil { @@ -711,17 +713,21 @@ func (s *OpenAIGatewayService) calculateOpenAIRecordUsageTokenCost( return s.billingService.CalculateCostUnified(CostInput{ Ctx: ctx, Model: billingModel, GroupID: &gid, Group: apiKey.Group, Tokens: tokens, RequestCount: 1, RateMultiplier: multiplier, PricingAt: pricingAt, - ServiceTier: serviceTier, Resolver: s.resolver, + ServiceTier: serviceTier, ReasoningEffort: reasoningEffort, Resolver: s.resolver, LongContextBillingEnabled: longContextBillingGate, }) } - return s.billingService.calculateCostWithServiceTierPolicy( + breakdown, err := s.billingService.calculateCostWithServiceTierPolicy( billingModel, tokens, multiplier, serviceTier, longContextBillingGate == nil || *longContextBillingGate, ) + if err == nil { + applyCostBreakdownMultiplier(breakdown, maxReasoningEffortBillingMultiplier(billingModel, reasoningEffort, nil)) + } + return breakdown, err } func (s *OpenAIGatewayService) calculateOpenAIImageCost( diff --git a/backend/internal/service/openai_gpt56_max_test.go b/backend/internal/service/openai_gpt56_max_test.go index 9dc865295c..0b0c8e6fac 100644 --- a/backend/internal/service/openai_gpt56_max_test.go +++ b/backend/internal/service/openai_gpt56_max_test.go @@ -2,6 +2,8 @@ package service import ( "context" + "encoding/json" + "fmt" "io" "net/http" "net/http/httptest" @@ -14,13 +16,55 @@ import ( "github.com/tidwall/gjson" ) -func TestNormalizeOpenAIReasoningEffortForGPT56(t *testing.T) { +func TestAstraForwardPreservesMaxInPayloadAndUsage(t *testing.T) { + for _, requestedModel := range []string{"gpt-6-astra", "astra-public"} { + for _, stream := range []bool{false, true} { + t.Run(fmt.Sprintf("%s/stream=%t", requestedModel, stream), func(t *testing.T) { + response := `{"id":"resp_astra","model":"gpt-6-astra","status":"completed","output":[{"type":"message","role":"assistant","content":[{"type":"output_text","text":"ok"}]}],"usage":{"input_tokens":1,"output_tokens":2}}` + contentType := "application/json" + if stream { + response = "data: {\"type\":\"response.completed\",\"response\":" + response + "}\n\ndata: [DONE]\n\n" + contentType = "text/event-stream" + } + upstream := &httpUpstreamRecorder{resp: &http.Response{StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{contentType}}, Body: io.NopCloser(strings.NewReader(response))}} + svc := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream} + account := rawGPT56ResponsesAPIKeyAccount(requestedModel, "gpt-6-astra") + body, err := json.Marshal(map[string]any{"model": requestedModel, "stream": stream, "input": "hello", "reasoning": map[string]string{"effort": "max"}}) + require.NoError(t, err) + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(string(body))) + SetOpenAIClientTransport(c, OpenAIClientTransportHTTP) + result, err := svc.Forward(context.Background(), c, account, body) + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, "gpt-6-astra", gjson.GetBytes(upstream.lastBody, "model").String()) + require.Equal(t, "max", gjson.GetBytes(upstream.lastBody, "reasoning.effort").String()) + require.NotNil(t, result.ReasoningEffort) + require.Equal(t, "max", *result.ReasoningEffort) + usageRepo := &openAIRecordUsageLogRepoStub{inserted: true} + billingRepo := &openAIRecordUsageBillingRepoStub{result: &UsageBillingApplyResult{Applied: true}} + usageService := newOpenAIRecordUsageServiceWithBillingRepoForTest(usageRepo, billingRepo, &openAIRecordUsageUserRepoStub{}, &openAIRecordUsageSubRepoStub{}, nil) + err = usageService.RecordUsage(context.Background(), &OpenAIRecordUsageInput{ + Result: result, Account: account, User: &User{ID: 21}, APIKey: &APIKey{ID: 22, Group: &Group{RateMultiplier: 1}}, + }) + require.NoError(t, err) + require.NotNil(t, usageRepo.lastLog) + require.Equal(t, "max", *usageRepo.lastLog.ReasoningEffort) + require.Equal(t, "max", *usageRepo.lastLog.RequestedReasoningEffort) + }) + } + } +} + +func TestNormalizeOpenAIReasoningEffortForMaxCapableModels(t *testing.T) { tests := []struct { name string raw string model string want string }{ + {name: "Astra 保留 max", raw: "max", model: "gpt-6-astra", want: "max"}, {name: "Sol 保留 max", raw: "max", model: "gpt-5.6-sol", want: "max"}, {name: "Terra 保留 max", raw: "max", model: "openai/gpt-5.6-terra", want: "max"}, {name: "Luna 后缀保留 max", raw: "max", model: "gpt-5.6-luna-2026-07-09", want: "max"}, diff --git a/backend/internal/service/openai_images.go b/backend/internal/service/openai_images.go index d2962ba43d..cf39931a91 100644 --- a/backend/internal/service/openai_images.go +++ b/backend/internal/service/openai_images.go @@ -650,7 +650,7 @@ func (s *OpenAIGatewayService) forwardOpenAIImagesAPIKey( resp.Body = io.NopCloser(bytes.NewReader(respBody)) upstreamMsg := strings.TrimSpace(extractUpstreamErrorMessage(respBody)) upstreamMsg = sanitizeUpstreamErrorMessage(upstreamMsg) - if s.shouldFailoverOpenAIUpstreamResponse(resp.StatusCode, upstreamMsg, respBody) { + if s.shouldFailoverOpenAIUpstreamResponse(account, resp.StatusCode, upstreamMsg, respBody) { appendOpsUpstreamError(c, OpsUpstreamErrorEvent{ ProxyID: opsUpstreamProxyID(account), ProxyName: opsUpstreamProxyName(account), @@ -722,7 +722,7 @@ func (s *OpenAIGatewayService) forwardOpenAIImagesAPIKey( ImageOutputSizes: imageOutputSizes, }, nil } else { - nonStreamUsage, nonStreamCount, nonStreamSizes, err := s.handleOpenAIImagesNonStreamingResponse(resp, c) + nonStreamUsage, nonStreamCount, nonStreamSizes, err := s.handleOpenAIImagesNonStreamingResponse(upstreamCtx, resp, c, account, parsed) if err != nil { return nil, err } @@ -894,11 +894,18 @@ func cloneMultipartHeader(src textproto.MIMEHeader) textproto.MIMEHeader { return dst } -func (s *OpenAIGatewayService) handleOpenAIImagesNonStreamingResponse(resp *http.Response, c *gin.Context) (OpenAIUsage, int, []string, error) { +func (s *OpenAIGatewayService) handleOpenAIImagesNonStreamingResponse( + ctx context.Context, + resp *http.Response, + c *gin.Context, + account *Account, + parsed *OpenAIImagesRequest, +) (OpenAIUsage, int, []string, error) { body, err := ReadUpstreamResponseBody(resp.Body, s.cfg, c, openAITooLargeError) if err != nil { return OpenAIUsage{}, 0, nil, err } + body = s.backfillOpenAIImagesB64JSON(ctx, account, parsed, body) responseheaders.WriteFilteredHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter) contentType := "application/json" if s.cfg != nil && !s.cfg.Security.ResponseHeaders.Enabled { diff --git a/backend/internal/service/openai_images_b64_backfill.go b/backend/internal/service/openai_images_b64_backfill.go new file mode 100644 index 0000000000..3210c0fcbe --- /dev/null +++ b/backend/internal/service/openai_images_b64_backfill.go @@ -0,0 +1,176 @@ +package service + +import ( + "context" + "encoding/base64" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "github.com/Wei-Shaw/sub2api/internal/pkg/logger" + "github.com/Wei-Shaw/sub2api/internal/util/urlvalidator" + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" +) + +// AccountExtraImagesURLToB64JSON 是账户 extra 中的开关键。开启后,Images 端点的非流式响应里 +// b64_json 缺失或为空但 url 非空的图片项,由网关下载 url 内容并以 base64 回填到 b64_json。 +const AccountExtraImagesURLToB64JSON = "images_url_to_b64_json" + +// openAIImageURLDownloadTimeout 是单张图片 url 下载的超时上限。 +const openAIImageURLDownloadTimeout = 60 * time.Second + +// ImagesURLToB64JSONEnabled 返回账户是否开启了 url 转 b64_json 回填。 +func ImagesURLToB64JSONEnabled(account *Account) bool { + return account != nil && account.getExtraBool(AccountExtraImagesURLToB64JSON) +} + +// backfillOpenAIImagesB64JSON 对 Images 端点的非流式响应做 url 转 b64_json 回填。 +// +// 只处理 data[i].b64_json 缺失或为空且 url 非空的项;url 字段原样保留。 +// 单项失败仅记日志并保留该项原样,响应整体照常返回。 +// 客户端显式要求 response_format=url 时不做回填。 +func (s *OpenAIGatewayService) backfillOpenAIImagesB64JSON( + ctx context.Context, + account *Account, + parsed *OpenAIImagesRequest, + body []byte, +) []byte { + if !ImagesURLToB64JSONEnabled(account) { + return body + } + if parsed != nil && parsed.ResponseFormat == "url" { + return body + } + if len(body) == 0 || !gjson.ValidBytes(body) { + return body + } + items := gjson.GetBytes(body, "data") + if !items.IsArray() { + return body + } + for index, item := range items.Array() { + if !item.IsObject() { + continue + } + if strings.TrimSpace(item.Get("b64_json").String()) != "" { + continue + } + rawURL := strings.TrimSpace(item.Get("url").String()) + if rawURL == "" { + continue + } + encoded, err := s.fetchOpenAIImageURLBase64(ctx, account, rawURL) + if err != nil { + logger.LegacyPrintf( + "service.openai_gateway", + "[OpenAI] Images b64_json backfill skipped account_id=%d index=%d err=%s", + account.ID, + index, + sanitizeUpstreamErrorMessage(err.Error()), + ) + continue + } + updated, err := sjson.SetBytes(body, fmt.Sprintf("data.%d.b64_json", index), encoded) + if err != nil { + logger.LegacyPrintf( + "service.openai_gateway", + "[OpenAI] Images b64_json backfill skipped account_id=%d index=%d err=%s", + account.ID, + index, + sanitizeUpstreamErrorMessage(err.Error()), + ) + continue + } + body = updated + } + return body +} + +// fetchOpenAIImageURLBase64 取得图片 url 内容的标准 base64 编码。 +// data: 形式的 url 直接取其 base64 载荷;其余 url 先沿用 base_url 的出站 URL 策略校验, +// 再无条件拒绝回环、私网、链路本地等目的地(含重定向的每一跳),经账户代理下载, +// 大小上限与 OAuth 路径的单图下载一致,且前 512 字节须嗅探为 png/jpeg/webp/gif。 +func (s *OpenAIGatewayService) fetchOpenAIImageURLBase64(ctx context.Context, account *Account, rawURL string) (string, error) { + if strings.HasPrefix(strings.ToLower(rawURL), "data:") { + if encoded := normalizeOpenAIImageBase64(rawURL); encoded != "" { + return encoded, nil + } + return "", errors.New("data url payload is not valid base64") + } + if s == nil || s.httpUpstream == nil { + return "", errors.New("http upstream is not configured") + } + downloadURL, err := s.validateOutboundURL(rawURL) + if err != nil { + return "", fmt.Errorf("invalid image url: %w", err) + } + if err := rejectPrivateImageHost(downloadURL); err != nil { + return "", err + } + ctx, cancel := context.WithTimeout(WithHTTPUpstreamPublicHostsOnly(ctx), openAIImageURLDownloadTimeout) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL, nil) + if err != nil { + return "", fmt.Errorf("build image download request: %w", err) + } + req.Header.Set("Accept", "image/*,*/*;q=0.8") + proxyURL := "" + if account.ProxyID != nil && account.Proxy != nil { + proxyURL = account.Proxy.URL() + } + resp, err := s.httpUpstream.Do(req, proxyURL, account.ID, account.Concurrency) + if err != nil { + return "", fmt.Errorf("download image: %w", err) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + return "", fmt.Errorf("download image: unexpected status %d", resp.StatusCode) + } + data, err := io.ReadAll(io.LimitReader(resp.Body, openAIImageMaxDownloadBytes+1)) + if err != nil { + return "", fmt.Errorf("read image body: %w", err) + } + if int64(len(data)) > openAIImageMaxDownloadBytes { + return "", fmt.Errorf("downloaded image exceeds %d bytes", openAIImageMaxDownloadBytes) + } + if len(data) == 0 { + return "", errors.New("download image: empty body") + } + if !isBackfillImageContent(data) { + return "", errors.New("download image: content is not an allowed image format") + } + return base64.StdEncoding.EncodeToString(data), nil +} + +// rejectPrivateImageHost 拒绝主机为 localhost 或回环、私网、链路本地、未指定地址字面量的下载 URL。 +// 不受 security.url_allowlist 配置影响;域名的解析结果与重定向的每一跳由上游客户端按 +// WithHTTPUpstreamPublicHostsOnly 标记校验。 +func rejectPrivateImageHost(downloadURL string) error { + parsed, err := url.Parse(downloadURL) + if err != nil { + return fmt.Errorf("invalid image url: %w", err) + } + if host := parsed.Hostname(); urlvalidator.IsBlockedHost(host) { + return fmt.Errorf("image url host is not allowed: %s", host) + } + return nil +} + +// openAIImageBackfillContentTypes 是允许回填的图片格式,以字节嗅探结果为准,响应头不作为依据。 +var openAIImageBackfillContentTypes = map[string]struct{}{ + "image/png": {}, + "image/jpeg": {}, + "image/webp": {}, + "image/gif": {}, +} + +// isBackfillImageContent 报告 data 的前 512 字节是否嗅探为允许回填的图片格式。 +func isBackfillImageContent(data []byte) bool { + _, ok := openAIImageBackfillContentTypes[detectedImageContentType(data)] + return ok +} diff --git a/backend/internal/service/openai_images_b64_backfill_test.go b/backend/internal/service/openai_images_b64_backfill_test.go new file mode 100644 index 0000000000..bbaa3f088b --- /dev/null +++ b/backend/internal/service/openai_images_b64_backfill_test.go @@ -0,0 +1,381 @@ +package service + +import ( + "bytes" + "context" + "encoding/base64" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +// 8 字节 PNG 魔数足以让字节嗅探判定为 image/png。 +var b64BackfillPNGBytes = []byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n', 0, 0, 0, 0} + +func b64BackfillImageResponse(status int, contentType string, payload []byte) *http.Response { + header := http.Header{} + if contentType != "" { + header.Set("Content-Type", contentType) + } + return &http.Response{ + StatusCode: status, + Header: header, + Body: io.NopCloser(bytes.NewReader(payload)), + } +} + +func b64BackfillAccount(enabled bool) *Account { + account := &Account{ + ID: 7, + Name: "openai-apikey", + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Credentials: map[string]any{ + "api_key": "sk-test", + "base_url": "https://relay.example.com/v1", + }, + } + if enabled { + account.Extra = map[string]any{AccountExtraImagesURLToB64JSON: true} + } + return account +} + +func TestImagesURLToB64JSONEnabled(t *testing.T) { + require.False(t, ImagesURLToB64JSONEnabled(nil)) + require.False(t, ImagesURLToB64JSONEnabled(&Account{})) + require.False(t, ImagesURLToB64JSONEnabled(&Account{Extra: map[string]any{AccountExtraImagesURLToB64JSON: "true"}})) + require.False(t, ImagesURLToB64JSONEnabled(&Account{Extra: map[string]any{AccountExtraImagesURLToB64JSON: false}})) + require.True(t, ImagesURLToB64JSONEnabled(&Account{Extra: map[string]any{AccountExtraImagesURLToB64JSON: true}})) +} + +func TestBackfillOpenAIImagesB64JSON(t *testing.T) { + wantB64 := base64.StdEncoding.EncodeToString(b64BackfillPNGBytes) + + tests := []struct { + name string + enabled bool + parsed *OpenAIImagesRequest + body string + upstream *httpUpstreamRecorder + wantB64 []string + wantDownloads int + }{ + { + name: "开关关闭时原样返回", + enabled: false, + body: `{"created":1,"data":[{"url":"https://cdn.example.com/a.png"}]}`, + upstream: &httpUpstreamRecorder{resp: b64BackfillImageResponse(http.StatusOK, "image/png", b64BackfillPNGBytes)}, + wantB64: []string{""}, + wantDownloads: 0, + }, + { + name: "缺少 b64_json 且有 url 时下载回填并保留 url", + enabled: true, + body: `{"created":1,"data":[{"url":"https://cdn.example.com/a.png","revised_prompt":"a cat"}]}`, + upstream: &httpUpstreamRecorder{resp: b64BackfillImageResponse(http.StatusOK, "image/png", b64BackfillPNGBytes)}, + wantB64: []string{wantB64}, + wantDownloads: 1, + }, + { + name: "b64_json 为空串时同样回填", + enabled: true, + body: `{"created":1,"data":[{"b64_json":"","url":"https://cdn.example.com/a.png"}]}`, + upstream: &httpUpstreamRecorder{resp: b64BackfillImageResponse(http.StatusOK, "image/png", b64BackfillPNGBytes)}, + wantB64: []string{wantB64}, + wantDownloads: 1, + }, + { + name: "b64_json 已有值时不下载", + enabled: true, + body: `{"created":1,"data":[{"b64_json":"aW1n","url":"https://cdn.example.com/a.png"}]}`, + upstream: &httpUpstreamRecorder{resp: b64BackfillImageResponse(http.StatusOK, "image/png", b64BackfillPNGBytes)}, + wantB64: []string{"aW1n"}, + wantDownloads: 0, + }, + { + name: "多项混合时只回填缺失项", + enabled: true, + body: `{"created":1,"data":[{"b64_json":"aW1n"},{"url":"https://cdn.example.com/b.png"},{"revised_prompt":"none"}]}`, + upstream: &httpUpstreamRecorder{resp: b64BackfillImageResponse(http.StatusOK, "image/png", b64BackfillPNGBytes)}, + wantB64: []string{"aW1n", wantB64, ""}, + wantDownloads: 1, + }, + { + name: "data URL 直接取载荷不下载", + enabled: true, + body: `{"created":1,"data":[{"url":"data:image/png;base64,` + wantB64 + `"}]}`, + upstream: &httpUpstreamRecorder{}, + wantB64: []string{wantB64}, + wantDownloads: 0, + }, + { + name: "客户端显式要求 url 时不回填", + enabled: true, + parsed: &OpenAIImagesRequest{ResponseFormat: "url"}, + body: `{"created":1,"data":[{"url":"https://cdn.example.com/a.png"}]}`, + upstream: &httpUpstreamRecorder{resp: b64BackfillImageResponse(http.StatusOK, "image/png", b64BackfillPNGBytes)}, + wantB64: []string{""}, + wantDownloads: 0, + }, + { + name: "下载非 2xx 时保留原样", + enabled: true, + body: `{"created":1,"data":[{"url":"https://cdn.example.com/a.png"}]}`, + upstream: &httpUpstreamRecorder{resp: b64BackfillImageResponse(http.StatusNotFound, "text/plain", []byte("gone"))}, + wantB64: []string{""}, + wantDownloads: 1, + }, + { + name: "下载内容不是图片时保留原样", + enabled: true, + body: `{"created":1,"data":[{"url":"https://cdn.example.com/a.png"}]}`, + upstream: &httpUpstreamRecorder{resp: b64BackfillImageResponse(http.StatusOK, "text/html", []byte("login required"))}, + wantB64: []string{""}, + wantDownloads: 1, + }, + { + name: "响应头未声明图片类型但字节嗅探为图片时回填", + enabled: true, + body: `{"created":1,"data":[{"url":"https://cdn.example.com/a"}]}`, + upstream: &httpUpstreamRecorder{resp: b64BackfillImageResponse(http.StatusOK, "application/octet-stream", b64BackfillPNGBytes)}, + wantB64: []string{wantB64}, + wantDownloads: 1, + }, + { + name: "响应头声明图片但字节不是图片时保留原样", + enabled: true, + body: `{"created":1,"data":[{"url":"https://cdn.example.com/a.png"}]}`, + upstream: &httpUpstreamRecorder{resp: b64BackfillImageResponse(http.StatusOK, "image/svg+xml", []byte(``))}, + wantB64: []string{""}, + wantDownloads: 1, + }, + { + name: "非 http(s) 协议的 url 不下载", + enabled: true, + body: `{"created":1,"data":[{"url":"ftp://cdn.example.com/a.png"}]}`, + upstream: &httpUpstreamRecorder{resp: b64BackfillImageResponse(http.StatusOK, "image/png", b64BackfillPNGBytes)}, + wantB64: []string{""}, + wantDownloads: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + svc := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: tt.upstream} + got := svc.backfillOpenAIImagesB64JSON(context.Background(), b64BackfillAccount(tt.enabled), tt.parsed, []byte(tt.body)) + require.True(t, gjson.ValidBytes(got)) + items := gjson.GetBytes(got, "data").Array() + require.Len(t, items, len(tt.wantB64)) + for i, want := range tt.wantB64 { + require.Equal(t, want, items[i].Get("b64_json").String(), "data.%d.b64_json", i) + } + require.Len(t, tt.upstream.requests, tt.wantDownloads) + // 除 b64_json 外的字段必须原样保留。 + original := gjson.Parse(tt.body) + original.Get("data").ForEach(func(key, item gjson.Result) bool { + item.ForEach(func(field, value gjson.Result) bool { + if field.String() == "b64_json" { + return true + } + require.Equal(t, value.Raw, gjson.GetBytes(got, "data."+key.String()+"."+field.String()).Raw) + return true + }) + return true + }) + require.Equal(t, original.Get("created").Raw, gjson.GetBytes(got, "created").Raw) + }) + } +} + +func TestBackfillOpenAIImagesB64JSON_DownloadRequestShape(t *testing.T) { + upstream := &httpUpstreamRecorder{resp: b64BackfillImageResponse(http.StatusOK, "image/png", b64BackfillPNGBytes)} + svc := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream} + account := b64BackfillAccount(true) + proxyID := int64(3) + account.ProxyID = &proxyID + account.Proxy = &Proxy{Protocol: "http", Host: "127.0.0.1", Port: 7890} + + body := []byte(`{"created":1,"data":[{"url":"https://cdn.example.com/a.png?sig=abc"}]}`) + got := svc.backfillOpenAIImagesB64JSON(context.Background(), account, nil, body) + require.Equal(t, base64.StdEncoding.EncodeToString(b64BackfillPNGBytes), gjson.GetBytes(got, "data.0.b64_json").String()) + + require.Len(t, upstream.requests, 1) + req := upstream.requests[0] + require.Equal(t, http.MethodGet, req.Method) + require.Equal(t, "https://cdn.example.com/a.png?sig=abc", req.URL.String()) + require.Equal(t, "http://127.0.0.1:7890", upstream.lastProxyURL) + _, hasDeadline := req.Context().Deadline() + require.True(t, hasDeadline) + // 目的地与重定向各跳都必须解析到公网地址;重定向本身保持跟随。 + require.True(t, HTTPUpstreamPublicHostsOnly(req.Context())) + require.False(t, HTTPUpstreamRedirectsDisabled(req.Context())) +} + +func TestBackfillOpenAIImagesB64JSON_RejectsPrivateHosts(t *testing.T) { + upstream := &httpUpstreamRecorder{resp: b64BackfillImageResponse(http.StatusOK, "image/png", b64BackfillPNGBytes)} + cfg := &config.Config{} + cfg.Security.URLAllowlist.AllowInsecureHTTP = true + svc := &OpenAIGatewayService{cfg: cfg, httpUpstream: upstream} + account := b64BackfillAccount(true) + + for _, rawURL := range []string{ + "http://localhost:8080/api/v1/admin/accounts", + "http://Foo.LocalHost/a.png", + "http://127.0.0.1:8080/a.png", + "https://127.0.0.1/a.png", + "http://[::1]:8080/a.png", + "http://10.0.0.5/a.png", + "http://172.16.0.9:9000/bucket/a.png", + "http://192.168.1.10:9000/bucket/a.png", + "http://169.254.169.254/latest/meta-data/", + "http://0.0.0.0:8080/a.png", + "http://[fe80::1]/a.png", + "http://[::ffff:127.0.0.1]/a.png", + } { + body := `{"created":1,"data":[{"url":"` + rawURL + `"}]}` + got := svc.backfillOpenAIImagesB64JSON(context.Background(), account, nil, []byte(body)) + require.Equal(t, body, string(got), "url=%s", rawURL) + } + require.Empty(t, upstream.requests, "private destinations must never be requested") + + // 同一配置下公网主机照常下载,证明拒绝依据是目的地而非协议。 + got := svc.backfillOpenAIImagesB64JSON(context.Background(), account, nil, []byte(`{"created":1,"data":[{"url":"http://cdn.example.com/a.png"}]}`)) + require.Equal(t, base64.StdEncoding.EncodeToString(b64BackfillPNGBytes), gjson.GetBytes(got, "data.0.b64_json").String()) + require.Len(t, upstream.requests, 1) +} + +func TestIsBackfillImageContent(t *testing.T) { + webp := append([]byte("RIFF\x24\x00\x00\x00WEBPVP8 "), make([]byte, 8)...) + tests := []struct { + name string + data []byte + want bool + }{ + {name: "png", data: b64BackfillPNGBytes, want: true}, + {name: "jpeg", data: []byte{0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 'J', 'F', 'I', 'F', 0x00}, want: true}, + {name: "webp", data: webp, want: true}, + {name: "gif", data: []byte("GIF89a\x01\x00\x01\x00"), want: true}, + {name: "bmp 不在允许列表", data: []byte("BM\x00\x00\x00\x00\x00\x00\x00\x00"), want: false}, + {name: "ico 不在允许列表", data: []byte{0x00, 0x00, 0x01, 0x00, 0x01, 0x00}, want: false}, + {name: "svg 文本", data: []byte(``), want: false}, + {name: "html", data: []byte("login required"), want: false}, + {name: "json", data: []byte(`{"error":"unauthorized"}`), want: false}, + {name: "空", data: nil, want: false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.want, isBackfillImageContent(tt.data)) + }) + } +} + +func TestBackfillOpenAIImagesB64JSON_NonObjectBodies(t *testing.T) { + upstream := &httpUpstreamRecorder{resp: b64BackfillImageResponse(http.StatusOK, "image/png", b64BackfillPNGBytes)} + svc := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream} + account := b64BackfillAccount(true) + + for _, body := range []string{``, `not json`, `{"created":1}`, `{"created":1,"data":{}}`, `{"created":1,"data":[]}`, `{"created":1,"data":["x"]}`} { + got := svc.backfillOpenAIImagesB64JSON(context.Background(), account, nil, []byte(body)) + require.Equal(t, body, string(got), "body=%q", body) + } + require.Empty(t, upstream.requests) +} + +func TestOpenAIGatewayServiceForwardImages_APIKeyBackfillsB64JSONFromURL(t *testing.T) { + gin.SetMode(gin.TestMode) + body := []byte(`{"model":"gpt-image-2","prompt":"draw a cat","size":"1024x1024"}`) + + req := httptest.NewRequest(http.MethodPost, "/v1/images/generations", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = req + c.Set("api_key", &APIKey{ID: 42}) + + svc := &OpenAIGatewayService{cfg: &config.Config{}} + parsed, err := svc.ParseOpenAIImagesRequest(c, body) + require.NoError(t, err) + + upstream := &httpUpstreamRecorder{ + responses: []*http.Response{ + { + StatusCode: http.StatusOK, + Header: http.Header{ + "Content-Type": []string{"application/json"}, + "X-Request-Id": []string{"req_img_url"}, + }, + Body: io.NopCloser(strings.NewReader( + `{"created":1710000000,"data":[{"url":"https://cdn.example.com/cat.png","revised_prompt":"a cat"}],"usage":{"input_tokens":10,"output_tokens":20}}`, + )), + }, + b64BackfillImageResponse(http.StatusOK, "image/png", b64BackfillPNGBytes), + }, + } + svc.httpUpstream = upstream + + result, err := svc.ForwardImages(context.Background(), c, b64BackfillAccount(true), body, parsed, "") + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, 1, result.ImageCount) + require.False(t, result.Stream) + + require.Len(t, upstream.requests, 2) + require.Equal(t, http.MethodPost, upstream.requests[0].Method) + require.Equal(t, "https://relay.example.com/v1/images/generations", upstream.requests[0].URL.String()) + require.Equal(t, http.MethodGet, upstream.requests[1].Method) + require.Equal(t, "https://cdn.example.com/cat.png", upstream.requests[1].URL.String()) + require.True(t, HTTPUpstreamPublicHostsOnly(upstream.requests[1].Context())) + + require.Equal(t, http.StatusOK, rec.Code) + wantB64 := base64.StdEncoding.EncodeToString(b64BackfillPNGBytes) + require.Equal(t, wantB64, gjson.Get(rec.Body.String(), "data.0.b64_json").String()) + require.Equal(t, "https://cdn.example.com/cat.png", gjson.Get(rec.Body.String(), "data.0.url").String()) + require.Equal(t, "a cat", gjson.Get(rec.Body.String(), "data.0.revised_prompt").String()) + require.Equal(t, int64(1710000000), gjson.Get(rec.Body.String(), "created").Int()) +} + +func TestOpenAIGatewayServiceForwardImages_APIKeyLeavesURLOnlyResponseWhenDisabled(t *testing.T) { + gin.SetMode(gin.TestMode) + body := []byte(`{"model":"gpt-image-2","prompt":"draw a cat","size":"1024x1024"}`) + + req := httptest.NewRequest(http.MethodPost, "/v1/images/generations", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = req + c.Set("api_key", &APIKey{ID: 42}) + + svc := &OpenAIGatewayService{cfg: &config.Config{}} + parsed, err := svc.ParseOpenAIImagesRequest(c, body) + require.NoError(t, err) + + upstreamBody := `{"created":1710000000,"data":[{"url":"https://cdn.example.com/cat.png"}]}` + upstream := &httpUpstreamRecorder{ + responses: []*http.Response{ + { + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(upstreamBody)), + }, + b64BackfillImageResponse(http.StatusOK, "image/png", b64BackfillPNGBytes), + }, + } + svc.httpUpstream = upstream + + result, err := svc.ForwardImages(context.Background(), c, b64BackfillAccount(false), body, parsed, "") + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, 1, result.ImageCount) + + require.Len(t, upstream.requests, 1) + require.Equal(t, http.StatusOK, rec.Code) + require.Equal(t, upstreamBody, rec.Body.String()) +} diff --git a/backend/internal/service/openai_images_responses.go b/backend/internal/service/openai_images_responses.go index 1ad5b5aaee..a0729dc97c 100644 --- a/backend/internal/service/openai_images_responses.go +++ b/backend/internal/service/openai_images_responses.go @@ -1856,7 +1856,7 @@ func (s *OpenAIGatewayService) forwardOpenAIImagesOAuth( resp.Body = io.NopCloser(bytes.NewReader(respBody)) upstreamMsg := strings.TrimSpace(extractUpstreamErrorMessage(respBody)) upstreamMsg = sanitizeUpstreamErrorMessage(upstreamMsg) - if s.shouldFailoverOpenAIUpstreamResponse(resp.StatusCode, upstreamMsg, respBody) { + if s.shouldFailoverOpenAIUpstreamResponse(account, resp.StatusCode, upstreamMsg, respBody) { appendOpsUpstreamError(c, OpsUpstreamErrorEvent{ ProxyID: opsUpstreamProxyID(account), ProxyName: opsUpstreamProxyName(account), diff --git a/backend/internal/service/openai_live.go b/backend/internal/service/openai_live.go index 68d3bb725e..29922910bb 100644 --- a/backend/internal/service/openai_live.go +++ b/backend/internal/service/openai_live.go @@ -199,7 +199,7 @@ func (s *OpenAIGatewayService) CreateLiveCall( selection.ReleaseFunc() if createErr != nil { s.releaseLiveLease(account.ID, identity.UserID, identity.APIKeyID, leaseID) - if !s.shouldFailoverLiveCreateError(createErr) { + if !s.shouldFailoverLiveCreateError(account, createErr) { return nil, createErr } excluded[account.ID] = struct{}{} @@ -245,13 +245,13 @@ func (s *OpenAIGatewayService) CreateLiveCall( return nil, ErrLiveUnavailable } -func (s *OpenAIGatewayService) shouldFailoverLiveCreateError(err error) bool { +func (s *OpenAIGatewayService) shouldFailoverLiveCreateError(account *Account, err error) bool { var upstreamErr *UpstreamFailoverError if !errors.As(err, &upstreamErr) { // 凭证读取和网络传输错误都可能只影响当前账号或代理。 return true } - return s.shouldFailoverOpenAIUpstreamResponse( + return s.shouldFailoverOpenAIUpstreamResponse(account, upstreamErr.StatusCode, "", upstreamErr.ResponseBody, diff --git a/backend/internal/service/openai_live_test.go b/backend/internal/service/openai_live_test.go index 81cff13b8c..259a5c5ccf 100644 --- a/backend/internal/service/openai_live_test.go +++ b/backend/internal/service/openai_live_test.go @@ -210,17 +210,18 @@ func TestLiveSidebandNormalCloseEndsCall(t *testing.T) { func TestLiveCreateFailoverUsesExistingOpenAIPolicy(t *testing.T) { service := &OpenAIGatewayService{} - require.False(t, service.shouldFailoverLiveCreateError(&UpstreamFailoverError{ + account := newOpenAIUpstreamErrorTestAccount() + require.False(t, service.shouldFailoverLiveCreateError(account, &UpstreamFailoverError{ StatusCode: http.StatusBadRequest, ResponseBody: []byte(`{"error":{"message":"invalid session"}}`), })) - require.True(t, service.shouldFailoverLiveCreateError(&UpstreamFailoverError{ + require.True(t, service.shouldFailoverLiveCreateError(account, &UpstreamFailoverError{ StatusCode: http.StatusForbidden, })) - require.True(t, service.shouldFailoverLiveCreateError(&UpstreamFailoverError{ + require.True(t, service.shouldFailoverLiveCreateError(account, &UpstreamFailoverError{ StatusCode: http.StatusBadGateway, })) - require.True(t, service.shouldFailoverLiveCreateError(errors.New("transport failed"))) + require.True(t, service.shouldFailoverLiveCreateError(account, errors.New("transport failed"))) } func TestLiveCallIDFromLocation(t *testing.T) { diff --git a/backend/internal/service/openai_messages_continuation.go b/backend/internal/service/openai_messages_continuation.go index 9beed51627..65bafbd544 100644 --- a/backend/internal/service/openai_messages_continuation.go +++ b/backend/internal/service/openai_messages_continuation.go @@ -133,7 +133,9 @@ func isOpenAICompatPreviousResponseUnsupported(statusCode int, upstreamMsg strin } return strings.Contains(lower, "unsupported parameter") || strings.Contains(lower, "only supported on responses websocket") || - strings.Contains(lower, "not supported") + strings.Contains(lower, "not supported") || + strings.Contains(lower, "is not available for this user") || + strings.Contains(lower, "requires an openai api-key account for http requests") } if check(upstreamMsg) || check(string(upstreamBody)) { return true diff --git a/backend/internal/service/openai_model_alias.go b/backend/internal/service/openai_model_alias.go index 4ddf8110c3..147ed20941 100644 --- a/backend/internal/service/openai_model_alias.go +++ b/backend/internal/service/openai_model_alias.go @@ -65,6 +65,8 @@ func normalizeKnownOpenAICodexModel(model string) string { } switch { + case normalized == "gpt-6" || normalized == "gpt-6-astra": + return "gpt-6-astra" case strings.Contains(normalized, "gpt-5.6-sol"): return "gpt-5.6-sol" case strings.Contains(normalized, "gpt-5.6-terra"): @@ -124,6 +126,13 @@ func isOpenAIGPT56Model(model string) bool { return false } +// isOpenAIGPT6AstraModel reports GPT-6 Astra and dated/provider-prefixed variants. +// The public "gpt-6" alias routes to Astra; unrelated GPT-6 families stay excluded. +func isOpenAIGPT6AstraModel(model string) bool { + normalized := canonicalizeOpenAIModelAliasSpelling(model) + return normalized == "gpt-6" || normalized == "gpt-6-astra" || strings.HasPrefix(normalized, "gpt-6-astra-") +} + func appendUsageBillingModelCandidate(candidates []string, seen map[string]struct{}, model string) []string { trimmed := strings.TrimSpace(model) if trimmed == "" { diff --git a/backend/internal/service/openai_model_alias_test.go b/backend/internal/service/openai_model_alias_test.go index 4cb27206c9..9ac5636892 100644 --- a/backend/internal/service/openai_model_alias_test.go +++ b/backend/internal/service/openai_model_alias_test.go @@ -6,6 +6,12 @@ import ( "github.com/stretchr/testify/require" ) +func TestNormalizeKnownOpenAICodexModelGPT6Astra(t *testing.T) { + for _, model := range []string{"gpt-6-astra", "openai/gpt-6-astra", "gpt-6", "openai/gpt-6"} { + require.Equal(t, "gpt-6-astra", normalizeKnownOpenAICodexModel(model)) + } +} + func TestNormalizeKnownOpenAICodexModel_BareGPT56RoutesToSol(t *testing.T) { tests := map[string]string{ "gpt-5.6": "gpt-5.6-sol", diff --git a/backend/internal/service/openai_models_list.go b/backend/internal/service/openai_models_list.go new file mode 100644 index 0000000000..42740c2df3 --- /dev/null +++ b/backend/internal/service/openai_models_list.go @@ -0,0 +1,374 @@ +package service + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "sort" + "strings" + + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" +) + +// FetchOpenAIModelsList discovers a single account's raw public model catalog. +// API keys use the standard endpoint; OAuth reuses the authenticated, cached +// Codex source. Account mappings and group policy are applied after this cache. +func (s *OpenAIGatewayService) FetchOpenAIModelsList(ctx context.Context, account *Account) (*OpenAIModelsResponse, error) { + if s == nil || account == nil { + return nil, infraerrors.New(http.StatusInternalServerError, "OPENAI_MODELS_ACCOUNT_REQUIRED", "OpenAI account is required") + } + credentialAccount, err := resolveCredentialAccount(ctx, s.accountRepo, account) + if err != nil { + return nil, fmt.Errorf("resolve model list credentials: %w", err) + } + if credentialAccount.IsOpenAIOAuth() { + clientVersion := CodexCanonicalClientVersion() + if s.settingService != nil { + clientVersion = s.settingService.GetOpenAICodexClientVersion(ctx) + } + response, err := s.FetchCodexModelsManifest(ctx, account, clientVersion, "") + if err != nil { + return nil, err + } + body, err := standardOpenAIModelsBody(response.Body, true) + if err != nil { + return nil, invalidOpenAIModelsList(err) + } + return &OpenAIModelsResponse{Body: body, ETag: codexModelsManifestBodyETag(body)}, nil + } + req, err := buildOpenAIAPIKeyModelsRequest(ctx, credentialAccount, s.validateUpstreamBaseURL) + if err != nil { + return nil, infraerrors.Newf(http.StatusBadGateway, "OPENAI_MODELS_REQUEST_INVALID", "cannot build upstream model list request: %v", err) + } + request := openAIModelsRequest{ + url: req.URL.String(), headers: req.Header, + proxyURL: upstreamModelsProxyURL(account), accountID: account.ID, + credentialAccountID: credentialAccount.ID, credentialAccount: credentialAccount, + accountConcurrency: account.Concurrency, useAPIKeyUpstream: true, + standardModelsList: true, + } + response, err := s.fetchCachedOpenAIModels(ctx, request, func(fetchCtx context.Context, etag string) (*OpenAIModelsResponse, error) { + response, err := s.fetchOpenAIModelsUpstream(fetchCtx, request, etag) + if err != nil || response.NotModified { + return response, err + } + body, err := standardOpenAIModelsBody(response.Body, false) + if err != nil { + return nil, invalidOpenAIModelsList(err) + } + response.Body = body + response.ETag = codexModelsManifestBodyETag(body) + return response, nil + }, "") + if err != nil { + return nil, err + } + if response.NotModified { + return nil, invalidOpenAIModelsList(fmt.Errorf("upstream returned 304 without a cached catalog")) + } + return response, nil +} + +func invalidOpenAIModelsList(err error) error { + return &codexModelsManifestUpstreamError{ + err: infraerrors.Newf(http.StatusBadGateway, "OPENAI_MODELS_UPSTREAM_INVALID", "invalid upstream model list: %v", err), + retryable: true, + } +} + +// modelCatalogEntries requires a real array. An empty array is authoritative; +// absent fields, null and error envelopes must not become successful empty lists. +func modelCatalogEntries(body []byte, field string) (map[string]json.RawMessage, []json.RawMessage, error) { + var envelope map[string]json.RawMessage + if err := json.Unmarshal(body, &envelope); err != nil { + return nil, nil, err + } + raw := bytes.TrimSpace(envelope[field]) + if len(raw) == 0 || raw[0] != '[' { + return nil, nil, fmt.Errorf("missing or invalid %s array", field) + } + var entries []json.RawMessage + if err := json.Unmarshal(raw, &entries); err != nil { + return nil, nil, err + } + return envelope, entries, nil +} + +func standardOpenAIModelsBody(body []byte, fromManifest bool) ([]byte, error) { + field, idField := "data", "id" + if fromManifest { + field, idField = "models", "slug" + } + _, entries, err := modelCatalogEntries(body, field) + if err != nil { + return nil, err + } + models := make([]json.RawMessage, 0, len(entries)) + seen := make(map[string]struct{}, len(entries)) + for _, raw := range entries { + var entry map[string]json.RawMessage + if err := json.Unmarshal(raw, &entry); err != nil || entry == nil { + return nil, fmt.Errorf("model entry must be an object") + } + var id string + if err := json.Unmarshal(entry[idField], &id); err != nil || strings.TrimSpace(id) == "" { + return nil, fmt.Errorf("model entry has no valid %s", idField) + } + id = strings.TrimSpace(id) + if _, ok := seen[id]; ok || strings.Contains(id, "*") { + continue + } + seen[id] = struct{}{} + if fromManifest { + entry = make(map[string]json.RawMessage) + } + entry["id"], _ = json.Marshal(id) + entry["object"] = json.RawMessage(`"model"`) + if len(entry["created"]) == 0 || string(entry["created"]) == "null" { + entry["created"] = json.RawMessage(`0`) + } + if len(entry["owned_by"]) == 0 || string(entry["owned_by"]) == "null" { + entry["owned_by"] = json.RawMessage(`"openai"`) + } + encoded, err := json.Marshal(entry) + if err != nil { + return nil, err + } + models = append(models, encoded) + } + return json.Marshal(struct { + Object string `json:"object"` + Data []json.RawMessage `json:"data"` + }{Object: "list", Data: models}) +} + +// projectAccountModelsBody applies the same public-name mapping to both model +// representations while retaining the source entry's metadata. It never changes +// the shared response and never synthesizes models absent from this account. +func projectAccountModelsBody(body []byte, account *Account, group *Group, codex bool) ([]byte, error) { + if account.IsOpenAIPassthroughEnabled() || len(account.GetModelMapping()) == 0 { + return body, nil + } + field, idField := "data", "id" + if codex { + field, idField = "models", "slug" + } + envelope, entries, err := modelCatalogEntries(body, field) + if err != nil { + return nil, err + } + byID := make(map[string]json.RawMessage, len(entries)) + candidates := make([]string, 0, len(entries)) + for _, raw := range entries { + var entry map[string]json.RawMessage + if json.Unmarshal(raw, &entry) != nil { + continue + } + var id string + if json.Unmarshal(entry[idField], &id) != nil { + continue + } + id = strings.TrimSpace(id) + if id == "" || strings.Contains(id, "*") { + continue + } + // Apply the allowlist to public names after mapping, not upstream targets. + if codex { + if isCodexDedicatedMediaModel(id) { + continue + } + if strings.HasPrefix(id, codexAutoModelPrefix) && len(FilterCodexModelIDsForGroup([]string{id}, group)) == 0 { + continue + } + } + if _, ok := byID[id]; !ok { + byID[id] = raw + candidates = append(candidates, id) + } + } + aliases := make([]string, 0, len(account.GetModelMapping())) + for alias := range account.GetModelMapping() { + aliases = append(aliases, alias) + } + sort.Strings(aliases) + candidates = append(candidates, aliases...) + if group.ModelAllowlistEnabled() { + candidates = append(candidates, group.ModelAllowlist.Models...) + } + projected := make([]json.RawMessage, 0, len(candidates)) + seen := make(map[string]struct{}, len(candidates)) + for _, id := range candidates { + id = strings.TrimSpace(id) + if id == "" || strings.Contains(id, "*") { + continue + } + if _, ok := seen[id]; ok { + continue + } + target, matched := account.ResolveMappedModel(id) + raw, available := byID[strings.TrimSpace(target)] + if !matched || !available { + continue + } + seen[id] = struct{}{} + var entry map[string]json.RawMessage + if err := json.Unmarshal(raw, &entry); err != nil { + return nil, err + } + entry[idField], _ = json.Marshal(id) + if id != target { + entry["display_name"], _ = json.Marshal(id) + } + encoded, err := json.Marshal(entry) + if err != nil { + return nil, err + } + projected = append(projected, encoded) + } + envelope[field], err = json.Marshal(projected) + if err != nil { + return nil, err + } + return json.Marshal(envelope) +} + +// ApplyPinnedCodexModelsMapping is used by pinned discovery and its scheduler +// fallback. The ordinary (non-pinned) Codex path retains its local catalog policy. +func ApplyPinnedCodexModelsMapping(response *OpenAIModelsResponse, account *Account, group *Group) error { + if group == nil || group.Platform != PlatformOpenAI || !group.CodexModelsManifestConfig.Enabled { + return nil + } + body, err := projectAccountModelsBody(response.Body, account, group, true) + if err != nil { + return err + } + response.Body = body + response.ETag = codexModelsManifestBodyETag(body) + return nil +} + +// FetchPinnedOpenAIModelsList includes explicitly enabled scheduler fallback. +// An authoritative empty catalog is success, including after group filtering. +func (s *OpenAIGatewayService) FetchPinnedOpenAIModelsList(ctx context.Context, group *Group, maxAccountSwitches int, ifNoneMatch string) (*OpenAIModelsResponse, *Account, error) { + fetch := func(ctx context.Context, account *Account) (*OpenAIModelsResponse, error) { + response, err := s.FetchOpenAIModelsList(ctx, account) + if err != nil { + return nil, err + } + response.Body, err = projectAccountModelsBody(response.Body, account, group, false) + return response, err + } + results, err := s.fetchPinnedOpenAIModels(ctx, group, fetch) + if err != nil && ctx.Err() == nil && group != nil && group.CodexModelsManifestConfig.FallbackToScheduler { + results, err = s.fetchScheduledOpenAIModels(ctx, group, maxAccountSwitches, fetch) + } + if err != nil { + return nil, nil, err + } + models := make([]json.RawMessage, 0) + modelIDs := make([]string, 0) + byID := make(map[string]json.RawMessage) + for _, result := range results { + _, entries, err := modelCatalogEntries(result.response.Body, "data") + if err != nil { + return nil, nil, err + } + for _, raw := range entries { + var model struct { + ID string `json:"id"` + } + if err := json.Unmarshal(raw, &model); err != nil { + return nil, nil, err + } + if _, exists := byID[model.ID]; !exists { + byID[model.ID] = raw + modelIDs = append(modelIDs, model.ID) + models = append(models, raw) + } + } + } + if group.ModelAllowlistEnabled() { + models = selectModelCatalogEntries(byID, group.ModelAllowlist.FilterForListing(modelIDs)) + } + body, err := json.Marshal(struct { + Object string `json:"object"` + Data []json.RawMessage `json:"data"` + }{Object: "list", Data: models}) + if err != nil { + return nil, nil, err + } + response := &OpenAIModelsResponse{Body: body, ETag: codexModelsManifestBodyETag(body)} + return openAIModelsResponseForClient(response, ifNoneMatch), results[0].account, nil +} + +func selectModelCatalogEntries(byID map[string]json.RawMessage, selected []string) []json.RawMessage { + models := make([]json.RawMessage, 0, len(selected)) + seen := make(map[string]struct{}, len(selected)) + for _, id := range selected { + id = strings.TrimSpace(id) + if _, ok := seen[id]; ok { + continue + } + if raw, exists := byID[id]; exists { + models = append(models, raw) + seen[id] = struct{}{} + } + } + return models +} + +func orderPinnedCodexModelsBySelection(body []byte, allowlist GroupModelAllowlist) ([]byte, error) { + envelope, entries, err := modelCatalogEntries(body, "models") + if err != nil { + return nil, err + } + byID := make(map[string]json.RawMessage, len(entries)) + modelIDs := make([]string, 0, len(entries)) + for _, raw := range entries { + var entry struct { + Slug string `json:"slug"` + } + if err := json.Unmarshal(raw, &entry); err != nil { + return nil, err + } + byID[entry.Slug] = raw + modelIDs = append(modelIDs, entry.Slug) + } + envelope["models"], err = json.Marshal(selectModelCatalogEntries(byID, allowlist.FilterForListing(modelIDs))) + if err != nil { + return nil, err + } + return json.Marshal(envelope) +} + +func (s *OpenAIGatewayService) fetchScheduledOpenAIModels(ctx context.Context, group *Group, maxSwitches int, fetch func(context.Context, *Account) (*OpenAIModelsResponse, error)) ([]pinnedOpenAIModelsResult, error) { + if maxSwitches <= 0 { + maxSwitches = 3 + } + excluded := make(map[int64]struct{}) + var lastErr error + for attempt := 0; attempt <= maxSwitches; attempt++ { + if err := ctx.Err(); err != nil { + return nil, err + } + account, err := s.SelectAccountForModelWithExclusions(ctx, &group.ID, "", "", excluded) + if err != nil { + if lastErr != nil { + return nil, lastErr + } + return nil, ErrNoPinnedCodexModelsAccounts + } + response, err := fetch(ctx, account) + if err == nil { + return []pinnedOpenAIModelsResult{{account: account, response: response}}, nil + } + lastErr = err + if !IsRetryableCodexModelsManifestError(err) { + return nil, err + } + excluded[account.ID] = struct{}{} + } + return nil, lastErr +} diff --git a/backend/internal/service/openai_models_list_test.go b/backend/internal/service/openai_models_list_test.go new file mode 100644 index 0000000000..c898c06189 --- /dev/null +++ b/backend/internal/service/openai_models_list_test.go @@ -0,0 +1,348 @@ +package service + +import ( + "context" + "encoding/json" + "io" + "net/http" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func ordinaryModelsUpstreamResponse(body string) *http.Response { + return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(body))} +} + +func TestFetchOpenAIModelsListUsesStandardRequestAndIsolatesCodexCache(t *testing.T) { + var calls atomic.Int32 + s := newCodexModelsAPIKeyTestService(&codexModelsHTTPUpstreamStub{do: func(req *http.Request, _ string, accountID int64, concurrency int) (*http.Response, error) { + calls.Add(1) + assert.EqualValues(t, 2, accountID) + assert.EqualValues(t, 3, concurrency) + assert.Equal(t, "/v1/models", req.URL.Path) + assert.Equal(t, "value", getHeaderRaw(req.Header, "x-account-header")) + if req.URL.Query().Has("client_version") { + return ordinaryModelsUpstreamResponse(`{"models":[{"slug":"codex-only"}]}`), nil + } + assert.Empty(t, req.Header.Get("Originator")) + assert.Empty(t, req.Header.Get("Version")) + return ordinaryModelsUpstreamResponse(`{"object":"list","data":[{"id":"special-upstream-model","owned_by":"provider","created":1234,"custom":true},{"id":"gpt-image-1"},{"id":"text-embedding-3-large"}]}`), nil + }}) + account := newCodexModelsAPIKeyTestAccount("https://models.example/v1") + account.Credentials["header_overrides"] = map[string]any{"X-Account-Header": "value"} + account.Credentials["header_override_enabled"] = true + response, err := s.FetchOpenAIModelsList(context.Background(), account) + require.NoError(t, err) + require.Contains(t, string(response.Body), `"id":"gpt-image-1"`) + require.Contains(t, string(response.Body), `"id":"text-embedding-3-large"`) + require.Contains(t, string(response.Body), `"created":1234`) + require.Contains(t, string(response.Body), `"custom":true`) + manifest, err := s.FetchCodexModelsManifest(context.Background(), account, CodexCanonicalClientVersion(), "") + require.NoError(t, err) + require.Contains(t, string(manifest.Body), `"slug":"codex-only"`) + again, err := s.FetchOpenAIModelsList(context.Background(), account) + require.NoError(t, err) + require.Equal(t, string(response.Body), string(again.Body)) + require.EqualValues(t, 2, calls.Load()) + account.Credentials["api_key"] = "rotated-token" + _, err = s.FetchOpenAIModelsList(context.Background(), account) + require.NoError(t, err) + require.EqualValues(t, 3, calls.Load(), "credentials must participate in the cache key") +} + +func TestFetchOpenAIModelsListOAuthSharesManifestCache(t *testing.T) { + _, calls := newCodexModelsOAuthCacheServer(t, `{"models":[{"slug":"special-oauth-model"},{"slug":"gpt-image-1"}]}`) + s := &OpenAIGatewayService{} + account := newCodexModelsTestAccount() + response, err := s.FetchOpenAIModelsList(context.Background(), account) + require.NoError(t, err) + require.JSONEq(t, `{"object":"list","data":[{"id":"special-oauth-model","object":"model","owned_by":"openai","created":0},{"id":"gpt-image-1","object":"model","owned_by":"openai","created":0}]}`, string(response.Body)) + manifest, err := s.FetchCodexModelsManifest(context.Background(), account, CodexCanonicalClientVersion(), "") + require.NoError(t, err) + require.Contains(t, string(manifest.Body), `"slug":"special-oauth-model"`) + require.NotContains(t, string(manifest.Body), `"data"`) + require.EqualValues(t, 1, calls.Load()) + _, err = s.FetchOpenAIModelsList(context.Background(), account) + require.NoError(t, err) + require.EqualValues(t, 1, calls.Load()) +} + +func TestFetchOpenAIModelsListCacheWindowsAndSingleflight(t *testing.T) { + var calls atomic.Int32 + started := make(chan int32, 3) + release := make(chan struct{}, 3) + t.Cleanup(func() { close(release) }) + s := newCodexModelsAPIKeyTestService(&codexModelsHTTPUpstreamStub{do: func(_ *http.Request, _ string, _ int64, _ int) (*http.Response, error) { + call := calls.Add(1) + if call > 1 && call < 4 { + started <- call + <-release + } + if call >= 4 { + response := ordinaryModelsUpstreamResponse(`{"error":"unavailable"}`) + response.StatusCode = http.StatusServiceUnavailable + return response, nil + } + return ordinaryModelsUpstreamResponse(`{"data":[{"id":"version-` + string(rune('0'+call)) + `"}]}`), nil + }}) + account := newCodexModelsAPIKeyTestAccount("https://models.example/v1") + first, err := s.FetchOpenAIModelsList(context.Background(), account) + require.NoError(t, err) + for range 5 { + _, err = s.FetchOpenAIModelsList(context.Background(), account) + require.NoError(t, err) + } + require.EqualValues(t, 1, calls.Load()) + expireCodexModelsManifestCache(s, 2*time.Minute) + stale, err := s.FetchOpenAIModelsList(context.Background(), account) + require.NoError(t, err) + require.Equal(t, string(first.Body), string(stale.Body)) + select { + case call := <-started: + require.EqualValues(t, 2, call) + case <-time.After(2 * time.Second): + t.Fatal("stale response did not start a background refresh") + } + for range 5 { + cached, err := s.FetchOpenAIModelsList(context.Background(), account) + require.NoError(t, err) + require.Equal(t, string(first.Body), string(cached.Body)) + } + require.EqualValues(t, 2, calls.Load(), "concurrent stale reads must share the refresh") + release <- struct{}{} + require.Eventually(t, func() bool { + s.openAIModelsCache.mu.Lock() + defer s.openAIModelsCache.mu.Unlock() + for _, entry := range s.openAIModelsCache.entries { + if strings.Contains(string(entry.manifest.Body), "version-2") { + return true + } + } + return false + }, 2*time.Second, time.Millisecond) + + expireCodexModelsManifestCache(s, 6*time.Minute) + responses := make(chan *OpenAIModelsResponse, 2) + fetchErrors := make(chan error, 2) + var wait sync.WaitGroup + for range 2 { + wait.Add(1) + go func() { + defer wait.Done() + response, err := s.FetchOpenAIModelsList(context.Background(), account) + responses <- response + fetchErrors <- err + }() + } + select { + case call := <-started: + require.EqualValues(t, 3, call) + case <-time.After(2 * time.Second): + t.Fatal("expired catalog did not start a synchronous refresh") + } + select { + case <-responses: + t.Fatal("expired catalog returned before the upstream refresh completed") + case <-time.After(20 * time.Millisecond): + } + release <- struct{}{} + wait.Wait() + for range 2 { + require.NoError(t, <-fetchErrors) + require.Contains(t, string((<-responses).Body), "version-3") + } + require.EqualValues(t, 3, calls.Load()) + expireCodexModelsManifestCache(s, 6*time.Minute) + response, err := s.FetchOpenAIModelsList(context.Background(), account) + require.Error(t, err) + require.Nil(t, response) + require.Equal(t, http.StatusBadGateway, infraerrors.Code(err)) +} + +func TestFetchOpenAIModelsListRevalidatesUpstreamETag(t *testing.T) { + var calls atomic.Int32 + s := newCodexModelsAPIKeyTestService(&codexModelsHTTPUpstreamStub{do: func(req *http.Request, _ string, _ int64, _ int) (*http.Response, error) { + response := ordinaryModelsUpstreamResponse(`{"data":[{"id":"unchanged"}]}`) + response.Header.Set("ETag", `"upstream"`) + if calls.Add(1) > 1 { + assert.Equal(t, `"upstream"`, req.Header.Get("If-None-Match")) + response.StatusCode = http.StatusNotModified + } + return response, nil + }}) + account := newCodexModelsAPIKeyTestAccount("https://models.example/v1") + first, err := s.FetchOpenAIModelsList(context.Background(), account) + require.NoError(t, err) + expireCodexModelsManifestCache(s, 2*time.Minute) + _, err = s.FetchOpenAIModelsList(context.Background(), account) + require.NoError(t, err) + require.Eventually(t, func() bool { + s.openAIModelsCache.mu.Lock() + defer s.openAIModelsCache.mu.Unlock() + for _, entry := range s.openAIModelsCache.entries { + return entry.expiresAt.After(time.Now()) + } + return false + }, 2*time.Second, time.Millisecond) + again, err := s.FetchOpenAIModelsList(context.Background(), account) + require.NoError(t, err) + require.Equal(t, first.ETag, again.ETag) + require.Equal(t, string(first.Body), string(again.Body)) + require.EqualValues(t, 2, calls.Load()) +} + +func TestProjectAccountModelsPassthroughIgnoresStaleMappings(t *testing.T) { + account := newCodexModelsAPIKeyTestAccount("https://models.example/v1") + account.Extra = map[string]any{"openai_passthrough": true} + account.Credentials["model_mapping"] = map[string]any{"obsolete": "missing"} + body := []byte(`{"object":"list","data":[{"id":"live-model"}]}`) + projected, err := projectAccountModelsBody(body, account, nil, false) + require.NoError(t, err) + require.JSONEq(t, string(body), string(projected)) +} + +func TestFetchOpenAIModelsListEmptyAndMalformedResponses(t *testing.T) { + for _, body := range []string{`{"data":[]}`, `{"data":null}`, `{}`, `{"data":{}}`, `{"data":[{}]}`, `{"data":[null]}`} { + t.Run(body, func(t *testing.T) { + s := newCodexModelsAPIKeyTestService(&codexModelsHTTPUpstreamStub{do: func(_ *http.Request, _ string, _ int64, _ int) (*http.Response, error) { + return ordinaryModelsUpstreamResponse(body), nil + }}) + response, err := s.FetchOpenAIModelsList(context.Background(), newCodexModelsAPIKeyTestAccount("https://models.example/v1")) + if body != `{"data":[]}` { + require.Error(t, err) + require.Equal(t, http.StatusBadGateway, infraerrors.Code(err)) + return + } + require.NoError(t, err) + var result struct { + Data []json.RawMessage `json:"data"` + } + require.NoError(t, json.Unmarshal(response.Body, &result)) + require.NotNil(t, result.Data) + require.Empty(t, result.Data) + }) + } +} + +func TestPinnedOpenAIModelsListMixedAccountsShareColdCacheAcrossGroups(t *testing.T) { + _, oauthCalls := newCodexModelsOAuthCacheServer(t, `{"models":[{"slug":"shared-model"},{"slug":"oauth-special"}]}`) + var apiCalls atomic.Int32 + s := newCodexModelsAPIKeyTestService(&codexModelsHTTPUpstreamStub{do: func(_ *http.Request, _ string, _ int64, _ int) (*http.Response, error) { + apiCalls.Add(1) + return ordinaryModelsUpstreamResponse(`{"data":[{"id":"shared-model","owned_by":"api-provider"},{"id":"api-special"}]}`), nil + }}) + apiAccount := newCodexModelsAPIKeyTestAccount("https://models.example/v1") + oauthAccount := newCodexModelsTestAccount() + for _, account := range []*Account{apiAccount, oauthAccount} { + account.Status, account.Schedulable = StatusActive, true + } + accounts := []Account{*apiAccount, *oauthAccount} + s.accountRepo = splitCodexModelsAccountRepo{all: map[int64][]Account{10: accounts, 11: accounts}} + groups := []*Group{ + {ID: 10, Platform: PlatformOpenAI, CodexModelsManifestConfig: GroupCodexModelsManifestConfig{Enabled: true, AccountIDs: []int64{2, 1}}, + ModelAllowlist: GroupModelAllowlist{Enabled: true, Models: []string{"oauth-special", "shared-model"}}}, + {ID: 11, Platform: PlatformOpenAI, CodexModelsManifestConfig: GroupCodexModelsManifestConfig{Enabled: true, AccountIDs: []int64{2, 1}}}, + } + type result struct { + response *OpenAIModelsResponse + account *Account + err error + } + results := make([]result, len(groups)) + var wait sync.WaitGroup + for i, group := range groups { + wait.Add(1) + go func() { + defer wait.Done() + results[i].response, results[i].account, results[i].err = s.FetchPinnedOpenAIModelsList(context.Background(), group, 3, "") + }() + } + wait.Wait() + for i, result := range results { + require.NoError(t, result.err) + require.EqualValues(t, 2, result.account.ID) + var catalog struct { + Data []struct { + ID string `json:"id"` + Owner string `json:"owned_by"` + } `json:"data"` + } + require.NoError(t, json.Unmarshal(result.response.Body, &catalog)) + ids := make([]string, 0, len(catalog.Data)) + for _, model := range catalog.Data { + ids = append(ids, model.ID) + if model.ID == "shared-model" { + require.Equal(t, "api-provider", model.Owner) + } + } + if i == 0 { + require.Equal(t, []string{"oauth-special", "shared-model"}, ids) + } else { + require.Equal(t, []string{"shared-model", "api-special", "oauth-special"}, ids) + } + } + require.EqualValues(t, 1, apiCalls.Load()) + require.EqualValues(t, 1, oauthCalls.Load()) +} + +func TestFetchOpenAIModelsListResolvesShadowOAuthCredentials(t *testing.T) { + _, calls := newCodexModelsOAuthCacheServer(t, `{"models":[{"slug":"parent-model"}]}`) + parent := newCodexModelsTestAccount() + shadow := &Account{ID: 9, Platform: PlatformOpenAI, Type: AccountTypeOAuth, ParentAccountID: &parent.ID} + s := &OpenAIGatewayService{accountRepo: newStubCredRepo(parent)} + response, err := s.FetchOpenAIModelsList(context.Background(), shadow) + require.NoError(t, err) + require.Contains(t, string(response.Body), `"id":"parent-model"`) + require.EqualValues(t, 1, calls.Load()) +} + +func TestProjectAccountModelsCannotExposeCodexMediaThroughAlias(t *testing.T) { + account := newCodexModelsAPIKeyTestAccount("https://models.example/v1") + account.Credentials["model_mapping"] = map[string]any{"image-alias": "gpt-image-1", "auto-alias": "codex-auto-fast"} + body := []byte(`{"models":[{"slug":"gpt-image-1"},{"slug":"codex-auto-fast"}]}`) + projected, err := projectAccountModelsBody(body, account, &Group{}, true) + require.NoError(t, err) + require.JSONEq(t, `{"models":[]}`, string(projected)) +} + +func TestFetchOpenAIModelsListRejectsUnexpectedCold304(t *testing.T) { + s := newCodexModelsAPIKeyTestService(&codexModelsHTTPUpstreamStub{do: func(_ *http.Request, _ string, _ int64, _ int) (*http.Response, error) { + return &http.Response{StatusCode: http.StatusNotModified, Header: make(http.Header), Body: http.NoBody}, nil + }}) + response, err := s.FetchOpenAIModelsList(context.Background(), newCodexModelsAPIKeyTestAccount("https://models.example/v1")) + require.Error(t, err) + require.Nil(t, response) + require.Equal(t, http.StatusBadGateway, infraerrors.Code(err)) +} + +func TestOpenAIModelsCacheSeparatesRepresentationsForIdenticalRequests(t *testing.T) { + s := &OpenAIGatewayService{} + request := openAIModelsRequest{url: "https://models.example/v1/models", accountID: 7} + var calls atomic.Int32 + fetch := func(body string) func(context.Context, string) (*OpenAIModelsResponse, error) { + return func(context.Context, string) (*OpenAIModelsResponse, error) { + calls.Add(1) + return &OpenAIModelsResponse{Body: []byte(body)}, nil + } + } + manifestBody := `{"models":[{"slug":"shared"}]}` + listBody := `{"object":"list","data":[{"id":"shared"}]}` + _, err := s.fetchCachedOpenAIModels(context.Background(), request, fetch(manifestBody), "") + require.NoError(t, err) + request.standardModelsList = true + list, err := s.fetchCachedOpenAIModels(context.Background(), request, fetch(listBody), "") + require.NoError(t, err) + require.JSONEq(t, listBody, string(list.Body)) + request.standardModelsList = false + manifest, err := s.fetchCachedOpenAIModels(context.Background(), request, fetch(manifestBody), "") + require.NoError(t, err) + require.JSONEq(t, manifestBody, string(manifest.Body)) + require.EqualValues(t, 2, calls.Load()) +} diff --git a/backend/internal/service/openai_opencode_session.go b/backend/internal/service/openai_opencode_session.go new file mode 100644 index 0000000000..fbb0b97eb4 --- /dev/null +++ b/backend/internal/service/openai_opencode_session.go @@ -0,0 +1,37 @@ +package service + +import ( + "net/http" + "net/url" + "strings" + + "github.com/gin-gonic/gin" +) + +const openCodeSessionHeader = "X-OpenCode-Session" + +// applyOpenCodeSessionHeader forwards the caller-owned conversation identifier +// only to OpenCode's official API origin. The caller applies this after account +// header overrides so a per-conversation value cannot be replaced by a fixed +// account-wide override. +func applyOpenCodeSessionHeader(c *gin.Context, account *Account, targetURL string, headers http.Header) { + if c == nil || c.Request == nil || account == nil || account.Type != AccountTypeAPIKey || headers == nil { + return + } + + parsed, err := url.Parse(targetURL) + if err != nil || !strings.EqualFold(parsed.Scheme, "https") || !strings.EqualFold(parsed.Hostname(), "opencode.ai") { + return + } + + sessionID := strings.TrimSpace(c.GetHeader(openCodeSessionHeader)) + if sessionID == "" { + return + } + for key := range headers { + if strings.EqualFold(key, openCodeSessionHeader) { + delete(headers, key) + } + } + headers.Set(openCodeSessionHeader, sessionID) +} diff --git a/backend/internal/service/openai_opencode_session_test.go b/backend/internal/service/openai_opencode_session_test.go new file mode 100644 index 0000000000..fe34a95958 --- /dev/null +++ b/backend/internal/service/openai_opencode_session_test.go @@ -0,0 +1,223 @@ +package service + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +func newOpenCodeSessionTestContext(t *testing.T, value string) *gin.Context { + t.Helper() + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil) + if value != "" { + c.Request.Header.Set(openCodeSessionHeader, value) + } + return c +} + +func openCodeSessionTestService() *OpenAIGatewayService { + return &OpenAIGatewayService{cfg: &config.Config{ + Security: config.SecurityConfig{ + URLAllowlist: config.URLAllowlistConfig{Enabled: false}, + }, + }} +} + +func openCodeSessionTestAccount(baseURL string) *Account { + return &Account{ + ID: 1, + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Credentials: map[string]any{ + "base_url": baseURL, + credKeyHeaderOverrideEnabled: true, + credKeyHeaderOverrides: map[string]any{"x-opencode-session": "fixed-account-value"}, + }, + } +} + +func requireSingleOpenCodeSessionHeader(t *testing.T, headers http.Header, want string) { + t.Helper() + count := 0 + for key, values := range headers { + if strings.EqualFold(key, openCodeSessionHeader) { + count += len(values) + require.Equal(t, []string{want}, values) + } + } + require.Equal(t, 1, count) +} + +func TestApplyOpenCodeSessionHeaderTrustBoundary(t *testing.T) { + tests := []struct { + name string + account *Account + targetURL string + incoming string + want string + }{ + { + name: "official origin", + account: openCodeSessionTestAccount("https://opencode.ai/zen/v1"), + targetURL: "https://opencode.ai/zen/v1/chat/completions", + incoming: " conversation-123 ", + want: "conversation-123", + }, + { + name: "lookalike origin", + account: openCodeSessionTestAccount("https://opencode.ai.evil.example/v1"), + targetURL: "https://opencode.ai.evil.example/v1/responses", + incoming: "conversation-123", + }, + { + name: "subdomain is not implicitly trusted", + account: openCodeSessionTestAccount("https://api.opencode.ai/v1"), + targetURL: "https://api.opencode.ai/v1/responses", + incoming: "conversation-123", + }, + { + name: "insecure official origin", + account: openCodeSessionTestAccount("http://opencode.ai/zen/v1"), + targetURL: "http://opencode.ai/zen/v1/responses", + incoming: "conversation-123", + }, + { + name: "missing caller value", + account: openCodeSessionTestAccount("https://opencode.ai/zen/v1"), + targetURL: "https://opencode.ai/zen/v1/responses", + }, + { + name: "oauth account", + account: &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth}, + targetURL: "https://opencode.ai/zen/v1/responses", + incoming: "conversation-123", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + headers := make(http.Header) + applyOpenCodeSessionHeader(newOpenCodeSessionTestContext(t, tt.incoming), tt.account, tt.targetURL, headers) + require.Equal(t, tt.want, headers.Get(openCodeSessionHeader)) + }) + } +} + +func TestOpenCodeSessionForwardedByResponsesBuildersAfterAccountOverride(t *testing.T) { + gin.SetMode(gin.TestMode) + svc := openCodeSessionTestService() + account := openCodeSessionTestAccount("https://opencode.ai/zen/v1") + body := []byte(`{"model":"gpt-5","input":"hello"}`) + + tests := []struct { + name string + build func(*gin.Context) (*http.Request, error) + }{ + { + name: "normal responses", + build: func(c *gin.Context) (*http.Request, error) { + return svc.buildUpstreamRequest(context.Background(), c, account, body, "token", false, "", false) + }, + }, + { + name: "passthrough responses", + build: func(c *gin.Context) (*http.Request, error) { + return svc.buildUpstreamRequestOpenAIPassthrough(context.Background(), c, account, body, "token") + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := newOpenCodeSessionTestContext(t, "conversation-456") + req, err := tt.build(c) + require.NoError(t, err) + requireSingleOpenCodeSessionHeader(t, req.Header, "conversation-456") + }) + } +} + +func TestOpenCodeSessionMissingCallerValueKeepsExistingOverrideBehavior(t *testing.T) { + gin.SetMode(gin.TestMode) + svc := openCodeSessionTestService() + account := openCodeSessionTestAccount("https://opencode.ai/zen/v1") + c := newOpenCodeSessionTestContext(t, "") + + req, err := svc.buildUpstreamRequest( + context.Background(), c, account, + []byte(`{"model":"gpt-5","input":"hello"}`), "token", false, "", false, + ) + require.NoError(t, err) + require.Equal(t, "fixed-account-value", getHeaderRaw(req.Header, "x-opencode-session")) +} + +type openCodeSessionHTTPUpstream struct { + request *http.Request +} + +func (u *openCodeSessionHTTPUpstream) Do(req *http.Request, _ string, _ int64, _ int) (*http.Response, error) { + u.request = req + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(`{}`)), + }, nil +} + +func (u *openCodeSessionHTTPUpstream) DoWithTLS(req *http.Request, proxyURL string, accountID int64, concurrency int, _ *tlsfingerprint.Profile) (*http.Response, error) { + return u.Do(req, proxyURL, accountID, concurrency) +} + +func TestOpenCodeSessionForwardedByRawChatCompletionsAfterAccountOverride(t *testing.T) { + gin.SetMode(gin.TestMode) + upstream := &openCodeSessionHTTPUpstream{} + svc := openCodeSessionTestService() + svc.httpUpstream = upstream + account := openCodeSessionTestAccount("https://opencode.ai/zen/v1") + c := newOpenCodeSessionTestContext(t, "conversation-789") + + resp, err := svc.sendCCUpstreamRequest( + context.Background(), c, account, + "https://opencode.ai/zen/v1/chat/completions", []byte(`{"model":"gpt-5"}`), + false, "token", "", "", + ) + require.NoError(t, err) + require.NoError(t, resp.Body.Close()) + require.NotNil(t, upstream.request) + requireSingleOpenCodeSessionHeader(t, upstream.request.Header, "conversation-789") +} + +func TestOpenCodeSessionIsNotForwardedToOtherUpstreams(t *testing.T) { + gin.SetMode(gin.TestMode) + svc := openCodeSessionTestService() + body := []byte(`{"model":"gpt-5","input":"hello"}`) + + for _, baseURL := range []string{ + "https://api.openai.com/v1", + "https://opencode.ai.evil.example/v1", + "https://api.opencode.ai/v1", + } { + t.Run(baseURL, func(t *testing.T) { + account := &Account{ + ID: 1, + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Credentials: map[string]any{"base_url": baseURL}, + } + c := newOpenCodeSessionTestContext(t, "private-conversation") + req, err := svc.buildUpstreamRequest(context.Background(), c, account, body, "token", false, "", false) + require.NoError(t, err) + require.Empty(t, req.Header.Get(openCodeSessionHeader)) + }) + } +} diff --git a/backend/internal/service/openai_reasoning_effort_policy.go b/backend/internal/service/openai_reasoning_effort_policy.go index b2b3aae6e8..0d1ad0dc5c 100644 --- a/backend/internal/service/openai_reasoning_effort_policy.go +++ b/backend/internal/service/openai_reasoning_effort_policy.go @@ -22,6 +22,7 @@ const ( ) var openAIReasoningEffortValues = []string{"minimal", "low", "medium", "high", "xhigh", "max"} +var anthropicReasoningEffortValues = []string{"low", "medium", "high", "xhigh", "max"} func normalizeReasoningEffortMappingSource(raw string) string { if strings.EqualFold(strings.TrimSpace(raw), "none") { @@ -90,10 +91,14 @@ func NormalizeMaxReasoningEffort(raw string) string { } func reasoningEffortValuesForPlatform(platform string) []string { - if platform != PlatformOpenAI && platform != PlatformComposite { + switch platform { + case PlatformOpenAI, PlatformComposite: + return openAIReasoningEffortValues + case PlatformAnthropic: + return anthropicReasoningEffortValues + default: return nil } - return openAIReasoningEffortValues } func normalizeMaxReasoningEffortForPlatform(platform, raw string) (string, error) { @@ -104,7 +109,8 @@ func normalizeMaxReasoningEffortForPlatform(platform, raw string) (string, error allowedValues := reasoningEffortValuesForPlatform(platform) if len(allowedValues) == 0 { return "", fmt.Errorf( - "reasoning effort policy is only supported for platforms %q and %q", + "reasoning effort policy is only supported for platforms %q, %q, and %q", + PlatformAnthropic, PlatformOpenAI, PlatformComposite, ) @@ -152,7 +158,8 @@ func normalizeMaxReasoningEffortOverLimitForPlatform(platform, raw string) (stri } if len(reasoningEffortValuesForPlatform(platform)) == 0 { return "", fmt.Errorf( - "reasoning effort policy is only supported for platforms %q and %q", + "reasoning effort policy is only supported for platforms %q, %q, and %q", + PlatformAnthropic, PlatformOpenAI, PlatformComposite, ) @@ -279,7 +286,7 @@ func selectReasoningEffortMapping(mappings []ReasoningEffortMapping, from, reque } // NormalizeReasoningEffortMappings validates group mapping rules against the -// fixed effort values supported by OpenAI routes. Optional model scopes are +// fixed effort values supported by the selected platform. Optional model scopes are // canonicalized to exact/prefix/suffix match; empty type and model keep the // global all-models rule. func NormalizeReasoningEffortMappings(platform string, raw []ReasoningEffortMapping) ([]ReasoningEffortMapping, error) { @@ -421,7 +428,7 @@ func sanitizeGroupReasoningEffortPolicy(group *Group) { if mappingsErr != nil { mappings = []ReasoningEffortMapping{} } - if overLimit == "" || (group.Platform != PlatformOpenAI && group.Platform != PlatformComposite) { + if overLimit == "" || (group.Platform != PlatformAnthropic && group.Platform != PlatformOpenAI && group.Platform != PlatformComposite) { overLimit = ReasoningEffortOverLimitDowngrade } group.MaxReasoningEffort = maxEffort @@ -429,12 +436,13 @@ func sanitizeGroupReasoningEffortPolicy(group *Group) { group.ReasoningEffortMappings = mappings } -// ApplyOpenAIReasoningEffortPolicy applies one mapping (optionally scoped to +// ApplyReasoningEffortPolicy applies one mapping (optionally scoped to // the request model by exact name, prefix, or suffix) and then either caps // known effort levels or rejects the request when the group is configured to // deny values above the ceiling. Omitted values remain untouched so upstream -// defaults stay in control. -func ApplyOpenAIReasoningEffortPolicy(body []byte, maxEffort string, mappings []ReasoningEffortMapping, overLimit string) ([]byte, bool, error) { +// defaults stay in control. It understands both OpenAI and Anthropic request +// field shapes. +func ApplyReasoningEffortPolicy(body []byte, maxEffort string, mappings []ReasoningEffortMapping, overLimit string) ([]byte, bool, error) { maxRank, hasMax := reasoningEffortRank(maxEffort) if len(body) == 0 || (!hasMax && len(mappings) == 0) { return body, false, nil @@ -445,7 +453,7 @@ func ApplyOpenAIReasoningEffortPolicy(body []byte, maxEffort string, mappings [] requestModel := strings.TrimSpace(gjson.GetBytes(body, "model").String()) result := body changed := false - for _, path := range []string{"reasoning.effort", "reasoning_effort"} { + for _, path := range []string{"reasoning.effort", "reasoning_effort", "output_config.effort"} { field := gjson.GetBytes(result, path) if !field.Exists() || field.Type != gjson.String { continue @@ -492,3 +500,8 @@ func applyOpenAIWSReasoningEffortPolicy(payload []byte, hooks *OpenAIWSIngressHo } return payload, nil } + +// ApplyOpenAIReasoningEffortPolicy is retained for OpenAI forwarding callers. +func ApplyOpenAIReasoningEffortPolicy(body []byte, maxEffort string, mappings []ReasoningEffortMapping, overLimit string) ([]byte, bool, error) { + return ApplyReasoningEffortPolicy(body, maxEffort, mappings, overLimit) +} diff --git a/backend/internal/service/openai_reasoning_effort_policy_test.go b/backend/internal/service/openai_reasoning_effort_policy_test.go index baab02c27f..f3697bbe86 100644 --- a/backend/internal/service/openai_reasoning_effort_policy_test.go +++ b/backend/internal/service/openai_reasoning_effort_policy_test.go @@ -147,9 +147,9 @@ func TestNormalizeReasoningEffortMappings(t *testing.T) { }) t.Run("rejects mappings for non OpenAI platforms", func(t *testing.T) { - for _, platform := range []string{PlatformAnthropic, PlatformGemini, PlatformAntigravity, PlatformGrok} { + for _, platform := range []string{PlatformGemini, PlatformAntigravity, PlatformGrok} { _, err := NormalizeReasoningEffortMappings(platform, []ReasoningEffortMapping{{From: "low", To: "high"}}) - require.ErrorContains(t, err, "only supported for platforms \"openai\" and \"composite\"") + require.ErrorContains(t, err, "only supported for platforms") } _, err := NormalizeReasoningEffortMappings(PlatformOpenAI, []ReasoningEffortMapping{{From: "ultra", To: "high"}}) @@ -166,6 +166,15 @@ func TestNormalizeReasoningEffortMappings(t *testing.T) { _, err := NormalizeReasoningEffortMappings(PlatformOpenAI, []ReasoningEffortMapping{{From: "low", To: "none"}}) require.ErrorContains(t, err, "empty or unknown") }) + + t.Run("supports Anthropic values except minimal", func(t *testing.T) { + got, err := NormalizeReasoningEffortMappings(PlatformAnthropic, []ReasoningEffortMapping{{From: " MAX ", To: " x-high "}}) + require.NoError(t, err) + require.Equal(t, []ReasoningEffortMapping{{From: "max", To: "xhigh"}}, got) + + _, err = NormalizeReasoningEffortMappings(PlatformAnthropic, []ReasoningEffortMapping{{From: "minimal", To: "low"}}) + require.ErrorContains(t, err, "not supported for platform \"anthropic\"") + }) } func TestNormalizeMaxReasoningEffortForPlatform(t *testing.T) { @@ -176,9 +185,15 @@ func TestNormalizeMaxReasoningEffortForPlatform(t *testing.T) { require.NoError(t, err) require.Equal(t, "max", value) - for _, platform := range []string{PlatformAnthropic, PlatformGemini, PlatformAntigravity, PlatformGrok} { + value, err = normalizeMaxReasoningEffortForPlatform(PlatformAnthropic, "xhigh") + require.NoError(t, err) + require.Equal(t, "xhigh", value) + _, err = normalizeMaxReasoningEffortForPlatform(PlatformAnthropic, "minimal") + require.ErrorContains(t, err, "not supported") + + for _, platform := range []string{PlatformGemini, PlatformAntigravity, PlatformGrok} { _, err = normalizeMaxReasoningEffortForPlatform(platform, "low") - require.ErrorContains(t, err, "only supported for platforms \"openai\" and \"composite\"") + require.ErrorContains(t, err, "only supported for platforms") } _, err = normalizeMaxReasoningEffortForPlatform(PlatformOpenAI, "none") @@ -205,8 +220,9 @@ func TestNormalizeMaxReasoningEffortOverLimitForPlatform(t *testing.T) { require.NoError(t, err) require.Equal(t, ReasoningEffortOverLimitDowngrade, value) - _, err = normalizeMaxReasoningEffortOverLimitForPlatform(PlatformAnthropic, "deny") - require.ErrorContains(t, err, "only supported for platforms \"openai\" and \"composite\"") + value, err = normalizeMaxReasoningEffortOverLimitForPlatform(PlatformAnthropic, "deny") + require.NoError(t, err) + require.Equal(t, ReasoningEffortOverLimitDeny, value) _, err = normalizeMaxReasoningEffortOverLimitForPlatform(PlatformOpenAI, "block") require.ErrorContains(t, err, "not supported") @@ -251,6 +267,8 @@ func TestApplyOpenAIReasoningEffortPolicy(t *testing.T) { }{ {name: "nested caps high", body: `{"reasoning":{"effort":"xhigh"}}`, max: "medium", path: "reasoning.effort", want: "medium", changed: true}, {name: "flat caps high", body: `{"reasoning_effort":"high"}`, max: "low", path: "reasoning_effort", want: "low", changed: true}, + {name: "Anthropic output config caps max", body: `{"output_config":{"effort":"max"}}`, max: "xhigh", path: "output_config.effort", want: "xhigh", changed: true}, + {name: "Anthropic output config maps before cap", body: `{"output_config":{"effort":"max"}}`, max: "high", mappings: []ReasoningEffortMapping{{From: "max", To: "xhigh"}}, path: "output_config.effort", want: "high", changed: true}, {name: "does not raise omitted", body: `{"model":"gpt-5"}`, max: "low", path: "reasoning_effort", want: "", changed: false}, {name: "keeps lower value", body: `{"reasoning_effort":"low"}`, max: "high", path: "reasoning_effort", want: "low", changed: false}, {name: "normalizes request alias", body: `{"reasoning_effort":"x-high"}`, max: "xhigh", path: "reasoning_effort", want: "xhigh", changed: true}, diff --git a/backend/internal/service/openai_responses_input_compat.go b/backend/internal/service/openai_responses_input_compat.go index e8c4a8116d..29619bc4dd 100644 --- a/backend/internal/service/openai_responses_input_compat.go +++ b/backend/internal/service/openai_responses_input_compat.go @@ -7,7 +7,8 @@ import ( const openAIResponsesInputTextMaxChars = 10000000 // sanitizeOpenAIResponsesOrphanToolOutputs removes tool-output items that have -// no matching call or item reference anywhere in the current input. +// no matching call or item reference anywhere in the current input. Named +// function outputs without a call ID are standalone inputs, not orphan results. func sanitizeOpenAIResponsesOrphanToolOutputs(reqBody map[string]any, input []any, hasPreviousResponseID bool) bool { if len(input) == 0 || hasPreviousResponseID { return false @@ -45,6 +46,15 @@ func sanitizeOpenAIResponsesOrphanToolOutputs(reqBody map[string]any, input []an } callID := strings.TrimSpace(firstNonEmptyString(item["call_id"])) + // Codex sends externally supplied inputs (such as task delegation) as + // named function outputs without a preceding function call. Preserve + // their native type, namespace and output instead of silently dropping + // the request that started the turn. + if callID == "" && strings.TrimSpace(firstNonEmptyString(item["type"])) == "function_call_output" && + strings.TrimSpace(firstNonEmptyString(item["name"])) != "" { + normalized = append(normalized, rawItem) + continue + } _, hasToolCall := toolCallIDs[callID] _, hasReference := referenceIDs[callID] if callID != "" && (hasToolCall || hasReference) { diff --git a/backend/internal/service/openai_responses_input_compat_test.go b/backend/internal/service/openai_responses_input_compat_test.go index fc9fa7418a..e0d01c03d7 100644 --- a/backend/internal/service/openai_responses_input_compat_test.go +++ b/backend/internal/service/openai_responses_input_compat_test.go @@ -2,6 +2,8 @@ package service import ( "context" + "encoding/json" + "fmt" "net/http" "strings" "testing" @@ -11,6 +13,21 @@ import ( ) func TestSanitizeOpenAIResponsesOrphanToolOutputs(t *testing.T) { + t.Run("named standalone inputs do not legitimize orphan results", func(t *testing.T) { + named := map[string]any{"type": "function_call_output", "name": "send_message_to_thread", "namespace": "codex_app", "output": "delegation"} + input := []any{ + named, + map[string]any{"type": "function_call_output", "output": "missing name and call id"}, + map[string]any{"type": "function_call_output", "name": " ", "output": "blank name"}, + map[string]any{"type": "function_call_output", "name": "send_message_to_thread", "call_id": "missing", "output": "orphan result"}, + map[string]any{"type": "custom_tool_call_output", "name": "apply_patch", "output": "missing call id"}, + } + reqBody := map[string]any{"input": input} + + require.True(t, sanitizeOpenAIResponsesOrphanToolOutputs(reqBody, input, false)) + require.Equal(t, []any{named}, reqBody["input"]) + }) + t.Run("preserves matches regardless of item order", func(t *testing.T) { input := []any{ map[string]any{"type": "tool_search_output", "call_id": "search_1", "output": "first"}, @@ -71,6 +88,48 @@ func TestSanitizeOpenAIResponsesOrphanToolOutputs(t *testing.T) { }) } +func TestWebSocketCompatibilityPreservesNamedDelegation(t *testing.T) { + for _, toolName := range []string{"create_thread", "send_message_to_thread"} { + for _, withHistory := range []bool{false, true} { + for _, previousResponseID := range []string{"", "resp_previous"} { + name := fmt.Sprintf("%s/history=%t/previous=%t", toolName, withHistory, previousResponseID != "") + t.Run(name, func(t *testing.T) { + input := []any{} + if withHistory { + input = append(input, + map[string]any{"type": "function_call", "call_id": "fc_history", "name": "lookup", "arguments": "{}"}, + map[string]any{"type": "function_call_output", "call_id": "fc_history", "output": "historical result"}, + ) + } + input = append(input, + map[string]any{"type": "message", "role": "user", "content": []any{map[string]any{"type": "input_text", "text": "Updated environment context."}}}, + map[string]any{"type": "function_call_output", "name": toolName, "namespace": "codex_app", "output": "\n source-task\n Reply with DELEGATION_OK.\n"}, + ) + reqBody := map[string]any{"type": "response.create", "model": "gpt-5.5", "input": input} + if previousResponseID != "" { + reqBody["previous_response_id"] = previousResponseID + } + body, err := json.Marshal(reqBody) + require.NoError(t, err) + account := &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth} + + normalized, _, err := normalizeOpenAIResponsesWebSocketCompatibilityBody(body, account, false) + require.NoError(t, err) + var got map[string]any + require.NoError(t, json.Unmarshal(normalized, &got)) + require.Equal(t, input, got["input"], "preserve the delegation envelope, native type and history in order") + require.Equal(t, reqBody["previous_response_id"], got["previous_response_id"]) + + again, changed, err := normalizeOpenAIResponsesWebSocketCompatibilityBody(normalized, account, false) + require.NoError(t, err) + require.False(t, changed, "normalization must be idempotent") + require.JSONEq(t, string(normalized), string(again)) + }) + } + } + } +} + func TestOpenAIResponsesInputTextIsNeverSilentlyTruncated(t *testing.T) { atLimit := strings.Repeat("z", openAIResponsesInputTextMaxChars) oversized := strings.Repeat("a", openAIResponsesInputTextMaxChars) + "中" diff --git a/backend/internal/service/openai_routing_hint.go b/backend/internal/service/openai_routing_hint.go index 85fd93314d..c66b941149 100644 --- a/backend/internal/service/openai_routing_hint.go +++ b/backend/internal/service/openai_routing_hint.go @@ -37,14 +37,14 @@ func setOpenAICodexRoutingHint(headers http.Header, account *Account, model stri // Codex treats "default" as an explicit standard-routing sentinel, not as a // service tier sent to the backend. Fast follows the gateway's existing - // canonicalization and therefore becomes "priority"; flex stays "flex". + // canonicalization and therefore becomes "priority"; flex and ultrafast + // stay as themselves. canonicalTier := normalizedOpenAIServiceTierValue(serviceTier) - // This backport has no Codex model-catalog snapshot with which to validate - // arbitrary tier ids. Keep the hint to the two effective tiers Codex itself - // selects; default, missing, and other gateway-compatible API values remain - // model-only rather than expanding the ChatGPT routing protocol here. + // Keep the hint to the effective tiers Codex itself selects. default, + // missing, auto, and scale remain model-only rather than expanding the + // ChatGPT routing protocol here. switch canonicalTier { - case OpenAIFastTierPriority, OpenAIFastTierFlex: + case OpenAIFastTierPriority, OpenAIFastTierFlex, OpenAIFastTierUltrafast: default: canonicalTier = "" } diff --git a/backend/internal/service/openai_routing_hint_test.go b/backend/internal/service/openai_routing_hint_test.go index 5801b15fa4..343d227357 100644 --- a/backend/internal/service/openai_routing_hint_test.go +++ b/backend/internal/service/openai_routing_hint_test.go @@ -24,6 +24,7 @@ func TestSetOpenAICodexRoutingHintCanonicalizesOfficialServiceTiers(t *testing.T {name: "fast alias", model: "gpt-5.6", serviceTier: " fast ", want: "model=gpt-5.6;tier=priority"}, {name: "priority", model: "gpt-5.6", serviceTier: "priority", want: "model=gpt-5.6;tier=priority"}, {name: "flex", model: "gpt-5.6", serviceTier: "flex", want: "model=gpt-5.6;tier=flex"}, + {name: "ultrafast", model: "gpt-5.6", serviceTier: "ultrafast", want: "model=gpt-5.6;tier=ultrafast"}, {name: "explicit default sentinel", model: "gpt-5.6", serviceTier: "default", want: "model=gpt-5.6"}, {name: "omitted tier", model: "gpt-5.6", want: "model=gpt-5.6"}, {name: "auto is not expanded without catalog support", model: "gpt-5.6", serviceTier: "auto", want: "model=gpt-5.6"}, @@ -90,6 +91,7 @@ func TestOpenAIOAuthHTTPBuildersSendRoutingHintFromFinalBody(t *testing.T) { }{ {name: "fast", body: []byte(`{"model":"gpt-5.6-codex","service_tier":"fast"}`), want: "model=gpt-5.6-codex;tier=priority"}, {name: "flex", body: []byte(`{"model":"gpt-5.6-codex","service_tier":"flex"}`), want: "model=gpt-5.6-codex;tier=flex"}, + {name: "ultrafast", body: []byte(`{"model":"gpt-5.6-codex","service_tier":"ultrafast"}`), want: "model=gpt-5.6-codex;tier=ultrafast"}, {name: "default", body: []byte(`{"model":"gpt-5.6-codex","service_tier":"default"}`), want: "model=gpt-5.6-codex"}, {name: "omitted", body: []byte(`{"model":"gpt-5.6-codex"}`), want: "model=gpt-5.6-codex"}, } @@ -216,6 +218,7 @@ func TestBuildOpenAIWSHeadersSendsOAuthRoutingHintOnly(t *testing.T) { }, } require.Equal(t, "model=gpt-5.6-codex;tier=priority", build(t, oauthAccount, "fast").Get(openAICodexRoutingHintHeader)) + require.Equal(t, "model=gpt-5.6-codex;tier=ultrafast", build(t, oauthAccount, "ultrafast").Get(openAICodexRoutingHintHeader)) require.Equal(t, "model=gpt-5.6-codex", build(t, oauthAccount, "default").Get(openAICodexRoutingHintHeader)) require.Empty(t, build(t, &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey}, "priority").Get(openAICodexRoutingHintHeader)) } diff --git a/backend/internal/service/openai_upstream_client_error.go b/backend/internal/service/openai_upstream_client_error.go index 97690e0ab7..431f5a7703 100644 --- a/backend/internal/service/openai_upstream_client_error.go +++ b/backend/internal/service/openai_upstream_client_error.go @@ -55,3 +55,9 @@ func writeOpenAIUpstreamClientError(c *gin.Context, statusCode int, body []byte, c.JSON(statusCode, gin.H{"error": errorPayload}) } + +// WriteOpenAIUpstreamClientError preserves a structured deterministic upstream +// client error when the handler has exhausted all eligible accounts. +func WriteOpenAIUpstreamClientError(c *gin.Context, statusCode int, body []byte, upstreamMsg string) { + writeOpenAIUpstreamClientError(c, statusCode, body, upstreamMsg) +} diff --git a/backend/internal/service/openai_upstream_client_error_test.go b/backend/internal/service/openai_upstream_client_error_test.go index 291c1c491e..cd50e700a6 100644 --- a/backend/internal/service/openai_upstream_client_error_test.go +++ b/backend/internal/service/openai_upstream_client_error_test.go @@ -45,6 +45,67 @@ func newOpenAIUpstreamErrorTestAccount() *Account { return &Account{ID: 1, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Name: "acct"} } +type modelNotFoundManagedAccountRepo struct{ AccountRepository } + +func TestOpenAICompatibleModelNotFound400FailoverScope(t *testing.T) { + svc := &OpenAIGatewayService{accountRepo: &modelNotFoundManagedAccountRepo{}} + body := []byte(`{"error":{"code":"model_not_found","message":"model not found"}}`) + + for _, tc := range []struct { + name string + account *Account + want bool + }{ + {name: "openai api key", account: &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey}, want: true}, + {name: "compatible provider", account: &Account{Platform: PlatformDeepseek, Type: AccountTypeAPIKey}, want: true}, + {name: "anthropic account", account: &Account{Platform: PlatformAnthropic, Type: AccountTypeAPIKey}, want: false}, + {name: "missing account", account: nil, want: false}, + } { + t.Run(tc.name, func(t *testing.T) { + require.Equal(t, tc.want, svc.shouldFailoverOpenAIUpstreamResponse( + tc.account, http.StatusBadRequest, "model not found", body, + )) + }) + } + + require.False(t, svc.shouldFailoverOpenAIUpstreamResponse( + &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey}, + http.StatusBadRequest, + "Invalid value for temperature", + []byte(`{"error":{"code":"invalid_request_error","message":"Invalid value for temperature"}}`), + )) +} + +func TestOpenAICompatibleModelNotFound400WithoutManagedCandidatesRemainsTerminal(t *testing.T) { + svc := &OpenAIGatewayService{} + body := []byte(`{"error":{"code":"model_not_found","message":"model not found"}}`) + + require.False(t, svc.shouldFailoverOpenAIUpstreamResponse( + &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth}, + http.StatusBadRequest, + "model not found", + body, + )) +} + +func TestFailoverOpenAIUpstreamHTTPError_ModelNotFoundIsNextAccountEligible(t *testing.T) { + c, _ := newOpenAIUpstreamErrorTestContext(t) + svc := &OpenAIGatewayService{cfg: &config.Config{}, accountRepo: &modelNotFoundManagedAccountRepo{}} + account := &Account{ID: 42, Name: "compatible", Platform: PlatformOpenAI, Type: AccountTypeAPIKey} + body := []byte(`{"error":{"code":"model_not_found","message":"model not found"}}`) + resp := newOpenAIUpstreamErrorResponse(http.StatusBadRequest, string(body)) + + failoverErr := svc.failoverOpenAIUpstreamHTTPError( + context.Background(), c, account, resp, body, "model not found", "missing-model", + ) + + require.NotNil(t, failoverErr) + require.Equal(t, http.StatusBadRequest, failoverErr.StatusCode) + require.Equal(t, body, failoverErr.ResponseBody) + require.True(t, failoverErr.ShouldRetryNextAccount()) + require.False(t, IsResponseCommitted(c), "failover must remain eligible before writing downstream") +} + // 主复现:原生 Responses 路径必须回真实的 400 与上游诊断信息,而不是可重试的 502。 // // 归一成 502 时下游网关(CCH 等)会把确定性的 Schema 错误当成临时上游故障重试, diff --git a/backend/internal/service/openai_ws_forwarder_ingress.go b/backend/internal/service/openai_ws_forwarder_ingress.go index 74eeac702b..f24dc25697 100644 --- a/backend/internal/service/openai_ws_forwarder_ingress.go +++ b/backend/internal/service/openai_ws_forwarder_ingress.go @@ -137,9 +137,9 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( forceHTTPBridge = true break } - // 透传 relay 通过 TurnStarted 记录每个 turn 的开始时刻,但不触发 - // BeforeTurn;因此仍只有建连时的利润准入门,没有 turn 级复核。 - // handler 计费在 turn 定价未冻结时回退到对应的 turn 开始时刻。 + // 首轮准入由握手路径完成;后续 response.create 会在写入上游前 + // 依次回调 BeforeRequest 和 BeforeTurn,并在终止或失败时回调 + // AfterTurn,从而覆盖 turn 级利润复核、定价冻结和并发槽位释放。 return s.proxyResponsesWebSocketV2Passthrough( ctx, c, @@ -521,6 +521,7 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( return err } + useHTTPBridge := forceHTTPBridge || s.shouldBridgeOpenAIWSHTTP(account, firstPayload.payloadBytes, firstPayload.previousResponseID) turnState := strings.TrimSpace(c.GetHeader(openAIWSTurnStateHeader)) stateStore := s.getOpenAIWSStateStore() groupID := getOpenAIGroupIDFromContext(c) @@ -530,20 +531,25 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( storeDisabled := false refreshIngressRouteState := func(payload openAIWSClientPayload) { sessionHash = s.GenerateSessionHash(c, payload.rawForHash) + preferredConnID = "" + storeDisabled = s.isOpenAIWSStoreDisabledInRequestRaw(payload.payloadRaw, account) + if useHTTPBridge { + // Sticky account affinity may be shared, but an HTTP bridge must not + // inherit another connection's native WS turn state or socket binding. + return + } if turnState == "" && stateStore != nil && sessionHash != "" { if savedTurnState, ok := stateStore.GetSessionTurnState(groupID, sessionHash); ok { turnState = savedTurnState } } - preferredConnID = "" if stateStore != nil && payload.previousResponseID != "" { if connID, ok := stateStore.GetResponseConn(payload.previousResponseID); ok { preferredConnID = connID } } - storeDisabled = s.isOpenAIWSStoreDisabledInRequestRaw(payload.payloadRaw, account) if stateStore != nil && storeDisabled && payload.previousResponseID == "" && sessionHash != "" { if connID, ok := stateStore.GetSessionConn(groupID, sessionHash); ok { preferredConnID = connID @@ -552,7 +558,7 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( } refreshIngressRouteState(firstPayload) - if forceHTTPBridge || s.shouldBridgeOpenAIWSHTTP(account, firstPayload.payloadBytes, firstPayload.previousResponseID) { + if useHTTPBridge { logOpenAIWSModeInfo( "ingress_ws_http_bridge_start account_id=%d account_type=%s payload_bytes=%d threshold_bytes=%d has_session_hash=%v store_disabled=%v", account.ID, @@ -725,10 +731,9 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( bridgeAccountFailoverInputExists = true } if bridgeTurnState := strings.TrimSpace(result.ResponseHeaders.Get(openAIWSTurnStateHeader)); bridgeTurnState != "" { + // Follow-up turns on this bridge retain their own upstream state; + // publishing it by session hash would leak it to independent bridges. turnState = bridgeTurnState - if stateStore != nil && sessionHash != "" { - stateStore.BindSessionTurnState(groupID, sessionHash, bridgeTurnState, s.openAIWSSessionStickyTTL()) - } } responseID := strings.TrimSpace(result.RequestID) if responseID != "" && stateStore != nil { @@ -1021,6 +1026,12 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( } lastEventType = eventType } + if openAIWSMessageShouldParseUsage(eventType, upstreamMessage) { + parseOpenAIWSResponseUsageFromCompletedEvent(upstreamMessage, &usage) + } + if eventType == "error" || eventType == "response.failed" { + markOpenAICyberPolicyEvent(c, upstreamMessage, http.StatusOK, &usage) + } if eventType == "error" { s.handleOpenAIWSErrorEventTransientFailure(ctx, account, mappedModel, lease.HandshakeHeaders(), upstreamMessage) errCodeRaw, errTypeRaw, errMsgRaw := parseOpenAIWSErrorEventFields(upstreamMessage) @@ -1135,23 +1146,8 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( ms := int(time.Since(turnStart).Milliseconds()) firstTokenMs = &ms } - if openAIWSMessageShouldParseUsage(eventType, upstreamMessage) { - parseOpenAIWSResponseUsageFromCompletedEvent(upstreamMessage, &usage) - } imageCounter.AddSSEData(upstreamMessage) - if eventType == "response.failed" { - if hit, code, msg := detectOpenAICyberPolicy(upstreamMessage); hit { - MarkOpsCyberPolicy(c, CyberPolicyMark{ - Code: code, - Message: msg, - Body: truncateString(string(upstreamMessage), 4096), - UpstreamStatus: http.StatusOK, - UpstreamInTok: usage.InputTokens, - UpstreamOutTok: usage.OutputTokens, - }) - } - } if !clientDisconnected { if needModelReplace && len(mappedModelBytes) > 0 && openAIWSEventMayContainModel(eventType) && bytes.Contains(upstreamMessage, mappedModelBytes) { upstreamMessage = replaceOpenAIWSMessageModel(upstreamMessage, mappedModel, originalModel) diff --git a/backend/internal/service/openai_ws_forwarder_v2.go b/backend/internal/service/openai_ws_forwarder_v2.go index 81f8631e96..260ec3eedf 100644 --- a/backend/internal/service/openai_ws_forwarder_v2.go +++ b/backend/internal/service/openai_ws_forwarder_v2.go @@ -585,17 +585,8 @@ func (s *OpenAIGatewayService) forwardOpenAIWSV2( } imageCounter.AddSSEData(message) - if eventType == "response.failed" { - if hit, code, msg := detectOpenAICyberPolicy(message); hit { - MarkOpsCyberPolicy(c, CyberPolicyMark{ - Code: code, - Message: msg, - Body: truncateString(string(message), 4096), - UpstreamStatus: http.StatusOK, - UpstreamInTok: usage.InputTokens, - UpstreamOutTok: usage.OutputTokens, - }) - } + if eventType == "error" || eventType == "response.failed" { + markOpenAICyberPolicyEvent(c, message, http.StatusOK, usage) } if eventType == "error" { diff --git a/backend/internal/service/openai_ws_forwarder_v2_test.go b/backend/internal/service/openai_ws_forwarder_v2_test.go index ab3301cc8d..636535a7eb 100644 --- a/backend/internal/service/openai_ws_forwarder_v2_test.go +++ b/backend/internal/service/openai_ws_forwarder_v2_test.go @@ -2,6 +2,7 @@ package service import ( "context" + "errors" "fmt" "net/http" "net/http/httptest" @@ -94,3 +95,76 @@ func TestForwardOpenAIWSV2_KeepsOutboundAndObservedServiceTiersSeparate(t *testi }) } } + +func TestForwardOpenAIWSV2_MarksCyberPolicyForFailureEventShapes(t *testing.T) { + gin.SetMode(gin.TestMode) + + tests := []struct { + name string + upstreamEvent []byte + wantError bool + wantInput int + wantOutput int + }{ + { + name: "error_before_return", + upstreamEvent: []byte(`{"type":"error","error":{"type":"rate_limit_error","code":"cyber_policy","message":"rate limit exceeded by cyber policy"},"usage":{"input_tokens":5,"output_tokens":1}}`), + wantError: true, + wantInput: 5, + wantOutput: 1, + }, + { + name: "response_failed_terminal", + upstreamEvent: []byte(`{"type":"response.failed","response":{"id":"resp_cyber","status":"failed","error":{"code":"cyber_policy","message":"blocked by cyber policy"},"usage":{"input_tokens":9,"output_tokens":2}}}`), + wantInput: 9, + wantOutput: 2, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, "/openai/v1/responses", nil) + + cfg := newOpenAIWSV2TestConfig() + cfg.Security.URLAllowlist.Enabled = false + cfg.Security.URLAllowlist.AllowInsecureHTTP = true + cfg.Gateway.OpenAIWS.MinIdlePerAccount = 0 + captureConn := &openAIWSCaptureConn{events: [][]byte{append([]byte(nil), tt.upstreamEvent...)}} + pool := newOpenAIWSConnPool(cfg) + pool.setClientDialerForTest(&openAIWSCaptureDialer{conn: captureConn}) + svc := &OpenAIGatewayService{ + cfg: cfg, + httpUpstream: &httpUpstreamRecorder{}, + cache: &stubGatewayCache{}, + openaiWSResolver: NewOpenAIWSProtocolResolver(cfg), + toolCorrector: NewCodexToolCorrector(), + openaiWSPool: pool, + } + account := &Account{ + ID: 5883, Name: "openai-ws-v2-cyber", Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, Status: StatusActive, Schedulable: true, Concurrency: 1, + Credentials: map[string]any{"api_key": "sk-test"}, + Extra: map[string]any{"responses_websockets_v2_enabled": true}, + } + + result, err := svc.Forward(context.Background(), c, account, []byte(`{"model":"gpt-5.5","stream":false,"input":"hello"}`)) + if tt.wantError { + require.Error(t, err) + require.Nil(t, result) + var failoverErr *UpstreamFailoverError + require.False(t, errors.As(err, &failoverErr)) + } else { + require.NoError(t, err) + require.NotNil(t, result) + } + + mark := GetOpsCyberPolicy(c) + require.NotNil(t, mark) + require.Equal(t, "cyber_policy", mark.Code) + require.Equal(t, tt.wantInput, mark.UpstreamInTok) + require.Equal(t, tt.wantOutput, mark.UpstreamOutTok) + }) + } +} diff --git a/backend/internal/service/openai_ws_http_bridge.go b/backend/internal/service/openai_ws_http_bridge.go index 4b1c487bef..52420c584a 100644 --- a/backend/internal/service/openai_ws_http_bridge.go +++ b/backend/internal/service/openai_ws_http_bridge.go @@ -563,6 +563,7 @@ func (s *OpenAIGatewayService) proxyOpenAIWSHTTPBridgeTurn( respBody, _ := io.ReadAll(io.LimitReader(resp.Body, openAIWSHTTPBridgeErrorBodyLimitBytes)) _ = resp.Body.Close() + markOpenAICyberPolicyEvent(c, respBody, resp.StatusCode, nil) if resp.StatusCode == http.StatusBadRequest && extractUpstreamErrorCode(respBody) == openAIWSFallbackReasonInvalidEncryptedContent { s.markOpenAIWSInvalidEncryptedContentLineageFromPayload( @@ -589,7 +590,7 @@ func (s *OpenAIGatewayService) proxyOpenAIWSHTTPBridgeTurn( if upstreamMsg == "" { upstreamMsg = http.StatusText(resp.StatusCode) } - shouldFailover := s.shouldFailoverOpenAIUpstreamResponse(resp.StatusCode, upstreamMsg, respBody) + shouldFailover := s.shouldFailoverOpenAIUpstreamResponse(account, resp.StatusCode, upstreamMsg, respBody) if account.Platform == PlatformGrok { shouldFailover = s.shouldFailoverGrokUpstreamError(resp.StatusCode, respBody) s.handleGrokAccountUpstreamError(withGrokTeamRateLimitModel(ctx, resolveGrokWSUpstreamModel(account, body, originalModel)), account, resp.StatusCode, resp.Header, respBody) @@ -776,6 +777,9 @@ func (s *OpenAIGatewayService) proxyOpenAIWSHTTPBridgeTurn( if openAIWSMessageShouldParseUsage(eventType, upstreamMessage) { parseOpenAIWSResponseUsageFromCompletedEvent(upstreamMessage, &usage) } + if eventType == "error" || eventType == "response.failed" { + markOpenAICyberPolicyEvent(c, upstreamMessage, http.StatusOK, &usage) + } imageCounter.AddSSEData(upstreamMessage) if needModelReplace && len(mappedModelBytes) > 0 && openAIWSEventMayContainModel(eventType) && strings.Contains(trimmedData, mappedModel) { diff --git a/backend/internal/service/openai_ws_http_bridge_isolation_test.go b/backend/internal/service/openai_ws_http_bridge_isolation_test.go new file mode 100644 index 0000000000..53711dccd3 --- /dev/null +++ b/backend/internal/service/openai_ws_http_bridge_isolation_test.go @@ -0,0 +1,249 @@ +package service + +import ( + "bytes" + "context" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint" + coderws "github.com/coder/websocket" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +type httpBridgeIsolationBody struct { + ctx context.Context + prefix *bytes.Reader + suffix *bytes.Reader + release <-chan struct{} + closed chan struct{} + once sync.Once +} + +func (b *httpBridgeIsolationBody) Read(p []byte) (int, error) { + if b.prefix.Len() > 0 { + return b.prefix.Read(p) + } + select { + case <-b.release: + return b.suffix.Read(p) + case <-b.closed: + return 0, io.EOF + case <-b.ctx.Done(): + return 0, b.ctx.Err() + } +} + +func (b *httpBridgeIsolationBody) Close() error { + b.once.Do(func() { close(b.closed) }) + return nil +} + +type httpBridgeIsolationRequest struct { + input []string + state string +} + +type httpBridgeIsolationUpstream struct { + ctx context.Context + mu sync.Mutex + requests []httpBridgeIsolationRequest + firstRelease chan struct{} +} + +func (u *httpBridgeIsolationUpstream) Do(req *http.Request, _ string, _ int64, _ int) (*http.Response, error) { + body, err := io.ReadAll(req.Body) + if err != nil { + return nil, err + } + _ = req.Body.Close() + input := gjson.GetBytes(body, "input") + var texts []string + if input.Type == gjson.String { + texts = append(texts, input.String()) + } else { + for _, item := range input.Array() { + if item.Type == gjson.String { + texts = append(texts, item.String()) + continue + } + if item.Get("type").String() == "function_call" { + texts = append(texts, item.Get("call_id").String()) + continue + } + if item.Get("type").String() == "function_call_output" { + texts = append(texts, item.Get("output").String()) + continue + } + content := item.Get("content") + if content.Type == gjson.String { + texts = append(texts, content.String()) + } else { + for _, part := range content.Array() { + if text := part.Get("text"); text.Exists() { + texts = append(texts, text.String()) + } + } + } + } + } + if len(texts) == 0 { + return nil, fmt.Errorf("missing bridge input") + } + u.mu.Lock() + u.requests = append(u.requests, httpBridgeIsolationRequest{input: texts, state: req.Header.Get(openAIWSTurnStateHeader)}) + u.mu.Unlock() + + id := texts[0] + turn := 1 + if input.IsArray() { + turn = 2 + } + responseID := fmt.Sprintf("resp_%s_%d", id, turn) + prefix := fmt.Sprintf("data: {\"type\":\"response.created\",\"response\":{\"id\":%q,\"status\":\"in_progress\"}}\n\n", responseID) + if turn == 1 { + prefix += "data: {\"type\":\"response.output_item.added\",\"output_index\":0,\"item\":{\"id\":\"rs_test\",\"type\":\"reasoning\",\"encrypted_content\":\"opaque\"}}\n\n" + } + output := "[]" + if turn == 1 { + output = fmt.Sprintf(`[{"type":"function_call","id":%q,"call_id":%q,"name":"lookup","arguments":"{}","status":"completed"}]`, "fc_"+id, "call_"+id) + } + suffix := fmt.Sprintf("data: {\"type\":\"response.completed\",\"response\":{\"id\":%q,\"status\":\"completed\",\"output\":%s,\"usage\":{\"input_tokens\":1,\"output_tokens\":1}}}\n\n", responseID, output) + headers := http.Header{"Content-Type": []string{"text/event-stream"}} + headers.Set(openAIWSTurnStateHeader, "turn-"+id) + responseBody := io.NopCloser(strings.NewReader(prefix + suffix)) + if turn == 1 { + responseBody = &httpBridgeIsolationBody{ctx: u.ctx, prefix: bytes.NewReader([]byte(prefix)), suffix: bytes.NewReader([]byte(suffix)), release: u.firstRelease, closed: make(chan struct{})} + } + return &http.Response{StatusCode: http.StatusOK, Header: headers, Body: responseBody}, nil +} + +func (u *httpBridgeIsolationUpstream) DoWithTLS(req *http.Request, proxyURL string, accountID int64, concurrency int, _ *tlsfingerprint.Profile) (*http.Response, error) { + return u.Do(req, proxyURL, accountID, concurrency) +} + +func TestOpenAIWSHTTPBridgeSessionIsolationAcrossSameSessionHash(t *testing.T) { + gin.SetMode(gin.TestMode) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + cfg := &config.Config{} + cfg.Gateway.OpenAIWS.Enabled = true + cfg.Gateway.OpenAIWS.OAuthEnabled = true + cfg.Gateway.OpenAIWS.ResponsesWebsocketsV2 = true + cfg.Gateway.OpenAIWS.ModeRouterV2Enabled = true + cfg.Gateway.OpenAIWS.IngressModeDefault = OpenAIWSIngressModeHTTPBridge + cfg.Gateway.OpenAIWS.ReadTimeoutSeconds = 5 + cfg.Gateway.OpenAIWS.WriteTimeoutSeconds = 5 + upstream := &httpBridgeIsolationUpstream{ctx: ctx, firstRelease: make(chan struct{})} + stateStore := NewOpenAIWSStateStore(nil) + svc := &OpenAIGatewayService{cfg: cfg, httpUpstream: upstream, openaiWSResolver: NewOpenAIWSProtocolResolver(cfg), openaiWSStateStore: stateStore} + account := &Account{ID: 1, Platform: PlatformOpenAI, Type: AccountTypeOAuth, + Credentials: map[string]any{"access_token": "test-token"}, + Extra: map[string]any{"openai_oauth_responses_websockets_v2_mode": OpenAIWSIngressModeHTTPBridge}, + Concurrency: 2, Status: StatusActive, Schedulable: true} + groupID := int64(7) + newContext := func(r *http.Request) *gin.Context { + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Request = r.Clone(ctx) + c.Request.Header.Set("session-id", "shared-session") + c.Set("api_key", &APIKey{ID: 11, GroupID: &groupID}) + return c + } + seedHash := svc.GenerateSessionHash(newContext(httptest.NewRequest(http.MethodGet, "/v1/responses", nil)), nil) + stateStore.BindSessionTurnState(groupID, seedHash, "sentinel-turn-state", time.Hour) + stateStore.BindSessionConn(groupID, seedHash, "sentinel-native-conn", time.Hour) + + serverResults := make(chan error, 2) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + conn, err := coderws.Accept(w, r, nil) + if err != nil { + serverResults <- err + return + } + defer func() { _ = conn.CloseNow() }() + _, firstMessage, err := conn.Read(ctx) + if err != nil { + serverResults <- err + return + } + c := newContext(r) + // Match the handler-owned registration and nested forwarding call. + preemptCtx, cleanup, _ := svc.BeginOpenAIWSIngressSessionPreemption(ctx, c, account, firstMessage) + defer cleanup() + hooks := &OpenAIWSIngressHooks{ClientLifecycleContext: ctx} + serverResults <- svc.ProxyResponsesWebSocketFromClient(preemptCtx, c, conn, account, "test-token", firstMessage, hooks) + })) + var releaseOnce sync.Once + release := func() { releaseOnce.Do(func() { close(upstream.firstRelease) }) } + defer func() { + release() + cancel() + server.Close() + }() + + readEvent := func(conn *coderws.Conn, wantType string) []byte { + t.Helper() + _, event, err := conn.Read(ctx) + require.NoError(t, err) + require.Equal(t, wantType, gjson.GetBytes(event, "type").String(), "event: %s", event) + return event + } + clients := make([]*coderws.Conn, 0, 2) + for _, id := range []string{"alpha", "beta"} { + conn, _, err := coderws.Dial(ctx, "ws"+strings.TrimPrefix(server.URL, "http"), nil) + require.NoError(t, err) + defer func() { _ = conn.CloseNow() }() + clients = append(clients, conn) + payload := fmt.Sprintf(`{"type":"response.create","model":"gpt-5","prompt_cache_key":%q,"client_metadata":{"thread_id":%q},"input":%q}`, id+"-cache", id+"-thread", id) + require.NoError(t, conn.Write(ctx, coderws.MessageText, []byte(payload))) + readEvent(conn, "response.created") + readEvent(conn, "response.output_item.added") + } + // Both HTTP streams are now in flight. Neither may replace the other. + release() + for i, id := range []string{"alpha", "beta"} { + event := readEvent(clients[i], "response.completed") + require.Equal(t, "resp_"+id+"_1", gjson.GetBytes(event, "response.id").String()) + } + for i, id := range []string{"alpha", "beta"} { + payload := fmt.Sprintf(`{"type":"response.create","model":"gpt-5","previous_response_id":%q,"input":[{"type":"function_call_output","call_id":%q,"output":%q}]}`, "resp_"+id+"_1", "call_"+id, id+"-result") + require.NoError(t, clients[i].Write(ctx, coderws.MessageText, []byte(payload))) + readEvent(clients[i], "response.created") + event := readEvent(clients[i], "response.completed") + require.Equal(t, "resp_"+id+"_2", gjson.GetBytes(event, "response.id").String()) + require.NoError(t, clients[i].Close(coderws.StatusNormalClosure, "done")) + } + for range clients { + select { + case err := <-serverResults: + require.NoError(t, err) + case <-ctx.Done(): + t.Fatal(ctx.Err()) + } + } + + upstream.mu.Lock() + requests := append([]httpBridgeIsolationRequest(nil), upstream.requests...) + upstream.mu.Unlock() + require.ElementsMatch(t, []httpBridgeIsolationRequest{ + {input: []string{"alpha"}}, + {input: []string{"beta"}}, + {input: []string{"alpha", "call_alpha", "alpha-result"}, state: "turn-alpha"}, + {input: []string{"beta", "call_beta", "beta-result"}, state: "turn-beta"}, + }, requests) + gotState, ok := stateStore.GetSessionTurnState(groupID, seedHash) + require.True(t, ok) + require.Equal(t, "sentinel-turn-state", gotState) + gotConn, ok := stateStore.GetSessionConn(groupID, seedHash) + require.True(t, ok) + require.Equal(t, "sentinel-native-conn", gotConn) +} diff --git a/backend/internal/service/openai_ws_http_bridge_test.go b/backend/internal/service/openai_ws_http_bridge_test.go index 0cdf64b974..5e23a51985 100644 --- a/backend/internal/service/openai_ws_http_bridge_test.go +++ b/backend/internal/service/openai_ws_http_bridge_test.go @@ -1052,6 +1052,79 @@ func TestProxyOpenAIWSHTTPBridgeTurnBareErrorUsesAuthoritativeFailed(t *testing. require.Equal(t, "response.failed", gjson.GetBytes(writes[1], "type").String()) } +func TestProxyOpenAIWSHTTPBridgeTurnMarksCyberPolicyForFailureShapes(t *testing.T) { + gin.SetMode(gin.TestMode) + + tests := []struct { + name string + statusCode int + body string + wantStatus int + wantInput int + wantOutput int + wantResult bool + wantError bool + }{ + { + name: "http_error_body", + statusCode: http.StatusBadRequest, + body: `{"error":{"code":"cyber_policy","message":"blocked by HTTP cyber policy"}}`, + wantStatus: http.StatusBadRequest, + wantError: true, + }, + { + name: "sse_error_event", + statusCode: http.StatusOK, + body: "data: {\"type\":\"error\",\"error\":{\"code\":\"cyber_policy\",\"message\":\"blocked by error event\"},\"usage\":{\"input_tokens\":5,\"output_tokens\":1}}\n\n", + wantStatus: http.StatusOK, + wantInput: 5, + wantOutput: 1, + wantResult: true, + wantError: true, + }, + { + name: "sse_response_failed_event", + statusCode: http.StatusOK, + body: "data: {\"type\":\"response.failed\",\"response\":{\"id\":\"resp_cyber\",\"error\":{\"code\":\"cyber_policy\",\"message\":\"blocked by failed event\"},\"usage\":{\"input_tokens\":9,\"output_tokens\":2}}}\n\n", + wantStatus: http.StatusOK, + wantInput: 9, + wantOutput: 2, + wantResult: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: tt.statusCode, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(tt.body)), + }} + svc := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream} + account := &Account{ID: 112, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Concurrency: 1} + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodGet, "/v1/responses", nil) + payload := []byte(`{"type":"response.create","model":"gpt-5","input":"hi"}`) + + result, err := svc.proxyOpenAIWSHTTPBridgeTurn( + context.Background(), c, account, "sk-test", payload, len(payload), + "gpt-5", "", "", "", "", 1, func([]byte) error { return nil }, + ) + + require.Equal(t, tt.wantResult, result != nil) + require.Equal(t, tt.wantError, err != nil) + mark := GetOpsCyberPolicy(c) + require.NotNil(t, mark) + require.Equal(t, "cyber_policy", mark.Code) + require.Equal(t, tt.wantStatus, mark.UpstreamStatus) + require.Equal(t, tt.wantInput, mark.UpstreamInTok) + require.Equal(t, tt.wantOutput, mark.UpstreamOutTok) + require.Contains(t, mark.Body, `"code":"cyber_policy"`) + }) + } +} + func TestProxyOpenAIWSHTTPBridgeTurnBareErrorEOFSynthesizesFailed(t *testing.T) { gin.SetMode(gin.TestMode) body := "data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_eof\",\"status\":\"in_progress\"}}\n\n" + diff --git a/backend/internal/service/openai_ws_ingress_capacity_shed_test.go b/backend/internal/service/openai_ws_ingress_capacity_shed_test.go index d37b7f2cda..c9d041afa6 100644 --- a/backend/internal/service/openai_ws_ingress_capacity_shed_test.go +++ b/backend/internal/service/openai_ws_ingress_capacity_shed_test.go @@ -2,6 +2,7 @@ package service import ( "context" + "errors" "net/http" "net/http/httptest" "strings" @@ -11,6 +12,7 @@ import ( coderws "github.com/coder/websocket" "github.com/gin-gonic/gin" "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" ) // openAIWSIngressCapacityShedRepo 补齐 SetError,避免非容量类错误(如 @@ -182,3 +184,128 @@ func TestProxyResponsesWebSocketFromClient_RewritesCapacityShedCodeForClient(t * }) } } + +func TestProxyResponsesWebSocketFromClient_MarksCyberPolicyBeforeEarlyReturn(t *testing.T) { + gin.SetMode(gin.TestMode) + + tests := []struct { + name string + upstreamEvent []byte + wantFailover bool + wantClientMsg bool + wantInput int + wantOutput int + }{ + { + name: "error_before_rate_limit_failover", + upstreamEvent: []byte(`{"type":"error","error":{"type":"rate_limit_error","code":"cyber_policy","message":"rate limit exceeded by cyber policy"},"usage":{"input_tokens":5,"output_tokens":1}}`), + wantFailover: true, + wantInput: 5, + wantOutput: 1, + }, + { + name: "response_failed_terminal", + upstreamEvent: []byte(`{"type":"response.failed","response":{"id":"resp_cyber","status":"failed","error":{"code":"cyber_policy","message":"blocked by cyber policy"},"usage":{"input_tokens":9,"output_tokens":2}}}`), + wantClientMsg: true, + wantInput: 9, + wantOutput: 2, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := newOpenAIWSV2TestConfig() + cfg.Security.URLAllowlist.Enabled = false + cfg.Security.URLAllowlist.AllowInsecureHTTP = true + cfg.Gateway.OpenAIWS.ModeRouterV2Enabled = true + cfg.Gateway.OpenAIWS.IngressModeDefault = OpenAIWSIngressModeCtxPool + cfg.Gateway.OpenAIWS.MinIdlePerAccount = 0 + + captureConn := &openAIWSCaptureConn{events: [][]byte{append([]byte(nil), tt.upstreamEvent...)}} + pool := newOpenAIWSConnPool(cfg) + pool.setClientDialerForTest(&openAIWSCaptureDialer{conn: captureConn}) + svc := &OpenAIGatewayService{ + cfg: cfg, + httpUpstream: &httpUpstreamRecorder{}, + cache: &stubGatewayCache{}, + openaiWSResolver: NewOpenAIWSProtocolResolver(cfg), + toolCorrector: NewCodexToolCorrector(), + openaiWSPool: pool, + } + account := &Account{ + ID: 5402, Name: "openai-ingress-cyber", Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, Status: StatusActive, Schedulable: true, Concurrency: 1, + Credentials: map[string]any{"api_key": "sk-test"}, + Extra: map[string]any{ + "openai_apikey_responses_websockets_v2_mode": OpenAIWSIngressModeCtxPool, + }, + } + + markCh := make(chan *CyberPolicyMark, 1) + serverErrCh := make(chan error, 1) + wsServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + conn, err := coderws.Accept(w, r, &coderws.AcceptOptions{CompressionMode: coderws.CompressionContextTakeover}) + if err != nil { + serverErrCh <- err + return + } + defer func() { _ = conn.CloseNow() }() + + readCtx, cancelRead := context.WithTimeout(r.Context(), 3*time.Second) + _, firstMessage, err := conn.Read(readCtx) + cancelRead() + if err != nil { + serverErrCh <- err + return + } + + recorder := httptest.NewRecorder() + ginCtx, _ := gin.CreateTestContext(recorder) + ginCtx.Request = r.Clone(r.Context()) + hooks := &OpenAIWSIngressHooks{AfterTurn: func(_ int, _ *OpenAIForwardResult, _ error) { + markCh <- GetOpsCyberPolicy(ginCtx) + }} + serverErrCh <- svc.ProxyResponsesWebSocketFromClient(r.Context(), ginCtx, conn, account, "sk-test", firstMessage, hooks) + })) + defer wsServer.Close() + + dialCtx, cancelDial := context.WithTimeout(context.Background(), 3*time.Second) + clientConn, _, err := coderws.Dial(dialCtx, "ws"+strings.TrimPrefix(wsServer.URL, "http"), nil) + cancelDial() + require.NoError(t, err) + defer func() { _ = clientConn.CloseNow() }() + + writeCtx, cancelWrite := context.WithTimeout(context.Background(), 3*time.Second) + err = clientConn.Write(writeCtx, coderws.MessageText, []byte(`{"type":"response.create","model":"gpt-5.1","stream":false}`)) + cancelWrite() + require.NoError(t, err) + + if tt.wantClientMsg { + readCtx, cancelRead := context.WithTimeout(context.Background(), 3*time.Second) + _, message, readErr := clientConn.Read(readCtx) + cancelRead() + require.NoError(t, readErr) + require.Equal(t, "response.failed", gjson.GetBytes(message, "type").String()) + } + + select { + case mark := <-markCh: + require.NotNil(t, mark) + require.Equal(t, "cyber_policy", mark.Code) + require.Equal(t, tt.wantInput, mark.UpstreamInTok) + require.Equal(t, tt.wantOutput, mark.UpstreamOutTok) + case <-time.After(3 * time.Second): + t.Fatal("AfterTurn did not observe the cyber mark") + } + + _ = clientConn.CloseNow() + select { + case serverErr := <-serverErrCh: + var failoverErr *UpstreamFailoverError + require.Equal(t, tt.wantFailover, errors.As(serverErr, &failoverErr)) + case <-time.After(5 * time.Second): + t.Fatal("waiting for ingress websocket shutdown timed out") + } + }) + } +} diff --git a/backend/internal/service/openai_ws_passthrough_turn_pricing_test.go b/backend/internal/service/openai_ws_passthrough_turn_pricing_test.go index 16be2bd4c6..a405fbb29a 100644 --- a/backend/internal/service/openai_ws_passthrough_turn_pricing_test.go +++ b/backend/internal/service/openai_ws_passthrough_turn_pricing_test.go @@ -60,50 +60,38 @@ func startPassthroughHookRecordingServer( return server, serverErr } -// TestPassthroughIngressReportsTurnStartedBeforeAfterTurnWithoutBeforeTurn 钉死 -// ws_v2 透传 ingress 与 handler 侧 turn 定价的耦合:透传 relay 不触发 -// BeforeTurn,但会在每个 AfterTurn 前通过 TurnStarted 报告同一 turn 的开始时刻。 -// -// handler 的 recordTurnStart 保存该时刻,AfterTurn 再用 currentOr(turnStart) -// 作为计费 PricingAt;不触发 BeforeTurn 也意味着透传仍没有 turn 级利润复核。 -func TestPassthroughIngressReportsTurnStartedBeforeAfterTurnWithoutBeforeTurn(t *testing.T) { +func TestPassthroughIngressFollowUpCallsBeforeTurnAfterBeforeRequest(t *testing.T) { gin.SetMode(gin.TestMode) controlCtx, cancelControl := context.WithCancelCause(context.Background()) defer cancelControl(context.Canceled) upstream := newStagedPassthroughConn() - upstream.Send(`{"type":"response.completed","response":{"id":"resp_pricing","model":"gpt-5.1","usage":{"input_tokens":1,"output_tokens":1}}}`) + upstream.Send(`{"type":"response.completed","response":{"id":"resp_pricing_1","model":"gpt-5.1","usage":{"input_tokens":1,"output_tokens":1}}}`) var hooksMu sync.Mutex - beforeTurnCalls := 0 - expectedTurnStartedAt := time.Date(2026, time.August, 17, 9, 59, 59, 0, time.UTC) - type hookEvent struct { - name string - turn int - startedAt time.Time - } - var hookEvents []hookEvent + var callbacks []string + afterTurnCalls := 0 hooks := &OpenAIWSIngressHooks{ - InitialTurnStartedAt: expectedTurnStartedAt, - TurnStarted: func(turn int, startedAt time.Time) { + BeforeRequest: func(int, []byte, string) error { hooksMu.Lock() - hookEvents = append(hookEvents, hookEvent{name: "TurnStarted", turn: turn, startedAt: startedAt}) - hooksMu.Unlock() - }, - BeforeTurn: func(int) error { - hooksMu.Lock() - beforeTurnCalls++ + callbacks = append(callbacks, "before_request") hooksMu.Unlock() return nil }, - AfterTurn: func(turn int, _ *OpenAIForwardResult, _ error) { + BeforeTurn: func(int) error { hooksMu.Lock() - hookEvents = append(hookEvents, hookEvent{name: "AfterTurn", turn: turn}) + callbacks = append(callbacks, "before_turn") + hooksMu.Unlock() + return nil + }, + AfterTurn: func(int, *OpenAIForwardResult, error) { + hooksMu.Lock() + afterTurnCalls++ hooksMu.Unlock() }, } - server, serverErr := startPassthroughHookRecordingServer( + server, _ := startPassthroughHookRecordingServer( t, controlCtx, newPassthroughLifecycleService(passthroughLifecycleConfig(), upstream), @@ -113,30 +101,90 @@ func TestPassthroughIngressReportsTurnStartedBeforeAfterTurnWithoutBeforeTurn(t defer server.Close() clientConn := dialPassthroughLifecycleClient(t, server) defer func() { _ = clientConn.CloseNow() }() + require.Equal(t, "response.create", gjson.GetBytes(requirePassthroughUpstreamWrite(t, upstream, time.Second), "type").String()) event, err := readPassthroughLifecycleFrame(t, clientConn, 3*time.Second) require.NoError(t, err) require.Equal(t, "response.completed", gjson.GetBytes(event, "type").String()) - // 等待连接自然结束(inter-turn idle 超时),确保 AfterTurn 已提交。 - _, _ = readPassthroughLifecycleFrame(t, clientConn, 3*time.Second) + writeCtx, cancelWrite := context.WithTimeout(context.Background(), time.Second) + err = clientConn.Write(writeCtx, coderws.MessageText, []byte(`{"type":"response.create","model":"gpt-5.1"}`)) + cancelWrite() + require.NoError(t, err) + require.Equal(t, "response.create", gjson.GetBytes(requirePassthroughUpstreamWrite(t, upstream, time.Second), "type").String()) + upstream.Send(`{"type":"response.completed","response":{"id":"resp_pricing_2","model":"gpt-5.1","usage":{"input_tokens":1,"output_tokens":1}}}`) + event, err = readPassthroughLifecycleFrame(t, clientConn, 3*time.Second) + require.NoError(t, err) + require.Equal(t, "response.completed", gjson.GetBytes(event, "type").String()) + + hooksMu.Lock() + gotCallbacks := append([]string(nil), callbacks...) + gotAfter := afterTurnCalls + hooksMu.Unlock() + + require.Equal(t, []string{"before_request", "before_turn"}, gotCallbacks) + require.Equal(t, 2, gotAfter) +} + +func TestPassthroughIngressBeforeTurnRejectionDoesNotForwardFollowUp(t *testing.T) { + gin.SetMode(gin.TestMode) + controlCtx, cancelControl := context.WithCancelCause(context.Background()) + defer cancelControl(context.Canceled) + + upstream := newStagedPassthroughConn() + upstream.Send(`{"type":"response.completed","response":{"id":"resp_reject_1","model":"gpt-5.1","usage":{"input_tokens":1,"output_tokens":1}}}`) + rejection := errors.New("turn rejected") + var hooksMu sync.Mutex + afterTurnCalls := 0 + var finalErr error + beforeTurnTurn := 0 + hooks := &OpenAIWSIngressHooks{ + BeforeTurn: func(turn int) error { + hooksMu.Lock() + beforeTurnTurn = turn + hooksMu.Unlock() + return rejection + }, + AfterTurn: func(_ int, _ *OpenAIForwardResult, turnErr error) { + hooksMu.Lock() + afterTurnCalls++ + if turnErr != nil { + finalErr = turnErr + } + hooksMu.Unlock() + }, + } + + server, serverErr := startPassthroughHookRecordingServer(t, controlCtx, newPassthroughLifecycleService(passthroughLifecycleConfig(), upstream), passthroughLifecycleAccount(), hooks) + defer server.Close() + clientConn := dialPassthroughLifecycleClient(t, server) + defer func() { _ = clientConn.CloseNow() }() + requirePassthroughUpstreamWrite(t, upstream, time.Second) + _, err := readPassthroughLifecycleFrame(t, clientConn, 3*time.Second) + require.NoError(t, err) + + writeCtx, cancelWrite := context.WithTimeout(context.Background(), time.Second) + err = clientConn.Write(writeCtx, coderws.MessageText, []byte(`{"type":"response.create","model":"gpt-5.1"}`)) + cancelWrite() + require.NoError(t, err) select { - case <-serverErr: + case payload := <-upstream.writes: + t.Fatalf("rejected response.create was forwarded upstream: %s", payload) + case <-time.After(100 * time.Millisecond): + } + select { + case err = <-serverErr: + require.ErrorIs(t, err, rejection) case <-time.After(3 * time.Second): - t.Fatal("passthrough ingress did not exit") + t.Fatal("passthrough ingress did not exit after BeforeTurn rejection") } hooksMu.Lock() - gotBefore := beforeTurnCalls - gotEvents := append([]hookEvent(nil), hookEvents...) + gotBeforeTurn, gotAfter, gotFinalErr := beforeTurnTurn, afterTurnCalls, finalErr hooksMu.Unlock() - - require.Zero(t, gotBefore, "透传 ingress 不应调用 BeforeTurn") - require.GreaterOrEqual(t, len(gotEvents), 2, "透传 ingress 应报告 TurnStarted 和 AfterTurn") - require.Equal(t, "TurnStarted", gotEvents[0].name) - require.Equal(t, expectedTurnStartedAt, gotEvents[0].startedAt, "TurnStarted 必须携带入口冻结的首轮开始时刻") - require.Equal(t, "AfterTurn", gotEvents[1].name) - require.Equal(t, gotEvents[0].turn, gotEvents[1].turn, "TurnStarted 后应提交同一 turn 的 AfterTurn") + require.Equal(t, 2, gotBeforeTurn) + require.Equal(t, 2, gotAfter, "each started turn must be finalized exactly once") + require.ErrorIs(t, gotFinalErr, rejection) } func TestPassthroughIngressFreezesSubsequentTurnBeforeRequestPolicy(t *testing.T) { diff --git a/backend/internal/service/openai_ws_session_preemption.go b/backend/internal/service/openai_ws_session_preemption.go index 3047de6998..95fa49e7f7 100644 --- a/backend/internal/service/openai_ws_session_preemption.go +++ b/backend/internal/service/openai_ws_session_preemption.go @@ -56,9 +56,14 @@ func (s *OpenAIGatewayService) BeginOpenAIWSIngressSessionPreemption( if armed, _ := ctx.Value(openAIWSSessionPreemptContextKey{}).(bool); armed { return ctx, func() {}, true } - if s != nil && s.cfg != nil && s.cfg.Gateway.OpenAIWS.ModeRouterV2Enabled && - account != nil && account.ResolveOpenAIResponsesWebSocketV2Mode(s.cfg.Gateway.OpenAIWS.IngressModeDefault) == OpenAIWSIngressModePassthrough { - return ctx, func() {}, false + if s != nil && s.cfg != nil && s.cfg.Gateway.OpenAIWS.ModeRouterV2Enabled && account != nil { + switch account.ResolveOpenAIResponsesWebSocketV2Mode(s.cfg.Gateway.OpenAIWS.IngressModeDefault) { + case OpenAIWSIngressModePassthrough, OpenAIWSIngressModeHTTPBridge: + // These modes own their upstream transport and replay state per + // inbound connection. Sharing a sticky/cache identity does not mean + // a new connection should cancel an independent in-flight request. + return ctx, func() {}, false + } } preemptSessionHash := "" diff --git a/backend/internal/service/openai_ws_session_preemption_test.go b/backend/internal/service/openai_ws_session_preemption_test.go index 0e0c6f5622..d00adf63b4 100644 --- a/backend/internal/service/openai_ws_session_preemption_test.go +++ b/backend/internal/service/openai_ws_session_preemption_test.go @@ -224,6 +224,59 @@ func TestOpenAIWSSessionPreemptRemoteClaimAndStaleReleaseAreAtomic(t *testing.T) require.Equal(t, "owner-b", current, "stale cleanup must preserve the replacement owner") } +func TestOpenAIWSHTTPBridgeSessionPreemptionEligibility(t *testing.T) { + gin.SetMode(gin.TestMode) + tests := []struct { + name string + routerEnabled bool + defaultMode string + accountMode string + wantArmed bool + }{ + {name: "explicit HTTP bridge", routerEnabled: true, defaultMode: OpenAIWSIngressModeCtxPool, accountMode: OpenAIWSIngressModeHTTPBridge}, + {name: "default HTTP bridge", routerEnabled: true, defaultMode: OpenAIWSIngressModeHTTPBridge}, + {name: "ctx pool overrides bridge default", routerEnabled: true, defaultMode: OpenAIWSIngressModeHTTPBridge, accountMode: OpenAIWSIngressModeCtxPool, wantArmed: true}, + {name: "disabled router retains legacy preemption", defaultMode: OpenAIWSIngressModeHTTPBridge, accountMode: OpenAIWSIngressModeHTTPBridge, wantArmed: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := &config.Config{} + cfg.Gateway.OpenAIWS.ModeRouterV2Enabled = tt.routerEnabled + cfg.Gateway.OpenAIWS.IngressModeDefault = tt.defaultMode + cache := &openAIWSSessionPreemptCacheStub{} + svc := &OpenAIGatewayService{cfg: cfg, cache: cache} + groupID := int64(7) + newContext := func() *gin.Context { + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Request = httptest.NewRequest(http.MethodGet, "/v1/responses", nil) + c.Request.Header.Set("session-id", "shared-session") + c.Set("api_key", &APIKey{ID: 11, GroupID: &groupID}) + return c + } + account := &Account{ID: 1, Platform: PlatformOpenAI, Type: AccountTypeOAuth, + Extra: map[string]any{"openai_oauth_responses_websockets_v2_mode": tt.accountMode}} + firstMessage := []byte(`{"type":"response.create","prompt_cache_key":"cache-a","input":"first request"}`) + secondMessage := []byte(`{"type":"response.create","prompt_cache_key":"cache-b","input":"second request"}`) + first, cleanupFirst, firstArmed := svc.BeginOpenAIWSIngressSessionPreemption(context.Background(), newContext(), account, firstMessage) + defer cleanupFirst() + second, cleanupSecond, secondArmed := svc.BeginOpenAIWSIngressSessionPreemption(context.Background(), newContext(), account, secondMessage) + defer cleanupSecond() + require.Equal(t, tt.wantArmed, firstArmed) + require.Equal(t, tt.wantArmed, secondArmed) + require.NoError(t, second.Err()) + if tt.wantArmed { + require.True(t, IsOpenAIWSSessionPreemptedError(context.Cause(first))) + } else { + require.NoError(t, first.Err(), "independent HTTP bridges must not cancel each other") + cache.mu.Lock() + ownerCount := len(cache.owners) + cache.mu.Unlock() + require.Zero(t, ownerCount, "HTTP bridges must not claim a distributed preemption owner") + } + }) + } +} + func TestNewOpenAIWSSessionPreemptKeyRequiresFullIsolationScope(t *testing.T) { _, ok := newOpenAIWSSessionPreemptKey(0, 11, "sess") require.False(t, ok) diff --git a/backend/internal/service/openai_ws_v2_passthrough_adapter.go b/backend/internal/service/openai_ws_v2_passthrough_adapter.go index 8d29839b3b..0d7f1095e4 100644 --- a/backend/internal/service/openai_ws_v2_passthrough_adapter.go +++ b/backend/internal/service/openai_ws_v2_passthrough_adapter.go @@ -1054,6 +1054,11 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough( return payload, nil, err } } + if hooks != nil && hooks.BeforeTurn != nil { + if err := hooks.BeforeTurn(turnNo); err != nil { + return payload, nil, err + } + } if hooks != nil && hooks.MapRequestModel != nil { upstreamModel, err := hooks.MapRequestModel(turnNo, requestModelForThisFrame) if err != nil { @@ -1477,21 +1482,9 @@ func openAIWSPassthroughRelayClientClose(exit openaiwsv2.RelayExit, completedTur } func markOpenAIWSV2PassthroughCyberPolicy(c *gin.Context, payload []byte) bool { - hit, code, message := detectOpenAICyberPolicy(payload) - if !hit { - return false - } usage := OpenAIUsage{} parseOpenAIWSResponseUsageFromCompletedEvent(payload, &usage) - MarkOpsCyberPolicy(c, CyberPolicyMark{ - Code: code, - Message: message, - Body: truncateString(string(payload), 4096), - UpstreamStatus: http.StatusOK, - UpstreamInTok: usage.InputTokens, - UpstreamOutTok: usage.OutputTokens, - }) - return true + return markOpenAICyberPolicyEvent(c, payload, http.StatusOK, &usage) } func (s *OpenAIGatewayService) mapOpenAIWSPassthroughDialError( diff --git a/backend/internal/service/payment_fulfillment.go b/backend/internal/service/payment_fulfillment.go index 4d442f3d1e..2b70a90ef6 100644 --- a/backend/internal/service/payment_fulfillment.go +++ b/backend/internal/service/payment_fulfillment.go @@ -317,20 +317,62 @@ const ( // resolveRedeemAction decides the idempotency action based on an existing redeem code lookup. // existing is the result of GetByCode; lookupErr is the error from that call. -func resolveRedeemAction(existing *RedeemCode, lookupErr error) redeemAction { - if existing == nil || lookupErr != nil { - return redeemActionCreate +func resolveRedeemAction(existing *RedeemCode, lookupErr error) (redeemAction, error) { + if lookupErr != nil { + if errors.Is(lookupErr, ErrRedeemCodeNotFound) { + return redeemActionCreate, nil + } + return redeemActionCreate, fmt.Errorf("lookup payment redeem code: %w", lookupErr) + } + if existing == nil { + return redeemActionCreate, nil } if existing.IsUsed() { - return redeemActionSkipCompleted + return redeemActionSkipCompleted, nil } - return redeemActionRedeem + return redeemActionRedeem, nil +} + +func validatePaymentRedeemCode(o *dbent.PaymentOrder, code *RedeemCode) error { + if o == nil || code == nil { + return errors.New("payment redeem code validation requires an order and code") + } + if code.Code != o.RechargeCode { + return fmt.Errorf("payment redeem code mismatch for order %d", o.ID) + } + if code.Type != RedeemTypeBalance { + return fmt.Errorf("payment redeem code type mismatch for order %d: got %s", o.ID, code.Type) + } + if math.IsNaN(code.Value) || math.IsInf(code.Value, 0) || math.Abs(code.Value-o.Amount) > 1e-8 { + return fmt.Errorf("payment redeem code amount mismatch for order %d: expected %.8f, got %.8f", o.ID, o.Amount, code.Value) + } + switch code.Status { + case StatusUnused: + if code.UsedBy != nil { + return fmt.Errorf("unused payment redeem code has a user for order %d", o.ID) + } + case StatusUsed: + if code.UsedBy == nil || *code.UsedBy != o.UserID { + return fmt.Errorf("payment redeem code user mismatch for order %d", o.ID) + } + default: + return fmt.Errorf("payment redeem code has invalid status for order %d: %s", o.ID, code.Status) + } + return nil } func (s *PaymentService) doBalance(ctx context.Context, o *dbent.PaymentOrder, lease *paymentFulfillmentLease) error { // Idempotency: check if redeem code already exists (from a previous partial run) existing, lookupErr := s.redeemService.GetByCode(ctx, o.RechargeCode) - action := resolveRedeemAction(existing, lookupErr) + action, err := resolveRedeemAction(existing, lookupErr) + if err != nil { + return err + } + if existing != nil { + if err := validatePaymentRedeemCode(o, existing); err != nil { + return err + } + } switch action { case redeemActionSkipCompleted: @@ -347,7 +389,7 @@ func (s *PaymentService) doBalance(ctx context.Context, o *dbent.PaymentOrder, l case redeemActionRedeem: // Code exists but unused — skip creation, proceed to redeem } - if _, err := s.redeemService.Redeem(ContextSkipRedeemAffiliate(ctx), o.UserID, o.RechargeCode); err != nil { + if _, err := s.redeemService.redeemForPaymentFulfillment(ctx, o.UserID, o.RechargeCode); err != nil { return fmt.Errorf("redeem balance: %w", err) } if err := s.applyAffiliateRebateForOrder(ctx, o); err != nil { diff --git a/backend/internal/service/payment_fulfillment_test.go b/backend/internal/service/payment_fulfillment_test.go index 50ccd485ed..3f1fdb1cf6 100644 --- a/backend/internal/service/payment_fulfillment_test.go +++ b/backend/internal/service/payment_fulfillment_test.go @@ -23,6 +23,52 @@ type paymentFulfillmentTestProvider struct { supportedTypes []payment.PaymentType } +type paymentFulfillmentRedeemCacheStub struct { + count int + getCalls int + incrementCalls int + acquireCalls int + releaseCalls int +} + +type paymentFulfillmentRedeemRepo struct { + paymentOrderLifecycleRedeemRepo + createCalls int +} + +func (r *paymentFulfillmentRedeemRepo) Create(_ context.Context, code *RedeemCode) error { + r.createCalls++ + if r.codesByCode == nil { + r.codesByCode = make(map[string]*RedeemCode) + } + cloned := *code + cloned.ID = int64(100 + r.createCalls) + code.ID = cloned.ID + r.codesByCode[cloned.Code] = &cloned + return nil +} + +func (c *paymentFulfillmentRedeemCacheStub) GetRedeemAttemptCount(context.Context, int64) (int, error) { + c.getCalls++ + return c.count, nil +} + +func (c *paymentFulfillmentRedeemCacheStub) IncrementRedeemAttemptCount(context.Context, int64) error { + c.incrementCalls++ + c.count++ + return nil +} + +func (c *paymentFulfillmentRedeemCacheStub) AcquireRedeemLock(context.Context, string, time.Duration) (bool, error) { + c.acquireCalls++ + return true, nil +} + +func (c *paymentFulfillmentRedeemCacheStub) ReleaseRedeemLock(context.Context, string) error { + c.releaseCalls++ + return nil +} + func (p paymentFulfillmentTestProvider) Name() string { return p.key } func (p paymentFulfillmentTestProvider) ProviderKey() string { return p.key } func (p paymentFulfillmentTestProvider) SupportedTypes() []payment.PaymentType { @@ -210,14 +256,15 @@ func ensurePaymentAuditOrderActionUniqueIndex(t *testing.T, ctx context.Context, func TestResolveRedeemAction_CodeNotFound(t *testing.T) { t.Parallel() - action := resolveRedeemAction(nil, nil) - assert.Equal(t, redeemActionCreate, action, "nil code with nil error should create") + action, err := resolveRedeemAction(nil, ErrRedeemCodeNotFound) + require.NoError(t, err) + assert.Equal(t, redeemActionCreate, action, "a missing code should be created") } func TestResolveRedeemAction_LookupError(t *testing.T) { t.Parallel() - action := resolveRedeemAction(nil, errors.New("db connection lost")) - assert.Equal(t, redeemActionCreate, action, "lookup error should fall back to create") + _, err := resolveRedeemAction(nil, errors.New("db connection lost")) + require.ErrorContains(t, err, "lookup payment redeem code") } func TestResolveRedeemAction_LookupErrorWithNonNilCode(t *testing.T) { @@ -225,8 +272,8 @@ func TestResolveRedeemAction_LookupErrorWithNonNilCode(t *testing.T) { // Edge case: both code and error are non-nil (shouldn't happen in practice, // but the function should still treat error as authoritative) code := &RedeemCode{Status: StatusUnused} - action := resolveRedeemAction(code, errors.New("partial error")) - assert.Equal(t, redeemActionCreate, action, "non-nil error should always result in create regardless of code") + _, err := resolveRedeemAction(code, errors.New("partial error")) + require.ErrorContains(t, err, "lookup payment redeem code") } func TestResolveRedeemAction_CodeExistsAndUsed(t *testing.T) { @@ -237,7 +284,8 @@ func TestResolveRedeemAction_CodeExistsAndUsed(t *testing.T) { Type: RedeemTypeBalance, Value: 10.0, } - action := resolveRedeemAction(code, nil) + action, err := resolveRedeemAction(code, nil) + require.NoError(t, err) assert.Equal(t, redeemActionSkipCompleted, action, "used code should skip to completed") } @@ -249,7 +297,8 @@ func TestResolveRedeemAction_CodeExistsAndUnused(t *testing.T) { Type: RedeemTypeBalance, Value: 25.0, } - action := resolveRedeemAction(code, nil) + action, err := resolveRedeemAction(code, nil) + require.NoError(t, err) assert.Equal(t, redeemActionRedeem, action, "unused code should skip creation and proceed to redeem") } @@ -261,7 +310,8 @@ func TestResolveRedeemAction_CodeExistsWithExpiredStatus(t *testing.T) { Code: "expired-code", Status: StatusExpired, } - action := resolveRedeemAction(code, nil) + action, err := resolveRedeemAction(code, nil) + require.NoError(t, err) assert.Equal(t, redeemActionRedeem, action, "expired-status code is not IsUsed(), should redeem") } @@ -277,6 +327,7 @@ func TestResolveRedeemAction_Table(t *testing.T) { code *RedeemCode err error expected redeemAction + wantErr bool }{ { name: "nil code, nil error — first run", @@ -291,10 +342,11 @@ func TestResolveRedeemAction_Table(t *testing.T) { expected: redeemActionCreate, }, { - name: "nil code, generic DB error — treat as not found", + name: "nil code, generic DB error — fail closed", code: nil, err: errors.New("connection refused"), expected: redeemActionCreate, + wantErr: true, }, { name: "code exists, used — previous run completed redeem", @@ -309,17 +361,23 @@ func TestResolveRedeemAction_Table(t *testing.T) { expected: redeemActionRedeem, }, { - name: "code exists but error also set — error takes precedence", + name: "code exists but error also set — fail closed", code: &RedeemCode{Status: StatusUsed}, err: errors.New("unexpected"), expected: redeemActionCreate, + wantErr: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() - got := resolveRedeemAction(tt.code, tt.err) + got, err := resolveRedeemAction(tt.code, tt.err) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) assert.Equal(t, tt.expected, got) }) } @@ -350,11 +408,48 @@ func TestResolveRedeemAction_IsUsedCanUseConsistency(t *testing.T) { // Verify our decision function is consistent with the domain model methods assert.True(t, usedCode.IsUsed()) assert.False(t, usedCode.CanUse()) - assert.Equal(t, redeemActionSkipCompleted, resolveRedeemAction(usedCode, nil)) + usedAction, err := resolveRedeemAction(usedCode, nil) + require.NoError(t, err) + assert.Equal(t, redeemActionSkipCompleted, usedAction) assert.False(t, unusedCode.IsUsed()) assert.True(t, unusedCode.CanUse()) - assert.Equal(t, redeemActionRedeem, resolveRedeemAction(unusedCode, nil)) + unusedAction, err := resolveRedeemAction(unusedCode, nil) + require.NoError(t, err) + assert.Equal(t, redeemActionRedeem, unusedAction) +} + +func TestValidatePaymentRedeemCode(t *testing.T) { + t.Parallel() + userID := int64(42) + otherUserID := int64(43) + order := &dbent.PaymentOrder{ID: 7, UserID: userID, RechargeCode: "PAY-7-12345", Amount: 80} + + tests := []struct { + name string + code *RedeemCode + wantErr string + }{ + {name: "unused code", code: &RedeemCode{Code: order.RechargeCode, Type: RedeemTypeBalance, Value: 80, Status: StatusUnused}}, + {name: "used by order user", code: &RedeemCode{Code: order.RechargeCode, Type: RedeemTypeBalance, Value: 80, Status: StatusUsed, UsedBy: &userID}}, + {name: "used without user", code: &RedeemCode{Code: order.RechargeCode, Type: RedeemTypeBalance, Value: 80, Status: StatusUsed}, wantErr: "user mismatch"}, + {name: "used by another user", code: &RedeemCode{Code: order.RechargeCode, Type: RedeemTypeBalance, Value: 80, Status: StatusUsed, UsedBy: &otherUserID}, wantErr: "user mismatch"}, + {name: "wrong code", code: &RedeemCode{Code: "OTHER", Type: RedeemTypeBalance, Value: 80, Status: StatusUnused}, wantErr: "code mismatch"}, + {name: "wrong type", code: &RedeemCode{Code: order.RechargeCode, Type: RedeemTypeConcurrency, Value: 80, Status: StatusUnused}, wantErr: "type mismatch"}, + {name: "wrong amount", code: &RedeemCode{Code: order.RechargeCode, Type: RedeemTypeBalance, Value: 79, Status: StatusUnused}, wantErr: "amount mismatch"}, + {name: "invalid status", code: &RedeemCode{Code: order.RechargeCode, Type: RedeemTypeBalance, Value: 80, Status: StatusDisabled}, wantErr: "invalid status"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validatePaymentRedeemCode(order, tt.code) + if tt.wantErr == "" { + require.NoError(t, err) + return + } + require.ErrorContains(t, err, tt.wantErr) + }) + } } func TestExpectedNotificationProviderKeyPrefersOrderInstanceProvider(t *testing.T) { @@ -668,6 +763,100 @@ func TestFulfillmentLeaseVersionRejectsStaleWorker(t *testing.T) { require.NoError(t, svc.markCompleted(ctx, order, secondLease, "SUBSCRIPTION_SUCCESS")) } +func TestPublicRedeemStillEnforcesFailureLimit(t *testing.T) { + cache := &paymentFulfillmentRedeemCacheStub{count: redeemMaxErrorsPerHour} + svc := &RedeemService{cache: cache} + + result, err := svc.Redeem(context.Background(), 42, "PUBLIC-CODE") + + require.Nil(t, result) + require.ErrorIs(t, err, ErrRedeemRateLimited) + require.Equal(t, 1, cache.getCalls) + require.Zero(t, cache.incrementCalls) + require.Zero(t, cache.acquireCalls) +} + +func TestPublicRedeemStillIncrementsInvalidCodeFailures(t *testing.T) { + cache := &paymentFulfillmentRedeemCacheStub{} + redeemRepo := &redeemRejectRepo{code: RedeemCode{Code: "OTHER"}} + svc := &RedeemService{redeemRepo: redeemRepo, cache: cache} + + result, err := svc.Redeem(context.Background(), 42, "MISSING") + + require.Nil(t, result) + require.ErrorIs(t, err, ErrRedeemCodeNotFound) + require.Equal(t, 1, cache.getCalls) + require.Equal(t, 1, cache.incrementCalls) + require.Equal(t, 1, cache.count) + require.Equal(t, 1, cache.acquireCalls) + require.Equal(t, 1, cache.releaseCalls) +} + +func TestPaymentRedeemDoesNotIncrementFailureLimit(t *testing.T) { + ctx := context.Background() + cache := &paymentFulfillmentRedeemCacheStub{count: redeemMaxErrorsPerHour} + redeemRepo := &redeemRejectRepo{code: RedeemCode{ + ID: 1, + Code: "PAY-EXPIRED", + Type: RedeemTypeBalance, + Value: 10, + Status: StatusExpired, + }} + svc := &RedeemService{redeemRepo: redeemRepo, cache: cache} + + result, err := svc.redeemForPaymentFulfillment(ctx, 42, redeemRepo.code.Code) + + require.Nil(t, result) + require.ErrorIs(t, err, ErrRedeemCodeExpired) + require.Zero(t, cache.getCalls) + require.Zero(t, cache.incrementCalls) + require.Equal(t, 1, cache.acquireCalls) + require.Equal(t, 1, cache.releaseCalls) +} + +func TestExecuteBalanceFulfillmentBypassesUserRedeemRateLimit(t *testing.T) { + ctx := context.Background() + client := newPaymentConfigServiceTestClient(t) + ensurePaymentAuditOrderActionUniqueIndex(t, ctx, client) + order := createPaymentFulfillmentSubscriptionOrder(t, ctx, client, OrderStatusPaid, time.Now()) + order, err := client.PaymentOrder.UpdateOneID(order.ID). + SetOrderType(payment.OrderTypeBalance). + ClearPlanID(). + ClearSubscriptionGroupID(). + ClearSubscriptionDays(). + Save(ctx) + require.NoError(t, err) + + redeemRepo := &paymentFulfillmentRedeemRepo{} + credited := 0.0 + userRepo := &mockUserRepo{getByIDUser: &User{ID: order.UserID, Balance: 0}} + userRepo.updateBalanceFn = func(_ context.Context, id int64, amount float64) error { + require.Equal(t, order.UserID, id) + credited += amount + return nil + } + cache := &paymentFulfillmentRedeemCacheStub{count: redeemMaxErrorsPerHour} + redeemService := NewRedeemService(redeemRepo, userRepo, nil, cache, nil, client, nil, nil) + svc := &PaymentService{entClient: client, redeemService: redeemService, userRepo: userRepo} + + require.NoError(t, svc.ExecuteBalanceFulfillment(ctx, order.ID)) + require.InDelta(t, order.Amount, credited, 1e-8) + require.Zero(t, cache.getCalls, "trusted payment fulfillment must not read the public failure counter") + require.Zero(t, cache.incrementCalls, "trusted payment fulfillment must not mutate the public failure counter") + require.Equal(t, 1, cache.acquireCalls) + require.Equal(t, 1, cache.releaseCalls) + require.Equal(t, 1, redeemRepo.createCalls) + require.Len(t, redeemRepo.useCalls, 1) + + usedCode := redeemRepo.codesByCode[order.RechargeCode] + require.Equal(t, StatusUsed, usedCode.Status) + require.NotNil(t, usedCode.UsedBy) + require.Equal(t, order.UserID, *usedCode.UsedBy) + reloaded, err := client.PaymentOrder.Get(ctx, order.ID) + require.NoError(t, err) + require.Equal(t, OrderStatusCompleted, reloaded.Status) +} + func TestExecuteBalanceFulfillmentRecoversAfterRedeemWithoutCreditingAgain(t *testing.T) { ctx := context.Background() client := newPaymentConfigServiceTestClient(t) @@ -683,6 +872,7 @@ func TestExecuteBalanceFulfillmentRecoversAfterRedeemWithoutCreditingAgain(t *te Save(ctx) require.NoError(t, err) + usedBy := order.UserID redeemRepo := &redeemCodeRepoStub{codesByCode: map[string]*RedeemCode{ order.RechargeCode: { ID: 101, @@ -690,6 +880,7 @@ func TestExecuteBalanceFulfillmentRecoversAfterRedeemWithoutCreditingAgain(t *te Type: RedeemTypeBalance, Value: order.Amount, Status: StatusUsed, + UsedBy: &usedBy, }, }} svc := &PaymentService{ @@ -770,6 +961,44 @@ func TestPaymentNotificationRejectsAmountMismatchBeforeFulfillment(t *testing.T) require.Equal(t, OrderStatusPending, reloaded.Status) } +func TestExecuteBalanceFulfillmentRejectsCodeUsedByAnotherUser(t *testing.T) { + ctx := context.Background() + client := newPaymentConfigServiceTestClient(t) + ensurePaymentAuditOrderActionUniqueIndex(t, ctx, client) + order := createPaymentFulfillmentSubscriptionOrder(t, ctx, client, OrderStatusPaid, time.Now()) + order, err := client.PaymentOrder.UpdateOneID(order.ID). + SetOrderType(payment.OrderTypeBalance). + ClearPlanID(). + ClearSubscriptionGroupID(). + ClearSubscriptionDays(). + Save(ctx) + require.NoError(t, err) + + otherUserID := order.UserID + 1 + redeemRepo := &redeemCodeRepoStub{codesByCode: map[string]*RedeemCode{ + order.RechargeCode: { + ID: 101, + Code: order.RechargeCode, + Type: RedeemTypeBalance, + Value: order.Amount, + Status: StatusUsed, + UsedBy: &otherUserID, + }, + }} + svc := &PaymentService{ + entClient: client, + redeemService: &RedeemService{redeemRepo: redeemRepo}, + } + + err = svc.ExecuteBalanceFulfillment(ctx, order.ID) + require.ErrorContains(t, err, "payment redeem code user mismatch") + require.Empty(t, redeemRepo.useCalls) + reloaded, err := client.PaymentOrder.Get(ctx, order.ID) + require.NoError(t, err) + require.Equal(t, OrderStatusFailed, reloaded.Status) + require.Nil(t, reloaded.CompletedAt) +} + func TestExecuteSubscriptionFulfillmentRecoversCommittedAssignmentWithoutExtendingAgain(t *testing.T) { ctx := context.Background() client := newPaymentConfigServiceTestClient(t) diff --git a/backend/internal/service/pricing_service.go b/backend/internal/service/pricing_service.go index de7955e0a1..ba39465f52 100644 --- a/backend/internal/service/pricing_service.go +++ b/backend/internal/service/pricing_service.go @@ -45,6 +45,23 @@ var ( Mode: "chat", SupportsPromptCaching: true, } + openAIGPT6AstraFallbackPricing = &LiteLLMModelPricing{ + InputCostPerToken: 1e-05, + InputCostPerTokenPriority: 2e-05, + OutputCostPerToken: 5e-05, + OutputCostPerTokenPriority: 1e-04, + CacheCreationInputTokenCost: 1.25e-05, + CacheCreationInputTokenCostPriority: 2.5e-05, + CacheReadInputTokenCost: 1e-06, + CacheReadInputTokenCostPriority: 2e-06, + LongContextInputTokenThreshold: 272_000, + LongContextInputCostMultiplier: 2, + LongContextOutputCostMultiplier: 1.5, + SupportsServiceTier: true, + LiteLLMProvider: "openai", + Mode: "chat", + SupportsPromptCaching: true, + } openAIGPT56SolFallbackPricing = &LiteLLMModelPricing{ InputCostPerToken: 5e-06, InputCostPerTokenPriority: 1e-05, @@ -1238,6 +1255,9 @@ func normalizeModelNameForPricing(model string) string { model = strings.TrimLeft(model, "/") if canonical := canonicalizeOpenAIModelAliasSpelling(model); canonical != "" { + if canonical == "gpt-6" { + return "gpt-6-astra" + } if canonical == "gpt-5.6" { return "gpt-5.6-sol" } @@ -1438,6 +1458,12 @@ func (s *PricingService) matchOpenAIModel(model string) *LiteLLMModelPricing { } } + if isOpenAIGPT6AstraModel(model) { + logger.With(zap.String("component", "service.pricing")). + Info(fmt.Sprintf("[Pricing] OpenAI fallback matched %s -> %s", model, "gpt-6-astra(static)")) + return openAIGPT6AstraFallbackPricing + } + if strings.HasPrefix(model, "gpt-5.6-sol") { logger.With(zap.String("component", "service.pricing")). Info(fmt.Sprintf("[Pricing] OpenAI fallback matched %s -> %s", model, "gpt-5.6-sol(static)")) diff --git a/backend/internal/service/pricing_service_test.go b/backend/internal/service/pricing_service_test.go index a02c74fe72..d65271dd16 100644 --- a/backend/internal/service/pricing_service_test.go +++ b/backend/internal/service/pricing_service_test.go @@ -77,6 +77,95 @@ func TestParsePricingData_ParsesPriorityAndServiceTierFields(t *testing.T) { require.True(t, pricing.SupportsServiceTier) } +const gpt6AstraCatalogJSON = `{ + "gpt-6-astra": { + "litellm_provider": "openai", + "mode": "chat", + "input_cost_per_token": 1e-05, + "input_cost_per_token_priority": 2e-05, + "output_cost_per_token": 5e-05, + "output_cost_per_token_priority": 1e-04, + "cache_creation_input_token_cost": 1.25e-05, + "cache_creation_input_token_cost_priority": 2.5e-05, + "cache_read_input_token_cost": 1e-06, + "cache_read_input_token_cost_priority": 2e-06, + "input_cost_per_token_above_272k_tokens": 2e-05, + "output_cost_per_token_above_272k_tokens": 7.5e-05, + "cache_creation_input_token_cost_above_272k_tokens": 2.5e-05, + "cache_read_input_token_cost_above_272k_tokens": 2e-06 + } +}` + +func TestBillingServiceGPT6AstraUsesOfficialPricingAcrossTiersAndLongContext(t *testing.T) { + svc := NewBillingService(&config.Config{}, newStubPricingServiceFromJSON(t, gpt6AstraCatalogJSON)) + boundaryTokens := UsageTokens{InputTokens: 100_000, CacheCreationTokens: 100_000, CacheReadTokens: 72_000, OutputTokens: 10} + boundary, err := svc.CalculateCost("gpt-6-astra", boundaryTokens, 1) + require.NoError(t, err) + require.False(t, boundary.LongContextBillingApplied) + require.InDelta(t, 100_000*10e-6, boundary.InputCost, 1e-12) + require.InDelta(t, 100_000*12.5e-6, boundary.CacheCreationCost, 1e-12) + require.InDelta(t, 72_000*1e-6, boundary.CacheReadCost, 1e-12) + require.InDelta(t, 10*50e-6, boundary.OutputCost, 1e-12) + + tokens := UsageTokens{InputTokens: 100_000, CacheCreationTokens: 100_000, CacheReadTokens: 73_000, OutputTokens: 10} + tiers := []struct { + name string + serviceTier string + priceScale float64 + }{ + {name: "standard", priceScale: 1}, + {name: "fast", serviceTier: "priority", priceScale: 2}, + {name: "flex", serviceTier: "flex", priceScale: 0.5}, + } + for _, tier := range tiers { + t.Run(tier.name, func(t *testing.T) { + cost, err := svc.CalculateCostWithServiceTier("gpt-6-astra", tokens, 1, tier.serviceTier) + require.NoError(t, err) + require.True(t, cost.LongContextBillingApplied) + require.InDelta(t, 100_000*10e-6*tier.priceScale*2, cost.InputCost, 1e-12) + require.InDelta(t, 100_000*12.5e-6*tier.priceScale*2, cost.CacheCreationCost, 1e-12) + require.InDelta(t, 73_000*1e-6*tier.priceScale*2, cost.CacheReadCost, 1e-12) + require.InDelta(t, 10*50e-6*tier.priceScale*1.5, cost.OutputCost, 1e-12) + }) + } +} + +func TestGPT6AstraDedicatedFallbacksUseOfficialRates(t *testing.T) { + tests := []struct { + name string + svc *BillingService + }{ + {name: "pricing_service", svc: NewBillingService(&config.Config{}, &PricingService{pricingData: map[string]*LiteLLMModelPricing{}})}, + {name: "billing_service", svc: NewBillingService(&config.Config{}, nil)}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + pricing, err := tt.svc.GetModelPricing("gpt-6-astra") + require.NoError(t, err) + require.InDelta(t, 10e-6, pricing.InputPricePerToken, 1e-12) + require.InDelta(t, 20e-6, pricing.InputPricePerTokenPriority, 1e-12) + require.InDelta(t, 50e-6, pricing.OutputPricePerToken, 1e-12) + require.InDelta(t, 100e-6, pricing.OutputPricePerTokenPriority, 1e-12) + require.InDelta(t, 12.5e-6, pricing.CacheCreationPricePerToken, 1e-12) + require.InDelta(t, 25e-6, pricing.CacheCreationPricePerTokenPriority, 1e-12) + require.InDelta(t, 1e-6, pricing.CacheReadPricePerToken, 1e-12) + require.InDelta(t, 2e-6, pricing.CacheReadPricePerTokenPriority, 1e-12) + require.Equal(t, 272_000, pricing.LongContextInputThreshold) + require.InDelta(t, 2.0, pricing.LongContextInputMultiplier, 1e-12) + require.InDelta(t, 1.5, pricing.LongContextOutputMultiplier, 1e-12) + }) + } +} + +func TestPricingServiceBareGPT6AliasUsesAstra(t *testing.T) { + astraPricing := &LiteLLMModelPricing{InputCostPerToken: 123e-6, OutputCostPerToken: 456e-6} + pricingSvc := &PricingService{pricingData: map[string]*LiteLLMModelPricing{"gpt-6-astra": astraPricing}} + for _, model := range []string{"gpt-6", "openai/gpt-6"} { + pricing := pricingSvc.GetModelPricing(model) + require.Same(t, astraPricing, pricing) + } +} + func TestBillingService_GPT56CacheWritePricingUsesOfficialMultiplier(t *testing.T) { tests := []struct { model string diff --git a/backend/internal/service/ratelimit_service.go b/backend/internal/service/ratelimit_service.go index 3bd467f464..47f234f85d 100644 --- a/backend/internal/service/ratelimit_service.go +++ b/backend/internal/service/ratelimit_service.go @@ -366,12 +366,16 @@ func (s *RateLimitService) HandleUpstreamError(ctx context.Context, account *Acc // otherwise a broad "rate limit" keyword rule can shorten a multi-hour // cooldown to a local temporary pause. if statusCode == http.StatusTooManyRequests && account.Platform == PlatformAnthropic { + // Fable may be rejected because the organization has no usage credits for + // this model. Anthropic reports that as 429, but it is a model entitlement + // failure rather than a shared account window exhaustion. + fableCreditsRequired := s.persistAnthropicFableCreditsRequired(ctx, account, headers, responseBody, firstRequestedModel(requestedModel)) // 7d_oi 是 Fable 模型专属的 7d 窗口:只标记模型级限流,账号对其他模型仍可调度。 fableLimited := s.persistAnthropicFableWindowLimit(ctx, account, headers) if s.persistAnthropicExhaustedWindowLimit(ctx, account, headers) { return false } - if fableLimited { + if fableCreditsRequired || fableLimited { return false } } @@ -1486,7 +1490,59 @@ func (s *RateLimitService) persistAnthropicExhaustedWindowLimit(ctx context.Cont return true } -const anthropicFableWindowReason = "anthropic_7d_oi_window_exhausted" +const ( + anthropicFableWindowReason = "anthropic_7d_oi_window_exhausted" + anthropicFableCreditsRequiredReason = "anthropic_fable_credits_required" +) + +// persistAnthropicFableCreditsRequired handles Anthropic's credits_required +// response for Fable. Although the upstream status is 429, this response only +// says that the organization cannot use Fable; marking the whole account rate +// limited would unnecessarily stop Sonnet, Opus, and Haiku scheduling. +func (s *RateLimitService) persistAnthropicFableCreditsRequired(ctx context.Context, account *Account, headers http.Header, responseBody []byte, requestedModel string) bool { + if s == nil || s.accountRepo == nil || account == nil { + return false + } + if !strings.EqualFold(strings.TrimSpace(gjson.GetBytes(responseBody, "error.details.error_code").String()), "credits_required") { + return false + } + + model := strings.TrimSpace(gjson.GetBytes(responseBody, "error.details.model").String()) + if model == "" { + model = strings.TrimSpace(requestedModel) + } + if !isAnthropicFableModel(model) { + return false + } + + now := time.Now() + resetAt, ok := parseAnthropicResetTimestamp(headers.Get("anthropic-ratelimit-unified-reset"), now, 366*24*time.Hour) + if !ok { + cooldown, enabled := s.get429FallbackCooldown(ctx, account) + if !enabled { + slog.Info("anthropic_fable_credits_required_cooldown_ignored", "account_id", account.ID) + return true + } + resetAt = now.Add(cooldown) + } + + if err := s.accountRepo.SetModelRateLimit(ctx, account.ID, anthropicFableRateLimitKey, resetAt, anthropicFableCreditsRequiredReason); err != nil { + slog.Warn("anthropic_fable_credits_required_rate_limit_set_failed", + "account_id", account.ID, + "scope", anthropicFableRateLimitKey, + "reset_at", resetAt, + "error", err) + // The response is still known to be Fable-specific. Do not widen a + // persistence failure into an account-level rate limit. + return true + } + slog.Info("anthropic_fable_credits_required_model_rate_limited", + "account_id", account.ID, + "scope", anthropicFableRateLimitKey, + "reset_at", resetAt, + "reset_in", time.Until(resetAt).Truncate(time.Second)) + return true +} // selectAnthropicFableWindowLimit parses the Anthropic 7d_oi per-model window // headers (the Fable-only 7d window, e.g. anthropic-ratelimit-unified-7d_oi-*). diff --git a/backend/internal/service/ratelimit_service_anthropic_window_limit_test.go b/backend/internal/service/ratelimit_service_anthropic_window_limit_test.go index a13e8fe333..328bf07fe6 100644 --- a/backend/internal/service/ratelimit_service_anthropic_window_limit_test.go +++ b/backend/internal/service/ratelimit_service_anthropic_window_limit_test.go @@ -160,6 +160,82 @@ func TestHandleUpstreamError_Anthropic7dOiOnlyMarksModelRateLimit(t *testing.T) require.Equal(t, 0.41, repo.lastExtraUpdates["session_window_utilization"]) } +func TestHandleUpstreamError_AnthropicFableCreditsRequiredOnlyMarksModelRateLimit(t *testing.T) { + resetAt := time.Now().Add(29 * 24 * time.Hour).Truncate(time.Second) + headers := http.Header{} + headers.Set("anthropic-ratelimit-unified-reset", strconv.FormatInt(resetAt.Unix(), 10)) + + repo := &anthropicWindowLimitRepo{} + svc := NewRateLimitService(repo, nil, nil, nil, nil) + account := &Account{ID: 42, Type: AccountTypeOAuth, Platform: PlatformAnthropic} + body := []byte(`{"type":"error","error":{"details":{"error_code":"credits_required","model":"claude-fable-5","disabled_reason":"org_level_disabled"},"message":"Usage credits are required for this model."}}`) + + shouldDisable := svc.HandleUpstreamError(context.Background(), account, http.StatusTooManyRequests, headers, body, "claude-fable-5") + + require.False(t, shouldDisable) + require.Zero(t, repo.rateLimitCalls, "Fable entitlement failure must not rate limit the whole account") + require.Zero(t, repo.sessionWindowCalls, "Fable entitlement failure must not rewrite the shared session window") + require.Equal(t, 1, repo.modelRateLimitCalls) + require.Equal(t, anthropicFableRateLimitKey, repo.lastModelRateLimitScope) + require.Equal(t, resetAt, repo.lastModelRateLimitReset) +} + +func TestHandleUpstreamError_AnthropicFableCreditsRequiredFallsBackToRequestedModel(t *testing.T) { + startedAt := time.Now() + repo := &anthropicWindowLimitRepo{} + svc := NewRateLimitService(repo, nil, nil, nil, nil) + account := &Account{ID: 42, Type: AccountTypeOAuth, Platform: PlatformAnthropic} + body := []byte(`{"type":"error","error":{"details":{"error_code":"credits_required"},"message":"Usage credits are required for this model."}}`) + + svc.HandleUpstreamError(context.Background(), account, http.StatusTooManyRequests, http.Header{}, body, "claude-fable-5[1m]") + + require.Zero(t, repo.rateLimitCalls) + require.Equal(t, 1, repo.modelRateLimitCalls) + require.Equal(t, anthropicFableRateLimitKey, repo.lastModelRateLimitScope) + require.True(t, repo.lastModelRateLimitReset.After(startedAt)) +} + +func TestHandleUpstreamError_AnthropicNonFableCreditsRequiredKeepsLegacyBehavior(t *testing.T) { + resetAt := time.Now().Add(2 * time.Hour).Truncate(time.Second) + headers := http.Header{} + headers.Set("anthropic-ratelimit-unified-reset", strconv.FormatInt(resetAt.Unix(), 10)) + + repo := &anthropicWindowLimitRepo{} + svc := NewRateLimitService(repo, nil, nil, nil, nil) + account := &Account{ID: 42, Type: AccountTypeOAuth, Platform: PlatformAnthropic} + body := []byte(`{"type":"error","error":{"details":{"error_code":"credits_required","model":"claude-opus-5"},"message":"Usage credits are required for this model."}}`) + + svc.HandleUpstreamError(context.Background(), account, http.StatusTooManyRequests, headers, body, "claude-opus-5") + + require.Zero(t, repo.modelRateLimitCalls) + require.Equal(t, 1, repo.rateLimitCalls) + require.Equal(t, resetAt, repo.lastRateLimitReset) +} + +func TestHandleUpstreamError_AnthropicSharedWindowStillWinsWithFableCreditsRequired(t *testing.T) { + now := time.Now() + reset5h := now.Add(2 * time.Hour).Truncate(time.Second) + reset7d := now.Add(80 * time.Hour).Truncate(time.Second) + headers := http.Header{} + headers.Set("anthropic-ratelimit-unified-5h-status", "rejected") + headers.Set("anthropic-ratelimit-unified-5h-utilization", "1.0") + headers.Set("anthropic-ratelimit-unified-5h-reset", strconv.FormatInt(reset5h.Unix(), 10)) + headers.Set("anthropic-ratelimit-unified-7d-status", "allowed") + headers.Set("anthropic-ratelimit-unified-7d-reset", strconv.FormatInt(reset7d.Unix(), 10)) + headers.Set("anthropic-ratelimit-unified-reset", strconv.FormatInt(reset5h.Unix(), 10)) + + repo := &anthropicWindowLimitRepo{} + svc := NewRateLimitService(repo, nil, nil, nil, nil) + account := &Account{ID: 42, Type: AccountTypeOAuth, Platform: PlatformAnthropic} + body := []byte(`{"type":"error","error":{"details":{"error_code":"credits_required","model":"claude-fable-5"}}}`) + + svc.HandleUpstreamError(context.Background(), account, http.StatusTooManyRequests, headers, body, "claude-fable-5") + + require.Equal(t, 1, repo.modelRateLimitCalls, "Fable-specific failure should still be recorded") + require.Equal(t, 1, repo.rateLimitCalls, "an explicitly rejected shared window must still rate limit the account") + require.Equal(t, reset5h, repo.lastRateLimitReset) +} + func TestHandleUpstreamError_Anthropic5hWindowStillWinsOver7dOi(t *testing.T) { // 5h 窗口 rejected 时必须仍按账号级限流处理(用 5h reset),同时记录 Fable 模型限流。 now := time.Now() diff --git a/backend/internal/service/redeem_admin_fulfillment_test.go b/backend/internal/service/redeem_admin_fulfillment_test.go new file mode 100644 index 0000000000..76e4ff4832 --- /dev/null +++ b/backend/internal/service/redeem_admin_fulfillment_test.go @@ -0,0 +1,59 @@ +//go:build unit + +package service + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestAdminFulfillmentBypassesLimitAndKeepsRedeemAffiliate(t *testing.T) { + ctx := context.Background() + client := newPaymentConfigServiceTestClient(t) + userID := int64(42) + inviterID := int64(9001) + code := &RedeemCode{ + ID: 102, Code: "ADMIN-SUCCESS", Type: RedeemTypeBalance, Value: 20, Status: StatusUnused, + } + redeemRepo := &paymentFulfillmentRedeemRepo{ + paymentOrderLifecycleRedeemRepo: paymentOrderLifecycleRedeemRepo{ + codesByCode: map[string]*RedeemCode{code.Code: code}, + }, + } + userRepo := &mockUserRepo{getByIDUser: &User{ID: userID}} + userRepo.updateBalanceFn = func(context.Context, int64, float64) error { return nil } + cache := &paymentFulfillmentRedeemCacheStub{count: redeemMaxErrorsPerHour} + affiliateRepo := &paymentFulfillmentAffiliateRepoStub{ + inviteeSummary: &AffiliateSummary{ + UserID: userID, AffCode: "INVITEE", InviterID: &inviterID, CreatedAt: time.Now().Add(-time.Hour), + }, + inviterSummary: &AffiliateSummary{ + UserID: inviterID, AffCode: "INVITER", CreatedAt: time.Now().Add(-2 * time.Hour), + }, + } + settingSvc := NewSettingService(&paymentFulfillmentSettingRepoStub{values: map[string]string{ + SettingKeyAffiliateEnabled: "true", + SettingKeyAffiliateRebateRate: "10", + SettingKeyAffiliateRebateFreezeHours: "0", + }}, nil) + affiliateSvc := NewAffiliateService(affiliateRepo, settingSvc, nil, nil) + svc := NewRedeemService(redeemRepo, userRepo, nil, cache, nil, client, nil, affiliateSvc) + + result, err := svc.RedeemForAdminFulfillment(ctx, userID, code.Code) + + require.NoError(t, err) + require.NotNil(t, result) + require.Zero(t, cache.getCalls) + require.Zero(t, cache.incrementCalls) + require.Equal(t, 1, cache.acquireCalls) + require.Equal(t, 1, cache.releaseCalls) + require.Len(t, redeemRepo.useCalls, 1) + require.Len(t, affiliateRepo.accrueCalls, 1) + require.Equal(t, inviterID, affiliateRepo.accrueCalls[0].inviterID) + require.Equal(t, userID, affiliateRepo.accrueCalls[0].inviteeUserID) + require.InDelta(t, 2, affiliateRepo.accrueCalls[0].amount, 1e-8) + require.Nil(t, affiliateRepo.accrueCalls[0].sourceOrderID) +} diff --git a/backend/internal/service/redeem_service.go b/backend/internal/service/redeem_service.go index 8794872e3d..7be6871f07 100644 --- a/backend/internal/service/redeem_service.go +++ b/backend/internal/service/redeem_service.go @@ -30,6 +30,13 @@ const ( redeemLockDuration = 10 * time.Second // 锁超时时间,防止死锁 ) +type redeemRateLimitPolicy uint8 + +const ( + enforceRedeemRateLimit redeemRateLimitPolicy = iota + bypassRedeemRateLimit +) + type ctxKeySkipRedeemAffiliate struct{} // ContextSkipRedeemAffiliate returns a context that suppresses the redeem-level @@ -386,9 +393,30 @@ func unsupportedRedeemTypeError(codeType string) error { // Redeem 使用兑换码 func (s *RedeemService) Redeem(ctx context.Context, userID int64, code string) (*RedeemCode, error) { - // 检查限流 - if err := s.checkRedeemRateLimit(ctx, userID); err != nil { - return nil, err + return s.redeem(ctx, userID, code, enforceRedeemRateLimit) +} + +// redeemForPaymentFulfillment is restricted to trusted payment fulfillment. +// Payment retries must not be blocked by, or contribute to, a user's public +// redeem failure counter. All code validation and transactional updates remain +// identical to the public redemption path. +func (s *RedeemService) redeemForPaymentFulfillment(ctx context.Context, userID int64, code string) (*RedeemCode, error) { + return s.redeem(ContextSkipRedeemAffiliate(ctx), userID, code, bypassRedeemRateLimit) +} + +// RedeemForAdminFulfillment is restricted to trusted admin fulfillment. +// Admin retries must not be blocked by, or contribute to, a user's public +// redeem failure counter. Unlike payment fulfillment, the normal redeem-level +// affiliate rebate remains enabled. +func (s *RedeemService) RedeemForAdminFulfillment(ctx context.Context, userID int64, code string) (*RedeemCode, error) { + return s.redeem(ctx, userID, code, bypassRedeemRateLimit) +} + +func (s *RedeemService) redeem(ctx context.Context, userID int64, code string, rateLimitPolicy redeemRateLimitPolicy) (*RedeemCode, error) { + if rateLimitPolicy == enforceRedeemRateLimit { + if err := s.checkRedeemRateLimit(ctx, userID); err != nil { + return nil, err + } } // 获取分布式锁,防止同一兑换码并发使用 @@ -401,7 +429,9 @@ func (s *RedeemService) Redeem(ctx context.Context, userID int64, code string) ( redeemCode, err := s.redeemRepo.GetByCode(ctx, code) if err != nil { if errors.Is(err, ErrRedeemCodeNotFound) { - s.incrementRedeemErrorCount(ctx, userID) + if rateLimitPolicy == enforceRedeemRateLimit { + s.incrementRedeemErrorCount(ctx, userID) + } return nil, ErrRedeemCodeNotFound } return nil, fmt.Errorf("get redeem code: %w", err) @@ -409,11 +439,15 @@ func (s *RedeemService) Redeem(ctx context.Context, userID int64, code string) ( // 检查兑换码状态和码本身的过期时间 if redeemCode.IsExpired() { - s.incrementRedeemErrorCount(ctx, userID) + if rateLimitPolicy == enforceRedeemRateLimit { + s.incrementRedeemErrorCount(ctx, userID) + } return nil, ErrRedeemCodeExpired } if !redeemCode.CanUse() { - s.incrementRedeemErrorCount(ctx, userID) + if rateLimitPolicy == enforceRedeemRateLimit { + s.incrementRedeemErrorCount(ctx, userID) + } return nil, ErrRedeemCodeUsed } diff --git a/backend/internal/service/session_limit_cache.go b/backend/internal/service/session_limit_cache.go index 5482d61063..41f8fd5b0b 100644 --- a/backend/internal/service/session_limit_cache.go +++ b/backend/internal/service/session_limit_cache.go @@ -33,6 +33,12 @@ type SessionLimitCache interface { // 用于活跃会话保持活动状态 RefreshSession(ctx context.Context, accountID int64, sessionUUID string, idleTimeout time.Duration) error + // UnregisterSession 立即移除会话注册(不等待空闲超时) + // 用于请求最终失败的场景:上游从未真正服务该会话,不应继续占用会话槽, + // 否则 max_sessions 受限的账号会被失败请求的 session hash 卡满整个空闲窗口, + // 导致后续新会话在超时窗口内全部被拒 + UnregisterSession(ctx context.Context, accountID int64, sessionUUID string) error + // GetActiveSessionCount 获取当前活跃会话数 // 返回未过期的会话数量 GetActiveSessionCount(ctx context.Context, accountID int64) (int, error) diff --git a/backend/internal/service/session_limit_release_test.go b/backend/internal/service/session_limit_release_test.go new file mode 100644 index 0000000000..ba29ccec27 --- /dev/null +++ b/backend/internal/service/session_limit_release_test.go @@ -0,0 +1,121 @@ +package service + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/require" +) + +// sessionLimitReleaseCacheStub 记录 UnregisterSession 调用,用于验证释放逻辑 +type sessionLimitReleaseCacheStub struct { + SessionLimitCache + + unregistered map[int64][]string + err error +} + +func newSessionLimitReleaseCacheStub() *sessionLimitReleaseCacheStub { + return &sessionLimitReleaseCacheStub{ + unregistered: make(map[int64][]string), + } +} + +func (s *sessionLimitReleaseCacheStub) UnregisterSession(_ context.Context, accountID int64, sessionUUID string) error { + if s.err != nil { + return s.err + } + s.unregistered[accountID] = append(s.unregistered[accountID], sessionUUID) + return nil +} + +func newSessionLimitTestAccount() *Account { + return &Account{ + ID: 42, + Platform: PlatformAnthropic, + Type: AccountTypeOAuth, + Extra: map[string]any{"max_sessions": 1}, + } +} + +// TestReleaseAccountSession_ReleasesRegisteredSlot 验证: +// 对启用会话限制的 Anthropic OAuth 账号,ReleaseAccountSession 必须立即移除 +// 该账号上注册的会话(不等待空闲超时)。 +func TestReleaseAccountSession_ReleasesRegisteredSlot(t *testing.T) { + cache := newSessionLimitReleaseCacheStub() + svc := &GatewayService{sessionLimitCache: cache} + acc := newSessionLimitTestAccount() + + svc.ReleaseAccountSession(context.Background(), acc, "session-hash-1") + + require.Equal(t, []string{"session-hash-1"}, cache.unregistered[42], + "应立即移除注册的会话槽") +} + +// TestReleaseAccountSession_NoOpForInapplicableAccounts 验证: +// - 非 Anthropic OAuth/SetupToken 账号 +// - 未启用 max_sessions 的账号 +// - 空 sessionID +// 以上场景均为 no-op,不得触发 UnregisterSession。 +func TestReleaseAccountSession_NoOpForInapplicableAccounts(t *testing.T) { + apiKeyAcc := &Account{ + ID: 43, + Platform: PlatformAnthropic, + Type: AccountTypeAPIKey, + Extra: map[string]any{"max_sessions": 1}, + } + noLimitAcc := &Account{ + ID: 44, + Platform: PlatformAnthropic, + Type: AccountTypeOAuth, + } + enabledAcc := newSessionLimitTestAccount() + + cases := []struct { + name string + account *Account + sessionID string + }{ + {"api_key_account", apiKeyAcc, "session-hash"}, + {"max_sessions_disabled", noLimitAcc, "session-hash"}, + {"empty_session_id", enabledAcc, ""}, + {"nil_account", nil, "session-hash"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + cache := newSessionLimitReleaseCacheStub() + svc := &GatewayService{sessionLimitCache: cache} + svc.ReleaseAccountSession(context.Background(), tc.account, tc.sessionID) + require.Empty(t, cache.unregistered, "不适用账号不应触发释放") + }) + } +} + +// TestReleaseAccountSession_NilCacheAndErrorTolerance 验证: +// sessionLimitCache 不可用时 no-op;UnregisterSession 返回错误时不 panic(仅记录日志)。 +func TestReleaseAccountSession_NilCacheAndErrorTolerance(t *testing.T) { + // nil cache:no-op + svc := &GatewayService{} + svc.ReleaseAccountSession(context.Background(), newSessionLimitTestAccount(), "session-hash") + + // 底层错误:不 panic + cache := &sessionLimitReleaseCacheStub{err: errors.New("redis down")} + svc = &GatewayService{sessionLimitCache: cache} + svc.ReleaseAccountSession(context.Background(), newSessionLimitTestAccount(), "session-hash") +} + +// TestReleaseAccountSession_Idempotent 验证释放操作幂等,可安全重复调用 +// (failover 链上按次释放 + defer 兜底可能对同一账号重复释放)。 +func TestReleaseAccountSession_Idempotent(t *testing.T) { + cache := newSessionLimitReleaseCacheStub() + svc := &GatewayService{sessionLimitCache: cache} + acc := newSessionLimitTestAccount() + + svc.ReleaseAccountSession(context.Background(), acc, "session-hash") + svc.ReleaseAccountSession(context.Background(), acc, "session-hash") + + // 两次调用都透传到缓存层(Redis ZREM 本身幂等,重复移除无副作用) + require.Len(t, cache.unregistered[42], 2) +} diff --git a/backend/internal/service/setting_features.go b/backend/internal/service/setting_features.go index 88b1ea4161..3827790fe8 100644 --- a/backend/internal/service/setting_features.go +++ b/backend/internal/service/setting_features.go @@ -1016,7 +1016,7 @@ func (s *SettingService) SetOpenAIFastPolicySettings(ctx context.Context, settin BetaPolicyScopeAll: true, BetaPolicyScopeOAuth: true, BetaPolicyScopeAPIKey: true, BetaPolicyScopeBedrock: true, } validTiers := map[string]bool{ - OpenAIFastTierAny: true, OpenAIFastTierPriority: true, OpenAIFastTierFlex: true, + OpenAIFastTierAny: true, OpenAIFastTierPriority: true, OpenAIFastTierUltrafast: true, OpenAIFastTierFlex: true, } for i, rule := range settings.Rules { diff --git a/backend/internal/service/setting_parse.go b/backend/internal/service/setting_parse.go index 36e7706dda..ade4de0d41 100644 --- a/backend/internal/service/setting_parse.go +++ b/backend/internal/service/setting_parse.go @@ -192,7 +192,8 @@ func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error { SettingKeyChannelMonitorHideThroughput: "true", SettingKeyChannelMonitorShowQuota: "false", - // Grok: safe defaults — no cross-vendor model rewrite unless operators enable it. + // Grok compatibility defaults: cross-client mapping stays enabled unless + // operators explicitly disable it. SettingKeyGrokDefaultTextModel: "grok-4.6", SettingKeyGrokCrossClientModelMapEnabled: "true", SettingKeyGrokDefaultBaseURLMode: GrokDefaultBaseURLModeCLI, diff --git a/backend/internal/service/settings_view.go b/backend/internal/service/settings_view.go index 002560e822..9b6932934d 100644 --- a/backend/internal/service/settings_view.go +++ b/backend/internal/service/settings_view.go @@ -669,15 +669,17 @@ func DefaultBetaPolicySettings() *BetaPolicySettings { // OpenAI Fast Policy 策略常量 // OpenAI 的 "fast 模式" 通过请求体中的 service_tier 字段识别: // - "priority"(客户端可传 "fast",归一化为 "priority"):fast 模式 +// - "ultrafast":Codex/API 的 Ultrafast 档位 // - "flex":低优先级模式 // - 省略:normal 默认 // // 本策略复用 BetaPolicyAction*/BetaPolicyScope* 常量语义,只是匹配键从 // anthropic-beta header 换成 body 的 service_tier 字段。 const ( - OpenAIFastTierAny = "all" // 匹配任意已识别的 service_tier - OpenAIFastTierPriority = "priority" // 仅匹配 fast(priority) - OpenAIFastTierFlex = "flex" // 仅匹配 flex + OpenAIFastTierAny = "all" // 匹配任意已识别的 service_tier + OpenAIFastTierPriority = "priority" // 仅匹配 fast(priority) + OpenAIFastTierUltrafast = "ultrafast" // 仅匹配 ultrafast + OpenAIFastTierFlex = "flex" // 仅匹配 flex // OpenAIFastPolicyActionForcePriority 会保留 service_tier 字段并强制写成 // priority,用于把 flex/auto/default/scale 等已识别 tier 收敛为 fast。 @@ -686,7 +688,7 @@ const ( // OpenAIFastPolicyRule 单条 OpenAI fast/flex 策略规则 type OpenAIFastPolicyRule struct { - ServiceTier string `json:"service_tier"` // "priority" | "flex" | "auto" | "default" | "scale" | "all" + ServiceTier string `json:"service_tier"` // "priority" | "ultrafast" | "flex" | "auto" | "default" | "scale" | "all" Action string `json:"action"` // "pass" | "filter" | "block" | "force_priority" Scope string `json:"scope"` // "all" | "oauth" | "apikey" | "bedrock" UserIDs []int64 `json:"user_ids,omitempty"` // 空=所有 Sub2API 用户;非空=仅指定 API Key 所属用户 diff --git a/backend/internal/service/upstream_models.go b/backend/internal/service/upstream_models.go index d3d88c4269..0916596657 100644 --- a/backend/internal/service/upstream_models.go +++ b/backend/internal/service/upstream_models.go @@ -24,18 +24,20 @@ const ( modelsDevRegistryTTL = 6 * time.Hour UpstreamModelMetadataExtraKey = "upstream_model_metadata" UpstreamModelMetadataIncompleteCode = "upstream_model_metadata_incomplete" + UpstreamModelMetadataPartialCode = "upstream_model_metadata_partial" ) type UpstreamModelMetadata struct { - ID string `json:"id"` - DisplayName string `json:"display_name,omitempty"` - Description string `json:"description,omitempty"` - Reasoning *bool `json:"reasoning,omitempty"` - DefaultReasoningLevel string `json:"default_reasoning_level,omitempty"` - SupportedReasoningLevels []string `json:"supported_reasoning_levels,omitempty"` - InputModalities []string `json:"input_modalities,omitempty"` - ContextWindow int64 `json:"context_window,omitempty"` - MaxOutputTokens int64 `json:"max_output_tokens,omitempty"` + ID string `json:"id"` + DisplayName string `json:"display_name,omitempty"` + Description string `json:"description,omitempty"` + Reasoning *bool `json:"reasoning,omitempty"` + DefaultReasoningLevel string `json:"default_reasoning_level,omitempty"` + SupportedReasoningLevels []string `json:"supported_reasoning_levels,omitempty"` + InputModalities []string `json:"input_modalities,omitempty"` + ContextWindow int64 `json:"context_window,omitempty"` + MaxOutputTokens int64 `json:"max_output_tokens,omitempty"` + CodexToolCapabilities map[string]json.RawMessage `json:"codex_tool_capabilities,omitempty"` } type UpstreamModelMetadataSnapshot struct { @@ -197,9 +199,16 @@ func (s *AccountTestService) FetchUpstreamSupportedModels(ctx context.Context, a // SyncUpstreamModelCatalog fetches the account's live model list, enriches // missing capability fields from the provider registry used by the upstream, -// and persists a normalized account snapshot when metadata is available. +// and persists a normalized account snapshot when complete metadata is available. +// +// Persistence is per-model: models with complete capability fields are saved even +// when other IDs in the same sync remain incomplete. An incomplete warning is +// still returned so admins can tell ID sync succeeded without a full capability +// snapshot. When no model is complete, the existing account snapshot is left +// untouched. func (s *AccountTestService) SyncUpstreamModelCatalog(ctx context.Context, account *Account) (*UpstreamModelCatalog, error) { models, body, err := s.fetchUpstreamModelList(ctx, account) + liveListAvailable := err == nil if err != nil { configuredModels := configuredUpstreamModelsForCapabilitySync(account) if !upstreamModelListEndpointUnsupported(err) || len(configuredModels) == 0 { @@ -222,10 +231,18 @@ func (s *AccountTestService) SyncUpstreamModelCatalog(ctx context.Context, accou } } + // Capability enrichment also covers concrete model_mapping targets. Admins may + // whitelist models that the live /models list omitted; those still need registry + // metadata so Codex catalogs can advertise reasoning and modalities. + enrichIDs := dedupeAndSortModelIDs(append(append([]string{}, models...), configuredUpstreamModelsForCapabilitySync(account)...)) + // Dedicated image/video generators are not Codex agent catalog entries and often + // omit context windows in public registries. Keep them out of completeness checks + // so they do not mask successful agent-model capability sync. + capabilityIDs := capabilitySyncModelIDs(enrichIDs) + source := "upstream" - metadataIncomplete := upstreamCatalogNeedsRegistry(models, catalog.Metadata) - if metadataIncomplete { - if registryMetadata, registryErr := s.fetchModelsDevMetadata(ctx, account, models); registryErr == nil { + if upstreamCatalogNeedsRegistry(capabilityIDs, catalog.Metadata) { + if registryMetadata, registryErr := s.fetchModelsDevMetadata(ctx, account, enrichIDs); registryErr == nil { for modelID, fallback := range registryMetadata { current := catalog.Metadata[modelID] merged, changed := mergeUpstreamModelMetadata(current, fallback) @@ -243,25 +260,59 @@ func (s *AccountTestService) SyncUpstreamModelCatalog(ctx context.Context, accou } } - if upstreamCatalogNeedsRegistry(models, catalog.Metadata) { - catalog.Warnings = append(catalog.Warnings, UpstreamModelSyncWarning{ - Code: UpstreamModelMetadataIncompleteCode, - Message: "Model IDs were synced, but capability metadata is incomplete.", - }) - return catalog, nil + completeMetadata := completeUpstreamModelMetadataSubset(capabilityIDs, catalog.Metadata) + persistedCapabilities := false + if len(completeMetadata) > 0 && account != nil && account.ID > 0 && s.accountRepo != nil { + // Retain known metadata only for models still listed or explicitly mapped. + if previous := account.GetUpstreamModelMetadataSnapshot(); previous != nil { + retainedModels := capabilityIDs + if !liveListAvailable { + retainedModels = append([]string(nil), capabilityIDs...) + for modelID := range previous.Models { + retainedModels = append(retainedModels, modelID) + } + } + for _, modelID := range retainedModels { + old, exists := previous.Models[modelID] + if !exists { + continue + } + if entry, ok := completeMetadata[modelID]; ok { + if entry.CodexToolCapabilities == nil { + entry.CodexToolCapabilities = make(map[string]json.RawMessage) + } + applyCodexToolCapabilities(entry.CodexToolCapabilities, old.CodexToolCapabilities, false) + completeMetadata[modelID] = entry + } else { + completeMetadata[modelID] = old + } + } + } + snapshot := UpstreamModelMetadataSnapshot{ + Source: source, + SyncedAt: time.Now().UTC().Format(time.RFC3339), + Models: completeMetadata, + } + if err := s.accountRepo.UpdateExtra(ctx, account.ID, map[string]any{UpstreamModelMetadataExtraKey: snapshot}); err != nil { + return nil, newUpstreamModelSyncInternalError("Failed to save upstream model metadata", err) + } + account.SetUpstreamModelMetadataSnapshot(snapshot) + persistedCapabilities = true } - if len(catalog.Metadata) == 0 || account == nil || account.ID <= 0 || s.accountRepo == nil { - return catalog, nil + + if upstreamCatalogNeedsRegistry(capabilityIDs, catalog.Metadata) { + if persistedCapabilities { + catalog.Warnings = append(catalog.Warnings, UpstreamModelSyncWarning{ + Code: UpstreamModelMetadataPartialCode, + Message: "Some model capabilities were saved; remaining models are still incomplete.", + }) + } else { + catalog.Warnings = append(catalog.Warnings, UpstreamModelSyncWarning{ + Code: UpstreamModelMetadataIncompleteCode, + Message: "Model IDs were synced, but capability metadata is incomplete.", + }) + } } - snapshot := UpstreamModelMetadataSnapshot{ - Source: source, - SyncedAt: time.Now().UTC().Format(time.RFC3339), - Models: catalog.Metadata, - } - if err := s.accountRepo.UpdateExtra(ctx, account.ID, map[string]any{UpstreamModelMetadataExtraKey: snapshot}); err != nil { - return nil, newUpstreamModelSyncInternalError("Failed to save upstream model metadata", err) - } - account.SetUpstreamModelMetadataSnapshot(snapshot) return catalog, nil } @@ -293,6 +344,18 @@ func configuredUpstreamModelsForCapabilitySync(account *Account) []string { return dedupeAndSortModelIDs(models) } +func capabilitySyncModelIDs(modelIDs []string) []string { + filtered := make([]string, 0, len(modelIDs)) + for _, modelID := range modelIDs { + modelID = strings.TrimSpace(modelID) + if modelID == "" || isCodexDedicatedMediaModel(modelID) { + continue + } + filtered = append(filtered, modelID) + } + return filtered +} + func upstreamModelSyncAccountID(account *Account) int64 { if account == nil { return 0 @@ -311,13 +374,7 @@ func upstreamCatalogNeedsRegistry(models []string, metadata map[string]UpstreamM for _, modelID := range models { modelID = strings.TrimSpace(modelID) model, ok := metadata[modelID] - if !ok || !upstreamModelMetadataIsUseful(model) { - return true - } - if model.Reasoning == nil || len(model.InputModalities) == 0 || model.ContextWindow <= 0 { - return true - } - if *model.Reasoning && len(model.SupportedReasoningLevels) == 0 { + if !ok || !upstreamModelMetadataIsComplete(model) { return true } } @@ -330,10 +387,57 @@ func upstreamModelMetadataIsUseful(metadata UpstreamModelMetadata) bool { metadata.Reasoning != nil || len(metadata.SupportedReasoningLevels) > 0 || len(metadata.InputModalities) > 0 || + len(metadata.CodexToolCapabilities) > 0 || metadata.ContextWindow > 0 || metadata.MaxOutputTokens > 0 } +// upstreamModelMetadataIsComplete reports whether a snapshot entry is safe to +// persist and later prefer over local Codex name-based fallbacks. +func upstreamModelMetadataIsComplete(metadata UpstreamModelMetadata) bool { + if metadata.Reasoning == nil { + return false + } + if len(normalizeCodexInputModalities(metadata.InputModalities)) == 0 { + return false + } + if metadata.ContextWindow <= 0 { + return false + } + if *metadata.Reasoning && len(normalizeReasoningLevels(metadata.SupportedReasoningLevels)) == 0 { + return false + } + return true +} + +func completeUpstreamModelMetadataSubset( + modelIDs []string, + metadata map[string]UpstreamModelMetadata, +) map[string]UpstreamModelMetadata { + if len(metadata) == 0 { + return nil + } + complete := make(map[string]UpstreamModelMetadata) + for _, modelID := range modelIDs { + modelID = strings.TrimSpace(modelID) + if modelID == "" { + continue + } + entry, ok := metadata[modelID] + if !ok || !upstreamModelMetadataIsComplete(entry) { + continue + } + if strings.TrimSpace(entry.ID) == "" { + entry.ID = modelID + } + complete[modelID] = entry + } + if len(complete) == 0 { + return nil + } + return complete +} + func mergeUpstreamModelMetadata(primary, fallback UpstreamModelMetadata) (UpstreamModelMetadata, bool) { merged := primary changed := false @@ -529,6 +633,13 @@ func upstreamModelRegistryBaseURL(account *Account) string { } func matchModelsDevProvider(registry map[string]modelsDevProvider, accountBaseURL string) (modelsDevProvider, bool) { + if provider, ok := matchModelsDevProviderByAPIURL(registry, accountBaseURL); ok { + return provider, true + } + return matchModelsDevProviderByKnownHost(registry, accountBaseURL) +} + +func matchModelsDevProviderByAPIURL(registry map[string]modelsDevProvider, accountBaseURL string) (modelsDevProvider, bool) { accountBaseURL = normalizeModelRegistryBaseURL(accountBaseURL) if accountBaseURL == "" { return modelsDevProvider{}, false @@ -553,6 +664,44 @@ func matchModelsDevProvider(registry map[string]modelsDevProvider, accountBaseUR return best, bestScore >= 0 } +// matchModelsDevProviderByKnownHost covers first-party hosts whose models.dev +// entries omit the `api` field (notably the official OpenAI provider). Custom +// compatible gateways must still match by API URL so same-named models are not +// cross-attributed across vendors. +func matchModelsDevProviderByKnownHost(registry map[string]modelsDevProvider, accountBaseURL string) (modelsDevProvider, bool) { + host := modelRegistryHostname(accountBaseURL) + if host == "" { + return modelsDevProvider{}, false + } + providerID := "" + switch host { + case "api.openai.com", "chatgpt.com": + providerID = "openai" + default: + return modelsDevProvider{}, false + } + provider, ok := registry[providerID] + if !ok || len(provider.Models) == 0 { + return modelsDevProvider{}, false + } + if strings.TrimSpace(provider.ID) == "" { + provider.ID = providerID + } + return provider, true +} + +func modelRegistryHostname(raw string) string { + normalized := normalizeModelRegistryBaseURL(raw) + if normalized == "" { + return "" + } + parsed, err := url.Parse(normalized) + if err != nil { + return "" + } + return strings.ToLower(strings.TrimSpace(parsed.Hostname())) +} + func normalizeModelRegistryBaseURL(raw string) string { parsed, err := url.Parse(strings.TrimSpace(raw)) if err != nil || parsed.Scheme == "" || parsed.Host == "" { @@ -842,6 +991,12 @@ func (s *AccountTestService) buildOpenAIUpstreamModelsRequest(ctx context.Contex if account.IsOpenAIOAuth() { return s.buildOpenAIOAuthUpstreamModelsRequest(ctx, account) } + return buildOpenAIAPIKeyModelsRequest(ctx, account, s.validateUpstreamBaseURL) +} + +// buildOpenAIAPIKeyModelsRequest is shared by admin discovery and public model +// listing. Codex content negotiation is intentionally absent from this request. +func buildOpenAIAPIKeyModelsRequest(ctx context.Context, account *Account, validateBaseURL func(string) (string, error)) (*http.Request, error) { if account.Type != AccountTypeAPIKey { return nil, newUpstreamModelSyncUnsupportedError( fmt.Sprintf("Unsupported OpenAI account type for upstream model sync: %s", account.Type), nil, @@ -858,7 +1013,7 @@ func (s *AccountTestService) buildOpenAIUpstreamModelsRequest(ctx context.Contex if strings.TrimSpace(baseURL) == "" { baseURL = "https://api.openai.com" } - normalizedBaseURL, err := s.validateUpstreamBaseURL(baseURL) + normalizedBaseURL, err := validateBaseURL(baseURL) if err != nil { return nil, newUpstreamModelSyncConfigError("Invalid OpenAI base URL", err) } @@ -1128,6 +1283,11 @@ func extractUpstreamModelCatalog(body []byte, grok bool) ([]string, map[string]U } models = append(models, modelID) entry := upstreamMetadataFromCapabilityEntry(modelID, capability) + var fields map[string]json.RawMessage + if err := json.Unmarshal(raw, &fields); err == nil { + entry.CodexToolCapabilities = make(map[string]json.RawMessage) + applyCodexToolCapabilities(entry.CodexToolCapabilities, fields, true) + } if upstreamModelMetadataIsUseful(entry) { metadata[modelID] = entry } diff --git a/backend/internal/service/upstream_models_test.go b/backend/internal/service/upstream_models_test.go index b358406b8b..e0e9f263ec 100644 --- a/backend/internal/service/upstream_models_test.go +++ b/backend/internal/service/upstream_models_test.go @@ -545,8 +545,12 @@ func TestSyncUpstreamModelCatalogUsesConfiguredModelsWhenListEndpointUnsupported }, } + account.SetUpstreamModelMetadataSnapshot(UpstreamModelMetadataSnapshot{Models: map[string]UpstreamModelMetadata{ + "old-live-model": {ID: "old-live-model", ContextWindow: 256000}, + }}) catalog, err := svc.SyncUpstreamModelCatalog(context.Background(), account) require.NoError(t, err) + require.Contains(t, account.GetUpstreamModelMetadataSnapshot().Models, "old-live-model", "an unavailable model-list endpoint is not evidence of removal") require.Equal(t, []string{"glm-5.3"}, catalog.Models) require.Empty(t, catalog.Warnings) require.Len(t, upstream.requests, 2) @@ -795,6 +799,71 @@ func TestSyncUpstreamModelCatalogDoesNotPersistPartialMetadataWhenRegistryFails( require.Nil(t, repo.updates, "partial metadata must not replace a more complete persisted snapshot") } +// Scenario: 图片专用模型缺少 context 时,不阻止 agent 模型能力落库,也不误报整批失败。 +func TestSyncUpstreamModelCatalogIgnoresDedicatedMediaModelsForCompleteness(t *testing.T) { + upstream := &httpUpstreamRecorder{responses: []*http.Response{ + { + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"object":"list","data":[ + {"id":"gpt-6-astra","object":"model"}, + {"id":"gpt-image-2","object":"model"} + ]}`)), + }, + { + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{ + "openai": { + "id": "openai", + "models": { + "gpt-6-astra": { + "id": "gpt-6-astra", + "name": "GPT-6 Astra", + "reasoning": true, + "reasoning_options": [{"type":"effort","values":["low","medium","high","xhigh","max"]}], + "modalities": {"input":["text","image"],"output":["text"]}, + "limit": {"context":1050000,"output":128000} + }, + "gpt-image-2": { + "id": "gpt-image-2", + "name": "gpt-image-2", + "reasoning": false, + "modalities": {"input":["text","image"],"output":["image"]}, + "limit": {"context":0,"output":0} + } + } + } + }`)), + }, + }} + repo := &upstreamModelMetadataRepoStub{} + svc := &AccountTestService{accountRepo: repo, httpUpstream: upstream, cfg: upstreamModelSyncTestConfig()} + + catalog, err := svc.SyncUpstreamModelCatalog(context.Background(), &Account{ + ID: 113, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, + Credentials: map[string]any{ + "api_key": "sk-test", + "base_url": "https://api.openai.com/v1", + "model_mapping": map[string]any{ + "gpt-6-astra": "gpt-6-astra", + "gpt-image-2": "gpt-image-2", + }, + }, + }) + require.NoError(t, err) + require.Empty(t, catalog.Warnings, "media generators must not keep agent capability sync in a failed state") + require.NotNil(t, repo.updates) + + encoded, err := json.Marshal(repo.updates[UpstreamModelMetadataExtraKey]) + require.NoError(t, err) + var snapshot UpstreamModelMetadataSnapshot + require.NoError(t, json.Unmarshal(encoded, &snapshot)) + require.Contains(t, snapshot.Models, "gpt-6-astra") + require.Equal(t, []string{"low", "medium", "high", "xhigh", "max"}, snapshot.Models["gpt-6-astra"].SupportedReasoningLevels) + require.NotContains(t, snapshot.Models, "gpt-image-2") +} + func TestFetchUpstreamSupportedModelsUsesConfiguredBodyLimit(t *testing.T) { t.Parallel() @@ -820,101 +889,299 @@ func TestFetchUpstreamSupportedModelsUsesConfiguredBodyLimit(t *testing.T) { require.Contains(t, err.Error(), "response exceeds 8 bytes") } -func TestFetchUpstreamSupportedModelsParsesGrokAPIKeyResponse(t *testing.T) { +func TestMatchModelsDevProviderFallsBackToOpenAIProviderWithoutAPIField(t *testing.T) { t.Parallel() - upstream := &httpUpstreamRecorder{resp: &http.Response{ - StatusCode: http.StatusOK, - Header: http.Header{"Content-Type": []string{"application/json"}}, - Body: io.NopCloser(strings.NewReader(`{"data":[{"id":"grok-4.5"},{"id":"grok-4.5"},{"id":"grok-imagine"}]}`)), - }} - svc := &AccountTestService{ - httpUpstream: upstream, - cfg: upstreamModelSyncTestConfig(), + registry := map[string]modelsDevProvider{ + "openai": { + ID: "openai", + Name: "OpenAI", + // Official models.dev entry currently omits `api`. + Models: map[string]modelsDevModel{ + "gpt-6-astra": {ID: "gpt-6-astra", Name: "GPT-6 Astra"}, + }, + }, + "opencode": { + ID: "opencode", + API: "https://opencode.ai/zen/v1", + Models: map[string]modelsDevModel{ + "x-preview-f-free": {ID: "x-preview-f-free", Name: "Ox"}, + }, + }, } - models, err := svc.FetchUpstreamSupportedModels(context.Background(), &Account{ - ID: 9, - Platform: PlatformGrok, - Type: AccountTypeAPIKey, + for _, baseURL := range []string{ + "https://api.openai.com", + "https://api.openai.com/v1", + "https://chatgpt.com/backend-api/codex", + } { + provider, ok := matchModelsDevProvider(registry, baseURL) + require.True(t, ok, baseURL) + require.Equal(t, "openai", provider.ID, baseURL) + require.Contains(t, provider.Models, "gpt-6-astra", baseURL) + } + + _, ok := matchModelsDevProvider(registry, "https://compatible.example/v1") + require.False(t, ok, "custom hosts must not inherit the official OpenAI provider by name") + + provider, ok := matchModelsDevProvider(registry, "https://opencode.ai/zen/v1") + require.True(t, ok) + require.Equal(t, "opencode", provider.ID) +} + +// Scenario: 官方 OpenAI ID-only /models + 无 api 字段的 models.dev openai 条目仍能补齐并落库。 +func TestSyncUpstreamModelCatalogEnrichesOfficialOpenAIHostWithoutRegistryAPIField(t *testing.T) { + upstream := &httpUpstreamRecorder{responses: []*http.Response{ + { + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"object":"list","data":[ + {"id":"gpt-6-astra","object":"model"}, + {"id":"gpt-5.6-sol","object":"model"} + ]}`)), + }, + { + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{ + "openai": { + "id": "openai", + "name": "OpenAI", + "models": { + "gpt-6-astra": { + "id": "gpt-6-astra", + "name": "GPT-6 Astra", + "description": "OpenAI GPT-6 Astra", + "reasoning": true, + "reasoning_options": [{"type":"effort","values":["low","medium","high","xhigh","max"]}], + "modalities": {"input":["text","image","pdf"],"output":["text"]}, + "limit": {"context":1050000,"output":128000} + }, + "gpt-5.6-sol": { + "id": "gpt-5.6-sol", + "name": "GPT-5.6 Sol", + "reasoning": true, + "reasoning_options": [{"type":"effort","values":["low","medium","high","xhigh","max"]}], + "modalities": {"input":["text","image"],"output":["text"]}, + "limit": {"context":1050000,"output":128000} + } + } + } + }`)), + }, + }} + repo := &upstreamModelMetadataRepoStub{} + svc := &AccountTestService{accountRepo: repo, httpUpstream: upstream, cfg: upstreamModelSyncTestConfig()} + + catalog, err := svc.SyncUpstreamModelCatalog(context.Background(), &Account{ + ID: 110, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Credentials: map[string]any{ - "api_key": "xai-key", - "base_url": "https://xai.example.com/v1", + "api_key": "sk-test", + "base_url": "https://api.openai.com/v1", }, }) require.NoError(t, err) - require.Equal(t, []string{"grok-4.5", "grok-imagine"}, models) - require.Equal(t, "https://xai.example.com/v1/models", upstream.lastReq.URL.String()) - require.Equal(t, "Bearer xai-key", upstream.lastReq.Header.Get("Authorization")) -} + require.Empty(t, catalog.Warnings) + require.Equal(t, []string{"gpt-5.6-sol", "gpt-6-astra"}, catalog.Models) -func TestFetchUpstreamSupportedModelsParsesGrokOAuthResponse(t *testing.T) { - t.Parallel() + astra := catalog.Metadata["gpt-6-astra"] + require.Equal(t, "GPT-6 Astra", astra.DisplayName) + require.NotNil(t, astra.Reasoning) + require.True(t, *astra.Reasoning) + require.Equal(t, []string{"low", "medium", "high", "xhigh", "max"}, astra.SupportedReasoningLevels) + require.Equal(t, []string{"text", "image"}, astra.InputModalities) + require.Equal(t, int64(1_050_000), astra.ContextWindow) - upstream := &httpUpstreamRecorder{resp: &http.Response{ - StatusCode: http.StatusOK, - Header: http.Header{"Content-Type": []string{"application/json"}}, - Body: io.NopCloser(strings.NewReader(`{"data":[{"model":"grok-4.5"},{"model":"grok-4.5"},{"modelId":"grok-build-0.1"}]}`)), - }} - svc := &AccountTestService{ - httpUpstream: upstream, - cfg: upstreamModelSyncTestConfig(), - grokTokenProvider: NewGrokTokenProvider(nil, nil), - } - - models, err := svc.FetchUpstreamSupportedModels(context.Background(), grokOAuthModelSyncTestAccount("")) + require.NotNil(t, repo.updates) + encoded, err := json.Marshal(repo.updates[UpstreamModelMetadataExtraKey]) require.NoError(t, err) - require.Equal(t, []string{"grok-4.5", "grok-build-0.1"}, models) - require.Equal(t, "https://cli-chat-proxy.grok.com/v1/models", upstream.lastReq.URL.String()) - require.Equal(t, "Bearer oauth-access-token", upstream.lastReq.Header.Get("Authorization")) - require.Equal(t, grokCLIVersion, upstream.lastReq.Header.Get("X-Grok-Client-Version")) - require.Equal(t, "interactive", upstream.lastReq.Header.Get("X-Grok-Client-Mode")) - require.Equal(t, "grok-user-id", upstream.lastReq.Header.Get("X-UserID")) - require.Equal(t, "grok-user@example.com", upstream.lastReq.Header.Get("X-Email")) + var snapshot UpstreamModelMetadataSnapshot + require.NoError(t, json.Unmarshal(encoded, &snapshot)) + require.Equal(t, "models.dev", snapshot.Source) + require.Equal(t, astra, snapshot.Models["gpt-6-astra"]) } -func TestBuildUpstreamModelsRequestGrokOAuthDoesNotSendIdentityToCustomBase(t *testing.T) { - t.Parallel() - - svc := &AccountTestService{ - cfg: upstreamModelSyncTestConfig(), - grokTokenProvider: NewGrokTokenProvider(nil, nil), - } - req, err := svc.buildUpstreamModelsRequest(context.Background(), grokOAuthModelSyncTestAccount("https://relay.example/v1")) - require.NoError(t, err) - require.Equal(t, "https://relay.example/v1/models", req.URL.String()) - require.Empty(t, req.Header.Get("X-UserID")) - require.Empty(t, req.Header.Get("X-Email")) -} - -func TestFetchUpstreamSupportedModelsDoesNotExposeUpstreamBody(t *testing.T) { - t.Parallel() - - upstream := &httpUpstreamRecorder{resp: &http.Response{ - StatusCode: http.StatusBadGateway, - Header: http.Header{"Content-Type": []string{"application/json"}}, - Body: io.NopCloser(strings.NewReader(`{"error":"SECRET_TOKEN should not be exposed"}`)), +// Scenario: 同一批同步里部分模型能力完整时仍落库完整条目,并对不完整条目告警。 +func TestSyncUpstreamModelCatalogPersistsCompleteModelsWhenSomeRemainIncomplete(t *testing.T) { + upstream := &httpUpstreamRecorder{responses: []*http.Response{ + { + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"models":[ + { + "id":"complete-model", + "display_name":"Complete", + "reasoning":true, + "supported_reasoning_levels":[{"effort":"low"},{"effort":"high"}], + "input_modalities":["text","image"], + "context_window":128000 + }, + { + "id":"incomplete-model", + "display_name":"Incomplete Only" + } + ]}`)), + }, + { + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{ + "provider": { + "id": "provider", + "api": "https://provider.example/v1", + "models": {} + } + }`)), + }, }} - svc := &AccountTestService{ - httpUpstream: upstream, - cfg: upstreamModelSyncTestConfig(), - } + repo := &upstreamModelMetadataRepoStub{} + svc := &AccountTestService{accountRepo: repo, httpUpstream: upstream, cfg: upstreamModelSyncTestConfig()} - _, err := svc.FetchUpstreamSupportedModels(context.Background(), &Account{ - ID: 8, - Platform: PlatformOpenAI, - Type: AccountTypeAPIKey, + catalog, err := svc.SyncUpstreamModelCatalog(context.Background(), &Account{ + ID: 111, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, + Credentials: map[string]any{"api_key": "key", "base_url": "https://provider.example/v1"}, + }) + require.NoError(t, err) + require.Equal(t, []string{"complete-model", "incomplete-model"}, catalog.Models) + require.Equal(t, UpstreamModelMetadataPartialCode, catalog.Warnings[0].Code) + require.NotNil(t, repo.updates) + + encoded, err := json.Marshal(repo.updates[UpstreamModelMetadataExtraKey]) + require.NoError(t, err) + var snapshot UpstreamModelMetadataSnapshot + require.NoError(t, json.Unmarshal(encoded, &snapshot)) + require.Contains(t, snapshot.Models, "complete-model") + require.NotContains(t, snapshot.Models, "incomplete-model") + require.Equal(t, []string{"low", "high"}, snapshot.Models["complete-model"].SupportedReasoningLevels) +} + +// Scenario: 上游清单未包含管理员 mapping 目标时,仍按 mapping 补齐并写入快照。 +func TestSyncUpstreamModelCatalogEnrichesConfiguredMappingModelsMissingFromUpstreamList(t *testing.T) { + upstream := &httpUpstreamRecorder{responses: []*http.Response{ + { + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"object":"list","data":[{"id":"gpt-5.6-sol","object":"model"}]}`)), + }, + { + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{ + "openai": { + "id": "openai", + "models": { + "gpt-5.6-sol": { + "id": "gpt-5.6-sol", + "name": "GPT-5.6 Sol", + "reasoning": true, + "reasoning_options": [{"type":"effort","values":["low","medium","high","xhigh","max"]}], + "modalities": {"input":["text","image"],"output":["text"]}, + "limit": {"context":1050000,"output":128000} + }, + "gpt-6-astra": { + "id": "gpt-6-astra", + "name": "GPT-6 Astra", + "reasoning": true, + "reasoning_options": [{"type":"effort","values":["low","medium","high","xhigh","max"]}], + "modalities": {"input":["text","image"],"output":["text"]}, + "limit": {"context":1050000,"output":128000} + } + } + } + }`)), + }, + }} + repo := &upstreamModelMetadataRepoStub{} + svc := &AccountTestService{accountRepo: repo, httpUpstream: upstream, cfg: upstreamModelSyncTestConfig()} + + catalog, err := svc.SyncUpstreamModelCatalog(context.Background(), &Account{ + ID: 112, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Credentials: map[string]any{ - "api_key": "openai-key", - "base_url": "https://openai.example.com/v1", + "api_key": "sk-test", + "base_url": "https://api.openai.com/v1", + "model_mapping": map[string]any{ + "gpt-6-astra": "gpt-6-astra", + }, }, }) - require.Error(t, err) - require.NotContains(t, err.Error(), "SECRET_TOKEN") + require.NoError(t, err) + require.Equal(t, []string{"gpt-5.6-sol"}, catalog.Models, "UI model list stays upstream-only") + require.Empty(t, catalog.Warnings) + require.Contains(t, catalog.Metadata, "gpt-6-astra") + require.Equal(t, []string{"low", "medium", "high", "xhigh", "max"}, catalog.Metadata["gpt-6-astra"].SupportedReasoningLevels) - var syncErr *UpstreamModelSyncError - require.True(t, errors.As(err, &syncErr)) - require.Equal(t, UpstreamModelSyncErrorUpstream, syncErr.Kind) - require.NotContains(t, syncErr.SafeMessage(), "SECRET_TOKEN") - require.Contains(t, syncErr.SafeMessage(), "HTTP 502") + encoded, err := json.Marshal(repo.updates[UpstreamModelMetadataExtraKey]) + require.NoError(t, err) + var snapshot UpstreamModelMetadataSnapshot + require.NoError(t, json.Unmarshal(encoded, &snapshot)) + require.Contains(t, snapshot.Models, "gpt-5.6-sol") + require.Contains(t, snapshot.Models, "gpt-6-astra") +} + +func TestSyncUpstreamModelCatalogAstraPartialRefreshPreservesKnownCapabilities(t *testing.T) { + upstream := &httpUpstreamRecorder{responses: []*http.Response{ + {StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(`{"data":[ + {"id":"gpt-6-astra","supports_search_tool":false,"apply_patch_tool_type":null}, + {"id":"still-listed"},{"id":"gpt-image-2"} + ]}`))}, + {StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(`{"openai":{"id":"openai","models":{ + "gpt-6-astra":{"reasoning":true,"reasoning_options":[{"type":"effort","values":["low","medium","high","xhigh","max"]}],"modalities":{"input":["text","image"]},"limit":{"context":1050000,"output":128000}}, + "mapped-only":{"reasoning":false,"modalities":{"input":["text"]},"limit":{"context":64000}}, + "gpt-image-2":{"reasoning":false,"modalities":{"input":["text","image"]},"limit":{"context":0}} + }}}`))}, + }} + repo := &upstreamModelMetadataRepoStub{} + svc := &AccountTestService{accountRepo: repo, httpUpstream: upstream, cfg: upstreamModelSyncTestConfig()} + account := &Account{ID: 114, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, + Credentials: map[string]any{"api_key": "test", "base_url": "https://api.openai.com/v1", + "model_mapping": map[string]any{"public-model": "mapped-only"}}, + } + old := UpstreamModelMetadata{ID: "still-listed", ContextWindow: 256000} + account.SetUpstreamModelMetadataSnapshot(UpstreamModelMetadataSnapshot{Models: map[string]UpstreamModelMetadata{ + "still-listed": old, "removed": {ID: "removed", ContextWindow: 128000}, + "gpt-6-astra": {ID: "gpt-6-astra", CodexToolCapabilities: map[string]json.RawMessage{ + "supports_search_tool": json.RawMessage("true"), "apply_patch_tool_type": json.RawMessage(`"freeform"`), + "comp_hash": json.RawMessage(`"3000"`), + }}, + }}) + + catalog, err := svc.SyncUpstreamModelCatalog(context.Background(), account) + require.NoError(t, err) + require.Equal(t, UpstreamModelMetadataPartialCode, catalog.Warnings[0].Code) + require.NotContains(t, catalog.Models, "mapped-only", "capability enrichment must not change discovery") + snapshot := account.GetUpstreamModelMetadataSnapshot() + require.Equal(t, old, snapshot.Models["still-listed"]) + require.NotContains(t, snapshot.Models, "removed") + require.NotContains(t, snapshot.Models, "gpt-image-2") + require.Contains(t, snapshot.Models, "mapped-only") + astra := snapshot.Models["gpt-6-astra"] + require.Equal(t, int64(1050000), astra.ContextWindow) + require.Equal(t, []string{"low", "medium", "high", "xhigh", "max"}, astra.SupportedReasoningLevels) + require.JSONEq(t, "false", string(astra.CodexToolCapabilities["supports_search_tool"])) + require.JSONEq(t, "null", string(astra.CodexToolCapabilities["apply_patch_tool_type"])) + require.JSONEq(t, `"3000"`, string(astra.CodexToolCapabilities["comp_hash"])) + require.NotNil(t, repo.updates) +} + +func TestMatchModelsDevProviderOfficialHostsWithoutAPI(t *testing.T) { + registry := map[string]modelsDevProvider{ + "openai": {ID: "openai", Models: map[string]modelsDevModel{"gpt-6-astra": {ID: "gpt-6-astra"}}}, + "relay": {ID: "relay", API: "https://relay.example/v1"}, + } + for _, baseURL := range []string{"https://api.openai.com/v1", "https://chatgpt.com/backend-api/codex"} { + provider, ok := matchModelsDevProvider(registry, baseURL) + require.True(t, ok) + require.Equal(t, "openai", provider.ID) + } + for _, baseURL := range []string{"https://api.openai.com.evil.example/v1", "https://unknown.example/v1"} { + _, ok := matchModelsDevProvider(registry, baseURL) + require.False(t, ok) + } + provider, ok := matchModelsDevProvider(registry, "https://relay.example/v1") + require.True(t, ok) + require.Equal(t, "relay", provider.ID) + metadata := map[string]UpstreamModelMetadata{"gpt-6-astra": { + Reasoning: new(bool), InputModalities: []string{"text"}, ContextWindow: 1050000, + }} + require.False(t, upstreamCatalogNeedsRegistry(capabilitySyncModelIDs([]string{"gpt-6-astra", "gpt-image-2"}), metadata)) } diff --git a/backend/internal/service/wire.go b/backend/internal/service/wire.go index 6e708de1d9..e7ff2dc4d3 100644 --- a/backend/internal/service/wire.go +++ b/backend/internal/service/wire.go @@ -267,6 +267,7 @@ func ProvideAccountTestService( tlsFPProfileService, ) service.agentIdentityWS = openAIGatewayService + service.SetOpenAIGatewayService(openAIGatewayService) service.SetSettingService(settingService) service.SetPluginManager(pluginManager) return service diff --git a/backend/internal/testutil/stubs.go b/backend/internal/testutil/stubs.go index 339d57d867..c9b37efb9f 100644 --- a/backend/internal/testutil/stubs.go +++ b/backend/internal/testutil/stubs.go @@ -139,6 +139,9 @@ func (c StubSessionLimitCache) RegisterSession(_ context.Context, _ int64, _ str func (c StubSessionLimitCache) RefreshSession(_ context.Context, _ int64, _ string, _ time.Duration) error { return nil } +func (c StubSessionLimitCache) UnregisterSession(_ context.Context, _ int64, _ string) error { + return nil +} func (c StubSessionLimitCache) GetActiveSessionCount(_ context.Context, _ int64) (int, error) { return 0, nil } diff --git a/backend/internal/util/urlvalidator/validator.go b/backend/internal/util/urlvalidator/validator.go index fc2b9bc4d7..882487b73b 100644 --- a/backend/internal/util/urlvalidator/validator.go +++ b/backend/internal/util/urlvalidator/validator.go @@ -162,6 +162,12 @@ func isAllowedHost(host string, allowlist []string) bool { return false } +// IsBlockedHost 报告 host 是否为 localhost、*.localhost,或回环、私网、链路本地、未指定地址的字面量 IP。 +// 只判断字面量,域名的解析结果由 ValidateResolvedIP 校验。 +func IsBlockedHost(host string) bool { + return isBlockedHost(strings.ToLower(strings.TrimSpace(host))) +} + func isBlockedHost(host string) bool { if host == "localhost" || strings.HasSuffix(host, ".localhost") { return true diff --git a/backend/internal/util/urlvalidator/validator_test.go b/backend/internal/util/urlvalidator/validator_test.go index bec9bb2110..5f401df170 100644 --- a/backend/internal/util/urlvalidator/validator_test.go +++ b/backend/internal/util/urlvalidator/validator_test.go @@ -73,3 +73,20 @@ func TestValidateHTTPURL(t *testing.T) { t.Fatalf("expected localhost to be blocked when allow_private_hosts is false") } } + +func TestIsBlockedHost(t *testing.T) { + for _, host := range []string{ + "localhost", "LOCALHOST", "foo.localhost", " 127.0.0.1 ", + "127.0.0.1", "::1", "10.0.0.5", "172.16.0.1", "192.168.1.1", + "169.254.169.254", "0.0.0.0", "::", "fe80::1", "fc00::1", "::ffff:127.0.0.1", + } { + if !IsBlockedHost(host) { + t.Fatalf("expected %q to be blocked", host) + } + } + for _, host := range []string{"example.com", "cdn.example.com", "93.184.216.34", "8.8.8.8", "2606:4700:4700::1111", ""} { + if IsBlockedHost(host) { + t.Fatalf("expected %q to be allowed", host) + } + } +} diff --git a/backend/migrations/234_channel_max_reasoning_effort_multiplier.sql b/backend/migrations/234_channel_max_reasoning_effort_multiplier.sql new file mode 100644 index 0000000000..07409f1162 --- /dev/null +++ b/backend/migrations/234_channel_max_reasoning_effort_multiplier.sql @@ -0,0 +1,11 @@ +ALTER TABLE channel_model_pricing + ADD COLUMN IF NOT EXISTS max_reasoning_effort_multiplier NUMERIC(10,4); + +COMMENT ON COLUMN channel_model_pricing.max_reasoning_effort_multiplier IS + 'Billing/quota multiplier applied when the forwarded reasoning effort is max'; + +ALTER TABLE channel_model_pricing + DROP CONSTRAINT IF EXISTS chk_channel_model_pricing_max_reasoning_effort_multiplier_positive; +ALTER TABLE channel_model_pricing + ADD CONSTRAINT chk_channel_model_pricing_max_reasoning_effort_multiplier_positive + CHECK (max_reasoning_effort_multiplier IS NULL OR max_reasoning_effort_multiplier > 0); diff --git a/frontend/package.json b/frontend/package.json index 8af7a4668b..ea14f61962 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -5,7 +5,8 @@ "type": "module", "scripts": { "dev": "vite", - "build": "vue-tsc -b && vite build", + "check:i18n": "vitest run src/i18n/__tests__/localeKeyCompleteness.spec.ts", + "build": "pnpm run check:i18n && vue-tsc -b && vite build", "preview": "vite preview", "lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix", "lint:check": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts", diff --git a/frontend/src/api/admin/accounts.ts b/frontend/src/api/admin/accounts.ts index 8de2ed4996..087cfaec41 100644 --- a/frontend/src/api/admin/accounts.ts +++ b/frontend/src/api/admin/accounts.ts @@ -6,6 +6,7 @@ import { apiClient } from '../client' import type { Account, + AccountListItem, CreateAccountRequest, UpdateAccountRequest, PaginatedResponse, @@ -53,8 +54,8 @@ export async function list( options?: { signal?: AbortSignal } -): Promise> { - const { data } = await apiClient.get>('/admin/accounts', { +): Promise> { + const { data } = await apiClient.get>('/admin/accounts', { params: { page, page_size: pageSize, @@ -68,7 +69,7 @@ export async function list( export interface AccountListWithEtagResult { notModified: boolean etag: string | null - data: PaginatedResponse | null + data: PaginatedResponse | null } export interface AccountUpstreamBillingRatesWithEtagResult { @@ -135,7 +136,7 @@ export async function listWithEtag( headers['If-None-Match'] = options.etag } - const response = await apiClient.get>('/admin/accounts', { + const response = await apiClient.get>('/admin/accounts', { params: { page, page_size: pageSize, diff --git a/frontend/src/api/admin/channels.ts b/frontend/src/api/admin/channels.ts index 3b355bc85f..d956bb92c7 100644 --- a/frontend/src/api/admin/channels.ts +++ b/frontend/src/api/admin/channels.ts @@ -50,6 +50,7 @@ export interface ChannelModelPricing { cache_read_price: number | null fast_multiplier?: number | null flex_multiplier?: number | null + max_reasoning_effort_multiplier?: number | null image_input_price: number | null image_output_price: number | null per_request_price: number | null @@ -179,6 +180,7 @@ export interface ModelDefaultPricing { cache_read_price?: number image_input_price?: number image_output_price?: number + max_reasoning_effort_multiplier?: number | null } export async function getModelDefaultPricing(model: string): Promise { diff --git a/frontend/src/api/admin/settings.ts b/frontend/src/api/admin/settings.ts index 29e1550c3b..3c8d608637 100644 --- a/frontend/src/api/admin/settings.ts +++ b/frontend/src/api/admin/settings.ts @@ -1429,7 +1429,7 @@ export async function updateRectifierSettings( * Matches backend dto.OpenAIFastPolicyRule. */ export interface OpenAIFastPolicyRule { - service_tier: "all" | "priority" | "flex"; + service_tier: "all" | "priority" | "flex" | "ultrafast"; action: "pass" | "filter" | "block" | "force_priority"; scope: "all" | "oauth" | "apikey" | "bedrock"; user_ids?: number[]; diff --git a/frontend/src/api/channels.ts b/frontend/src/api/channels.ts index 6a88cc5ed8..c998fc3efb 100644 --- a/frontend/src/api/channels.ts +++ b/frontend/src/api/channels.ts @@ -41,6 +41,7 @@ export interface UserSupportedModelPricing { cache_write_price: number | null cache_write_1h_price?: number | null cache_read_price: number | null + max_reasoning_effort_multiplier?: number | null image_input_price: number | null image_output_price: number | null per_request_price: number | null diff --git a/frontend/src/components/TurnstileWidget.vue b/frontend/src/components/TurnstileWidget.vue index 2f0022bfe6..e04bd0aa3f 100644 --- a/frontend/src/components/TurnstileWidget.vue +++ b/frontend/src/components/TurnstileWidget.vue @@ -1,11 +1,19 @@ - + + + {{ t('auth.captchaLoading') }} +