Compare commits

...
Author SHA1 Message Date
shaw 948b63c9ca feat(moderation): route content moderation through configurable proxy server
Implements #2646: the risk-control content audit can now send OpenAI
Moderations requests through a proxy from IP Management - Proxy Servers.

Backend:
- ContentModerationConfig gains proxy_id (nil = direct, unchanged default)
- update semantics: null keeps, 0 clears, >0 selects (validated to exist)
- moderation calls build the client via the shared httpclient pool; proxy
  resolution failure surfaces as a moderation error and never silently
  falls back to direct connection
- proxy_id -> URL resolution cached 60s (single-entry, invalidated on
  config save) so the pre-block hot path does not hit the DB per request
- test-key endpoint accepts proxy_id too (null = saved config's proxy,
  0 = force direct), so input-key/saved-key tests exercise the same path
- proxy usage/inactivity logged (content_moderation.proxy_enabled /
  proxy_not_active) without leaking credentials

Frontend:
- ProxySelector in the risk-control basic settings tab, proxy list loaded
  non-blockingly; save and test payloads carry proxy_id; zh/en i18n
2026-07-31 23:12:22 +08:00
shaw 4c80d160dd fix(email): unify SMTP connection path between send and test-connection
- UseTLS now tries implicit TLS first (port 465 semantics) and, when the
  server answers in plaintext (tls.RecordHeaderError, e.g. port 587
  submission), automatically retries with mandatory STARTTLS; encryption
  is never silently downgraded (fixes #1470, supersedes #1488)
- TestSMTPConnectionWithConfig now shares connectSMTP with the send path,
  adding the opportunistic STARTTLS upgrade the send path gained in
  b402c367d; this removes the 'test connection fails but test email
  sends' mismatch reported in #1488
- test-connection now also honors dial/IO timeouts and ignores
  non-standard QUIT responses, matching the send path
2026-07-31 23:12:02 +08:00
Wesley Liddick 570ea74d12 Merge pull request #5117 from gaoren002/feat/prompt-audit-blocking-latest-input
feat(security-audit): add optional narrow blocking audit scope
2026-07-31 22:32:00 +08:00
Wesley Liddick 2980ff3850 Merge pull request #5094 from wucm667/feat/issue-5065-compact-homepage
feat(home): add compact home page preset to avoid abuse classification
2026-07-31 21:51:33 +08:00
Wesley Liddick 04c96a2015 Merge pull request #4981 from INKCR0W/fix/openai-preserve-codex-namespaces
fix(openai): OAuth 原生 Responses 默认保留 Codex namespace,修复 code_mode_only 模型无法派发子代理
2026-07-31 21:49:44 +08:00
Wesley Liddick 07f980b99f Merge pull request #5084 from apple-ouyang/codex/fix-openai-compaction-encrypted-retry
fix(openai): recover stale encrypted compaction
2026-07-31 21:48:10 +08:00
Ouyang Xingyuan fe21725865 fix(openai): recover stale encrypted compaction
Reason:
- Responses retries can carry account-bound encrypted compaction items that OpenAI rejects with invalid_encrypted_content.

Changes:
- Drop encrypted compaction and compaction_summary items only during the existing recovery retry.
- Preserve unencrypted compaction items and cover HTTP and WebSocket recovery paths.
2026-07-31 21:21:13 +08:00
Wesley Liddick d29acc29a5 Merge pull request #5066 from wucm667/fix/issue-5051-subscription-quota-window
fix(subscription): align quota windows with subscription term
2026-07-31 20:40:18 +08:00
Wesley Liddick 66998918b6 Merge pull request #5143 from wucm667/fix/issue-5138-codex-instructions
fix(openai): default missing passthrough instructions
2026-07-31 20:39:57 +08:00
Wesley Liddick da6194c1c3 Merge pull request #5112 from chenty2333/fix/openai-stream-capacity-pool-retry
fix(openai): retry streamed capacity errors in pool mode
2026-07-31 20:38:15 +08:00
Wesley Liddick 132d446ca9 Merge pull request #5133 from dawnx/fix/payment-visible-method-wipe
fix(payment): 保存系统设置时不再清空可见支付方式配置
2026-07-31 20:37:58 +08:00
Wesley Liddick 0eac363e67 Merge pull request #5120 from Vibeone/fix/grok-pool-mode-cooldown-bypass
fix(grok): 公共池模式跳过所有默认冷却路径
2026-07-31 20:27:46 +08:00
Wesley Liddick 796313e993 Merge pull request #5131 from wucm667/fix/issue-5125-image-data-url-offload
fix(images): decode data URLs during task offload
2026-07-31 20:27:35 +08:00
Wesley Liddick c772d18666 Merge pull request #5130 from moonfunjohn/codex/fix-epay-method-selector-overflow
fix(payment): prevent EasyPay method selector overflow
2026-07-31 20:27:25 +08:00
Wesley Liddick 94df1fffc2 Merge pull request #5124 from wucm667/fix/issue-5105-filter-grok-billing-ping
fix(grok): filter billing ping response events
2026-07-31 19:20:16 +08:00
shaw 30967d5d9a fix(grok): ping 帧统一改写为 SSE 注释并限制过滤缓冲
Responses 事件类型对严格客户端是闭合枚举,任何 event: ping 帧都会令
grok CLI / Codex CLI 整轮失败。原实现只精确匹配 inference-cost 标记帧
和 cost=="0" 帧,上游尾帧携带非零 cost 或格式微调即复发 #5105;且对
其它 ping 变体的保守放行同样会炸掉严格解析器。现改为:event: ping 帧
(data 声明的 type 与事件名不冲突时)一律改写为 SSE 注释 ": ping",
所有解析器安全忽略且保留保活效果。

同时把整帧缓冲改为增量状态机:非 ping 帧首行即判定、逐行零拷贝直通,
不再累积;仅 ping 候选帧缓冲,并设 16 行 / 16KB 上限,超限回放原文
转直通,杜绝上游用永不结束的帧撑爆网关内存。
2026-07-31 18:57:43 +08:00
wucm667 dfdbc27709 fix(openai): default missing passthrough instructions 2026-07-31 18:55:15 +08:00
wucm667 beeb2f989b test(settings): include compact home in API contracts 2026-07-31 18:34:13 +08:00
wucm667 77d4df9544 test(grok): check filter body close error 2026-07-31 18:34:11 +08:00
github-actions[bot] 7ceabb3fd5 chore: sync VERSION to 0.1.169 [skip ci] 2026-07-31 09:19:08 +00:00
wucm667 d6467f6eb0 fix(images): decode data URLs during task offload 2026-07-31 15:04:56 +08:00
moonfunjohn 8ed9f754cf fix(payment): prevent method selector overflow 2026-07-31 14:54:01 +08:00
dawn 3deb2f17d8 fix(payment): 保存系统设置时不再清空可见支付方式配置
UpdatePaymentConfig 无条件写入全部设置键,未传的指针字段经
derefStr(nil) / formatBoolOrEmpty(nil) 转成空串后仍会落库。

setting_handler_update.go 构造 UpdatePaymentConfigRequest 时从不填充
VisibleMethod* 四个字段,而 UpdateSettingsWithAuthSourceDefaultsOmitting
先写入正确值、UpdatePaymentConfig 紧接着以空串覆盖,导致管理员每次保存
系统设置都会静默重置支付宝/微信的可见支付方式路由。

改为仅写入调用方显式提供的字段,符合 PATCH 语义。EnabledTypes 传
空切片仍可显式清空。
2026-07-31 14:21:36 +08:00
wucm667 baaae8e121 fix(grok): filter billing ping response events 2026-07-31 12:31:45 +08:00
eyre 5c9629ddb7 fix(grok): unify pool mode bypass for all default cooldown paths
- Move pool mode check before the status switch so 401/402/403/5xx
  all skip tempUnschedule consistently
- Skip rateLimitGrok in updateGrokUsageSnapshot for pool mode
- Expand test coverage to all affected status codes
2026-07-31 03:02:01 +00:00
eyre 4d13925c9e fix(grok): skip entitlement 403 cooldown for pool mode accounts 2026-07-31 03:02:01 +00:00
gaoren002 d74e669a23 feat(security-audit): add narrow blocking audit scope 2026-07-31 01:47:53 +00:00
diannaaav 7d3bf86e55 fix(openai): retry streamed capacity errors in pool mode 2026-07-31 02:31:35 +08:00
wucm667 739c0ff9c5 feat(home): add compact home page preset to avoid abuse classification
- Add compact_home_enabled setting to provide a minimal landing page
- Preserves custom home_content priority over compact mode
- Renders only site identity, navigation, and login/dashboard link
- Avoids marketing copy that triggers anti-fraud systems
- Includes focused backend/frontend tests and i18n support

Fixes #5065
2026-07-30 16:28:19 +08:00
wucm667 7b6111f2f7 fix(subscription): align quota windows with subscription term 2026-07-30 02:37:14 +08:00
InkCrow 272735b0a7 fix(openai): preserve Codex namespace tools on OAuth Responses forwarding
Flattening Codex `type:"namespace"` tool declarations into `namespace__name`
renames the tool but cannot rewrite the contract the client already handed the
model: Codex tells it to call `to=functions.collaboration.spawn_agent`. Under
`tool_mode: code_mode_only` (gpt-5.6-*) collaboration tools are the model's only
direct channel, so the rename left it unable to address them — it fell back to
`functions.exec` and looped on meaningless shell commands until the session
collapsed, with no upstream error anywhere. Fixes #4978.

Preserving is what the upstream actually wants. OAuth egress is unconditionally
chatgpt.com/backend-api/codex/responses (base_url is read only for API-key
accounts), i.e. the party that defines the extension; codex-rs builds one
request for both WS and HTTP and falls back WS->HTTP mid-session, so these
declarations already reach that host over HTTP — as they do today via the
responses-lite carrier and the WS/HTTP bridge, neither of which ever flattened.

Namespace names cannot be allowlisted: `features.multi_agent_v2.tool_namespace`
is user-configurable (openai/codex#31864 recommends renaming it to `agents`) and
MCP/connector namespaces are generated at runtime (mcp__codex_apps__gmail).
So the default is inverted rather than extended with more names.

- Preserve namespace declarations by default for OpenAI OAuth; the new
  per-account `extra.openai_responses_flatten_namespaces` restores the previous
  behavior for deployments routing OAuth traffic to a relay that rejects them.
- Keep `input[].namespace` on tool-call items for OAuth non-compact requests.
  The upstream requires the round-trip ("Missing namespace for function_call
  '...'. Round-trip the model's function_call item with its namespace field
  included."), so preserving declarations while stripping the calls would 400 on
  the second turn. The item-type allowlist mirrors the existing reactive strip.
- Keep compact on its current behavior: that endpoint rejects the field outright
  ("Unknown parameter: 'input[894].namespace'", #4761) and there is no evidence
  either way about namespace declarations there, so this change does not widen
  its surface. API-key accounts are untouched — their upstream is a standard
  Responses API and the reactive retry only clears one index per round trip.
- Clear the flatten mapping at the start of each Forward attempt so an account
  reached through failover cannot restore responses with the previous account's
  mapping.

Admin UI gets an OAuth-only toggle (create/edit/bulk) plus zh/en copy noting the
compact exception.
2026-07-28 09:19:13 +08:00
97 changed files with 3916 additions and 427 deletions
+1 -1
View File
@@ -1 +1 @@
0.1.168
0.1.169
+8 -8
View File
@@ -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,
+2
View File
@@ -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")
+84 -77
View File
@@ -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)
+109 -20
View File
@@ -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,
+16
View File
@@ -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 {
+120 -13
View File
@@ -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)
+92 -119
View File
@@ -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}
+66 -1
View File
@@ -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)
+19 -15
View File
@@ -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
}
+51 -2
View File
@@ -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,
}
+5
View File
@@ -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'
+2
View File
@@ -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: '',
+1
View File
@@ -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,
+1
View File
@@ -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
+72 -1
View File
@@ -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">
&copy; {{ 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()
})
})
+18 -2
View File
@@ -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,
})
+15
View File
@@ -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();