diff --git a/backend/cmd/server/wire_gen.go b/backend/cmd/server/wire_gen.go
index 6e8be8fce9a..62f1ac70b83 100644
--- a/backend/cmd/server/wire_gen.go
+++ b/backend/cmd/server/wire_gen.go
@@ -141,7 +141,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
oAuthRefreshAPI := service.ProvideOAuthRefreshAPI(accountRepository, geminiTokenCache)
openAITokenProvider := service.ProvideOpenAITokenProvider(accountRepository, geminiTokenCache, openAIOAuthService, oAuthRefreshAPI)
channelRepository := repository.NewChannelRepository(db)
- channelService := service.NewChannelService(channelRepository, groupRepository, apiKeyAuthCacheInvalidator, pricingService)
+ channelService := service.NewChannelService(channelRepository, groupRepository, accountRepository, apiKeyAuthCacheInvalidator, pricingService)
modelPricingResolver := service.NewModelPricingResolver(channelService, billingService)
notificationEmailService := service.NewNotificationEmailService(settingRepository, emailService)
balanceNotifyService := service.ProvideBalanceNotifyService(emailService, settingRepository, accountRepository, notificationEmailService)
diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go
index f689c2a908e..5a71b2b3584 100644
--- a/backend/internal/config/config.go
+++ b/backend/internal/config/config.go
@@ -681,6 +681,34 @@ const (
ImageConcurrencyOverflowModeWait = "wait"
)
+const (
+ OpenAIImagesResponsesReasoningEffortLow = "low"
+ OpenAIImagesResponsesReasoningEffortMedium = "medium"
+ OpenAIImagesResponsesReasoningEffortHigh = "high"
+ OpenAIImagesResponsesReasoningEffortXHigh = "xhigh"
+ OpenAIImagesResponsesReasoningEffortDefault = OpenAIImagesResponsesReasoningEffortMedium
+)
+
+func IsValidOpenAIImagesResponsesReasoningEffort(raw string) bool {
+ switch strings.ToLower(strings.TrimSpace(raw)) {
+ case OpenAIImagesResponsesReasoningEffortLow,
+ OpenAIImagesResponsesReasoningEffortMedium,
+ OpenAIImagesResponsesReasoningEffortHigh,
+ OpenAIImagesResponsesReasoningEffortXHigh:
+ return true
+ default:
+ return false
+ }
+}
+
+func NormalizeOpenAIImagesResponsesReasoningEffort(raw string) string {
+ normalized := strings.ToLower(strings.TrimSpace(raw))
+ if IsValidOpenAIImagesResponsesReasoningEffort(normalized) {
+ return normalized
+ }
+ return OpenAIImagesResponsesReasoningEffortDefault
+}
+
// GatewayConfig API网关相关配置
type GatewayConfig struct {
// 等待上游响应头的超时时间(秒),0表示无超时
@@ -705,6 +733,9 @@ type GatewayConfig struct {
// CodexImageGenerationBridgeEnabled: 是否为 Codex `/v1/responses` 自动注入 image_generation 工具和桥接指令。
// 默认关闭,避免纯文本 Codex 请求被意外改写;显式携带 image_generation 工具的请求仍按分组能力转发。
CodexImageGenerationBridgeEnabled bool `mapstructure:"codex_image_generation_bridge_enabled"`
+ // OpenAIImagesResponsesReasoningEffort: OAuth 图片桥接走 Responses API 时的 reasoning.effort。
+ // 允许 low/medium/high/xhigh,默认 medium。
+ OpenAIImagesResponsesReasoningEffort string `mapstructure:"openai_images_responses_reasoning_effort"`
// ForcedCodexInstructionsTemplateFile: 服务端强制附加到 Codex 顶层 instructions 的模板文件路径。
// 模板渲染后会直接覆盖最终 instructions;若需要保留客户端 system 转换结果,请在模板中显式引用 {{ .ExistingInstructions }}。
ForcedCodexInstructionsTemplateFile string `mapstructure:"forced_codex_instructions_template_file"`
@@ -1422,6 +1453,10 @@ func load(allowMissingJWTSecret bool) (*Config, error) {
cfg.Log.Environment = strings.TrimSpace(cfg.Log.Environment)
cfg.Log.StacktraceLevel = strings.ToLower(strings.TrimSpace(cfg.Log.StacktraceLevel))
cfg.Log.Output.FilePath = strings.TrimSpace(cfg.Log.Output.FilePath)
+ cfg.Gateway.OpenAIImagesResponsesReasoningEffort = strings.ToLower(strings.TrimSpace(cfg.Gateway.OpenAIImagesResponsesReasoningEffort))
+ if cfg.Gateway.OpenAIImagesResponsesReasoningEffort == "" {
+ cfg.Gateway.OpenAIImagesResponsesReasoningEffort = OpenAIImagesResponsesReasoningEffortDefault
+ }
cfg.Gateway.ForcedCodexInstructionsTemplateFile = strings.TrimSpace(cfg.Gateway.ForcedCodexInstructionsTemplateFile)
if cfg.Gateway.ForcedCodexInstructionsTemplateFile != "" {
content, err := os.ReadFile(cfg.Gateway.ForcedCodexInstructionsTemplateFile)
@@ -1779,6 +1814,7 @@ func setDefaults() {
viper.SetDefault("gateway.max_account_switches_gemini", 3)
viper.SetDefault("gateway.force_codex_cli", false)
viper.SetDefault("gateway.codex_image_generation_bridge_enabled", false)
+ viper.SetDefault("gateway.openai_images_responses_reasoning_effort", OpenAIImagesResponsesReasoningEffortDefault)
viper.SetDefault("gateway.openai_passthrough_allow_timeout_headers", false)
// OpenAI Responses WebSocket(默认开启;可通过 force_http 紧急回滚)
viper.SetDefault("gateway.openai_ws.enabled", true)
@@ -2428,6 +2464,9 @@ func (c *Config) Validate() error {
if c.Gateway.ImageConcurrency.MaxWaitingRequests < 0 {
return fmt.Errorf("gateway.image_concurrency.max_waiting_requests must be non-negative")
}
+ if !IsValidOpenAIImagesResponsesReasoningEffort(c.Gateway.OpenAIImagesResponsesReasoningEffort) {
+ return fmt.Errorf("gateway.openai_images_responses_reasoning_effort must be one of: low/medium/high/xhigh")
+ }
if c.Gateway.MaxIdleConns <= 0 {
return fmt.Errorf("gateway.max_idle_conns must be positive")
}
diff --git a/backend/internal/config/config_test.go b/backend/internal/config/config_test.go
index 1eae5ed9595..9948b0772ff 100644
--- a/backend/internal/config/config_test.go
+++ b/backend/internal/config/config_test.go
@@ -107,6 +107,9 @@ func TestLoadDefaultOpenAIWSConfig(t *testing.T) {
if cfg.Gateway.OpenAIWS.APIKeyMaxConnsFactor != 1.0 {
t.Fatalf("Gateway.OpenAIWS.APIKeyMaxConnsFactor = %v, want 1.0", cfg.Gateway.OpenAIWS.APIKeyMaxConnsFactor)
}
+ if cfg.Gateway.OpenAIImagesResponsesReasoningEffort != "medium" {
+ t.Fatalf("Gateway.OpenAIImagesResponsesReasoningEffort = %q, want medium", cfg.Gateway.OpenAIImagesResponsesReasoningEffort)
+ }
if cfg.Gateway.OpenAIWS.StickySessionTTLSeconds != 3600 {
t.Fatalf("Gateway.OpenAIWS.StickySessionTTLSeconds = %d, want 3600", cfg.Gateway.OpenAIWS.StickySessionTTLSeconds)
}
@@ -1385,6 +1388,11 @@ func TestValidateConfigErrors(t *testing.T) {
mutate: func(c *Config) { c.Gateway.ImageConcurrency.MaxWaitingRequests = -1 },
wantErr: "gateway.image_concurrency.max_waiting_requests must be non-negative",
},
+ {
+ name: "gateway openai images responses reasoning effort invalid",
+ mutate: func(c *Config) { c.Gateway.OpenAIImagesResponsesReasoningEffort = "advanced" },
+ wantErr: "gateway.openai_images_responses_reasoning_effort must be one of: low/medium/high/xhigh",
+ },
{
name: "gateway max line size",
mutate: func(c *Config) { c.Gateway.MaxLineSize = 1024 },
diff --git a/backend/internal/handler/admin/account_handler.go b/backend/internal/handler/admin/account_handler.go
index 4f566a8be9f..b64b4b23c62 100644
--- a/backend/internal/handler/admin/account_handler.go
+++ b/backend/internal/handler/admin/account_handler.go
@@ -703,6 +703,20 @@ type TestAccountRequest struct {
Mode string `json:"mode"`
}
+type ModelProbeListRequest struct {
+ Platform string `json:"platform" binding:"required"`
+ BaseURL string `json:"base_url"`
+ APIKey string `json:"api_key" binding:"required"`
+}
+
+type ModelProbeTestRequest struct {
+ Platform string `json:"platform" binding:"required"`
+ BaseURL string `json:"base_url"`
+ APIKey string `json:"api_key" binding:"required"`
+ Mode string `json:"mode"`
+ Models []string `json:"models" binding:"required"`
+}
+
type SyncFromCRSRequest struct {
BaseURL string `json:"base_url" binding:"required"`
Username string `json:"username" binding:"required"`
@@ -743,6 +757,62 @@ func (h *AccountHandler) Test(c *gin.Context) {
}
}
+// ProbeModelList discovers upstream models with a temporary URL and API key.
+// POST /api/v1/admin/accounts/model-probe/list
+func (h *AccountHandler) ProbeModelList(c *gin.Context) {
+ if h.accountTestService == nil {
+ response.Error(c, http.StatusServiceUnavailable, "Account test service unavailable")
+ return
+ }
+
+ var req ModelProbeListRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ response.BadRequest(c, "Invalid request: "+err.Error())
+ return
+ }
+
+ result, err := h.accountTestService.ProbeModelList(c.Request.Context(), service.ModelProbeListInput{
+ Platform: req.Platform,
+ BaseURL: req.BaseURL,
+ APIKey: req.APIKey,
+ })
+ if err != nil {
+ response.BadRequest(c, err.Error())
+ return
+ }
+
+ response.Success(c, result)
+}
+
+// ProbeModels sends minimal requests to selected upstream models.
+// POST /api/v1/admin/accounts/model-probe/test
+func (h *AccountHandler) ProbeModels(c *gin.Context) {
+ if h.accountTestService == nil {
+ response.Error(c, http.StatusServiceUnavailable, "Account test service unavailable")
+ return
+ }
+
+ var req ModelProbeTestRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ response.BadRequest(c, "Invalid request: "+err.Error())
+ return
+ }
+
+ result, err := h.accountTestService.ProbeModels(c.Request.Context(), service.ModelProbeTestInput{
+ Platform: req.Platform,
+ BaseURL: req.BaseURL,
+ APIKey: req.APIKey,
+ Mode: req.Mode,
+ Models: req.Models,
+ })
+ if err != nil {
+ response.BadRequest(c, err.Error())
+ return
+ }
+
+ response.Success(c, result)
+}
+
// RecoverState handles unified recovery of recoverable account runtime state.
// POST /api/v1/admin/accounts/:id/recover-state
func (h *AccountHandler) RecoverState(c *gin.Context) {
diff --git a/backend/internal/handler/admin/setting_handler.go b/backend/internal/handler/admin/setting_handler.go
index c229d3408c1..86a5554aed2 100644
--- a/backend/internal/handler/admin/setting_handler.go
+++ b/backend/internal/handler/admin/setting_handler.go
@@ -255,6 +255,7 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
EnableAnthropicCacheTTL1hInjection: settings.EnableAnthropicCacheTTL1hInjection,
RewriteMessageCacheControl: settings.RewriteMessageCacheControl,
AntigravityUserAgentVersion: settings.AntigravityUserAgentVersion,
+ OpenAIImagesResponsesReasoningEffort: settings.OpenAIImagesResponsesReasoningEffort,
OpenAICodexUserAgent: settings.OpenAICodexUserAgent,
OpenAIAllowClaudeCodeCodexPlugin: settings.OpenAIAllowClaudeCodeCodexPlugin,
WebSearchEmulationEnabled: settings.WebSearchEmulationEnabled,
@@ -578,14 +579,15 @@ type UpdateSettingsRequest struct {
BackendModeEnabled bool `json:"backend_mode_enabled"`
// Gateway forwarding behavior
- EnableFingerprintUnification *bool `json:"enable_fingerprint_unification"`
- EnableMetadataPassthrough *bool `json:"enable_metadata_passthrough"`
- EnableCCHSigning *bool `json:"enable_cch_signing"`
- EnableAnthropicCacheTTL1hInjection *bool `json:"enable_anthropic_cache_ttl_1h_injection"`
- RewriteMessageCacheControl *bool `json:"rewrite_message_cache_control"`
- AntigravityUserAgentVersion *string `json:"antigravity_user_agent_version"`
- OpenAICodexUserAgent *string `json:"openai_codex_user_agent"`
- OpenAIAllowClaudeCodeCodexPlugin *bool `json:"openai_allow_claude_code_codex_plugin"`
+ EnableFingerprintUnification *bool `json:"enable_fingerprint_unification"`
+ EnableMetadataPassthrough *bool `json:"enable_metadata_passthrough"`
+ EnableCCHSigning *bool `json:"enable_cch_signing"`
+ EnableAnthropicCacheTTL1hInjection *bool `json:"enable_anthropic_cache_ttl_1h_injection"`
+ RewriteMessageCacheControl *bool `json:"rewrite_message_cache_control"`
+ AntigravityUserAgentVersion *string `json:"antigravity_user_agent_version"`
+ OpenAIImagesResponsesReasoningEffort *string `json:"openai_images_responses_reasoning_effort"`
+ OpenAICodexUserAgent *string `json:"openai_codex_user_agent"`
+ OpenAIAllowClaudeCodeCodexPlugin *bool `json:"openai_allow_claude_code_codex_plugin"`
// Payment visible method routing
PaymentVisibleMethodAlipaySource *string `json:"payment_visible_method_alipay_source"`
@@ -1440,6 +1442,14 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
return
}
}
+ if req.OpenAIImagesResponsesReasoningEffort != nil {
+ normalized := strings.ToLower(strings.TrimSpace(*req.OpenAIImagesResponsesReasoningEffort))
+ req.OpenAIImagesResponsesReasoningEffort = &normalized
+ if normalized != "" && !service.IsValidOpenAIImagesResponsesReasoningEffort(normalized) {
+ response.Error(c, http.StatusBadRequest, "openai_images_responses_reasoning_effort must be one of: low, medium, high, xhigh")
+ return
+ }
+ }
if req.OpenAICodexUserAgent != nil {
normalized := strings.TrimSpace(*req.OpenAICodexUserAgent)
req.OpenAICodexUserAgent = &normalized
@@ -1651,6 +1661,12 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
}
return previousSettings.AntigravityUserAgentVersion
}(),
+ OpenAIImagesResponsesReasoningEffort: func() string {
+ if req.OpenAIImagesResponsesReasoningEffort != nil {
+ return *req.OpenAIImagesResponsesReasoningEffort
+ }
+ return previousSettings.OpenAIImagesResponsesReasoningEffort
+ }(),
OpenAICodexUserAgent: func() string {
if req.OpenAICodexUserAgent != nil {
return *req.OpenAICodexUserAgent
@@ -2038,6 +2054,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
EnableAnthropicCacheTTL1hInjection: updatedSettings.EnableAnthropicCacheTTL1hInjection,
RewriteMessageCacheControl: updatedSettings.RewriteMessageCacheControl,
AntigravityUserAgentVersion: updatedSettings.AntigravityUserAgentVersion,
+ OpenAIImagesResponsesReasoningEffort: updatedSettings.OpenAIImagesResponsesReasoningEffort,
OpenAICodexUserAgent: updatedSettings.OpenAICodexUserAgent,
OpenAIAllowClaudeCodeCodexPlugin: updatedSettings.OpenAIAllowClaudeCodeCodexPlugin,
PaymentVisibleMethodAlipaySource: updatedSettings.PaymentVisibleMethodAlipaySource,
@@ -2506,6 +2523,9 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings,
if before.AntigravityUserAgentVersion != after.AntigravityUserAgentVersion {
changed = append(changed, "antigravity_user_agent_version")
}
+ if before.OpenAIImagesResponsesReasoningEffort != after.OpenAIImagesResponsesReasoningEffort {
+ changed = append(changed, "openai_images_responses_reasoning_effort")
+ }
if before.OpenAICodexUserAgent != after.OpenAICodexUserAgent {
changed = append(changed, "openai_codex_user_agent")
}
diff --git a/backend/internal/handler/admin/setting_handler_auth_source_defaults_test.go b/backend/internal/handler/admin/setting_handler_auth_source_defaults_test.go
index f953f76760f..4f998609a0b 100644
--- a/backend/internal/handler/admin/setting_handler_auth_source_defaults_test.go
+++ b/backend/internal/handler/admin/setting_handler_auth_source_defaults_test.go
@@ -252,6 +252,68 @@ func TestSettingHandler_UpdateSettings_PersistsPaymentVisibleMethodsAndAdvancedS
require.Equal(t, true, data["openai_advanced_scheduler_enabled"])
}
+func TestSettingHandler_UpdateSettings_PersistsOpenAIImagesResponsesReasoningEffort(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ repo := &settingHandlerRepoStub{
+ values: map[string]string{
+ service.SettingKeyPromoCodeEnabled: "true",
+ },
+ }
+ svc := service.NewSettingService(repo, &config.Config{Default: config.DefaultConfig{UserConcurrency: 5}})
+ handler := NewSettingHandler(svc, nil, nil, nil, nil, nil, nil)
+
+ body := map[string]any{
+ "promo_code_enabled": true,
+ "openai_images_responses_reasoning_effort": "xhigh",
+ }
+ rawBody, err := json.Marshal(body)
+ require.NoError(t, err)
+
+ rec := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(rec)
+ c.Request = httptest.NewRequest(http.MethodPut, "/api/v1/admin/settings", bytes.NewReader(rawBody))
+ c.Request.Header.Set("Content-Type", "application/json")
+
+ handler.UpdateSettings(c)
+
+ require.Equal(t, http.StatusOK, rec.Code)
+ require.Equal(t, service.OpenAIImagesResponsesReasoningEffortXHigh, repo.values[service.SettingKeyOpenAIImagesResponsesReasoningEffort])
+
+ var resp response.Response
+ require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
+ data, ok := resp.Data.(map[string]any)
+ require.True(t, ok)
+ require.Equal(t, service.OpenAIImagesResponsesReasoningEffortXHigh, data["openai_images_responses_reasoning_effort"])
+}
+
+func TestSettingHandler_UpdateSettings_RejectsInvalidOpenAIImagesResponsesReasoningEffort(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ repo := &settingHandlerRepoStub{
+ values: map[string]string{
+ service.SettingKeyPromoCodeEnabled: "true",
+ },
+ }
+ svc := service.NewSettingService(repo, &config.Config{Default: config.DefaultConfig{UserConcurrency: 5}})
+ handler := NewSettingHandler(svc, nil, nil, nil, nil, nil, nil)
+
+ body := map[string]any{
+ "promo_code_enabled": true,
+ "openai_images_responses_reasoning_effort": "advanced",
+ }
+ rawBody, err := json.Marshal(body)
+ require.NoError(t, err)
+
+ rec := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(rec)
+ c.Request = httptest.NewRequest(http.MethodPut, "/api/v1/admin/settings", bytes.NewReader(rawBody))
+ c.Request.Header.Set("Content-Type", "application/json")
+
+ handler.UpdateSettings(c)
+
+ require.Equal(t, http.StatusBadRequest, rec.Code)
+ require.NotContains(t, repo.values, service.SettingKeyOpenAIImagesResponsesReasoningEffort)
+}
+
func TestSettingHandler_UpdateSettings_PreservesLegacyBlankPaymentVisibleMethodSource(t *testing.T) {
gin.SetMode(gin.TestMode)
repo := &settingHandlerRepoStub{
diff --git a/backend/internal/handler/available_channel_handler.go b/backend/internal/handler/available_channel_handler.go
index 8982b80defc..2afcc1fc108 100644
--- a/backend/internal/handler/available_channel_handler.go
+++ b/backend/internal/handler/available_channel_handler.go
@@ -2,6 +2,7 @@ package handler
import (
"sort"
+ "strings"
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
"github.com/Wei-Shaw/sub2api/internal/server/middleware"
@@ -143,6 +144,7 @@ func (h *AvailableChannelHandler) List(c *gin.Context) {
return
}
+ groupModelCache := make(map[int64][]service.SupportedModel)
out := make([]userAvailableChannel, 0, len(channels))
for _, ch := range channels {
if ch.Status != service.StatusActive {
@@ -153,6 +155,15 @@ func (h *AvailableChannelHandler) List(c *gin.Context) {
continue
}
sections := buildPlatformSections(ch, visibleGroups)
+ if !ch.RestrictModels {
+ visibleRefs := filterAvailableGroupRefs(ch.Groups, allowedGroupIDs)
+ groupModels, err := h.supportedModelsForGroups(c, visibleRefs, groupModelCache)
+ if err != nil {
+ response.ErrorFrom(c, err)
+ return
+ }
+ mergeGroupSupportedModels(sections, groupModels)
+ }
if len(sections) == 0 {
continue
}
@@ -166,6 +177,42 @@ func (h *AvailableChannelHandler) List(c *gin.Context) {
response.Success(c, out)
}
+func (h *AvailableChannelHandler) supportedModelsForGroups(
+ c *gin.Context,
+ groups []service.AvailableGroupRef,
+ cache map[int64][]service.SupportedModel,
+) (map[int64][]service.SupportedModel, error) {
+ missing := make([]service.AvailableGroupRef, 0, len(groups))
+ seen := make(map[int64]struct{}, len(groups))
+ for _, group := range groups {
+ if _, ok := cache[group.ID]; ok {
+ continue
+ }
+ if _, ok := seen[group.ID]; ok {
+ continue
+ }
+ seen[group.ID] = struct{}{}
+ missing = append(missing, group)
+ }
+ if len(missing) > 0 {
+ models, err := h.channelService.ListSupportedModelsForGroups(c.Request.Context(), missing)
+ if err != nil {
+ return nil, err
+ }
+ for _, group := range missing {
+ cache[group.ID] = models[group.ID]
+ }
+ }
+
+ out := make(map[int64][]service.SupportedModel, len(groups))
+ for _, group := range groups {
+ if models, ok := cache[group.ID]; ok && len(models) > 0 {
+ out[group.ID] = models
+ }
+ }
+ return out, nil
+}
+
// buildPlatformSections 把一个渠道按 visibleGroups 的平台集合拆成有序的 section 列表:
// 每个 section 对应一个平台,只包含该平台的 groups 和 supported_models。
// 输出按 platform 字母序稳定排序,便于前端等效比较与回归测试。
@@ -202,6 +249,69 @@ func buildPlatformSections(
return sections
}
+func mergeGroupSupportedModels(
+ sections []userChannelPlatformSection,
+ groupModels map[int64][]service.SupportedModel,
+) {
+ if len(sections) == 0 || len(groupModels) == 0 {
+ return
+ }
+ for i := range sections {
+ sections[i].SupportedModels = mergeSupportedModelsForSection(
+ sections[i].SupportedModels,
+ sections[i].Groups,
+ groupModels,
+ sections[i].Platform,
+ )
+ }
+}
+
+func mergeSupportedModelsForSection(
+ configured []userSupportedModel,
+ groups []userAvailableGroup,
+ groupModels map[int64][]service.SupportedModel,
+ platform string,
+) []userSupportedModel {
+ seen := make(map[string]struct{}, len(configured))
+ out := make([]userSupportedModel, 0, len(configured))
+ for _, model := range configured {
+ out = append(out, model)
+ seen[supportedModelKey(model.Platform, model.Name)] = struct{}{}
+ }
+
+ for _, group := range groups {
+ if group.Platform != platform {
+ continue
+ }
+ for _, model := range groupModels[group.ID] {
+ if model.Platform != platform {
+ continue
+ }
+ key := supportedModelKey(model.Platform, model.Name)
+ if _, ok := seen[key]; ok {
+ continue
+ }
+ seen[key] = struct{}{}
+ out = append(out, userSupportedModel{
+ Name: model.Name,
+ Platform: model.Platform,
+ Pricing: toUserPricing(model.Pricing),
+ })
+ }
+ }
+ sort.SliceStable(out, func(i, j int) bool {
+ if out[i].Platform != out[j].Platform {
+ return out[i].Platform < out[j].Platform
+ }
+ return out[i].Name < out[j].Name
+ })
+ return out
+}
+
+func supportedModelKey(platform, name string) string {
+ return strings.TrimSpace(platform) + "\x00" + strings.ToLower(strings.TrimSpace(name))
+}
+
// filterUserVisibleGroups 仅保留用户可访问的分组。
func filterUserVisibleGroups(
groups []service.AvailableGroupRef,
@@ -224,6 +334,20 @@ func filterUserVisibleGroups(
return visible
}
+func filterAvailableGroupRefs(
+ groups []service.AvailableGroupRef,
+ allowed map[int64]struct{},
+) []service.AvailableGroupRef {
+ visible := make([]service.AvailableGroupRef, 0, len(groups))
+ for _, group := range groups {
+ if _, ok := allowed[group.ID]; !ok {
+ continue
+ }
+ visible = append(visible, group)
+ }
+ return visible
+}
+
// toUserSupportedModels 将 service 层支持模型转换为用户 DTO(字段白名单)。
// 仅保留平台在 allowedPlatforms 中的条目,防止跨平台模型信息泄漏。
// allowedPlatforms 为 nil 时不做平台过滤(保留全部,供测试或明确无过滤场景使用)。
diff --git a/backend/internal/handler/available_channel_handler_test.go b/backend/internal/handler/available_channel_handler_test.go
index 0a7ce6c466a..5438c5bab43 100644
--- a/backend/internal/handler/available_channel_handler_test.go
+++ b/backend/internal/handler/available_channel_handler_test.go
@@ -3,6 +3,7 @@
package handler
import (
+ "context"
"encoding/json"
"net/http"
"net/http/httptest"
@@ -155,3 +156,134 @@ func TestBuildPlatformSections_GroupsByPlatform(t *testing.T) {
require.Len(t, sections[0].SupportedModels, 1)
require.Equal(t, "claude-sonnet-4-6", sections[0].SupportedModels[0].Name)
}
+
+func TestBuildPlatformSections_IncludesVisibleGroupAccountModels(t *testing.T) {
+ ch := service.AvailableChannel{
+ Name: "ch",
+ SupportedModels: []service.SupportedModel{
+ {Name: "gpt-configured", Platform: "openai"},
+ },
+ }
+ visible := []userAvailableGroup{
+ {ID: 1, Name: "public", Platform: "openai", IsExclusive: false},
+ }
+ groupModels := map[int64][]service.SupportedModel{
+ 1: {
+ {Name: "gpt-account", Platform: "openai"},
+ },
+ 2: {
+ {Name: "gpt-hidden-exclusive", Platform: "openai"},
+ },
+ }
+
+ sections := buildPlatformSections(ch, visible)
+ mergeGroupSupportedModels(sections, groupModels)
+
+ require.Len(t, sections, 1)
+ require.Equal(t, "openai", sections[0].Platform)
+ require.ElementsMatch(t, []string{"gpt-configured", "gpt-account"}, supportedModelNames(sections[0].SupportedModels))
+}
+
+func TestBuildPlatformSections_ChannelModelWinsWhenAccountModelDuplicates(t *testing.T) {
+ configuredPricing := &service.ChannelModelPricing{BillingMode: service.BillingModeToken}
+ ch := service.AvailableChannel{
+ Name: "ch",
+ SupportedModels: []service.SupportedModel{
+ {Name: "gpt-5", Platform: "openai", Pricing: configuredPricing},
+ },
+ }
+ visible := []userAvailableGroup{{ID: 1, Name: "public", Platform: "openai"}}
+ groupModels := map[int64][]service.SupportedModel{
+ 1: {
+ {Name: "GPT-5", Platform: "openai", Pricing: nil},
+ },
+ }
+
+ sections := buildPlatformSections(ch, visible)
+ mergeGroupSupportedModels(sections, groupModels)
+
+ require.Len(t, sections, 1)
+ require.Len(t, sections[0].SupportedModels, 1)
+ require.Equal(t, "gpt-5", sections[0].SupportedModels[0].Name)
+ require.NotNil(t, sections[0].SupportedModels[0].Pricing)
+}
+
+func TestBuildPlatformSections_RestrictModelsKeepsConfiguredModelsOnly(t *testing.T) {
+ ch := service.AvailableChannel{
+ Name: "ch",
+ RestrictModels: true,
+ SupportedModels: []service.SupportedModel{
+ {Name: "gpt-configured", Platform: "openai"},
+ },
+ }
+ visible := []userAvailableGroup{{ID: 1, Name: "public", Platform: "openai"}}
+ groupModels := map[int64][]service.SupportedModel{
+ 1: {
+ {Name: "gpt-account", Platform: "openai"},
+ },
+ }
+
+ sections := buildPlatformSections(ch, visible)
+ if !ch.RestrictModels {
+ mergeGroupSupportedModels(sections, groupModels)
+ }
+
+ require.Len(t, sections, 1)
+ require.Equal(t, []string{"gpt-configured"}, supportedModelNames(sections[0].SupportedModels))
+}
+
+func TestSupportedModelsForGroups_ReusesRequestCache(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ w := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(w)
+ c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/channels/available", nil)
+
+ repo := &countingAvailableAccountRepo{}
+ h := &AvailableChannelHandler{
+ channelService: service.NewChannelService(nil, nil, repo, nil, nil),
+ }
+ groups := []service.AvailableGroupRef{
+ {ID: 1, Platform: service.PlatformOpenAI},
+ {ID: 1, Platform: service.PlatformOpenAI},
+ }
+ cache := make(map[int64][]service.SupportedModel)
+
+ first, err := h.supportedModelsForGroups(c, groups, cache)
+ require.NoError(t, err)
+ second, err := h.supportedModelsForGroups(c, groups, cache)
+ require.NoError(t, err)
+
+ require.Equal(t, 1, repo.calls[1])
+ require.Equal(t, []service.SupportedModel{{Name: "gpt-cache", Platform: service.PlatformOpenAI}}, first[1])
+ require.Equal(t, first, second)
+}
+
+type countingAvailableAccountRepo struct {
+ calls map[int64]int
+}
+
+func (r *countingAvailableAccountRepo) ListSchedulableByGroupID(_ context.Context, groupID int64) ([]service.Account, error) {
+ if r.calls == nil {
+ r.calls = make(map[int64]int)
+ }
+ r.calls[groupID]++
+ return []service.Account{
+ {
+ ID: 10,
+ Platform: service.PlatformOpenAI,
+ Credentials: map[string]any{
+ "model_mapping": map[string]any{
+ "gpt-cache": "gpt-cache",
+ },
+ },
+ },
+ }, nil
+}
+
+func supportedModelNames(models []userSupportedModel) []string {
+ names := make([]string, 0, len(models))
+ for _, model := range models {
+ names = append(names, model.Name)
+ }
+ return names
+}
diff --git a/backend/internal/handler/dto/settings.go b/backend/internal/handler/dto/settings.go
index 17772a2eef7..95f6d3b2f20 100644
--- a/backend/internal/handler/dto/settings.go
+++ b/backend/internal/handler/dto/settings.go
@@ -178,14 +178,15 @@ type SystemSettings struct {
BackendModeEnabled bool `json:"backend_mode_enabled"`
// Gateway forwarding behavior
- EnableFingerprintUnification bool `json:"enable_fingerprint_unification"`
- EnableMetadataPassthrough bool `json:"enable_metadata_passthrough"`
- EnableCCHSigning bool `json:"enable_cch_signing"`
- EnableAnthropicCacheTTL1hInjection bool `json:"enable_anthropic_cache_ttl_1h_injection"`
- RewriteMessageCacheControl bool `json:"rewrite_message_cache_control"`
- AntigravityUserAgentVersion string `json:"antigravity_user_agent_version"`
- OpenAICodexUserAgent string `json:"openai_codex_user_agent"`
- OpenAIAllowClaudeCodeCodexPlugin bool `json:"openai_allow_claude_code_codex_plugin"`
+ EnableFingerprintUnification bool `json:"enable_fingerprint_unification"`
+ EnableMetadataPassthrough bool `json:"enable_metadata_passthrough"`
+ EnableCCHSigning bool `json:"enable_cch_signing"`
+ EnableAnthropicCacheTTL1hInjection bool `json:"enable_anthropic_cache_ttl_1h_injection"`
+ RewriteMessageCacheControl bool `json:"rewrite_message_cache_control"`
+ AntigravityUserAgentVersion string `json:"antigravity_user_agent_version"`
+ OpenAIImagesResponsesReasoningEffort string `json:"openai_images_responses_reasoning_effort"`
+ OpenAICodexUserAgent string `json:"openai_codex_user_agent"`
+ OpenAIAllowClaudeCodeCodexPlugin bool `json:"openai_allow_claude_code_codex_plugin"`
// Web Search Emulation
WebSearchEmulationEnabled bool `json:"web_search_emulation_enabled"`
diff --git a/backend/internal/handler/openai_gateway_handler_test.go b/backend/internal/handler/openai_gateway_handler_test.go
index b3fb35eee9a..c4058ec4bb8 100644
--- a/backend/internal/handler/openai_gateway_handler_test.go
+++ b/backend/internal/handler/openai_gateway_handler_test.go
@@ -1462,7 +1462,7 @@ func runOpenAIResponsesWebSocketUsageLogCase(t *testing.T, tc openAIResponsesWSU
ModelMapping: map[string]map[string]string{service.PlatformOpenAI: tc.channelMapping},
}},
groupPlatforms: map[int64]string{groupID: service.PlatformOpenAI},
- }, nil, nil, nil)
+ }, nil, nil, nil, nil)
}
billingCacheSvc := service.NewBillingCacheService(nil, nil, nil, nil, nil, nil, cfg, nil)
diff --git a/backend/internal/pkg/openai/constants.go b/backend/internal/pkg/openai/constants.go
index be9f3aae789..6ca35e2112f 100644
--- a/backend/internal/pkg/openai/constants.go
+++ b/backend/internal/pkg/openai/constants.go
@@ -18,6 +18,7 @@ var DefaultModels = []Model{
{ID: "gpt-5.5", Object: "model", Created: 1776873600, OwnedBy: "openai", Type: "model", DisplayName: "GPT-5.5"},
{ID: "gpt-5.4", Object: "model", Created: 1738368000, OwnedBy: "openai", Type: "model", DisplayName: "GPT-5.4"},
{ID: "gpt-5.4-mini", Object: "model", Created: 1738368000, OwnedBy: "openai", Type: "model", DisplayName: "GPT-5.4 Mini"},
+ {ID: "gpt-5.4-openai-compact", Object: "model", Created: 1738368000, OwnedBy: "openai", Type: "model", DisplayName: "GPT-5.4 OpenAI Compact"},
{ID: "gpt-5.3-codex", Object: "model", Created: 1735689600, OwnedBy: "openai", Type: "model", DisplayName: "GPT-5.3 Codex"},
{ID: "gpt-5.3-codex-spark", Object: "model", Created: 1735689600, OwnedBy: "openai", Type: "model", DisplayName: "GPT-5.3 Codex Spark"},
{ID: "gpt-5.2", Object: "model", Created: 1733875200, OwnedBy: "openai", Type: "model", DisplayName: "GPT-5.2"},
diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go
index 9eea092452f..e0329df9ed0 100644
--- a/backend/internal/server/api_contract_test.go
+++ b/backend/internal/server/api_contract_test.go
@@ -652,15 +652,16 @@ func TestAPIContracts(t *testing.T) {
service.SettingKeyTableDefaultPageSize: "20",
service.SettingKeyTablePageSizeOptions: "[10,20,50,100]",
- service.SettingKeyOpsMonitoringEnabled: "false",
- service.SettingKeyOpsRealtimeMonitoringEnabled: "true",
- service.SettingKeyOpsQueryModeDefault: "auto",
- service.SettingKeyOpsMetricsIntervalSeconds: "60",
- service.SettingPaymentVisibleMethodAlipaySource: service.VisibleMethodSourceEasyPayAlipay,
- service.SettingPaymentVisibleMethodWxpaySource: service.VisibleMethodSourceOfficialWechat,
- service.SettingPaymentVisibleMethodAlipayEnabled: "true",
- service.SettingPaymentVisibleMethodWxpayEnabled: "false",
- "openai_advanced_scheduler_enabled": "true",
+ service.SettingKeyOpsMonitoringEnabled: "false",
+ service.SettingKeyOpsRealtimeMonitoringEnabled: "true",
+ service.SettingKeyOpsQueryModeDefault: "auto",
+ service.SettingKeyOpsMetricsIntervalSeconds: "60",
+ service.SettingPaymentVisibleMethodAlipaySource: service.VisibleMethodSourceEasyPayAlipay,
+ service.SettingPaymentVisibleMethodWxpaySource: service.VisibleMethodSourceOfficialWechat,
+ service.SettingPaymentVisibleMethodAlipayEnabled: "true",
+ service.SettingPaymentVisibleMethodWxpayEnabled: "false",
+ service.SettingKeyOpenAIImagesResponsesReasoningEffort: service.OpenAIImagesResponsesReasoningEffortXHigh,
+ "openai_advanced_scheduler_enabled": "true",
})
},
method: http.MethodGet,
@@ -834,6 +835,7 @@ func TestAPIContracts(t *testing.T) {
"enable_anthropic_cache_ttl_1h_injection": false,
"rewrite_message_cache_control": false,
"antigravity_user_agent_version": "",
+ "openai_images_responses_reasoning_effort": "xhigh",
"enable_fingerprint_unification": true,
"enable_metadata_passthrough": false,
"web_search_emulation_enabled": false,
@@ -1073,6 +1075,7 @@ func TestAPIContracts(t *testing.T) {
"enable_anthropic_cache_ttl_1h_injection": false,
"rewrite_message_cache_control": false,
"antigravity_user_agent_version": "",
+ "openai_images_responses_reasoning_effort": "medium",
"web_search_emulation_enabled": false,
"payment_visible_method_alipay_source": "",
"payment_visible_method_wxpay_source": "",
diff --git a/backend/internal/server/routes/admin.go b/backend/internal/server/routes/admin.go
index 2301adda6bf..cf1e3d24799 100644
--- a/backend/internal/server/routes/admin.go
+++ b/backend/internal/server/routes/admin.go
@@ -284,6 +284,8 @@ func registerAccountRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
accounts.POST("/import/codex-session", h.Admin.Account.ImportCodexSession)
accounts.POST("/sync/crs", h.Admin.Account.SyncFromCRS)
accounts.POST("/sync/crs/preview", h.Admin.Account.PreviewFromCRS)
+ accounts.POST("/model-probe/list", h.Admin.Account.ProbeModelList)
+ accounts.POST("/model-probe/test", h.Admin.Account.ProbeModels)
accounts.PUT("/:id", h.Admin.Account.Update)
accounts.DELETE("/:id", h.Admin.Account.Delete)
accounts.POST("/:id/test", h.Admin.Account.Test)
diff --git a/backend/internal/service/account_model_probe.go b/backend/internal/service/account_model_probe.go
new file mode 100644
index 00000000000..93ca17d7ac6
--- /dev/null
+++ b/backend/internal/service/account_model_probe.go
@@ -0,0 +1,568 @@
+package service
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "strings"
+ "time"
+
+ "github.com/Wei-Shaw/sub2api/internal/pkg/claude"
+ "github.com/Wei-Shaw/sub2api/internal/pkg/geminicli"
+ "github.com/Wei-Shaw/sub2api/internal/util/logredact"
+)
+
+const (
+ ModelProbeModeOpenAIResponses = "responses"
+ ModelProbeModeOpenAIChatCompletions = "chat_completions"
+ ModelProbeModeGeminiGenerateContent = "gemini_generate_content"
+ ModelProbeModeAnthropicMessages = "anthropic_messages"
+
+ modelProbeTimeout = 30 * time.Second
+ modelProbeMaxModels = 20
+ modelProbeErrorBodySize = 2048
+)
+
+type ModelProbeListInput struct {
+ Platform string
+ BaseURL string
+ APIKey string
+}
+
+type ModelProbeTestInput struct {
+ Platform string
+ BaseURL string
+ APIKey string
+ Mode string
+ Models []string
+}
+
+type ModelProbeModel struct {
+ ID string `json:"id"`
+ Object string `json:"object,omitempty"`
+ DisplayName string `json:"display_name,omitempty"`
+ OwnedBy string `json:"owned_by,omitempty"`
+}
+
+type ModelProbeListResult struct {
+ Models []ModelProbeModel `json:"models"`
+}
+
+type ModelProbeTestResult struct {
+ Results []ModelProbeSingleResult `json:"results"`
+}
+
+type ModelProbeSingleResult struct {
+ Model string `json:"model"`
+ Mode string `json:"mode"`
+ OK bool `json:"ok"`
+ Status int `json:"status,omitempty"`
+ Error string `json:"error,omitempty"`
+}
+
+func (s *AccountTestService) ProbeModelList(ctx context.Context, input ModelProbeListInput) (ModelProbeListResult, error) {
+ platform := normalizeModelProbePlatform(input.Platform)
+ if platform == "" {
+ return ModelProbeListResult{}, errors.New("unsupported platform")
+ }
+ apiKey := strings.TrimSpace(input.APIKey)
+ if apiKey == "" {
+ return ModelProbeListResult{}, errors.New("api key is required")
+ }
+
+ switch platform {
+ case PlatformOpenAI:
+ return s.probeOpenAIModelList(ctx, input.BaseURL, apiKey)
+ case PlatformGemini:
+ return s.probeGeminiModelList(ctx, input.BaseURL, apiKey)
+ case PlatformAnthropic:
+ return ModelProbeListResult{Models: defaultClaudeProbeModels()}, nil
+ default:
+ return ModelProbeListResult{}, errors.New("unsupported platform")
+ }
+}
+
+func (s *AccountTestService) ProbeModels(ctx context.Context, input ModelProbeTestInput) (ModelProbeTestResult, error) {
+ platform := normalizeModelProbePlatform(input.Platform)
+ if platform == "" {
+ return ModelProbeTestResult{}, errors.New("unsupported platform")
+ }
+ apiKey := strings.TrimSpace(input.APIKey)
+ if apiKey == "" {
+ return ModelProbeTestResult{}, errors.New("api key is required")
+ }
+ models := normalizeProbeModels(input.Models)
+ if len(models) == 0 {
+ return ModelProbeTestResult{}, errors.New("at least one model is required")
+ }
+ if len(models) > modelProbeMaxModels {
+ return ModelProbeTestResult{}, fmt.Errorf("too many models: max %d", modelProbeMaxModels)
+ }
+
+ mode := normalizeModelProbeMode(platform, input.Mode)
+ if mode == "" {
+ return ModelProbeTestResult{}, errors.New("unsupported probe mode")
+ }
+
+ result := ModelProbeTestResult{Results: make([]ModelProbeSingleResult, 0, len(models))}
+ for _, model := range models {
+ result.Results = append(result.Results, s.probeSingleModel(ctx, platform, input.BaseURL, apiKey, mode, model))
+ }
+ return result, nil
+}
+
+func (s *AccountTestService) probeOpenAIModelList(ctx context.Context, baseURL, apiKey string) (ModelProbeListResult, error) {
+ normalizedBaseURL, err := s.normalizeProbeBaseURL(baseURL, "https://api.openai.com")
+ if err != nil {
+ return ModelProbeListResult{}, err
+ }
+
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, buildOpenAIProbeModelsURL(normalizedBaseURL), nil)
+ if err != nil {
+ return ModelProbeListResult{}, err
+ }
+ req.Header.Set("Accept", "application/json")
+ req.Header.Set("Authorization", "Bearer "+apiKey)
+
+ resp, err := s.doModelProbeRequest(req)
+ if err != nil {
+ return ModelProbeListResult{}, err
+ }
+ defer drainAndClose(resp.Body)
+
+ body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
+ if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+ return ModelProbeListResult{}, fmt.Errorf("upstream returned %d: %s", resp.StatusCode, sanitizeProbeError(body, apiKey))
+ }
+
+ var parsed struct {
+ Data []struct {
+ ID string `json:"id"`
+ Object string `json:"object"`
+ OwnedBy string `json:"owned_by"`
+ DisplayName string `json:"display_name"`
+ } `json:"data"`
+ }
+ if err := json.Unmarshal(body, &parsed); err != nil {
+ return ModelProbeListResult{}, fmt.Errorf("parse models response: %w", err)
+ }
+
+ models := make([]ModelProbeModel, 0, len(parsed.Data))
+ seen := map[string]struct{}{}
+ for _, item := range parsed.Data {
+ id := strings.TrimSpace(item.ID)
+ if id == "" {
+ continue
+ }
+ if _, ok := seen[id]; ok {
+ continue
+ }
+ seen[id] = struct{}{}
+ models = append(models, ModelProbeModel{
+ ID: id,
+ Object: item.Object,
+ DisplayName: firstNonEmptyProbeString(item.DisplayName, id),
+ OwnedBy: item.OwnedBy,
+ })
+ }
+
+ return ModelProbeListResult{Models: models}, nil
+}
+
+func (s *AccountTestService) probeGeminiModelList(ctx context.Context, baseURL, apiKey string) (ModelProbeListResult, error) {
+ normalizedBaseURL, err := s.normalizeProbeBaseURL(baseURL, geminicli.AIStudioBaseURL)
+ if err != nil {
+ return ModelProbeListResult{}, err
+ }
+
+ listURL := strings.TrimRight(normalizedBaseURL, "/") + "/v1beta/models"
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, listURL, nil)
+ if err != nil {
+ return ModelProbeListResult{}, err
+ }
+ req.Header.Set("Accept", "application/json")
+ req.Header.Set("x-goog-api-key", apiKey)
+
+ resp, err := s.doModelProbeRequest(req)
+ if err != nil {
+ return ModelProbeListResult{}, err
+ }
+ defer drainAndClose(resp.Body)
+
+ body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
+ if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+ return ModelProbeListResult{}, fmt.Errorf("upstream returned %d: %s", resp.StatusCode, sanitizeProbeError(body, apiKey))
+ }
+
+ var parsed struct {
+ Models []struct {
+ Name string `json:"name"`
+ DisplayName string `json:"displayName"`
+ } `json:"models"`
+ }
+ if err := json.Unmarshal(body, &parsed); err != nil {
+ return ModelProbeListResult{}, fmt.Errorf("parse models response: %w", err)
+ }
+
+ models := make([]ModelProbeModel, 0, len(parsed.Models))
+ seen := map[string]struct{}{}
+ for _, item := range parsed.Models {
+ id := strings.TrimPrefix(strings.TrimSpace(item.Name), "models/")
+ if id == "" {
+ continue
+ }
+ if _, ok := seen[id]; ok {
+ continue
+ }
+ seen[id] = struct{}{}
+ models = append(models, ModelProbeModel{
+ ID: id,
+ Object: "model",
+ DisplayName: firstNonEmptyProbeString(item.DisplayName, id),
+ OwnedBy: "google",
+ })
+ }
+
+ return ModelProbeListResult{Models: models}, nil
+}
+
+func (s *AccountTestService) probeSingleModel(ctx context.Context, platform, baseURL, apiKey, mode, model string) ModelProbeSingleResult {
+ result := ModelProbeSingleResult{Model: model, Mode: mode}
+ req, err := s.buildModelProbeRequest(ctx, platform, baseURL, apiKey, mode, model)
+ if err != nil {
+ result.Error = sanitizeProbeError([]byte(err.Error()), apiKey)
+ return result
+ }
+
+ result = s.doSingleModelProbeRequest(req, apiKey, result)
+ if shouldRetryOpenAIChatCompletionWithMaxTokens(platform, mode, result) {
+ retryReq, retryErr := s.buildOpenAIChatCompletionsProbeRequest(ctx, baseURL, apiKey, openAIChatCompletionsLegacyMinimalProbePayload(model))
+ if retryErr != nil {
+ result.Error = sanitizeProbeError([]byte(retryErr.Error()), apiKey)
+ return result
+ }
+ retryResult := ModelProbeSingleResult{Model: model, Mode: mode}
+ return s.doSingleModelProbeRequest(retryReq, apiKey, retryResult)
+ }
+ return result
+}
+
+func (s *AccountTestService) doSingleModelProbeRequest(req *http.Request, apiKey string, result ModelProbeSingleResult) ModelProbeSingleResult {
+ resp, err := s.doModelProbeRequest(req)
+ if err != nil {
+ result.Error = sanitizeProbeError([]byte(err.Error()), apiKey)
+ return result
+ }
+ defer drainAndClose(resp.Body)
+
+ body, _ := io.ReadAll(io.LimitReader(resp.Body, modelProbeErrorBodySize))
+ result.Status = resp.StatusCode
+ result.OK = resp.StatusCode >= 200 && resp.StatusCode < 300
+ if !result.OK {
+ result.Error = fmt.Sprintf("upstream returned %d: %s", resp.StatusCode, sanitizeProbeError(body, apiKey))
+ }
+ return result
+}
+
+func (s *AccountTestService) buildModelProbeRequest(ctx context.Context, platform, baseURL, apiKey, mode, model string) (*http.Request, error) {
+ switch {
+ case platform == PlatformOpenAI && mode == ModelProbeModeOpenAIResponses:
+ normalizedBaseURL, err := s.normalizeProbeBaseURL(baseURL, "https://api.openai.com")
+ if err != nil {
+ return nil, err
+ }
+ return newJSONProbeRequest(ctx, http.MethodPost, buildOpenAIResponsesURL(normalizedBaseURL), apiKey, openAIResponsesMinimalProbePayload(model))
+ case platform == PlatformOpenAI && mode == ModelProbeModeOpenAIChatCompletions:
+ return s.buildOpenAIChatCompletionsProbeRequest(ctx, baseURL, apiKey, openAIChatCompletionsMinimalProbePayload(model))
+ case platform == PlatformGemini && mode == ModelProbeModeGeminiGenerateContent:
+ normalizedBaseURL, err := s.normalizeProbeBaseURL(baseURL, geminicli.AIStudioBaseURL)
+ if err != nil {
+ return nil, err
+ }
+ return newGeminiProbeRequest(ctx, normalizedBaseURL, apiKey, model)
+ case platform == PlatformAnthropic && mode == ModelProbeModeAnthropicMessages:
+ normalizedBaseURL, err := s.normalizeProbeBaseURL(baseURL, "https://api.anthropic.com")
+ if err != nil {
+ return nil, err
+ }
+ return newAnthropicProbeRequest(ctx, normalizedBaseURL, apiKey, model)
+ default:
+ return nil, errors.New("unsupported probe mode")
+ }
+}
+
+func (s *AccountTestService) buildOpenAIChatCompletionsProbeRequest(ctx context.Context, baseURL, apiKey string, payload []byte) (*http.Request, error) {
+ normalizedBaseURL, err := s.normalizeProbeBaseURL(baseURL, "https://api.openai.com")
+ if err != nil {
+ return nil, err
+ }
+ return newJSONProbeRequest(ctx, http.MethodPost, buildOpenAIChatCompletionsURL(normalizedBaseURL), apiKey, payload)
+}
+
+func (s *AccountTestService) normalizeProbeBaseURL(baseURL, fallback string) (string, error) {
+ baseURL = strings.TrimSpace(baseURL)
+ if baseURL == "" {
+ baseURL = fallback
+ }
+ if s.cfg == nil {
+ return "", errors.New("config is not available")
+ }
+ return s.validateUpstreamBaseURL(baseURL)
+}
+
+func (s *AccountTestService) doModelProbeRequest(req *http.Request) (*http.Response, error) {
+ if s.httpUpstream == nil {
+ return nil, errors.New("http upstream is not configured")
+ }
+ ctx, cancel := context.WithTimeout(req.Context(), modelProbeTimeout)
+ req = req.WithContext(ctx)
+ resp, err := s.httpUpstream.DoWithTLS(req, "", 0, 0, nil)
+ if err != nil {
+ cancel()
+ return nil, err
+ }
+ resp.Body = &cancelOnCloseReadCloser{ReadCloser: resp.Body, cancel: cancel}
+ return resp, nil
+}
+
+func newJSONProbeRequest(ctx context.Context, method, targetURL, apiKey string, payload []byte) (*http.Request, error) {
+ req, err := http.NewRequestWithContext(ctx, method, targetURL, bytes.NewReader(payload))
+ if err != nil {
+ return nil, err
+ }
+ req.Header.Set("Accept", "application/json")
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("Authorization", "Bearer "+apiKey)
+ return req, nil
+}
+
+func newGeminiProbeRequest(ctx context.Context, baseURL, apiKey, model string) (*http.Request, error) {
+ targetURL := fmt.Sprintf("%s/v1beta/models/%s:generateContent", strings.TrimRight(baseURL, "/"), url.PathEscape(model))
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, targetURL, bytes.NewReader(geminiMinimalProbePayload()))
+ if err != nil {
+ return nil, err
+ }
+ req.Header.Set("Accept", "application/json")
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("x-goog-api-key", apiKey)
+ return req, nil
+}
+
+func newAnthropicProbeRequest(ctx context.Context, baseURL, apiKey, model string) (*http.Request, error) {
+ targetURL := strings.TrimRight(baseURL, "/") + "/v1/messages"
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, targetURL, bytes.NewReader(anthropicMinimalProbePayload(model)))
+ if err != nil {
+ return nil, err
+ }
+ req.Header.Set("Accept", "application/json")
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("x-api-key", apiKey)
+ req.Header.Set("anthropic-version", "2023-06-01")
+ return req, nil
+}
+
+func openAIResponsesMinimalProbePayload(model string) []byte {
+ body, _ := json.Marshal(map[string]any{
+ "model": model,
+ "input": "ping",
+ "max_output_tokens": 1,
+ })
+ return body
+}
+
+func openAIChatCompletionsMinimalProbePayload(model string) []byte {
+ body, _ := json.Marshal(map[string]any{
+ "model": model,
+ "messages": []map[string]any{
+ {"role": "user", "content": "ping"},
+ },
+ "max_completion_tokens": 1,
+ })
+ return body
+}
+
+func openAIChatCompletionsLegacyMinimalProbePayload(model string) []byte {
+ body, _ := json.Marshal(map[string]any{
+ "model": model,
+ "messages": []map[string]any{
+ {"role": "user", "content": "ping"},
+ },
+ "max_tokens": 1,
+ })
+ return body
+}
+
+func geminiMinimalProbePayload() []byte {
+ body, _ := json.Marshal(map[string]any{
+ "contents": []map[string]any{
+ {
+ "role": "user",
+ "parts": []map[string]any{
+ {"text": "ping"},
+ },
+ },
+ },
+ "generationConfig": map[string]any{
+ "maxOutputTokens": 1,
+ },
+ })
+ return body
+}
+
+func anthropicMinimalProbePayload(model string) []byte {
+ body, _ := json.Marshal(map[string]any{
+ "model": model,
+ "max_tokens": 1,
+ "messages": []map[string]any{
+ {"role": "user", "content": "ping"},
+ },
+ })
+ return body
+}
+
+func buildOpenAIProbeModelsURL(base string) string {
+ normalized := strings.TrimRight(strings.TrimSpace(base), "/")
+ if strings.HasSuffix(normalized, "/models") {
+ return normalized
+ }
+ if strings.HasSuffix(normalized, "/v1") {
+ return normalized + "/models"
+ }
+ return normalized + "/v1/models"
+}
+
+func normalizeModelProbePlatform(platform string) string {
+ switch strings.ToLower(strings.TrimSpace(platform)) {
+ case PlatformOpenAI:
+ return PlatformOpenAI
+ case PlatformGemini:
+ return PlatformGemini
+ case PlatformAnthropic, "claude":
+ return PlatformAnthropic
+ default:
+ return ""
+ }
+}
+
+func normalizeModelProbeMode(platform, mode string) string {
+ mode = strings.ToLower(strings.TrimSpace(mode))
+ switch platform {
+ case PlatformOpenAI:
+ switch mode {
+ case "", ModelProbeModeOpenAIResponses:
+ return ModelProbeModeOpenAIResponses
+ case ModelProbeModeOpenAIChatCompletions:
+ return ModelProbeModeOpenAIChatCompletions
+ }
+ case PlatformGemini:
+ if mode == "" || mode == ModelProbeModeGeminiGenerateContent {
+ return ModelProbeModeGeminiGenerateContent
+ }
+ case PlatformAnthropic:
+ if mode == "" || mode == ModelProbeModeAnthropicMessages {
+ return ModelProbeModeAnthropicMessages
+ }
+ }
+ return ""
+}
+
+func normalizeProbeModels(models []string) []string {
+ normalized := make([]string, 0, len(models))
+ seen := map[string]struct{}{}
+ for _, model := range models {
+ model = strings.TrimSpace(model)
+ if model == "" {
+ continue
+ }
+ if _, ok := seen[model]; ok {
+ continue
+ }
+ seen[model] = struct{}{}
+ normalized = append(normalized, model)
+ }
+ return normalized
+}
+
+func sanitizeProbeError(body []byte, apiKey string) string {
+ message := strings.TrimSpace(string(body))
+ if message == "" {
+ message = "empty response"
+ }
+ message = redactProbeAPIKeyFragments(message, apiKey)
+ return logredact.RedactText(message, "api_key", "key", "token", "authorization", "x-api-key", "x-goog-api-key")
+}
+
+func redactProbeAPIKeyFragments(message, apiKey string) string {
+ apiKey = strings.TrimSpace(apiKey)
+ if apiKey == "" {
+ return message
+ }
+ message = strings.ReplaceAll(message, apiKey, "***")
+
+ maxFragmentLen := len(apiKey) - 1
+ if maxFragmentLen > 32 {
+ maxFragmentLen = 32
+ }
+ for fragmentLen := maxFragmentLen; fragmentLen >= 8; fragmentLen-- {
+ prefix := apiKey[:fragmentLen]
+ suffix := apiKey[len(apiKey)-fragmentLen:]
+ message = strings.ReplaceAll(message, prefix, "***")
+ if suffix != prefix {
+ message = strings.ReplaceAll(message, suffix, "***")
+ }
+ }
+ return message
+}
+
+func shouldRetryOpenAIChatCompletionWithMaxTokens(platform, mode string, result ModelProbeSingleResult) bool {
+ if platform != PlatformOpenAI || mode != ModelProbeModeOpenAIChatCompletions || result.OK {
+ return false
+ }
+ return strings.Contains(strings.ToLower(result.Error), "max_completion_tokens")
+}
+
+func defaultClaudeProbeModels() []ModelProbeModel {
+ models := make([]ModelProbeModel, 0, len(claude.DefaultModels))
+ for _, model := range claude.DefaultModels {
+ models = append(models, ModelProbeModel{
+ ID: model.ID,
+ Object: model.Type,
+ DisplayName: model.DisplayName,
+ OwnedBy: "anthropic",
+ })
+ }
+ return models
+}
+
+func firstNonEmptyProbeString(values ...string) string {
+ for _, value := range values {
+ if strings.TrimSpace(value) != "" {
+ return value
+ }
+ }
+ return ""
+}
+
+func drainAndClose(body io.ReadCloser) {
+ if body == nil {
+ return
+ }
+ _, _ = io.Copy(io.Discard, io.LimitReader(body, 1<<20))
+ _ = body.Close()
+}
+
+type cancelOnCloseReadCloser struct {
+ io.ReadCloser
+ cancel context.CancelFunc
+}
+
+func (r *cancelOnCloseReadCloser) Close() error {
+ err := r.ReadCloser.Close()
+ r.cancel()
+ return err
+}
diff --git a/backend/internal/service/account_model_probe_test.go b/backend/internal/service/account_model_probe_test.go
new file mode 100644
index 00000000000..42f3b36a98d
--- /dev/null
+++ b/backend/internal/service/account_model_probe_test.go
@@ -0,0 +1,188 @@
+//go:build unit
+
+package service
+
+import (
+ "context"
+ "io"
+ "net/http"
+ "strings"
+ "testing"
+
+ "github.com/Wei-Shaw/sub2api/internal/config"
+ "github.com/stretchr/testify/require"
+)
+
+func modelProbeTestConfig() *config.Config {
+ return &config.Config{
+ Security: config.SecurityConfig{
+ URLAllowlist: config.URLAllowlistConfig{
+ AllowInsecureHTTP: true,
+ },
+ },
+ }
+}
+
+func TestAccountTestService_ModelProbeOpenAIListModels(t *testing.T) {
+ upstream := &queuedHTTPUpstream{responses: []*http.Response{
+ newJSONResponse(http.StatusOK, `{"object":"list","data":[{"id":"gpt-5.4","object":"model","owned_by":"openai"},{"id":"gpt-5.4-openai-compact","object":"model","owned_by":"openai"}]}`),
+ }}
+ svc := &AccountTestService{httpUpstream: upstream, cfg: modelProbeTestConfig()}
+
+ result, err := svc.ProbeModelList(context.Background(), ModelProbeListInput{
+ Platform: PlatformOpenAI,
+ BaseURL: "https://one.example.com",
+ APIKey: "sk-test-secret",
+ })
+
+ require.NoError(t, err)
+ require.Len(t, result.Models, 2)
+ require.Equal(t, "gpt-5.4", result.Models[0].ID)
+ require.Equal(t, "gpt-5.4-openai-compact", result.Models[1].ID)
+ require.Len(t, upstream.requests, 1)
+ req := upstream.requests[0]
+ require.Equal(t, http.MethodGet, req.Method)
+ require.Equal(t, "https://one.example.com/v1/models", req.URL.String())
+ require.Equal(t, "Bearer sk-test-secret", req.Header.Get("Authorization"))
+}
+
+func TestAccountTestService_ModelProbeOpenAIResponsesMinimalRequest(t *testing.T) {
+ upstream := &queuedHTTPUpstream{responses: []*http.Response{
+ newJSONResponse(http.StatusOK, `{"id":"resp_123","output":[]}`),
+ }}
+ svc := &AccountTestService{httpUpstream: upstream, cfg: modelProbeTestConfig()}
+
+ result, err := svc.ProbeModels(context.Background(), ModelProbeTestInput{
+ Platform: PlatformOpenAI,
+ BaseURL: "https://one.example.com",
+ APIKey: "sk-test-secret",
+ Mode: ModelProbeModeOpenAIResponses,
+ Models: []string{"gpt-5.4"},
+ })
+
+ require.NoError(t, err)
+ require.Len(t, result.Results, 1)
+ require.True(t, result.Results[0].OK)
+ require.Equal(t, http.StatusOK, result.Results[0].Status)
+ require.Len(t, upstream.requests, 1)
+ req := upstream.requests[0]
+ require.Equal(t, http.MethodPost, req.Method)
+ require.Equal(t, "https://one.example.com/v1/responses", req.URL.String())
+
+ body, err := io.ReadAll(req.Body)
+ require.NoError(t, err)
+ require.JSONEq(t, `{"model":"gpt-5.4","input":"ping","max_output_tokens":1}`, string(body))
+}
+
+func TestAccountTestService_ModelProbeAnthropicListUsesBuiltInCandidates(t *testing.T) {
+ upstream := &queuedHTTPUpstream{}
+ svc := &AccountTestService{httpUpstream: upstream, cfg: modelProbeTestConfig()}
+
+ result, err := svc.ProbeModelList(context.Background(), ModelProbeListInput{
+ Platform: PlatformAnthropic,
+ BaseURL: "https://invalid.example.com",
+ APIKey: "sk-ant-test",
+ })
+
+ require.NoError(t, err)
+ require.NotEmpty(t, result.Models)
+ require.Equal(t, "anthropic", result.Models[0].OwnedBy)
+ require.Empty(t, upstream.requests)
+}
+
+func TestAccountTestService_ModelProbeOpenAIChatCompletionsMinimalRequest(t *testing.T) {
+ upstream := &queuedHTTPUpstream{responses: []*http.Response{
+ newJSONResponse(http.StatusOK, `{"id":"chatcmpl_123","choices":[]}`),
+ }}
+ svc := &AccountTestService{httpUpstream: upstream, cfg: modelProbeTestConfig()}
+
+ result, err := svc.ProbeModels(context.Background(), ModelProbeTestInput{
+ Platform: PlatformOpenAI,
+ BaseURL: "https://one.example.com/v1",
+ APIKey: "sk-test-secret",
+ Mode: ModelProbeModeOpenAIChatCompletions,
+ Models: []string{"gpt-5.4"},
+ })
+
+ require.NoError(t, err)
+ require.True(t, result.Results[0].OK)
+ require.Len(t, upstream.requests, 1)
+ req := upstream.requests[0]
+ require.Equal(t, "https://one.example.com/v1/chat/completions", req.URL.String())
+
+ body, err := io.ReadAll(req.Body)
+ require.NoError(t, err)
+ require.JSONEq(t, `{"model":"gpt-5.4","messages":[{"role":"user","content":"ping"}],"max_completion_tokens":1}`, string(body))
+}
+
+func TestAccountTestService_ModelProbeOpenAIChatCompletionsFallsBackToMaxTokens(t *testing.T) {
+ upstream := &queuedHTTPUpstream{responses: []*http.Response{
+ newJSONResponse(http.StatusBadRequest, `{"error":{"message":"Unknown parameter: max_completion_tokens"}}`),
+ newJSONResponse(http.StatusOK, `{"id":"chatcmpl_123","choices":[]}`),
+ }}
+ svc := &AccountTestService{httpUpstream: upstream, cfg: modelProbeTestConfig()}
+
+ result, err := svc.ProbeModels(context.Background(), ModelProbeTestInput{
+ Platform: PlatformOpenAI,
+ BaseURL: "https://one.example.com/v1",
+ APIKey: "sk-test-secret",
+ Mode: ModelProbeModeOpenAIChatCompletions,
+ Models: []string{"gpt-5.4"},
+ })
+
+ require.NoError(t, err)
+ require.Len(t, result.Results, 1)
+ require.True(t, result.Results[0].OK)
+ require.Len(t, upstream.requests, 2)
+
+ firstBody, err := io.ReadAll(upstream.requests[0].Body)
+ require.NoError(t, err)
+ require.JSONEq(t, `{"model":"gpt-5.4","messages":[{"role":"user","content":"ping"}],"max_completion_tokens":1}`, string(firstBody))
+
+ secondBody, err := io.ReadAll(upstream.requests[1].Body)
+ require.NoError(t, err)
+ require.JSONEq(t, `{"model":"gpt-5.4","messages":[{"role":"user","content":"ping"}],"max_tokens":1}`, string(secondBody))
+}
+
+func TestAccountTestService_ModelProbeErrorDoesNotExposeAPIKey(t *testing.T) {
+ upstream := &queuedHTTPUpstream{responses: []*http.Response{
+ newJSONResponse(http.StatusUnauthorized, `{"error":{"message":"invalid key sk-test-secret"}}`),
+ }}
+ svc := &AccountTestService{httpUpstream: upstream, cfg: modelProbeTestConfig()}
+
+ result, err := svc.ProbeModels(context.Background(), ModelProbeTestInput{
+ Platform: PlatformOpenAI,
+ BaseURL: "https://one.example.com",
+ APIKey: "sk-test-secret",
+ Mode: ModelProbeModeOpenAIResponses,
+ Models: []string{"gpt-5.4"},
+ })
+
+ require.NoError(t, err)
+ require.Len(t, result.Results, 1)
+ require.False(t, result.Results[0].OK)
+ require.Equal(t, http.StatusUnauthorized, result.Results[0].Status)
+ require.NotContains(t, result.Results[0].Error, "sk-test-secret")
+ require.Contains(t, strings.ToLower(result.Results[0].Error), "invalid key")
+}
+
+func TestAccountTestService_ModelProbeErrorDoesNotExposePartialAPIKey(t *testing.T) {
+ upstream := &queuedHTTPUpstream{responses: []*http.Response{
+ newJSONResponse(http.StatusUnauthorized, `{"error":{"message":"invalid key sk-test-sec..."}}`),
+ }}
+ svc := &AccountTestService{httpUpstream: upstream, cfg: modelProbeTestConfig()}
+
+ result, err := svc.ProbeModels(context.Background(), ModelProbeTestInput{
+ Platform: PlatformOpenAI,
+ BaseURL: "https://one.example.com",
+ APIKey: "sk-test-secret",
+ Mode: ModelProbeModeOpenAIResponses,
+ Models: []string{"gpt-5.4"},
+ })
+
+ require.NoError(t, err)
+ require.Len(t, result.Results, 1)
+ require.False(t, result.Results[0].OK)
+ require.NotContains(t, result.Results[0].Error, "sk-test")
+ require.Contains(t, strings.ToLower(result.Results[0].Error), "invalid key")
+}
diff --git a/backend/internal/service/account_test_service.go b/backend/internal/service/account_test_service.go
index 032c13b18e0..01554c1e7c1 100644
--- a/backend/internal/service/account_test_service.go
+++ b/backend/internal/service/account_test_service.go
@@ -1588,7 +1588,7 @@ func (s *AccountTestService) testOpenAIImageOAuth(c *gin.Context, ctx context.Co
}
applyOpenAIImagesDefaults(parsed)
- responsesBody, err := buildOpenAIImagesResponsesRequest(parsed, parsed.Model)
+ responsesBody, err := buildOpenAIImagesResponsesRequest(parsed, parsed.Model, OpenAIImagesResponsesReasoningEffortDefault)
if err != nil {
return s.sendErrorAndEnd(c, fmt.Sprintf("Failed to build image request: %s", err.Error()))
}
diff --git a/backend/internal/service/channel_available.go b/backend/internal/service/channel_available.go
index d2d24659a1a..794863948fa 100644
--- a/backend/internal/service/channel_available.go
+++ b/backend/internal/service/channel_available.go
@@ -34,6 +34,12 @@ type AvailableChannel struct {
SupportedModels []SupportedModel
}
+// AvailableChannelAccountModelRepository is the narrow account read port needed
+// by the user-facing available-channel view.
+type AvailableChannelAccountModelRepository interface {
+ ListSchedulableByGroupID(ctx context.Context, groupID int64) ([]Account, error)
+}
+
// ListAvailable 返回所有渠道的可用视图:每个渠道附带关联分组信息与支持模型列表。
//
// 支持模型通过 (*Channel).SupportedModels() 计算(mapping ∪ pricing 并联)。
@@ -102,6 +108,84 @@ func (s *ChannelService) ListAvailable(ctx context.Context) ([]AvailableChannel,
return out, nil
}
+// ListSupportedModelsForGroups derives displayable models from schedulable
+// accounts under the supplied groups. Callers should pass only groups visible to
+// the current user; this method intentionally does not perform user authorization.
+func (s *ChannelService) ListSupportedModelsForGroups(
+ ctx context.Context,
+ groups []AvailableGroupRef,
+) (map[int64][]SupportedModel, error) {
+ if s.accountModelRepo == nil || len(groups) == 0 {
+ return nil, nil
+ }
+
+ out := make(map[int64][]SupportedModel, len(groups))
+ seenGroups := make(map[int64]struct{}, len(groups))
+ for _, group := range groups {
+ if group.ID <= 0 || group.Platform == "" {
+ continue
+ }
+ if _, ok := seenGroups[group.ID]; ok {
+ continue
+ }
+ seenGroups[group.ID] = struct{}{}
+
+ accounts, err := s.accountModelRepo.ListSchedulableByGroupID(ctx, group.ID)
+ if err != nil {
+ return nil, fmt.Errorf("list group account models: group_id=%d: %w", group.ID, err)
+ }
+ models := supportedModelsFromAccounts(accounts, group.Platform)
+ s.fillGlobalPricingFallback(models)
+ if len(models) > 0 {
+ out[group.ID] = models
+ }
+ }
+ return out, nil
+}
+
+func supportedModelsFromAccounts(accounts []Account, platform string) []SupportedModel {
+ type dedupKey struct {
+ platform string
+ name string
+ }
+ seen := make(map[dedupKey]struct{})
+ models := make([]SupportedModel, 0)
+ for _, account := range accounts {
+ if account.Platform != platform {
+ continue
+ }
+ for model := range explicitAccountModelMapping(account) {
+ model = strings.TrimSpace(model)
+ if model == "" {
+ continue
+ }
+ key := dedupKey{platform: platform, name: strings.ToLower(model)}
+ if _, ok := seen[key]; ok {
+ continue
+ }
+ seen[key] = struct{}{}
+ models = append(models, SupportedModel{
+ Name: model,
+ Platform: platform,
+ })
+ }
+ }
+ sort.SliceStable(models, func(i, j int) bool {
+ if models[i].Platform != models[j].Platform {
+ return models[i].Platform < models[j].Platform
+ }
+ return models[i].Name < models[j].Name
+ })
+ return models
+}
+
+func explicitAccountModelMapping(account Account) map[string]string {
+ if account.Credentials == nil {
+ return nil
+ }
+ return stringMappingFromRaw(account.Credentials["model_mapping"])
+}
+
// fillGlobalPricingFallback 对未命中渠道定价的支持模型,从全局 LiteLLM 数据合成一份
// 展示用定价。仅用于「可用渠道」展示,不影响真实计费链路。
//
diff --git a/backend/internal/service/channel_available_test.go b/backend/internal/service/channel_available_test.go
index d59e587ecd5..ad0a208505b 100644
--- a/backend/internal/service/channel_available_test.go
+++ b/backend/internal/service/channel_available_test.go
@@ -21,6 +21,18 @@ type stubGroupRepoForAvailable struct {
listActiveCalls int
}
+type stubAccountRepoForAvailable struct {
+ byGroup map[int64][]Account
+ err error
+}
+
+func (s *stubAccountRepoForAvailable) ListSchedulableByGroupID(ctx context.Context, groupID int64) ([]Account, error) {
+ if s.err != nil {
+ return nil, s.err
+ }
+ return s.byGroup[groupID], nil
+}
+
func (s *stubGroupRepoForAvailable) ListActive(ctx context.Context) ([]Group, error) {
s.listActiveCalls++
if s.listActiveErr != nil {
@@ -75,7 +87,7 @@ func newAvailableChannelService(channels []Channel, groupRepo GroupRepository) *
repo := &mockChannelRepository{
listAllFn: func(ctx context.Context) ([]Channel, error) { return channels, nil },
}
- return NewChannelService(repo, groupRepo, nil, nil)
+ return NewChannelService(repo, groupRepo, nil, nil, nil)
}
func TestListAvailable_EmptyActiveGroups_NoGroupsAttached(t *testing.T) {
@@ -134,7 +146,7 @@ func TestListAvailable_ListAllErrorPropagates(t *testing.T) {
listAllFn: func(ctx context.Context) ([]Channel, error) { return nil, sentinel },
}
groupRepo := &stubGroupRepoForAvailable{}
- svc := NewChannelService(repo, groupRepo, nil, nil)
+ svc := NewChannelService(repo, groupRepo, nil, nil, nil)
out, err := svc.ListAvailable(context.Background())
require.Nil(t, out)
require.ErrorIs(t, err, sentinel)
@@ -176,6 +188,68 @@ func TestListAvailable_DefaultsEmptyBillingModelSource(t *testing.T) {
require.Equal(t, BillingModelSourceUpstream, byName["explicit"])
}
+func TestListAvailable_IncludesSchedulableAccountMappingModelsByGroup(t *testing.T) {
+ accountRepo := &stubAccountRepoForAvailable{
+ byGroup: map[int64][]Account{
+ 1: {
+ {
+ ID: 10,
+ Platform: PlatformOpenAI,
+ Credentials: map[string]any{
+ "model_mapping": map[string]any{
+ "gpt-5.4": "gpt-5.4",
+ },
+ },
+ },
+ },
+ },
+ }
+ svc := NewChannelService(nil, nil, accountRepo, nil, nil)
+
+ out, err := svc.ListSupportedModelsForGroups(context.Background(), []AvailableGroupRef{
+ {ID: 1, Platform: PlatformOpenAI},
+ })
+
+ require.NoError(t, err)
+ require.Len(t, out, 1)
+ require.Equal(t, []SupportedModel{{Name: "gpt-5.4", Platform: PlatformOpenAI}}, out[1])
+}
+
+func TestListAvailable_UsesExplicitAccountMappingOnly(t *testing.T) {
+ accountRepo := &stubAccountRepoForAvailable{
+ byGroup: map[int64][]Account{
+ 1: {
+ {
+ ID: 10,
+ Platform: PlatformAntigravity,
+ Credentials: map[string]any{},
+ },
+ },
+ },
+ }
+ svc := NewChannelService(nil, nil, accountRepo, nil, nil)
+
+ out, err := svc.ListSupportedModelsForGroups(context.Background(), []AvailableGroupRef{
+ {ID: 1, Platform: PlatformAntigravity},
+ })
+
+ require.NoError(t, err)
+ require.Empty(t, out)
+}
+
+func TestListAvailable_AccountModelAggregationErrorPropagates(t *testing.T) {
+ sentinel := errors.New("account-model-boom")
+ svc := NewChannelService(nil, nil, &stubAccountRepoForAvailable{err: sentinel}, nil, nil)
+
+ out, err := svc.ListSupportedModelsForGroups(context.Background(), []AvailableGroupRef{
+ {ID: 1, Platform: PlatformOpenAI},
+ })
+
+ require.Nil(t, out)
+ require.ErrorIs(t, err, sentinel)
+ require.Contains(t, err.Error(), "list group account models")
+}
+
func TestPricingNeedsFallback(t *testing.T) {
tests := []struct {
name string
diff --git a/backend/internal/service/channel_service.go b/backend/internal/service/channel_service.go
index 4bf0147f38c..fa15cb781cc 100644
--- a/backend/internal/service/channel_service.go
+++ b/backend/internal/service/channel_service.go
@@ -142,6 +142,7 @@ const (
type ChannelService struct {
repo ChannelRepository
groupRepo GroupRepository
+ accountModelRepo AvailableChannelAccountModelRepository
authCacheInvalidator APIKeyAuthCacheInvalidator
pricingService *PricingService // 用于「可用渠道」展示时回落到全局定价;可为 nil(测试场景)
@@ -152,10 +153,11 @@ type ChannelService struct {
// NewChannelService 创建渠道服务实例。
// pricingService 仅供 ListAvailable 在渠道未配置定价时回落到全局 LiteLLM 数据;
// 计费热路径走独立的 ModelPricingResolver,与此参数无关。可传 nil。
-func NewChannelService(repo ChannelRepository, groupRepo GroupRepository, authCacheInvalidator APIKeyAuthCacheInvalidator, pricingService *PricingService) *ChannelService {
+func NewChannelService(repo ChannelRepository, groupRepo GroupRepository, accountModelRepo AvailableChannelAccountModelRepository, authCacheInvalidator APIKeyAuthCacheInvalidator, pricingService *PricingService) *ChannelService {
s := &ChannelService{
repo: repo,
groupRepo: groupRepo,
+ accountModelRepo: accountModelRepo,
authCacheInvalidator: authCacheInvalidator,
pricingService: pricingService,
}
diff --git a/backend/internal/service/channel_service_test.go b/backend/internal/service/channel_service_test.go
index e737a21125b..8d3d917a800 100644
--- a/backend/internal/service/channel_service_test.go
+++ b/backend/internal/service/channel_service_test.go
@@ -189,11 +189,11 @@ func (m *mockChannelAuthCacheInvalidator) InvalidateAuthCacheByGroupID(_ context
// ---------------------------------------------------------------------------
func newTestChannelService(repo *mockChannelRepository) *ChannelService {
- return NewChannelService(repo, nil, nil, nil)
+ return NewChannelService(repo, nil, nil, nil, nil)
}
func newTestChannelServiceWithAuth(repo *mockChannelRepository, auth *mockChannelAuthCacheInvalidator) *ChannelService {
- return NewChannelService(repo, nil, auth, nil)
+ return NewChannelService(repo, nil, nil, auth, nil)
}
// makeStandardRepo returns a repo that serves one active channel with anthropic pricing
diff --git a/backend/internal/service/domain_constants.go b/backend/internal/service/domain_constants.go
index b64412383fc..53cf1c4f072 100644
--- a/backend/internal/service/domain_constants.go
+++ b/backend/internal/service/domain_constants.go
@@ -427,6 +427,8 @@ const (
SettingKeyRewriteMessageCacheControl = "rewrite_message_cache_control"
// SettingKeyAntigravityUserAgentVersion Antigravity 上游 User-Agent 版本号(空值使用环境变量/默认值)
SettingKeyAntigravityUserAgentVersion = "antigravity_user_agent_version"
+ // SettingKeyOpenAIImagesResponsesReasoningEffort OpenAI OAuth 图片桥接走 Responses API 时的 reasoning.effort
+ SettingKeyOpenAIImagesResponsesReasoningEffort = "openai_images_responses_reasoning_effort"
// SettingKeyOpenAICodexUserAgent OpenAI Codex 完整 User-Agent(空值使用内置默认)
// 当客户端 UA 被识别为浏览器(Chrome/Firefox/Safari/Edge 等)时,转发给 OpenAI 上游前会替换为此值,
// 用于避免 Cloudflare 对浏览器型 UA 的质询拦截。
diff --git a/backend/internal/service/model_pricing_resolver_test.go b/backend/internal/service/model_pricing_resolver_test.go
index 4548c1d598e..bcc6801f1a1 100644
--- a/backend/internal/service/model_pricing_resolver_test.go
+++ b/backend/internal/service/model_pricing_resolver_test.go
@@ -184,7 +184,7 @@ func newResolverWithChannel(t *testing.T, pricing []ChannelModelPricing) *ModelP
return map[int64]string{groupID: "anthropic"}, nil
},
}
- cs := NewChannelService(repo, nil, nil, nil)
+ cs := NewChannelService(repo, nil, nil, nil, nil)
bs := newTestBillingServiceForResolver()
return NewModelPricingResolver(cs, bs)
}
@@ -517,7 +517,7 @@ func TestResolve_WithChannelOverride_CacheError(t *testing.T) {
return nil, errors.New("database unavailable")
},
}
- cs := NewChannelService(repo, nil, nil, nil)
+ cs := NewChannelService(repo, nil, nil, nil, nil)
bs := newTestBillingServiceForResolver()
r := NewModelPricingResolver(cs, bs)
diff --git a/backend/internal/service/openai_images_reasoning.go b/backend/internal/service/openai_images_reasoning.go
new file mode 100644
index 00000000000..31aaba03994
--- /dev/null
+++ b/backend/internal/service/openai_images_reasoning.go
@@ -0,0 +1,19 @@
+package service
+
+import "github.com/Wei-Shaw/sub2api/internal/config"
+
+const (
+ OpenAIImagesResponsesReasoningEffortLow = config.OpenAIImagesResponsesReasoningEffortLow
+ OpenAIImagesResponsesReasoningEffortMedium = config.OpenAIImagesResponsesReasoningEffortMedium
+ OpenAIImagesResponsesReasoningEffortHigh = config.OpenAIImagesResponsesReasoningEffortHigh
+ OpenAIImagesResponsesReasoningEffortXHigh = config.OpenAIImagesResponsesReasoningEffortXHigh
+ OpenAIImagesResponsesReasoningEffortDefault = config.OpenAIImagesResponsesReasoningEffortDefault
+)
+
+func IsValidOpenAIImagesResponsesReasoningEffort(raw string) bool {
+ return config.IsValidOpenAIImagesResponsesReasoningEffort(raw)
+}
+
+func NormalizeOpenAIImagesResponsesReasoningEffort(raw string) string {
+ return config.NormalizeOpenAIImagesResponsesReasoningEffort(raw)
+}
diff --git a/backend/internal/service/openai_images_responses.go b/backend/internal/service/openai_images_responses.go
index 849ad7920c1..e7b46cd4191 100644
--- a/backend/internal/service/openai_images_responses.go
+++ b/backend/internal/service/openai_images_responses.go
@@ -280,7 +280,7 @@ func openAIImageUploadToDataURL(upload OpenAIImagesUpload) (string, error) {
return "data:" + contentType + ";base64," + base64.StdEncoding.EncodeToString(upload.Data), nil
}
-func buildOpenAIImagesResponsesRequest(parsed *OpenAIImagesRequest, toolModel string) ([]byte, error) {
+func buildOpenAIImagesResponsesRequest(parsed *OpenAIImagesRequest, toolModel string, reasoningEffort string) ([]byte, error) {
if parsed == nil {
return nil, fmt.Errorf("parsed images request is required")
}
@@ -308,6 +308,7 @@ func buildOpenAIImagesResponsesRequest(parsed *OpenAIImagesRequest, toolModel st
req := []byte(`{"instructions":"","stream":true,"reasoning":{"effort":"medium","summary":"auto"},"parallel_tool_calls":true,"include":["reasoning.encrypted_content"],"model":"","store":false,"tool_choice":{"type":"image_generation"}}`)
req, _ = sjson.SetBytes(req, "model", openAIImagesResponsesMainModel)
+ req, _ = sjson.SetBytes(req, "reasoning.effort", NormalizeOpenAIImagesResponsesReasoningEffort(reasoningEffort))
input := []byte(`[{"type":"message","role":"user","content":[{"type":"input_text","text":""}]}]`)
input, _ = sjson.SetBytes(input, "0.content.0.text", prompt)
@@ -338,7 +339,7 @@ func buildOpenAIImagesResponsesRequest(parsed *OpenAIImagesRequest, toolModel st
{path: "background", value: parsed.Background},
{path: "output_format", value: parsed.OutputFormat},
{path: "moderation", value: parsed.Moderation},
- {path: "style", value: parsed.Style},
+ {path: "input_fidelity", value: parsed.InputFidelity},
} {
if trimmed := strings.TrimSpace(field.value); trimmed != "" {
tool, _ = sjson.SetBytes(tool, field.path, trimmed)
@@ -1139,7 +1140,13 @@ func (s *OpenAIGatewayService) forwardOpenAIImagesOAuth(
return nil, err
}
- responsesBody, err := buildOpenAIImagesResponsesRequest(parsed, requestModel)
+ reasoningEffort := OpenAIImagesResponsesReasoningEffortDefault
+ if s != nil && s.settingService != nil {
+ reasoningEffort = s.settingService.GetOpenAIImagesResponsesReasoningEffort(ctx)
+ } else if s != nil && s.cfg != nil {
+ reasoningEffort = NormalizeOpenAIImagesResponsesReasoningEffort(s.cfg.Gateway.OpenAIImagesResponsesReasoningEffort)
+ }
+ responsesBody, err := buildOpenAIImagesResponsesRequest(parsed, requestModel, reasoningEffort)
if err != nil {
return nil, err
}
diff --git a/backend/internal/service/openai_images_test.go b/backend/internal/service/openai_images_test.go
index a87e96c1077..892e7198e66 100644
--- a/backend/internal/service/openai_images_test.go
+++ b/backend/internal/service/openai_images_test.go
@@ -514,6 +514,67 @@ type openAIImageTestSSEEvent struct {
Data string
}
+type openAIImageSettingRepoStub struct {
+ values map[string]string
+}
+
+func (s *openAIImageSettingRepoStub) Get(ctx context.Context, key string) (*Setting, error) {
+ value, ok := s.values[key]
+ if !ok {
+ return nil, ErrSettingNotFound
+ }
+ return &Setting{Key: key, Value: value}, nil
+}
+
+func (s *openAIImageSettingRepoStub) GetValue(ctx context.Context, key string) (string, error) {
+ value, ok := s.values[key]
+ if !ok {
+ return "", ErrSettingNotFound
+ }
+ return value, nil
+}
+
+func (s *openAIImageSettingRepoStub) Set(ctx context.Context, key, value string) error {
+ if s.values == nil {
+ s.values = make(map[string]string)
+ }
+ s.values[key] = value
+ return nil
+}
+
+func (s *openAIImageSettingRepoStub) GetMultiple(ctx context.Context, keys []string) (map[string]string, error) {
+ out := make(map[string]string, len(keys))
+ for _, key := range keys {
+ if value, ok := s.values[key]; ok {
+ out[key] = value
+ }
+ }
+ return out, nil
+}
+
+func (s *openAIImageSettingRepoStub) SetMultiple(ctx context.Context, settings map[string]string) error {
+ if s.values == nil {
+ s.values = make(map[string]string)
+ }
+ for key, value := range settings {
+ s.values[key] = value
+ }
+ return nil
+}
+
+func (s *openAIImageSettingRepoStub) GetAll(ctx context.Context) (map[string]string, error) {
+ out := make(map[string]string, len(s.values))
+ for key, value := range s.values {
+ out[key] = value
+ }
+ return out, nil
+}
+
+func (s *openAIImageSettingRepoStub) Delete(ctx context.Context, key string) error {
+ delete(s.values, key)
+ return nil
+}
+
func parseOpenAIImageTestSSEEvents(body string) []openAIImageTestSSEEvent {
chunks := strings.Split(body, "\n\n")
events := make([]openAIImageTestSSEEvent, 0, len(chunks))
@@ -547,6 +608,10 @@ func findOpenAIImageTestSSEEvent(events []openAIImageTestSSEEvent, name string)
return openAIImageTestSSEEvent{}, false
}
+func newOpenAIImageTestSettingService(values map[string]string, cfg *config.Config) *SettingService {
+ return NewSettingService(&openAIImageSettingRepoStub{values: values}, cfg)
+}
+
func TestOpenAIGatewayServiceForwardImages_OAuthPassesNAndReturnsAllImages(t *testing.T) {
gin.SetMode(gin.TestMode)
body := []byte(`{"model":"gpt-image-2","prompt":"draw a cat","size":"1024x1024","quality":"high","n":3}`)
@@ -558,7 +623,11 @@ func TestOpenAIGatewayServiceForwardImages_OAuthPassesNAndReturnsAllImages(t *te
c.Request = req
c.Set("api_key", &APIKey{ID: 42})
- svc := &OpenAIGatewayService{}
+ svc := &OpenAIGatewayService{
+ settingService: newOpenAIImageTestSettingService(map[string]string{
+ SettingKeyOpenAIImagesResponsesReasoningEffort: OpenAIImagesResponsesReasoningEffortXHigh,
+ }, &config.Config{}),
+ }
parsed, err := svc.ParseOpenAIImagesRequest(c, body)
require.NoError(t, err)
@@ -614,6 +683,7 @@ func TestOpenAIGatewayServiceForwardImages_OAuthPassesNAndReturnsAllImages(t *te
require.Equal(t, "gpt-image-2", gjson.GetBytes(upstream.lastBody, "tools.0.model").String())
require.Equal(t, "1024x1024", gjson.GetBytes(upstream.lastBody, "tools.0.size").String())
require.Equal(t, "high", gjson.GetBytes(upstream.lastBody, "tools.0.quality").String())
+ require.Equal(t, "xhigh", gjson.GetBytes(upstream.lastBody, "reasoning.effort").String())
require.Equal(t, int64(3), gjson.GetBytes(upstream.lastBody, "tools.0.n").Int())
require.Equal(t, "draw a cat", gjson.GetBytes(upstream.lastBody, "input.0.content.0.text").String())
@@ -1152,7 +1222,7 @@ func TestOpenAIGatewayServiceForwardImages_OAuthEditsMultipartUsesResponsesAPI(t
require.Equal(t, 1, result.ImageCount)
require.Equal(t, "gpt-image-2", gjson.GetBytes(upstream.lastBody, "tools.0.model").String())
require.Equal(t, "edit", gjson.GetBytes(upstream.lastBody, "tools.0.action").String())
- require.False(t, gjson.GetBytes(upstream.lastBody, "tools.0.input_fidelity").Exists())
+ require.Equal(t, "high", gjson.GetBytes(upstream.lastBody, "tools.0.input_fidelity").String())
require.Equal(t, "webp", gjson.GetBytes(upstream.lastBody, "tools.0.output_format").String())
require.True(t, strings.HasPrefix(gjson.GetBytes(upstream.lastBody, "input.0.content.1.image_url").String(), "data:image/png;base64,"))
require.True(t, strings.HasPrefix(gjson.GetBytes(upstream.lastBody, "tools.0.input_image_mask.image_url").String(), "data:image/png;base64,"))
@@ -1251,12 +1321,13 @@ func TestBuildOpenAIImagesResponsesRequest_PassesThroughNForMultiImageModels(t *
N: 2,
}
- body, err := buildOpenAIImagesResponsesRequest(parsed, "gpt-image-2")
+ body, err := buildOpenAIImagesResponsesRequest(parsed, "gpt-image-2", OpenAIImagesResponsesReasoningEffortDefault)
require.NoError(t, err)
require.NotNil(t, body)
require.Equal(t, int64(2), gjson.GetBytes(body, "tools.0.n").Int())
require.Equal(t, "gpt-image-2", gjson.GetBytes(body, "tools.0.model").String())
require.Equal(t, "draw a cat", gjson.GetBytes(body, "input.0.content.0.text").String())
+ require.Equal(t, "medium", gjson.GetBytes(body, "reasoning.effort").String())
}
func TestBuildOpenAIImagesResponsesRequest_DoesNotPassNForDallE3(t *testing.T) {
@@ -1267,7 +1338,7 @@ func TestBuildOpenAIImagesResponsesRequest_DoesNotPassNForDallE3(t *testing.T) {
N: 2,
}
- body, err := buildOpenAIImagesResponsesRequest(parsed, "dall-e-3")
+ body, err := buildOpenAIImagesResponsesRequest(parsed, "dall-e-3", OpenAIImagesResponsesReasoningEffortDefault)
require.NoError(t, err)
require.NotNil(t, body)
require.False(t, gjson.GetBytes(body, "tools.0.n").Exists())
@@ -1280,16 +1351,19 @@ func TestBuildOpenAIImagesResponsesRequest_StripsInputFidelity(t *testing.T) {
Model: "gpt-image-2",
Prompt: "replace background",
InputFidelity: "high",
+ Style: "vivid",
InputImageURLs: []string{
"https://example.com/source.png",
},
}
- body, err := buildOpenAIImagesResponsesRequest(parsed, "gpt-image-2")
+ body, err := buildOpenAIImagesResponsesRequest(parsed, "gpt-image-2", "xhigh")
require.NoError(t, err)
require.NotNil(t, body)
- require.False(t, gjson.GetBytes(body, "tools.0.input_fidelity").Exists())
+ require.Equal(t, "high", gjson.GetBytes(body, "tools.0.input_fidelity").String())
+ require.False(t, gjson.GetBytes(body, "tools.0.style").Exists())
require.Equal(t, "edit", gjson.GetBytes(body, "tools.0.action").String())
+ require.Equal(t, "xhigh", gjson.GetBytes(body, "reasoning.effort").String())
}
func TestCollectOpenAIImagesFromResponsesBody_FallsBackToOutputItemDone(t *testing.T) {
diff --git a/backend/internal/service/setting_service.go b/backend/internal/service/setting_service.go
index 98acdb80f56..c22dfc09044 100644
--- a/backend/internal/service/setting_service.go
+++ b/backend/internal/service/setting_service.go
@@ -999,6 +999,40 @@ func (s *SettingService) GetAntigravityUserAgentVersion(ctx context.Context) str
return fallback
}
+func (s *SettingService) defaultOpenAIImagesResponsesReasoningEffort() string {
+ if s != nil && s.cfg != nil {
+ return NormalizeOpenAIImagesResponsesReasoningEffort(s.cfg.Gateway.OpenAIImagesResponsesReasoningEffort)
+ }
+ return OpenAIImagesResponsesReasoningEffortDefault
+}
+
+// GetOpenAIImagesResponsesReasoningEffort returns the Responses API reasoning effort
+// used by the OAuth images bridge. DB setting wins; missing or invalid values fall
+// back to config/default so a corrupted setting cannot break image requests.
+func (s *SettingService) GetOpenAIImagesResponsesReasoningEffort(ctx context.Context) string {
+ fallback := s.defaultOpenAIImagesResponsesReasoningEffort()
+ if s == nil || s.settingRepo == nil {
+ return fallback
+ }
+ if ctx == nil {
+ ctx = context.Background()
+ }
+ dbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), gatewayForwardingDBTimeout)
+ defer cancel()
+ value, err := s.settingRepo.GetValue(dbCtx, SettingKeyOpenAIImagesResponsesReasoningEffort)
+ if err != nil {
+ if !errors.Is(err, ErrSettingNotFound) {
+ slog.Warn("failed to get openai images responses reasoning effort setting", "error", err)
+ }
+ return fallback
+ }
+ trimmed := strings.TrimSpace(value)
+ if trimmed == "" || !IsValidOpenAIImagesResponsesReasoningEffort(trimmed) {
+ return fallback
+ }
+ return NormalizeOpenAIImagesResponsesReasoningEffort(trimmed)
+}
+
// GetOpenAICodexUserAgent 返回 OpenAI Codex 上游请求使用的 User-Agent。
// 后台设置优先;为空时回退到内置默认值。
func (s *SettingService) GetOpenAICodexUserAgent(ctx context.Context) string {
@@ -1896,6 +1930,18 @@ func (s *SettingService) buildSystemSettingsUpdates(ctx context.Context, setting
updates[SettingKeyEnableAnthropicCacheTTL1hInjection] = strconv.FormatBool(settings.EnableAnthropicCacheTTL1hInjection)
updates[SettingKeyRewriteMessageCacheControl] = strconv.FormatBool(settings.RewriteMessageCacheControl)
updates[SettingKeyAntigravityUserAgentVersion] = antigravity.NormalizeUserAgentVersion(settings.AntigravityUserAgentVersion)
+ openAIImagesResponsesReasoningEffort := strings.TrimSpace(settings.OpenAIImagesResponsesReasoningEffort)
+ if openAIImagesResponsesReasoningEffort == "" {
+ openAIImagesResponsesReasoningEffort = s.defaultOpenAIImagesResponsesReasoningEffort()
+ }
+ if !IsValidOpenAIImagesResponsesReasoningEffort(openAIImagesResponsesReasoningEffort) {
+ return nil, infraerrors.BadRequest(
+ "INVALID_OPENAI_IMAGES_RESPONSES_REASONING_EFFORT",
+ "openai_images_responses_reasoning_effort must be one of: low, medium, high, xhigh",
+ )
+ }
+ settings.OpenAIImagesResponsesReasoningEffort = NormalizeOpenAIImagesResponsesReasoningEffort(openAIImagesResponsesReasoningEffort)
+ updates[SettingKeyOpenAIImagesResponsesReasoningEffort] = settings.OpenAIImagesResponsesReasoningEffort
updates[SettingKeyOpenAICodexUserAgent] = strings.TrimSpace(settings.OpenAICodexUserAgent)
updates[SettingKeyOpenAIAllowClaudeCodeCodexPlugin] = strconv.FormatBool(settings.OpenAIAllowClaudeCodeCodexPlugin)
updates[SettingPaymentVisibleMethodAlipaySource] = settings.PaymentVisibleMethodAlipaySource
@@ -2806,16 +2852,17 @@ func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error {
SettingKeyMaxClaudeCodeVersion: "",
// 分组隔离(默认不允许未分组 Key 调度)
- SettingKeyAllowUngroupedKeyScheduling: "false",
- SettingKeyEnableAnthropicCacheTTL1hInjection: "false",
- SettingKeyRewriteMessageCacheControl: strconv.FormatBool(s.defaultRewriteMessageCacheControl()),
- SettingKeyAntigravityUserAgentVersion: "",
- SettingKeyOpenAICodexUserAgent: "",
- SettingPaymentVisibleMethodAlipaySource: "",
- SettingPaymentVisibleMethodWxpaySource: "",
- SettingPaymentVisibleMethodAlipayEnabled: "false",
- SettingPaymentVisibleMethodWxpayEnabled: "false",
- openAIAdvancedSchedulerSettingKey: "false",
+ SettingKeyAllowUngroupedKeyScheduling: "false",
+ SettingKeyEnableAnthropicCacheTTL1hInjection: "false",
+ SettingKeyRewriteMessageCacheControl: strconv.FormatBool(s.defaultRewriteMessageCacheControl()),
+ SettingKeyAntigravityUserAgentVersion: "",
+ SettingKeyOpenAIImagesResponsesReasoningEffort: s.defaultOpenAIImagesResponsesReasoningEffort(),
+ SettingKeyOpenAICodexUserAgent: "",
+ SettingPaymentVisibleMethodAlipaySource: "",
+ SettingPaymentVisibleMethodWxpaySource: "",
+ SettingPaymentVisibleMethodAlipayEnabled: "false",
+ SettingPaymentVisibleMethodWxpayEnabled: "false",
+ openAIAdvancedSchedulerSettingKey: "false",
}
return s.settingRepo.SetMultiple(ctx, defaults)
@@ -3330,6 +3377,12 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin
result.RewriteMessageCacheControl = s.defaultRewriteMessageCacheControl()
}
result.AntigravityUserAgentVersion = antigravity.NormalizeUserAgentVersion(settings[SettingKeyAntigravityUserAgentVersion])
+ result.OpenAIImagesResponsesReasoningEffort = s.defaultOpenAIImagesResponsesReasoningEffort()
+ if v, ok := settings[SettingKeyOpenAIImagesResponsesReasoningEffort]; ok && strings.TrimSpace(v) != "" {
+ if IsValidOpenAIImagesResponsesReasoningEffort(v) {
+ result.OpenAIImagesResponsesReasoningEffort = NormalizeOpenAIImagesResponsesReasoningEffort(v)
+ }
+ }
result.OpenAICodexUserAgent = strings.TrimSpace(settings[SettingKeyOpenAICodexUserAgent])
result.OpenAIAllowClaudeCodeCodexPlugin = settings[SettingKeyOpenAIAllowClaudeCodeCodexPlugin] == "true"
diff --git a/backend/internal/service/setting_service_update_test.go b/backend/internal/service/setting_service_update_test.go
index 379bf9bc04a..04de85bf46b 100644
--- a/backend/internal/service/setting_service_update_test.go
+++ b/backend/internal/service/setting_service_update_test.go
@@ -290,6 +290,29 @@ func TestSettingService_UpdateSettings_AntigravityUserAgentVersion(t *testing.T)
require.Equal(t, "1.23.2", repo.updates[SettingKeyAntigravityUserAgentVersion])
}
+func TestSettingService_UpdateSettings_OpenAIImagesResponsesReasoningEffort(t *testing.T) {
+ repo := &settingUpdateRepoStub{}
+ svc := NewSettingService(repo, &config.Config{})
+
+ err := svc.UpdateSettings(context.Background(), &SystemSettings{
+ OpenAIImagesResponsesReasoningEffort: "xhigh",
+ })
+ require.NoError(t, err)
+ require.Equal(t, OpenAIImagesResponsesReasoningEffortXHigh, repo.updates[SettingKeyOpenAIImagesResponsesReasoningEffort])
+}
+
+func TestSettingService_UpdateSettings_RejectsInvalidOpenAIImagesResponsesReasoningEffort(t *testing.T) {
+ repo := &settingUpdateRepoStub{}
+ svc := NewSettingService(repo, &config.Config{})
+
+ err := svc.UpdateSettings(context.Background(), &SystemSettings{
+ OpenAIImagesResponsesReasoningEffort: "advanced",
+ })
+ require.Error(t, err)
+ require.Equal(t, "INVALID_OPENAI_IMAGES_RESPONSES_REASONING_EFFORT", infraerrors.Reason(err))
+ require.Nil(t, repo.updates)
+}
+
func TestSettingService_UpdateSettings_APIKeyACLTrustForwardedIPRefreshesConfig(t *testing.T) {
repo := &settingUpdateRepoStub{}
cfg := &config.Config{}
@@ -338,6 +361,44 @@ func TestSettingService_GetAntigravityUserAgentVersion_Precedence(t *testing.T)
})
}
+func TestSettingService_GetOpenAIImagesResponsesReasoningEffort_Precedence(t *testing.T) {
+ t.Run("后台设置优先", func(t *testing.T) {
+ svc := NewSettingService(&settingAntigravityUARepoStub{values: map[string]string{
+ SettingKeyOpenAIImagesResponsesReasoningEffort: "xhigh",
+ }}, &config.Config{
+ Gateway: config.GatewayConfig{
+ OpenAIImagesResponsesReasoningEffort: "high",
+ },
+ })
+
+ require.Equal(t, OpenAIImagesResponsesReasoningEffortXHigh, svc.GetOpenAIImagesResponsesReasoningEffort(context.Background()))
+ })
+
+ t.Run("空值回退配置默认值", func(t *testing.T) {
+ svc := NewSettingService(&settingAntigravityUARepoStub{values: map[string]string{
+ SettingKeyOpenAIImagesResponsesReasoningEffort: "",
+ }}, &config.Config{
+ Gateway: config.GatewayConfig{
+ OpenAIImagesResponsesReasoningEffort: "high",
+ },
+ })
+
+ require.Equal(t, OpenAIImagesResponsesReasoningEffortHigh, svc.GetOpenAIImagesResponsesReasoningEffort(context.Background()))
+ })
+
+ t.Run("非法值回退配置默认值", func(t *testing.T) {
+ svc := NewSettingService(&settingAntigravityUARepoStub{values: map[string]string{
+ SettingKeyOpenAIImagesResponsesReasoningEffort: "advanced",
+ }}, &config.Config{
+ Gateway: config.GatewayConfig{
+ OpenAIImagesResponsesReasoningEffort: "high",
+ },
+ })
+
+ require.Equal(t, OpenAIImagesResponsesReasoningEffortHigh, svc.GetOpenAIImagesResponsesReasoningEffort(context.Background()))
+ })
+}
+
func TestSettingService_UpdateSettings_RejectsInvalidPaymentVisibleMethodSource(t *testing.T) {
repo := &settingUpdateRepoStub{}
svc := NewSettingService(repo, &config.Config{})
diff --git a/backend/internal/service/settings_view.go b/backend/internal/service/settings_view.go
index 7b45ef1a689..8314f47f27e 100644
--- a/backend/internal/service/settings_view.go
+++ b/backend/internal/service/settings_view.go
@@ -188,14 +188,15 @@ type SystemSettings struct {
BackendModeEnabled bool
// Gateway forwarding behavior
- EnableFingerprintUnification bool // 是否统一 OAuth 账号的指纹头(默认 true)
- EnableMetadataPassthrough bool // 是否透传客户端原始 metadata(默认 false)
- EnableCCHSigning bool // 是否对 billing header cch 进行签名(默认 false)
- EnableAnthropicCacheTTL1hInjection bool // 是否对 Anthropic OAuth/SetupToken 请求体注入 1h cache_control ttl(默认 false)
- RewriteMessageCacheControl bool // 是否改写 messages[*].content[*].cache_control(默认 false)
- AntigravityUserAgentVersion string // Antigravity 上游 User-Agent 版本号;空值使用配置/默认值
- OpenAICodexUserAgent string // OpenAI Codex 上游完整 User-Agent;空值使用内置默认
- OpenAIAllowClaudeCodeCodexPlugin bool // 全局开关:是否额外放行 Claude Code 的 Codex 插件(默认 false)
+ EnableFingerprintUnification bool // 是否统一 OAuth 账号的指纹头(默认 true)
+ EnableMetadataPassthrough bool // 是否透传客户端原始 metadata(默认 false)
+ EnableCCHSigning bool // 是否对 billing header cch 进行签名(默认 false)
+ EnableAnthropicCacheTTL1hInjection bool // 是否对 Anthropic OAuth/SetupToken 请求体注入 1h cache_control ttl(默认 false)
+ RewriteMessageCacheControl bool // 是否改写 messages[*].content[*].cache_control(默认 false)
+ AntigravityUserAgentVersion string // Antigravity 上游 User-Agent 版本号;空值使用配置/默认值
+ OpenAIImagesResponsesReasoningEffort string // OpenAI OAuth 图片桥接 Responses reasoning.effort
+ OpenAICodexUserAgent string // OpenAI Codex 上游完整 User-Agent;空值使用内置默认
+ OpenAIAllowClaudeCodeCodexPlugin bool // 全局开关:是否额外放行 Claude Code 的 Codex 插件(默认 false)
// Web Search Emulation
WebSearchEmulationEnabled bool // 是否启用 web search 模拟
diff --git a/backend/internal/service/wire.go b/backend/internal/service/wire.go
index d3e4ce51bc7..7455936fb88 100644
--- a/backend/internal/service/wire.go
+++ b/backend/internal/service/wire.go
@@ -478,6 +478,10 @@ func ProvideAPIKeyService(
return svc
}
+func ProvideAvailableChannelAccountModelRepository(accountRepo AccountRepository) AvailableChannelAccountModelRepository {
+ return accountRepo
+}
+
// ProviderSet is the Wire provider set for all services
var ProviderSet = wire.NewSet(
// Core services
@@ -560,6 +564,7 @@ var ProviderSet = wire.NewSet(
ProvideScheduledTestService,
ProvideScheduledTestRunnerService,
NewGroupCapacityService,
+ ProvideAvailableChannelAccountModelRepository,
NewChannelService,
NewModelPricingResolver,
NewContentModerationService,
diff --git a/frontend/src/api/admin/accounts.ts b/frontend/src/api/admin/accounts.ts
index 6fd23c475d4..d333e196f7f 100644
--- a/frontend/src/api/admin/accounts.ts
+++ b/frontend/src/api/admin/accounts.ts
@@ -473,6 +473,57 @@ export async function getAvailableModels(id: number): Promise {{ t('admin.accounts.modelProbe.secretHint') }}
+ {{ + t( + "admin.settings.gatewayForwarding.openAIImagesResponsesReasoningEffortHint", + ) + }} +
+