mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-20 00:18:55 +00:00
Compare commits
31
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
948b63c9ca | ||
|
|
4c80d160dd | ||
|
|
570ea74d12 | ||
|
|
2980ff3850 | ||
|
|
04c96a2015 | ||
|
|
07f980b99f | ||
|
|
fe21725865 | ||
|
|
d29acc29a5 | ||
|
|
66998918b6 | ||
|
|
da6194c1c3 | ||
|
|
132d446ca9 | ||
|
|
0eac363e67 | ||
|
|
796313e993 | ||
|
|
c772d18666 | ||
|
|
94df1fffc2 | ||
|
|
30967d5d9a | ||
|
|
dfdbc27709 | ||
|
|
beeb2f989b | ||
|
|
77d4df9544 | ||
|
|
7ceabb3fd5 | ||
|
|
d6467f6eb0 | ||
|
|
8ed9f754cf | ||
|
|
3deb2f17d8 | ||
|
|
baaae8e121 | ||
|
|
5c9629ddb7 | ||
|
|
4d13925c9e | ||
|
|
d74e669a23 | ||
|
|
7d3bf86e55 | ||
|
|
739c0ff9c5 | ||
|
|
7b6111f2f7 | ||
|
|
272735b0a7 |
@@ -1 +1 @@
|
||||
0.1.168
|
||||
0.1.169
|
||||
|
||||
@@ -46,10 +46,8 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
||||
return nil, err
|
||||
}
|
||||
userRepository := repository.NewUserRepository(client, db)
|
||||
passkeyRepository := repository.NewPasskeyRepository(db)
|
||||
redeemCodeRepository := repository.NewRedeemCodeRepository(client)
|
||||
redisClient := repository.ProvideRedis(configConfig)
|
||||
passkeySessionStore := repository.NewPasskeySessionStore(redisClient)
|
||||
refreshTokenCache := repository.NewRefreshTokenCache(redisClient)
|
||||
settingRepository := repository.NewSettingRepository(client)
|
||||
groupRepository := repository.NewGroupRepository(client, db)
|
||||
@@ -81,10 +79,6 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
||||
affiliateRepository := repository.NewAffiliateRepository(client, db)
|
||||
affiliateService := service.NewAffiliateService(affiliateRepository, settingService, apiKeyAuthCacheInvalidator, billingCacheService)
|
||||
authService := service.NewAuthService(client, userRepository, redeemCodeRepository, refreshTokenCache, configConfig, settingService, emailService, turnstileService, emailQueueService, promoService, subscriptionService, affiliateService, serviceUserPlatformQuotaRepository)
|
||||
passkeyService, err := service.NewPasskeyService(configConfig, passkeyRepository, passkeySessionStore, userRepository)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
userService := service.NewUserService(userRepository, settingRepository, apiKeyAuthCacheInvalidator, billingCache)
|
||||
redeemCache := repository.NewRedeemCache(redisClient)
|
||||
redeemService := service.NewRedeemService(redeemCodeRepository, userRepository, subscriptionService, redeemCache, billingCacheService, client, apiKeyAuthCacheInvalidator, affiliateService)
|
||||
@@ -98,7 +92,6 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
||||
userAttributeValueRepository := repository.NewUserAttributeValueRepository(client)
|
||||
userAttributeService := service.NewUserAttributeService(userAttributeDefinitionRepository, userAttributeValueRepository)
|
||||
authHandler := handler.NewAuthHandler(configConfig, authService, userService, settingService, promoService, redeemService, totpService, userAttributeService)
|
||||
passkeyHandler := handler.NewPasskeyHandler(passkeyService, authService, settingService)
|
||||
userHandler := handler.NewUserHandler(userService, authService, emailService, emailCache, affiliateService, serviceUserPlatformQuotaRepository)
|
||||
apiKeyHandler := handler.NewAPIKeyHandler(apiKeyService)
|
||||
usageLogRepository := repository.NewUsageLogRepository(client, db)
|
||||
@@ -260,7 +253,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
||||
channelMonitorRequestTemplateHandler := admin.NewChannelMonitorRequestTemplateHandler(channelMonitorRequestTemplateService)
|
||||
contentModerationRepository := repository.NewContentModerationRepository(db)
|
||||
contentModerationHashCache := repository.NewContentModerationHashCache(redisClient)
|
||||
contentModerationService := service.NewContentModerationService(settingRepository, contentModerationRepository, contentModerationHashCache, groupRepository, userRepository, apiKeyAuthCacheInvalidator, emailService)
|
||||
contentModerationService := service.NewContentModerationService(settingRepository, contentModerationRepository, contentModerationHashCache, groupRepository, userRepository, proxyRepository, apiKeyAuthCacheInvalidator, emailService)
|
||||
contentModerationHandler := admin.NewContentModerationHandler(contentModerationService)
|
||||
configManager := securityaudit.NewConfigManager(db, settingRepository, redisClient, secretEncryptor, configConfig)
|
||||
postgreSQLRepository := securityaudit.NewPostgreSQLRepository(db)
|
||||
@@ -287,6 +280,13 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
||||
openAIGatewayHandler := handler.ProvideOpenAIGatewayHandler(openAIGatewayService, concurrencyService, billingCacheService, apiKeyService, usageRecordWorkerPool, errorPassthroughService, contentModerationService, opsService, grokQuotaService, configConfig, coordinator)
|
||||
handlerSettingHandler := handler.ProvideSettingHandler(settingService, buildInfo, notificationEmailService)
|
||||
totpHandler := handler.NewTotpHandler(totpService)
|
||||
passkeyRepository := repository.NewPasskeyRepository(db)
|
||||
passkeySessionStore := repository.NewPasskeySessionStore(redisClient)
|
||||
passkeyService, err := service.NewPasskeyService(configConfig, passkeyRepository, passkeySessionStore, userRepository)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
passkeyHandler := handler.NewPasskeyHandler(passkeyService, authService, settingService)
|
||||
handlerPaymentHandler := handler.NewPaymentHandler(paymentService, paymentConfigService)
|
||||
paymentWebhookHandler := handler.NewPaymentWebhookHandler(paymentService, registry)
|
||||
availableChannelHandler := handler.NewAvailableChannelHandler(channelService, apiKeyService, settingService)
|
||||
|
||||
@@ -20,10 +20,12 @@ func NewContentModerationHandler(svc *service.ContentModerationService) *Content
|
||||
}
|
||||
|
||||
type contentModerationConfigRequest struct {
|
||||
Enabled *bool `json:"enabled"`
|
||||
Mode *string `json:"mode"`
|
||||
BaseURL *string `json:"base_url"`
|
||||
Model *string `json:"model"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
Mode *string `json:"mode"`
|
||||
BaseURL *string `json:"base_url"`
|
||||
Model *string `json:"model"`
|
||||
// 审计请求使用的代理服务器:null 不修改;0 清除(直连);>0 指定代理。
|
||||
ProxyID *int64 `json:"proxy_id"`
|
||||
APIKey *string `json:"api_key"`
|
||||
APIKeys *[]string `json:"api_keys"`
|
||||
APIKeysMode string `json:"api_keys_mode"`
|
||||
@@ -60,6 +62,7 @@ type contentModerationAPIKeyTestRequest struct {
|
||||
BaseURL string `json:"base_url"`
|
||||
Model string `json:"model"`
|
||||
TimeoutMS int `json:"timeout_ms"`
|
||||
ProxyID *int64 `json:"proxy_id"`
|
||||
Prompt string `json:"prompt"`
|
||||
Images []string `json:"images"`
|
||||
}
|
||||
@@ -88,6 +91,7 @@ func (h *ContentModerationHandler) UpdateConfig(c *gin.Context) {
|
||||
Mode: req.Mode,
|
||||
BaseURL: req.BaseURL,
|
||||
Model: req.Model,
|
||||
ProxyID: req.ProxyID,
|
||||
APIKey: req.APIKey,
|
||||
APIKeys: req.APIKeys,
|
||||
APIKeysMode: req.APIKeysMode,
|
||||
@@ -134,6 +138,7 @@ func (h *ContentModerationHandler) TestAPIKeys(c *gin.Context) {
|
||||
BaseURL: req.BaseURL,
|
||||
Model: req.Model,
|
||||
TimeoutMS: req.TimeoutMS,
|
||||
ProxyID: req.ProxyID,
|
||||
Prompt: req.Prompt,
|
||||
Images: req.Images,
|
||||
})
|
||||
|
||||
@@ -233,6 +233,7 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
|
||||
ContactInfo: settings.ContactInfo,
|
||||
DocURL: settings.DocURL,
|
||||
HomeContent: settings.HomeContent,
|
||||
CompactHomeEnabled: settings.CompactHomeEnabled,
|
||||
HideCcsImportButton: settings.HideCcsImportButton,
|
||||
PurchaseSubscriptionEnabled: settings.PurchaseSubscriptionEnabled,
|
||||
PurchaseSubscriptionURL: settings.PurchaseSubscriptionURL,
|
||||
|
||||
@@ -299,6 +299,9 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings,
|
||||
if before.HomeContent != after.HomeContent {
|
||||
changed = append(changed, "home_content")
|
||||
}
|
||||
if before.CompactHomeEnabled != after.CompactHomeEnabled {
|
||||
changed = append(changed, "compact_home_enabled")
|
||||
}
|
||||
if before.HideCcsImportButton != after.HideCcsImportButton {
|
||||
changed = append(changed, "hide_ccs_import_button")
|
||||
}
|
||||
|
||||
@@ -64,6 +64,18 @@ func TestDiffSettings_NoChangeWhenEqual(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiffSettings_DetectsCompactHomeChange(t *testing.T) {
|
||||
changed := diffSettings(
|
||||
&service.SystemSettings{},
|
||||
&service.SystemSettings{CompactHomeEnabled: true},
|
||||
nil,
|
||||
nil,
|
||||
UpdateSettingsRequest{},
|
||||
)
|
||||
|
||||
require.Contains(t, changed, service.SettingKeyCompactHomeEnabled)
|
||||
}
|
||||
|
||||
func TestEqualNullableFloat(t *testing.T) {
|
||||
five := 5.0
|
||||
five2 := 5.0
|
||||
|
||||
@@ -142,6 +142,7 @@ type UpdateSettingsRequest struct {
|
||||
ContactInfo string `json:"contact_info"`
|
||||
DocURL string `json:"doc_url"`
|
||||
HomeContent string `json:"home_content"`
|
||||
CompactHomeEnabled bool `json:"compact_home_enabled"`
|
||||
HideCcsImportButton bool `json:"hide_ccs_import_button"`
|
||||
PurchaseSubscriptionEnabled *bool `json:"purchase_subscription_enabled"`
|
||||
PurchaseSubscriptionURL *string `json:"purchase_subscription_url"`
|
||||
@@ -1430,6 +1431,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
||||
ContactInfo: req.ContactInfo,
|
||||
DocURL: req.DocURL,
|
||||
HomeContent: req.HomeContent,
|
||||
CompactHomeEnabled: req.CompactHomeEnabled,
|
||||
HideCcsImportButton: req.HideCcsImportButton,
|
||||
PurchaseSubscriptionEnabled: purchaseEnabled,
|
||||
PurchaseSubscriptionURL: purchaseURL,
|
||||
@@ -1982,6 +1984,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
||||
ContactInfo: updatedSettings.ContactInfo,
|
||||
DocURL: updatedSettings.DocURL,
|
||||
HomeContent: updatedSettings.HomeContent,
|
||||
CompactHomeEnabled: updatedSettings.CompactHomeEnabled,
|
||||
HideCcsImportButton: updatedSettings.HideCcsImportButton,
|
||||
PurchaseSubscriptionEnabled: updatedSettings.PurchaseSubscriptionEnabled,
|
||||
PurchaseSubscriptionURL: updatedSettings.PurchaseSubscriptionURL,
|
||||
|
||||
@@ -142,6 +142,7 @@ type SystemSettings struct {
|
||||
ContactInfo string `json:"contact_info"`
|
||||
DocURL string `json:"doc_url"`
|
||||
HomeContent string `json:"home_content"`
|
||||
CompactHomeEnabled bool `json:"compact_home_enabled"`
|
||||
HideCcsImportButton bool `json:"hide_ccs_import_button"`
|
||||
PurchaseSubscriptionEnabled bool `json:"purchase_subscription_enabled"`
|
||||
PurchaseSubscriptionURL string `json:"purchase_subscription_url"`
|
||||
@@ -344,6 +345,7 @@ type PublicSettings struct {
|
||||
ContactInfo string `json:"contact_info"`
|
||||
DocURL string `json:"doc_url"`
|
||||
HomeContent string `json:"home_content"`
|
||||
CompactHomeEnabled bool `json:"compact_home_enabled"`
|
||||
HideCcsImportButton bool `json:"hide_ccs_import_button"`
|
||||
PurchaseSubscriptionEnabled bool `json:"purchase_subscription_enabled"`
|
||||
PurchaseSubscriptionURL string `json:"purchase_subscription_url"`
|
||||
|
||||
@@ -1266,6 +1266,7 @@ func TestOpenAIResponsesWebSocket_ContentModerationBlocksFirstFrame(t *testing.T
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
decision, err := moderationSvc.Check(context.Background(), service.ContentModerationCheckInput{
|
||||
UserID: 1,
|
||||
|
||||
@@ -67,6 +67,7 @@ func (h *SettingHandler) GetPublicSettings(c *gin.Context) {
|
||||
ContactInfo: settings.ContactInfo,
|
||||
DocURL: settings.DocURL,
|
||||
HomeContent: settings.HomeContent,
|
||||
CompactHomeEnabled: settings.CompactHomeEnabled,
|
||||
HideCcsImportButton: settings.HideCcsImportButton,
|
||||
PurchaseSubscriptionEnabled: settings.PurchaseSubscriptionEnabled,
|
||||
PurchaseSubscriptionURL: settings.PurchaseSubscriptionURL,
|
||||
|
||||
@@ -358,12 +358,18 @@ func (r *userSubscriptionRepository) UpdateNotes(ctx context.Context, subscripti
|
||||
|
||||
func (r *userSubscriptionRepository) ActivateWindows(ctx context.Context, id int64, start time.Time) error {
|
||||
client := clientFromContext(ctx, r.client)
|
||||
_, err := client.UserSubscription.UpdateOneID(id).
|
||||
n, err := client.UserSubscription.Update().
|
||||
Where(
|
||||
usersubscription.IDEQ(id),
|
||||
usersubscription.DailyWindowStartIsNil(),
|
||||
usersubscription.WeeklyWindowStartIsNil(),
|
||||
usersubscription.MonthlyWindowStartIsNil(),
|
||||
).
|
||||
SetDailyWindowStart(start).
|
||||
SetWeeklyWindowStart(start).
|
||||
SetMonthlyWindowStart(start).
|
||||
Save(ctx)
|
||||
return translatePersistenceError(err, service.ErrSubscriptionNotFound, nil)
|
||||
return r.translateConditionalWindowReset(ctx, client, id, n, err)
|
||||
}
|
||||
|
||||
func (r *userSubscriptionRepository) ResetUsageWindows(ctx context.Context, id int64, resetDaily, resetWeekly, resetMonthly bool, newWindowStart time.Time) error {
|
||||
|
||||
@@ -463,6 +463,25 @@ func (s *UserSubscriptionRepoSuite) TestActivateWindows() {
|
||||
s.Require().WithinDuration(activateAt, *got.DailyWindowStart, time.Microsecond)
|
||||
}
|
||||
|
||||
func (s *UserSubscriptionRepoSuite) TestActivateWindows_StaleActivationPreservesExistingWindows() {
|
||||
user := s.mustCreateUser("activate-cas@test.com", service.RoleUser)
|
||||
group := s.mustCreateGroup("g-activate-cas")
|
||||
sub := s.mustCreateSubscription(user.ID, group.ID, nil)
|
||||
activatedAt := time.Date(2025, 1, 1, 12, 0, 0, 0, time.UTC)
|
||||
manualResetAt := activatedAt.Add(2 * time.Hour)
|
||||
|
||||
s.Require().NoError(s.repo.ActivateWindows(s.ctx, sub.ID, activatedAt))
|
||||
s.Require().NoError(s.repo.ResetUsageWindows(s.ctx, sub.ID, true, true, true, manualResetAt))
|
||||
// Simulate a concurrent request carrying the original unactivated snapshot.
|
||||
s.Require().NoError(s.repo.ActivateWindows(s.ctx, sub.ID, activatedAt.Add(time.Hour)))
|
||||
|
||||
got, err := s.repo.GetByID(s.ctx, sub.ID)
|
||||
s.Require().NoError(err)
|
||||
s.Require().WithinDuration(manualResetAt, *got.DailyWindowStart, time.Microsecond)
|
||||
s.Require().WithinDuration(manualResetAt, *got.WeeklyWindowStart, time.Microsecond)
|
||||
s.Require().WithinDuration(manualResetAt, *got.MonthlyWindowStart, time.Microsecond)
|
||||
}
|
||||
|
||||
func (s *UserSubscriptionRepoSuite) TestResetDailyUsage() {
|
||||
user := s.mustCreateUser("resetd@test.com", service.RoleUser)
|
||||
group := s.mustCreateGroup("g-resetd")
|
||||
|
||||
@@ -64,20 +64,21 @@ type StorageEndpoint struct {
|
||||
}
|
||||
|
||||
type storageConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
BlockingEnabled bool `json:"blocking_enabled"`
|
||||
StorePassEvents bool `json:"store_pass_events"`
|
||||
Strategy string `json:"strategy"`
|
||||
WorkerCount int `json:"worker_count"`
|
||||
QueueCapacity int `json:"queue_capacity"`
|
||||
Scanners []string `json:"scanners"`
|
||||
AllGroups bool `json:"all_groups"`
|
||||
GroupIDs []int64 `json:"group_ids"`
|
||||
Endpoints []StorageEndpoint `json:"endpoints"`
|
||||
ConfigVersion int64 `json:"config_version"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
UpdatedBy int64 `json:"updated_by"`
|
||||
ChangeSummary string `json:"change_summary"`
|
||||
Enabled bool `json:"enabled"`
|
||||
BlockingEnabled bool `json:"blocking_enabled"`
|
||||
BlockingLatestTurnOnly bool `json:"blocking_latest_turn_only"`
|
||||
StorePassEvents bool `json:"store_pass_events"`
|
||||
Strategy string `json:"strategy"`
|
||||
WorkerCount int `json:"worker_count"`
|
||||
QueueCapacity int `json:"queue_capacity"`
|
||||
Scanners []string `json:"scanners"`
|
||||
AllGroups bool `json:"all_groups"`
|
||||
GroupIDs []int64 `json:"group_ids"`
|
||||
Endpoints []StorageEndpoint `json:"endpoints"`
|
||||
ConfigVersion int64 `json:"config_version"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
UpdatedBy int64 `json:"updated_by"`
|
||||
ChangeSummary string `json:"change_summary"`
|
||||
}
|
||||
|
||||
type ActiveEndpoint struct {
|
||||
@@ -98,21 +99,22 @@ type ActiveEndpoint struct {
|
||||
}
|
||||
|
||||
type ActiveConfig struct {
|
||||
RiskControlEnabled bool
|
||||
Enabled bool
|
||||
BlockingEnabled bool
|
||||
StorePassEvents bool
|
||||
Strategy string
|
||||
WorkerCount int
|
||||
QueueCapacity int
|
||||
Scanners []string
|
||||
AllGroups bool
|
||||
GroupIDs []int64
|
||||
Endpoints []ActiveEndpoint
|
||||
ConfigVersion int64
|
||||
UpdatedAt time.Time
|
||||
UpdatedBy int64
|
||||
ChangeSummary string
|
||||
RiskControlEnabled bool
|
||||
Enabled bool
|
||||
BlockingEnabled bool
|
||||
BlockingLatestTurnOnly bool
|
||||
StorePassEvents bool
|
||||
Strategy string
|
||||
WorkerCount int
|
||||
QueueCapacity int
|
||||
Scanners []string
|
||||
AllGroups bool
|
||||
GroupIDs []int64
|
||||
Endpoints []ActiveEndpoint
|
||||
ConfigVersion int64
|
||||
UpdatedAt time.Time
|
||||
UpdatedBy int64
|
||||
ChangeSummary string
|
||||
}
|
||||
|
||||
type PublicEndpoint struct {
|
||||
@@ -129,21 +131,22 @@ type PublicEndpoint struct {
|
||||
}
|
||||
|
||||
type PublicConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
BlockingEnabled bool `json:"blocking_enabled"`
|
||||
StorePassEvents bool `json:"store_pass_events"`
|
||||
EffectiveMode Mode `json:"effective_mode"`
|
||||
Strategy string `json:"strategy"`
|
||||
WorkerCount int `json:"worker_count"`
|
||||
QueueCapacity int `json:"queue_capacity"`
|
||||
Scanners []string `json:"scanners"`
|
||||
AllGroups bool `json:"all_groups"`
|
||||
GroupIDs []int64 `json:"group_ids"`
|
||||
Endpoints []PublicEndpoint `json:"endpoints"`
|
||||
ConfigVersion int64 `json:"config_version"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
UpdatedBy int64 `json:"updated_by"`
|
||||
ChangeSummary string `json:"change_summary"`
|
||||
Enabled bool `json:"enabled"`
|
||||
BlockingEnabled bool `json:"blocking_enabled"`
|
||||
BlockingLatestTurnOnly bool `json:"blocking_latest_turn_only"`
|
||||
StorePassEvents bool `json:"store_pass_events"`
|
||||
EffectiveMode Mode `json:"effective_mode"`
|
||||
Strategy string `json:"strategy"`
|
||||
WorkerCount int `json:"worker_count"`
|
||||
QueueCapacity int `json:"queue_capacity"`
|
||||
Scanners []string `json:"scanners"`
|
||||
AllGroups bool `json:"all_groups"`
|
||||
GroupIDs []int64 `json:"group_ids"`
|
||||
Endpoints []PublicEndpoint `json:"endpoints"`
|
||||
ConfigVersion int64 `json:"config_version"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
UpdatedBy int64 `json:"updated_by"`
|
||||
ChangeSummary string `json:"change_summary"`
|
||||
}
|
||||
|
||||
type UpdateEndpoint struct {
|
||||
@@ -160,32 +163,34 @@ type UpdateEndpoint struct {
|
||||
}
|
||||
|
||||
type UpdateConfigRequest struct {
|
||||
ExpectedConfigVersion int64 `json:"expected_config_version" binding:"required"`
|
||||
Enabled bool `json:"enabled"`
|
||||
BlockingEnabled bool `json:"blocking_enabled"`
|
||||
StorePassEvents bool `json:"store_pass_events"`
|
||||
Strategy string `json:"strategy"`
|
||||
WorkerCount int `json:"worker_count"`
|
||||
QueueCapacity int `json:"queue_capacity"`
|
||||
Scanners []string `json:"scanners"`
|
||||
AllGroups bool `json:"all_groups"`
|
||||
GroupIDs []int64 `json:"group_ids"`
|
||||
Endpoints []UpdateEndpoint `json:"endpoints"`
|
||||
ExpectedConfigVersion int64 `json:"expected_config_version" binding:"required"`
|
||||
Enabled bool `json:"enabled"`
|
||||
BlockingEnabled bool `json:"blocking_enabled"`
|
||||
BlockingLatestTurnOnly bool `json:"blocking_latest_turn_only"`
|
||||
StorePassEvents bool `json:"store_pass_events"`
|
||||
Strategy string `json:"strategy"`
|
||||
WorkerCount int `json:"worker_count"`
|
||||
QueueCapacity int `json:"queue_capacity"`
|
||||
Scanners []string `json:"scanners"`
|
||||
AllGroups bool `json:"all_groups"`
|
||||
GroupIDs []int64 `json:"group_ids"`
|
||||
Endpoints []UpdateEndpoint `json:"endpoints"`
|
||||
}
|
||||
|
||||
func DefaultStorageConfig() storageConfig {
|
||||
return storageConfig{
|
||||
Enabled: false,
|
||||
BlockingEnabled: false,
|
||||
StorePassEvents: false,
|
||||
Strategy: "priority",
|
||||
WorkerCount: DefaultWorkerCount,
|
||||
QueueCapacity: DefaultQueueCapacity,
|
||||
Scanners: append([]string(nil), AllScannerIDs...),
|
||||
AllGroups: true,
|
||||
GroupIDs: []int64{},
|
||||
Endpoints: []StorageEndpoint{},
|
||||
ConfigVersion: 1,
|
||||
Enabled: false,
|
||||
BlockingEnabled: false,
|
||||
BlockingLatestTurnOnly: false,
|
||||
StorePassEvents: false,
|
||||
Strategy: "priority",
|
||||
WorkerCount: DefaultWorkerCount,
|
||||
QueueCapacity: DefaultQueueCapacity,
|
||||
Scanners: append([]string(nil), AllScannerIDs...),
|
||||
AllGroups: true,
|
||||
GroupIDs: []int64{},
|
||||
Endpoints: []StorageEndpoint{},
|
||||
ConfigVersion: 1,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -407,7 +412,7 @@ func PublicFromStorage(cfg storageConfig, riskControlEnabled bool, invalidTokenE
|
||||
}
|
||||
active := ActiveConfig{RiskControlEnabled: riskControlEnabled, Enabled: cfg.Enabled, BlockingEnabled: cfg.BlockingEnabled}
|
||||
return PublicConfig{
|
||||
Enabled: cfg.Enabled, BlockingEnabled: cfg.BlockingEnabled, StorePassEvents: cfg.StorePassEvents,
|
||||
Enabled: cfg.Enabled, BlockingEnabled: cfg.BlockingEnabled, BlockingLatestTurnOnly: cfg.BlockingLatestTurnOnly, StorePassEvents: cfg.StorePassEvents,
|
||||
EffectiveMode: active.EffectiveMode(), Strategy: cfg.Strategy, WorkerCount: cfg.WorkerCount,
|
||||
QueueCapacity: cfg.QueueCapacity, Scanners: scanners, AllGroups: cfg.AllGroups,
|
||||
GroupIDs: groupIDs, Endpoints: endpoints, ConfigVersion: cfg.ConfigVersion,
|
||||
@@ -418,7 +423,8 @@ func PublicFromStorage(cfg storageConfig, riskControlEnabled bool, invalidTokenE
|
||||
func ActiveFromStorage(cfg storageConfig, riskControlEnabled bool, encryptor SecretEncryptor) (ActiveConfig, error) {
|
||||
active := ActiveConfig{
|
||||
RiskControlEnabled: riskControlEnabled, Enabled: cfg.Enabled, BlockingEnabled: cfg.BlockingEnabled,
|
||||
StorePassEvents: cfg.StorePassEvents, Strategy: cfg.Strategy, WorkerCount: cfg.WorkerCount,
|
||||
BlockingLatestTurnOnly: cfg.BlockingLatestTurnOnly,
|
||||
StorePassEvents: cfg.StorePassEvents, Strategy: cfg.Strategy, WorkerCount: cfg.WorkerCount,
|
||||
QueueCapacity: cfg.QueueCapacity, Scanners: append([]string(nil), cfg.Scanners...), AllGroups: cfg.AllGroups,
|
||||
GroupIDs: append([]int64(nil), cfg.GroupIDs...), ConfigVersion: cfg.ConfigVersion,
|
||||
UpdatedAt: cfg.UpdatedAt, UpdatedBy: cfg.UpdatedBy, ChangeSummary: cfg.ChangeSummary,
|
||||
@@ -454,15 +460,16 @@ func ActiveFromStorage(cfg storageConfig, riskControlEnabled bool, encryptor Sec
|
||||
|
||||
func changeSummary(cfg storageConfig) string {
|
||||
summary := struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
BlockingEnabled bool `json:"blocking_enabled"`
|
||||
StorePassEvents bool `json:"store_pass_events"`
|
||||
EndpointCount int `json:"endpoint_count"`
|
||||
ScannerCount int `json:"scanner_count"`
|
||||
AllGroups bool `json:"all_groups"`
|
||||
GroupCount int `json:"group_count"`
|
||||
GroupHash string `json:"group_hash"`
|
||||
}{cfg.Enabled, cfg.BlockingEnabled, cfg.StorePassEvents, len(cfg.Endpoints), len(cfg.Scanners), cfg.AllGroups, len(cfg.GroupIDs), ""}
|
||||
Enabled bool `json:"enabled"`
|
||||
BlockingEnabled bool `json:"blocking_enabled"`
|
||||
BlockingLatestTurnOnly bool `json:"blocking_latest_turn_only"`
|
||||
StorePassEvents bool `json:"store_pass_events"`
|
||||
EndpointCount int `json:"endpoint_count"`
|
||||
ScannerCount int `json:"scanner_count"`
|
||||
AllGroups bool `json:"all_groups"`
|
||||
GroupCount int `json:"group_count"`
|
||||
GroupHash string `json:"group_hash"`
|
||||
}{cfg.Enabled, cfg.BlockingEnabled, cfg.BlockingLatestTurnOnly, cfg.StorePassEvents, len(cfg.Endpoints), len(cfg.Scanners), cfg.AllGroups, len(cfg.GroupIDs), ""}
|
||||
rawGroups, _ := json.Marshal(cfg.GroupIDs)
|
||||
digest := sha256.Sum256(rawGroups)
|
||||
summary.GroupHash = hex.EncodeToString(digest[:])
|
||||
|
||||
@@ -335,7 +335,7 @@ func (m *ConfigManager) buildNextStorage(current storageConfig, req UpdateConfig
|
||||
currentByID[endpoint.ID] = endpoint
|
||||
}
|
||||
next := storageConfig{
|
||||
Enabled: req.Enabled, BlockingEnabled: req.BlockingEnabled, StorePassEvents: req.StorePassEvents,
|
||||
Enabled: req.Enabled, BlockingEnabled: req.BlockingEnabled, BlockingLatestTurnOnly: req.BlockingLatestTurnOnly, StorePassEvents: req.StorePassEvents,
|
||||
Strategy: strings.TrimSpace(req.Strategy), WorkerCount: req.WorkerCount,
|
||||
QueueCapacity: req.QueueCapacity, Scanners: append([]string(nil), req.Scanners...),
|
||||
AllGroups: req.AllGroups, GroupIDs: append([]int64(nil), req.GroupIDs...),
|
||||
|
||||
@@ -32,6 +32,7 @@ func TestDefaultConfigIsOff(t *testing.T) {
|
||||
storage, err := ParseStorageConfig("")
|
||||
require.NoError(t, err)
|
||||
require.False(t, storage.Enabled)
|
||||
require.False(t, storage.BlockingLatestTurnOnly)
|
||||
active, err := ActiveFromStorage(storage, true, prefixEncryptor{})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, ModeOff, active.EffectiveMode())
|
||||
@@ -42,6 +43,28 @@ func TestDefaultConfigIsOff(t *testing.T) {
|
||||
require.Contains(t, string(publicJSON), `"endpoints":[]`)
|
||||
}
|
||||
|
||||
func TestBlockingLatestTurnOnlyConfigRoundTrip(t *testing.T) {
|
||||
manager := &ConfigManager{encryptor: prefixEncryptor{}, encryptionKeyConfigured: true}
|
||||
request := UpdateConfigRequest{
|
||||
ExpectedConfigVersion: 1, Enabled: true, BlockingEnabled: true, BlockingLatestTurnOnly: true,
|
||||
Strategy: "priority", WorkerCount: 1, QueueCapacity: 10, Scanners: []string{"pii"}, AllGroups: true,
|
||||
Endpoints: []UpdateEndpoint{{
|
||||
ID: "guard-1", Name: "Guard", Protocol: "openai_compatible", BaseURL: "http://127.0.0.1:8080",
|
||||
Model: DefaultGuardModel, TimeoutMS: 1000, InputLimit: 1000, Enabled: true,
|
||||
}},
|
||||
}
|
||||
next, err := manager.buildNextStorage(DefaultStorageConfig(), request, 9)
|
||||
require.NoError(t, err)
|
||||
require.True(t, next.BlockingLatestTurnOnly)
|
||||
require.Contains(t, changeSummary(next), `"blocking_latest_turn_only":true`)
|
||||
|
||||
active, err := ActiveFromStorage(next, true, prefixEncryptor{})
|
||||
require.NoError(t, err)
|
||||
require.True(t, active.BlockingLatestTurnOnly)
|
||||
public := PublicFromStorage(next, true, nil)
|
||||
require.True(t, public.BlockingLatestTurnOnly)
|
||||
}
|
||||
|
||||
func TestConfigRejectsBlockingWithoutAudit(t *testing.T) {
|
||||
storage := DefaultStorageConfig()
|
||||
storage.BlockingEnabled = true
|
||||
|
||||
@@ -228,7 +228,8 @@ func configAuditFields(request UpdateConfigRequest, saved *PublicConfig) map[str
|
||||
}
|
||||
return map[string]any{
|
||||
"enabled": request.Enabled, "blocking_enabled": request.BlockingEnabled,
|
||||
"config_version": version, "endpoint_count": len(request.Endpoints),
|
||||
"blocking_latest_turn_only": request.BlockingLatestTurnOnly,
|
||||
"config_version": version, "endpoint_count": len(request.Endpoints),
|
||||
"scanner_count": len(request.Scanners), "all_groups": request.AllGroups,
|
||||
"group_count": len(request.GroupIDs),
|
||||
}
|
||||
|
||||
@@ -156,7 +156,7 @@ func (s *PromptService) Evaluate(ctx context.Context, req Request) (*PromptDecis
|
||||
if cfg.EffectiveMode() != ModeBlocking || !cfg.IncludesGroup(req.GroupID) {
|
||||
return &PromptDecision{Kind: DecisionAllow, AllowNextStage: true}, nil
|
||||
}
|
||||
snapshot, err := ExtractPromptSnapshot(req)
|
||||
snapshot, err := ExtractBlockingPromptSnapshot(req, cfg.BlockingLatestTurnOnly)
|
||||
if errors.Is(err, ErrNoPromptText) {
|
||||
return &PromptDecision{Kind: DecisionAllow, AllowNextStage: true}, nil
|
||||
}
|
||||
|
||||
@@ -68,6 +68,25 @@ func TestPromptServiceStartReportsDependencyFailureWithoutPanic(t *testing.T) {
|
||||
require.NoError(t, service.Shutdown(ctx))
|
||||
}
|
||||
|
||||
func TestPromptServiceBlockingLatestTurnOnlyUsesNarrowSnapshot(t *testing.T) {
|
||||
seen := make([]string, 0, 2)
|
||||
evaluator := newGuardEvaluator(PromptScannerFunc(func(_ context.Context, _ ActiveEndpoint, chunk string, _ []string) (*NormalizedResult, error) {
|
||||
seen = append(seen, chunk)
|
||||
return &NormalizedResult{Decision: EventPass, RiskLevel: RiskLow, Action: ActionAllow, ScannerScores: map[string]float64{}, ScannerEvidence: map[string]string{}}, nil
|
||||
}), nil, NewAtomicMetrics(), 2, 2)
|
||||
service := &PromptService{
|
||||
config: &fakeConfigStore{active: true, cfg: ActiveConfig{
|
||||
RiskControlEnabled: true, Enabled: true, BlockingEnabled: true, BlockingLatestTurnOnly: true, AllGroups: true,
|
||||
Scanners: AllScannerIDs, Endpoints: []ActiveEndpoint{{ID: "guard-1", Enabled: true, TimeoutMS: 1000, InputLimit: 4096}},
|
||||
}},
|
||||
evaluator: evaluator,
|
||||
}
|
||||
decision, err := service.Evaluate(context.Background(), Request{Protocol: "openai_chat_completions", Body: []byte(`{"messages":[{"role":"system","content":"system instruction"},{"role":"user","content":"older user input"},{"role":"assistant","content":"previous output"},{"role":"user","content":"latest user input"}]}`)})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, DecisionAllow, decision.Kind)
|
||||
require.Equal(t, []string{"latest user input", "previous output"}, seen)
|
||||
}
|
||||
|
||||
func TestPromptServiceRejectsInvalidDeleteConfirmationClaims(t *testing.T) {
|
||||
now := time.Date(2026, 7, 16, 10, 0, 0, 0, time.UTC)
|
||||
start, end := now.Add(-time.Hour), now.Add(time.Hour)
|
||||
|
||||
@@ -26,15 +26,30 @@ const promptAuditPrioritySeparator = "\x00SUB2API_PROMPT_AUDIT_PRIORITY_END\x00"
|
||||
type promptSegment struct {
|
||||
text string
|
||||
user bool
|
||||
role string
|
||||
}
|
||||
|
||||
func ExtractPromptSnapshot(req Request) (PromptSnapshot, error) {
|
||||
return extractPromptSnapshot(req, false)
|
||||
}
|
||||
|
||||
// ExtractBlockingPromptSnapshot builds the narrow, low-latency blocking input
|
||||
// when configured. Asynchronous auditing always uses ExtractPromptSnapshot so
|
||||
// the complete client-controlled transcript is retained for review.
|
||||
func ExtractBlockingPromptSnapshot(req Request, latestTurnOnly bool) (PromptSnapshot, error) {
|
||||
return extractPromptSnapshot(req, latestTurnOnly)
|
||||
}
|
||||
|
||||
func extractPromptSnapshot(req Request, latestTurnOnly bool) (PromptSnapshot, error) {
|
||||
var document any
|
||||
if err := json.Unmarshal(req.Body, &document); err != nil {
|
||||
return PromptSnapshot{}, errors.New("prompt audit request JSON is invalid")
|
||||
}
|
||||
extracted := extractProtocolSegments(req.Protocol, document)
|
||||
segments := normalizeSegmentsLatestUserFirst(extracted)
|
||||
if latestTurnOnly {
|
||||
segments = blockingSegmentsLatestUserAndPreviousOutput(extracted)
|
||||
}
|
||||
if len(segments) == 0 {
|
||||
return PromptSnapshot{}, ErrNoPromptText
|
||||
}
|
||||
@@ -138,7 +153,7 @@ func extractMessages(value any, wantedRoles ...string) []promptSegment {
|
||||
}
|
||||
texts := contentTexts(message["content"])
|
||||
for _, text := range texts {
|
||||
result = append(result, promptSegment{text: text, user: role == "user"})
|
||||
result = append(result, promptSegment{text: text, user: role == "user", role: role})
|
||||
}
|
||||
}
|
||||
return result
|
||||
@@ -148,7 +163,7 @@ func extractInstructions(value any) []promptSegment {
|
||||
switch typed := value.(type) {
|
||||
case string:
|
||||
if text := strings.TrimSpace(typed); text != "" {
|
||||
return []promptSegment{{text: text}}
|
||||
return []promptSegment{{text: text, role: "system"}}
|
||||
}
|
||||
case []any:
|
||||
return systemPromptSegments(contentTexts(typed))
|
||||
@@ -162,7 +177,7 @@ func extractAnthropicSystem(value any) []promptSegment {
|
||||
switch typed := value.(type) {
|
||||
case string:
|
||||
if text := strings.TrimSpace(typed); text != "" {
|
||||
return []promptSegment{{text: text}}
|
||||
return []promptSegment{{text: text, role: "system"}}
|
||||
}
|
||||
case []any:
|
||||
return systemPromptSegments(contentTexts(typed))
|
||||
@@ -175,13 +190,13 @@ func extractAnthropicSystem(value any) []promptSegment {
|
||||
func extractResponses(value any) []promptSegment {
|
||||
switch typed := value.(type) {
|
||||
case string:
|
||||
return []promptSegment{{text: typed, user: true}}
|
||||
return []promptSegment{{text: typed, user: true, role: "user"}}
|
||||
case []any:
|
||||
result := make([]promptSegment, 0, len(typed))
|
||||
for _, item := range typed {
|
||||
switch entry := item.(type) {
|
||||
case string:
|
||||
result = append(result, promptSegment{text: entry, user: true})
|
||||
result = append(result, promptSegment{text: entry, user: true, role: "user"})
|
||||
case map[string]any:
|
||||
role := strings.ToLower(stringValue(entry["role"]))
|
||||
if role != "" && !isClientInstructionRole(role) {
|
||||
@@ -189,10 +204,10 @@ func extractResponses(value any) []promptSegment {
|
||||
}
|
||||
if content, exists := entry["content"]; exists {
|
||||
for _, text := range contentTexts(content) {
|
||||
result = append(result, promptSegment{text: text, user: role == "" || role == "user"})
|
||||
result = append(result, promptSegment{text: text, user: role == "" || role == "user", role: role})
|
||||
}
|
||||
} else if text := stringValue(entry["text"]); text != "" {
|
||||
result = append(result, promptSegment{text: text, user: role == "" || role == "user"})
|
||||
result = append(result, promptSegment{text: text, user: role == "" || role == "user", role: role})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -241,7 +256,7 @@ func extractGemini(value any) []promptSegment {
|
||||
for _, part := range parts {
|
||||
if object, ok := part.(map[string]any); ok {
|
||||
if text := stringValue(object["text"]); text != "" {
|
||||
result = append(result, promptSegment{text: text, user: role == "" || role == "user"})
|
||||
result = append(result, promptSegment{text: text, user: role == "" || role == "user", role: role})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -278,7 +293,7 @@ func extractGeminiSystemInstruction(value any) []promptSegment {
|
||||
switch typed := value.(type) {
|
||||
case string:
|
||||
if text := strings.TrimSpace(typed); text != "" {
|
||||
return []promptSegment{{text: text}}
|
||||
return []promptSegment{{text: text, role: "system"}}
|
||||
}
|
||||
case map[string]any:
|
||||
if parts, ok := typed["parts"].([]any); ok {
|
||||
@@ -286,7 +301,7 @@ func extractGeminiSystemInstruction(value any) []promptSegment {
|
||||
for _, part := range parts {
|
||||
if object, ok := part.(map[string]any); ok {
|
||||
if text := stringValue(object["text"]); text != "" {
|
||||
result = append(result, promptSegment{text: text})
|
||||
result = append(result, promptSegment{text: text, role: "system"})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -297,6 +312,7 @@ func extractGeminiSystemInstruction(value any) []promptSegment {
|
||||
segments := extractGemini(typed)
|
||||
for index := range segments {
|
||||
segments[index].user = false
|
||||
segments[index].role = "system"
|
||||
}
|
||||
return segments
|
||||
}
|
||||
@@ -312,7 +328,7 @@ func extractGeminiInstances(value any) []promptSegment {
|
||||
for _, item := range instances {
|
||||
if instance, ok := item.(map[string]any); ok {
|
||||
if prompt := stringValue(instance["prompt"]); prompt != "" {
|
||||
result = append(result, promptSegment{text: prompt, user: true})
|
||||
result = append(result, promptSegment{text: prompt, user: true, role: "user"})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -420,19 +436,13 @@ func contentTexts(value any) []string {
|
||||
}
|
||||
|
||||
func normalizeSegmentsLatestUserFirst(values []promptSegment) []string {
|
||||
normalized := make([]promptSegment, 0, len(values))
|
||||
for _, value := range values {
|
||||
value.text = strings.TrimSpace(value.text)
|
||||
if value.text != "" {
|
||||
normalized = append(normalized, value)
|
||||
}
|
||||
}
|
||||
normalized := normalizedPromptSegments(values)
|
||||
if len(normalized) == 0 {
|
||||
return nil
|
||||
}
|
||||
priorityIndex := len(normalized) - 1
|
||||
for index := len(normalized) - 1; index >= 0; index-- {
|
||||
if normalized[index].user {
|
||||
if isUserSegment(normalized[index]) {
|
||||
priorityIndex = index
|
||||
break
|
||||
}
|
||||
@@ -447,6 +457,85 @@ func normalizeSegmentsLatestUserFirst(values []promptSegment) []string {
|
||||
return result
|
||||
}
|
||||
|
||||
// blockingSegmentsLatestUserAndPreviousOutput limits synchronous guard input to
|
||||
// the current user turn and the nearest preceding assistant/model turn. It is
|
||||
// deliberately opt-in because full transcript scanning remains stronger at
|
||||
// finding client-controlled content placed in older or non-user messages.
|
||||
func blockingSegmentsLatestUserAndPreviousOutput(values []promptSegment) []string {
|
||||
normalized := normalizedPromptSegments(values)
|
||||
latestUserStart := latestUserSegmentStart(normalized)
|
||||
if latestUserStart < 0 {
|
||||
// A request without user content cannot be narrowed safely. Preserve the
|
||||
// established full-snapshot behavior for unusual protocol payloads.
|
||||
return normalizeSegmentsLatestUserFirst(values)
|
||||
}
|
||||
latestUserEnd := latestUserStart
|
||||
for latestUserEnd < len(normalized) && isUserSegment(normalized[latestUserEnd]) {
|
||||
latestUserEnd++
|
||||
}
|
||||
currentUserText := make([]string, 0, latestUserEnd-latestUserStart)
|
||||
for _, segment := range normalized[latestUserStart:latestUserEnd] {
|
||||
currentUserText = append(currentUserText, segment.text)
|
||||
}
|
||||
// A single client turn may have several text content parts. Keep it in one
|
||||
// priority segment so every part of the latest input is scanned before the
|
||||
// prior output begins.
|
||||
selected := []promptSegment{{text: strings.Join(currentUserText, "\n\n"), user: true, role: "user"}}
|
||||
for index := latestUserStart - 1; index >= 0; index-- {
|
||||
if !isAssistantOutputSegment(normalized[index]) {
|
||||
continue
|
||||
}
|
||||
start := index
|
||||
for start > 0 && isAssistantOutputSegment(normalized[start-1]) {
|
||||
start--
|
||||
}
|
||||
selected = append(selected, normalized[start:index+1]...)
|
||||
break
|
||||
}
|
||||
return promptSegmentTexts(selected)
|
||||
}
|
||||
|
||||
func normalizedPromptSegments(values []promptSegment) []promptSegment {
|
||||
normalized := make([]promptSegment, 0, len(values))
|
||||
for _, value := range values {
|
||||
value.text = strings.TrimSpace(value.text)
|
||||
if value.text != "" {
|
||||
normalized = append(normalized, value)
|
||||
}
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
func latestUserSegmentStart(values []promptSegment) int {
|
||||
latest := -1
|
||||
for index := len(values) - 1; index >= 0; index-- {
|
||||
if isUserSegment(values[index]) {
|
||||
latest = index
|
||||
break
|
||||
}
|
||||
}
|
||||
for latest > 0 && isUserSegment(values[latest-1]) {
|
||||
latest--
|
||||
}
|
||||
return latest
|
||||
}
|
||||
|
||||
func isUserSegment(segment promptSegment) bool {
|
||||
return segment.user || segment.role == "user"
|
||||
}
|
||||
|
||||
func isAssistantOutputSegment(segment promptSegment) bool {
|
||||
return segment.role == "assistant" || segment.role == "model"
|
||||
}
|
||||
|
||||
func promptSegmentTexts(values []promptSegment) []string {
|
||||
result := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
result = append(result, value.text)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func buildPrioritizedScanText(segments []string) (scanText string, metadataText string) {
|
||||
metadataText = strings.Join(segments, "\n\n")
|
||||
if len(segments) <= 1 {
|
||||
@@ -458,7 +547,7 @@ func buildPrioritizedScanText(segments []string) (scanText string, metadataText
|
||||
func promptSegmentsForRole(texts []string, role string) []promptSegment {
|
||||
result := make([]promptSegment, 0, len(texts))
|
||||
for _, text := range texts {
|
||||
result = append(result, promptSegment{text: text, user: role == "" || role == "user"})
|
||||
result = append(result, promptSegment{text: text, user: role == "" || role == "user", role: role})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -293,6 +293,65 @@ func TestPromptSnapshotIncludesClientControlledInstructions(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlockingPromptSnapshotLimitsInputToLatestUserAndPreviousOutput(t *testing.T) {
|
||||
tests := []struct {
|
||||
name, protocol, body, want string
|
||||
omitted []string
|
||||
}{
|
||||
{
|
||||
name: "chat keeps multipart latest user and prior assistant",
|
||||
protocol: "openai_chat_completions",
|
||||
body: `{"messages":[
|
||||
{"role":"system","content":"system instruction"},
|
||||
{"role":"user","content":"older user input"},
|
||||
{"role":"assistant","content":"older assistant output"},
|
||||
{"role":"tool","content":"tool payload"},
|
||||
{"role":"assistant","content":"previous assistant output"},
|
||||
{"role":"user","content":[{"type":"text","text":"latest user first part"},{"type":"text","text":"latest user second part"}]}
|
||||
]}`,
|
||||
want: "latest user first part\n\nlatest user second part" + promptAuditPrioritySeparator + "previous assistant output",
|
||||
omitted: []string{"system instruction", "older user input", "older assistant output", "tool payload"},
|
||||
},
|
||||
{
|
||||
name: "gemini keeps prior model output",
|
||||
protocol: "gemini",
|
||||
body: `{"systemInstruction":{"parts":[{"text":"system instruction"}]},"contents":[
|
||||
{"role":"user","parts":[{"text":"older user input"}]},
|
||||
{"role":"model","parts":[{"text":"previous model output"}]},
|
||||
{"role":"user","parts":[{"text":"latest user input"}]}
|
||||
]}`,
|
||||
want: "latest user input" + promptAuditPrioritySeparator + "previous model output",
|
||||
omitted: []string{"system instruction", "older user input"},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
snapshot, err := ExtractBlockingPromptSnapshot(Request{Protocol: tt.protocol, Body: []byte(tt.body)}, true)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tt.want, snapshot.ScanText)
|
||||
for _, omitted := range tt.omitted {
|
||||
require.NotContains(t, snapshot.ScanText, omitted)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlockingPromptSnapshotPreservesFullScopeByDefaultAndWithoutUserInput(t *testing.T) {
|
||||
req := Request{Protocol: "openai_chat_completions", Body: []byte(`{"messages":[{"role":"system","content":"system instruction"},{"role":"user","content":"older user input"},{"role":"assistant","content":"previous output"},{"role":"user","content":"latest user input"}]}`)}
|
||||
full, err := ExtractPromptSnapshot(req)
|
||||
require.NoError(t, err)
|
||||
defaultBlocking, err := ExtractBlockingPromptSnapshot(req, false)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, full, defaultBlocking)
|
||||
|
||||
noUser := Request{Protocol: "openai_chat_completions", Body: []byte(`{"messages":[{"role":"system","content":"system instruction"},{"role":"assistant","content":"assistant output"}]}`)}
|
||||
fullWithoutUser, err := ExtractPromptSnapshot(noUser)
|
||||
require.NoError(t, err)
|
||||
narrowWithoutUser, err := ExtractBlockingPromptSnapshot(noUser, true)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, fullWithoutUser, narrowWithoutUser)
|
||||
}
|
||||
|
||||
func TestBuildPromptPreviewWithholdsMajorityOfOrdinaryText(t *testing.T) {
|
||||
prompt := strings.Repeat("机密业务提示词内容", 40)
|
||||
preview := BuildPromptPreview(prompt, DefaultPromptPreviewMaxRunes)
|
||||
|
||||
@@ -874,6 +874,7 @@ func TestAPIContracts(t *testing.T) {
|
||||
"max_codex_version": "",
|
||||
"codex_cli_only_blacklist": "",
|
||||
"codex_cli_only_whitelist": "",
|
||||
"compact_home_enabled": false,
|
||||
"codex_cli_only_allow_app_server_clients": false,
|
||||
"codex_cli_only_engine_fingerprint_signals": "[{\"type\":\"header_prefix\",\"match\":[\"x-codex-\"],\"required\":true},{\"type\":\"header_exact\",\"match\":[\"session-id\",\"session_id\"],\"required\":false},{\"type\":\"header_exact\",\"match\":[\"thread-id\",\"thread_id\"],\"required\":false},{\"type\":\"body_path\",\"match\":[\"client_metadata.x-codex-window-id\",\"client_metadata.x-codex-installation-id\"],\"required\":false}]",
|
||||
"allow_ungrouped_key_scheduling": false,
|
||||
@@ -1175,6 +1176,7 @@ func TestAPIContracts(t *testing.T) {
|
||||
"max_codex_version": "",
|
||||
"codex_cli_only_blacklist": "",
|
||||
"codex_cli_only_whitelist": "",
|
||||
"compact_home_enabled": false,
|
||||
"codex_cli_only_allow_app_server_clients": false,
|
||||
"codex_cli_only_engine_fingerprint_signals": "[{\"type\":\"header_prefix\",\"match\":[\"x-codex-\"],\"required\":true},{\"type\":\"header_exact\",\"match\":[\"session-id\",\"session_id\"],\"required\":false},{\"type\":\"header_exact\",\"match\":[\"thread-id\",\"thread_id\"],\"required\":false},{\"type\":\"body_path\",\"match\":[\"client_metadata.x-codex-window-id\",\"client_metadata.x-codex-installation-id\"],\"required\":false}]",
|
||||
"web_search_emulation_enabled": false,
|
||||
|
||||
@@ -1844,6 +1844,22 @@ func (a *Account) IsOpenAIWSForceHTTPEnabled() bool {
|
||||
return ok && enabled
|
||||
}
|
||||
|
||||
// IsOpenAIResponsesFlattenNamespacesEnabled 返回账号级"摊平 Codex namespace 工具"开关。
|
||||
// 字段:accounts.extra.openai_responses_flatten_namespaces,缺省 false(原样保留)。
|
||||
//
|
||||
// namespace 是 Codex 后端定义的私有扩展,OAuth 出口恒为 chatgpt.com/backend-api/codex
|
||||
// (buildUpstreamRequest 只对 API Key 账号取 base_url),即定义方本身,因此默认保留。
|
||||
// 该开关只为把流量转发到不认识 namespace 的兼容上游的部署保留退路:打开后恢复
|
||||
// 0.1.166 及更早版本的摊平行为。仅对 OpenAI OAuth 账号有效——API Key 走 chat
|
||||
// completions 回退桥时由桥自行摊平,Grok/Anthropic 出口有各自的适配链路。
|
||||
func (a *Account) IsOpenAIResponsesFlattenNamespacesEnabled() bool {
|
||||
if a == nil || !a.IsOpenAI() || a.Extra == nil {
|
||||
return false
|
||||
}
|
||||
enabled, ok := a.Extra["openai_responses_flatten_namespaces"].(bool)
|
||||
return ok && enabled
|
||||
}
|
||||
|
||||
// IsOpenAIWSAllowStoreRecoveryEnabled 返回账号级 store 恢复开关。
|
||||
// 字段:accounts.extra.openai_ws_allow_store_recovery。
|
||||
func (a *Account) IsOpenAIWSAllowStoreRecoveryEnabled() bool {
|
||||
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
"time"
|
||||
|
||||
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/httpclient"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/servertiming"
|
||||
)
|
||||
@@ -138,10 +139,12 @@ func ContentModerationCategories() []string {
|
||||
}
|
||||
|
||||
type ContentModerationConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Mode string `json:"mode"`
|
||||
BaseURL string `json:"base_url"`
|
||||
Model string `json:"model"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Mode string `json:"mode"`
|
||||
BaseURL string `json:"base_url"`
|
||||
Model string `json:"model"`
|
||||
// ProxyID 指定审计请求使用的代理服务器(IP管理-代理服务器),nil 表示直连。
|
||||
ProxyID *int64 `json:"proxy_id,omitempty"`
|
||||
APIKey string `json:"api_key,omitempty"`
|
||||
APIKeys []string `json:"api_keys,omitempty"`
|
||||
TimeoutMS int `json:"timeout_ms"`
|
||||
@@ -176,6 +179,7 @@ type ContentModerationConfigView struct {
|
||||
Mode string `json:"mode"`
|
||||
BaseURL string `json:"base_url"`
|
||||
Model string `json:"model"`
|
||||
ProxyID *int64 `json:"proxy_id"`
|
||||
APIKeyConfigured bool `json:"api_key_configured"`
|
||||
APIKeyMasked string `json:"api_key_masked"`
|
||||
APIKeyCount int `json:"api_key_count"`
|
||||
@@ -240,8 +244,10 @@ type TestContentModerationAPIKeysInput struct {
|
||||
BaseURL string `json:"base_url"`
|
||||
Model string `json:"model"`
|
||||
TimeoutMS int `json:"timeout_ms"`
|
||||
Prompt string `json:"prompt"`
|
||||
Images []string `json:"images"`
|
||||
// ProxyID nil 表示沿用已保存配置的代理;<=0 表示强制直连测试;>0 表示指定代理测试。
|
||||
ProxyID *int64 `json:"proxy_id"`
|
||||
Prompt string `json:"prompt"`
|
||||
Images []string `json:"images"`
|
||||
}
|
||||
|
||||
type TestContentModerationAPIKeysResult struct {
|
||||
@@ -260,10 +266,12 @@ type ContentModerationTestAuditResult struct {
|
||||
}
|
||||
|
||||
type UpdateContentModerationConfigInput struct {
|
||||
Enabled *bool `json:"enabled"`
|
||||
Mode *string `json:"mode"`
|
||||
BaseURL *string `json:"base_url"`
|
||||
Model *string `json:"model"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
Mode *string `json:"mode"`
|
||||
BaseURL *string `json:"base_url"`
|
||||
Model *string `json:"model"`
|
||||
// ProxyID nil 表示不修改;<=0 表示清除代理(恢复直连);>0 表示指定代理。
|
||||
ProxyID *int64 `json:"proxy_id"`
|
||||
APIKey *string `json:"api_key"`
|
||||
APIKeys *[]string `json:"api_keys"`
|
||||
APIKeysMode string `json:"api_keys_mode"`
|
||||
@@ -495,9 +503,11 @@ type ContentModerationService struct {
|
||||
hashCache ContentModerationHashCache
|
||||
groupRepo GroupRepository
|
||||
userRepo UserRepository
|
||||
proxyRepo ProxyRepository
|
||||
authCacheInvalidator APIKeyAuthCacheInvalidator
|
||||
emailService *EmailService
|
||||
httpClient *http.Client
|
||||
moderationProxyCache atomic.Pointer[moderationProxyURLCacheEntry]
|
||||
asyncQueue chan contentModerationTask
|
||||
workerCount int
|
||||
apiKeyCursor atomic.Uint64
|
||||
@@ -566,6 +576,7 @@ func NewContentModerationService(
|
||||
hashCache ContentModerationHashCache,
|
||||
groupRepo GroupRepository,
|
||||
userRepo UserRepository,
|
||||
proxyRepo ProxyRepository,
|
||||
authCacheInvalidator APIKeyAuthCacheInvalidator,
|
||||
emailService *EmailService,
|
||||
) *ContentModerationService {
|
||||
@@ -575,6 +586,7 @@ func NewContentModerationService(
|
||||
hashCache: hashCache,
|
||||
groupRepo: groupRepo,
|
||||
userRepo: userRepo,
|
||||
proxyRepo: proxyRepo,
|
||||
authCacheInvalidator: authCacheInvalidator,
|
||||
emailService: emailService,
|
||||
httpClient: servertiming.InstrumentClient(nil),
|
||||
@@ -616,6 +628,14 @@ func (s *ContentModerationService) UpdateConfig(ctx context.Context, input Updat
|
||||
if input.Model != nil {
|
||||
cfg.Model = strings.TrimSpace(*input.Model)
|
||||
}
|
||||
if input.ProxyID != nil {
|
||||
if *input.ProxyID > 0 {
|
||||
id := *input.ProxyID
|
||||
cfg.ProxyID = &id
|
||||
} else {
|
||||
cfg.ProxyID = nil
|
||||
}
|
||||
}
|
||||
if input.TimeoutMS != nil {
|
||||
cfg.TimeoutMS = *input.TimeoutMS
|
||||
}
|
||||
@@ -716,6 +736,8 @@ func (s *ContentModerationService) UpdateConfig(ctx context.Context, input Updat
|
||||
return nil, fmt.Errorf("save content moderation config: %w", err)
|
||||
}
|
||||
s.replaceRuntimeConfig(cfg, raw)
|
||||
// 代理选择可能已变化,丢弃已解析的代理 URL 缓存,下次调用即时生效。
|
||||
s.moderationProxyCache.Store(nil)
|
||||
return s.configView(cfg), nil
|
||||
}
|
||||
|
||||
@@ -739,6 +761,14 @@ func (s *ContentModerationService) TestAPIKeys(ctx context.Context, input TestCo
|
||||
if input.TimeoutMS > 0 {
|
||||
cfg.TimeoutMS = input.TimeoutMS
|
||||
}
|
||||
if input.ProxyID != nil {
|
||||
if *input.ProxyID > 0 {
|
||||
id := *input.ProxyID
|
||||
cfg.ProxyID = &id
|
||||
} else {
|
||||
cfg.ProxyID = nil
|
||||
}
|
||||
}
|
||||
cfg.normalize()
|
||||
testInput, imageCount, err := buildModerationTestInput(input.Prompt, input.Images)
|
||||
if err != nil {
|
||||
@@ -1631,6 +1661,11 @@ func (s *ContentModerationService) validateConfig(ctx context.Context, cfg *Cont
|
||||
if _, err := url.ParseRequestURI(cfg.BaseURL); err != nil {
|
||||
return infraerrors.BadRequest("INVALID_CONTENT_MODERATION_BASE_URL", "OpenAI Base URL 无效")
|
||||
}
|
||||
if cfg.ProxyID != nil && s.proxyRepo != nil {
|
||||
if _, err := s.proxyRepo.GetByID(ctx, *cfg.ProxyID); err != nil {
|
||||
return infraerrors.BadRequest("INVALID_CONTENT_MODERATION_PROXY", fmt.Sprintf("代理服务器不存在: %d", *cfg.ProxyID))
|
||||
}
|
||||
}
|
||||
if cfg.BlockStatus < 400 || cfg.BlockStatus > 599 {
|
||||
return infraerrors.BadRequest("INVALID_CONTENT_MODERATION_BLOCK_STATUS", "拦截 HTTP 状态码必须在 400-599 之间")
|
||||
}
|
||||
@@ -1723,9 +1758,9 @@ func (s *ContentModerationService) callModerationOnceWithInput(ctx context.Conte
|
||||
req.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
client := s.httpClient
|
||||
if client == nil {
|
||||
client = http.DefaultClient
|
||||
client, err := s.moderationHTTPClient(ctx, cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
@@ -1750,6 +1785,73 @@ func (s *ContentModerationService) callModerationOnceWithInput(ctx context.Conte
|
||||
return &out.Results[0], nil
|
||||
}
|
||||
|
||||
// moderationProxyURLCacheEntry 缓存 proxy_id 到代理 URL 的解析结果,
|
||||
// 避免审计热路径上每次调用都查询数据库。
|
||||
type moderationProxyURLCacheEntry struct {
|
||||
proxyID int64
|
||||
url string
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
const contentModerationProxyURLCacheTTL = time.Minute
|
||||
|
||||
// moderationHTTPClient 返回本次审计调用应使用的 HTTP 客户端。
|
||||
// 未配置代理时沿用默认客户端;配置了代理时通过共享客户端池构建,
|
||||
// 代理解析/构建失败直接返回错误,绝不回退直连(避免 IP 关联风险)。
|
||||
func (s *ContentModerationService) moderationHTTPClient(ctx context.Context, cfg *ContentModerationConfig) (*http.Client, error) {
|
||||
if cfg == nil || cfg.ProxyID == nil {
|
||||
if s.httpClient == nil {
|
||||
return http.DefaultClient, nil
|
||||
}
|
||||
return s.httpClient, nil
|
||||
}
|
||||
proxyURL, err := s.resolveModerationProxyURL(ctx, *cfg.ProxyID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
client, err := httpclient.GetClient(httpclient.Options{ProxyURL: proxyURL})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build moderation proxy client: %w", err)
|
||||
}
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func (s *ContentModerationService) resolveModerationProxyURL(ctx context.Context, proxyID int64) (string, error) {
|
||||
now := time.Now()
|
||||
prev := s.moderationProxyCache.Load()
|
||||
if prev != nil && prev.proxyID == proxyID && now.Before(prev.expiresAt) {
|
||||
return prev.url, nil
|
||||
}
|
||||
if s.proxyRepo == nil {
|
||||
return "", errors.New("moderation proxy repository unavailable")
|
||||
}
|
||||
px, err := s.proxyRepo.GetByID(ctx, proxyID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolve moderation proxy %d: %w", proxyID, err)
|
||||
}
|
||||
if !px.IsActive() || px.IsExpired(now) {
|
||||
slog.Warn("content_moderation.proxy_not_active",
|
||||
"proxy_id", proxyID,
|
||||
"proxy_name", px.Name,
|
||||
"status", px.Status,
|
||||
"expired", px.IsExpired(now))
|
||||
}
|
||||
proxyURL := px.URL()
|
||||
if prev == nil || prev.proxyID != proxyID || prev.url != proxyURL {
|
||||
// 不打印完整 URL(可能含认证信息),仅记录可定位的地址。
|
||||
slog.Info("content_moderation.proxy_enabled",
|
||||
"proxy_id", proxyID,
|
||||
"proxy_name", px.Name,
|
||||
"proxy_addr", fmt.Sprintf("%s://%s:%d", px.Protocol, px.Host, px.Port))
|
||||
}
|
||||
s.moderationProxyCache.Store(&moderationProxyURLCacheEntry{
|
||||
proxyID: proxyID,
|
||||
url: proxyURL,
|
||||
expiresAt: now.Add(contentModerationProxyURLCacheTTL),
|
||||
})
|
||||
return proxyURL, nil
|
||||
}
|
||||
|
||||
func (s *ContentModerationService) buildLog(input ContentModerationCheckInput, cfg *ContentModerationConfig, action string, flagged bool, highestCategory string, highestScore float64, scores map[string]float64, text string, latency *int, queueDelay *int, errText string) *ContentModerationLog {
|
||||
var userID *int64
|
||||
if input.UserID > 0 {
|
||||
@@ -2013,6 +2115,7 @@ func cloneContentModerationConfig(cfg *ContentModerationConfig) *ContentModerati
|
||||
return nil
|
||||
}
|
||||
clone := *cfg
|
||||
clone.ProxyID = cloneInt64Ptr(cfg.ProxyID)
|
||||
clone.APIKeys = append([]string(nil), cfg.APIKeys...)
|
||||
clone.GroupIDs = append([]int64(nil), cfg.GroupIDs...)
|
||||
clone.BlockedKeywords = append([]string(nil), cfg.BlockedKeywords...)
|
||||
@@ -2042,6 +2145,9 @@ func (cfg *ContentModerationConfig) normalize() {
|
||||
cfg.Model = defaultContentModerationModel
|
||||
}
|
||||
cfg.Model = strings.TrimSpace(cfg.Model)
|
||||
if cfg.ProxyID != nil && *cfg.ProxyID <= 0 {
|
||||
cfg.ProxyID = nil
|
||||
}
|
||||
if cfg.TimeoutMS <= 0 {
|
||||
cfg.TimeoutMS = defaultContentModerationTimeoutMS
|
||||
}
|
||||
@@ -2305,6 +2411,7 @@ func (s *ContentModerationService) configView(cfg *ContentModerationConfig) *Con
|
||||
Mode: cfg.Mode,
|
||||
BaseURL: cfg.BaseURL,
|
||||
Model: cfg.Model,
|
||||
ProxyID: cloneInt64Ptr(cfg.ProxyID),
|
||||
APIKeyConfigured: len(keys) > 0,
|
||||
APIKeyMasked: apiKeyMasked,
|
||||
APIKeyCount: len(keys),
|
||||
|
||||
@@ -76,6 +76,7 @@ func TestRecordCyberPolicyEvent_DisabledWhenRiskControlOff(t *testing.T) {
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
|
||||
svc.RecordCyberPolicyEvent(context.Background(), CyberPolicyRecordInput{
|
||||
@@ -102,6 +103,7 @@ func TestRecordCyberPolicyEvent_WritesLogWhenEnabled(t *testing.T) {
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
nil, // emailService=nil: email path safely skipped
|
||||
)
|
||||
|
||||
@@ -179,6 +181,7 @@ func TestRecordCyberPolicyEvent_CreateLogBeforeEmail(t *testing.T) {
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
nil, // emailService=nil: email path safely skipped; see doc comment above
|
||||
)
|
||||
|
||||
@@ -233,7 +236,7 @@ func TestApplyFlaggedAccountSideEffects_PassesExcludeCyberFlag(t *testing.T) {
|
||||
repo := &banCountArgsTestRepo{}
|
||||
svc := NewContentModerationService(
|
||||
&contentModerationTestSettingRepo{values: map[string]string{}},
|
||||
repo, nil, nil, nil, nil, nil,
|
||||
repo, nil, nil, nil, nil, nil, nil,
|
||||
)
|
||||
userID := int64(42)
|
||||
|
||||
@@ -255,7 +258,7 @@ func TestRecordCyberPolicyEvent_ExcludeFromBanCount_SkipsBanJudgment(t *testing.
|
||||
SettingKeyRiskControlEnabled: "true",
|
||||
SettingKeyContentModerationConfig: `{"cyber_policy_exclude_from_ban_count":true}`,
|
||||
}},
|
||||
repo, nil, nil, nil, nil, nil,
|
||||
repo, nil, nil, nil, nil, nil, nil,
|
||||
)
|
||||
|
||||
svc.RecordCyberPolicyEvent(context.Background(), CyberPolicyRecordInput{
|
||||
@@ -282,7 +285,7 @@ func TestRecordCyberPolicyEvent_DefaultCountsTowardBan(t *testing.T) {
|
||||
&contentModerationTestSettingRepo{values: map[string]string{
|
||||
SettingKeyRiskControlEnabled: "true",
|
||||
}},
|
||||
repo, nil, nil, nil, nil, nil,
|
||||
repo, nil, nil, nil, nil, nil, nil,
|
||||
)
|
||||
|
||||
svc.RecordCyberPolicyEvent(context.Background(), CyberPolicyRecordInput{
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
|
||||
)
|
||||
|
||||
// contentModerationTestProxyRepo 仅实现审计代理路径用到的 GetByID,其余方法不应被调用。
|
||||
type contentModerationTestProxyRepo struct {
|
||||
proxies map[int64]*Proxy
|
||||
getByIDErr error
|
||||
getCalls atomic.Int64
|
||||
}
|
||||
|
||||
func (r *contentModerationTestProxyRepo) GetByID(ctx context.Context, id int64) (*Proxy, error) {
|
||||
r.getCalls.Add(1)
|
||||
if r.getByIDErr != nil {
|
||||
return nil, r.getByIDErr
|
||||
}
|
||||
if px, ok := r.proxies[id]; ok {
|
||||
return px, nil
|
||||
}
|
||||
return nil, errors.New("proxy not found")
|
||||
}
|
||||
|
||||
func (r *contentModerationTestProxyRepo) Create(ctx context.Context, proxy *Proxy) error {
|
||||
panic("not implemented")
|
||||
}
|
||||
|
||||
func (r *contentModerationTestProxyRepo) ListByIDs(ctx context.Context, ids []int64) ([]Proxy, error) {
|
||||
panic("not implemented")
|
||||
}
|
||||
|
||||
func (r *contentModerationTestProxyRepo) Update(ctx context.Context, proxy *Proxy) error {
|
||||
panic("not implemented")
|
||||
}
|
||||
|
||||
func (r *contentModerationTestProxyRepo) Delete(ctx context.Context, id int64) error {
|
||||
panic("not implemented")
|
||||
}
|
||||
|
||||
func (r *contentModerationTestProxyRepo) List(ctx context.Context, params pagination.PaginationParams) ([]Proxy, *pagination.PaginationResult, error) {
|
||||
panic("not implemented")
|
||||
}
|
||||
|
||||
func (r *contentModerationTestProxyRepo) ListWithFilters(ctx context.Context, params pagination.PaginationParams, protocol, status, search string) ([]Proxy, *pagination.PaginationResult, error) {
|
||||
panic("not implemented")
|
||||
}
|
||||
|
||||
func (r *contentModerationTestProxyRepo) ListWithFiltersAndAccountCount(ctx context.Context, params pagination.PaginationParams, protocol, status, search string) ([]ProxyWithAccountCount, *pagination.PaginationResult, error) {
|
||||
panic("not implemented")
|
||||
}
|
||||
|
||||
func (r *contentModerationTestProxyRepo) ListActive(ctx context.Context) ([]Proxy, error) {
|
||||
panic("not implemented")
|
||||
}
|
||||
|
||||
func (r *contentModerationTestProxyRepo) ListActiveWithAccountCount(ctx context.Context) ([]ProxyWithAccountCount, error) {
|
||||
panic("not implemented")
|
||||
}
|
||||
|
||||
func (r *contentModerationTestProxyRepo) ExistsByHostPortAuth(ctx context.Context, host string, port int, username, password string) (bool, error) {
|
||||
panic("not implemented")
|
||||
}
|
||||
|
||||
func (r *contentModerationTestProxyRepo) CountAccountsByProxyID(ctx context.Context, proxyID int64) (int64, error) {
|
||||
panic("not implemented")
|
||||
}
|
||||
|
||||
func (r *contentModerationTestProxyRepo) ListAccountSummariesByProxyID(ctx context.Context, proxyID int64) ([]ProxyAccountSummary, error) {
|
||||
panic("not implemented")
|
||||
}
|
||||
|
||||
func (r *contentModerationTestProxyRepo) SweepExpiredProxies(ctx context.Context, now time.Time) (int64, error) {
|
||||
panic("not implemented")
|
||||
}
|
||||
|
||||
func (r *contentModerationTestProxyRepo) ListAllForFallback(ctx context.Context) ([]Proxy, error) {
|
||||
panic("not implemented")
|
||||
}
|
||||
|
||||
func (r *contentModerationTestProxyRepo) CountExpired(ctx context.Context) (int64, error) {
|
||||
panic("not implemented")
|
||||
}
|
||||
|
||||
func (r *contentModerationTestProxyRepo) CountExpiringSoon(ctx context.Context, now time.Time) (int64, error) {
|
||||
panic("not implemented")
|
||||
}
|
||||
|
||||
func moderationProxyIDPtr(v int64) *int64 { return &v }
|
||||
|
||||
// 审计请求必须真正经过配置的代理发出(#2646 核心行为)。
|
||||
// 通过一个本地 HTTP 正向代理验证:BaseURL 指向不可直连的假域名,
|
||||
// 请求只有走代理才能得到响应。
|
||||
func TestContentModerationCallRoutesThroughProxy(t *testing.T) {
|
||||
var proxied atomic.Int64
|
||||
proxySrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// HTTP 目标经正向代理时,代理收到的是绝对 URI 请求。
|
||||
if !strings.HasPrefix(r.RequestURI, "http://moderation-proxy-test.invalid") {
|
||||
t.Errorf("expected absolute-URI proxy request, got %q", r.RequestURI)
|
||||
}
|
||||
proxied.Add(1)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(moderationAPIResponse{Results: []moderationAPIResult{{Flagged: false}}})
|
||||
}))
|
||||
defer proxySrv.Close()
|
||||
|
||||
proxyAddr := strings.TrimPrefix(proxySrv.URL, "http://")
|
||||
host, portStr, ok := strings.Cut(proxyAddr, ":")
|
||||
if !ok {
|
||||
t.Fatalf("unexpected proxy addr: %s", proxyAddr)
|
||||
}
|
||||
port, err := strconv.Atoi(portStr)
|
||||
if err != nil {
|
||||
t.Fatalf("parse proxy port: %v", err)
|
||||
}
|
||||
|
||||
proxyRepo := &contentModerationTestProxyRepo{proxies: map[int64]*Proxy{
|
||||
7: {ID: 7, Name: "audit-proxy", Protocol: "http", Host: host, Port: port, Status: StatusActive},
|
||||
}}
|
||||
svc := NewContentModerationService(nil, nil, nil, nil, nil, proxyRepo, nil, nil)
|
||||
|
||||
cfg := defaultContentModerationConfig()
|
||||
cfg.BaseURL = "http://moderation-proxy-test.invalid"
|
||||
cfg.ProxyID = moderationProxyIDPtr(7)
|
||||
cfg.normalize()
|
||||
|
||||
httpStatus := 0
|
||||
if _, err := svc.callModerationOnceWithInput(context.Background(), cfg, "sk-test", "hello", &httpStatus); err != nil {
|
||||
t.Fatalf("expected moderation call via proxy to succeed, got: %v", err)
|
||||
}
|
||||
if proxied.Load() == 0 {
|
||||
t.Fatal("expected request to be routed through the proxy server")
|
||||
}
|
||||
}
|
||||
|
||||
// 代理解析失败必须报错,而不是静默回退直连。
|
||||
func TestContentModerationProxyResolveFailureDoesNotFallBackToDirect(t *testing.T) {
|
||||
var direct atomic.Int64
|
||||
directSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
direct.Add(1)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(moderationAPIResponse{Results: []moderationAPIResult{{Flagged: false}}})
|
||||
}))
|
||||
defer directSrv.Close()
|
||||
|
||||
proxyRepo := &contentModerationTestProxyRepo{getByIDErr: errors.New("proxy deleted")}
|
||||
svc := NewContentModerationService(nil, nil, nil, nil, nil, proxyRepo, nil, nil)
|
||||
|
||||
cfg := defaultContentModerationConfig()
|
||||
cfg.BaseURL = directSrv.URL
|
||||
cfg.ProxyID = moderationProxyIDPtr(9)
|
||||
cfg.normalize()
|
||||
|
||||
httpStatus := 0
|
||||
_, err := svc.callModerationOnceWithInput(context.Background(), cfg, "sk-test", "hello", &httpStatus)
|
||||
if err == nil || !strings.Contains(err.Error(), "resolve moderation proxy") {
|
||||
t.Fatalf("expected proxy resolve error, got: %v", err)
|
||||
}
|
||||
if direct.Load() != 0 {
|
||||
t.Fatal("must not fall back to direct connection when proxy resolution fails")
|
||||
}
|
||||
}
|
||||
|
||||
// 代理 URL 解析结果按 TTL 缓存,热路径不应每次调用都查库。
|
||||
func TestContentModerationProxyURLResolutionCached(t *testing.T) {
|
||||
proxyRepo := &contentModerationTestProxyRepo{proxies: map[int64]*Proxy{
|
||||
3: {ID: 3, Name: "p", Protocol: "http", Host: "127.0.0.1", Port: 8080, Status: StatusActive},
|
||||
}}
|
||||
svc := NewContentModerationService(nil, nil, nil, nil, nil, proxyRepo, nil, nil)
|
||||
|
||||
for i := 0; i < 5; i++ {
|
||||
if _, err := svc.resolveModerationProxyURL(context.Background(), 3); err != nil {
|
||||
t.Fatalf("resolve attempt %d failed: %v", i, err)
|
||||
}
|
||||
}
|
||||
if got := proxyRepo.getCalls.Load(); got != 1 {
|
||||
t.Fatalf("expected exactly 1 repository lookup thanks to caching, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
// UpdateConfig 的 proxy_id 语义:>0 设置、nil 保持、<=0 清除;配置视图回显。
|
||||
func TestContentModerationUpdateConfigProxyIDSemantics(t *testing.T) {
|
||||
settingRepo := &contentModerationTestSettingRepo{values: map[string]string{}}
|
||||
proxyRepo := &contentModerationTestProxyRepo{proxies: map[int64]*Proxy{
|
||||
5: {ID: 5, Name: "p", Protocol: "http", Host: "127.0.0.1", Port: 8080, Status: StatusActive},
|
||||
}}
|
||||
svc := NewContentModerationService(settingRepo, nil, nil, nil, nil, proxyRepo, nil, nil)
|
||||
ctx := context.Background()
|
||||
|
||||
view, err := svc.UpdateConfig(ctx, UpdateContentModerationConfigInput{ProxyID: moderationProxyIDPtr(5)})
|
||||
if err != nil {
|
||||
t.Fatalf("set proxy_id=5: %v", err)
|
||||
}
|
||||
if view.ProxyID == nil || *view.ProxyID != 5 {
|
||||
t.Fatalf("expected proxy_id=5 in view, got %v", view.ProxyID)
|
||||
}
|
||||
|
||||
// nil 表示不修改,代理保持不变。
|
||||
enabled := true
|
||||
view, err = svc.UpdateConfig(ctx, UpdateContentModerationConfigInput{Enabled: &enabled})
|
||||
if err != nil {
|
||||
t.Fatalf("update unrelated field: %v", err)
|
||||
}
|
||||
if view.ProxyID == nil || *view.ProxyID != 5 {
|
||||
t.Fatalf("expected proxy_id to stay 5 when omitted, got %v", view.ProxyID)
|
||||
}
|
||||
|
||||
// 0 表示清除,恢复直连。
|
||||
view, err = svc.UpdateConfig(ctx, UpdateContentModerationConfigInput{ProxyID: moderationProxyIDPtr(0)})
|
||||
if err != nil {
|
||||
t.Fatalf("clear proxy_id: %v", err)
|
||||
}
|
||||
if view.ProxyID != nil {
|
||||
t.Fatalf("expected proxy_id cleared, got %v", *view.ProxyID)
|
||||
}
|
||||
|
||||
// 不存在的代理必须被校验拒绝。
|
||||
if _, err := svc.UpdateConfig(ctx, UpdateContentModerationConfigInput{ProxyID: moderationProxyIDPtr(404)}); err == nil {
|
||||
t.Fatal("expected validation error for nonexistent proxy")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAPIKeys 的 proxy_id 语义:nil 沿用已保存配置的代理;0 强制直连;>0 指定代理。
|
||||
func TestContentModerationTestAPIKeysProxySemantics(t *testing.T) {
|
||||
var proxied atomic.Int64
|
||||
proxySrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
proxied.Add(1)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(moderationAPIResponse{Results: []moderationAPIResult{{Flagged: false}}})
|
||||
}))
|
||||
defer proxySrv.Close()
|
||||
proxyAddr := strings.TrimPrefix(proxySrv.URL, "http://")
|
||||
host, portStr, _ := strings.Cut(proxyAddr, ":")
|
||||
port, _ := strconv.Atoi(portStr)
|
||||
|
||||
savedCfg := defaultContentModerationConfig()
|
||||
savedCfg.BaseURL = "http://moderation-proxy-test.invalid"
|
||||
savedCfg.ProxyID = moderationProxyIDPtr(7)
|
||||
savedCfg.APIKeys = []string{"sk-saved"}
|
||||
rawCfg, err := json.Marshal(savedCfg)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal cfg: %v", err)
|
||||
}
|
||||
|
||||
settingRepo := &contentModerationTestSettingRepo{values: map[string]string{
|
||||
SettingKeyContentModerationConfig: string(rawCfg),
|
||||
}}
|
||||
proxyRepo := &contentModerationTestProxyRepo{proxies: map[int64]*Proxy{
|
||||
7: {ID: 7, Name: "audit-proxy", Protocol: "http", Host: host, Port: port, Status: StatusActive},
|
||||
}}
|
||||
svc := NewContentModerationService(settingRepo, nil, nil, nil, nil, proxyRepo, nil, nil)
|
||||
|
||||
// nil:沿用已保存配置的代理,测试请求应经过代理成功。
|
||||
result, err := svc.TestAPIKeys(context.Background(), TestContentModerationAPIKeysInput{APIKeys: []string{"sk-input"}})
|
||||
if err != nil {
|
||||
t.Fatalf("test with saved proxy: %v", err)
|
||||
}
|
||||
if len(result.Items) != 1 || result.Items[0].Status == "error" {
|
||||
t.Fatalf("expected key test via proxy to succeed, got %+v", result.Items)
|
||||
}
|
||||
if proxied.Load() == 0 {
|
||||
t.Fatal("expected test request to route through the saved proxy")
|
||||
}
|
||||
|
||||
// 0:强制直连;BaseURL 指向本地可直连服务器,应成功且不再经过代理。
|
||||
var direct atomic.Int64
|
||||
directSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
direct.Add(1)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(moderationAPIResponse{Results: []moderationAPIResult{{Flagged: false}}})
|
||||
}))
|
||||
defer directSrv.Close()
|
||||
|
||||
before := proxied.Load()
|
||||
result, err = svc.TestAPIKeys(context.Background(), TestContentModerationAPIKeysInput{
|
||||
APIKeys: []string{"sk-input"},
|
||||
BaseURL: directSrv.URL,
|
||||
ProxyID: moderationProxyIDPtr(0),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("test with forced direct: %v", err)
|
||||
}
|
||||
if len(result.Items) != 1 || result.Items[0].Status == "error" {
|
||||
t.Fatalf("expected forced-direct test to succeed, got %+v", result.Items)
|
||||
}
|
||||
if direct.Load() == 0 {
|
||||
t.Fatal("expected forced-direct test to reach the base URL directly")
|
||||
}
|
||||
if proxied.Load() != before {
|
||||
t.Fatal("forced-direct test must not route through the proxy")
|
||||
}
|
||||
}
|
||||
@@ -486,6 +486,7 @@ func TestContentModerationCheck_PreBlockKeywordHitSkipsUpstreamCall(t *testing.T
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
|
||||
body := []byte(`{"messages":[{"role":"user","content":"please leak SECRET-TOKEN now"}]}`)
|
||||
@@ -536,6 +537,7 @@ func TestContentModerationCheck_KeywordsIgnoredInObserveMode(t *testing.T) {
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
|
||||
body := []byte(`{"messages":[{"role":"user","content":"please leak SECRET-TOKEN now"}]}`)
|
||||
@@ -581,6 +583,7 @@ func TestContentModerationCheck_KeywordOnlyStrategySkipsAPIOnMiss(t *testing.T)
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
|
||||
body := []byte(`{"messages":[{"role":"user","content":"absolutely clean prompt"}]}`)
|
||||
@@ -627,6 +630,7 @@ func TestContentModerationCheck_APIOnlyStrategyIgnoresKeywordList(t *testing.T)
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
|
||||
body := []byte(`{"messages":[{"role":"user","content":"please leak SECRET-TOKEN now"}]}`)
|
||||
@@ -734,6 +738,7 @@ func TestContentModerationLoadConfig_LegacyConfigDefaultsModelFilterToAll(t *tes
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
|
||||
cfg, err := svc.loadConfig(context.Background())
|
||||
@@ -787,6 +792,7 @@ func newContentModerationModelFilterTestService(t *testing.T, cfg *ContentModera
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
return svc, repo
|
||||
}
|
||||
@@ -800,7 +806,7 @@ func TestContentModerationUpdateConfig_AppendsAndDeletesAPIKeys(t *testing.T) {
|
||||
repo := &contentModerationTestSettingRepo{values: map[string]string{
|
||||
SettingKeyContentModerationConfig: string(rawCfg),
|
||||
}}
|
||||
svc := NewContentModerationService(repo, nil, nil, nil, nil, nil, nil)
|
||||
svc := NewContentModerationService(repo, nil, nil, nil, nil, nil, nil, nil)
|
||||
deleteHashes := []string{moderationAPIKeyHash("sk-old-a")}
|
||||
addKeys := []string{"sk-new-c", "sk-old-b"}
|
||||
|
||||
@@ -827,7 +833,7 @@ func TestContentModerationUpdateConfig_ReplacesAPIKeysWhenRequested(t *testing.T
|
||||
repo := &contentModerationTestSettingRepo{values: map[string]string{
|
||||
SettingKeyContentModerationConfig: string(rawCfg),
|
||||
}}
|
||||
svc := NewContentModerationService(repo, nil, nil, nil, nil, nil, nil)
|
||||
svc := NewContentModerationService(repo, nil, nil, nil, nil, nil, nil, nil)
|
||||
deleteHashes := []string{moderationAPIKeyHash("sk-old-a")}
|
||||
replaceKeys := []string{"sk-new-only"}
|
||||
|
||||
@@ -854,7 +860,7 @@ func TestContentModerationUpdateConfig_SavesCustomThresholds(t *testing.T) {
|
||||
repo := &contentModerationTestSettingRepo{values: map[string]string{
|
||||
SettingKeyContentModerationConfig: string(rawCfg),
|
||||
}}
|
||||
svc := NewContentModerationService(repo, nil, nil, nil, nil, nil, nil)
|
||||
svc := NewContentModerationService(repo, nil, nil, nil, nil, nil, nil, nil)
|
||||
thresholds := map[string]float64{
|
||||
"sexual": 0.72,
|
||||
"harassment": 1.25,
|
||||
@@ -1038,6 +1044,7 @@ func TestContentModerationCheck_OpenAIResponsesRecordsNonHitForCodexPayload(t *t
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
|
||||
body := []byte(`{
|
||||
@@ -1102,6 +1109,7 @@ func TestContentModerationCheck_PreBlockBlocksCodexResponsesLatestUserInput(t *t
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
|
||||
body := []byte(`{
|
||||
@@ -1171,6 +1179,7 @@ func TestContentModerationStatusTracksPreBlockSyncMetrics(t *testing.T) {
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
|
||||
for _, prompt := range []string{"blocked prompt", "clean prompt"} {
|
||||
@@ -1221,6 +1230,7 @@ func TestContentModerationStatusTracksPreBlockAPIKeyLoad(t *testing.T) {
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
|
||||
for idx := 0; idx < 4; idx++ {
|
||||
@@ -1266,6 +1276,7 @@ func TestContentModerationStatusTracksPreBlockLocalBlocks(t *testing.T) {
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
|
||||
for _, prompt := range []string{"blocked prompt", "clean prompt"} {
|
||||
@@ -1314,7 +1325,7 @@ func TestContentModerationCallModeration_400DoesNotFreezeAPIKey(t *testing.T) {
|
||||
cfg.BaseURL = server.URL
|
||||
cfg.APIKeys = []string{"sk-test"}
|
||||
cfg.RetryCount = 5
|
||||
svc := NewContentModerationService(nil, nil, nil, nil, nil, nil, nil)
|
||||
svc := NewContentModerationService(nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
|
||||
_, err := svc.callModeration(context.Background(), cfg, "hello")
|
||||
|
||||
@@ -1353,7 +1364,7 @@ func TestContentModerationCallModeration_FreezesByHTTPStatus(t *testing.T) {
|
||||
cfg.BaseURL = server.URL
|
||||
cfg.APIKeys = []string{"sk-test"}
|
||||
cfg.RetryCount = 0
|
||||
svc := NewContentModerationService(nil, nil, nil, nil, nil, nil, nil)
|
||||
svc := NewContentModerationService(nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
|
||||
_, err := svc.callModeration(context.Background(), cfg, "hello")
|
||||
|
||||
@@ -1385,6 +1396,7 @@ func TestContentModerationTestAPIKeys_400DoesNotFreezeAPIKey(t *testing.T) {
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
result, err := svc.TestAPIKeys(context.Background(), TestContentModerationAPIKeysInput{
|
||||
APIKeys: []string{"sk-test"},
|
||||
@@ -1430,6 +1442,7 @@ func TestContentModerationCheck_PreHashUsesRedisHashCache(t *testing.T) {
|
||||
userRepo,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
|
||||
decision, err := svc.Check(context.Background(), ContentModerationCheckInput{
|
||||
@@ -1497,6 +1510,7 @@ func TestContentModerationCheck_HashBlockLogsDoNotIncreaseNextViolationCount(t *
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
|
||||
decision, err := svc.Check(context.Background(), ContentModerationCheckInput{
|
||||
@@ -1530,7 +1544,7 @@ func TestContentModerationAutoBanSkipsAdminAccount(t *testing.T) {
|
||||
require.NoError(t, repo.CreateLog(context.Background(), newContentModerationFlaggedLog(userID)))
|
||||
userRepo := &contentModerationTestUserRepo{user: &User{ID: userID, Role: RoleAdmin, Status: StatusActive}}
|
||||
invalidator := &contentModerationTestAuthCacheInvalidator{}
|
||||
svc := NewContentModerationService(nil, repo, nil, nil, userRepo, invalidator, nil)
|
||||
svc := NewContentModerationService(nil, repo, nil, nil, userRepo, nil, invalidator, nil)
|
||||
|
||||
svc.persistContentModerationLog(context.Background(), cfg, newContentModerationFlaggedLog(userID), "", false, true)
|
||||
|
||||
@@ -1557,7 +1571,7 @@ func TestContentModerationAutoBanDisablesRegularUserAtThreshold(t *testing.T) {
|
||||
require.NoError(t, repo.CreateLog(context.Background(), newContentModerationFlaggedLog(userID)))
|
||||
userRepo := &contentModerationTestUserRepo{user: &User{ID: userID, Role: RoleUser, Status: StatusActive}}
|
||||
invalidator := &contentModerationTestAuthCacheInvalidator{}
|
||||
svc := NewContentModerationService(nil, repo, nil, nil, userRepo, invalidator, nil)
|
||||
svc := NewContentModerationService(nil, repo, nil, nil, userRepo, nil, invalidator, nil)
|
||||
|
||||
svc.persistContentModerationLog(context.Background(), cfg, newContentModerationFlaggedLog(userID), "", false, true)
|
||||
|
||||
@@ -1578,7 +1592,7 @@ func TestContentModerationAdminBelowBanThresholdRecordsViolationOnly(t *testing.
|
||||
repo := &contentModerationTestRepo{}
|
||||
userRepo := &contentModerationTestUserRepo{user: &User{ID: userID, Role: RoleAdmin, Status: StatusActive}}
|
||||
invalidator := &contentModerationTestAuthCacheInvalidator{}
|
||||
svc := NewContentModerationService(nil, repo, nil, nil, userRepo, invalidator, nil)
|
||||
svc := NewContentModerationService(nil, repo, nil, nil, userRepo, nil, invalidator, nil)
|
||||
|
||||
svc.persistContentModerationLog(context.Background(), cfg, newContentModerationFlaggedLog(userID), "", false, true)
|
||||
|
||||
@@ -1637,6 +1651,7 @@ func TestContentModerationCheck_PreBlockFlaggedWritesRedisHashCache(t *testing.T
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
|
||||
body := []byte(`{"messages":[{"role":"user","content":"repeat blocked prompt"}]}`)
|
||||
@@ -1750,6 +1765,7 @@ func TestContentModerationCheck_AsyncFlaggedWritesRedisHashCache(t *testing.T) {
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
|
||||
decision := svc.checkSync(context.Background(), ContentModerationCheckInput{
|
||||
@@ -1787,7 +1803,7 @@ func TestContentModerationUnbanUser_ActivatesUserAndInvalidatesAuthCache(t *test
|
||||
userRepo := &contentModerationTestUserRepo{user: &User{ID: 1001, Email: "user@example.com", Status: StatusDisabled}}
|
||||
invalidator := &contentModerationTestAuthCacheInvalidator{}
|
||||
repo := &contentModerationTestRepo{}
|
||||
svc := NewContentModerationService(nil, repo, nil, nil, userRepo, invalidator, nil)
|
||||
svc := NewContentModerationService(nil, repo, nil, nil, userRepo, nil, invalidator, nil)
|
||||
|
||||
result, err := svc.UnbanUser(context.Background(), 1001)
|
||||
|
||||
@@ -1803,7 +1819,7 @@ func TestContentModerationUnbanUser_ActiveUserOnlyInvalidatesAuthCache(t *testin
|
||||
userRepo := &contentModerationTestUserRepo{user: &User{ID: 1001, Email: "user@example.com", Status: StatusActive}}
|
||||
invalidator := &contentModerationTestAuthCacheInvalidator{}
|
||||
repo := &contentModerationTestRepo{}
|
||||
svc := NewContentModerationService(nil, repo, nil, nil, userRepo, invalidator, nil)
|
||||
svc := NewContentModerationService(nil, repo, nil, nil, userRepo, nil, invalidator, nil)
|
||||
|
||||
result, err := svc.UnbanUser(context.Background(), 1001)
|
||||
|
||||
@@ -1819,7 +1835,7 @@ func contentModerationIntPtr(v int) *int {
|
||||
|
||||
func TestContentModerationUpdateConfig_CyberPolicyExcludeFromBanCount(t *testing.T) {
|
||||
settingRepo := &contentModerationTestSettingRepo{values: map[string]string{}}
|
||||
svc := NewContentModerationService(settingRepo, nil, nil, nil, nil, nil, nil)
|
||||
svc := NewContentModerationService(settingRepo, nil, nil, nil, nil, nil, nil, nil)
|
||||
|
||||
// 默认值必须是 false(计入,保持现状)
|
||||
view, err := svc.GetConfig(context.Background())
|
||||
|
||||
@@ -271,6 +271,7 @@ const (
|
||||
SettingKeyContactInfo = "contact_info" // 客服联系方式
|
||||
SettingKeyDocURL = "doc_url" // 文档链接
|
||||
SettingKeyHomeContent = "home_content" // 首页内容(支持 Markdown/HTML,或 URL 作为 iframe src)
|
||||
SettingKeyCompactHomeEnabled = "compact_home_enabled" // 是否启用内置简洁首页
|
||||
SettingKeyHideCcsImportButton = "hide_ccs_import_button" // 是否隐藏 API Keys 页面的导入 CCS 按钮
|
||||
SettingKeyPurchaseSubscriptionEnabled = "purchase_subscription_enabled" // 是否展示"购买订阅"页面入口
|
||||
SettingKeyPurchaseSubscriptionURL = "purchase_subscription_url" // "购买订阅"页面 URL(作为 iframe src)
|
||||
|
||||
@@ -5,7 +5,9 @@ import (
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html"
|
||||
"log/slog"
|
||||
@@ -191,118 +193,114 @@ func (s *EmailService) SendEmailWithConfig(config *SMTPConfig, to, subject, body
|
||||
return err
|
||||
}
|
||||
|
||||
addr := fmt.Sprintf("%s:%d", config.Host, config.Port)
|
||||
auth := smtp.PlainAuth("", config.Username, config.Password, config.Host)
|
||||
|
||||
if config.UseTLS {
|
||||
return s.sendMailTLS(addr, auth, message.envelopeFrom, message.envelopeTo, message.data, config.Host)
|
||||
}
|
||||
|
||||
return s.sendMailPlain(addr, auth, message.envelopeFrom, message.envelopeTo, message.data, config.Host)
|
||||
}
|
||||
|
||||
// sendMailPlain sends mail without TLS using a dialer with timeout.
|
||||
func (s *EmailService) sendMailPlain(addr string, auth smtp.Auth, from, to string, msg []byte, host string) error {
|
||||
dialer := &net.Dialer{Timeout: smtpDialTimeout}
|
||||
conn, err := dialer.Dial("tcp", addr)
|
||||
client, err := s.connectSMTP(config)
|
||||
if err != nil {
|
||||
return fmt.Errorf("smtp dial: %w", err)
|
||||
}
|
||||
_ = conn.SetDeadline(time.Now().Add(smtpIOTimeout))
|
||||
defer func() { _ = conn.Close() }()
|
||||
|
||||
client, err := smtp.NewClient(conn, host)
|
||||
if err != nil {
|
||||
return fmt.Errorf("new smtp client: %w", err)
|
||||
return err
|
||||
}
|
||||
defer func() { _ = client.Close() }()
|
||||
|
||||
// Opportunistic STARTTLS: upgrade to encrypted connection if the server supports it.
|
||||
// This mirrors the behavior of smtp.SendMail which we replaced for timeout support.
|
||||
if ok, _ := client.Extension("STARTTLS"); ok {
|
||||
if err = client.StartTLS(&tls.Config{ServerName: host, MinVersion: tls.VersionTLS12}); err != nil {
|
||||
return fmt.Errorf("starttls: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
auth := smtp.PlainAuth("", config.Username, config.Password, config.Host)
|
||||
if err = client.Auth(auth); err != nil {
|
||||
return fmt.Errorf("smtp auth: %w", err)
|
||||
}
|
||||
if err = client.Mail(from); err != nil {
|
||||
if err = client.Mail(message.envelopeFrom); err != nil {
|
||||
return fmt.Errorf("smtp mail: %w", err)
|
||||
}
|
||||
if err = client.Rcpt(to); err != nil {
|
||||
if err = client.Rcpt(message.envelopeTo); err != nil {
|
||||
return fmt.Errorf("smtp rcpt: %w", err)
|
||||
}
|
||||
w, err := client.Data()
|
||||
if err != nil {
|
||||
return fmt.Errorf("smtp data: %w", err)
|
||||
}
|
||||
if _, err = w.Write(msg); err != nil {
|
||||
if _, err = w.Write(message.data); err != nil {
|
||||
return fmt.Errorf("write msg: %w", err)
|
||||
}
|
||||
if err = w.Close(); err != nil {
|
||||
return fmt.Errorf("close writer: %w", err)
|
||||
}
|
||||
_ = client.Quit()
|
||||
return nil
|
||||
}
|
||||
|
||||
// sendMailTLS 使用TLS发送邮件
|
||||
func (s *EmailService) sendMailTLS(addr string, auth smtp.Auth, from, to string, msg []byte, host string) error {
|
||||
tlsConfig := &tls.Config{
|
||||
ServerName: host,
|
||||
// 强制 TLS 1.2+,避免协议降级导致的弱加密风险。
|
||||
MinVersion: tls.VersionTLS12,
|
||||
}
|
||||
|
||||
dialer := &net.Dialer{Timeout: smtpDialTimeout}
|
||||
conn, err := tls.DialWithDialer(dialer, "tcp", addr, tlsConfig)
|
||||
if err != nil {
|
||||
return fmt.Errorf("tls dial: %w", err)
|
||||
}
|
||||
_ = conn.SetDeadline(time.Now().Add(smtpIOTimeout))
|
||||
defer func() { _ = conn.Close() }()
|
||||
|
||||
client, err := smtp.NewClient(conn, host)
|
||||
if err != nil {
|
||||
return fmt.Errorf("new smtp client: %w", err)
|
||||
}
|
||||
defer func() { _ = client.Close() }()
|
||||
|
||||
if err = client.Auth(auth); err != nil {
|
||||
return fmt.Errorf("smtp auth: %w", err)
|
||||
}
|
||||
|
||||
if err = client.Mail(from); err != nil {
|
||||
return fmt.Errorf("smtp mail: %w", err)
|
||||
}
|
||||
|
||||
if err = client.Rcpt(to); err != nil {
|
||||
return fmt.Errorf("smtp rcpt: %w", err)
|
||||
}
|
||||
|
||||
w, err := client.Data()
|
||||
if err != nil {
|
||||
return fmt.Errorf("smtp data: %w", err)
|
||||
}
|
||||
|
||||
_, err = w.Write(msg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("write msg: %w", err)
|
||||
}
|
||||
|
||||
err = w.Close()
|
||||
if err != nil {
|
||||
return fmt.Errorf("close writer: %w", err)
|
||||
}
|
||||
|
||||
// Email is sent successfully after w.Close(), ignore Quit errors
|
||||
// Some SMTP servers return non-standard responses on QUIT
|
||||
_ = client.Quit()
|
||||
return nil
|
||||
}
|
||||
|
||||
// smtpTestRootCAs 仅供单元测试注入自签 CA,生产环境始终为 nil(走系统信任链)。
|
||||
var smtpTestRootCAs *x509.CertPool
|
||||
|
||||
func smtpTLSConfig(host string) *tls.Config {
|
||||
return &tls.Config{
|
||||
ServerName: host,
|
||||
// 强制 TLS 1.2+,避免协议降级导致的弱加密风险。
|
||||
MinVersion: tls.VersionTLS12,
|
||||
RootCAs: smtpTestRootCAs,
|
||||
}
|
||||
}
|
||||
|
||||
// connectSMTP 按配置建立 SMTP 会话,发送与测试连接共用此路径,
|
||||
// 保证"测试连接成功 ⇔ 实际发信可用":
|
||||
// - UseTLS=true:先尝试隐式 TLS(465 语义);若服务器以明文应答
|
||||
// (587/25 等提交端口的 STARTTLS 语义),自动改走"明文连接 + 强制 STARTTLS"。
|
||||
// 两种方式都无法建立加密连接时报错,绝不明文继续。
|
||||
// - UseTLS=false:明文连接后若服务器支持 STARTTLS 则机会式升级,
|
||||
// 与 smtp.SendMail 的默认行为一致。
|
||||
func (s *EmailService) connectSMTP(config *SMTPConfig) (*smtp.Client, error) {
|
||||
addr := fmt.Sprintf("%s:%d", config.Host, config.Port)
|
||||
dialer := &net.Dialer{Timeout: smtpDialTimeout}
|
||||
tlsConfig := smtpTLSConfig(config.Host)
|
||||
|
||||
if config.UseTLS {
|
||||
conn, err := tls.DialWithDialer(dialer, "tcp", addr, tlsConfig)
|
||||
if err == nil {
|
||||
return newSMTPClient(conn, config.Host)
|
||||
}
|
||||
var recordErr tls.RecordHeaderError
|
||||
if !errors.As(err, &recordErr) {
|
||||
return nil, fmt.Errorf("tls dial: %w", err)
|
||||
}
|
||||
// SMTP 服务器先发问候语:明文问候会让 TLS 握手立刻返回
|
||||
// RecordHeaderError,据此可靠判定对端期望 STARTTLS。
|
||||
return s.connectSMTPStartTLS(dialer, addr, config.Host, tlsConfig, true)
|
||||
}
|
||||
|
||||
return s.connectSMTPStartTLS(dialer, addr, config.Host, tlsConfig, false)
|
||||
}
|
||||
|
||||
// connectSMTPStartTLS 建立明文连接并按需升级 STARTTLS。
|
||||
// mandatory 为 true 时服务器必须支持 STARTTLS,否则报错。
|
||||
func (s *EmailService) connectSMTPStartTLS(dialer *net.Dialer, addr, host string, tlsConfig *tls.Config, mandatory bool) (*smtp.Client, error) {
|
||||
conn, err := dialer.Dial("tcp", addr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("smtp dial: %w", err)
|
||||
}
|
||||
client, err := newSMTPClient(conn, host)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if ok, _ := client.Extension("STARTTLS"); !ok {
|
||||
if mandatory {
|
||||
_ = client.Close()
|
||||
return nil, errors.New("smtp server does not support STARTTLS")
|
||||
}
|
||||
return client, nil
|
||||
}
|
||||
if err := client.StartTLS(tlsConfig); err != nil {
|
||||
_ = client.Close()
|
||||
return nil, fmt.Errorf("starttls: %w", err)
|
||||
}
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func newSMTPClient(conn net.Conn, host string) (*smtp.Client, error) {
|
||||
_ = conn.SetDeadline(time.Now().Add(smtpIOTimeout))
|
||||
client, err := smtp.NewClient(conn, host)
|
||||
if err != nil {
|
||||
_ = conn.Close()
|
||||
return nil, fmt.Errorf("new smtp client: %w", err)
|
||||
}
|
||||
return client, nil
|
||||
}
|
||||
|
||||
// GenerateVerifyCode 生成6位数字验证码
|
||||
func (s *EmailService) GenerateVerifyCode() (string, error) {
|
||||
const digits = "0123456789"
|
||||
@@ -451,49 +449,24 @@ func (s *EmailService) buildVerifyCodeEmailBody(code, siteName string) string {
|
||||
`, html.EscapeString(siteName), code)
|
||||
}
|
||||
|
||||
// TestSMTPConnectionWithConfig 使用指定配置测试SMTP连接
|
||||
// TestSMTPConnectionWithConfig 使用指定配置测试SMTP连接。
|
||||
// 与 SendEmailWithConfig 共用 connectSMTP 建连(含 STARTTLS 升级逻辑),
|
||||
// 避免出现"测试连接失败但实际发信成功"的不一致。
|
||||
func (s *EmailService) TestSMTPConnectionWithConfig(config *SMTPConfig) error {
|
||||
addr := fmt.Sprintf("%s:%d", config.Host, config.Port)
|
||||
|
||||
if config.UseTLS {
|
||||
tlsConfig := &tls.Config{
|
||||
ServerName: config.Host,
|
||||
// 与发送逻辑一致,显式要求 TLS 1.2+。
|
||||
MinVersion: tls.VersionTLS12,
|
||||
}
|
||||
conn, err := tls.Dial("tcp", addr, tlsConfig)
|
||||
if err != nil {
|
||||
return fmt.Errorf("tls connection failed: %w", err)
|
||||
}
|
||||
defer func() { _ = conn.Close() }()
|
||||
|
||||
client, err := smtp.NewClient(conn, config.Host)
|
||||
if err != nil {
|
||||
return fmt.Errorf("smtp client creation failed: %w", err)
|
||||
}
|
||||
defer func() { _ = client.Close() }()
|
||||
|
||||
auth := smtp.PlainAuth("", config.Username, config.Password, config.Host)
|
||||
if err = client.Auth(auth); err != nil {
|
||||
return fmt.Errorf("smtp authentication failed: %w", err)
|
||||
}
|
||||
|
||||
return client.Quit()
|
||||
}
|
||||
|
||||
// 非TLS连接测试
|
||||
client, err := smtp.Dial(addr)
|
||||
client, err := s.connectSMTP(config)
|
||||
if err != nil {
|
||||
return fmt.Errorf("smtp connection failed: %w", err)
|
||||
}
|
||||
defer func() { _ = client.Close() }()
|
||||
|
||||
auth := smtp.PlainAuth("", config.Username, config.Password, config.Host)
|
||||
if err = client.Auth(auth); err != nil {
|
||||
if err := client.Auth(auth); err != nil {
|
||||
return fmt.Errorf("smtp authentication failed: %w", err)
|
||||
}
|
||||
|
||||
return client.Quit()
|
||||
// 认证成功即视为连接可用;与发送路径一致,忽略 QUIT 的非标准响应。
|
||||
_ = client.Quit()
|
||||
return nil
|
||||
}
|
||||
|
||||
// GeneratePasswordResetToken generates a secure 32-byte random token (64 hex characters)
|
||||
|
||||
@@ -0,0 +1,383 @@
|
||||
//go:build unit
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"math/big"
|
||||
"net"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// newSMTPTestCert 生成 127.0.0.1/localhost 的自签证书及其信任池。
|
||||
func newSMTPTestCert(t *testing.T) (tls.Certificate, *x509.CertPool) {
|
||||
t.Helper()
|
||||
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("generate key: %v", err)
|
||||
}
|
||||
template := x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
Subject: pkix.Name{CommonName: "127.0.0.1"},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().Add(time.Hour),
|
||||
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||
BasicConstraintsValid: true,
|
||||
IsCA: true,
|
||||
IPAddresses: []net.IP{net.ParseIP("127.0.0.1")},
|
||||
DNSNames: []string{"localhost"},
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv)
|
||||
if err != nil {
|
||||
t.Fatalf("create certificate: %v", err)
|
||||
}
|
||||
leaf, err := x509.ParseCertificate(der)
|
||||
if err != nil {
|
||||
t.Fatalf("parse certificate: %v", err)
|
||||
}
|
||||
pool := x509.NewCertPool()
|
||||
pool.AddCert(leaf)
|
||||
return tls.Certificate{Certificate: [][]byte{der}, PrivateKey: priv}, pool
|
||||
}
|
||||
|
||||
// fakeSMTPServer 是覆盖三种连接形态的最小 SMTP 服务器:
|
||||
// 隐式 TLS(465 语义)、明文+STARTTLS(587 语义)、纯明文。
|
||||
type fakeSMTPServer struct {
|
||||
listener net.Listener
|
||||
tlsConfig *tls.Config
|
||||
advertiseStartTLS bool
|
||||
|
||||
mu sync.Mutex
|
||||
commands []string
|
||||
conns atomic.Int64
|
||||
wg sync.WaitGroup
|
||||
}
|
||||
|
||||
func startFakeSMTPServer(t *testing.T, implicitTLS, advertiseStartTLS bool) (*fakeSMTPServer, int) {
|
||||
t.Helper()
|
||||
cert, pool := newSMTPTestCert(t)
|
||||
prevPool := smtpTestRootCAs
|
||||
smtpTestRootCAs = pool
|
||||
t.Cleanup(func() { smtpTestRootCAs = prevPool })
|
||||
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
srv := &fakeSMTPServer{
|
||||
listener: listener,
|
||||
tlsConfig: &tls.Config{Certificates: []tls.Certificate{cert}, MinVersion: tls.VersionTLS12},
|
||||
advertiseStartTLS: advertiseStartTLS,
|
||||
}
|
||||
if implicitTLS {
|
||||
srv.listener = tls.NewListener(listener, srv.tlsConfig)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = srv.listener.Close()
|
||||
srv.wg.Wait()
|
||||
})
|
||||
|
||||
srv.wg.Add(1)
|
||||
go func() {
|
||||
defer srv.wg.Done()
|
||||
for {
|
||||
conn, err := srv.listener.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
srv.conns.Add(1)
|
||||
srv.wg.Add(1)
|
||||
go func() {
|
||||
defer srv.wg.Done()
|
||||
defer func() { _ = conn.Close() }()
|
||||
_ = conn.SetDeadline(time.Now().Add(10 * time.Second))
|
||||
srv.serve(conn, srv.advertiseStartTLS)
|
||||
}()
|
||||
}
|
||||
}()
|
||||
|
||||
port := listener.Addr().(*net.TCPAddr).Port
|
||||
return srv, port
|
||||
}
|
||||
|
||||
func (srv *fakeSMTPServer) record(cmd string) {
|
||||
srv.mu.Lock()
|
||||
defer srv.mu.Unlock()
|
||||
srv.commands = append(srv.commands, cmd)
|
||||
}
|
||||
|
||||
func (srv *fakeSMTPServer) sawCommand(prefix string) bool {
|
||||
srv.mu.Lock()
|
||||
defer srv.mu.Unlock()
|
||||
for _, cmd := range srv.commands {
|
||||
if strings.HasPrefix(strings.ToUpper(cmd), prefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (srv *fakeSMTPServer) serve(conn net.Conn, allowStartTLS bool) {
|
||||
reader := bufio.NewReader(conn)
|
||||
writer := bufio.NewWriter(conn)
|
||||
writeLine := func(line string) bool {
|
||||
if _, err := writer.WriteString(line + "\r\n"); err != nil {
|
||||
return false
|
||||
}
|
||||
return writer.Flush() == nil
|
||||
}
|
||||
if !writeLine("220 fake.test ESMTP ready") {
|
||||
return
|
||||
}
|
||||
for {
|
||||
line, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
cmd := strings.TrimSpace(line)
|
||||
srv.record(cmd)
|
||||
upper := strings.ToUpper(cmd)
|
||||
switch {
|
||||
case strings.HasPrefix(upper, "EHLO"), strings.HasPrefix(upper, "HELO"):
|
||||
ok := writeLine("250-fake.test")
|
||||
if allowStartTLS {
|
||||
ok = ok && writeLine("250-STARTTLS")
|
||||
}
|
||||
if !(ok && writeLine("250-AUTH PLAIN LOGIN") && writeLine("250 8BITMIME")) {
|
||||
return
|
||||
}
|
||||
case upper == "STARTTLS" && allowStartTLS:
|
||||
if !writeLine("220 2.0.0 ready to start TLS") {
|
||||
return
|
||||
}
|
||||
tlsConn := tls.Server(conn, srv.tlsConfig)
|
||||
if err := tlsConn.Handshake(); err != nil {
|
||||
return
|
||||
}
|
||||
srv.serveUpgraded(tlsConn)
|
||||
return
|
||||
case strings.HasPrefix(upper, "AUTH"):
|
||||
if !writeLine("235 2.7.0 authentication successful") {
|
||||
return
|
||||
}
|
||||
case strings.HasPrefix(upper, "MAIL"), strings.HasPrefix(upper, "RCPT"):
|
||||
if !writeLine("250 ok") {
|
||||
return
|
||||
}
|
||||
case upper == "DATA":
|
||||
if !writeLine("354 go ahead") {
|
||||
return
|
||||
}
|
||||
for {
|
||||
dataLine, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if strings.TrimRight(dataLine, "\r\n") == "." {
|
||||
break
|
||||
}
|
||||
}
|
||||
if !writeLine("250 message accepted") {
|
||||
return
|
||||
}
|
||||
case upper == "QUIT":
|
||||
_ = writeLine("221 bye")
|
||||
return
|
||||
default:
|
||||
if !writeLine("250 ok") {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// serveUpgraded 复用命令循环处理 STARTTLS 升级后的会话(升级后不再提供 STARTTLS)。
|
||||
func (srv *fakeSMTPServer) serveUpgraded(conn net.Conn) {
|
||||
reader := bufio.NewReader(conn)
|
||||
writer := bufio.NewWriter(conn)
|
||||
// net/smtp 在 StartTLS 成功后会重新发送 EHLO,直接进入命令循环即可。
|
||||
srv.serveCommands(reader, writer)
|
||||
}
|
||||
|
||||
func (srv *fakeSMTPServer) serveCommands(reader *bufio.Reader, writer *bufio.Writer) {
|
||||
writeLine := func(line string) bool {
|
||||
if _, err := writer.WriteString(line + "\r\n"); err != nil {
|
||||
return false
|
||||
}
|
||||
return writer.Flush() == nil
|
||||
}
|
||||
for {
|
||||
line, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
cmd := strings.TrimSpace(line)
|
||||
srv.record(cmd)
|
||||
upper := strings.ToUpper(cmd)
|
||||
switch {
|
||||
case strings.HasPrefix(upper, "EHLO"), strings.HasPrefix(upper, "HELO"):
|
||||
if !(writeLine("250-fake.test") && writeLine("250-AUTH PLAIN LOGIN") && writeLine("250 8BITMIME")) {
|
||||
return
|
||||
}
|
||||
case strings.HasPrefix(upper, "AUTH"):
|
||||
if !writeLine("235 2.7.0 authentication successful") {
|
||||
return
|
||||
}
|
||||
case strings.HasPrefix(upper, "MAIL"), strings.HasPrefix(upper, "RCPT"):
|
||||
if !writeLine("250 ok") {
|
||||
return
|
||||
}
|
||||
case upper == "DATA":
|
||||
if !writeLine("354 go ahead") {
|
||||
return
|
||||
}
|
||||
for {
|
||||
dataLine, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if strings.TrimRight(dataLine, "\r\n") == "." {
|
||||
break
|
||||
}
|
||||
}
|
||||
if !writeLine("250 message accepted") {
|
||||
return
|
||||
}
|
||||
case upper == "QUIT":
|
||||
_ = writeLine("221 bye")
|
||||
return
|
||||
default:
|
||||
if !writeLine("250 ok") {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func smtpTestConfig(port int, useTLS bool) *SMTPConfig {
|
||||
return &SMTPConfig{
|
||||
Host: "127.0.0.1",
|
||||
Port: port,
|
||||
Username: "user",
|
||||
Password: "pass",
|
||||
From: "noreply@example.com",
|
||||
FromName: "Test",
|
||||
UseTLS: useTLS,
|
||||
}
|
||||
}
|
||||
|
||||
// 465 语义:UseTLS=true + 隐式 TLS 服务器,原有路径保持可用。
|
||||
func TestSMTPConnectionImplicitTLS(t *testing.T) {
|
||||
srv, port := startFakeSMTPServer(t, true, false)
|
||||
svc := &EmailService{}
|
||||
|
||||
if err := svc.TestSMTPConnectionWithConfig(smtpTestConfig(port, true)); err != nil {
|
||||
t.Fatalf("expected implicit TLS connection to succeed, got: %v", err)
|
||||
}
|
||||
if !srv.sawCommand("EHLO") {
|
||||
t.Fatal("expected server to receive EHLO")
|
||||
}
|
||||
}
|
||||
|
||||
// 587 语义(#1470/#1488 核心场景):UseTLS=true + 明文问候的 STARTTLS 服务器,
|
||||
// 隐式 TLS 失败后必须自动降级为强制 STARTTLS 并成功。
|
||||
func TestSMTPConnectionStartTLSFallbackWhenTLSEnabled(t *testing.T) {
|
||||
srv, port := startFakeSMTPServer(t, false, true)
|
||||
svc := &EmailService{}
|
||||
|
||||
if err := svc.TestSMTPConnectionWithConfig(smtpTestConfig(port, true)); err != nil {
|
||||
t.Fatalf("expected STARTTLS fallback to succeed, got: %v", err)
|
||||
}
|
||||
if !srv.sawCommand("STARTTLS") {
|
||||
t.Fatal("expected server to receive STARTTLS command")
|
||||
}
|
||||
if got := srv.conns.Load(); got < 2 {
|
||||
t.Fatalf("expected implicit TLS attempt before STARTTLS fallback (>=2 connections), got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
// UseTLS=true 但服务器不支持 STARTTLS:必须报错,且绝不能把凭据发到明文连接上。
|
||||
func TestSMTPConnectionMandatoryStartTLSRefusesPlaintext(t *testing.T) {
|
||||
srv, port := startFakeSMTPServer(t, false, false)
|
||||
svc := &EmailService{}
|
||||
|
||||
err := svc.TestSMTPConnectionWithConfig(smtpTestConfig(port, true))
|
||||
if err == nil {
|
||||
t.Fatal("expected error when server does not support STARTTLS")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "STARTTLS") {
|
||||
t.Fatalf("expected STARTTLS-related error, got: %v", err)
|
||||
}
|
||||
if srv.sawCommand("AUTH") {
|
||||
t.Fatal("credentials must not be sent over plaintext when TLS is required")
|
||||
}
|
||||
}
|
||||
|
||||
// UseTLS=false + 服务器支持 STARTTLS:测试连接与发送路径一致,机会式升级后认证成功。
|
||||
// 这是 #1488 评论"测试连接不成功,发送测试邮件实际上能发"的回归用例。
|
||||
func TestSMTPConnectionOpportunisticStartTLSWhenTLSDisabled(t *testing.T) {
|
||||
srv, port := startFakeSMTPServer(t, false, true)
|
||||
svc := &EmailService{}
|
||||
|
||||
if err := svc.TestSMTPConnectionWithConfig(smtpTestConfig(port, false)); err != nil {
|
||||
t.Fatalf("expected opportunistic STARTTLS test connection to succeed, got: %v", err)
|
||||
}
|
||||
if !srv.sawCommand("STARTTLS") {
|
||||
t.Fatal("expected test connection to upgrade via STARTTLS like the send path")
|
||||
}
|
||||
}
|
||||
|
||||
// UseTLS=false + 服务器不支持 STARTTLS:保持明文直连(既有行为不回归)。
|
||||
func TestSMTPConnectionPlainWhenNoStartTLS(t *testing.T) {
|
||||
srv, port := startFakeSMTPServer(t, false, false)
|
||||
svc := &EmailService{}
|
||||
|
||||
if err := svc.TestSMTPConnectionWithConfig(smtpTestConfig(port, false)); err != nil {
|
||||
t.Fatalf("expected plain connection to succeed, got: %v", err)
|
||||
}
|
||||
if srv.sawCommand("STARTTLS") {
|
||||
t.Fatal("did not expect STARTTLS command when server does not advertise it")
|
||||
}
|
||||
}
|
||||
|
||||
// 发送路径全流程:UseTLS=true + STARTTLS 服务器(587 语义)完整走完 MAIL/RCPT/DATA。
|
||||
func TestSendEmailWithConfigStartTLSFallback(t *testing.T) {
|
||||
srv, port := startFakeSMTPServer(t, false, true)
|
||||
svc := &EmailService{}
|
||||
|
||||
err := svc.SendEmailWithConfig(smtpTestConfig(port, true), "rcpt@example.com", "subject", "<p>body</p>")
|
||||
if err != nil {
|
||||
t.Fatalf("expected send via STARTTLS fallback to succeed, got: %v", err)
|
||||
}
|
||||
if !srv.sawCommand("STARTTLS") {
|
||||
t.Fatal("expected send path to upgrade via STARTTLS")
|
||||
}
|
||||
if !srv.sawCommand("DATA") {
|
||||
t.Fatal("expected send path to reach DATA")
|
||||
}
|
||||
}
|
||||
|
||||
// 发送路径全流程:UseTLS=true + 隐式 TLS 服务器(465 语义)保持既有行为。
|
||||
func TestSendEmailWithConfigImplicitTLS(t *testing.T) {
|
||||
srv, port := startFakeSMTPServer(t, true, false)
|
||||
svc := &EmailService{}
|
||||
|
||||
err := svc.SendEmailWithConfig(smtpTestConfig(port, true), "rcpt@example.com", "subject", "<p>body</p>")
|
||||
if err != nil {
|
||||
t.Fatalf("expected send via implicit TLS to succeed, got: %v", err)
|
||||
}
|
||||
if !srv.sawCommand("DATA") {
|
||||
t.Fatal("expected send path to reach DATA")
|
||||
}
|
||||
}
|
||||
@@ -259,6 +259,74 @@ func TestHandleGrokAccountUpstreamErrorEntitlement403KeepsDefaultCooldown(t *tes
|
||||
require.Less(t, repo.lastTempUnschedUntil, before.Add(31*time.Minute))
|
||||
}
|
||||
|
||||
func TestHandleGrokAccountUpstreamErrorDefaultCooldownsRespectPoolMode(t *testing.T) {
|
||||
for _, statusCode := range []int{
|
||||
http.StatusUnauthorized,
|
||||
http.StatusPaymentRequired,
|
||||
http.StatusForbidden,
|
||||
http.StatusInternalServerError,
|
||||
} {
|
||||
t.Run(http.StatusText(statusCode), func(t *testing.T) {
|
||||
repo := &grokQuotaAccountRepo{}
|
||||
svc := &OpenAIGatewayService{accountRepo: repo}
|
||||
account := &Account{
|
||||
ID: int64(4800 + statusCode),
|
||||
Platform: PlatformGrok,
|
||||
Type: AccountTypeAPIKey,
|
||||
Credentials: map[string]any{
|
||||
"pool_mode": true,
|
||||
},
|
||||
}
|
||||
body := []byte(`{"error":{"message":"grok access or entitlement denied"}}`)
|
||||
|
||||
svc.handleGrokAccountUpstreamError(
|
||||
context.Background(), account, statusCode, nil, body,
|
||||
)
|
||||
|
||||
require.Zero(t, repo.tempUnschedCalls)
|
||||
require.False(t, svc.isOpenAIAccountRuntimeBlocked(account))
|
||||
require.Nil(t, account.TempUnschedulableUntil)
|
||||
require.Empty(t, account.TempUnschedulableReason)
|
||||
require.True(t, svc.shouldFailoverGrokUpstreamError(statusCode, body))
|
||||
})
|
||||
}
|
||||
|
||||
account := &Account{Type: AccountTypeAPIKey, Credentials: map[string]any{"pool_mode": true}}
|
||||
require.True(t, account.IsPoolModeRetryableStatus(http.StatusForbidden))
|
||||
|
||||
t.Run("explicit temporary rule still applies", func(t *testing.T) {
|
||||
repo := &grokQuotaAccountRepo{}
|
||||
svc := &OpenAIGatewayService{accountRepo: repo}
|
||||
account := &Account{
|
||||
ID: 4723,
|
||||
Platform: PlatformGrok,
|
||||
Type: AccountTypeAPIKey,
|
||||
Credentials: map[string]any{
|
||||
"pool_mode": true,
|
||||
"temp_unschedulable_enabled": true,
|
||||
"temp_unschedulable_rules": []any{
|
||||
map[string]any{
|
||||
"error_code": float64(http.StatusForbidden),
|
||||
"keywords": []any{"entitlement denied"},
|
||||
"duration_minutes": float64(7),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
before := time.Now()
|
||||
|
||||
svc.handleGrokAccountUpstreamError(
|
||||
context.Background(), account, http.StatusForbidden, nil,
|
||||
[]byte(`{"error":{"message":"grok access or entitlement denied"}}`),
|
||||
)
|
||||
|
||||
require.Equal(t, 1, repo.tempUnschedCalls)
|
||||
require.Equal(t, "grok configured forbidden rule", repo.lastTempUnschedReason)
|
||||
require.WithinDuration(t, before.Add(7*time.Minute), repo.lastTempUnschedUntil, time.Second)
|
||||
require.True(t, svc.isOpenAIAccountRuntimeBlocked(account))
|
||||
})
|
||||
}
|
||||
|
||||
func TestHandleGrokAccountUpstreamError403UsesConfiguredRule(t *testing.T) {
|
||||
repo := &grokQuotaAccountRepo{}
|
||||
svc := &OpenAIGatewayService{accountRepo: repo}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -126,6 +127,9 @@ func (u *ImageResultUploader) fetchImageBytes(ctx context.Context, item map[stri
|
||||
var rawURL string
|
||||
if err := json.Unmarshal(raw, &rawURL); err == nil {
|
||||
if rawURL = strings.TrimSpace(rawURL); rawURL != "" {
|
||||
if len(rawURL) >= len("data:") && strings.EqualFold(rawURL[:len("data:")], "data:") {
|
||||
return u.decodeImageDataURL(rawURL)
|
||||
}
|
||||
return u.download(ctx, rawURL)
|
||||
}
|
||||
}
|
||||
@@ -133,6 +137,60 @@ func (u *ImageResultUploader) fetchImageBytes(ctx context.Context, item map[stri
|
||||
return nil, "", errors.New("image item has neither b64_json nor url")
|
||||
}
|
||||
|
||||
func (u *ImageResultUploader) decodeImageDataURL(rawURL string) ([]byte, string, error) {
|
||||
header, payload, ok := strings.Cut(rawURL[len("data:"):], ",")
|
||||
if !ok {
|
||||
return nil, "", errors.New("decode image data URL: missing comma separator")
|
||||
}
|
||||
|
||||
parts := strings.Split(header, ";")
|
||||
if strings.TrimSpace(parts[0]) == "" {
|
||||
return nil, "", errors.New("decode image data URL: missing media type")
|
||||
}
|
||||
base64Index := len(parts) - 1
|
||||
if base64Index < 1 || !strings.EqualFold(strings.TrimSpace(parts[base64Index]), "base64") {
|
||||
for i := 1; i < base64Index; i++ {
|
||||
if strings.EqualFold(strings.TrimSpace(parts[i]), "base64") {
|
||||
return nil, "", errors.New("decode image data URL: base64 marker must be the final header token")
|
||||
}
|
||||
}
|
||||
return nil, "", errors.New("decode image data URL: payload is not base64 encoded")
|
||||
}
|
||||
for i := 1; i < base64Index; i++ {
|
||||
if strings.EqualFold(strings.TrimSpace(parts[i]), "base64") {
|
||||
return nil, "", errors.New("decode image data URL: duplicate base64 marker")
|
||||
}
|
||||
}
|
||||
mediaTypeHeader := strings.Join(parts[:base64Index], ";")
|
||||
declaredType, _, err := mime.ParseMediaType(mediaTypeHeader)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("decode image data URL: invalid media type: %w", err)
|
||||
}
|
||||
declaredType = strings.ToLower(declaredType)
|
||||
if !strings.HasPrefix(declaredType, "image/") {
|
||||
return nil, "", fmt.Errorf("decode image data URL: media type %q is not an image", declaredType)
|
||||
}
|
||||
|
||||
limit := u.maxDownloadBytes
|
||||
if limit <= 0 {
|
||||
limit = defaultImageMaxDownloadBytes
|
||||
}
|
||||
decoder := base64.NewDecoder(base64.StdEncoding, strings.NewReader(payload))
|
||||
data, err := io.ReadAll(io.LimitReader(decoder, limit+1))
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("decode image data URL base64 payload: %w", err)
|
||||
}
|
||||
if int64(len(data)) > limit {
|
||||
return nil, "", fmt.Errorf("decoded image data URL exceeds %d bytes", limit)
|
||||
}
|
||||
|
||||
contentType := detectedImageContentType(data)
|
||||
if contentType == "" {
|
||||
contentType = declaredType
|
||||
}
|
||||
return data, contentType, nil
|
||||
}
|
||||
|
||||
func (u *ImageResultUploader) download(ctx context.Context, rawURL string) ([]byte, string, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
|
||||
if err != nil {
|
||||
@@ -169,11 +227,18 @@ func (u *ImageResultUploader) buildKey(taskID string, index int, contentType str
|
||||
}
|
||||
|
||||
func detectImageContentType(data []byte) string {
|
||||
if ct := detectedImageContentType(data); ct != "" {
|
||||
return ct
|
||||
}
|
||||
return "image/png"
|
||||
}
|
||||
|
||||
func detectedImageContentType(data []byte) string {
|
||||
ct := strings.TrimSpace(strings.Split(http.DetectContentType(data), ";")[0])
|
||||
if strings.HasPrefix(ct, "image/") {
|
||||
return ct
|
||||
}
|
||||
return "image/png"
|
||||
return ""
|
||||
}
|
||||
|
||||
func extensionForContentType(ct string) string {
|
||||
|
||||
@@ -29,6 +29,12 @@ type fakeImageStorage struct {
|
||||
err error
|
||||
}
|
||||
|
||||
type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return f(req)
|
||||
}
|
||||
|
||||
func (f *fakeImageStorage) Save(_ context.Context, key, contentType string, data []byte) (string, error) {
|
||||
if f.err != nil {
|
||||
return "", f.err
|
||||
@@ -91,6 +97,89 @@ func TestImageResultUploaderRewritesURL(t *testing.T) {
|
||||
require.JSONEq(t, `"https://cdn.test/images/imgtask_xyz-0.png"`, string(parsed.Data[0]["url"]))
|
||||
}
|
||||
|
||||
func TestImageResultUploaderRewritesImageDataURLWithoutHTTP(t *testing.T) {
|
||||
httpCalls := 0
|
||||
client := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||
httpCalls++
|
||||
return nil, errors.New("HTTP must not be called for data URLs")
|
||||
})}
|
||||
storage := &fakeImageStorage{}
|
||||
uploader := NewImageResultUploader(storage, "images/", 0, client)
|
||||
b64 := base64.StdEncoding.EncodeToString(pngBytes)
|
||||
result := json.RawMessage(`{"data":[{"url":"DATA:image/jpeg;name=photo.jpg;BaSe64,` + b64 + `","revised_prompt":"kept"}]}`)
|
||||
|
||||
out, err := uploader.Rewrite(context.Background(), "imgtask_data", result)
|
||||
require.NoError(t, err)
|
||||
require.Zero(t, httpCalls)
|
||||
require.Len(t, storage.saved, 1)
|
||||
require.Equal(t, pngBytes, storage.saved[0].data)
|
||||
require.Equal(t, "image/png", storage.saved[0].contentType, "detected bytes take precedence over a conflicting declaration")
|
||||
require.Equal(t, "images/imgtask_data-0.png", storage.saved[0].key)
|
||||
|
||||
var parsed struct {
|
||||
Data []map[string]json.RawMessage `json:"data"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(out, &parsed))
|
||||
require.JSONEq(t, `"https://cdn.test/images/imgtask_data-0.png"`, string(parsed.Data[0]["url"]))
|
||||
require.JSONEq(t, `"kept"`, string(parsed.Data[0]["revised_prompt"]))
|
||||
}
|
||||
|
||||
func TestImageResultUploaderDataURLValidation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
url string
|
||||
wantErr string
|
||||
}{
|
||||
{name: "missing comma", url: "data:image/png;base64", wantErr: "missing comma"},
|
||||
{name: "non image", url: "data:text/plain;base64,aGVsbG8=", wantErr: "is not an image"},
|
||||
{name: "non base64", url: "data:image/png,raw", wantErr: "not base64"},
|
||||
{name: "invalid base64", url: "data:image/png;base64,%%%", wantErr: "base64 payload"},
|
||||
{name: "invalid media type", url: "data:image/png;bad parameter;base64,aGVsbG8=", wantErr: "invalid media type"},
|
||||
{name: "parameter after base64", url: "data:image/png;base64;name=photo.png,aGVsbG8=", wantErr: "base64 marker must be the final header token"},
|
||||
{name: "duplicate base64 marker", url: "data:image/png;base64;base64,aGVsbG8=", wantErr: "duplicate base64 marker"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
httpCalls := 0
|
||||
client := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||
httpCalls++
|
||||
return nil, errors.New("HTTP must not be called for data URLs")
|
||||
})}
|
||||
uploader := NewImageResultUploader(&fakeImageStorage{}, "images/", 0, client)
|
||||
result, err := json.Marshal(map[string]any{"data": []map[string]string{{"url": tt.url}}})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = uploader.Rewrite(context.Background(), "imgtask_bad", result)
|
||||
require.ErrorContains(t, err, tt.wantErr)
|
||||
require.Zero(t, httpCalls)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestImageResultUploaderRejectsOversizedImageDataURL(t *testing.T) {
|
||||
storage := &fakeImageStorage{}
|
||||
uploader := NewImageResultUploader(storage, "images/", 3, nil)
|
||||
payload := base64.StdEncoding.EncodeToString([]byte("four"))
|
||||
result := json.RawMessage(`{"data":[{"url":"data:image/png;base64,` + payload + `"}]}`)
|
||||
|
||||
_, err := uploader.Rewrite(context.Background(), "imgtask_large", result)
|
||||
require.ErrorContains(t, err, "decoded image data URL exceeds 3 bytes")
|
||||
require.Empty(t, storage.saved)
|
||||
}
|
||||
|
||||
func TestImageResultUploaderB64JSONTakesPrecedenceOverDataURL(t *testing.T) {
|
||||
storage := &fakeImageStorage{}
|
||||
uploader := NewImageResultUploader(storage, "images/", 0, nil)
|
||||
b64 := base64.StdEncoding.EncodeToString(pngBytes)
|
||||
result := json.RawMessage(`{"data":[{"b64_json":"` + b64 + `","url":"data:text/plain,not-an-image"}]}`)
|
||||
|
||||
_, err := uploader.Rewrite(context.Background(), "imgtask_precedence", result)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, storage.saved, 1)
|
||||
require.Equal(t, pngBytes, storage.saved[0].data)
|
||||
}
|
||||
|
||||
func TestImageResultUploaderPropagatesStorageError(t *testing.T) {
|
||||
storage := &fakeImageStorage{err: errors.New("bucket unreachable")}
|
||||
uploader := NewImageResultUploader(storage, "images/", 0, nil)
|
||||
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
// Forward forwards request to OpenAI API
|
||||
func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, account *Account, body []byte) (*OpenAIForwardResult, error) {
|
||||
clearGrokResponsesClientToolMapping(c)
|
||||
clearOpenAIResponsesNamespaceNames(c)
|
||||
startTime := time.Now()
|
||||
// 固定渠道映射后的请求级 canonical body;账号 normalize/strip 不得改写跨 failover hint。
|
||||
canonicalImageIntentBody := body
|
||||
@@ -62,7 +63,8 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
|
||||
// 仅允许 WS 入站请求走 WS 上游,避免出现 HTTP -> WS 协议混用。
|
||||
wsDecision = resolveOpenAIWSDecisionByClientTransport(wsDecision, GetOpenAIClientTransport(c))
|
||||
passthroughEnabled := account.IsOpenAIPassthroughEnabled()
|
||||
if shouldFlattenOpenAIResponsesNamespaces(account, wsDecision.Transport, passthroughEnabled) {
|
||||
compactPath := isOpenAIResponsesCompactPath(c)
|
||||
if shouldFlattenOpenAIResponsesNamespaces(account, wsDecision.Transport, passthroughEnabled, compactPath) {
|
||||
body, err = flattenOpenAIResponsesNamespaces(c, body)
|
||||
if err != nil {
|
||||
setOpsUpstreamError(c, http.StatusBadRequest, err.Error(), "")
|
||||
@@ -73,7 +75,10 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
|
||||
}
|
||||
}
|
||||
if shouldStripOpenAIResponsesInputNamespaces(account, wsDecision.Transport, passthroughEnabled) {
|
||||
body, err = stripOpenAIResponsesInputNamespaces(body)
|
||||
keepToolCallNamespaces := shouldKeepOpenAIResponsesToolCallNamespaces(
|
||||
account, wsDecision.Transport, passthroughEnabled, compactPath,
|
||||
)
|
||||
body, err = stripOpenAIResponsesInputNamespaces(body, keepToolCallNamespaces)
|
||||
if err != nil {
|
||||
setOpsUpstreamError(c, http.StatusBadRequest, err.Error(), "")
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": gin.H{
|
||||
@@ -273,7 +278,7 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
|
||||
markPatchSet("model", billingModel)
|
||||
}
|
||||
upstreamModel := billingModel
|
||||
isCompactRequest := isOpenAIResponsesCompactPath(c)
|
||||
isCompactRequest := compactPath
|
||||
compactMapped := false
|
||||
if isCompactRequest {
|
||||
compactMappedModel := resolveOpenAICompactForwardModel(account, billingModel)
|
||||
|
||||
@@ -179,11 +179,12 @@ func (s *OpenAIGatewayService) forwardGrokResponses(
|
||||
var firstTokenMs *int
|
||||
responseID := ""
|
||||
if reqStream {
|
||||
maxLineSize := defaultMaxLineSize
|
||||
if s.cfg != nil && s.cfg.Gateway.MaxLineSize > 0 {
|
||||
maxLineSize = s.cfg.Gateway.MaxLineSize
|
||||
}
|
||||
resp.Body = newGrokResponsesBillingPingFilterBody(resp.Body, account, maxLineSize)
|
||||
if hasGrokResponsesClientToolMapping(clientToolMapping) {
|
||||
maxLineSize := defaultMaxLineSize
|
||||
if s.cfg != nil && s.cfg.Gateway.MaxLineSize > 0 {
|
||||
maxLineSize = s.cfg.Gateway.MaxLineSize
|
||||
}
|
||||
resp.Body = newGrokResponsesClientToolStreamBody(resp.Body, clientToolMapping, maxLineSize)
|
||||
}
|
||||
streamResult, err := s.handleStreamingResponse(ctx, resp, c, account, startTime, originalModel, upstreamModel)
|
||||
@@ -1129,12 +1130,11 @@ func (s *OpenAIGatewayService) updateGrokUsageSnapshot(ctx context.Context, acco
|
||||
grokQuotaSnapshotExtraKey: snapshot,
|
||||
})
|
||||
}
|
||||
// Error responses are reconciled by handleGrokAccountUpstreamError, which
|
||||
// also installs the immediate in-memory scheduling block. Successful
|
||||
// responses can still consume the last available request/token, so persist
|
||||
// that exhausted window here as a real rate limit rather than relying only
|
||||
// on the passive snapshot scheduler check.
|
||||
if hasActiveLimit {
|
||||
// Error responses are reconciled by handleGrokAccountUpstreamError. Pool-mode
|
||||
// API keys retain the snapshot for observability but leave account health to
|
||||
// the upstream pool. Other accounts install the immediate runtime and durable
|
||||
// rate-limit state when the observed window is exhausted.
|
||||
if hasActiveLimit && !account.IsPoolMode() {
|
||||
s.rateLimitGrok(stateCtx, account, resetAt)
|
||||
} else if recovery {
|
||||
clearGrokRateLimitAfterRecovery(stateCtx, s.accountRepo, account)
|
||||
@@ -1356,20 +1356,24 @@ func (s *OpenAIGatewayService) handleGrokAccountUpstreamError(ctx context.Contex
|
||||
}
|
||||
now := time.Now()
|
||||
s.updateGrokUsageSnapshot(ctx, account, parseGrokQuotaSnapshot(headers, statusCode, now))
|
||||
if statusCode == http.StatusForbidden && s.applyGrokForbiddenPolicy(ctx, account, responseBody) {
|
||||
return
|
||||
}
|
||||
if account.IsPoolMode() {
|
||||
slog.Info("grok_pool_mode_error_state_skipped", "account_id", account.ID, "status_code", statusCode)
|
||||
return
|
||||
}
|
||||
switch statusCode {
|
||||
case http.StatusUnauthorized:
|
||||
s.tempUnscheduleGrok(ctx, account, 10*time.Minute, "grok credentials unauthorized")
|
||||
case http.StatusPaymentRequired:
|
||||
s.tempUnscheduleGrok(ctx, account, 30*time.Minute, "grok payment required")
|
||||
case http.StatusForbidden:
|
||||
if s.applyGrokForbiddenPolicy(ctx, account, responseBody) {
|
||||
return
|
||||
}
|
||||
s.tempUnscheduleGrok(ctx, account, 30*time.Minute, "grok access or entitlement denied")
|
||||
case http.StatusTooManyRequests:
|
||||
// updateGrokUsageSnapshot installs both runtime and durable rate-limit state.
|
||||
// updateGrokUsageSnapshot installs rate-limit state for non-pool accounts.
|
||||
default:
|
||||
if statusCode >= 500 && !account.IsPoolMode() {
|
||||
if statusCode >= 500 {
|
||||
s.tempUnscheduleGrok(ctx, account, 2*time.Minute, "grok upstream temporary error")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// OpenAI Responses SSE event types are a closed enum for strict clients
|
||||
// (grok CLI, Codex CLI): an unknown `event: ping` frame aborts the whole
|
||||
// turn. Vendor gateways behind Grok subscriptions inject such frames for
|
||||
// billing/keepalive, so ping frames are rewritten into an SSE comment that
|
||||
// every parser ignores while the connection still looks alive downstream.
|
||||
var grokResponsesPingComment = []byte(": ping\n\n")
|
||||
|
||||
// A vendor ping frame is one event line plus one small data line. Cap what is
|
||||
// buffered while deciding, so an upstream streaming a frame that never ends
|
||||
// cannot grow gateway memory; frames over the cap are passed through as-is.
|
||||
const (
|
||||
grokResponsesPingFrameMaxLines = 16
|
||||
grokResponsesPingFrameMaxBytes = 16 * 1024
|
||||
)
|
||||
|
||||
type grokResponsesBillingPingFilterBody struct {
|
||||
*io.PipeReader
|
||||
source io.Closer
|
||||
closeOnce sync.Once
|
||||
closeErr error
|
||||
}
|
||||
|
||||
func (b *grokResponsesBillingPingFilterBody) Close() error {
|
||||
readerErr := b.PipeReader.Close()
|
||||
sourceErr := b.closeSource()
|
||||
if readerErr != nil {
|
||||
return readerErr
|
||||
}
|
||||
return sourceErr
|
||||
}
|
||||
|
||||
func (b *grokResponsesBillingPingFilterBody) closeSource() error {
|
||||
b.closeOnce.Do(func() { b.closeErr = b.source.Close() })
|
||||
return b.closeErr
|
||||
}
|
||||
|
||||
func newGrokResponsesBillingPingFilterBody(source io.ReadCloser, account *Account, maxLineSize int) io.ReadCloser {
|
||||
if account == nil || account.Platform != PlatformGrok {
|
||||
return source
|
||||
}
|
||||
reader, writer := io.Pipe()
|
||||
body := &grokResponsesBillingPingFilterBody{PipeReader: reader, source: source}
|
||||
go filterGrokResponsesBillingPings(source, writer, body.closeSource, maxLineSize)
|
||||
return body
|
||||
}
|
||||
|
||||
func filterGrokResponsesBillingPings(
|
||||
source io.Reader,
|
||||
destination *io.PipeWriter,
|
||||
closeSource func() error,
|
||||
maxLineSize int,
|
||||
) {
|
||||
defer func() { _ = closeSource() }()
|
||||
if maxLineSize <= 0 {
|
||||
maxLineSize = defaultMaxLineSize
|
||||
}
|
||||
|
||||
scanner := bufio.NewScanner(source)
|
||||
scanBuf := getSSEScannerBuf64K()
|
||||
defer putSSEScannerBuf64K(scanBuf)
|
||||
initialBufferSize := len(scanBuf)
|
||||
if maxLineSize < initialBufferSize {
|
||||
initialBufferSize = maxLineSize
|
||||
}
|
||||
scanner.Buffer(scanBuf[:0:initialBufferSize], maxLineSize)
|
||||
scanner.Split(scanSSELinesPreservingEndings)
|
||||
|
||||
// Only frames opened by an `event: ping` line are buffered (pingFrame);
|
||||
// every other frame streams through line by line without copying.
|
||||
pingFrame := make([][]byte, 0, 3)
|
||||
pingFrameBytes := 0
|
||||
inPassthroughFrame := false
|
||||
|
||||
replayPingFrame := func() error {
|
||||
for _, line := range pingFrame {
|
||||
if _, err := destination.Write(line); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
pingFrame = pingFrame[:0]
|
||||
pingFrameBytes = 0
|
||||
return nil
|
||||
}
|
||||
// endPingFrame decides a complete buffered candidate: vendor ping frames
|
||||
// become an SSE comment, everything else is replayed verbatim. blankLine
|
||||
// is nil when the stream ends inside the frame.
|
||||
endPingFrame := func(blankLine []byte) error {
|
||||
if isGrokResponsesPingEventFrame(pingFrame) {
|
||||
pingFrame = pingFrame[:0]
|
||||
pingFrameBytes = 0
|
||||
_, err := destination.Write(grokResponsesPingComment)
|
||||
return err
|
||||
}
|
||||
if err := replayPingFrame(); err != nil {
|
||||
return err
|
||||
}
|
||||
if blankLine == nil {
|
||||
return nil
|
||||
}
|
||||
_, err := destination.Write(blankLine)
|
||||
return err
|
||||
}
|
||||
abort := func(err error) { _ = destination.CloseWithError(err) }
|
||||
|
||||
for scanner.Scan() {
|
||||
line := scanner.Bytes()
|
||||
isBlank := len(trimSSELineEnding(line)) == 0
|
||||
|
||||
if inPassthroughFrame {
|
||||
if _, err := destination.Write(line); err != nil {
|
||||
abort(err)
|
||||
return
|
||||
}
|
||||
if isBlank {
|
||||
inPassthroughFrame = false
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if len(pingFrame) > 0 {
|
||||
if isBlank {
|
||||
if err := endPingFrame(line); err != nil {
|
||||
abort(err)
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
if canExtendGrokResponsesPingFrame(line) &&
|
||||
len(pingFrame) < grokResponsesPingFrameMaxLines &&
|
||||
pingFrameBytes+len(line) <= grokResponsesPingFrameMaxBytes {
|
||||
pingFrame = append(pingFrame, append([]byte(nil), line...))
|
||||
pingFrameBytes += len(line)
|
||||
continue
|
||||
}
|
||||
// Not a filterable ping frame after all (unexpected field line,
|
||||
// or past the buffering caps): replay it and stream the rest of
|
||||
// the frame through unchanged.
|
||||
if err := replayPingFrame(); err != nil {
|
||||
abort(err)
|
||||
return
|
||||
}
|
||||
if _, err := destination.Write(line); err != nil {
|
||||
abort(err)
|
||||
return
|
||||
}
|
||||
inPassthroughFrame = true
|
||||
continue
|
||||
}
|
||||
|
||||
// Frame start: only `event: ping` opens a buffered candidate.
|
||||
if !isBlank {
|
||||
if value, ok := extractOpenAISSEEventLine(string(trimSSELineEnding(line))); ok && value == "ping" {
|
||||
pingFrame = append(pingFrame, append([]byte(nil), line...))
|
||||
pingFrameBytes = len(line)
|
||||
continue
|
||||
}
|
||||
}
|
||||
if _, err := destination.Write(line); err != nil {
|
||||
abort(err)
|
||||
return
|
||||
}
|
||||
inPassthroughFrame = !isBlank
|
||||
}
|
||||
if len(pingFrame) > 0 {
|
||||
if err := endPingFrame(nil); err != nil {
|
||||
abort(err)
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
abort(fmt.Errorf("filter Grok Responses billing ping: %w", err))
|
||||
return
|
||||
}
|
||||
_ = destination.Close()
|
||||
}
|
||||
|
||||
func scanSSELinesPreservingEndings(data []byte, atEOF bool) (advance int, token []byte, err error) {
|
||||
for index, value := range data {
|
||||
switch value {
|
||||
case '\n':
|
||||
return index + 1, data[:index+1], nil
|
||||
case '\r':
|
||||
if index+1 == len(data) && !atEOF {
|
||||
return 0, nil, nil
|
||||
}
|
||||
if index+1 < len(data) && data[index+1] == '\n' {
|
||||
return index + 2, data[:index+2], nil
|
||||
}
|
||||
return index + 1, data[:index+1], nil
|
||||
}
|
||||
}
|
||||
if atEOF && len(data) > 0 {
|
||||
return len(data), data, nil
|
||||
}
|
||||
return 0, nil, nil
|
||||
}
|
||||
|
||||
func trimSSELineEnding(line []byte) []byte {
|
||||
return bytes.TrimSuffix(bytes.TrimSuffix(line, []byte("\n")), []byte("\r"))
|
||||
}
|
||||
|
||||
// canExtendGrokResponsesPingFrame reports whether a line may still belong to a
|
||||
// vendor ping frame: only data lines and SSE comments. Any other field (a
|
||||
// second event line, id, retry, ...) means the frame is not a plain ping.
|
||||
func canExtendGrokResponsesPingFrame(rawLine []byte) bool {
|
||||
line := trimSSELineEnding(rawLine)
|
||||
if len(line) > 0 && line[0] == ':' {
|
||||
return true
|
||||
}
|
||||
_, ok := extractOpenAISSEDataLine(string(line))
|
||||
return ok
|
||||
}
|
||||
|
||||
// isGrokResponsesPingEventFrame decides a buffered candidate whose first line
|
||||
// is already `event: ping`. The only candidates replayed verbatim are frames
|
||||
// whose data payload declares a different event type than the SSE event line;
|
||||
// every other shape (billing cost, keepalive, no data, malformed JSON) would
|
||||
// break strict Responses clients and is rewritten into a comment.
|
||||
func isGrokResponsesPingEventFrame(rawLines [][]byte) bool {
|
||||
dataParts := make([]string, 0, 1)
|
||||
for _, rawLine := range rawLines[1:] {
|
||||
if value, ok := extractOpenAISSEDataLine(string(trimSSELineEnding(rawLine))); ok {
|
||||
dataParts = append(dataParts, value)
|
||||
}
|
||||
}
|
||||
if len(dataParts) == 0 {
|
||||
return true
|
||||
}
|
||||
var payload struct {
|
||||
Type *string `json:"type"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(strings.Join(dataParts, "\n")), &payload); err != nil || payload.Type == nil {
|
||||
return true
|
||||
}
|
||||
return *payload.Type == "ping"
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func filterGrokPingTestInput(t *testing.T, input string) string {
|
||||
t.Helper()
|
||||
body := newGrokResponsesBillingPingFilterBody(
|
||||
io.NopCloser(strings.NewReader(input)),
|
||||
&Account{Platform: PlatformGrok},
|
||||
defaultMaxLineSize,
|
||||
)
|
||||
output, err := io.ReadAll(body)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, body.Close())
|
||||
return string(output)
|
||||
}
|
||||
|
||||
func TestGrokResponsesBillingPingFilter(t *testing.T) {
|
||||
input := strings.Join([]string{
|
||||
": upstream keepalive",
|
||||
"",
|
||||
"event: response.output_text.delta",
|
||||
`data: {"type":"response.output_text.delta","delta":"hello"}`,
|
||||
"",
|
||||
"event: future.vendor_event",
|
||||
`data: {"type":"future.vendor_event","value":1}`,
|
||||
"",
|
||||
"event: ping",
|
||||
`data: {"type":"ping","x-opencode-type":"inference-cost","cost":2.75,"input-tokens":42}`,
|
||||
"",
|
||||
"event: ping",
|
||||
`data: {"type":"ping","cost":"0"}`,
|
||||
"",
|
||||
"event: response.completed",
|
||||
`data: {"type":"response.completed","response":{"id":"resp_1","usage":{"input_tokens":3,"output_tokens":5}}}`,
|
||||
"",
|
||||
}, "\n")
|
||||
|
||||
result := filterGrokPingTestInput(t, input)
|
||||
require.NotContains(t, result, "event: ping")
|
||||
require.NotContains(t, result, `"x-opencode-type":"inference-cost"`)
|
||||
require.NotContains(t, result, `{"type":"ping","cost":"0"}`)
|
||||
require.Equal(t, 2, strings.Count(result, ": ping\n\n"))
|
||||
require.Contains(t, result, ": upstream keepalive\n\n")
|
||||
require.Contains(t, result, "event: response.output_text.delta")
|
||||
require.Contains(t, result, `{"type":"response.output_text.delta","delta":"hello"}`)
|
||||
require.Contains(t, result, "event: future.vendor_event")
|
||||
require.Contains(t, result, `{"type":"future.vendor_event","value":1}`)
|
||||
require.Contains(t, result, "event: response.completed")
|
||||
require.Contains(t, result, `"usage":{"input_tokens":3,"output_tokens":5}`)
|
||||
}
|
||||
|
||||
// Every `event: ping` frame is outside the Responses closed event enum and
|
||||
// breaks strict clients regardless of its payload shape, so all variants are
|
||||
// rewritten into an SSE comment (issue #5105).
|
||||
func TestGrokResponsesBillingPingFilterConvertsPingVariants(t *testing.T) {
|
||||
frames := []string{
|
||||
"event: ping\ndata: {\"type\":\"ping\",\"x-opencode-type\":\"inference-cost\",\"cost\":\"0.06029240\"}\n\n",
|
||||
"event: ping\ndata: {\"type\":\"ping\",\"cost\":\"0\"}\n\n",
|
||||
"event: ping\ndata: {\"type\":\"ping\",\"cost\":\"0.06029240\"}\n\n",
|
||||
"event: ping\ndata: {\"type\":\"ping\",\"kind\":\"keepalive\"}\n\n",
|
||||
"event: ping\ndata: {\"type\":\"ping\"}\n\n",
|
||||
"event: ping\ndata: {\"type\":\"ping\",\"cost\":2}\n\n",
|
||||
"event: ping\ndata: {\"type\":\"ping\",\"cost\":0.0001}\n\n",
|
||||
"event: ping\ndata: {\"type\":\"ping\",\"cost\":\" 0 \"}\n\n",
|
||||
"event: ping\ndata: {\"type\":\"ping\",\"x-opencode-type\":\"keepalive\",\"cost\":0}\n\n",
|
||||
"event: ping\ndata: {\"type\":\"ping\",\"x-opencode-type\":null,\"cost\":0}\n\n",
|
||||
"event: ping\ndata: {\"cost\":\"0\"}\n\n",
|
||||
"event: ping\ndata: {not-json}\n\n",
|
||||
"event: ping\n\n",
|
||||
"event: ping\n: vendor note\ndata: {\"type\":\"ping\"}\n\n",
|
||||
}
|
||||
result := filterGrokPingTestInput(t, strings.Join(frames, ""))
|
||||
require.Equal(t, strings.Repeat(": ping\n\n", len(frames)), result)
|
||||
}
|
||||
|
||||
func TestGrokResponsesBillingPingFilterPreservesNonPingFrames(t *testing.T) {
|
||||
input := strings.Join([]string{
|
||||
"event: ping",
|
||||
`data: {"type":" ping ","cost":0}`,
|
||||
"",
|
||||
"event: ping",
|
||||
`data: {"type":"response.completed"}`,
|
||||
"",
|
||||
"event: custom",
|
||||
`data: {"type":"ping","x-opencode-type":"inference-cost"}`,
|
||||
"",
|
||||
`data: {"type":"ping","cost":"0"}`,
|
||||
"",
|
||||
": keepalive comment",
|
||||
"",
|
||||
"retry: 1000",
|
||||
"",
|
||||
}, "\n")
|
||||
|
||||
require.Equal(t, input, filterGrokPingTestInput(t, input))
|
||||
}
|
||||
|
||||
// A ping candidate that turns out to carry an unexpected SSE field is not a
|
||||
// vendor billing/keepalive frame; it must be replayed byte for byte.
|
||||
func TestGrokResponsesBillingPingFilterPassesThroughPingFrameWithUnknownField(t *testing.T) {
|
||||
input := "event: ping\nid: 7\ndata: {\"type\":\"ping\",\"cost\":\"0\"}\n\n"
|
||||
require.Equal(t, input, filterGrokPingTestInput(t, input))
|
||||
}
|
||||
|
||||
// Buffering caps: a ping candidate that grows past the line or byte limit is
|
||||
// streamed through unchanged instead of accumulating unbounded memory.
|
||||
func TestGrokResponsesBillingPingFilterPassesThroughOversizedPingFrame(t *testing.T) {
|
||||
lines := []string{"event: ping"}
|
||||
for i := 0; i < grokResponsesPingFrameMaxLines; i++ {
|
||||
lines = append(lines, ": filler comment")
|
||||
}
|
||||
lines = append(lines, `data: {"type":"ping","cost":"0"}`, "")
|
||||
byLines := strings.Join(lines, "\n")
|
||||
require.Equal(t, byLines, filterGrokPingTestInput(t, byLines))
|
||||
|
||||
byBytes := "event: ping\ndata: {\"type\":\"ping\",\"pad\":\"" +
|
||||
strings.Repeat("x", grokResponsesPingFrameMaxBytes) + "\"}\n\n"
|
||||
require.Equal(t, byBytes, filterGrokPingTestInput(t, byBytes))
|
||||
}
|
||||
|
||||
func TestGrokResponsesBillingPingFilterConvertsMalformedPingFrames(t *testing.T) {
|
||||
input := "event: ping\r\ndata: {not-json}\r\n\r\n" +
|
||||
"event: ping\r\ndata: {\"type\":\"ping\",\"cost\":\"0\"} trailing\r\n\r\n" +
|
||||
"event: future.response.event\r\ndata: {\"type\":\"future.response.event\"}"
|
||||
want := ": ping\n\n" + ": ping\n\n" +
|
||||
"event: future.response.event\r\ndata: {\"type\":\"future.response.event\"}"
|
||||
require.Equal(t, want, filterGrokPingTestInput(t, input))
|
||||
}
|
||||
|
||||
func TestGrokResponsesBillingPingFilterHandlesBareCRFrames(t *testing.T) {
|
||||
input := "event: ping\rdata: {\"type\":\"ping\",\"cost\":\"0\"}\r\r" +
|
||||
"event: future.event\rdata: {\"type\":\"future.event\"}\r\r"
|
||||
want := ": ping\n\n" + "event: future.event\rdata: {\"type\":\"future.event\"}\r\r"
|
||||
require.Equal(t, want, filterGrokPingTestInput(t, input))
|
||||
}
|
||||
|
||||
func TestGrokResponsesBillingPingFilterConvertsPartialPingFrameAtEOF(t *testing.T) {
|
||||
input := "event: ping\ndata: {\"type\":\"ping\",\"cost\":\"0\"}"
|
||||
require.Equal(t, ": ping\n\n", filterGrokPingTestInput(t, input))
|
||||
}
|
||||
|
||||
func TestGrokResponsesBillingPingFilterDoesNotFilterNonGrokAccounts(t *testing.T) {
|
||||
input := "event: ping\ndata: {\"type\":\"ping\",\"cost\":\"0\"}\n\n"
|
||||
source := io.NopCloser(strings.NewReader(input))
|
||||
body := newGrokResponsesBillingPingFilterBody(source, &Account{Platform: PlatformOpenAI}, defaultMaxLineSize)
|
||||
|
||||
output, err := io.ReadAll(body)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, body.Close())
|
||||
require.Equal(t, input, string(output))
|
||||
}
|
||||
|
||||
func TestGrokResponsesBillingPingFilterPreservesUsageAndTerminalEvent(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
input := strings.Join([]string{
|
||||
"event: ping",
|
||||
`data: {"type":"ping","x-opencode-type":"inference-cost","cost":"0"}`,
|
||||
"",
|
||||
"event: response.completed",
|
||||
`data: {"type":"response.completed","response":{"id":"resp_1","usage":{"input_tokens":3,"output_tokens":5}}}`,
|
||||
"",
|
||||
}, "\n")
|
||||
account := &Account{ID: 1, Platform: PlatformGrok}
|
||||
resp := &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{},
|
||||
Body: newGrokResponsesBillingPingFilterBody(
|
||||
io.NopCloser(strings.NewReader(input)), account, defaultMaxLineSize,
|
||||
),
|
||||
}
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
|
||||
svc := &OpenAIGatewayService{
|
||||
cfg: &config.Config{Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize}},
|
||||
toolCorrector: NewCodexToolCorrector(),
|
||||
}
|
||||
|
||||
result, err := svc.handleStreamingResponse(context.Background(), resp, c, account, time.Now(), "grok-4.5", "grok-4.5")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 3, result.usage.InputTokens)
|
||||
require.Equal(t, 5, result.usage.OutputTokens)
|
||||
require.Equal(t, "resp_1", result.responseID)
|
||||
require.Contains(t, recorder.Body.String(), "response.completed")
|
||||
require.NotContains(t, recorder.Body.String(), "inference-cost")
|
||||
require.NotContains(t, recorder.Body.String(), "event: ping")
|
||||
}
|
||||
|
||||
type grokPingFilterTestReadCloser struct {
|
||||
reader io.ReadCloser
|
||||
closeCount atomic.Int32
|
||||
}
|
||||
|
||||
func (r *grokPingFilterTestReadCloser) Read(p []byte) (int, error) { return r.reader.Read(p) }
|
||||
func (r *grokPingFilterTestReadCloser) Close() error {
|
||||
r.closeCount.Add(1)
|
||||
return r.reader.Close()
|
||||
}
|
||||
|
||||
func TestGrokResponsesBillingPingFilterCloseCancelsSourceOnce(t *testing.T) {
|
||||
upstreamReader, upstreamWriter := io.Pipe()
|
||||
source := &grokPingFilterTestReadCloser{reader: upstreamReader}
|
||||
body := newGrokResponsesBillingPingFilterBody(source, &Account{Platform: PlatformGrok}, defaultMaxLineSize)
|
||||
|
||||
require.NoError(t, body.Close())
|
||||
require.Eventually(t, func() bool { return source.closeCount.Load() == 1 }, time.Second, time.Millisecond)
|
||||
_, err := upstreamWriter.Write([]byte("blocked"))
|
||||
require.Error(t, err)
|
||||
require.NoError(t, upstreamWriter.Close())
|
||||
}
|
||||
|
||||
func TestGrokResponsesBillingPingFilterFlushesCompletedFrames(t *testing.T) {
|
||||
upstreamReader, upstreamWriter := io.Pipe()
|
||||
body := newGrokResponsesBillingPingFilterBody(upstreamReader, &Account{Platform: PlatformGrok}, defaultMaxLineSize)
|
||||
t.Cleanup(func() { require.NoError(t, body.Close()) })
|
||||
|
||||
go func() {
|
||||
_, _ = io.WriteString(upstreamWriter, "event: future.event\ndata: {\"type\":\"future.event\"}\n\n")
|
||||
}()
|
||||
|
||||
result := make(chan error, 1)
|
||||
go func() {
|
||||
buffer := make([]byte, 64)
|
||||
n, err := body.Read(buffer)
|
||||
if err == nil && !strings.Contains(string(buffer[:n]), "future.event") {
|
||||
err = errors.New("completed frame was not forwarded")
|
||||
}
|
||||
result <- err
|
||||
}()
|
||||
select {
|
||||
case err := <-result:
|
||||
require.NoError(t, err)
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("completed frame was buffered until upstream EOF")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGrokResponsesBillingPingFilterReportsOversizedLine(t *testing.T) {
|
||||
body := newGrokResponsesBillingPingFilterBody(
|
||||
io.NopCloser(strings.NewReader("data: 123456789\n\n")),
|
||||
&Account{Platform: PlatformGrok},
|
||||
8,
|
||||
)
|
||||
_, err := io.ReadAll(body)
|
||||
require.ErrorContains(t, err, "filter Grok Responses billing ping")
|
||||
require.NoError(t, body.Close())
|
||||
}
|
||||
@@ -2612,6 +2612,30 @@ func TestHandleGrokAccountUpstreamError429SetsRateLimitedFromRetryAfter(t *testi
|
||||
require.Zero(t, repo.tempUnschedCalls)
|
||||
}
|
||||
|
||||
func TestHandleGrokAccountUpstreamError429PoolModeKeepsSchedulingState(t *testing.T) {
|
||||
account := &Account{
|
||||
ID: 613,
|
||||
Platform: PlatformGrok,
|
||||
Type: AccountTypeAPIKey,
|
||||
Credentials: map[string]any{
|
||||
"pool_mode": true,
|
||||
},
|
||||
}
|
||||
repo := &grokQuotaAccountRepo{}
|
||||
svc := &OpenAIGatewayService{accountRepo: repo}
|
||||
|
||||
svc.handleGrokAccountUpstreamError(
|
||||
context.Background(), account, http.StatusTooManyRequests,
|
||||
http.Header{"Retry-After": []string{"45"}}, nil,
|
||||
)
|
||||
|
||||
require.Equal(t, 1, repo.updateCalls, "pool mode should retain the quota snapshot for observability")
|
||||
require.Zero(t, repo.rateLimitedCalls)
|
||||
require.Zero(t, repo.tempUnschedCalls)
|
||||
require.False(t, svc.isOpenAIAccountRuntimeBlocked(account))
|
||||
require.Nil(t, account.RateLimitResetAt)
|
||||
}
|
||||
|
||||
func TestHandleGrokAccountUpstreamError402RecoversAfterCooldownExpiry(t *testing.T) {
|
||||
account := &Account{
|
||||
ID: 610, Platform: PlatformGrok, Type: AccountTypeOAuth,
|
||||
|
||||
@@ -63,6 +63,13 @@ func (s *OpenAIGatewayService) forwardOpenAIPassthrough(
|
||||
})
|
||||
return nil, fmt.Errorf("openai passthrough rejected before upstream: %s", rejectReason)
|
||||
}
|
||||
if isOpenAICodexModel(reqModel) && !gjson.GetBytes(body, "instructions").Exists() {
|
||||
nextBody, setErr := sjson.SetBytes(body, "instructions", defaultCodexSynthInstructions(reqModel))
|
||||
if setErr != nil {
|
||||
return nil, fmt.Errorf("set passthrough codex instructions: %w", setErr)
|
||||
}
|
||||
body = nextBody
|
||||
}
|
||||
|
||||
normalizedBody, normalized, err := normalizeOpenAIPassthroughOAuthBody(body, isOpenAIResponsesCompactPath(c))
|
||||
if err != nil {
|
||||
@@ -892,6 +899,15 @@ func openAIStreamFailedEventShouldFailover(payload []byte, message string) bool
|
||||
return true
|
||||
}
|
||||
|
||||
func openAIStreamFailedEventRetryableOnSameAccount(account *Account, payload []byte, message string) bool {
|
||||
if account == nil || !account.IsPoolMode() {
|
||||
return false
|
||||
}
|
||||
semanticStatus := openAIStreamFailedEventSemanticStatus(payload, message)
|
||||
return account.IsPoolModeRetryableStatus(semanticStatus) ||
|
||||
isOpenAITransientProcessingError(http.StatusBadRequest, message, payload)
|
||||
}
|
||||
|
||||
func (s *OpenAIGatewayService) recordOpenAIStreamUpstreamError(
|
||||
c *gin.Context,
|
||||
account *Account,
|
||||
@@ -954,8 +970,9 @@ func (s *OpenAIGatewayService) newOpenAIStreamFailoverError(
|
||||
},
|
||||
})
|
||||
return &UpstreamFailoverError{
|
||||
StatusCode: http.StatusBadGateway,
|
||||
ResponseBody: body,
|
||||
StatusCode: http.StatusBadGateway,
|
||||
ResponseBody: body,
|
||||
RetryableOnSameAccount: openAIStreamFailedEventRetryableOnSameAccount(account, payload, message),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -132,7 +132,14 @@ func sanitizeEncryptedReasoningInputItem(item any) (next any, changed bool, keep
|
||||
}
|
||||
|
||||
itemType, _ := inputItem["type"].(string)
|
||||
if strings.TrimSpace(itemType) != "reasoning" {
|
||||
switch strings.TrimSpace(itemType) {
|
||||
case "compaction", "compaction_summary":
|
||||
if _, encrypted := inputItem["encrypted_content"]; encrypted {
|
||||
return nil, true, false
|
||||
}
|
||||
return item, false, true
|
||||
case "reasoning":
|
||||
default:
|
||||
return item, false, true
|
||||
}
|
||||
|
||||
@@ -777,14 +784,13 @@ func normalizeOpenAIPassthroughOAuthBody(body []byte, compact bool) ([]byte, boo
|
||||
}
|
||||
|
||||
func detectOpenAIPassthroughInstructionsRejectReason(reqModel string, body []byte) string {
|
||||
model := strings.ToLower(strings.TrimSpace(reqModel))
|
||||
if !strings.Contains(model, "codex") {
|
||||
if !isOpenAICodexModel(reqModel) {
|
||||
return ""
|
||||
}
|
||||
|
||||
instructions := gjson.GetBytes(body, "instructions")
|
||||
if !instructions.Exists() {
|
||||
return "instructions_missing"
|
||||
return ""
|
||||
}
|
||||
if instructions.Type != gjson.String {
|
||||
return "instructions_not_string"
|
||||
@@ -795,6 +801,10 @@ func detectOpenAIPassthroughInstructionsRejectReason(reqModel string, body []byt
|
||||
return ""
|
||||
}
|
||||
|
||||
func isOpenAICodexModel(model string) bool {
|
||||
return strings.Contains(strings.ToLower(strings.TrimSpace(model)), "codex")
|
||||
}
|
||||
|
||||
// extractOpenAIReasoningEffortFromBody 按优先级传入模型候选(如 upstreamModel,
|
||||
// billingModel, originalModel):显式 effort 的模型归一化(max 保留判定)用第一个
|
||||
// 非空候选;body 未携带 effort 时的模型后缀推导依次尝试每个候选——OAuth 的
|
||||
|
||||
@@ -99,6 +99,47 @@ func TestTrimOpenAIEncryptedReasoningItems_NoReasoningItems(t *testing.T) {
|
||||
assert.False(t, changed)
|
||||
}
|
||||
|
||||
func TestTrimOpenAIEncryptedReasoningItems_Compaction(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
itemType string
|
||||
encrypted bool
|
||||
changed bool
|
||||
}{
|
||||
{name: "compaction", itemType: "compaction", encrypted: true, changed: true},
|
||||
{name: "compaction summary", itemType: "compaction_summary", encrypted: true, changed: true},
|
||||
{name: "unencrypted compaction", itemType: "compaction", encrypted: false, changed: false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
item := map[string]any{"type": tt.itemType, "id": "cmp_stale"}
|
||||
if tt.encrypted {
|
||||
item["encrypted_content"] = "gAAA"
|
||||
}
|
||||
reqBody := map[string]any{"input": []any{
|
||||
item,
|
||||
map[string]any{"type": "message", "content": "hi"},
|
||||
}}
|
||||
|
||||
changed := trimOpenAIEncryptedReasoningItems(reqBody)
|
||||
assert.Equal(t, tt.changed, changed)
|
||||
input, ok := reqBody["input"].([]any)
|
||||
require.True(t, ok)
|
||||
require.NotEmpty(t, input)
|
||||
first, ok := input[0].(map[string]any)
|
||||
require.True(t, ok)
|
||||
if tt.changed {
|
||||
require.Len(t, input, 1)
|
||||
assert.Equal(t, "message", first["type"])
|
||||
return
|
||||
}
|
||||
require.Len(t, input, 2)
|
||||
assert.Equal(t, tt.itemType, first["type"])
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeOpenAICrossModeFailoverReasoning_DropsWholeEncryptedItem(t *testing.T) {
|
||||
body := []byte(`{"model":"gpt-5.1","input":[` +
|
||||
`{"type":"message","role":"user","content":"hi"},` +
|
||||
|
||||
@@ -593,6 +593,52 @@ func TestOpenAIGatewayService_Forward_HTTPRetryRecoveryDoesNotDecodeBeforeError(
|
||||
require.Equal(t, "summary_text", gjson.GetBytes(upstream.bodies[1], "input.0.summary.0.type").String())
|
||||
}
|
||||
|
||||
func TestOpenAIGatewayService_Forward_HTTPRetryRecoveryDropsCompaction(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
upstream := &httpUpstreamRecorder{
|
||||
responses: []*http.Response{
|
||||
{
|
||||
StatusCode: http.StatusBadRequest,
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
Body: io.NopCloser(strings.NewReader(`{"error":{"code":"invalid_encrypted_content","type":"invalid_request_error","message":"bad encrypted content"}}`)),
|
||||
},
|
||||
{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
Body: io.NopCloser(strings.NewReader(`{"usage":{"input_tokens":1,"output_tokens":2}}`)),
|
||||
},
|
||||
},
|
||||
}
|
||||
cfg := &config.Config{}
|
||||
cfg.Security.URLAllowlist.Enabled = false
|
||||
svc := &OpenAIGatewayService{cfg: cfg, httpUpstream: upstream}
|
||||
account := &Account{
|
||||
ID: 10,
|
||||
Name: "openai-apikey",
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeAPIKey,
|
||||
Concurrency: 1,
|
||||
Credentials: map[string]any{
|
||||
"api_key": "sk-test",
|
||||
"base_url": "https://example.com",
|
||||
},
|
||||
Extra: map[string]any{"use_responses_api": true},
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/openai/v1/responses", nil)
|
||||
SetOpenAIClientTransport(c, OpenAIClientTransportHTTP)
|
||||
|
||||
body := []byte(`{"model":"gpt-5.6-sol","stream":false,"input":[{"id":"cmp_stale","type":"compaction","encrypted_content":"gAAA"},{"type":"message","content":[{"type":"input_text","text":"hi"}]}]}`)
|
||||
result, err := svc.Forward(context.Background(), c, account, body)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
require.Len(t, upstream.bodies, 2)
|
||||
require.Equal(t, "compaction", gjson.GetBytes(upstream.bodies[0], "input.0.type").String())
|
||||
require.Equal(t, "message", gjson.GetBytes(upstream.bodies[1], "input.0.type").String())
|
||||
require.False(t, gjson.GetBytes(upstream.bodies[1], "input.1").Exists())
|
||||
}
|
||||
|
||||
func TestOpenAIGatewayService_Forward_CodexSparkRejectsEscapedInputImage(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
upstream := &httpUpstreamRecorder{
|
||||
|
||||
@@ -1482,6 +1482,7 @@ func TestOpenAIStreamingResponseFailedBeforeOutputReturnsFailover(t *testing.T)
|
||||
var failoverErr *UpstreamFailoverError
|
||||
require.ErrorAs(t, err, &failoverErr)
|
||||
require.Equal(t, http.StatusBadGateway, failoverErr.StatusCode)
|
||||
require.False(t, failoverErr.RetryableOnSameAccount)
|
||||
require.Contains(t, string(failoverErr.ResponseBody), "An error occurred while processing your request")
|
||||
require.False(t, c.Writer.Written())
|
||||
require.Empty(t, rec.Body.String())
|
||||
@@ -1518,11 +1519,21 @@ func TestOpenAIStreamingResponseFailedBeforeOutputCapacityErrorReturnsFailover(t
|
||||
Header: http.Header{"X-Request-Id": []string{"rid-capacity-failed"}},
|
||||
}
|
||||
|
||||
_, err := svc.handleStreamingResponse(c.Request.Context(), resp, c, &Account{ID: 1, Platform: PlatformOpenAI, Name: "acc"}, time.Now(), "model", "model")
|
||||
account := &Account{
|
||||
ID: 1,
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeAPIKey,
|
||||
Name: "pool-account",
|
||||
Credentials: map[string]any{
|
||||
"pool_mode": true,
|
||||
},
|
||||
}
|
||||
_, err := svc.handleStreamingResponse(c.Request.Context(), resp, c, account, time.Now(), "model", "model")
|
||||
require.Error(t, err)
|
||||
var failoverErr *UpstreamFailoverError
|
||||
require.ErrorAs(t, err, &failoverErr)
|
||||
require.Equal(t, http.StatusBadGateway, failoverErr.StatusCode)
|
||||
require.True(t, failoverErr.RetryableOnSameAccount)
|
||||
require.Contains(t, string(failoverErr.ResponseBody), "Selected model is at capacity")
|
||||
require.False(t, c.Writer.Written())
|
||||
require.Empty(t, rec.Body.String())
|
||||
|
||||
@@ -433,7 +433,77 @@ func TestOpenAIGatewayService_OAuthPassthrough_StreamKeepsToolNameAndBodyNormali
|
||||
require.NotContains(t, body, "\"name\":\"edit\"")
|
||||
}
|
||||
|
||||
func TestOpenAIGatewayService_OAuthPassthrough_NamespaceRequestAndStreamResponse(t *testing.T) {
|
||||
// 「自动透传(仅替换认证)」的默认行为必须真的只替换认证:namespace 声明、
|
||||
// namespace 形态的 tool_choice、历史调用项上的 namespace 都原样转发,只清掉
|
||||
// 非调用项上的残留 namespace(Codex 协议里只有调用项会带该字段)。
|
||||
func TestOpenAIGatewayService_OAuthPassthrough_PreservesNamespaceRequest(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(nil))
|
||||
c.Request.Header.Set("User-Agent", "codex_cli_rs/0.144.1")
|
||||
|
||||
originalBody := []byte(`{
|
||||
"model":"gpt-5.5",
|
||||
"stream":true,
|
||||
"instructions":"local-test-instructions",
|
||||
"tools":[
|
||||
{"type":"function","name":"plain","description":"keep","parameters":{"type":"object"}},
|
||||
{"type":"namespace","name":"collaboration","tools":[{"type":"function","name":"spawn_agent","description":"spawn","parameters":{"type":"object"}}]}
|
||||
],
|
||||
"tool_choice":{"type":"function","name":"spawn_agent","namespace":"collaboration"},
|
||||
"input":[
|
||||
{"type":"function_call","call_id":"call_old","name":"spawn_agent","namespace":"collaboration","arguments":"{}"},
|
||||
{"type":"message","role":"user","namespace":"residual","content":[{"type":"input_text","text":"keep","namespace":"nested"}]}
|
||||
]
|
||||
}`)
|
||||
|
||||
upstreamSSE := strings.Join([]string{
|
||||
`data: {"type":"response.output_item.done","output_index":0,"item":{"type":"function_call","id":"fc_1","call_id":"call_1","name":"spawn_agent","namespace":"collaboration","arguments":"{}"}}`,
|
||||
"",
|
||||
`data: {"type":"response.completed","response":{"id":"resp_1","status":"completed","output":[],"usage":{"input_tokens":2,"output_tokens":1,"total_tokens":3}}}`,
|
||||
"",
|
||||
"data: [DONE]",
|
||||
"",
|
||||
}, "\n")
|
||||
upstream := &httpUpstreamRecorder{resp: &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid_preserve"}},
|
||||
Body: io.NopCloser(strings.NewReader(upstreamSSE)),
|
||||
}}
|
||||
svc := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream}
|
||||
account := &Account{
|
||||
ID: 125, Name: "acc", Platform: PlatformOpenAI, Type: AccountTypeOAuth, Concurrency: 1,
|
||||
Credentials: map[string]any{"access_token": "oauth-token", "chatgpt_account_id": "chatgpt-acc"},
|
||||
Extra: map[string]any{"openai_passthrough": true}, Status: StatusActive, Schedulable: true, RateMultiplier: f64p(1),
|
||||
}
|
||||
|
||||
result, err := svc.Forward(context.Background(), c, account, originalBody)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
|
||||
require.Len(t, gjson.GetBytes(upstream.lastBody, "tools").Array(), 2)
|
||||
require.Equal(t, "namespace", gjson.GetBytes(upstream.lastBody, "tools.1.type").String())
|
||||
require.Equal(t, "collaboration", gjson.GetBytes(upstream.lastBody, "tools.1.name").String())
|
||||
require.Equal(t, "spawn_agent", gjson.GetBytes(upstream.lastBody, "tools.1.tools.0.name").String())
|
||||
require.Equal(t, "collaboration", gjson.GetBytes(upstream.lastBody, "tool_choice.namespace").String())
|
||||
require.Equal(t, "spawn_agent", gjson.GetBytes(upstream.lastBody, "tool_choice.name").String())
|
||||
require.Equal(t, "collaboration", gjson.GetBytes(upstream.lastBody, "input.0.namespace").String())
|
||||
require.Equal(t, "spawn_agent", gjson.GetBytes(upstream.lastBody, "input.0.name").String())
|
||||
require.False(t, gjson.GetBytes(upstream.lastBody, "input.1.namespace").Exists())
|
||||
require.Equal(t, "nested", gjson.GetBytes(upstream.lastBody, "input.1.content.0.namespace").String())
|
||||
require.NotContains(t, string(upstream.lastBody), "collaboration__spawn_agent")
|
||||
|
||||
// 未摊平即无需回程还原,上游事件原样下发。
|
||||
downstream := rec.Body.String()
|
||||
require.Contains(t, downstream, `"name":"spawn_agent"`)
|
||||
require.Contains(t, downstream, `"namespace":"collaboration"`)
|
||||
}
|
||||
|
||||
// 兼容开关打开时的旧行为:摊平请求、回程还原。默认路径见
|
||||
// TestOpenAIGatewayService_OAuthPassthrough_PreservesNamespaceRequest。
|
||||
func TestOpenAIGatewayService_OAuthPassthrough_FlattenEnabledNamespaceRequestAndStreamResponse(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
@@ -475,7 +545,11 @@ func TestOpenAIGatewayService_OAuthPassthrough_NamespaceRequestAndStreamResponse
|
||||
account := &Account{
|
||||
ID: 123, Name: "acc", Platform: PlatformOpenAI, Type: AccountTypeOAuth, Concurrency: 1,
|
||||
Credentials: map[string]any{"access_token": "oauth-token", "chatgpt_account_id": "chatgpt-acc"},
|
||||
Extra: map[string]any{"openai_passthrough": true}, Status: StatusActive, Schedulable: true, RateMultiplier: f64p(1),
|
||||
Extra: map[string]any{
|
||||
"openai_passthrough": true,
|
||||
"openai_responses_flatten_namespaces": true,
|
||||
},
|
||||
Status: StatusActive, Schedulable: true, RateMultiplier: f64p(1),
|
||||
}
|
||||
|
||||
result, err := svc.Forward(context.Background(), c, account, originalBody)
|
||||
@@ -501,7 +575,9 @@ func TestOpenAIGatewayService_OAuthPassthrough_NamespaceRequestAndStreamResponse
|
||||
require.Contains(t, downstream, `"namespace":"collaboration"`)
|
||||
}
|
||||
|
||||
func TestOpenAIGatewayService_NativeOAuth_NamespaceRequestAndStreamResponse(t *testing.T) {
|
||||
// 兼容开关打开时的旧行为;默认保留路径见
|
||||
// TestOpenAIGatewayService_OAuthPreservesCodexNamespaceTools。
|
||||
func TestOpenAIGatewayService_NativeOAuth_FlattenEnabledNamespaceRequestAndStreamResponse(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
@@ -529,6 +605,7 @@ func TestOpenAIGatewayService_NativeOAuth_NamespaceRequestAndStreamResponse(t *t
|
||||
account := &Account{
|
||||
ID: 124, Name: "native", Platform: PlatformOpenAI, Type: AccountTypeOAuth, Concurrency: 1,
|
||||
Credentials: map[string]any{"access_token": "oauth-token", "chatgpt_account_id": "chatgpt-acc"},
|
||||
Extra: map[string]any{"openai_responses_flatten_namespaces": true},
|
||||
Status: StatusActive, Schedulable: true, RateMultiplier: f64p(1),
|
||||
}
|
||||
|
||||
@@ -596,7 +673,8 @@ func TestOpenAIGatewayService_OAuthPassthrough_NamespaceNonStreamingResponse(t *
|
||||
require.Contains(t, rec.Body.String(), `"namespace":"collaboration"`)
|
||||
}
|
||||
|
||||
func TestOpenAIGatewayService_OAuthPassthrough_NamespaceCollisionReturnsBadRequest(t *testing.T) {
|
||||
// 摊平名冲突只在兼容开关打开时才可能发生:默认保留 namespace,不存在平名冲突。
|
||||
func TestOpenAIGatewayService_OAuthPassthrough_FlattenEnabledNamespaceCollisionReturnsBadRequest(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
@@ -614,7 +692,11 @@ func TestOpenAIGatewayService_OAuthPassthrough_NamespaceCollisionReturnsBadReque
|
||||
account := &Account{
|
||||
ID: 123, Name: "acc", Platform: PlatformOpenAI, Type: AccountTypeOAuth, Concurrency: 1,
|
||||
Credentials: map[string]any{"access_token": "oauth-token", "chatgpt_account_id": "chatgpt-acc"},
|
||||
Extra: map[string]any{"openai_passthrough": true}, Status: StatusActive, Schedulable: true, RateMultiplier: f64p(1),
|
||||
Extra: map[string]any{
|
||||
"openai_passthrough": true,
|
||||
"openai_responses_flatten_namespaces": true,
|
||||
},
|
||||
Status: StatusActive, Schedulable: true, RateMultiplier: f64p(1),
|
||||
}
|
||||
|
||||
result, err := svc.Forward(context.Background(), c, account, body)
|
||||
@@ -727,57 +809,53 @@ func TestOpenAIGatewayService_OAuthPassthrough_UpstreamRequestIgnoresClientCance
|
||||
require.NoError(t, upstream.lastReq.Context().Err())
|
||||
}
|
||||
|
||||
func TestOpenAIGatewayService_OAuthPassthrough_CodexMissingInstructionsRejectedBeforeUpstream(t *testing.T) {
|
||||
func TestOpenAIGatewayService_OAuthPassthrough_CodexMissingInstructionsGetsDefault(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
logSink, restore := captureStructuredLog(t)
|
||||
defer restore()
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses?trace=1", bytes.NewReader(nil))
|
||||
c.Request.Header.Set("User-Agent", "codex_cli_rs/0.98.0 (Windows 10.0.19045; x86_64) unknown")
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
c.Request.Header.Set("OpenAI-Beta", "responses=experimental")
|
||||
for _, stream := range []bool{false, true} {
|
||||
t.Run(fmt.Sprintf("stream=%t", stream), func(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
path := "/v1/responses"
|
||||
responseBody := strings.Join([]string{
|
||||
`data: {"type":"response.completed","response":{"id":"resp_1","status":"completed","output":[],"usage":{"input_tokens":1,"output_tokens":1}}}`,
|
||||
"", "data: [DONE]", "",
|
||||
}, "\n")
|
||||
responseContentType := "text/event-stream"
|
||||
if !stream {
|
||||
path = "/v1/responses/compact"
|
||||
responseBody = `{"id":"resp_1","status":"completed","output":[],"usage":{"input_tokens":1,"output_tokens":1}}`
|
||||
responseContentType = "application/json"
|
||||
}
|
||||
c.Request = httptest.NewRequest(http.MethodPost, path, bytes.NewReader(nil))
|
||||
c.Request.Header.Set("User-Agent", "codex_cli_rs/0.98.0")
|
||||
|
||||
// Codex 模型且缺少 instructions,应在本地直接 403 拒绝,不触达上游。
|
||||
originalBody := []byte(`{"model":"gpt-5.1-codex-max","stream":false,"store":true,"input":[{"type":"text","text":"hi"}]}`)
|
||||
originalBody := []byte(fmt.Sprintf(`{"model":"gpt-5.1-codex-max","stream":%t,"store":true,"input":[{"type":"text","text":"hi"}]}`, stream))
|
||||
upstream := &httpUpstreamRecorder{resp: &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{"Content-Type": []string{responseContentType}, "x-request-id": []string{"rid"}},
|
||||
Body: io.NopCloser(strings.NewReader(responseBody)),
|
||||
}}
|
||||
svc := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream}
|
||||
account := &Account{
|
||||
ID: 123, Name: "acc", Platform: PlatformOpenAI, Type: AccountTypeOAuth, Concurrency: 1,
|
||||
Credentials: map[string]any{"access_token": "oauth-token", "chatgpt_account_id": "chatgpt-acc"},
|
||||
Extra: map[string]any{"openai_passthrough": true, "openai_oauth_responses_websockets_v2_mode": OpenAIWSIngressModeOff},
|
||||
Status: StatusActive, Schedulable: true, RateMultiplier: f64p(1),
|
||||
}
|
||||
|
||||
upstream := &httpUpstreamRecorder{
|
||||
resp: &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}, "x-request-id": []string{"rid"}},
|
||||
Body: io.NopCloser(strings.NewReader(`{"output":[],"usage":{"input_tokens":1,"output_tokens":1}}`)),
|
||||
},
|
||||
result, err := svc.Forward(context.Background(), c, account, originalBody)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
require.NotNil(t, upstream.lastReq)
|
||||
if stream {
|
||||
require.True(t, gjson.GetBytes(upstream.lastBody, "stream").Bool())
|
||||
} else {
|
||||
require.False(t, gjson.GetBytes(upstream.lastBody, "stream").Exists())
|
||||
}
|
||||
require.Equal(t, strings.TrimSpace(defaultCodexSynthInstructions("gpt-5.1-codex-max")), strings.TrimSpace(gjson.GetBytes(upstream.lastBody, "instructions").String()))
|
||||
})
|
||||
}
|
||||
|
||||
svc := &OpenAIGatewayService{
|
||||
cfg: &config.Config{Gateway: config.GatewayConfig{ForceCodexCLI: false}},
|
||||
httpUpstream: upstream,
|
||||
}
|
||||
|
||||
account := &Account{
|
||||
ID: 123,
|
||||
Name: "acc",
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeOAuth,
|
||||
Concurrency: 1,
|
||||
Credentials: map[string]any{"access_token": "oauth-token", "chatgpt_account_id": "chatgpt-acc"},
|
||||
Extra: map[string]any{"openai_passthrough": true},
|
||||
Status: StatusActive,
|
||||
Schedulable: true,
|
||||
RateMultiplier: f64p(1),
|
||||
}
|
||||
|
||||
result, err := svc.Forward(context.Background(), c, account, originalBody)
|
||||
require.Error(t, err)
|
||||
require.Nil(t, result)
|
||||
require.Equal(t, http.StatusForbidden, rec.Code)
|
||||
require.Contains(t, rec.Body.String(), "requires a non-empty instructions field")
|
||||
require.Nil(t, upstream.lastReq)
|
||||
|
||||
require.True(t, logSink.ContainsMessage("OpenAI passthrough 本地拦截:Codex 请求缺少有效 instructions"))
|
||||
require.True(t, logSink.ContainsFieldValue("request_user_agent", "codex_cli_rs/0.98.0 (Windows 10.0.19045; x86_64) unknown"))
|
||||
require.True(t, logSink.ContainsFieldValue("reject_reason", "instructions_missing"))
|
||||
}
|
||||
|
||||
func TestOpenAIGatewayService_OAuthPassthrough_DisabledUsesLegacyTransform(t *testing.T) {
|
||||
|
||||
@@ -85,3 +85,20 @@ func TestNormalizeOpenAIPassthroughOAuthBody_ArrayInputUnchanged(t *testing.T) {
|
||||
require.Len(t, input.Array(), 1)
|
||||
require.Equal(t, "message", input.Array()[0].Get("type").String())
|
||||
}
|
||||
|
||||
func TestDetectOpenAIPassthroughInstructionsRejectReason(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
body string
|
||||
want string
|
||||
}{
|
||||
{name: "missing is optional", body: `{"model":"gpt-5.1-codex"}`, want: ""},
|
||||
{name: "non string remains rejected", body: `{"instructions":{"text":"invalid"}}`, want: "instructions_not_string"},
|
||||
{name: "empty remains rejected", body: `{"instructions":" "}`, want: "instructions_empty"},
|
||||
{name: "non empty remains accepted", body: `{"instructions":"client guidance"}`, want: ""},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
require.Equal(t, tt.want, detectOpenAIPassthroughInstructionsRejectReason("gpt-5.1-codex", []byte(tt.body)))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/apicompat"
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -14,14 +15,39 @@ import (
|
||||
const openAIResponsesNamespaceNamesContextKey = "openai_responses_namespace_names"
|
||||
|
||||
// shouldFlattenOpenAIResponsesNamespaces 判定原生 Responses 转发前是否摊平
|
||||
// Codex namespace 工具。WSv2 上游原生支持 namespace,且 WS 出口
|
||||
// (openai_ws_forwarder_v2)原样转发上游事件、不经 HTTP 回程还原,摊平后的
|
||||
// 平名无法还原会破坏客户端工具匹配,因此实际走 WSv2 分支的请求保持 namespace
|
||||
// 原样。透传账号先于 WSv2 分支经 HTTP 转发返回,仍需摊平。
|
||||
func shouldFlattenOpenAIResponsesNamespaces(account *Account, transport OpenAIUpstreamTransport, passthroughEnabled bool) bool {
|
||||
// Codex namespace 工具。
|
||||
//
|
||||
// 默认不摊平:OAuth 账号的 HTTP 出口恒为 chatgpt.com/backend-api/codex/responses
|
||||
// (buildUpstreamRequest 只在 API Key 分支读 base_url),也就是 namespace 扩展的
|
||||
// 定义方本身;Codex 客户端对 WS 与 HTTP 两条传输发送同一份 tools(codex-rs
|
||||
// client.rs build_responses_request 无传输分支,WS 失败后会 session 级回落 HTTP
|
||||
// 继续发同样的声明)。摊平只改写工具名,改不掉客户端在 tools 描述与 developer
|
||||
// 消息里写死的 `to=functions.<namespace>.<tool>` 寻址约定,模型据此寻址必然落空
|
||||
// (issue #4978);命名空间名还可由 features.multi_agent_v2.tool_namespace 自定义、
|
||||
// 或由 MCP/connector 动态生成(mcp__codex_apps__gmail),保留名单枚举不完。
|
||||
//
|
||||
// compact 端点例外:已知它的 schema 比 /responses 窄(连 input[].namespace 都会报
|
||||
// Unknown parameter,见 issue #4761),而是否接受 namespace 工具声明没有任何实测
|
||||
// 证据;compact 只做历史摘要、不需要模型寻址工具,回程也没有工具调用可还原,因此
|
||||
// 保持 0.1.166 起就在跑的摊平行为,不随本次默认值翻转扩大风险面。
|
||||
//
|
||||
// 账号开关 openai_responses_flatten_namespaces 为不认识 namespace 的兼容上游保留
|
||||
// 退路,打开后恢复旧行为:WSv2 上游原生支持 namespace,且 WS 出口
|
||||
// (openai_ws_forwarder_v2)原样转发上游事件、不经 HTTP 回程还原,摊平后的平名
|
||||
// 无法还原会破坏客户端工具匹配,因此实际走 WSv2 分支的请求仍保持 namespace 原样;
|
||||
// 透传账号先于 WSv2 分支经 HTTP 转发返回,仍需摊平。
|
||||
func shouldFlattenOpenAIResponsesNamespaces(
|
||||
account *Account,
|
||||
transport OpenAIUpstreamTransport,
|
||||
passthroughEnabled bool,
|
||||
compactPath bool,
|
||||
) bool {
|
||||
if account == nil || !account.IsOpenAIOAuth() {
|
||||
return false
|
||||
}
|
||||
if !compactPath && !account.IsOpenAIResponsesFlattenNamespacesEnabled() {
|
||||
return false
|
||||
}
|
||||
if transport == OpenAIUpstreamTransportResponsesWebsocketV2 && !passthroughEnabled {
|
||||
return false
|
||||
}
|
||||
@@ -41,6 +67,50 @@ func shouldStripOpenAIResponsesInputNamespaces(account *Account, transport OpenA
|
||||
return true
|
||||
}
|
||||
|
||||
// shouldKeepOpenAIResponsesToolCallNamespaces 判定清理 input 残留 namespace 时是否
|
||||
// 保留工具调用项上的 namespace。
|
||||
//
|
||||
// 上游对这个字段有两套互斥要求,判定按「出口 + 端点」而非工具声明内容:
|
||||
// - /backend-api/codex/responses 会按 namespace 解析历史调用,缺字段直接 400
|
||||
// `Missing namespace for function_call '...'. Round-trip the model's
|
||||
// function_call item with its namespace field included.`(issue #4761 回帖),
|
||||
// 故 OAuth 非 compact 请求必须保留。
|
||||
// - compact 端点的 schema 不含该字段,携带即 400 `Unknown parameter:
|
||||
// input[N].namespace`(issue #4761 正文),故 compact 一律清理。
|
||||
// - API Key 出口是标准 Responses API(api.openai.com 或自定义 base_url),同样
|
||||
// 不认识该字段,维持全量清理;否则只能退化成
|
||||
// openai_responses_rejected_field_retry 的逐项删除,6 次上限根本盖不住长历史。
|
||||
// - 摊平模式下调用项已被改写成平名,残留 namespace 指向的声明已不存在,一律清理。
|
||||
func shouldKeepOpenAIResponsesToolCallNamespaces(
|
||||
account *Account,
|
||||
transport OpenAIUpstreamTransport,
|
||||
passthroughEnabled bool,
|
||||
compactPath bool,
|
||||
) bool {
|
||||
if account == nil || !account.IsOpenAIOAuth() {
|
||||
return false
|
||||
}
|
||||
if compactPath {
|
||||
return false
|
||||
}
|
||||
return !shouldFlattenOpenAIResponsesNamespaces(account, transport, passthroughEnabled, compactPath)
|
||||
}
|
||||
|
||||
// openAIResponsesToolCallItemTypes 是携带 namespace 的调用项类型集合。与
|
||||
// removeOpenAIResponsesRejectedNamespaceAtIndex 的反应式白名单保持一致;codex-rs
|
||||
// protocol/src/models.rs 中只有 FunctionCall 与 CustomToolCall 序列化 namespace,
|
||||
// 其余类型带该字段一定是非 Codex 客户端或历史残留,清掉才安全。
|
||||
var openAIResponsesToolCallItemTypes = map[string]bool{
|
||||
"function_call": true,
|
||||
"tool_call": true,
|
||||
"custom_tool_call": true,
|
||||
"mcp_tool_call": true,
|
||||
}
|
||||
|
||||
func isOpenAIResponsesToolCallItemType(itemType string) bool {
|
||||
return openAIResponsesToolCallItemTypes[strings.ToLower(strings.TrimSpace(itemType))]
|
||||
}
|
||||
|
||||
func flattenOpenAIResponsesNamespaces(c *gin.Context, body []byte) ([]byte, error) {
|
||||
if !bytes.Contains(body, []byte(`"namespace"`)) {
|
||||
return body, nil
|
||||
@@ -68,7 +138,11 @@ func flattenOpenAIResponsesNamespaces(c *gin.Context, body []byte) ([]byte, erro
|
||||
// array items. Namespace declarations and nested namespace fields are left
|
||||
// untouched. Rebuilding the input array once keeps this linear for long
|
||||
// histories and avoids decoding JSON numbers through float64.
|
||||
func stripOpenAIResponsesInputNamespaces(body []byte) ([]byte, error) {
|
||||
//
|
||||
// keepToolCallNamespaces 保留工具调用项(function_call / custom_tool_call 等)上的
|
||||
// namespace,让 Codex 调用能按上游要求原样回传;判定见
|
||||
// shouldKeepOpenAIResponsesToolCallNamespaces。
|
||||
func stripOpenAIResponsesInputNamespaces(body []byte, keepToolCallNamespaces bool) ([]byte, error) {
|
||||
if !bytes.Contains(body, []byte(`"namespace"`)) {
|
||||
return body, nil
|
||||
}
|
||||
@@ -89,7 +163,10 @@ func stripOpenAIResponsesInputNamespaces(body []byte) ([]byte, error) {
|
||||
}
|
||||
first = false
|
||||
itemBody := []byte(item.Raw)
|
||||
if item.IsObject() && item.Get("namespace").Exists() {
|
||||
// 先判存在再判类型:长历史里绝大多数是 message/reasoning 等不带 namespace
|
||||
// 的项,这样它们无需再扫一次 type。
|
||||
if item.IsObject() && item.Get("namespace").Exists() &&
|
||||
(!keepToolCallNamespaces || !isOpenAIResponsesToolCallItemType(item.Get("type").String())) {
|
||||
itemBody, stripErr = sjson.DeleteBytes(itemBody, "namespace")
|
||||
if stripErr != nil {
|
||||
return false
|
||||
@@ -99,13 +176,13 @@ func stripOpenAIResponsesInputNamespaces(body []byte) ([]byte, error) {
|
||||
_, _ = rebuilt.Write(itemBody)
|
||||
return true
|
||||
})
|
||||
_ = rebuilt.WriteByte(']')
|
||||
if stripErr != nil {
|
||||
return body, fmt.Errorf("delete OpenAI input namespace: %w", stripErr)
|
||||
}
|
||||
if !changed {
|
||||
return body, nil
|
||||
}
|
||||
_ = rebuilt.WriteByte(']')
|
||||
stripped, err := sjson.SetRawBytes(body, "input", rebuilt.Bytes())
|
||||
if err != nil {
|
||||
return body, fmt.Errorf("replace OpenAI input after namespace deletion: %w", err)
|
||||
@@ -119,6 +196,18 @@ func setOpenAIResponsesNamespaceNames(c *gin.Context, names map[string]apicompat
|
||||
}
|
||||
}
|
||||
|
||||
// clearOpenAIResponsesNamespaceNames 清除上一次尝试登记的摊平名映射。handler 的
|
||||
// failover 会在同一个 *gin.Context 上重试下一个账号,映射不清会让保留 namespace 的
|
||||
// 账号拿着上一个账号的摊平名做回程还原。
|
||||
func clearOpenAIResponsesNamespaceNames(c *gin.Context) {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
if _, exists := c.Get(openAIResponsesNamespaceNamesContextKey); exists {
|
||||
c.Set(openAIResponsesNamespaceNamesContextKey, map[string]apicompat.ResponsesNamespaceName(nil))
|
||||
}
|
||||
}
|
||||
|
||||
func openAIResponsesNamespaceNames(c *gin.Context) map[string]apicompat.ResponsesNamespaceName {
|
||||
if c == nil {
|
||||
return nil
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/apicompat"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
// codexNamespaceRequestBody 模拟 Codex 多智能体请求:collaboration 命名空间声明 +
|
||||
// 历史里的命名空间调用项 + 带残留 namespace 的普通消息项。
|
||||
const codexNamespaceRequestBody = `{
|
||||
"model":"gpt-5.6-terra",
|
||||
"stream":false,
|
||||
"instructions":"test",
|
||||
"tools":[
|
||||
{"type":"namespace","name":"collaboration","description":"Tools for spawning and managing sub-agents.","tools":[
|
||||
{"type":"function","name":"spawn_agent","description":"Call as to=functions.collaboration.spawn_agent","parameters":{"type":"object"}},
|
||||
{"type":"function","name":"wait_agent","parameters":{"type":"object"}}
|
||||
]},
|
||||
{"type":"function","name":"exec","parameters":{"type":"object"}}
|
||||
],
|
||||
"input":[
|
||||
{"type":"function_call","namespace":"collaboration","name":"spawn_agent","call_id":"call_1","arguments":"{}"},
|
||||
{"type":"message","role":"user","namespace":"leftover","content":[{"type":"input_text","text":"hello"}]}
|
||||
]
|
||||
}`
|
||||
|
||||
const namespaceForwardOKResponse = `{"id":"resp_ns","output":[],"usage":{"input_tokens":1,"output_tokens":1,"input_tokens_details":{"cached_tokens":0}}}`
|
||||
|
||||
// OAuth 出口即 namespace 扩展的定义方:声明必须原样送达,历史调用项必须保留
|
||||
// namespace(缺字段上游会 400 "Missing namespace for function_call"),而非调用项上的
|
||||
// 残留 namespace 仍要清掉。回归 issue #4978。
|
||||
func TestOpenAIGatewayService_OAuthPreservesCodexNamespaceTools(t *testing.T) {
|
||||
body := []byte(codexNamespaceRequestBody)
|
||||
upstream := &httpUpstreamRecorder{responses: []*http.Response{
|
||||
newOpenAIRejectedFieldTestResponse(http.StatusOK, namespaceForwardOKResponse),
|
||||
}}
|
||||
c := newOpenAIRejectedFieldTestContext(body)
|
||||
|
||||
result, err := newOpenAIRejectedFieldTestService(upstream).Forward(
|
||||
context.Background(), c, newOpenAIOAuthNamespaceTestAccount(), body,
|
||||
)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
require.Len(t, upstream.bodies, 1)
|
||||
forwarded := upstream.bodies[0]
|
||||
|
||||
namespaceTool := gjson.GetBytes(forwarded, `tools.#(type=="namespace")`)
|
||||
require.True(t, namespaceTool.Exists(), "namespace 声明必须原样转发")
|
||||
require.Equal(t, "collaboration", namespaceTool.Get("name").String())
|
||||
require.Equal(t, "spawn_agent", namespaceTool.Get("tools.0.name").String())
|
||||
require.Equal(t, "wait_agent", namespaceTool.Get("tools.1.name").String())
|
||||
// 摊平名一旦出现,模型就无法按工具描述里的 to=functions.collaboration.spawn_agent 寻址。
|
||||
require.NotContains(t, string(forwarded), "collaboration__spawn_agent")
|
||||
|
||||
require.Equal(t, "collaboration", gjson.GetBytes(forwarded, "input.0.namespace").String())
|
||||
require.Equal(t, "spawn_agent", gjson.GetBytes(forwarded, "input.0.name").String())
|
||||
require.False(t, gjson.GetBytes(forwarded, "input.1.namespace").Exists())
|
||||
|
||||
// 未摊平即无需回程还原,不得登记映射。
|
||||
require.Empty(t, openAIResponsesNamespaceNames(c))
|
||||
}
|
||||
|
||||
// compact 端点 schema 更窄:input[].namespace 会 400 Unknown parameter(issue #4761),
|
||||
// 且没有证据表明它接受 namespace 工具声明。compact 只做历史摘要、不需要模型寻址工具,
|
||||
// 因此保持既有的摊平 + 全量清理行为,不随默认值翻转扩大风险面。
|
||||
func TestOpenAIGatewayService_OAuthCompactKeepsFlattening(t *testing.T) {
|
||||
body := []byte(codexNamespaceRequestBody)
|
||||
upstream := &httpUpstreamRecorder{responses: []*http.Response{
|
||||
newOpenAIRejectedFieldTestResponse(http.StatusOK, namespaceForwardOKResponse),
|
||||
}}
|
||||
c := newOpenAIRejectedFieldTestContext(body)
|
||||
c.Request.URL.Path = "/v1/responses/compact"
|
||||
|
||||
result, err := newOpenAIRejectedFieldTestService(upstream).Forward(
|
||||
context.Background(), c, newOpenAIOAuthNamespaceTestAccount(), body,
|
||||
)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
require.Len(t, upstream.bodies, 1)
|
||||
forwarded := upstream.bodies[0]
|
||||
|
||||
require.False(t, gjson.GetBytes(forwarded, "input.0.namespace").Exists())
|
||||
require.False(t, gjson.GetBytes(forwarded, "input.1.namespace").Exists())
|
||||
require.False(t, gjson.GetBytes(forwarded, `tools.#(type=="namespace")`).Exists())
|
||||
require.Equal(t, "collaboration__spawn_agent", gjson.GetBytes(forwarded, "input.0.name").String())
|
||||
}
|
||||
|
||||
// 账号开关为不认识 namespace 的兼容上游保留退路:打开后恢复 0.1.166 的摊平行为。
|
||||
func TestOpenAIGatewayService_OAuthFlattenFlagRestoresLegacyBehavior(t *testing.T) {
|
||||
body := []byte(codexNamespaceRequestBody)
|
||||
upstream := &httpUpstreamRecorder{responses: []*http.Response{
|
||||
newOpenAIRejectedFieldTestResponse(http.StatusOK, namespaceForwardOKResponse),
|
||||
}}
|
||||
c := newOpenAIRejectedFieldTestContext(body)
|
||||
account := newOpenAIOAuthNamespaceTestAccount()
|
||||
account.Extra = map[string]any{"openai_responses_flatten_namespaces": true}
|
||||
|
||||
result, err := newOpenAIRejectedFieldTestService(upstream).Forward(
|
||||
context.Background(), c, account, body,
|
||||
)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
require.Len(t, upstream.bodies, 1)
|
||||
forwarded := upstream.bodies[0]
|
||||
|
||||
require.False(t, gjson.GetBytes(forwarded, `tools.#(type=="namespace")`).Exists())
|
||||
require.True(t, gjson.GetBytes(forwarded, `tools.#(name=="collaboration__spawn_agent")`).Exists())
|
||||
require.True(t, gjson.GetBytes(forwarded, `tools.#(name=="collaboration__wait_agent")`).Exists())
|
||||
// 摊平后调用项已改写成平名,不得再带 namespace。
|
||||
require.Equal(t, "collaboration__spawn_agent", gjson.GetBytes(forwarded, "input.0.name").String())
|
||||
require.False(t, gjson.GetBytes(forwarded, "input.0.namespace").Exists())
|
||||
require.False(t, gjson.GetBytes(forwarded, "input.1.namespace").Exists())
|
||||
|
||||
names := openAIResponsesNamespaceNames(c)
|
||||
require.Equal(t,
|
||||
apicompat.ResponsesNamespaceName{Namespace: "collaboration", Name: "spawn_agent"},
|
||||
names["collaboration__spawn_agent"],
|
||||
)
|
||||
}
|
||||
|
||||
// handler 的 failover 在同一个 *gin.Context 上重试下一个账号;保留 namespace 的账号
|
||||
// 不得沿用上一个账号登记的摊平名映射做回程还原。
|
||||
func TestOpenAIGatewayService_ForwardClearsStaleNamespaceNames(t *testing.T) {
|
||||
body := []byte(codexNamespaceRequestBody)
|
||||
upstream := &httpUpstreamRecorder{responses: []*http.Response{
|
||||
newOpenAIRejectedFieldTestResponse(http.StatusOK, namespaceForwardOKResponse),
|
||||
}}
|
||||
c := newOpenAIRejectedFieldTestContext(body)
|
||||
setOpenAIResponsesNamespaceNames(c, map[string]apicompat.ResponsesNamespaceName{
|
||||
"stale__tool": {Namespace: "stale", Name: "tool"},
|
||||
})
|
||||
|
||||
_, err := newOpenAIRejectedFieldTestService(upstream).Forward(
|
||||
context.Background(), c, newOpenAIOAuthNamespaceTestAccount(), body,
|
||||
)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, openAIResponsesNamespaceNames(c))
|
||||
}
|
||||
@@ -12,27 +12,96 @@ func TestShouldFlattenOpenAIResponsesNamespaces(t *testing.T) {
|
||||
oauth := &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth}
|
||||
apiKey := &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey}
|
||||
grokOAuth := &Account{Platform: PlatformGrok, Type: AccountTypeOAuth}
|
||||
// 账号级兼容开关:为不认识 namespace 的兼容上游恢复旧的摊平行为。
|
||||
flattenOAuth := &Account{
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeOAuth,
|
||||
Extra: map[string]any{"openai_responses_flatten_namespaces": true},
|
||||
}
|
||||
flattenAPIKey := &Account{
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeAPIKey,
|
||||
Extra: map[string]any{"openai_responses_flatten_namespaces": true},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
account *Account
|
||||
transport OpenAIUpstreamTransport
|
||||
passthroughEnabled bool
|
||||
compactPath bool
|
||||
want bool
|
||||
}{
|
||||
{name: "oauth_http", account: oauth, transport: OpenAIUpstreamTransportHTTPSSE, want: true},
|
||||
{name: "oauth_http_passthrough", account: oauth, transport: OpenAIUpstreamTransportHTTPSSE, passthroughEnabled: true, want: true},
|
||||
// 默认保留:OAuth 出口是 namespace 扩展的定义方,摊平会让模型无法按
|
||||
// `to=functions.<namespace>.<tool>` 寻址(issue #4978)。
|
||||
{name: "oauth_http_default_preserves", account: oauth, transport: OpenAIUpstreamTransportHTTPSSE, want: false},
|
||||
{name: "oauth_http_passthrough_default_preserves", account: oauth, transport: OpenAIUpstreamTransportHTTPSSE, passthroughEnabled: true, want: false},
|
||||
{name: "oauth_wsv2_default_preserves", account: oauth, transport: OpenAIUpstreamTransportResponsesWebsocketV2, want: false},
|
||||
// compact 端点 schema 更窄且无实测证据,保持既有摊平行为。
|
||||
{name: "oauth_compact_flattens", account: oauth, transport: OpenAIUpstreamTransportHTTPSSE, compactPath: true, want: true},
|
||||
{name: "oauth_compact_wsv2_preserves", account: oauth, transport: OpenAIUpstreamTransportResponsesWebsocketV2, compactPath: true, want: false},
|
||||
{name: "apikey_compact", account: apiKey, transport: OpenAIUpstreamTransportHTTPSSE, compactPath: true, want: false},
|
||||
{name: "oauth_flatten_enabled_http", account: flattenOAuth, transport: OpenAIUpstreamTransportHTTPSSE, want: true},
|
||||
{name: "oauth_flatten_enabled_http_passthrough", account: flattenOAuth, transport: OpenAIUpstreamTransportHTTPSSE, passthroughEnabled: true, want: true},
|
||||
// WSv2 出口原样转发上游事件、不做回程还原,摊平会让客户端收到无法匹配的平名。
|
||||
{name: "oauth_wsv2", account: oauth, transport: OpenAIUpstreamTransportResponsesWebsocketV2, want: false},
|
||||
// 透传账号先于 WSv2 分支经 HTTP 转发返回,仍需摊平。
|
||||
{name: "oauth_wsv2_passthrough", account: oauth, transport: OpenAIUpstreamTransportResponsesWebsocketV2, passthroughEnabled: true, want: true},
|
||||
{name: "oauth_flatten_enabled_wsv2", account: flattenOAuth, transport: OpenAIUpstreamTransportResponsesWebsocketV2, want: false},
|
||||
// 透传账号先于 WSv2 分支经 HTTP 转发返回,开关打开时仍需摊平。
|
||||
{name: "oauth_flatten_enabled_wsv2_passthrough", account: flattenOAuth, transport: OpenAIUpstreamTransportResponsesWebsocketV2, passthroughEnabled: true, want: true},
|
||||
// 开关仅对 OAuth 生效:API Key 走 chat completions 回退桥时由桥自行摊平。
|
||||
{name: "apikey_flatten_enabled_http", account: flattenAPIKey, transport: OpenAIUpstreamTransportHTTPSSE, want: false},
|
||||
{name: "apikey_http", account: apiKey, transport: OpenAIUpstreamTransportHTTPSSE, want: false},
|
||||
{name: "grok_oauth_http", account: grokOAuth, transport: OpenAIUpstreamTransportHTTPSSE, want: false},
|
||||
{name: "nil_account", account: nil, transport: OpenAIUpstreamTransportHTTPSSE, want: false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
require.Equal(t, tt.want, shouldFlattenOpenAIResponsesNamespaces(tt.account, tt.transport, tt.passthroughEnabled))
|
||||
require.Equal(t, tt.want, shouldFlattenOpenAIResponsesNamespaces(
|
||||
tt.account, tt.transport, tt.passthroughEnabled, tt.compactPath,
|
||||
))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldKeepOpenAIResponsesToolCallNamespaces(t *testing.T) {
|
||||
oauth := &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth}
|
||||
apiKey := &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey}
|
||||
setupToken := &Account{Platform: PlatformOpenAI, Type: AccountTypeSetupToken}
|
||||
flattenOAuth := &Account{
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeOAuth,
|
||||
Extra: map[string]any{"openai_responses_flatten_namespaces": true},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
account *Account
|
||||
transport OpenAIUpstreamTransport
|
||||
passthroughEnabled bool
|
||||
compactPath bool
|
||||
want bool
|
||||
}{
|
||||
// 上游按 namespace 解析历史调用,缺字段会 400 "Missing namespace for function_call"。
|
||||
{name: "oauth_http_keeps", account: oauth, transport: OpenAIUpstreamTransportHTTPSSE, want: true},
|
||||
{name: "oauth_http_passthrough_keeps", account: oauth, transport: OpenAIUpstreamTransportHTTPSSE, passthroughEnabled: true, want: true},
|
||||
// compact 端点 schema 不含该字段,携带即 400 "Unknown parameter: input[N].namespace"。
|
||||
{name: "oauth_compact_strips", account: oauth, transport: OpenAIUpstreamTransportHTTPSSE, compactPath: true, want: false},
|
||||
// 摊平后调用项已是平名,残留 namespace 指向的声明不存在。
|
||||
{name: "oauth_flatten_enabled_strips", account: flattenOAuth, transport: OpenAIUpstreamTransportHTTPSSE, want: false},
|
||||
// WSv2 实际由 shouldStrip 提前短路,此处只钉住策略本身的取值。
|
||||
{name: "oauth_wsv2_keeps", account: oauth, transport: OpenAIUpstreamTransportResponsesWebsocketV2, want: true},
|
||||
// WSv2 + compact 是唯一「不摊平但仍必须清理」的组合,钉住 compact 判定本身,
|
||||
// 使其不会被误当成可由 shouldFlatten 推导出的冗余分支。
|
||||
{name: "oauth_compact_wsv2_strips", account: oauth, transport: OpenAIUpstreamTransportResponsesWebsocketV2, compactPath: true, want: false},
|
||||
// API Key 出口是标准 Responses API,不认识该字段。
|
||||
{name: "apikey_strips", account: apiKey, transport: OpenAIUpstreamTransportHTTPSSE, want: false},
|
||||
{name: "setup_token_strips", account: setupToken, transport: OpenAIUpstreamTransportHTTPSSE, want: false},
|
||||
{name: "nil_account", account: nil, transport: OpenAIUpstreamTransportHTTPSSE, want: false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
require.Equal(t, tt.want, shouldKeepOpenAIResponsesToolCallNamespaces(
|
||||
tt.account, tt.transport, tt.passthroughEnabled, tt.compactPath,
|
||||
))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -85,7 +154,7 @@ func TestStripOpenAIResponsesInputNamespaces(t *testing.T) {
|
||||
]
|
||||
}`)
|
||||
|
||||
stripped, err := stripOpenAIResponsesInputNamespaces(body)
|
||||
stripped, err := stripOpenAIResponsesInputNamespaces(body, false)
|
||||
require.NoError(t, err)
|
||||
for index := 0; index < 8; index++ {
|
||||
require.False(t, gjson.GetBytes(stripped, "input."+strconv.Itoa(index)+".namespace").Exists())
|
||||
@@ -106,8 +175,61 @@ func TestStripOpenAIResponsesInputNamespacesLeavesOtherShapesByteExact(t *testin
|
||||
[]byte(`{"input":[{"content":{"namespace":"nested-only"}}],"tools":[{"namespace":"keep"}]}`),
|
||||
}
|
||||
for _, body := range tests {
|
||||
stripped, err := stripOpenAIResponsesInputNamespaces(body)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, body, stripped)
|
||||
for _, keepToolCallNamespaces := range []bool{false, true} {
|
||||
stripped, err := stripOpenAIResponsesInputNamespaces(body, keepToolCallNamespaces)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, body, stripped)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 保留模式下只有工具调用项留住 namespace:上游按 namespace 解析历史调用,
|
||||
// 而 message / reasoning / 输出项带该字段会被 schema 拒绝。
|
||||
func TestStripOpenAIResponsesInputNamespacesKeepsToolCallNamespaces(t *testing.T) {
|
||||
body := []byte(`{
|
||||
"meta":9007199254740993,
|
||||
"input":[
|
||||
{"type":"function_call","namespace":"collaboration","name":"spawn_agent","arguments":"{}","large":9007199254740993},
|
||||
{"type":"custom_tool_call","namespace":"codex_app","name":"exec","input":"{}"},
|
||||
{"type":"tool_call","namespace":"mcp__codex_apps__gmail","name":"send"},
|
||||
{"type":"mcp_tool_call","namespace":"mcp__codex_apps__gmail","name":"list"},
|
||||
{"type":"message","namespace":"leftover","role":"assistant","content":[{"type":"output_text","text":"hi"}]},
|
||||
{"type":"function_call_output","namespace":"leftover","output":"ok"},
|
||||
{"type":"reasoning","namespace":"leftover"},
|
||||
{"type":"item","namespace":"leftover"}
|
||||
]
|
||||
}`)
|
||||
|
||||
stripped, err := stripOpenAIResponsesInputNamespaces(body, true)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, "collaboration", gjson.GetBytes(stripped, "input.0.namespace").String())
|
||||
require.Equal(t, "codex_app", gjson.GetBytes(stripped, "input.1.namespace").String())
|
||||
require.Equal(t, "mcp__codex_apps__gmail", gjson.GetBytes(stripped, "input.2.namespace").String())
|
||||
require.Equal(t, "mcp__codex_apps__gmail", gjson.GetBytes(stripped, "input.3.namespace").String())
|
||||
for index := 4; index < 8; index++ {
|
||||
require.False(t, gjson.GetBytes(stripped, "input."+strconv.Itoa(index)+".namespace").Exists())
|
||||
}
|
||||
// 大整数不得经 float64 往返。
|
||||
require.Equal(t, gjson.GetBytes(body, "meta").Raw, gjson.GetBytes(stripped, "meta").Raw)
|
||||
require.Equal(t, gjson.GetBytes(body, "input.0.large").Raw, gjson.GetBytes(stripped, "input.0.large").Raw)
|
||||
|
||||
// 类型比对不区分大小写与首尾空白。
|
||||
mixedCase := []byte(`{"input":[{"type":" Function_Call ","namespace":"collaboration","name":"spawn_agent"}]}`)
|
||||
keptMixedCase, err := stripOpenAIResponsesInputNamespaces(mixedCase, true)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, mixedCase, keptMixedCase)
|
||||
|
||||
// 全部为调用项时无改动,应原样返回。
|
||||
callsOnly := []byte(`{"input":[{"type":"function_call","namespace":"collaboration","name":"spawn_agent"}]}`)
|
||||
unchanged, err := stripOpenAIResponsesInputNamespaces(callsOnly, true)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, callsOnly, unchanged)
|
||||
|
||||
// 关闭保留时回到全量清理。
|
||||
strippedAll, err := stripOpenAIResponsesInputNamespaces(body, false)
|
||||
require.NoError(t, err)
|
||||
for index := 0; index < 8; index++ {
|
||||
require.False(t, gjson.GetBytes(strippedAll, "input."+strconv.Itoa(index)+".namespace").Exists())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1537,7 +1537,7 @@ func TestOpenAIGatewayService_Forward_WSv2InvalidEncryptedContentRecoversOnce(t
|
||||
},
|
||||
}
|
||||
|
||||
body := []byte(`{"model":"gpt-5.3-codex","stream":false,"previous_response_id":"resp_prev_encrypted","input":[{"type":"reasoning","encrypted_content":"gAAA"},{"type":"input_text","text":"hello"}]}`)
|
||||
body := []byte(`{"model":"gpt-5.3-codex","stream":false,"previous_response_id":"resp_prev_encrypted","input":[{"type":"reasoning","encrypted_content":"gAAA"},{"type":"compaction","encrypted_content":"cAAA"},{"type":"input_text","text":"hello"}]}`)
|
||||
result, err := svc.Forward(context.Background(), c, account, body)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
@@ -1553,6 +1553,7 @@ func TestOpenAIGatewayService_Forward_WSv2InvalidEncryptedContentRecoversOnce(t
|
||||
require.Len(t, requests, 2)
|
||||
require.True(t, gjson.GetBytes(requests[0], "previous_response_id").Exists(), "首轮请求应保留 previous_response_id")
|
||||
require.True(t, gjson.GetBytes(requests[0], `input.0.encrypted_content`).Exists(), "首轮请求应保留 encrypted reasoning")
|
||||
require.True(t, gjson.GetBytes(requests[0], `input.1.encrypted_content`).Exists(), "首轮请求应保留 encrypted compaction")
|
||||
require.False(t, gjson.GetBytes(requests[1], "previous_response_id").Exists(), "恢复重试应移除 previous_response_id")
|
||||
require.False(t, gjson.GetBytes(requests[1], `input.0.encrypted_content`).Exists(), "恢复重试应移除 encrypted reasoning item")
|
||||
require.Equal(t, "input_text", gjson.GetBytes(requests[1], `input.0.type`).String())
|
||||
|
||||
@@ -343,38 +343,87 @@ func (s *PaymentConfigService) UpdatePaymentConfig(ctx context.Context, req Upda
|
||||
return infraerrors.BadRequest("INVALID_RECHARGE_FEE_RATE", "recharge fee rate allows at most 2 decimal places")
|
||||
}
|
||||
}
|
||||
m := map[string]string{
|
||||
SettingPaymentEnabled: formatBoolOrEmpty(req.Enabled),
|
||||
SettingMinRechargeAmount: formatPositiveFloat(req.MinAmount),
|
||||
SettingMaxRechargeAmount: formatPositiveFloat(req.MaxAmount),
|
||||
SettingDailyRechargeLimit: formatPositiveFloat(req.DailyLimit),
|
||||
SettingOrderTimeoutMinutes: formatPositiveInt(req.OrderTimeoutMin),
|
||||
SettingMaxPendingOrders: formatPositiveInt(req.MaxPendingOrders),
|
||||
SettingBalancePayDisabled: formatBoolOrEmpty(req.BalanceDisabled),
|
||||
SettingBalanceRechargeMult: formatPositiveFloat(req.BalanceRechargeMultiplier),
|
||||
SettingSubscriptionUSDToCNYRate: formatPositiveFloatExact(req.SubscriptionUSDToCNYRate),
|
||||
SettingRechargeFeeRate: formatNonNegativeFloat(req.RechargeFeeRate),
|
||||
SettingLoadBalanceStrategy: derefStr(req.LoadBalanceStrategy),
|
||||
SettingProductNamePrefix: derefStr(req.ProductNamePrefix),
|
||||
SettingProductNameSuffix: derefStr(req.ProductNameSuffix),
|
||||
SettingHelpImageURL: derefStr(req.HelpImageURL),
|
||||
SettingHelpText: derefStr(req.HelpText),
|
||||
SettingCancelRateLimitOn: formatBoolOrEmpty(req.CancelRateLimitEnabled),
|
||||
SettingCancelRateLimitMax: formatPositiveInt(req.CancelRateLimitMax),
|
||||
SettingCancelWindowSize: formatPositiveInt(req.CancelRateLimitWindow),
|
||||
SettingCancelWindowUnit: derefStr(req.CancelRateLimitUnit),
|
||||
SettingCancelWindowMode: derefStr(req.CancelRateLimitMode),
|
||||
SettingAlipayForceQRCode: formatBoolOrEmpty(req.AlipayForceQRCode),
|
||||
SettingAlipayMobilePrecreateDeepLink: formatBoolOrEmpty(req.AlipayMobilePrecreateDeepLink),
|
||||
SettingPaymentVisibleMethodAlipaySource: derefStr(req.VisibleMethodAlipaySource),
|
||||
SettingPaymentVisibleMethodWxpaySource: derefStr(req.VisibleMethodWxpaySource),
|
||||
SettingPaymentVisibleMethodAlipayEnabled: formatBoolOrEmpty(req.VisibleMethodAlipayEnabled),
|
||||
SettingPaymentVisibleMethodWxpayEnabled: formatBoolOrEmpty(req.VisibleMethodWxpayEnabled),
|
||||
m := make(map[string]string)
|
||||
if req.Enabled != nil {
|
||||
m[SettingPaymentEnabled] = formatBoolOrEmpty(req.Enabled)
|
||||
}
|
||||
if req.MinAmount != nil {
|
||||
m[SettingMinRechargeAmount] = formatPositiveFloat(req.MinAmount)
|
||||
}
|
||||
if req.MaxAmount != nil {
|
||||
m[SettingMaxRechargeAmount] = formatPositiveFloat(req.MaxAmount)
|
||||
}
|
||||
if req.DailyLimit != nil {
|
||||
m[SettingDailyRechargeLimit] = formatPositiveFloat(req.DailyLimit)
|
||||
}
|
||||
if req.OrderTimeoutMin != nil {
|
||||
m[SettingOrderTimeoutMinutes] = formatPositiveInt(req.OrderTimeoutMin)
|
||||
}
|
||||
if req.MaxPendingOrders != nil {
|
||||
m[SettingMaxPendingOrders] = formatPositiveInt(req.MaxPendingOrders)
|
||||
}
|
||||
if req.EnabledTypes != nil {
|
||||
m[SettingEnabledPaymentTypes] = strings.Join(req.EnabledTypes, ",")
|
||||
} else {
|
||||
m[SettingEnabledPaymentTypes] = ""
|
||||
}
|
||||
if req.BalanceDisabled != nil {
|
||||
m[SettingBalancePayDisabled] = formatBoolOrEmpty(req.BalanceDisabled)
|
||||
}
|
||||
if req.BalanceRechargeMultiplier != nil {
|
||||
m[SettingBalanceRechargeMult] = formatPositiveFloat(req.BalanceRechargeMultiplier)
|
||||
}
|
||||
if req.SubscriptionUSDToCNYRate != nil {
|
||||
m[SettingSubscriptionUSDToCNYRate] = formatPositiveFloatExact(req.SubscriptionUSDToCNYRate)
|
||||
}
|
||||
if req.RechargeFeeRate != nil {
|
||||
m[SettingRechargeFeeRate] = formatNonNegativeFloat(req.RechargeFeeRate)
|
||||
}
|
||||
if req.LoadBalanceStrategy != nil {
|
||||
m[SettingLoadBalanceStrategy] = derefStr(req.LoadBalanceStrategy)
|
||||
}
|
||||
if req.ProductNamePrefix != nil {
|
||||
m[SettingProductNamePrefix] = derefStr(req.ProductNamePrefix)
|
||||
}
|
||||
if req.ProductNameSuffix != nil {
|
||||
m[SettingProductNameSuffix] = derefStr(req.ProductNameSuffix)
|
||||
}
|
||||
if req.HelpImageURL != nil {
|
||||
m[SettingHelpImageURL] = derefStr(req.HelpImageURL)
|
||||
}
|
||||
if req.HelpText != nil {
|
||||
m[SettingHelpText] = derefStr(req.HelpText)
|
||||
}
|
||||
if req.CancelRateLimitEnabled != nil {
|
||||
m[SettingCancelRateLimitOn] = formatBoolOrEmpty(req.CancelRateLimitEnabled)
|
||||
}
|
||||
if req.CancelRateLimitMax != nil {
|
||||
m[SettingCancelRateLimitMax] = formatPositiveInt(req.CancelRateLimitMax)
|
||||
}
|
||||
if req.CancelRateLimitWindow != nil {
|
||||
m[SettingCancelWindowSize] = formatPositiveInt(req.CancelRateLimitWindow)
|
||||
}
|
||||
if req.CancelRateLimitUnit != nil {
|
||||
m[SettingCancelWindowUnit] = derefStr(req.CancelRateLimitUnit)
|
||||
}
|
||||
if req.CancelRateLimitMode != nil {
|
||||
m[SettingCancelWindowMode] = derefStr(req.CancelRateLimitMode)
|
||||
}
|
||||
if req.AlipayForceQRCode != nil {
|
||||
m[SettingAlipayForceQRCode] = formatBoolOrEmpty(req.AlipayForceQRCode)
|
||||
}
|
||||
if req.AlipayMobilePrecreateDeepLink != nil {
|
||||
m[SettingAlipayMobilePrecreateDeepLink] = formatBoolOrEmpty(req.AlipayMobilePrecreateDeepLink)
|
||||
}
|
||||
if req.VisibleMethodAlipaySource != nil {
|
||||
m[SettingPaymentVisibleMethodAlipaySource] = derefStr(req.VisibleMethodAlipaySource)
|
||||
}
|
||||
if req.VisibleMethodWxpaySource != nil {
|
||||
m[SettingPaymentVisibleMethodWxpaySource] = derefStr(req.VisibleMethodWxpaySource)
|
||||
}
|
||||
if req.VisibleMethodAlipayEnabled != nil {
|
||||
m[SettingPaymentVisibleMethodAlipayEnabled] = formatBoolOrEmpty(req.VisibleMethodAlipayEnabled)
|
||||
}
|
||||
if req.VisibleMethodWxpayEnabled != nil {
|
||||
m[SettingPaymentVisibleMethodWxpayEnabled] = formatBoolOrEmpty(req.VisibleMethodWxpayEnabled)
|
||||
}
|
||||
return s.settingRepo.SetMultiple(ctx, m)
|
||||
}
|
||||
|
||||
@@ -441,7 +441,12 @@ func (s *paymentConfigSettingRepoStub) GetAll(context.Context) (map[string]strin
|
||||
func (s *paymentConfigSettingRepoStub) Delete(context.Context, string) error { return nil }
|
||||
|
||||
func TestUpdatePaymentConfig_PersistsVisibleMethodRouting(t *testing.T) {
|
||||
repo := &paymentConfigSettingRepoStub{values: map[string]string{}}
|
||||
repo := &paymentConfigSettingRepoStub{values: map[string]string{
|
||||
SettingPaymentVisibleMethodAlipayEnabled: "false",
|
||||
SettingPaymentVisibleMethodAlipaySource: VisibleMethodSourceOfficialAlipay,
|
||||
SettingPaymentVisibleMethodWxpayEnabled: "true",
|
||||
SettingPaymentVisibleMethodWxpaySource: VisibleMethodSourceEasyPayWechat,
|
||||
}}
|
||||
svc := &PaymentConfigService{settingRepo: repo}
|
||||
|
||||
alipayEnabled := true
|
||||
@@ -470,6 +475,82 @@ func TestUpdatePaymentConfig_PersistsVisibleMethodRouting(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdatePaymentConfig_OmittedVisibleMethodRoutingIsPreserved(t *testing.T) {
|
||||
wantVisibleMethods := map[string]string{
|
||||
SettingPaymentVisibleMethodAlipayEnabled: "true",
|
||||
SettingPaymentVisibleMethodAlipaySource: VisibleMethodSourceEasyPayAlipay,
|
||||
SettingPaymentVisibleMethodWxpayEnabled: "false",
|
||||
SettingPaymentVisibleMethodWxpaySource: VisibleMethodSourceOfficialWechat,
|
||||
}
|
||||
initial := make(map[string]string, len(wantVisibleMethods))
|
||||
for key, value := range wantVisibleMethods {
|
||||
initial[key] = value
|
||||
}
|
||||
repo := &paymentConfigSettingRepoStub{values: initial}
|
||||
svc := &PaymentConfigService{settingRepo: repo}
|
||||
|
||||
enabled := true
|
||||
err := svc.UpdatePaymentConfig(context.Background(), UpdatePaymentConfigRequest{Enabled: &enabled})
|
||||
if err != nil {
|
||||
t.Fatalf("UpdatePaymentConfig returned error: %v", err)
|
||||
}
|
||||
|
||||
visibleMethodKeys := []string{
|
||||
SettingPaymentVisibleMethodAlipayEnabled,
|
||||
SettingPaymentVisibleMethodAlipaySource,
|
||||
SettingPaymentVisibleMethodWxpayEnabled,
|
||||
SettingPaymentVisibleMethodWxpaySource,
|
||||
}
|
||||
for _, key := range visibleMethodKeys {
|
||||
if _, ok := repo.updates[key]; ok {
|
||||
t.Fatalf("omitted visible method setting %q was written", key)
|
||||
}
|
||||
if repo.values[key] != wantVisibleMethods[key] {
|
||||
t.Fatalf("visible method setting %q = %q, want preserved value %q", key, repo.values[key], wantVisibleMethods[key])
|
||||
}
|
||||
}
|
||||
if repo.updates[SettingPaymentEnabled] != "true" {
|
||||
t.Fatalf("payment enabled update = %q, want true", repo.updates[SettingPaymentEnabled])
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdatePaymentConfig_PersistsExplicitEmptyAndFalseValues(t *testing.T) {
|
||||
repo := &paymentConfigSettingRepoStub{values: map[string]string{
|
||||
SettingEnabledPaymentTypes: "alipay,wxpay",
|
||||
SettingBalancePayDisabled: "true",
|
||||
SettingProductNamePrefix: "existing",
|
||||
}}
|
||||
svc := &PaymentConfigService{settingRepo: repo}
|
||||
|
||||
falseValue := false
|
||||
emptyString := ""
|
||||
err := svc.UpdatePaymentConfig(context.Background(), UpdatePaymentConfigRequest{
|
||||
EnabledTypes: []string{},
|
||||
BalanceDisabled: &falseValue,
|
||||
ProductNamePrefix: &emptyString,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("UpdatePaymentConfig returned error: %v", err)
|
||||
}
|
||||
|
||||
want := map[string]string{
|
||||
SettingEnabledPaymentTypes: "",
|
||||
SettingBalancePayDisabled: "false",
|
||||
SettingProductNamePrefix: "",
|
||||
}
|
||||
if len(repo.updates) != len(want) {
|
||||
t.Fatalf("updates = %v, want exactly %v", repo.updates, want)
|
||||
}
|
||||
for key, value := range want {
|
||||
if repo.updates[key] != value {
|
||||
t.Fatalf("update %q = %q, want %q", key, repo.updates[key], value)
|
||||
}
|
||||
if repo.values[key] != value {
|
||||
t.Fatalf("stored %q = %q, want %q", key, repo.values[key], value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func paymentConfigStrPtr(value string) *string {
|
||||
return &value
|
||||
}
|
||||
|
||||
@@ -330,6 +330,7 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin
|
||||
ContactInfo: settings[SettingKeyContactInfo],
|
||||
DocURL: settings[SettingKeyDocURL],
|
||||
HomeContent: settings[SettingKeyHomeContent],
|
||||
CompactHomeEnabled: settings[SettingKeyCompactHomeEnabled] == "true",
|
||||
HideCcsImportButton: settings[SettingKeyHideCcsImportButton] == "true",
|
||||
PurchaseSubscriptionEnabled: settings[SettingKeyPurchaseSubscriptionEnabled] == "true",
|
||||
PurchaseSubscriptionURL: strings.TrimSpace(settings[SettingKeyPurchaseSubscriptionURL]),
|
||||
|
||||
@@ -179,6 +179,7 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
|
||||
SettingKeyContactInfo,
|
||||
SettingKeyDocURL,
|
||||
SettingKeyHomeContent,
|
||||
SettingKeyCompactHomeEnabled,
|
||||
SettingKeyHideCcsImportButton,
|
||||
SettingKeyPurchaseSubscriptionEnabled,
|
||||
SettingKeyPurchaseSubscriptionURL,
|
||||
@@ -307,6 +308,7 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
|
||||
ContactInfo: settings[SettingKeyContactInfo],
|
||||
DocURL: settings[SettingKeyDocURL],
|
||||
HomeContent: settings[SettingKeyHomeContent],
|
||||
CompactHomeEnabled: settings[SettingKeyCompactHomeEnabled] == "true",
|
||||
HideCcsImportButton: settings[SettingKeyHideCcsImportButton] == "true",
|
||||
PurchaseSubscriptionEnabled: settings[SettingKeyPurchaseSubscriptionEnabled] == "true",
|
||||
PurchaseSubscriptionURL: strings.TrimSpace(settings[SettingKeyPurchaseSubscriptionURL]),
|
||||
@@ -495,6 +497,7 @@ type PublicSettingsInjectionPayload struct {
|
||||
ContactInfo string `json:"contact_info"`
|
||||
DocURL string `json:"doc_url"`
|
||||
HomeContent string `json:"home_content"`
|
||||
CompactHomeEnabled bool `json:"compact_home_enabled"`
|
||||
HideCcsImportButton bool `json:"hide_ccs_import_button"`
|
||||
PurchaseSubscriptionEnabled bool `json:"purchase_subscription_enabled"`
|
||||
PurchaseSubscriptionURL string `json:"purchase_subscription_url"`
|
||||
@@ -567,6 +570,7 @@ func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any
|
||||
ContactInfo: settings.ContactInfo,
|
||||
DocURL: settings.DocURL,
|
||||
HomeContent: settings.HomeContent,
|
||||
CompactHomeEnabled: settings.CompactHomeEnabled,
|
||||
HideCcsImportButton: settings.HideCcsImportButton,
|
||||
PurchaseSubscriptionEnabled: settings.PurchaseSubscriptionEnabled,
|
||||
PurchaseSubscriptionURL: settings.PurchaseSubscriptionURL,
|
||||
|
||||
@@ -78,6 +78,25 @@ func TestSettingService_GetPublicSettings_ExposesTablePreferences(t *testing.T)
|
||||
require.Equal(t, []int{20, 50, 100}, settings.TablePageSizeOptions)
|
||||
}
|
||||
|
||||
func TestSettingService_GetPublicSettings_ExposesCompactHomeEnabled(t *testing.T) {
|
||||
repo := &settingPublicRepoStub{
|
||||
values: map[string]string{
|
||||
SettingKeyCompactHomeEnabled: "true",
|
||||
},
|
||||
}
|
||||
svc := NewSettingService(repo, &config.Config{})
|
||||
|
||||
settings, err := svc.GetPublicSettings(context.Background())
|
||||
|
||||
require.NoError(t, err)
|
||||
require.True(t, settings.CompactHomeEnabled)
|
||||
|
||||
missingSettings, err := NewSettingService(&settingPublicRepoStub{values: map[string]string{}}, &config.Config{}).
|
||||
GetPublicSettings(context.Background())
|
||||
require.NoError(t, err)
|
||||
require.False(t, missingSettings.CompactHomeEnabled)
|
||||
}
|
||||
|
||||
func TestSettingService_GetPublicSettings_ExposesForceEmailOnThirdPartySignup(t *testing.T) {
|
||||
repo := &settingPublicRepoStub{
|
||||
values: map[string]string{
|
||||
|
||||
@@ -228,6 +228,16 @@ func (s *defaultSubGroupReaderStub) GetByID(ctx context.Context, id int64) (*Gro
|
||||
return nil, ErrGroupNotFound
|
||||
}
|
||||
|
||||
func TestSettingService_UpdateSettings_PersistsCompactHomeEnabled(t *testing.T) {
|
||||
repo := &settingUpdateRepoStub{}
|
||||
svc := NewSettingService(repo, &config.Config{})
|
||||
|
||||
err := svc.UpdateSettings(context.Background(), &SystemSettings{CompactHomeEnabled: true})
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "true", repo.updates[SettingKeyCompactHomeEnabled])
|
||||
}
|
||||
|
||||
func TestSettingService_UpdateSettings_DefaultSubscriptions_ValidGroup(t *testing.T) {
|
||||
repo := &settingUpdateRepoStub{}
|
||||
groupReader := &defaultSubGroupReaderStub{
|
||||
|
||||
@@ -320,6 +320,7 @@ func (s *SettingService) buildSystemSettingsUpdates(ctx context.Context, setting
|
||||
updates[SettingKeyContactInfo] = settings.ContactInfo
|
||||
updates[SettingKeyDocURL] = settings.DocURL
|
||||
updates[SettingKeyHomeContent] = settings.HomeContent
|
||||
updates[SettingKeyCompactHomeEnabled] = strconv.FormatBool(settings.CompactHomeEnabled)
|
||||
updates[SettingKeyHideCcsImportButton] = strconv.FormatBool(settings.HideCcsImportButton)
|
||||
updates[SettingKeyPurchaseSubscriptionEnabled] = strconv.FormatBool(settings.PurchaseSubscriptionEnabled)
|
||||
updates[SettingKeyPurchaseSubscriptionURL] = strings.TrimSpace(settings.PurchaseSubscriptionURL)
|
||||
|
||||
@@ -139,6 +139,7 @@ type SystemSettings struct {
|
||||
ContactInfo string
|
||||
DocURL string
|
||||
HomeContent string
|
||||
CompactHomeEnabled bool
|
||||
HideCcsImportButton bool
|
||||
PurchaseSubscriptionEnabled bool
|
||||
PurchaseSubscriptionURL string
|
||||
@@ -305,6 +306,7 @@ type PublicSettings struct {
|
||||
ContactInfo string
|
||||
DocURL string
|
||||
HomeContent string
|
||||
CompactHomeEnabled bool
|
||||
HideCcsImportButton bool
|
||||
|
||||
PurchaseSubscriptionEnabled bool
|
||||
|
||||
@@ -417,9 +417,9 @@ func TestAssignSubscriptionRenewsExpiredSemanticMatch(t *testing.T) {
|
||||
require.False(t, sub.StartsAt.Before(before))
|
||||
require.False(t, sub.StartsAt.After(after))
|
||||
require.Equal(t, sub.StartsAt.AddDate(0, 0, 30), sub.ExpiresAt)
|
||||
require.Equal(t, startOfDay(sub.StartsAt), *sub.DailyWindowStart)
|
||||
require.Equal(t, startOfDay(sub.StartsAt), *sub.WeeklyWindowStart)
|
||||
require.Equal(t, startOfDay(sub.StartsAt), *sub.MonthlyWindowStart)
|
||||
require.Equal(t, sub.StartsAt, *sub.DailyWindowStart)
|
||||
require.Equal(t, sub.StartsAt, *sub.WeeklyWindowStart)
|
||||
require.Equal(t, sub.StartsAt, *sub.MonthlyWindowStart)
|
||||
require.Zero(t, sub.DailyUsageUSD)
|
||||
require.Zero(t, sub.WeeklyUsageUSD)
|
||||
require.Zero(t, sub.MonthlyUsageUSD)
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
//go:build unit
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type activateWindowUserSubRepo struct {
|
||||
userSubRepoNoop
|
||||
windowStart time.Time
|
||||
}
|
||||
|
||||
type monthlyResetUserSubRepo struct {
|
||||
userSubRepoNoop
|
||||
resetCalled bool
|
||||
resetAt time.Time
|
||||
}
|
||||
|
||||
func (r *monthlyResetUserSubRepo) ResetMonthlyUsage(_ context.Context, _ int64, _ *time.Time, resetAt time.Time) error {
|
||||
r.resetCalled = true
|
||||
r.resetAt = resetAt
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *activateWindowUserSubRepo) ActivateWindows(_ context.Context, _ int64, start time.Time) error {
|
||||
r.windowStart = start
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestDelayedFirstUseAnchorsMonthlyWindowAtActivation(t *testing.T) {
|
||||
repo := &activateWindowUserSubRepo{}
|
||||
svc := NewSubscriptionService(groupRepoNoop{}, repo, nil, nil, nil)
|
||||
startsAt := time.Date(2026, 7, 1, 9, 0, 0, 0, time.UTC)
|
||||
activatedAt := time.Date(2026, 7, 10, 23, 30, 0, 0, time.UTC)
|
||||
svc.now = func() time.Time { return activatedAt }
|
||||
sub := &UserSubscription{
|
||||
ID: 1,
|
||||
StartsAt: startsAt,
|
||||
ExpiresAt: startsAt.Add(45 * 24 * time.Hour),
|
||||
}
|
||||
|
||||
require.NoError(t, svc.CheckAndActivateWindow(context.Background(), sub))
|
||||
|
||||
require.Equal(t, activatedAt, repo.windowStart)
|
||||
monthlyWindowStart := repo.windowStart
|
||||
resetAt, ok := sub.automaticWindowStartAt(&monthlyWindowStart, 30*24*time.Hour, activatedAt.Add(30*24*time.Hour))
|
||||
require.True(t, ok)
|
||||
require.Equal(t, activatedAt.Add(30*24*time.Hour), resetAt)
|
||||
require.NotEqual(t, startsAt.Add(30*24*time.Hour), resetAt)
|
||||
}
|
||||
|
||||
func TestThirtyDaySubscriptionDoesNotResetMonthlyQuotaBeforeExpiry(t *testing.T) {
|
||||
startsAt := time.Date(2026, 7, 1, 23, 30, 0, 0, time.UTC)
|
||||
expiresAt := startsAt.Add(30 * 24 * time.Hour)
|
||||
renewed := renewedSubscriptionTerm(&UserSubscription{}, "", startsAt, expiresAt)
|
||||
|
||||
require.Equal(t, startsAt, *renewed.MonthlyWindowStart)
|
||||
require.False(t, renewed.NeedsMonthlyResetAt(expiresAt.Add(-time.Second)))
|
||||
require.True(t, renewed.NeedsMonthlyResetAt(expiresAt))
|
||||
require.False(t, renewed.canAutomaticallyResetMonthlyAt(expiresAt))
|
||||
require.Equal(t, expiresAt, *renewed.MonthlyResetTime())
|
||||
}
|
||||
|
||||
func TestCheckAndResetWindowsDoesNotResetExactThirtyDayLegacyMonthlyWindow(t *testing.T) {
|
||||
windowStart := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC)
|
||||
startsAt := time.Date(2026, 7, 1, 23, 30, 0, 0, time.UTC)
|
||||
now := startsAt.Add(30 * 24 * time.Hour)
|
||||
repo := &monthlyResetUserSubRepo{}
|
||||
svc := NewSubscriptionService(groupRepoNoop{}, repo, nil, nil, nil)
|
||||
svc.now = func() time.Time { return now }
|
||||
sub := &UserSubscription{
|
||||
ID: 1,
|
||||
StartsAt: startsAt,
|
||||
ExpiresAt: startsAt.Add(30 * 24 * time.Hour),
|
||||
MonthlyWindowStart: &windowStart,
|
||||
MonthlyUsageUSD: 12,
|
||||
}
|
||||
|
||||
require.NoError(t, svc.CheckAndResetWindows(context.Background(), sub))
|
||||
require.False(t, repo.resetCalled)
|
||||
require.Equal(t, 12.0, sub.MonthlyUsageUSD)
|
||||
require.Equal(t, windowStart, *sub.MonthlyWindowStart)
|
||||
}
|
||||
|
||||
func TestCheckAndResetWindowsResetsPartialFinalMonthlySubscriptions(t *testing.T) {
|
||||
for _, durationDays := range []int{31, 45} {
|
||||
t.Run(strconv.Itoa(durationDays)+"_days", func(t *testing.T) {
|
||||
legacyWindowStart := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC)
|
||||
startsAt := time.Date(2026, 7, 1, 23, 30, 0, 0, time.UTC)
|
||||
now := startsAt.Add(30 * 24 * time.Hour)
|
||||
repo := &monthlyResetUserSubRepo{}
|
||||
svc := NewSubscriptionService(groupRepoNoop{}, repo, nil, nil, nil)
|
||||
svc.now = func() time.Time { return now }
|
||||
sub := &UserSubscription{
|
||||
ID: 2,
|
||||
StartsAt: startsAt,
|
||||
ExpiresAt: startsAt.Add(time.Duration(durationDays) * 24 * time.Hour),
|
||||
MonthlyWindowStart: &legacyWindowStart,
|
||||
MonthlyUsageUSD: 12,
|
||||
}
|
||||
|
||||
require.NoError(t, svc.CheckAndResetWindows(context.Background(), sub))
|
||||
require.True(t, repo.resetCalled)
|
||||
require.Equal(t, startsAt.Add(30*24*time.Hour), repo.resetAt)
|
||||
require.Zero(t, sub.MonthlyUsageUSD)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeExpiredWindowsKeepsLegacyMonthlyUsageBeforeExpiry(t *testing.T) {
|
||||
windowStart := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC)
|
||||
startsAt := windowStart.Add(23*time.Hour + 30*time.Minute)
|
||||
now := windowStart.Add(30 * 24 * time.Hour)
|
||||
subs := []UserSubscription{{
|
||||
StartsAt: startsAt,
|
||||
ExpiresAt: startsAt.Add(30 * 24 * time.Hour),
|
||||
MonthlyWindowStart: &windowStart,
|
||||
MonthlyUsageUSD: 12,
|
||||
}}
|
||||
|
||||
normalizeExpiredWindowsAt(subs, now)
|
||||
|
||||
require.Equal(t, 12.0, subs[0].MonthlyUsageUSD)
|
||||
require.Equal(t, windowStart, *subs[0].MonthlyWindowStart)
|
||||
}
|
||||
|
||||
func TestNormalizeExpiredWindowsResetsMonthlyUsageWithPartialFinalPeriod(t *testing.T) {
|
||||
startsAt := time.Date(2026, 7, 1, 23, 30, 0, 0, time.UTC)
|
||||
windowStart := startsAt.Add(-23*time.Hour - 30*time.Minute)
|
||||
now := startsAt.Add(30 * 24 * time.Hour)
|
||||
subs := []UserSubscription{{
|
||||
StartsAt: startsAt,
|
||||
ExpiresAt: startsAt.Add(31 * 24 * time.Hour),
|
||||
MonthlyWindowStart: &windowStart,
|
||||
MonthlyUsageUSD: 12,
|
||||
}}
|
||||
|
||||
normalizeExpiredWindowsAt(subs, now)
|
||||
|
||||
require.Zero(t, subs[0].MonthlyUsageUSD)
|
||||
require.Nil(t, subs[0].MonthlyWindowStart)
|
||||
}
|
||||
|
||||
func TestValidateAndCheckLimitsKeepsLegacyMonthlyUsageBeforeExpiry(t *testing.T) {
|
||||
windowStart := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC)
|
||||
startsAt := windowStart.Add(23*time.Hour + 30*time.Minute)
|
||||
now := windowStart.Add(30 * 24 * time.Hour)
|
||||
limit := 10.0
|
||||
sub := &UserSubscription{
|
||||
Status: SubscriptionStatusActive,
|
||||
StartsAt: startsAt,
|
||||
ExpiresAt: startsAt.Add(30 * 24 * time.Hour),
|
||||
MonthlyWindowStart: &windowStart,
|
||||
MonthlyUsageUSD: 12,
|
||||
}
|
||||
svc := NewSubscriptionService(groupRepoNoop{}, userSubRepoNoop{}, nil, nil, nil)
|
||||
svc.now = func() time.Time { return now }
|
||||
|
||||
needsMaintenance, err := svc.ValidateAndCheckLimits(sub, &Group{MonthlyLimitUSD: &limit})
|
||||
|
||||
require.ErrorIs(t, err, ErrMonthlyLimitExceeded)
|
||||
require.False(t, needsMaintenance)
|
||||
require.Equal(t, 12.0, sub.MonthlyUsageUSD)
|
||||
}
|
||||
|
||||
func TestValidateAndCheckLimitsResetsMonthlyUsageWithPartialFinalPeriod(t *testing.T) {
|
||||
startsAt := time.Date(2026, 7, 1, 23, 30, 0, 0, time.UTC)
|
||||
windowStart := startsAt.Add(-23*time.Hour - 30*time.Minute)
|
||||
now := startsAt.Add(30 * 24 * time.Hour)
|
||||
limit := 10.0
|
||||
sub := &UserSubscription{
|
||||
Status: SubscriptionStatusActive,
|
||||
StartsAt: startsAt,
|
||||
ExpiresAt: startsAt.Add(45 * 24 * time.Hour),
|
||||
MonthlyWindowStart: &windowStart,
|
||||
MonthlyUsageUSD: 12,
|
||||
}
|
||||
svc := NewSubscriptionService(groupRepoNoop{}, userSubRepoNoop{}, nil, nil, nil)
|
||||
svc.now = func() time.Time { return now }
|
||||
|
||||
needsMaintenance, err := svc.ValidateAndCheckLimits(sub, &Group{MonthlyLimitUSD: &limit})
|
||||
|
||||
require.NoError(t, err)
|
||||
require.True(t, needsMaintenance)
|
||||
require.Zero(t, sub.MonthlyUsageUSD)
|
||||
}
|
||||
|
||||
func TestValidateAndCheckLimitsRejectsExactExpiry(t *testing.T) {
|
||||
now := time.Date(2026, 7, 31, 12, 0, 0, 0, time.UTC)
|
||||
sub := &UserSubscription{Status: SubscriptionStatusActive, ExpiresAt: now}
|
||||
svc := NewSubscriptionService(groupRepoNoop{}, userSubRepoNoop{}, nil, nil, nil)
|
||||
svc.now = func() time.Time { return now }
|
||||
|
||||
needsMaintenance, err := svc.ValidateAndCheckLimits(sub, &Group{})
|
||||
|
||||
require.ErrorIs(t, err, ErrSubscriptionExpired)
|
||||
require.False(t, needsMaintenance)
|
||||
}
|
||||
|
||||
func TestAutomaticWindowsAllowPartialFinalDailyAndWeeklyPeriods(t *testing.T) {
|
||||
startsAt := time.Date(2026, 7, 1, 15, 45, 0, 0, time.UTC)
|
||||
legacyWindowStart := startOfDay(startsAt)
|
||||
tests := []struct {
|
||||
name string
|
||||
period time.Duration
|
||||
expiresAt time.Time
|
||||
}{
|
||||
{name: "daily", period: 24 * time.Hour, expiresAt: startsAt.Add(36 * time.Hour)},
|
||||
{name: "weekly", period: 7 * 24 * time.Hour, expiresAt: startsAt.Add(10 * 24 * time.Hour)},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
sub := &UserSubscription{StartsAt: startsAt, ExpiresAt: tt.expiresAt}
|
||||
resetAt, ok := sub.automaticWindowStartAt(&legacyWindowStart, tt.period, startsAt.Add(tt.period))
|
||||
|
||||
require.True(t, ok)
|
||||
require.Equal(t, startsAt.Add(tt.period), resetAt)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutomaticWindowPreservesPersistedManualAnchorAndAdvancesWholePeriods(t *testing.T) {
|
||||
startsAt := time.Date(2026, 1, 1, 10, 0, 0, 0, time.UTC)
|
||||
manualAnchor := startsAt.Add(5 * 24 * time.Hour)
|
||||
sub := &UserSubscription{StartsAt: startsAt, ExpiresAt: startsAt.Add(100 * 24 * time.Hour)}
|
||||
|
||||
resetAt, ok := sub.automaticWindowStartAt(&manualAnchor, 30*24*time.Hour, manualAnchor.Add(65*24*time.Hour))
|
||||
|
||||
require.True(t, ok)
|
||||
require.Equal(t, manualAnchor.Add(60*24*time.Hour), resetAt)
|
||||
}
|
||||
|
||||
func TestAutomaticWindowPreservesPeriodAlignedLaterMidnightManualAnchor(t *testing.T) {
|
||||
startsAt := time.Date(2026, 1, 1, 10, 0, 0, 0, time.UTC)
|
||||
manualAnchor := startOfDay(startsAt).Add(30 * 24 * time.Hour)
|
||||
sub := &UserSubscription{StartsAt: startsAt, ExpiresAt: startsAt.Add(100 * 24 * time.Hour)}
|
||||
|
||||
resetAt, ok := sub.automaticWindowStartAt(&manualAnchor, 30*24*time.Hour, startsAt.Add(60*24*time.Hour))
|
||||
|
||||
require.True(t, ok)
|
||||
require.Equal(t, manualAnchor.Add(30*24*time.Hour), resetAt)
|
||||
}
|
||||
|
||||
func TestAutomaticWindowPreservesExactMidnightManualAnchor(t *testing.T) {
|
||||
startsAt := time.Date(2026, 1, 1, 10, 0, 0, 0, time.UTC)
|
||||
manualAnchor := time.Date(2026, 1, 20, 0, 0, 0, 0, time.UTC)
|
||||
sub := &UserSubscription{StartsAt: startsAt, ExpiresAt: startsAt.Add(100 * 24 * time.Hour)}
|
||||
|
||||
resetAt, ok := sub.automaticWindowStartAt(&manualAnchor, 30*24*time.Hour, manualAnchor.Add(30*24*time.Hour))
|
||||
|
||||
require.True(t, ok)
|
||||
require.Equal(t, manualAnchor.Add(30*24*time.Hour), resetAt)
|
||||
}
|
||||
@@ -24,6 +24,7 @@ type resetQuotaUserSubRepoStub struct {
|
||||
resetDailyErr error
|
||||
resetWeeklyErr error
|
||||
resetMonthlyErr error
|
||||
windowStart time.Time
|
||||
}
|
||||
|
||||
func (r *resetQuotaUserSubRepoStub) GetByID(_ context.Context, id int64) (*UserSubscription, error) {
|
||||
@@ -38,6 +39,7 @@ func (r *resetQuotaUserSubRepoStub) ResetUsageWindows(_ context.Context, _ int64
|
||||
r.resetDailyCalled = resetDaily
|
||||
r.resetWeeklyCalled = resetWeekly
|
||||
r.resetMonthlyCalled = resetMonthly
|
||||
r.windowStart = windowStart
|
||||
if resetDaily && r.resetDailyErr != nil {
|
||||
return r.resetDailyErr
|
||||
}
|
||||
@@ -93,6 +95,8 @@ func TestAdminResetQuota_ResetBoth(t *testing.T) {
|
||||
sub: &UserSubscription{ID: 1, UserID: 10, GroupID: 20},
|
||||
}
|
||||
svc := newResetQuotaSvc(stub)
|
||||
resetAt := time.Date(2026, 7, 1, 10, 37, 42, 123, time.UTC)
|
||||
svc.now = func() time.Time { return resetAt }
|
||||
|
||||
result, err := svc.AdminResetQuota(context.Background(), 1, true, true, false)
|
||||
|
||||
@@ -101,6 +105,9 @@ func TestAdminResetQuota_ResetBoth(t *testing.T) {
|
||||
require.True(t, stub.resetDailyCalled, "应调用 ResetDailyUsage")
|
||||
require.True(t, stub.resetWeeklyCalled, "应调用 ResetWeeklyUsage")
|
||||
require.False(t, stub.resetMonthlyCalled, "不应调用 ResetMonthlyUsage")
|
||||
require.Equal(t, resetAt, stub.windowStart)
|
||||
require.Equal(t, resetAt, *result.DailyWindowStart)
|
||||
require.Equal(t, resetAt, *result.WeeklyWindowStart)
|
||||
}
|
||||
|
||||
func TestAdminResetQuota_ResetDailyOnly(t *testing.T) {
|
||||
@@ -203,6 +210,30 @@ func TestAdminResetQuota_ResetMonthlyOnly(t *testing.T) {
|
||||
require.True(t, stub.resetMonthlyCalled, "应调用 ResetMonthlyUsage")
|
||||
}
|
||||
|
||||
func TestAdminResetQuota_BeforeStartsAtSameDayPreservesAutomaticBoundary(t *testing.T) {
|
||||
startsAt := time.Date(2026, 7, 1, 15, 0, 0, 0, time.UTC)
|
||||
resetAt := time.Date(2026, 7, 1, 10, 37, 42, 123, time.UTC)
|
||||
stub := &resetQuotaUserSubRepoStub{
|
||||
sub: &UserSubscription{
|
||||
ID: 10,
|
||||
UserID: 10,
|
||||
GroupID: 20,
|
||||
StartsAt: startsAt,
|
||||
ExpiresAt: startsAt.Add(45 * 24 * time.Hour),
|
||||
},
|
||||
}
|
||||
svc := newResetQuotaSvc(stub)
|
||||
svc.now = func() time.Time { return resetAt }
|
||||
|
||||
result, err := svc.AdminResetQuota(context.Background(), 10, false, false, true)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, resetAt, *result.MonthlyWindowStart)
|
||||
boundary, ok := result.automaticWindowStartAt(result.MonthlyWindowStart, 30*24*time.Hour, resetAt.Add(30*24*time.Hour))
|
||||
require.True(t, ok)
|
||||
require.Equal(t, resetAt.Add(30*24*time.Hour), boundary)
|
||||
}
|
||||
|
||||
func TestAdminResetQuota_ResetMonthlyUsageError(t *testing.T) {
|
||||
dbErr := errors.New("db error")
|
||||
stub := &resetQuotaUserSubRepoStub{
|
||||
|
||||
@@ -55,6 +55,7 @@ type SubscriptionService struct {
|
||||
subCacheJitter int // 抖动百分比
|
||||
|
||||
maintenanceQueue *SubscriptionMaintenanceQueue
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// NewSubscriptionService 创建订阅服务
|
||||
@@ -64,6 +65,7 @@ func NewSubscriptionService(groupRepo GroupRepository, userSubRepo UserSubscript
|
||||
userSubRepo: userSubRepo,
|
||||
billingCacheService: billingCacheService,
|
||||
entClient: entClient,
|
||||
now: time.Now,
|
||||
}
|
||||
svc.initSubCache(cfg)
|
||||
svc.initMaintenanceQueue(cfg)
|
||||
@@ -368,7 +370,7 @@ func (s *SubscriptionService) withSubscriptionUpdateTx(ctx context.Context, fn f
|
||||
|
||||
func renewedSubscriptionTerm(existingSub *UserSubscription, notes string, startsAt, expiresAt time.Time) *UserSubscription {
|
||||
renewed := *existingSub
|
||||
windowStart := startOfDay(startsAt)
|
||||
windowStart := startsAt
|
||||
renewed.StartsAt = startsAt
|
||||
renewed.ExpiresAt = expiresAt
|
||||
renewed.Status = SubscriptionStatusActive
|
||||
@@ -803,20 +805,24 @@ func (s *SubscriptionService) List(ctx context.Context, page, pageSize int, user
|
||||
// normalizeExpiredWindows 将已过期窗口的数据清零(仅影响返回数据,不影响数据库)
|
||||
// 这确保前端显示正确的当前窗口状态,而不是过期窗口的历史数据
|
||||
func normalizeExpiredWindows(subs []UserSubscription) {
|
||||
normalizeExpiredWindowsAt(subs, time.Now())
|
||||
}
|
||||
|
||||
func normalizeExpiredWindowsAt(subs []UserSubscription, now time.Time) {
|
||||
for i := range subs {
|
||||
sub := &subs[i]
|
||||
// 日窗口过期:清零展示数据
|
||||
if sub.NeedsDailyReset() {
|
||||
if sub.canAutomaticallyResetDailyAt(now) {
|
||||
sub.DailyWindowStart = nil
|
||||
sub.DailyUsageUSD = 0
|
||||
}
|
||||
// 周窗口过期:清零展示数据
|
||||
if sub.NeedsWeeklyReset() {
|
||||
if sub.canAutomaticallyResetWeeklyAt(now) {
|
||||
sub.WeeklyWindowStart = nil
|
||||
sub.WeeklyUsageUSD = 0
|
||||
}
|
||||
// 月窗口过期:清零展示数据
|
||||
if sub.NeedsMonthlyReset() {
|
||||
if sub.canAutomaticallyResetMonthlyAt(now) {
|
||||
sub.MonthlyWindowStart = nil
|
||||
sub.MonthlyUsageUSD = 0
|
||||
}
|
||||
@@ -842,17 +848,18 @@ func startOfDay(t time.Time) time.Time {
|
||||
|
||||
// CheckAndActivateWindow 检查并激活窗口(首次使用时)
|
||||
func (s *SubscriptionService) CheckAndActivateWindow(ctx context.Context, sub *UserSubscription) error {
|
||||
return s.checkAndActivateWindowAt(ctx, sub, s.now())
|
||||
}
|
||||
|
||||
func (s *SubscriptionService) checkAndActivateWindowAt(ctx context.Context, sub *UserSubscription, now time.Time) error {
|
||||
if sub.IsWindowActivated() {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 使用当天零点作为窗口起始时间
|
||||
windowStart := startOfDay(time.Now())
|
||||
return s.userSubRepo.ActivateWindows(ctx, sub.ID, windowStart)
|
||||
return s.userSubRepo.ActivateWindows(ctx, sub.ID, now)
|
||||
}
|
||||
|
||||
// AdminResetQuota manually resets the daily, weekly, and/or monthly usage windows.
|
||||
// Uses startOfDay(now) as the new window start, matching automatic resets.
|
||||
func (s *SubscriptionService) AdminResetQuota(ctx context.Context, subscriptionID int64, resetDaily, resetWeekly, resetMonthly bool) (*UserSubscription, error) {
|
||||
if !resetDaily && !resetWeekly && !resetMonthly {
|
||||
return nil, ErrInvalidInput
|
||||
@@ -861,7 +868,7 @@ func (s *SubscriptionService) AdminResetQuota(ctx context.Context, subscriptionI
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
windowStart := startOfDay(time.Now())
|
||||
windowStart := s.now()
|
||||
if err := s.userSubRepo.ResetUsageWindows(ctx, sub.ID, resetDaily, resetWeekly, resetMonthly, windowStart); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -878,12 +885,11 @@ func (s *SubscriptionService) AdminResetQuota(ctx context.Context, subscriptionI
|
||||
|
||||
// CheckAndResetWindows 检查并重置过期的窗口
|
||||
func (s *SubscriptionService) CheckAndResetWindows(ctx context.Context, sub *UserSubscription) error {
|
||||
// 使用当天零点作为新窗口起始时间
|
||||
windowStart := startOfDay(time.Now())
|
||||
now := s.now()
|
||||
needsInvalidateCache := false
|
||||
|
||||
// 日窗口重置(24小时)
|
||||
if sub.NeedsDailyReset() {
|
||||
if windowStart, ok := sub.automaticWindowStartAt(sub.DailyWindowStart, 24*time.Hour, now); !sub.HasOneTimeDailyQuota() && ok {
|
||||
expectedWindowStart := sub.DailyWindowStart
|
||||
if err := s.userSubRepo.ResetDailyUsage(ctx, sub.ID, expectedWindowStart, windowStart); err != nil {
|
||||
return err
|
||||
@@ -894,7 +900,7 @@ func (s *SubscriptionService) CheckAndResetWindows(ctx context.Context, sub *Use
|
||||
}
|
||||
|
||||
// 周窗口重置(7天)
|
||||
if sub.NeedsWeeklyReset() {
|
||||
if windowStart, ok := sub.automaticWindowStartAt(sub.WeeklyWindowStart, 7*24*time.Hour, now); ok {
|
||||
expectedWindowStart := sub.WeeklyWindowStart
|
||||
if err := s.userSubRepo.ResetWeeklyUsage(ctx, sub.ID, expectedWindowStart, windowStart); err != nil {
|
||||
return err
|
||||
@@ -905,7 +911,7 @@ func (s *SubscriptionService) CheckAndResetWindows(ctx context.Context, sub *Use
|
||||
}
|
||||
|
||||
// 月窗口重置(30天)
|
||||
if sub.NeedsMonthlyReset() {
|
||||
if windowStart, ok := sub.automaticWindowStartAt(sub.MonthlyWindowStart, 30*24*time.Hour, now); ok {
|
||||
expectedWindowStart := sub.MonthlyWindowStart
|
||||
if err := s.userSubRepo.ResetMonthlyUsage(ctx, sub.ID, expectedWindowStart, windowStart); err != nil {
|
||||
return err
|
||||
@@ -971,6 +977,7 @@ func (s *SubscriptionService) CheckUsageLimits(ctx context.Context, sub *UserSub
|
||||
// 仅做内存检查,不触发 DB 写入。调用方必须在放行请求前同步完成窗口维护。
|
||||
// 返回 needsMaintenance 表示是否需要执行窗口维护并回读数据库快照。
|
||||
func (s *SubscriptionService) ValidateAndCheckLimits(sub *UserSubscription, group *Group) (needsMaintenance bool, err error) {
|
||||
now := s.now()
|
||||
// 1. 验证订阅状态
|
||||
if sub.Status == SubscriptionStatusExpired {
|
||||
return false, ErrSubscriptionExpired
|
||||
@@ -978,21 +985,21 @@ func (s *SubscriptionService) ValidateAndCheckLimits(sub *UserSubscription, grou
|
||||
if sub.Status == SubscriptionStatusSuspended {
|
||||
return false, ErrSubscriptionSuspended
|
||||
}
|
||||
if sub.IsExpired() {
|
||||
if !sub.ExpiresAt.After(now) {
|
||||
return false, ErrSubscriptionExpired
|
||||
}
|
||||
|
||||
// 2. 内存中修正过期窗口的用量,确保预检查不会误拒绝用户。
|
||||
// 调用方随后同步推进 DB 窗口,并用回读快照重新校验。
|
||||
if sub.NeedsDailyReset() {
|
||||
if sub.canAutomaticallyResetDailyAt(now) {
|
||||
sub.DailyUsageUSD = 0
|
||||
needsMaintenance = true
|
||||
}
|
||||
if sub.NeedsWeeklyReset() {
|
||||
if sub.canAutomaticallyResetWeeklyAt(now) {
|
||||
sub.WeeklyUsageUSD = 0
|
||||
needsMaintenance = true
|
||||
}
|
||||
if sub.NeedsMonthlyReset() {
|
||||
if sub.canAutomaticallyResetMonthlyAt(now) {
|
||||
sub.MonthlyUsageUSD = 0
|
||||
needsMaintenance = true
|
||||
}
|
||||
|
||||
@@ -85,17 +85,66 @@ func (s *UserSubscription) NeedsDailyResetAt(now time.Time) bool {
|
||||
}
|
||||
|
||||
func (s *UserSubscription) NeedsWeeklyReset() bool {
|
||||
return s.NeedsWeeklyResetAt(time.Now())
|
||||
}
|
||||
|
||||
func (s *UserSubscription) NeedsWeeklyResetAt(now time.Time) bool {
|
||||
if s.WeeklyWindowStart == nil {
|
||||
return false
|
||||
}
|
||||
return time.Since(*s.WeeklyWindowStart) >= 7*24*time.Hour
|
||||
return !now.Before(s.WeeklyWindowStart.Add(7 * 24 * time.Hour))
|
||||
}
|
||||
|
||||
func (s *UserSubscription) NeedsMonthlyReset() bool {
|
||||
return s.NeedsMonthlyResetAt(time.Now())
|
||||
}
|
||||
|
||||
func (s *UserSubscription) NeedsMonthlyResetAt(now time.Time) bool {
|
||||
if s.MonthlyWindowStart == nil {
|
||||
return false
|
||||
}
|
||||
return time.Since(*s.MonthlyWindowStart) >= 30*24*time.Hour
|
||||
return !now.Before(s.MonthlyWindowStart.Add(30 * 24 * time.Hour))
|
||||
}
|
||||
|
||||
func (s *UserSubscription) canAutomaticallyResetDailyAt(now time.Time) bool {
|
||||
_, ok := s.automaticWindowStartAt(s.DailyWindowStart, 24*time.Hour, now)
|
||||
return !s.HasOneTimeDailyQuota() && ok
|
||||
}
|
||||
|
||||
func (s *UserSubscription) canAutomaticallyResetWeeklyAt(now time.Time) bool {
|
||||
_, ok := s.automaticWindowStartAt(s.WeeklyWindowStart, 7*24*time.Hour, now)
|
||||
return ok
|
||||
}
|
||||
|
||||
func (s *UserSubscription) canAutomaticallyResetMonthlyAt(now time.Time) bool {
|
||||
_, ok := s.automaticWindowStartAt(s.MonthlyWindowStart, 30*24*time.Hour, now)
|
||||
return ok
|
||||
}
|
||||
|
||||
func (s *UserSubscription) automaticWindowStartAt(previous *time.Time, period time.Duration, now time.Time) (time.Time, bool) {
|
||||
if previous == nil {
|
||||
return time.Time{}, false
|
||||
}
|
||||
|
||||
anchor := *previous
|
||||
// Older subscriptions initialized their first windows at midnight on their
|
||||
// start date. Only that initial value is unambiguous; later midnight anchors
|
||||
// may be manual resets and must remain authoritative.
|
||||
legacyAnchor := startOfDay(s.StartsAt)
|
||||
if legacyAnchor.Before(s.StartsAt) && anchor.Equal(legacyAnchor) {
|
||||
anchor = s.StartsAt
|
||||
}
|
||||
next := anchor.Add(period)
|
||||
if now.Before(next) || !next.Before(s.ExpiresAt) {
|
||||
return time.Time{}, false
|
||||
}
|
||||
|
||||
periods := now.Sub(anchor) / period
|
||||
lastPeriodBeforeExpiry := (s.ExpiresAt.Sub(anchor) - 1) / period
|
||||
if periods > lastPeriodBeforeExpiry {
|
||||
periods = lastPeriodBeforeExpiry
|
||||
}
|
||||
return anchor.Add(periods * period), true
|
||||
}
|
||||
|
||||
func (s *UserSubscription) DailyResetTime() *time.Time {
|
||||
|
||||
@@ -58,7 +58,7 @@ func TestAssignOrExtendSubscription_ExpiredDailyCardStartsNewOneTimeQuota(t *tes
|
||||
require.True(t, renewed.StartsAt.After(oldStart), "重新购买过期订阅时应重置当前周期 StartsAt")
|
||||
require.False(t, renewed.ExpiresAt.After(renewed.StartsAt.AddDate(0, 0, 1)))
|
||||
require.NotNil(t, renewed.DailyWindowStart)
|
||||
require.Equal(t, startOfDay(renewed.StartsAt), *renewed.DailyWindowStart)
|
||||
require.Equal(t, renewed.StartsAt, *renewed.DailyWindowStart)
|
||||
require.Equal(t, 0.0, renewed.DailyUsageUSD)
|
||||
require.Equal(t, 0.0, renewed.WeeklyUsageUSD)
|
||||
require.Equal(t, 0.0, renewed.MonthlyUsageUSD)
|
||||
@@ -160,17 +160,18 @@ func TestCheckAndResetWindows_DailyCardDoesNotResetDailyUsage(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCheckAndResetWindows_MultiDaySubscriptionStillResetsDailyUsage(t *testing.T) {
|
||||
now := time.Now()
|
||||
now := time.Date(2026, 7, 29, 12, 0, 0, 0, time.UTC)
|
||||
startsAt := now.Add(-48 * time.Hour)
|
||||
dailyWindowStart := now.Add(-25 * time.Hour)
|
||||
repo := &dailyResetTrackingUserSubRepo{}
|
||||
svc := NewSubscriptionService(groupRepoNoop{}, repo, nil, nil, nil)
|
||||
svc.now = func() time.Time { return now }
|
||||
sub := &UserSubscription{
|
||||
ID: 1,
|
||||
UserID: 10,
|
||||
GroupID: 20,
|
||||
StartsAt: startsAt,
|
||||
ExpiresAt: startsAt.AddDate(0, 0, 2),
|
||||
ExpiresAt: startsAt.AddDate(0, 0, 4),
|
||||
DailyUsageUSD: 10,
|
||||
DailyWindowStart: &dailyWindowStart,
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ export interface ContentModerationConfig {
|
||||
mode: ModerationMode
|
||||
base_url: string
|
||||
model: string
|
||||
proxy_id: number | null
|
||||
api_key_configured: boolean
|
||||
api_key_masked: string
|
||||
api_key_count: number
|
||||
@@ -66,6 +67,8 @@ export interface TestContentModerationAPIKeysPayload {
|
||||
base_url?: string
|
||||
model?: string
|
||||
timeout_ms?: number
|
||||
// null/undefined 沿用已保存配置的代理;0 强制直连;>0 指定代理
|
||||
proxy_id?: number
|
||||
prompt?: string
|
||||
images?: string[]
|
||||
}
|
||||
@@ -90,6 +93,8 @@ export interface UpdateContentModerationConfig {
|
||||
mode?: ModerationMode
|
||||
base_url?: string
|
||||
model?: string
|
||||
// undefined 不修改;0 清除(直连);>0 指定代理
|
||||
proxy_id?: number
|
||||
api_key?: string
|
||||
api_keys?: string[]
|
||||
api_keys_mode?: 'append' | 'replace'
|
||||
|
||||
@@ -440,6 +440,7 @@ export interface SystemSettings {
|
||||
contact_info: string;
|
||||
doc_url: string;
|
||||
home_content: string;
|
||||
compact_home_enabled: boolean;
|
||||
hide_ccs_import_button: boolean;
|
||||
table_default_page_size: number;
|
||||
table_page_size_options: number[];
|
||||
@@ -752,6 +753,7 @@ export interface UpdateSettingsRequest {
|
||||
contact_info?: string;
|
||||
doc_url?: string;
|
||||
home_content?: string;
|
||||
compact_home_enabled?: boolean;
|
||||
hide_ccs_import_button?: boolean;
|
||||
table_default_page_size?: number;
|
||||
table_page_size_options?: number[];
|
||||
|
||||
@@ -82,6 +82,57 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- OpenAI Codex namespace 工具摊平(兼容开关,仅 OAuth) -->
|
||||
<div
|
||||
v-if="allOpenAIOAuthOnly"
|
||||
class="border-t border-gray-200 pt-4 dark:border-dark-600"
|
||||
>
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<div class="flex-1 pr-4">
|
||||
<label
|
||||
id="bulk-edit-openai-flatten-namespaces-label"
|
||||
class="input-label mb-0"
|
||||
for="bulk-edit-openai-flatten-namespaces-enabled"
|
||||
>
|
||||
{{ t('admin.accounts.openai.flattenNamespaces') }}
|
||||
</label>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.accounts.openai.flattenNamespacesDesc') }}
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
v-model="enableOpenAIFlattenNamespaces"
|
||||
id="bulk-edit-openai-flatten-namespaces-enabled"
|
||||
type="checkbox"
|
||||
aria-controls="bulk-edit-openai-flatten-namespaces-body"
|
||||
class="rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
id="bulk-edit-openai-flatten-namespaces-body"
|
||||
:class="!enableOpenAIFlattenNamespaces && 'pointer-events-none opacity-50'"
|
||||
role="group"
|
||||
aria-labelledby="bulk-edit-openai-flatten-namespaces-label"
|
||||
>
|
||||
<button
|
||||
id="bulk-edit-openai-flatten-namespaces-toggle"
|
||||
type="button"
|
||||
:class="[
|
||||
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
|
||||
openaiFlattenNamespacesEnabled ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
|
||||
]"
|
||||
@click="openaiFlattenNamespacesEnabled = !openaiFlattenNamespacesEnabled"
|
||||
>
|
||||
<span
|
||||
:class="[
|
||||
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
|
||||
openaiFlattenNamespacesEnabled ? 'translate-x-5' : 'translate-x-0'
|
||||
]"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Base URL (API Key only) -->
|
||||
<div class="border-t border-gray-200 pt-4 dark:border-dark-600">
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
@@ -1331,6 +1382,16 @@ const allOpenAIOAuth = computed(() => {
|
||||
)
|
||||
})
|
||||
|
||||
// 严格 OAuth(不含 setup-token):namespace 摊平兼容开关只对 OAuth 账号生效
|
||||
const allOpenAIOAuthOnly = computed(() => {
|
||||
return (
|
||||
targetSelectedPlatforms.value.length === 1 &&
|
||||
targetSelectedPlatforms.value[0] === 'openai' &&
|
||||
targetSelectedTypes.value.length > 0 &&
|
||||
targetSelectedTypes.value.every(t => t === 'oauth')
|
||||
)
|
||||
})
|
||||
|
||||
const allOpenAIAPIKey = computed(() => {
|
||||
return (
|
||||
targetSelectedPlatforms.value.length === 1 &&
|
||||
@@ -1398,6 +1459,7 @@ const enableRateMultiplier = ref(false)
|
||||
const enableStatus = ref(false)
|
||||
const enableGroups = ref(false)
|
||||
const enableOpenAIPassthrough = ref(false)
|
||||
const enableOpenAIFlattenNamespaces = ref(false)
|
||||
const enableOpenAIWSMode = ref(false)
|
||||
const enableOpenAIAPIKeyWSMode = ref(false)
|
||||
const enableUpstreamBillingAutoProbe = ref(false)
|
||||
@@ -1429,6 +1491,8 @@ const rateMultiplier = ref(1)
|
||||
const status = ref<'active' | 'inactive'>('active')
|
||||
const groupIds = ref<number[]>([])
|
||||
const openaiPassthroughEnabled = ref(false)
|
||||
// Codex namespace 工具摊平兼容开关(仅 OAuth),缺省关闭即原样保留
|
||||
const openaiFlattenNamespacesEnabled = ref(false)
|
||||
const openaiOAuthResponsesWebSocketV2Mode = ref<OpenAIWSMode>(OPENAI_WS_MODE_OFF)
|
||||
const openaiAPIKeyResponsesWebSocketV2Mode = ref<OpenAIWSMode>(OPENAI_WS_MODE_OFF)
|
||||
const upstreamBillingAutoProbeMode = ref<'enabled' | 'disabled'>('enabled')
|
||||
@@ -1638,6 +1702,12 @@ const buildUpdatePayload = (): Record<string, unknown> | null => {
|
||||
}
|
||||
}
|
||||
|
||||
// 同时校验可见性:勾选后又改了目标筛选条件时,不应把该键写到非 OAuth 账号上
|
||||
if (enableOpenAIFlattenNamespaces.value && allOpenAIOAuthOnly.value) {
|
||||
const extra = ensureExtra()
|
||||
extra.openai_responses_flatten_namespaces = openaiFlattenNamespacesEnabled.value
|
||||
}
|
||||
|
||||
if (enableModelRestriction.value && !isOpenAIModelRestrictionDisabled.value) {
|
||||
// 统一使用 model_mapping 字段
|
||||
if (modelRestrictionMode.value === 'whitelist') {
|
||||
@@ -1806,6 +1876,7 @@ const handleSubmit = async () => {
|
||||
const hasAnyFieldEnabled =
|
||||
enableBaseUrl.value ||
|
||||
enableOpenAIPassthrough.value ||
|
||||
enableOpenAIFlattenNamespaces.value ||
|
||||
enableModelRestriction.value ||
|
||||
enableCustomErrorCodes.value ||
|
||||
enableInterceptWarmup.value ||
|
||||
@@ -1946,6 +2017,7 @@ watch(
|
||||
enableStatus.value = false
|
||||
enableGroups.value = false
|
||||
enableOpenAIPassthrough.value = false
|
||||
enableOpenAIFlattenNamespaces.value = false
|
||||
enableOpenAIWSMode.value = false
|
||||
enableOpenAIAPIKeyWSMode.value = false
|
||||
enableUpstreamBillingAutoProbe.value = false
|
||||
@@ -1958,6 +2030,7 @@ watch(
|
||||
// Reset all values
|
||||
baseUrl.value = ''
|
||||
openaiPassthroughEnabled.value = false
|
||||
openaiFlattenNamespacesEnabled.value = false
|
||||
modelRestrictionMode.value = 'whitelist'
|
||||
allowedModels.value = []
|
||||
modelMappings.value = []
|
||||
|
||||
@@ -2781,6 +2781,37 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- OpenAI Codex namespace 工具摊平(兼容开关,仅 OAuth) -->
|
||||
<div
|
||||
v-if="form.platform === 'openai' && form.type === 'oauth'"
|
||||
class="border-t border-gray-200 pt-4 dark:border-dark-600"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<label class="input-label mb-0">{{ t('admin.accounts.openai.flattenNamespaces') }}</label>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.accounts.openai.flattenNamespacesDesc') }}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="create-openai-flatten-namespaces-toggle"
|
||||
@click="openaiFlattenNamespacesEnabled = !openaiFlattenNamespacesEnabled"
|
||||
:class="[
|
||||
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
|
||||
openaiFlattenNamespacesEnabled ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
|
||||
]"
|
||||
>
|
||||
<span
|
||||
:class="[
|
||||
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
|
||||
openaiFlattenNamespacesEnabled ? 'translate-x-5' : 'translate-x-0'
|
||||
]"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- OpenAI WS Mode 三态(off/ctx_pool/passthrough) -->
|
||||
<div
|
||||
v-if="form.platform === 'openai' && (accountCategory === 'oauth-based' || accountCategory === 'apikey')"
|
||||
@@ -3778,6 +3809,8 @@ const applyGrokOAuthUpstreamConfig = (credentials: Record<string, unknown>) => {
|
||||
const interceptWarmupRequests = ref(false)
|
||||
const autoPauseOnExpired = ref(true)
|
||||
const openaiPassthroughEnabled = ref(false)
|
||||
// OpenAI Codex namespace 工具摊平兼容开关(仅 OAuth),缺省关闭即原样保留
|
||||
const openaiFlattenNamespacesEnabled = ref(false)
|
||||
const openAILongContextBillingEnabled = ref(false)
|
||||
const openAILongContextBillingTouched = ref(false)
|
||||
const openAICompactMode = ref<OpenAICompactMode>('auto')
|
||||
@@ -4231,6 +4264,7 @@ watch(
|
||||
}
|
||||
if (newPlatform !== 'openai') {
|
||||
openaiPassthroughEnabled.value = false
|
||||
openaiFlattenNamespacesEnabled.value = false
|
||||
openAIEndpointCapabilities.value = ['chat_completions', 'embeddings']
|
||||
openaiOAuthResponsesWebSocketV2Mode.value = OPENAI_WS_MODE_OFF
|
||||
openaiAPIKeyResponsesWebSocketV2Mode.value = OPENAI_WS_MODE_OFF
|
||||
@@ -4656,6 +4690,7 @@ const resetForm = () => {
|
||||
interceptWarmupRequests.value = false
|
||||
autoPauseOnExpired.value = true
|
||||
openaiPassthroughEnabled.value = false
|
||||
openaiFlattenNamespacesEnabled.value = false
|
||||
openAILongContextBillingEnabled.value = false
|
||||
openAILongContextBillingTouched.value = false
|
||||
openAICompactMode.value = 'auto'
|
||||
@@ -4740,6 +4775,12 @@ const buildOpenAIExtra = (base?: Record<string, unknown>): Record<string, unknow
|
||||
delete extra.openai_passthrough
|
||||
delete extra.openai_oauth_passthrough
|
||||
}
|
||||
// 缺省即保留 namespace,不写空值,避免 extra 里堆积默认项
|
||||
if (form.type === 'oauth' && openaiFlattenNamespacesEnabled.value) {
|
||||
extra.openai_responses_flatten_namespaces = true
|
||||
} else {
|
||||
delete extra.openai_responses_flatten_namespaces
|
||||
}
|
||||
extra.openai_long_context_billing_enabled = openAILongContextBillingEnabled.value
|
||||
|
||||
if (accountCategory.value === 'oauth-based' && codexCLIOnlyEnabled.value) {
|
||||
|
||||
@@ -1481,6 +1481,37 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- OpenAI Codex namespace 工具摊平(兼容开关,仅 OAuth) -->
|
||||
<div
|
||||
v-if="account?.platform === 'openai' && account?.type === 'oauth'"
|
||||
class="border-t border-gray-200 pt-4 dark:border-dark-600"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<label class="input-label mb-0">{{ t('admin.accounts.openai.flattenNamespaces') }}</label>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.accounts.openai.flattenNamespacesDesc') }}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="edit-openai-flatten-namespaces-toggle"
|
||||
@click="openaiFlattenNamespacesEnabled = !openaiFlattenNamespacesEnabled"
|
||||
:class="[
|
||||
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
|
||||
openaiFlattenNamespacesEnabled ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
|
||||
]"
|
||||
>
|
||||
<span
|
||||
:class="[
|
||||
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
|
||||
openaiFlattenNamespacesEnabled ? 'translate-x-5' : 'translate-x-0'
|
||||
]"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- OpenAI Codex hosted image_generation bridge policy -->
|
||||
<div
|
||||
v-if="account?.platform === 'openai' && (account?.type === 'oauth' || account?.type === 'setup-token' || account?.type === 'apikey')"
|
||||
@@ -2841,6 +2872,8 @@ const customBaseUrl = ref('')
|
||||
|
||||
// OpenAI 自动透传开关(OAuth/API Key)
|
||||
const openaiPassthroughEnabled = ref(false)
|
||||
// OpenAI Codex namespace 工具摊平兼容开关(仅 OAuth),缺省关闭即原样保留
|
||||
const openaiFlattenNamespacesEnabled = ref(false)
|
||||
const openAILongContextBillingEnabled = ref(false)
|
||||
// OpenAI 订阅档位(Plus/Pro/Free)手动覆盖值,存于 credentials.plan_type;'' 表示清空/自动识别
|
||||
const editPlanType = ref<string>('')
|
||||
@@ -3276,6 +3309,7 @@ const syncFormFromAccount = (newAccount: Account | null) => {
|
||||
|
||||
// Load OpenAI passthrough toggle (OpenAI OAuth/SetupToken/API Key)
|
||||
openaiPassthroughEnabled.value = false
|
||||
openaiFlattenNamespacesEnabled.value = false
|
||||
openAILongContextBillingEnabled.value = false
|
||||
editPlanType.value = ''
|
||||
openAICompactMode.value = 'auto'
|
||||
@@ -3292,6 +3326,8 @@ const syncFormFromAccount = (newAccount: Account | null) => {
|
||||
webSearchEmulationMode.value = 'default'
|
||||
if (newAccount.platform === 'openai' && (newAccount.type === 'oauth' || newAccount.type === 'setup-token' || newAccount.type === 'apikey')) {
|
||||
openaiPassthroughEnabled.value = extra?.openai_passthrough === true || extra?.openai_oauth_passthrough === true
|
||||
openaiFlattenNamespacesEnabled.value =
|
||||
newAccount.type === 'oauth' && extra?.openai_responses_flatten_namespaces === true
|
||||
const longContextBillingValue = extra?.openai_long_context_billing_enabled
|
||||
openAILongContextBillingEnabled.value = longContextBillingValue === true
|
||||
// plan_type 手动覆盖仅 OAuth 有实际调度语义(IsOpenAIChatGPTSubscription 要求 oauth),故只对 oauth 回填
|
||||
@@ -4526,6 +4562,12 @@ const handleSubmit = async () => {
|
||||
delete newExtra.openai_passthrough
|
||||
delete newExtra.openai_oauth_passthrough
|
||||
}
|
||||
// 缺省即保留 namespace,不写空值,避免 extra 里堆积默认项
|
||||
if (props.account.type === 'oauth' && openaiFlattenNamespacesEnabled.value) {
|
||||
newExtra.openai_responses_flatten_namespaces = true
|
||||
} else {
|
||||
delete newExtra.openai_responses_flatten_namespaces
|
||||
}
|
||||
if (isSparkShadow.value) {
|
||||
delete newExtra.openai_long_context_billing_enabled
|
||||
} else {
|
||||
|
||||
@@ -237,6 +237,34 @@ describe('BulkEditAccountModal', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('OpenAI OAuth 批量编辑可开启 namespace 摊平兼容开关', async () => {
|
||||
const wrapper = mountModal({
|
||||
selectedPlatforms: ['openai'],
|
||||
selectedTypes: ['oauth']
|
||||
})
|
||||
|
||||
await wrapper.get('#bulk-edit-openai-flatten-namespaces-enabled').setValue(true)
|
||||
await wrapper.get('#bulk-edit-openai-flatten-namespaces-toggle').trigger('click')
|
||||
await wrapper.get('#bulk-edit-account-form').trigger('submit.prevent')
|
||||
await flushPromises()
|
||||
|
||||
expect(adminAPI.accounts.bulkUpdate).toHaveBeenCalledTimes(1)
|
||||
expect(adminAPI.accounts.bulkUpdate).toHaveBeenCalledWith([1, 2], {
|
||||
extra: {
|
||||
openai_responses_flatten_namespaces: true
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('namespace 摊平开关不对 setup-token 等非 OAuth 选择展示', async () => {
|
||||
const wrapper = mountModal({
|
||||
selectedPlatforms: ['openai'],
|
||||
selectedTypes: ['oauth', 'setup-token']
|
||||
})
|
||||
|
||||
expect(wrapper.find('#bulk-edit-openai-flatten-namespaces-enabled').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('OpenAI OAuth 批量编辑应提交 OAuth 专属 WS mode 字段(含 http_bridge)', async () => {
|
||||
const wrapper = mountModal({
|
||||
selectedPlatforms: ['openai'],
|
||||
|
||||
@@ -167,6 +167,21 @@ describe('CreateAccountModal OpenAI long-context billing', () => {
|
||||
expect(createAccountMock.mock.calls[0]?.[0]?.extra?.openai_long_context_billing_enabled).toBe(false)
|
||||
})
|
||||
|
||||
// namespace 摊平是仅 OAuth 的兼容开关:API Key 走 chat completions 回退桥时由桥自行摊平
|
||||
it('shows the Codex namespace flatten toggle only for OpenAI OAuth accounts', async () => {
|
||||
const wrapper = mountModal()
|
||||
await selectButtonByText(wrapper, 'OpenAI')
|
||||
|
||||
expect(wrapper.find('[data-testid="create-openai-flatten-namespaces-toggle"]').exists()).toBe(
|
||||
true
|
||||
)
|
||||
|
||||
await selectButtonByText(wrapper, 'API Key')
|
||||
expect(wrapper.find('[data-testid="create-openai-flatten-namespaces-toggle"]').exists()).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
it('enables upstream billing probes by default for new OpenAI API key accounts', async () => {
|
||||
await submitApiKeyAccount('openai')
|
||||
|
||||
|
||||
@@ -416,6 +416,57 @@ describe('EditAccountModal', () => {
|
||||
expect(updateAccountMock.mock.calls[0]?.[1]?.extra?.openai_long_context_billing_enabled).toBe(false)
|
||||
})
|
||||
|
||||
it('loads and clears the OAuth-only Codex namespace flatten toggle', async () => {
|
||||
const account = buildAccount()
|
||||
account.type = 'oauth'
|
||||
account.extra = {
|
||||
openai_responses_flatten_namespaces: true
|
||||
}
|
||||
updateAccountMock.mockReset()
|
||||
checkMixedChannelRiskMock.mockReset()
|
||||
checkMixedChannelRiskMock.mockResolvedValue({ has_risk: false })
|
||||
updateAccountMock.mockResolvedValue(account)
|
||||
|
||||
const wrapper = mountModal(account)
|
||||
const toggle = wrapper.get('[data-testid="edit-openai-flatten-namespaces-toggle"]')
|
||||
|
||||
// 关闭后应从 extra 中删除该键,而不是写入 false
|
||||
await toggle.trigger('click')
|
||||
await wrapper.get('form#edit-account-form').trigger('submit.prevent')
|
||||
|
||||
expect(updateAccountMock).toHaveBeenCalledTimes(1)
|
||||
expect(updateAccountMock.mock.calls[0]?.[1]?.extra).not.toHaveProperty(
|
||||
'openai_responses_flatten_namespaces'
|
||||
)
|
||||
})
|
||||
|
||||
it('submits the Codex namespace flatten toggle when switched on', async () => {
|
||||
const account = buildAccount()
|
||||
account.type = 'oauth'
|
||||
updateAccountMock.mockReset()
|
||||
checkMixedChannelRiskMock.mockReset()
|
||||
checkMixedChannelRiskMock.mockResolvedValue({ has_risk: false })
|
||||
updateAccountMock.mockResolvedValue(account)
|
||||
|
||||
const wrapper = mountModal(account)
|
||||
await wrapper.get('[data-testid="edit-openai-flatten-namespaces-toggle"]').trigger('click')
|
||||
await wrapper.get('form#edit-account-form').trigger('submit.prevent')
|
||||
|
||||
expect(updateAccountMock).toHaveBeenCalledTimes(1)
|
||||
expect(updateAccountMock.mock.calls[0]?.[1]?.extra?.openai_responses_flatten_namespaces).toBe(
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
it('hides the Codex namespace flatten toggle for non-OAuth OpenAI accounts', async () => {
|
||||
const account = buildAccount()
|
||||
const wrapper = mountModal(account)
|
||||
|
||||
expect(wrapper.find('[data-testid="edit-openai-flatten-namespaces-toggle"]').exists()).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
it('defaults legacy OpenAI accounts to long-context billing disabled', async () => {
|
||||
const account = buildAccount()
|
||||
updateAccountMock.mockReset()
|
||||
|
||||
@@ -73,6 +73,7 @@ function buildPublicSettings(overrides: Partial<WeChatPublicSettings> = {}): WeC
|
||||
contact_info: '',
|
||||
doc_url: '',
|
||||
home_content: '',
|
||||
compact_home_enabled: false,
|
||||
hide_ccs_import_button: false,
|
||||
payment_enabled: false,
|
||||
table_default_page_size: 20,
|
||||
|
||||
@@ -3,14 +3,18 @@
|
||||
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ t('payment.paymentMethod') }}
|
||||
</label>
|
||||
<div class="grid grid-cols-2 gap-3 sm:flex">
|
||||
<div
|
||||
data-testid="payment-method-grid"
|
||||
class="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4"
|
||||
>
|
||||
<button
|
||||
v-for="method in sortedMethods"
|
||||
:key="method.type"
|
||||
type="button"
|
||||
:title="methodLabel(method)"
|
||||
:disabled="!method.available"
|
||||
:class="[
|
||||
'relative flex h-[60px] flex-col items-center justify-center rounded-lg border px-3 transition-all sm:flex-1',
|
||||
'relative flex h-[60px] min-w-0 flex-col items-center justify-center rounded-lg border px-3 transition-all',
|
||||
!method.available
|
||||
? 'cursor-not-allowed border-gray-200 bg-gray-50 opacity-50 dark:border-dark-700 dark:bg-dark-800/50'
|
||||
: selected === method.type
|
||||
@@ -19,10 +23,12 @@
|
||||
]"
|
||||
@click="method.available && emit('select', method.type)"
|
||||
>
|
||||
<span class="flex items-center gap-2">
|
||||
<img :src="methodIcon(method.type)" :alt="methodLabel(method)" class="h-7 w-7 object-contain" />
|
||||
<span class="flex flex-col items-start leading-none">
|
||||
<span class="text-base font-semibold">{{ methodLabel(method) }}</span>
|
||||
<span class="flex w-full min-w-0 items-center justify-center gap-2">
|
||||
<img :src="methodIcon(method.type)" :alt="methodLabel(method)" class="h-7 w-7 shrink-0 object-contain" />
|
||||
<span class="flex min-w-0 flex-col items-start leading-none">
|
||||
<span data-testid="payment-method-label" class="block w-full truncate text-base font-semibold">
|
||||
{{ methodLabel(method) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="method.fee_rate > 0"
|
||||
class="text-[10px] tracking-wide text-gray-500 dark:text-dark-400"
|
||||
|
||||
@@ -9,6 +9,32 @@ vi.mock('vue-i18n', () => ({
|
||||
}))
|
||||
|
||||
describe('PaymentMethodSelector', () => {
|
||||
it('wraps large custom method collections without letting labels widen the selector', () => {
|
||||
const methods = Array.from({ length: 12 }, (_, index) => ({
|
||||
type: `custom_${index}`,
|
||||
display_name: `CUSTOM_PAYMENT_METHOD_${index}`,
|
||||
fee_rate: 0,
|
||||
available: true,
|
||||
}))
|
||||
|
||||
const wrapper = mount(PaymentMethodSelector, {
|
||||
props: {
|
||||
selected: 'custom_0',
|
||||
methods,
|
||||
},
|
||||
})
|
||||
|
||||
const grid = wrapper.get('[data-testid="payment-method-grid"]')
|
||||
expect(grid.classes()).toEqual(expect.arrayContaining(['grid', 'sm:grid-cols-3', 'lg:grid-cols-4']))
|
||||
expect(grid.classes()).not.toContain('sm:flex')
|
||||
|
||||
const buttons = wrapper.findAll('button')
|
||||
expect(buttons).toHaveLength(methods.length)
|
||||
expect(buttons.every(button => button.classes().includes('min-w-0'))).toBe(true)
|
||||
expect(buttons.every((button, index) => button.attributes('title') === methods[index].display_name)).toBe(true)
|
||||
expect(wrapper.findAll('[data-testid="payment-method-label"]').every(label => label.classes().includes('truncate'))).toBe(true)
|
||||
})
|
||||
|
||||
it('shows the configured display name for custom EasyPay methods', () => {
|
||||
const wrapper = mount(PaymentMethodSelector, {
|
||||
props: {
|
||||
|
||||
@@ -250,6 +250,7 @@ describe('ProfileIdentityBindingsSection', () => {
|
||||
contact_info: '',
|
||||
doc_url: '',
|
||||
home_content: '',
|
||||
compact_home_enabled: false,
|
||||
hide_ccs_import_button: false,
|
||||
payment_enabled: false,
|
||||
table_default_page_size: 20,
|
||||
|
||||
@@ -95,6 +95,7 @@
|
||||
<div class="flex flex-wrap items-center gap-x-5 gap-y-2">
|
||||
<SaveToggle :label="t('admin.promptAudit.saveBar.enabled')" :model-value="draft.enabled" data-test="enabled-toggle" @update:model-value="setEnabled" />
|
||||
<SaveToggle :label="t('admin.promptAudit.saveBar.blocking')" :model-value="draft.blocking_enabled" :disabled="!draft.enabled" data-test="blocking-toggle" @update:model-value="setBlocking" />
|
||||
<SaveToggle :label="t('admin.promptAudit.saveBar.blockingLatestTurnOnly')" :model-value="draft.blocking_latest_turn_only" :disabled="!draft.enabled || !draft.blocking_enabled" data-test="blocking-latest-turn-only-toggle" @update:model-value="replaceDraft({ ...draft!, blocking_latest_turn_only: $event })" />
|
||||
<SaveToggle :label="t('admin.promptAudit.saveBar.storePass')" :model-value="draft.store_pass_events" data-test="store-pass-toggle" @update:model-value="replaceDraft({ ...draft!, store_pass_events: $event })" />
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
|
||||
@@ -19,7 +19,7 @@ vi.mock('vue-i18n', async () => {
|
||||
})
|
||||
|
||||
const baseConfig = (): PromptAuditConfig => ({
|
||||
enabled: true, blocking_enabled: false, store_pass_events: false, effective_mode: 'async_audit', strategy: 'priority',
|
||||
enabled: true, blocking_enabled: false, blocking_latest_turn_only: false, store_pass_events: false, effective_mode: 'async_audit', strategy: 'priority',
|
||||
worker_count: 4, queue_capacity: 100, scanners: SCANNER_CATALOG.map((item) => item.id), all_groups: true, group_ids: [],
|
||||
endpoints: [{ id: 'guard-1', name: 'Guard One', protocol: 'openai_compatible', base_url: 'http://127.0.0.1:8000', model: 'guard-model', timeout_ms: 3000, input_limit: 4000, enabled: true, has_token: true, token_status: 'configured' }],
|
||||
config_version: 7, updated_at: '2026-07-16T00:00:00Z', updated_by: 1, change_summary: '{}',
|
||||
@@ -126,10 +126,13 @@ describe('PromptAuditView', () => {
|
||||
expect(wrapper.find('[data-test="confirm"]').exists()).toBe(true)
|
||||
await wrapper.get('[data-test="confirm-action"]').trigger('click')
|
||||
expect(wrapper.get('[data-test="blocking-toggle"]').attributes('aria-checked')).toBe('true')
|
||||
await wrapper.get('[data-test="blocking-latest-turn-only-toggle"]').trigger('click')
|
||||
expect(wrapper.get('[data-test="blocking-latest-turn-only-toggle"]').attributes('aria-checked')).toBe('true')
|
||||
await wrapper.get('[data-test="enabled-toggle"]').trigger('click')
|
||||
expect(wrapper.get('[data-test="enabled-toggle"]').attributes('aria-checked')).toBe('false')
|
||||
expect(wrapper.get('[data-test="blocking-toggle"]').attributes('aria-checked')).toBe('false')
|
||||
expect(wrapper.get('[data-test="blocking-toggle"]').attributes()).toHaveProperty('disabled')
|
||||
expect(wrapper.get('[data-test="blocking-latest-turn-only-toggle"]').attributes()).toHaveProperty('disabled')
|
||||
})
|
||||
|
||||
it('clears plaintext token state after a successful save', async () => {
|
||||
@@ -175,7 +178,7 @@ describe('PromptAuditView', () => {
|
||||
await flushPromises()
|
||||
await wrapper.get('[data-test="tab-config"]').trigger('click')
|
||||
const switches = wrapper.findAll('[role="switch"]')
|
||||
expect(switches).toHaveLength(3)
|
||||
expect(switches).toHaveLength(4)
|
||||
expect(switches.every((item) => Boolean(item.attributes('aria-label')))).toBe(true)
|
||||
expect(wrapper.html()).toContain('fixed inset-x-0 bottom-0')
|
||||
expect(wrapper.html()).toContain('flex-wrap')
|
||||
|
||||
@@ -67,7 +67,7 @@ describe('Prompt Audit components', () => {
|
||||
|
||||
it('supports group search, stale configured groups, nine scanners, and bounded worker inputs', async () => {
|
||||
const draft: PromptAuditDraft = {
|
||||
enabled: true, blocking_enabled: false, store_pass_events: false, effective_mode: 'async_audit', strategy: 'priority',
|
||||
enabled: true, blocking_enabled: false, blocking_latest_turn_only: false, store_pass_events: false, effective_mode: 'async_audit', strategy: 'priority',
|
||||
worker_count: 4, queue_capacity: 100, scanners: SCANNER_CATALOG.map((item) => item.id), all_groups: false, group_ids: [1, 99],
|
||||
endpoints: [endpoint()], config_version: 1, updated_at: '', updated_by: 0, change_summary: '',
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
const config = (): PromptAuditConfig => ({
|
||||
enabled: true,
|
||||
blocking_enabled: false,
|
||||
blocking_latest_turn_only: false,
|
||||
store_pass_events: false,
|
||||
effective_mode: 'async_audit',
|
||||
strategy: 'priority',
|
||||
@@ -56,6 +57,12 @@ describe('Prompt Audit view model', () => {
|
||||
expect(buildUpdateRequest(draft).endpoints[0]).toMatchObject({ token: undefined, clear_token: true })
|
||||
})
|
||||
|
||||
it('includes the optional narrow blocking scope in the update payload', () => {
|
||||
const draft = configToDraft(config())
|
||||
draft.blocking_latest_turn_only = true
|
||||
expect(buildUpdateRequest(draft)).toMatchObject({ blocking_latest_turn_only: true })
|
||||
})
|
||||
|
||||
it('tracks dirty state from the full normalized save payload', () => {
|
||||
const original = configToDraft(config())
|
||||
const changed = configToDraft(config())
|
||||
|
||||
@@ -23,6 +23,7 @@ export interface PromptAuditEndpointDraft extends PromptAuditEndpoint {
|
||||
export interface PromptAuditConfig {
|
||||
enabled: boolean
|
||||
blocking_enabled: boolean
|
||||
blocking_latest_turn_only: boolean
|
||||
store_pass_events: boolean
|
||||
effective_mode: PromptAuditMode
|
||||
strategy: 'priority'
|
||||
@@ -46,6 +47,7 @@ export interface PromptAuditUpdateRequest {
|
||||
expected_config_version: number
|
||||
enabled: boolean
|
||||
blocking_enabled: boolean
|
||||
blocking_latest_turn_only: boolean
|
||||
store_pass_events: boolean
|
||||
strategy: 'priority'
|
||||
worker_count: number
|
||||
|
||||
@@ -62,6 +62,7 @@ export function buildUpdateRequest(draft: PromptAuditDraft): PromptAuditUpdateRe
|
||||
expected_config_version: draft.config_version,
|
||||
enabled: draft.enabled,
|
||||
blocking_enabled: draft.enabled && draft.blocking_enabled,
|
||||
blocking_latest_turn_only: draft.blocking_latest_turn_only,
|
||||
store_pass_events: draft.store_pass_events,
|
||||
strategy: 'priority',
|
||||
worker_count: Number(draft.worker_count),
|
||||
|
||||
@@ -489,6 +489,9 @@ export default {
|
||||
oauthPassthrough: 'Auto passthrough (auth only)',
|
||||
oauthPassthroughDesc:
|
||||
'When enabled, this OpenAI account uses automatic passthrough: the gateway forwards request/response as-is and only swaps auth, while keeping billing/concurrency/audit and necessary safety filtering.',
|
||||
flattenNamespaces: 'Flatten Codex namespace tools (compatibility)',
|
||||
flattenNamespacesDesc:
|
||||
'Disabled by default: Codex namespace tool declarations are forwarded as-is on /responses, which is what the ChatGPT Codex backend expects. Enable only when this OAuth account is routed to a relay that rejects namespace tools — flattening renames them to namespace__tool, which breaks models that address collaboration tools as functions.<namespace>.<tool>. Compaction requests always flatten regardless of this switch.',
|
||||
longContextBilling: 'API long-context pricing',
|
||||
longContextBillingDesc:
|
||||
'Disabled by default. Enable only when this account\'s upstream charges OpenAI API long-context rates above the model threshold.',
|
||||
|
||||
@@ -286,6 +286,8 @@ export default {
|
||||
timeoutMs: 'HTTP Timeout (ms)',
|
||||
retryCount: 'Retry Count',
|
||||
sampleRate: 'Sample Rate',
|
||||
proxy: 'Proxy Server',
|
||||
proxyHint: 'Send moderation requests through the selected proxy (IP Management - Proxy Servers), useful when the egress IP is not supported by OpenAI. Defaults to direct connection.',
|
||||
recordNonHits: 'Record Non-Hits',
|
||||
recordNonHitsHint: 'When enabled, sampled non-hit request summaries are redacted before storage.',
|
||||
preHashCheck: 'Enable Pre-Hash Check',
|
||||
|
||||
@@ -55,7 +55,7 @@ export default {
|
||||
searchGroups: 'Search groups', noGroups: 'No matching groups', missingGroups: 'Configured IDs for groups that no longer exist', selectedCount: '{count} groups selected',
|
||||
scanners: 'Qwen3Guard input-risk categories', workerCount: 'Worker count', queueCapacity: 'Persistent queue capacity', strategy: 'Node strategy', strategyHint: 'Try nodes in configuration order and fail over when allowed.',
|
||||
},
|
||||
saveBar: { enabled: 'Enable prompt audit', blocking: 'Synchronous blocking', storePass: 'Store safe events', dirty: 'Unsaved changes', synced: 'Configuration synced' },
|
||||
saveBar: { enabled: 'Enable prompt audit', blocking: 'Synchronous blocking', blockingLatestTurnOnly: 'Only latest input and prior output', storePass: 'Store safe events', dirty: 'Unsaved changes', synced: 'Configuration synced' },
|
||||
blockingConfirm: {
|
||||
title: 'Enable synchronous blocking?',
|
||||
message: 'Applicable requests wait for Guard before account selection, billing, or upstream access. Block, unavailable Guard, and invalid responses all prevent upstream access.',
|
||||
|
||||
@@ -549,6 +549,8 @@ export default {
|
||||
homeContentPlaceholder: 'Enter custom content for the home page. Supports Markdown & HTML. If a URL is entered, it will be displayed as an iframe.',
|
||||
homeContentHint: 'Customize the home page content. Supports Markdown/HTML. If you enter a URL (starting with http:// or https://), it will be used as an iframe src to embed an external page. When set, the default status information will no longer be displayed.',
|
||||
homeContentIframeWarning: '⚠️ iframe mode note: Some websites have X-Frame-Options or CSP security policies that prevent embedding in iframes. If the page appears blank or shows an error, please verify the target website allows embedding, or consider using HTML mode to build your own content.',
|
||||
compactHome: 'Compact Home Page',
|
||||
compactHomeHint: 'Show a restrained site identity page when no custom home page content is set.',
|
||||
hideCcsImportButton: 'Hide CCS Import Button',
|
||||
hideCcsImportButtonHint: 'When enabled, the "Import to CCS" button will be hidden on the API Keys page'
|
||||
},
|
||||
|
||||
@@ -556,6 +556,9 @@ export default {
|
||||
oauthPassthrough: '自动透传(仅替换认证)',
|
||||
oauthPassthroughDesc:
|
||||
'开启后,该 OpenAI 账号将自动透传请求与响应,仅替换认证并保留计费/并发/审计及必要安全过滤;如遇兼容性问题可随时关闭回滚。',
|
||||
flattenNamespaces: '摊平 Codex namespace 工具(兼容)',
|
||||
flattenNamespacesDesc:
|
||||
'默认关闭:/responses 上的 namespace 工具声明原样转发,这正是 ChatGPT Codex 后端期望的形态。仅当该 OAuth 账号指向不认识 namespace 的兼容上游时才开启——摊平会把工具改名为 namespace__tool,使按 functions.<命名空间>.<工具> 寻址的模型(如 gpt-5.6 多智能体)无法调用。压缩(compact)请求不受该开关影响,始终摊平。',
|
||||
longContextBilling: 'API 长上下文计费',
|
||||
longContextBillingDesc: '默认关闭。仅当该账号的上游会按模型阈值收取 OpenAI API 长上下文费率时开启。',
|
||||
responsesWebsocketsV2: 'Responses WebSocket v2',
|
||||
|
||||
@@ -286,6 +286,8 @@ export default {
|
||||
timeoutMs: 'HTTP 超时 (ms)',
|
||||
retryCount: '失败重试次数',
|
||||
sampleRate: '采样率',
|
||||
proxy: '代理服务器',
|
||||
proxyHint: '审计请求经指定代理(IP管理-代理服务器)发出,适用于出口 IP 不受 OpenAI 支持的部署;默认直连。',
|
||||
recordNonHits: '记录未命中输入',
|
||||
recordNonHitsHint: '开启后会记录抽样但未命中的请求摘要,摘要会先脱敏再入库。',
|
||||
preHashCheck: '启用前置哈希比对',
|
||||
|
||||
@@ -55,7 +55,7 @@ export default {
|
||||
searchGroups: '搜索分组', noGroups: '没有匹配分组', missingGroups: '配置中包含已删除的分组 ID', selectedCount: '已选择 {count} 个分组',
|
||||
scanners: 'Qwen3Guard 输入风险分类', workerCount: 'Worker 数量', queueCapacity: '持久队列容量', strategy: '节点策略', strategyHint: '按配置顺序优先尝试,必要时故障切换。',
|
||||
},
|
||||
saveBar: { enabled: '启用提示词审计', blocking: '同步阻止', storePass: '保存安全事件', dirty: '有未保存的更改', synced: '配置已同步' },
|
||||
saveBar: { enabled: '启用提示词审计', blocking: '同步阻止', blockingLatestTurnOnly: '仅审最新输入和上一轮输出', storePass: '保存安全事件', dirty: '有未保存的更改', synced: '配置已同步' },
|
||||
blockingConfirm: {
|
||||
title: '开启同步阻止?',
|
||||
message: '适用请求会在账号选择、计费和访问上游之前等待 Guard。命中 Block、Guard 不可用或响应非法时,请求都不会访问上游。',
|
||||
|
||||
@@ -544,6 +544,8 @@ export default {
|
||||
'自定义首页内容,支持 Markdown/HTML。如果输入的是链接(以 http:// 或 https:// 开头),则会使用该链接作为 iframe 的 src 属性,这允许你设置任意网页作为首页。设置后首页的状态信息将不再显示。',
|
||||
homeContentIframeWarning:
|
||||
'⚠️ iframe 模式提示:部分网站设置了 X-Frame-Options 或 CSP 安全策略,禁止被嵌入到 iframe 中。如果页面显示空白或报错,请确认目标网站允许被嵌入,或考虑使用 HTML 模式自行构建页面内容。',
|
||||
compactHome: '简洁首页',
|
||||
compactHomeHint: '未设置自定义首页内容时,展示简洁的站点信息页面。',
|
||||
hideCcsImportButton: '隐藏 CCS 导入按钮',
|
||||
hideCcsImportButtonHint: '启用后将在 API Keys 页面隐藏"导入 CCS"按钮'
|
||||
},
|
||||
|
||||
@@ -33,6 +33,7 @@ function createPublicSettings(overrides: Partial<PublicSettings> = {}): PublicSe
|
||||
contact_info: '',
|
||||
doc_url: '',
|
||||
home_content: '',
|
||||
compact_home_enabled: false,
|
||||
hide_ccs_import_button: false,
|
||||
payment_enabled: false,
|
||||
risk_control_enabled: false,
|
||||
@@ -452,6 +453,7 @@ describe('useAppStore', () => {
|
||||
contact_info: '',
|
||||
doc_url: '',
|
||||
home_content: '',
|
||||
compact_home_enabled: false,
|
||||
hide_ccs_import_button: false,
|
||||
purchase_subscription_enabled: false,
|
||||
purchase_subscription_url: '',
|
||||
|
||||
@@ -342,6 +342,7 @@ export const useAppStore = defineStore('app', () => {
|
||||
contact_info: contactInfo.value,
|
||||
doc_url: docUrl.value,
|
||||
home_content: '',
|
||||
compact_home_enabled: false,
|
||||
hide_ccs_import_button: false,
|
||||
payment_enabled: false,
|
||||
table_default_page_size: 20,
|
||||
|
||||
@@ -210,6 +210,7 @@ export interface PublicSettings {
|
||||
contact_info: string
|
||||
doc_url: string
|
||||
home_content: string
|
||||
compact_home_enabled: boolean
|
||||
hide_ccs_import_button: boolean
|
||||
payment_enabled: boolean
|
||||
risk_control_enabled: boolean
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<!-- Custom Home Content: Full Page Mode -->
|
||||
<div v-if="homeContent" class="min-h-screen">
|
||||
<div v-if="hasHomeContent" class="min-h-screen">
|
||||
<!-- iframe mode -->
|
||||
<iframe
|
||||
v-if="isHomeContentUrl"
|
||||
@@ -12,6 +12,75 @@
|
||||
<div v-else v-html="homeContent"></div>
|
||||
</div>
|
||||
|
||||
<!-- Compact Home Page -->
|
||||
<div
|
||||
v-else-if="compactHomeEnabled"
|
||||
data-testid="compact-home"
|
||||
class="flex min-h-screen flex-col bg-gray-50 text-gray-900 dark:bg-dark-950 dark:text-white"
|
||||
>
|
||||
<header class="border-b border-gray-200 px-4 py-4 sm:px-6 dark:border-dark-800">
|
||||
<nav class="mx-auto flex max-w-5xl flex-wrap items-center justify-between gap-3 sm:gap-4">
|
||||
<div class="flex min-w-0 flex-1 items-center gap-3">
|
||||
<img
|
||||
:src="siteLogo || '/logo.svg'"
|
||||
alt="Logo"
|
||||
class="h-9 w-9 shrink-0 rounded-lg object-contain"
|
||||
/>
|
||||
<span class="min-w-0 truncate text-base font-semibold">{{ siteName }}</span>
|
||||
</div>
|
||||
<div class="flex max-w-full shrink-0 flex-wrap items-center justify-end gap-2">
|
||||
<LocaleSwitcher />
|
||||
<a
|
||||
v-if="docUrl"
|
||||
:href="docUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg text-gray-500 hover:bg-gray-100 dark:text-dark-400 dark:hover:bg-dark-800"
|
||||
:title="t('home.viewDocs')"
|
||||
>
|
||||
<Icon name="book" size="md" />
|
||||
</a>
|
||||
<button
|
||||
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg text-gray-500 hover:bg-gray-100 dark:text-dark-400 dark:hover:bg-dark-800"
|
||||
:title="isDark ? t('home.switchToLight') : t('home.switchToDark')"
|
||||
@click="toggleTheme"
|
||||
>
|
||||
<Icon v-if="isDark" name="sun" size="md" />
|
||||
<Icon v-else name="moon" size="md" />
|
||||
</button>
|
||||
<router-link
|
||||
:to="isAuthenticated ? dashboardPath : '/login'"
|
||||
class="inline-flex min-h-10 shrink-0 items-center justify-center rounded-lg bg-gray-900 px-4 py-2 text-sm font-medium text-white hover:bg-gray-800 dark:bg-white dark:text-gray-900 dark:hover:bg-gray-200"
|
||||
>
|
||||
{{ isAuthenticated ? t('home.dashboard') : t('home.login') }}
|
||||
</router-link>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main class="flex min-w-0 flex-1 items-center justify-center px-4 py-16 sm:px-6">
|
||||
<div class="min-w-0 max-w-2xl text-center">
|
||||
<img
|
||||
:src="siteLogo || '/logo.svg'"
|
||||
alt="Logo"
|
||||
class="mx-auto mb-6 h-20 w-20 rounded-2xl object-contain"
|
||||
/>
|
||||
<h1 class="[overflow-wrap:anywhere] text-3xl font-bold md:text-4xl">{{ siteName }}</h1>
|
||||
<p class="mt-4 whitespace-pre-wrap [overflow-wrap:anywhere] text-base text-gray-600 dark:text-dark-300">{{ siteSubtitle }}</p>
|
||||
<router-link
|
||||
:to="isAuthenticated ? dashboardPath : '/login'"
|
||||
class="mt-8 inline-flex min-h-10 items-center justify-center rounded-lg bg-primary-600 px-5 py-2.5 text-sm font-medium text-white hover:bg-primary-700"
|
||||
>
|
||||
{{ isAuthenticated ? t('home.goToDashboard') : t('home.login') }}
|
||||
</router-link>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer class="min-w-0 border-t border-gray-200 px-4 py-5 text-center text-sm text-gray-500 [overflow-wrap:anywhere] sm:px-6 dark:border-dark-800 dark:text-dark-400">
|
||||
© {{ currentYear }} {{ siteName }}
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<!-- Default Home Page -->
|
||||
<div
|
||||
v-else
|
||||
@@ -423,6 +492,8 @@ const siteLogo = computed(() => sanitizeUrl(appStore.cachedPublicSettings?.site_
|
||||
const siteSubtitle = computed(() => appStore.cachedPublicSettings?.site_subtitle || 'AI API Gateway Platform')
|
||||
const docUrl = computed(() => sanitizeUrl(appStore.cachedPublicSettings?.doc_url || appStore.docUrl || ''))
|
||||
const homeContent = computed(() => appStore.cachedPublicSettings?.home_content || '')
|
||||
const hasHomeContent = computed(() => homeContent.value.trim().length > 0)
|
||||
const compactHomeEnabled = computed(() => appStore.cachedPublicSettings?.compact_home_enabled === true)
|
||||
|
||||
// Check if homeContent is a URL (for iframe display)
|
||||
const isHomeContentUrl = computed(() => {
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mount, RouterLinkStub } from '@vue/test-utils'
|
||||
|
||||
import HomeView from '../HomeView.vue'
|
||||
|
||||
const { appStore, authStore } = vi.hoisted(() => ({
|
||||
appStore: {
|
||||
cachedPublicSettings: {} as Record<string, unknown>,
|
||||
siteName: 'Fallback site',
|
||||
siteLogo: '',
|
||||
docUrl: '',
|
||||
publicSettingsLoaded: true,
|
||||
fetchPublicSettings: vi.fn(),
|
||||
},
|
||||
authStore: {
|
||||
isAuthenticated: false,
|
||||
isAdmin: false,
|
||||
user: null as { email?: string } | null,
|
||||
checkAuth: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/stores', () => ({
|
||||
useAppStore: () => appStore,
|
||||
useAuthStore: () => authStore,
|
||||
}))
|
||||
|
||||
vi.mock('vue-i18n', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('vue-i18n')>()
|
||||
return {
|
||||
...actual,
|
||||
useI18n: () => ({ t: (key: string) => key }),
|
||||
}
|
||||
})
|
||||
|
||||
function mountHome(settings: Record<string, unknown> = {}) {
|
||||
appStore.cachedPublicSettings = {
|
||||
site_name: 'Test site',
|
||||
site_subtitle: 'Test subtitle',
|
||||
...settings,
|
||||
}
|
||||
|
||||
return mount(HomeView, {
|
||||
global: {
|
||||
stubs: {
|
||||
RouterLink: RouterLinkStub,
|
||||
LocaleSwitcher: { template: '<div data-testid="locale-switcher" />' },
|
||||
Icon: { template: '<span data-testid="icon" />' },
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function compactDestination(wrapper: ReturnType<typeof mountHome>) {
|
||||
return wrapper.get('[data-testid="compact-home"]').findComponent(RouterLinkStub).props('to')
|
||||
}
|
||||
|
||||
describe('HomeView compact mode', () => {
|
||||
beforeEach(() => {
|
||||
authStore.isAuthenticated = false
|
||||
authStore.isAdmin = false
|
||||
authStore.user = null
|
||||
authStore.checkAuth.mockClear()
|
||||
appStore.fetchPublicSettings.mockClear()
|
||||
localStorage.clear()
|
||||
vi.spyOn(window, 'matchMedia').mockReturnValue({ matches: false } as MediaQueryList)
|
||||
})
|
||||
|
||||
it('renders custom HTML ahead of compact mode', () => {
|
||||
const wrapper = mountHome({
|
||||
compact_home_enabled: true,
|
||||
home_content: '<section id="custom-home">Custom home</section>',
|
||||
})
|
||||
|
||||
expect(wrapper.get('#custom-home').text()).toBe('Custom home')
|
||||
expect(wrapper.find('[data-testid="compact-home"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('renders custom URL content ahead of compact mode', () => {
|
||||
const wrapper = mountHome({
|
||||
compact_home_enabled: true,
|
||||
home_content: ' https://example.com/home ',
|
||||
})
|
||||
|
||||
expect(wrapper.get('iframe').attributes('src')).toBe('https://example.com/home')
|
||||
expect(wrapper.find('[data-testid="compact-home"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('treats whitespace-only custom content as empty and selects compact mode', () => {
|
||||
const wrapper = mountHome({ compact_home_enabled: true, home_content: ' \n\t ' })
|
||||
|
||||
expect(wrapper.get('[data-testid="compact-home"]').text()).toContain('Test site')
|
||||
})
|
||||
|
||||
it.each([undefined, false])('selects the default home when compact mode is %s', (enabled) => {
|
||||
const settings = enabled === undefined ? {} : { compact_home_enabled: enabled }
|
||||
const wrapper = mountHome(settings)
|
||||
|
||||
expect(wrapper.find('[data-testid="compact-home"]').exists()).toBe(false)
|
||||
expect(wrapper.find('.terminal-container').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('links unauthenticated visitors to login', () => {
|
||||
expect(compactDestination(mountHome({ compact_home_enabled: true }))).toBe('/login')
|
||||
})
|
||||
|
||||
it('links authenticated users to their dashboard', () => {
|
||||
authStore.isAuthenticated = true
|
||||
|
||||
expect(compactDestination(mountHome({ compact_home_enabled: true }))).toBe('/dashboard')
|
||||
})
|
||||
|
||||
it('links administrators to the admin dashboard', () => {
|
||||
authStore.isAuthenticated = true
|
||||
authStore.isAdmin = true
|
||||
|
||||
const wrapper = mountHome({ compact_home_enabled: true })
|
||||
expect(compactDestination(wrapper)).toBe('/admin/dashboard')
|
||||
expect(authStore.checkAuth).toHaveBeenCalledOnce()
|
||||
expect(appStore.fetchPublicSettings).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -424,6 +424,11 @@
|
||||
<span class="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-gray-400">%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.riskControl.proxy') }}</label>
|
||||
<ProxySelector v-model="configForm.proxy_id" :proxies="proxies" />
|
||||
<p class="mt-2 text-xs leading-5 text-gray-500 dark:text-gray-400">{{ t('admin.riskControl.proxyHint') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="overflow-hidden rounded-xl border border-gray-100 bg-white shadow-sm dark:border-dark-700 dark:bg-dark-800">
|
||||
@@ -1123,6 +1128,7 @@ import Select from '@/components/common/Select.vue'
|
||||
import Toggle from '@/components/common/Toggle.vue'
|
||||
import Pagination from '@/components/common/Pagination.vue'
|
||||
import ModelWhitelistSelector from '@/components/account/ModelWhitelistSelector.vue'
|
||||
import ProxySelector from '@/components/common/ProxySelector.vue'
|
||||
import { adminAPI } from '@/api/admin'
|
||||
import type {
|
||||
ContentModerationAPIKeyLoad,
|
||||
@@ -1137,7 +1143,7 @@ import type {
|
||||
ModerationMode,
|
||||
UpdateContentModerationConfig,
|
||||
} from '@/api/admin/riskControl'
|
||||
import type { AdminGroup, SelectOption } from '@/types'
|
||||
import type { AdminGroup, Proxy, SelectOption } from '@/types'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { extractApiErrorMessage } from '@/utils/apiError'
|
||||
import { formatDateTime as formatDateTimeValue } from '@/utils/format'
|
||||
@@ -1205,6 +1211,7 @@ const activeSettingsTab = ref<SettingsTab>('basic')
|
||||
const groupSearch = ref('')
|
||||
const flaggedHashInput = ref('')
|
||||
const groups = ref<AdminGroup[]>([])
|
||||
const proxies = ref<Proxy[]>([])
|
||||
const logs = ref<ContentModerationLog[]>([])
|
||||
const status = ref<ContentModerationRuntimeStatus | null>(null)
|
||||
const testedApiKeyStatuses = ref<ContentModerationAPIKeyStatus[]>([])
|
||||
@@ -1221,6 +1228,7 @@ const configForm = reactive({
|
||||
mode: 'pre_block' as ModerationMode,
|
||||
base_url: 'https://api.openai.com',
|
||||
model: 'omni-moderation-latest',
|
||||
proxy_id: null as number | null,
|
||||
api_keys_text: '',
|
||||
api_key_configured: false,
|
||||
api_key_masked: '',
|
||||
@@ -1695,6 +1703,7 @@ function applyConfig(config: ContentModerationConfig) {
|
||||
configForm.mode = config.mode
|
||||
configForm.base_url = config.base_url || 'https://api.openai.com'
|
||||
configForm.model = config.model || 'omni-moderation-latest'
|
||||
configForm.proxy_id = config.proxy_id || null
|
||||
configForm.api_keys_text = ''
|
||||
configForm.api_key_configured = config.api_key_configured
|
||||
configForm.api_key_masked = config.api_key_masked || ''
|
||||
@@ -1735,14 +1744,17 @@ function applyConfig(config: ContentModerationConfig) {
|
||||
async function loadAll() {
|
||||
loading.value = true
|
||||
try {
|
||||
const [config, groupItems, runtimeStatus] = await Promise.all([
|
||||
const [config, groupItems, runtimeStatus, proxyItems] = await Promise.all([
|
||||
adminAPI.riskControl.getConfig(),
|
||||
adminAPI.groups.getAll(),
|
||||
adminAPI.riskControl.getStatus(),
|
||||
// 代理列表加载失败不阻塞风控页面(仅影响下拉可选项)
|
||||
adminAPI.proxies.getAll().catch(() => [] as Proxy[]),
|
||||
])
|
||||
applyConfig(config)
|
||||
groups.value = groupItems
|
||||
status.value = runtimeStatus
|
||||
proxies.value = proxyItems
|
||||
if (Array.isArray(runtimeStatus.api_key_statuses)) {
|
||||
configForm.api_key_statuses = [...runtimeStatus.api_key_statuses]
|
||||
prunePendingDeleteAPIKeyHashes()
|
||||
@@ -1786,6 +1798,8 @@ async function saveConfig() {
|
||||
mode: configForm.mode,
|
||||
base_url: configForm.base_url,
|
||||
model: configForm.model,
|
||||
// 后端语义:0 清除代理(直连),>0 指定代理
|
||||
proxy_id: configForm.proxy_id ?? 0,
|
||||
timeout_ms: Number(configForm.timeout_ms) || 3000,
|
||||
retry_count: Number(configForm.retry_count) || 0,
|
||||
sample_rate: Number(configForm.sample_rate) || 0,
|
||||
@@ -1984,6 +1998,8 @@ async function testApiKeys(useInputKeys: boolean) {
|
||||
base_url: configForm.base_url,
|
||||
model: configForm.model,
|
||||
timeout_ms: Number(configForm.timeout_ms) || 3000,
|
||||
// 与保存语义一致:0 强制直连,>0 指定代理,确保测试与实际审计走同一条链路
|
||||
proxy_id: configForm.proxy_id ?? 0,
|
||||
prompt: moderationTestPrompt.value,
|
||||
images: moderationTestImages.value,
|
||||
})
|
||||
|
||||
@@ -5977,6 +5977,19 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Compact Home Page -->
|
||||
<div class="flex items-center justify-between border-t border-gray-100 pt-4 dark:border-dark-700">
|
||||
<div>
|
||||
<label class="font-medium text-gray-900 dark:text-white">{{
|
||||
t("admin.settings.site.compactHome")
|
||||
}}</label>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ t("admin.settings.site.compactHomeHint") }}
|
||||
</p>
|
||||
</div>
|
||||
<Toggle v-model="form.compact_home_enabled" data-testid="compact-home-toggle" />
|
||||
</div>
|
||||
|
||||
<!-- Hide CCS Import Button -->
|
||||
<div
|
||||
class="flex items-center justify-between border-t border-gray-100 pt-4 dark:border-dark-700"
|
||||
@@ -8837,6 +8850,7 @@ const form = reactive<SettingsForm>({
|
||||
contact_info: "",
|
||||
doc_url: "",
|
||||
home_content: "",
|
||||
compact_home_enabled: false,
|
||||
backend_mode_enabled: false,
|
||||
hide_ccs_import_button: false,
|
||||
payment_enabled: false,
|
||||
@@ -10361,6 +10375,7 @@ async function saveSettings() {
|
||||
contact_info: form.contact_info,
|
||||
doc_url: form.doc_url,
|
||||
home_content: form.home_content,
|
||||
compact_home_enabled: form.compact_home_enabled,
|
||||
backend_mode_enabled: form.backend_mode_enabled,
|
||||
hide_ccs_import_button: form.hide_ccs_import_button,
|
||||
table_default_page_size: form.table_default_page_size,
|
||||
|
||||
@@ -12,6 +12,7 @@ const {
|
||||
getStatus,
|
||||
listLogs,
|
||||
getGroups,
|
||||
getProxies,
|
||||
showError,
|
||||
showSuccess,
|
||||
} = vi.hoisted(() => ({
|
||||
@@ -20,6 +21,7 @@ const {
|
||||
getStatus: vi.fn(),
|
||||
listLogs: vi.fn(),
|
||||
getGroups: vi.fn(),
|
||||
getProxies: vi.fn(),
|
||||
showError: vi.fn(),
|
||||
showSuccess: vi.fn(),
|
||||
}))
|
||||
@@ -39,6 +41,9 @@ vi.mock('@/api/admin', () => ({
|
||||
groups: {
|
||||
getAll: getGroups,
|
||||
},
|
||||
proxies: {
|
||||
getAll: getProxies,
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
@@ -73,6 +78,7 @@ const baseConfig = (): ContentModerationConfig => ({
|
||||
mode: 'pre_block',
|
||||
base_url: 'https://api.openai.com',
|
||||
model: 'omni-moderation-latest',
|
||||
proxy_id: null,
|
||||
api_key_configured: false,
|
||||
api_key_masked: '',
|
||||
api_key_count: 0,
|
||||
@@ -198,6 +204,7 @@ describe('admin RiskControlView', () => {
|
||||
getStatus.mockResolvedValue(runtimeStatus())
|
||||
listLogs.mockResolvedValue({ items: [], total: 0, page: 1, page_size: 20, pages: 1 })
|
||||
getGroups.mockResolvedValue([])
|
||||
getProxies.mockResolvedValue([])
|
||||
updateConfig.mockImplementation(async (payload: UpdateContentModerationConfig) => ({
|
||||
...baseConfig(),
|
||||
...payload,
|
||||
@@ -221,6 +228,7 @@ describe('admin RiskControlView', () => {
|
||||
Toggle: true,
|
||||
Pagination: true,
|
||||
ModelWhitelistSelector: ModelWhitelistSelectorStub,
|
||||
ProxySelector: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -254,6 +262,7 @@ describe('admin RiskControlView', () => {
|
||||
Toggle: true,
|
||||
Pagination: true,
|
||||
ModelWhitelistSelector: ModelWhitelistSelectorStub,
|
||||
ProxySelector: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -294,6 +303,7 @@ describe('admin RiskControlView', () => {
|
||||
Toggle: true,
|
||||
Pagination: true,
|
||||
ModelWhitelistSelector: ModelWhitelistSelectorStub,
|
||||
ProxySelector: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -361,6 +371,7 @@ describe('admin RiskControlView', () => {
|
||||
Toggle: true,
|
||||
Pagination: true,
|
||||
ModelWhitelistSelector: ModelWhitelistSelectorStub,
|
||||
ProxySelector: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -378,6 +378,7 @@ const baseSettingsResponse = {
|
||||
contact_info: "",
|
||||
doc_url: "",
|
||||
home_content: "",
|
||||
compact_home_enabled: false,
|
||||
hide_ccs_import_button: false,
|
||||
table_default_page_size: 20,
|
||||
table_page_size_options: [10, 20, 50, 100],
|
||||
@@ -680,6 +681,22 @@ describe("admin SettingsView payment visible method controls", () => {
|
||||
adminSettingsFetch.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it("submits the compact home page toggle", async () => {
|
||||
const wrapper = mountView();
|
||||
await flushPromises();
|
||||
|
||||
const toggle = wrapper.get('[data-testid="compact-home-toggle"]');
|
||||
expect((toggle.element as HTMLInputElement).checked).toBe(false);
|
||||
|
||||
await toggle.setValue(true);
|
||||
await wrapper.find("form").trigger("submit.prevent");
|
||||
await flushPromises();
|
||||
|
||||
expect(updateSettings).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ compact_home_enabled: true }),
|
||||
);
|
||||
});
|
||||
|
||||
it("renders panel rate limit card and saves settings", async () => {
|
||||
getPanelRateLimitSettings.mockClear();
|
||||
updatePanelRateLimitSettings.mockClear();
|
||||
|
||||
Reference in New Issue
Block a user