From 5348d31ebe48ae3669a22fce16110122535d3c5a Mon Sep 17 00:00:00 2001
From: miaoheng <2020745908@qq.com>
Date: Fri, 29 May 2026 14:32:36 +0800
Subject: [PATCH 1/4] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E5=88=87?=
=?UTF-8?q?=E6=8D=A2=20OpenAI=20=E5=9B=BE=E7=89=87=E8=AF=B7=E6=B1=82?=
=?UTF-8?q?=E5=88=86=E6=9E=90=E5=BC=BA=E5=BA=A6?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
backend/internal/config/config.go | 39 +++++++++
backend/internal/config/config_test.go | 8 ++
.../internal/handler/admin/setting_handler.go | 34 ++++++--
...tting_handler_auth_source_defaults_test.go | 62 +++++++++++++
backend/internal/handler/dto/settings.go | 15 ++--
.../handler/openai_gateway_handler_test.go | 2 +-
backend/internal/pkg/openai/constants.go | 1 +
backend/internal/server/api_contract_test.go | 21 +++--
.../internal/service/account_test_service.go | 2 +-
backend/internal/service/domain_constants.go | 2 +
.../service/openai_images_reasoning.go | 19 ++++
.../service/openai_images_responses.go | 13 ++-
.../internal/service/openai_images_test.go | 86 +++++++++++++++++--
backend/internal/service/setting_service.go | 73 +++++++++++++---
.../service/setting_service_update_test.go | 61 +++++++++++++
backend/internal/service/settings_view.go | 15 ++--
frontend/src/api/admin/settings.ts | 8 ++
frontend/src/views/admin/SettingsView.vue | 39 +++++++++
.../admin/__tests__/SettingsView.spec.ts | 76 +++++++++++-----
19 files changed, 502 insertions(+), 74 deletions(-)
create mode 100644 backend/internal/service/openai_images_reasoning.go
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/setting_handler.go b/backend/internal/handler/admin/setting_handler.go
index 3c7fe581734..646d47577ba 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,
WebSearchEmulationEnabled: settings.WebSearchEmulationEnabled,
PaymentVisibleMethodAlipaySource: settings.PaymentVisibleMethodAlipaySource,
@@ -577,13 +578,14 @@ 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"`
+ 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"`
// Payment visible method routing
PaymentVisibleMethodAlipaySource *string `json:"payment_visible_method_alipay_source"`
@@ -1438,6 +1440,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
@@ -1649,6 +1659,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
@@ -2030,6 +2046,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
EnableAnthropicCacheTTL1hInjection: updatedSettings.EnableAnthropicCacheTTL1hInjection,
RewriteMessageCacheControl: updatedSettings.RewriteMessageCacheControl,
AntigravityUserAgentVersion: updatedSettings.AntigravityUserAgentVersion,
+ OpenAIImagesResponsesReasoningEffort: updatedSettings.OpenAIImagesResponsesReasoningEffort,
OpenAICodexUserAgent: updatedSettings.OpenAICodexUserAgent,
PaymentVisibleMethodAlipaySource: updatedSettings.PaymentVisibleMethodAlipaySource,
PaymentVisibleMethodWxpaySource: updatedSettings.PaymentVisibleMethodWxpaySource,
@@ -2497,6 +2514,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/dto/settings.go b/backend/internal/handler/dto/settings.go
index eecf98aca9c..dc493612c72 100644
--- a/backend/internal/handler/dto/settings.go
+++ b/backend/internal/handler/dto/settings.go
@@ -178,13 +178,14 @@ 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"`
+ 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"`
// 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 b304640edfe..0e6b73122cb 100644
--- a/backend/internal/handler/openai_gateway_handler_test.go
+++ b/backend/internal/handler/openai_gateway_handler_test.go
@@ -1198,7 +1198,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 8bc9e280ac6..ca334fc4a8e 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,
@@ -1072,6 +1074,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/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/domain_constants.go b/backend/internal/service/domain_constants.go
index 59c34eaa280..9c8c731dea7 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/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 b39fa609640..215570f01d8 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 854e9f6d3ba..92f542e7acc 100644
--- a/backend/internal/service/openai_images_test.go
+++ b/backend/internal/service/openai_images_test.go
@@ -441,6 +441,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))
@@ -474,6 +535,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}`)
@@ -485,7 +550,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)
@@ -541,6 +610,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())
@@ -1079,7 +1149,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,"))
@@ -1178,12 +1248,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) {
@@ -1194,7 +1265,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())
@@ -1207,16 +1278,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 e6f0f2bc161..81d9cee6ac6 100644
--- a/backend/internal/service/setting_service.go
+++ b/backend/internal/service/setting_service.go
@@ -966,6 +966,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 {
@@ -1815,6 +1849,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[SettingPaymentVisibleMethodAlipaySource] = settings.PaymentVisibleMethodAlipaySource
updates[SettingPaymentVisibleMethodWxpaySource] = settings.PaymentVisibleMethodWxpaySource
@@ -2708,16 +2754,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)
@@ -3232,6 +3279,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])
// Web search emulation: quick enabled check from the JSON config
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 3f961ab2fa5..f02c22c5895 100644
--- a/backend/internal/service/settings_view.go
+++ b/backend/internal/service/settings_view.go
@@ -188,13 +188,14 @@ 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;空值使用内置默认
+ 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;空值使用内置默认
// Web Search Emulation
WebSearchEmulationEnabled bool // 是否启用 web search 模拟
diff --git a/frontend/src/api/admin/settings.ts b/frontend/src/api/admin/settings.ts
index d2b878ccbe3..e2d48760525 100644
--- a/frontend/src/api/admin/settings.ts
+++ b/frontend/src/api/admin/settings.ts
@@ -355,6 +355,12 @@ export function deriveWeChatConnectStoredMode(
/**
* System settings interface
*/
+export type OpenAIImagesResponsesReasoningEffort =
+ | "low"
+ | "medium"
+ | "high"
+ | "xhigh";
+
export interface SystemSettings {
// Registration settings
registration_enabled: boolean;
@@ -559,6 +565,7 @@ export interface SystemSettings {
enable_anthropic_cache_ttl_1h_injection: boolean;
rewrite_message_cache_control: boolean;
antigravity_user_agent_version: string;
+ openai_images_responses_reasoning_effort: OpenAIImagesResponsesReasoningEffort;
openai_codex_user_agent: string;
web_search_emulation_enabled?: boolean;
@@ -791,6 +798,7 @@ export interface UpdateSettingsRequest {
enable_anthropic_cache_ttl_1h_injection?: boolean;
rewrite_message_cache_control?: boolean;
antigravity_user_agent_version?: string;
+ openai_images_responses_reasoning_effort?: OpenAIImagesResponsesReasoningEffort;
openai_codex_user_agent?: string;
// Payment configuration
payment_enabled?: boolean;
diff --git a/frontend/src/views/admin/SettingsView.vue b/frontend/src/views/admin/SettingsView.vue
index 68eb4849c5c..832c32fe086 100644
--- a/frontend/src/views/admin/SettingsView.vue
+++ b/frontend/src/views/admin/SettingsView.vue
@@ -3919,6 +3919,38 @@
+
+
+
+
+
+ {{
+ t(
+ "admin.settings.gatewayForwarding.openAIImagesResponsesReasoningEffortHint",
+ )
+ }}
+
+
+
+
+
@@ -135,6 +150,7 @@ import { useAppStore } from '@/stores/app'
import { accountsAPI } from '@/api/admin/accounts'
import ModelIcon from '@/components/common/ModelIcon.vue'
import Icon from '@/components/icons/Icon.vue'
+import ModelProbeModal from '@/components/account/ModelProbeModal.vue'
import { allModels, getModelsByPlatform } from '@/composables/useModelWhitelist'
const { t } = useI18n()
@@ -156,6 +172,8 @@ const showDropdown = ref(false)
const searchQuery = ref('')
const customModel = ref('')
const isComposing = ref(false)
+const showProbeModal = ref(false)
+const probedModels = ref([])
const isSyncingUpstream = ref(false)
const normalizedPlatforms = computed(() => {
const rawPlatforms =
@@ -174,6 +192,8 @@ const normalizedPlatforms = computed(() => {
)
})
+const primaryPlatform = computed(() => normalizedPlatforms.value[0] || props.platform || 'openai')
+
const upstreamSyncPlatforms = new Set(['anthropic', 'openai', 'gemini', 'antigravity'])
const canSyncUpstream = computed(() => {
if (!props.accountId) return false
@@ -182,8 +202,13 @@ const canSyncUpstream = computed(() => {
})
const availableOptions = computed(() => {
+ const optionMap = new Map(allModels.map(model => [model.value, model]))
+
if (normalizedPlatforms.value.length === 0) {
- return allModels
+ for (const model of probedModels.value) {
+ optionMap.set(model, { value: model, label: model })
+ }
+ return Array.from(optionMap.values())
}
const allowedModels = new Set()
@@ -192,8 +217,14 @@ const availableOptions = computed(() => {
allowedModels.add(model)
}
}
+ for (const model of probedModels.value) {
+ allowedModels.add(model)
+ if (!optionMap.has(model)) {
+ optionMap.set(model, { value: model, label: model })
+ }
+ }
- return allModels.filter(model => allowedModels.has(model.value))
+ return Array.from(optionMap.values()).filter(model => allowedModels.has(model.value))
})
const filteredModels = computed(() => {
@@ -287,4 +318,30 @@ const clearAll = () => {
emit('update:modelValue', [])
}
+const applyProbedModels = (models: string[]) => {
+ const nextModels = [...props.modelValue]
+ const nextProbedModels = [...probedModels.value]
+ let added = 0
+
+ for (const rawModel of models) {
+ const model = rawModel.trim()
+ if (!model) continue
+ if (!nextProbedModels.includes(model)) {
+ nextProbedModels.push(model)
+ }
+ if (!nextModels.includes(model)) {
+ nextModels.push(model)
+ added += 1
+ }
+ }
+
+ probedModels.value = nextProbedModels
+ emit('update:modelValue', nextModels)
+ if (added > 0) {
+ appStore.showSuccess(t('admin.accounts.modelProbe.addedModels', { count: added }))
+ } else {
+ appStore.showInfo(t('admin.accounts.modelExists'))
+ }
+}
+
diff --git a/frontend/src/components/account/__tests__/ModelProbeModal.spec.ts b/frontend/src/components/account/__tests__/ModelProbeModal.spec.ts
new file mode 100644
index 00000000000..697307d20d5
--- /dev/null
+++ b/frontend/src/components/account/__tests__/ModelProbeModal.spec.ts
@@ -0,0 +1,151 @@
+import { describe, expect, it, vi, beforeEach } from 'vitest'
+import { flushPromises, mount } from '@vue/test-utils'
+import { defineComponent } from 'vue'
+import ModelProbeModal from '../ModelProbeModal.vue'
+import { adminAPI } from '@/api/admin'
+
+const discoveredModelCount = 25
+
+const { probeModelListMock, probeModelsMock } = vi.hoisted(() => ({
+ probeModelListMock: vi.fn(),
+ probeModelsMock: vi.fn()
+}))
+
+vi.mock('@/api/admin', () => ({
+ adminAPI: {
+ accounts: {
+ probeModelList: probeModelListMock,
+ probeModels: probeModelsMock
+ }
+ }
+}))
+
+vi.mock('vue-i18n', async () => {
+ const actual = await vi.importActual('vue-i18n')
+ return {
+ ...actual,
+ useI18n: () => ({
+ t: (key: string, params?: Record) => params?.count !== undefined ? `${key}:${params.count}` : key
+ })
+ }
+})
+
+const BaseDialogStub = defineComponent({
+ name: 'BaseDialog',
+ props: { show: { type: Boolean, default: false } },
+ template: '
'
+})
+
+function mountModal() {
+ return mount(ModelProbeModal, {
+ props: {
+ show: true,
+ defaultPlatform: 'openai'
+ },
+ global: {
+ stubs: {
+ BaseDialog: BaseDialogStub,
+ ModelIcon: true,
+ Icon: true
+ }
+ }
+ })
+}
+
+function findButton(wrapper: ReturnType, text: string) {
+ return wrapper.findAll('button').find(button => button.text().includes(text))!
+}
+
+describe('ModelProbeModal', () => {
+ beforeEach(() => {
+ probeModelListMock.mockReset()
+ probeModelsMock.mockReset()
+ probeModelListMock.mockResolvedValue({
+ models: Array.from({ length: discoveredModelCount }, (_, index) => ({ id: `model-${index + 1}` }))
+ })
+ probeModelsMock.mockResolvedValue({
+ results: [
+ { model: 'model-1', mode: 'responses', ok: true, status: 200 },
+ { model: 'model-2', mode: 'responses', ok: false, status: 404, error: 'not found' }
+ ]
+ })
+ })
+
+ it('发现、验证并只应用验证通过的模型', async () => {
+ const wrapper = mountModal()
+
+ const inputs = wrapper.findAll('input')
+ await inputs[1].setValue('sk-test')
+
+ await findButton(wrapper, 'admin.accounts.modelProbe.discover').trigger('click')
+ await flushPromises()
+
+ expect(adminAPI.accounts.probeModelList).toHaveBeenCalledWith({
+ platform: 'openai',
+ base_url: '',
+ api_key: 'sk-test'
+ })
+ expect(wrapper.text()).toContain('model-25')
+
+ await findButton(wrapper, 'admin.accounts.modelProbe.testSelected').trigger('click')
+ await flushPromises()
+
+ expect(adminAPI.accounts.probeModels).toHaveBeenCalledWith({
+ platform: 'openai',
+ base_url: '',
+ api_key: 'sk-test',
+ mode: 'responses',
+ models: Array.from({ length: 20 }, (_, index) => `model-${index + 1}`)
+ })
+
+ await findButton(wrapper, 'admin.accounts.modelProbe.applyModels').trigger('click')
+
+ expect(wrapper.emitted('apply')?.[0]).toEqual([['model-1']])
+ })
+
+ it('验证全部失败后不允许应用选中模型', async () => {
+ probeModelsMock.mockResolvedValue({
+ results: [
+ { model: 'gpt-5.4', mode: 'responses', ok: false, status: 404, error: 'not found' }
+ ]
+ })
+ const wrapper = mountModal()
+
+ const inputs = wrapper.findAll('input')
+ await inputs[1].setValue('sk-test')
+ await findButton(wrapper, 'admin.accounts.modelProbe.discover').trigger('click')
+ await flushPromises()
+ await findButton(wrapper, 'admin.accounts.modelProbe.testSelected').trigger('click')
+ await flushPromises()
+
+ const applyButton = findButton(wrapper, 'admin.accounts.modelProbe.applyModels')
+ expect(applyButton.attributes('disabled')).toBeDefined()
+ })
+
+ it('验证后只应用当前仍选中的成功模型', async () => {
+ probeModelListMock.mockResolvedValue({
+ models: [
+ { id: 'model-1' },
+ { id: 'model-2' }
+ ]
+ })
+ probeModelsMock.mockResolvedValue({
+ results: [
+ { model: 'model-1', mode: 'responses', ok: true, status: 200 },
+ { model: 'model-2', mode: 'responses', ok: true, status: 200 }
+ ]
+ })
+ const wrapper = mountModal()
+
+ const inputs = wrapper.findAll('input')
+ await inputs[1].setValue('sk-test')
+ await findButton(wrapper, 'admin.accounts.modelProbe.discover').trigger('click')
+ await flushPromises()
+ await findButton(wrapper, 'admin.accounts.modelProbe.testSelected').trigger('click')
+ await flushPromises()
+ await findButton(wrapper, 'model-2').trigger('click')
+ await findButton(wrapper, 'admin.accounts.modelProbe.applyModels').trigger('click')
+
+ expect(wrapper.emitted('apply')?.[0]).toEqual([['model-1']])
+ })
+})
diff --git a/frontend/src/components/account/__tests__/ModelWhitelistSelector.spec.ts b/frontend/src/components/account/__tests__/ModelWhitelistSelector.spec.ts
new file mode 100644
index 00000000000..f0933bf5fac
--- /dev/null
+++ b/frontend/src/components/account/__tests__/ModelWhitelistSelector.spec.ts
@@ -0,0 +1,117 @@
+import { describe, expect, it, vi } from 'vitest'
+import { mount } from '@vue/test-utils'
+import ModelWhitelistSelector from '../ModelWhitelistSelector.vue'
+
+const { showSuccessMock, showInfoMock } = vi.hoisted(() => {
+ Object.defineProperty(globalThis, 'localStorage', {
+ value: {
+ getItem: vi.fn(),
+ setItem: vi.fn(),
+ removeItem: vi.fn(),
+ clear: vi.fn()
+ },
+ configurable: true
+ })
+
+ return {
+ showSuccessMock: vi.fn(),
+ showInfoMock: vi.fn()
+ }
+})
+
+vi.mock('@/stores/app', () => ({
+ useAppStore: () => ({
+ showSuccess: showSuccessMock,
+ showInfo: showInfoMock
+ })
+}))
+
+vi.mock('@/api/admin', () => ({
+ adminAPI: {
+ accounts: {
+ probeModelList: vi.fn(),
+ probeModels: vi.fn()
+ }
+ }
+}))
+
+vi.mock('@/api/admin/index', () => ({
+ adminAPI: {
+ accounts: {
+ probeModelList: vi.fn(),
+ probeModels: vi.fn()
+ }
+ }
+}))
+
+vi.mock('@/api/admin/index.ts', () => ({
+ adminAPI: {
+ accounts: {
+ probeModelList: vi.fn(),
+ probeModels: vi.fn()
+ }
+ }
+}))
+
+vi.mock('@/utils/apiError', () => ({
+ extractApiErrorMessage: (_err: unknown, fallback: string) => fallback
+}))
+
+vi.mock('@/components/account/ModelProbeModal.vue', () => ({
+ default: {
+ name: 'ModelProbeModal',
+ props: ['show', 'defaultPlatform'],
+ emits: ['close', 'apply'],
+ template: `
+
+
+
+ `
+ }
+}))
+
+vi.mock('../ModelProbeModal.vue', () => ({
+ default: {
+ name: 'ModelProbeModal',
+ props: ['show', 'defaultPlatform'],
+ emits: ['close', 'apply'],
+ template: `
+
+
+
+ `
+ }
+}))
+
+vi.mock('vue-i18n', async () => {
+ const actual = await vi.importActual('vue-i18n')
+ return {
+ ...actual,
+ useI18n: () => ({
+ t: (key: string, params?: Record) => params?.count !== undefined ? `${key}:${params.count}` : key
+ })
+ }
+})
+
+describe('ModelWhitelistSelector', () => {
+ it('通过探测弹窗把模型合并到白名单', async () => {
+ const wrapper = mount(ModelWhitelistSelector, {
+ props: {
+ modelValue: ['gpt-5.4'],
+ platform: 'openai'
+ },
+ global: {
+ stubs: {
+ ModelIcon: true,
+ Icon: true
+ }
+ }
+ })
+
+ await wrapper.findAll('button').find(button => button.text().includes('admin.accounts.modelProbe.openButton'))!.trigger('click')
+ await wrapper.get('[data-test="probe-modal"] button').trigger('click')
+
+ expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([['gpt-5.4', 'gpt-5.4-openai-compact']])
+ expect(showSuccessMock).toHaveBeenCalledWith('admin.accounts.modelProbe.addedModels:1')
+ })
+})
From 2987ba8fe2a570210980ae62361b29271a5a0e97 Mon Sep 17 00:00:00 2001
From: miaoheng <2020745908@qq.com>
Date: Fri, 29 May 2026 14:38:17 +0800
Subject: [PATCH 3/4] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E6=A8=A1?=
=?UTF-8?q?=E5=9E=8B=E5=B9=BF=E5=9C=BA=E4=B8=8E=E5=8F=AF=E7=94=A8=E6=B8=A0?=
=?UTF-8?q?=E9=81=93=E5=85=A5=E5=8F=A3?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../handler/available_channel_handler.go | 87 ++++++
.../handler/available_channel_handler_test.go | 83 ++++++
frontend/src/components/layout/AppSidebar.vue | 5 +-
.../__tests__/useRoutePrefetch.spec.ts | 51 ++++
frontend/src/composables/useRoutePrefetch.ts | 10 +-
frontend/src/i18n/locales/en.ts | 73 ++++-
frontend/src/i18n/locales/zh.ts | 73 ++++-
frontend/src/router/__tests__/guards.spec.ts | 24 ++
frontend/src/router/index.ts | 25 ++
frontend/src/router/meta.d.ts | 6 +
.../src/utils/__tests__/modelMarket.spec.ts | 244 +++++++++++++++
frontend/src/utils/modelMarket.ts | 277 ++++++++++++++++++
frontend/src/views/user/ModelMarketView.vue | 235 +++++++++++++++
13 files changed, 1181 insertions(+), 12 deletions(-)
create mode 100644 frontend/src/utils/__tests__/modelMarket.spec.ts
create mode 100644 frontend/src/utils/modelMarket.ts
create mode 100644 frontend/src/views/user/ModelMarketView.vue
diff --git a/backend/internal/handler/available_channel_handler.go b/backend/internal/handler/available_channel_handler.go
index 8982b80defc..8997a741e99 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"
@@ -153,6 +154,15 @@ func (h *AvailableChannelHandler) List(c *gin.Context) {
continue
}
sections := buildPlatformSections(ch, visibleGroups)
+ if !ch.RestrictModels {
+ visibleRefs := filterAvailableGroupRefs(ch.Groups, allowedGroupIDs)
+ groupModels, err := h.channelService.ListSupportedModelsForGroups(c.Request.Context(), visibleRefs)
+ if err != nil {
+ response.ErrorFrom(c, err)
+ return
+ }
+ mergeGroupSupportedModels(sections, groupModels)
+ }
if len(sections) == 0 {
continue
}
@@ -202,6 +212,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 +297,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..4a6398f42a0 100644
--- a/backend/internal/handler/available_channel_handler_test.go
+++ b/backend/internal/handler/available_channel_handler_test.go
@@ -155,3 +155,86 @@ 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 supportedModelNames(models []userSupportedModel) []string {
+ names := make([]string, 0, len(models))
+ for _, model := range models {
+ names = append(names, model.Name)
+ }
+ return names
+}
diff --git a/frontend/src/components/layout/AppSidebar.vue b/frontend/src/components/layout/AppSidebar.vue
index 3d7f1604c7e..faecb245577 100644
--- a/frontend/src/components/layout/AppSidebar.vue
+++ b/frontend/src/components/layout/AppSidebar.vue
@@ -657,8 +657,8 @@ const flagAdminPayment = () => adminSettingsStore.paymentEnabled
// buildSelfNavItems 构造用户自己的导航项(用户端主菜单和管理员的"我的账户"子菜单共享这组声明)。
// withDashboard=true 时包含仪表盘(用户端),false 时不含(管理员的个人区已经有独立仪表盘入口)。
//
-// 条目顺序:密钥 → 用量 → 可用渠道 → 渠道状态 → 订阅/支付 → 兑换/资料。
-// 可用渠道紧挨渠道状态之上,让用户"先看自己能用什么、再看对应状态"。
+// 条目顺序:密钥 → 用量 → 模型广场/可用渠道 → 渠道状态 → 订阅/支付 → 兑换/资料。
+// 模型广场和可用渠道共享可用渠道开关,先给用户展示"能用什么",再展示运行状态。
function buildSelfNavItems(withDashboard: boolean): NavItem[] {
const items: NavItem[] = []
if (withDashboard) {
@@ -667,6 +667,7 @@ function buildSelfNavItems(withDashboard: boolean): NavItem[] {
items.push(
{ path: '/keys', label: t('nav.apiKeys'), icon: KeyIcon },
{ path: '/usage', label: t('nav.usage'), icon: ChartIcon, hideInSimpleMode: true },
+ { path: '/models', label: t('nav.modelMarket'), icon: GlobeIcon, hideInSimpleMode: true, featureFlag: flagAvailableChannels },
{ path: '/available-channels', label: t('nav.availableChannels'), icon: ChannelIcon, hideInSimpleMode: true, featureFlag: flagAvailableChannels },
{ path: '/monitor', label: t('nav.channelStatus'), icon: SignalIcon, featureFlag: flagChannelMonitor },
{ path: '/subscriptions', label: t('nav.mySubscriptions'), icon: CreditCardIcon, hideInSimpleMode: true },
diff --git a/frontend/src/composables/__tests__/useRoutePrefetch.spec.ts b/frontend/src/composables/__tests__/useRoutePrefetch.spec.ts
index 95a7716fce3..5296e566b04 100644
--- a/frontend/src/composables/__tests__/useRoutePrefetch.spec.ts
+++ b/frontend/src/composables/__tests__/useRoutePrefetch.spec.ts
@@ -3,7 +3,9 @@
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import type { RouteLocationNormalized, Router, RouteRecordNormalized } from 'vue-router'
+import { setActivePinia, createPinia } from 'pinia'
+import { useAppStore } from '@/stores/app'
import { useRoutePrefetch, _adminPrefetchMap, _userPrefetchMap } from '../useRoutePrefetch'
// Mock 路由对象
@@ -33,6 +35,8 @@ const createMockRouter = (): Router => {
{ path: '/dashboard', components: { default: mockImportFn } },
{ path: '/keys', components: { default: mockImportFn } },
{ path: '/usage', components: { default: mockImportFn } },
+ { path: '/models', components: { default: mockImportFn }, meta: { requiresAvailableChannels: true } },
+ { path: '/available-channels', components: { default: mockImportFn }, meta: { requiresAvailableChannels: true } },
{ path: '/redeem', components: { default: mockImportFn } },
{ path: '/profile', components: { default: mockImportFn } }
]
@@ -48,6 +52,41 @@ describe('useRoutePrefetch', () => {
let mockRouter: Router
beforeEach(() => {
+ setActivePinia(createPinia())
+ const appStore = useAppStore()
+ appStore.cachedPublicSettings = {
+ site_name: 'Sub2API',
+ site_logo: '',
+ api_base_url: '',
+ contact_info: '',
+ doc_url: '',
+ home_content: '',
+ hide_ccs_import_button: false,
+ payment_enabled: false,
+ table_default_page_size: 20,
+ table_page_size_options: [10, 20, 50, 100],
+ custom_menu_items: [],
+ custom_endpoints: [],
+ linuxdo_oauth_enabled: false,
+ wechat_oauth_enabled: false,
+ wechat_oauth_open_enabled: false,
+ wechat_oauth_mp_enabled: false,
+ wechat_oauth_mobile_enabled: false,
+ oidc_oauth_enabled: false,
+ oidc_oauth_provider_name: 'OIDC',
+ github_oauth_enabled: false,
+ google_oauth_enabled: false,
+ backend_mode_enabled: false,
+ version: '',
+ balance_low_notify_enabled: false,
+ account_quota_notify_enabled: false,
+ balance_low_notify_threshold: 0,
+ channel_monitor_enabled: true,
+ channel_monitor_default_interval_seconds: 60,
+ available_channels_enabled: true,
+ risk_control_enabled: false,
+ affiliate_enabled: false
+ }
mockRouter = createMockRouter()
// 保存原始函数
@@ -102,6 +141,18 @@ describe('useRoutePrefetch', () => {
expect(config).toHaveLength(2)
})
+ it('可用渠道开关关闭时不预加载模型广场和可用渠道页面', () => {
+ const appStore = useAppStore()
+ appStore.cachedPublicSettings = {
+ ...appStore.cachedPublicSettings!,
+ available_channels_enabled: false
+ }
+ const { _getPrefetchConfig } = useRoutePrefetch(mockRouter)
+
+ expect(_getPrefetchConfig(createMockRoute('/keys'))).toHaveLength(2)
+ expect(_getPrefetchConfig(createMockRoute('/models'))).toHaveLength(1)
+ })
+
it('未定义的路由应该返回空数组', () => {
const { _getPrefetchConfig } = useRoutePrefetch(mockRouter)
const route = createMockRoute('/unknown-route')
diff --git a/frontend/src/composables/useRoutePrefetch.ts b/frontend/src/composables/useRoutePrefetch.ts
index b1b5a03215d..83686c99c6c 100644
--- a/frontend/src/composables/useRoutePrefetch.ts
+++ b/frontend/src/composables/useRoutePrefetch.ts
@@ -9,6 +9,7 @@
*/
import { ref, readonly } from 'vue'
import type { RouteLocationNormalized, Router } from 'vue-router'
+import { FeatureFlags, isFeatureFlagEnabled } from '@/utils/featureFlags'
/**
* 组件导入函数类型
@@ -28,8 +29,10 @@ const PREFETCH_ADJACENCY: Record = {
'/admin/subscriptions': ['/admin/groups', '/admin/redeem'],
// User routes
'/dashboard': ['/keys', '/usage'],
- '/keys': ['/dashboard', '/usage'],
- '/usage': ['/keys', '/redeem'],
+ '/keys': ['/dashboard', '/usage', '/models'],
+ '/usage': ['/keys', '/models', '/redeem'],
+ '/models': ['/available-channels', '/keys'],
+ '/available-channels': ['/models', '/monitor'],
'/redeem': ['/usage', '/profile'],
'/profile': ['/dashboard', '/keys']
}
@@ -82,6 +85,9 @@ export function useRoutePrefetch(router?: Router) {
const routes = router.getRoutes()
const route = routes.find((r) => r.path === path)
+ if (route?.meta?.requiresAvailableChannels && !isFeatureFlagEnabled(FeatureFlags.availableChannels)) {
+ return null
+ }
if (route && route.components?.default) {
const component = route.components.default
diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts
index 1d94fa29e2b..a849aa7c2f7 100644
--- a/frontend/src/i18n/locales/en.ts
+++ b/frontend/src/i18n/locales/en.ts
@@ -360,6 +360,7 @@ export default {
users: 'Users',
groups: 'Groups',
channels: 'Channels',
+ modelMarket: 'Model Market',
availableChannels: 'Available Channels',
subscriptions: 'Subscriptions',
accounts: 'Accounts',
@@ -1049,6 +1050,34 @@ export default {
}
},
+ modelMarket: {
+ title: 'Model Market',
+ description: 'Browse channels, models, and pricing by accessible group',
+ searchPlaceholder: 'Search models, channels, or groups...',
+ empty: 'No available models',
+ pricingConfigured: 'Configured',
+ pricingVaries: 'Pricing varies by channel',
+ filters: {
+ allPlatforms: 'All platforms',
+ allChannels: 'All channels',
+ allPricing: 'All pricing',
+ withPricing: 'Pricing configured',
+ withoutPricing: 'No pricing'
+ },
+ summary: {
+ groups: '{count} groups',
+ models: '{count} models',
+ channels: '{count} channels'
+ },
+ columns: {
+ group: 'Group',
+ platform: 'Platform',
+ channels: 'Channels',
+ models: 'Models',
+ pricing: 'Pricing'
+ }
+ },
+
affiliate: {
title: 'Affiliate Rebates',
description: 'Invite new users and convert your rebate quota into account balance',
@@ -3403,6 +3432,40 @@ export default {
addModel: 'Add',
modelExists: 'Model already exists',
modelCount: '{count} models',
+ modelProbe: {
+ title: 'Model probe',
+ openButton: 'Probe models',
+ platform: 'Platform',
+ baseUrl: 'Base URL',
+ apiKey: 'API Key',
+ secretHint: 'Used only for this probe and not saved to the account.',
+ discover: 'Discover models',
+ discovering: 'Discovering...',
+ testSelected: 'Test selected',
+ testing: 'Testing...',
+ selectAll: 'Select all',
+ clearSelected: 'Clear selected',
+ discoveredModels: '{count} models found',
+ noModels: 'No models discovered yet',
+ maxSelectionHint: 'A single test supports up to {count} models. The first {count} were selected automatically.',
+ mode: 'Test mode',
+ modeResponses: 'OpenAI Responses',
+ modeChatCompletions: 'Chat Completions',
+ modeGemini: 'Gemini generateContent',
+ modeAnthropic: 'Anthropic Messages',
+ responsesHint: 'Send a minimal ping request to /v1/responses.',
+ chatCompletionsHint: 'Send a minimal ping request to /v1/chat/completions for older compatible providers.',
+ geminiHint: 'Send a minimal ping request through generateContent.',
+ anthropicHint: 'Anthropic discovery uses the trusted built-in list; tests use a minimal Messages request.',
+ latestResults: 'Latest results',
+ ok: 'OK',
+ failed: 'Failed',
+ allFailed: 'No selected model passed validation',
+ discoverFailed: 'Failed to discover models',
+ testFailed: 'Failed to test models',
+ applyModels: 'Add to whitelist ({count})',
+ addedModels: 'Added {count} model(s)'
+ },
poolMode: 'Pool Mode',
poolModeHint: 'Enable when upstream is an account pool; errors won\'t mark local account status',
poolModeInfo:
@@ -5271,11 +5334,11 @@ export default {
defaultIntervalHint: 'Pre-fills the interval when creating a new monitor; each monitor can override it. Range 15 – 3600.',
},
availableChannels: {
- title: 'Available Channels',
- description: 'Show logged-in users an aggregate view of the channels, models and pricing they can access. Disabled by default.',
+ title: 'Model Market and Available Channels',
+ description: 'Show logged-in users the channels, models, and pricing available to their account. Disabled by default.',
configureLink: 'Configure model pricing in Channel Management > Channel Pricing',
- enabled: 'Enable Available Channels',
- enabledHint: 'When off, the sidebar entry is hidden and the endpoint returns an empty list.',
+ enabled: 'Enable Model Market and Available Channels',
+ enabledHint: 'When off, the user sidebar entries for Model Market and Available Channels are hidden and the endpoint returns an empty list.',
},
riskControl: {
title: 'Risk Control',
@@ -5564,6 +5627,8 @@ export default {
antigravityUserAgentVersion: 'Antigravity UA Version',
antigravityUserAgentVersionPlaceholder: '1.23.2',
antigravityUserAgentVersionHint: 'Leave empty to use ANTIGRAVITY_USER_AGENT_VERSION or the built-in default 1.23.2; when set, the admin setting takes precedence.',
+ openAIImagesResponsesReasoningEffort: 'OpenAI Image Bridge Reasoning Effort',
+ openAIImagesResponsesReasoningEffortHint: 'Only affects the reasoning.effort value used when the OAuth image bridge calls the Responses API. Options: low, medium, high, xhigh. Default: medium.',
openaiCodexUserAgent: 'OpenAI Codex UA',
openaiCodexUserAgentPlaceholder: 'codex-tui/0.125.0 (Ubuntu 22.4.0; x86_64) xterm-256color (codex-tui; 0.125.0)',
openaiCodexUserAgentHint: 'Used to bypass Cloudflare browser-UA challenges on the OpenAI upstream. Only applies when the client User-Agent is detected as a browser (Mozilla/...). Leave empty to use the built-in default.',
diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts
index 8fa15e72441..793819972ce 100644
--- a/frontend/src/i18n/locales/zh.ts
+++ b/frontend/src/i18n/locales/zh.ts
@@ -360,6 +360,7 @@ export default {
users: '用户管理',
groups: '分组管理',
channels: '渠道管理',
+ modelMarket: '模型广场',
availableChannels: '可用渠道',
subscriptions: '订阅管理',
accounts: '账号管理',
@@ -1053,6 +1054,34 @@ export default {
}
},
+ modelMarket: {
+ title: '模型广场',
+ description: '按分组查看您可访问的渠道、模型与定价',
+ searchPlaceholder: '搜索模型、渠道或分组...',
+ empty: '暂无可用模型',
+ pricingConfigured: '已配置',
+ pricingVaries: '多渠道定价不同',
+ filters: {
+ allPlatforms: '全部平台',
+ allChannels: '全部渠道',
+ allPricing: '全部定价',
+ withPricing: '已配置定价',
+ withoutPricing: '未配置定价'
+ },
+ summary: {
+ groups: '{count} 个分组',
+ models: '{count} 个模型',
+ channels: '{count} 个渠道'
+ },
+ columns: {
+ group: '分组',
+ platform: '平台',
+ channels: '可用渠道',
+ models: '支持模型',
+ pricing: '定价'
+ }
+ },
+
affiliate: {
title: '邀请返利',
description: '邀请新用户注册,并将返利额度转入账户余额',
@@ -3547,6 +3576,40 @@ export default {
addModel: '填入',
modelExists: '该模型已存在',
modelCount: '{count} 个模型',
+ modelProbe: {
+ title: '模型探测',
+ openButton: '探测模型',
+ platform: '平台',
+ baseUrl: 'Base URL',
+ apiKey: 'API Key',
+ secretHint: '仅用于本次探测,不会保存到账号配置。',
+ discover: '发现模型',
+ discovering: '发现中...',
+ testSelected: '验证选中',
+ testing: '验证中...',
+ selectAll: '全选',
+ clearSelected: '清空选择',
+ discoveredModels: '发现 {count} 个模型',
+ noModels: '还没有发现模型',
+ maxSelectionHint: '一次最多验证 {count} 个模型,已自动选择前 {count} 个。',
+ mode: '验证方式',
+ modeResponses: 'OpenAI Responses',
+ modeChatCompletions: 'Chat Completions',
+ modeGemini: 'Gemini generateContent',
+ modeAnthropic: 'Anthropic Messages',
+ responsesHint: '使用 /v1/responses 发送最小 ping 请求。',
+ chatCompletionsHint: '使用 /v1/chat/completions 发送最小 ping 请求,适合旧兼容平台。',
+ geminiHint: '使用 generateContent 发送最小 ping 请求。',
+ anthropicHint: 'Anthropic 模型列表使用内置可信列表,验证时发送 Messages 最小请求。',
+ latestResults: '最近验证结果',
+ ok: '通过',
+ failed: '失败',
+ allFailed: '选中模型均未验证通过',
+ discoverFailed: '发现模型失败',
+ testFailed: '验证模型失败',
+ applyModels: '加入白名单({count})',
+ addedModels: '已加入 {count} 个模型'
+ },
poolMode: '池模式',
poolModeHint: '上游为账号池时启用,错误不标记本地账号状态',
poolModeInfo:
@@ -5434,11 +5497,11 @@ export default {
defaultIntervalHint: '新建渠道监控时表单的默认值,可被单个渠道覆盖。范围 15 – 3600 秒。',
},
availableChannels: {
- title: '可用渠道',
- description: '向已登录用户展示他们能访问的渠道、模型和定价聚合视图。默认关闭。',
+ title: '模型广场与可用渠道',
+ description: '向已登录用户展示其账号可访问的渠道、模型和定价聚合视图。默认关闭。',
configureLink: '前往 渠道管理 > 渠道定价 配置模型价格',
- enabled: '启用可用渠道',
- enabledHint: '关闭后用户端侧边栏入口隐藏,接口返回空数组。',
+ enabled: '启用模型广场与可用渠道',
+ enabledHint: '关闭后用户端“模型广场”和“可用渠道”入口隐藏,接口返回空数组。',
},
riskControl: {
title: '风控中心',
@@ -5721,6 +5784,8 @@ export default {
antigravityUserAgentVersion: 'Antigravity UA 版本',
antigravityUserAgentVersionPlaceholder: '1.23.2',
antigravityUserAgentVersionHint: '留空时使用 ANTIGRAVITY_USER_AGENT_VERSION 或内置默认值 1.23.2;填写后后台设置优先。',
+ openAIImagesResponsesReasoningEffort: 'OpenAI 图片桥接推理强度',
+ openAIImagesResponsesReasoningEffortHint: '仅影响 OAuth 图片桥接走 Responses API 时写入 reasoning.effort 的值。可选 low、medium、high、xhigh;默认 medium。',
openaiCodexUserAgent: 'OpenAI Codex UA',
openaiCodexUserAgentPlaceholder: 'codex-tui/0.125.0 (Ubuntu 22.4.0; x86_64) xterm-256color (codex-tui; 0.125.0)',
openaiCodexUserAgentHint: '用于规避 OpenAI 上游 Cloudflare 对浏览器 UA 的访问质询。仅在检测到客户端 User-Agent 为浏览器(Mozilla/...)时生效,其他客户端原样透传。留空使用内置默认值。',
diff --git a/frontend/src/router/__tests__/guards.spec.ts b/frontend/src/router/__tests__/guards.spec.ts
index a27aaa127cd..3a80caa65d2 100644
--- a/frontend/src/router/__tests__/guards.spec.ts
+++ b/frontend/src/router/__tests__/guards.spec.ts
@@ -54,6 +54,7 @@ interface MockAuthState {
isSimpleMode: boolean
backendModeEnabled: boolean
hasPendingAuthSession: boolean
+ availableChannelsEnabled?: boolean
setupNeedsSetup?: boolean
}
@@ -114,6 +115,10 @@ function simulateGuard(
return '/dashboard'
}
+ if (toMeta.requiresAvailableChannels && authState.availableChannelsEnabled !== true) {
+ return authState.isAdmin ? '/admin/settings' : '/dashboard'
+ }
+
// 简易模式限制
if (authState.isSimpleMode) {
const restrictedPaths = [
@@ -226,6 +231,20 @@ describe('路由守卫逻辑', () => {
const redirect = simulateGuard('/admin/users', { requiresAdmin: true }, authState)
expect(redirect).toBe('/dashboard')
})
+
+ it('可用渠道关闭时访问 /models 重定向到 /dashboard', () => {
+ const redirect = simulateGuard('/models', { requiresAvailableChannels: true }, authState)
+ expect(redirect).toBe('/dashboard')
+ })
+
+ it('可用渠道开启时访问 /models 允许通过', () => {
+ const redirect = simulateGuard(
+ '/models',
+ { requiresAvailableChannels: true },
+ { ...authState, availableChannelsEnabled: true }
+ )
+ expect(redirect).toBeNull()
+ })
})
// --- 已认证管理员 ---
@@ -253,6 +272,11 @@ describe('路由守卫逻辑', () => {
const redirect = simulateGuard('/dashboard', {}, authState)
expect(redirect).toBeNull()
})
+
+ it('可用渠道关闭时访问 /models 重定向到 /admin/settings', () => {
+ const redirect = simulateGuard('/models', { requiresAvailableChannels: true }, authState)
+ expect(redirect).toBe('/admin/settings')
+ })
})
// --- 简易模式 ---
diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts
index 3979ac70aff..57261186e03 100644
--- a/frontend/src/router/index.ts
+++ b/frontend/src/router/index.ts
@@ -10,6 +10,7 @@ import { useAdminSettingsStore } from '@/stores/adminSettings'
import { useNavigationLoadingState } from '@/composables/useNavigationLoading'
import { useRoutePrefetch } from '@/composables/useRoutePrefetch'
import { getSetupStatus } from '@/api/setup'
+import { FeatureFlags, isFeatureFlagEnabled } from '@/utils/featureFlags'
import { resolveCompletedSetupRedirectPath } from './setupRedirect'
import { resolveDocumentTitle } from './title'
@@ -247,11 +248,25 @@ const routes: RouteRecordRaw[] = [
meta: {
requiresAuth: true,
requiresAdmin: false,
+ requiresAvailableChannels: true,
title: 'Available Channels',
titleKey: 'availableChannels.title',
descriptionKey: 'availableChannels.description'
}
},
+ {
+ path: '/models',
+ name: 'ModelMarket',
+ component: () => import('@/views/user/ModelMarketView.vue'),
+ meta: {
+ requiresAuth: true,
+ requiresAdmin: false,
+ requiresAvailableChannels: true,
+ title: 'Model Market',
+ titleKey: 'modelMarket.title',
+ descriptionKey: 'modelMarket.description'
+ }
+ },
{
path: '/profile',
name: 'Profile',
@@ -825,6 +840,16 @@ router.beforeEach(async (to, _from, next) => {
}
}
+ if (to.meta.requiresAvailableChannels) {
+ if (!appStore.cachedPublicSettings) {
+ await appStore.fetchPublicSettings()
+ }
+ if (!isFeatureFlagEnabled(FeatureFlags.availableChannels)) {
+ next(authStore.isAdmin ? '/admin/settings' : '/dashboard')
+ return
+ }
+ }
+
// 简易模式下限制访问某些页面
if (authStore.isSimpleMode) {
const restrictedPaths = [
diff --git a/frontend/src/router/meta.d.ts b/frontend/src/router/meta.d.ts
index 5c468016457..7fd4dc8d929 100644
--- a/frontend/src/router/meta.d.ts
+++ b/frontend/src/router/meta.d.ts
@@ -55,6 +55,12 @@ declare module 'vue-router' {
*/
requiresRiskControl?: boolean
+ /**
+ * 是否要求可用渠道功能开关已启用
+ * @default false
+ */
+ requiresAvailableChannels?: boolean
+
/**
* i18n key for the page title
*/
diff --git a/frontend/src/utils/__tests__/modelMarket.spec.ts b/frontend/src/utils/__tests__/modelMarket.spec.ts
new file mode 100644
index 00000000000..a39f2763aa7
--- /dev/null
+++ b/frontend/src/utils/__tests__/modelMarket.spec.ts
@@ -0,0 +1,244 @@
+import { describe, expect, it } from 'vitest'
+
+import { buildModelMarketItems, filterModelMarketItems, getModelMarketFilterOptions } from '@/utils/modelMarket'
+import type { UserAvailableChannel } from '@/api/channels'
+
+const channels: UserAvailableChannel[] = [
+ {
+ name: '标准渠道',
+ description: '公开可用',
+ platforms: [
+ {
+ platform: 'openai',
+ groups: [
+ {
+ id: 2,
+ name: 'OpenAI Pro',
+ platform: 'openai',
+ subscription_type: 'standard',
+ rate_multiplier: 1.2,
+ is_exclusive: false
+ }
+ ],
+ supported_models: [
+ {
+ name: 'gpt-4o-mini',
+ platform: 'openai',
+ pricing: {
+ billing_mode: 'token',
+ input_price: 0.000001,
+ output_price: 0.000002,
+ cache_write_price: null,
+ cache_read_price: null,
+ image_output_price: null,
+ per_request_price: null,
+ intervals: []
+ }
+ },
+ {
+ name: 'gpt-image-2',
+ platform: 'openai',
+ pricing: null
+ }
+ ]
+ }
+ ]
+ },
+ {
+ name: '专属渠道',
+ description: '定向授权',
+ platforms: [
+ {
+ platform: 'openai',
+ groups: [
+ {
+ id: 3,
+ name: 'OpenAI VIP',
+ platform: 'openai',
+ subscription_type: 'subscription',
+ rate_multiplier: 0.8,
+ is_exclusive: true
+ }
+ ],
+ supported_models: [
+ {
+ name: 'GPT-4O-MINI',
+ platform: 'openai',
+ pricing: null
+ }
+ ]
+ },
+ {
+ platform: 'anthropic',
+ groups: [
+ {
+ id: 1,
+ name: 'Claude',
+ platform: 'anthropic',
+ subscription_type: 'standard',
+ rate_multiplier: 1,
+ is_exclusive: false
+ }
+ ],
+ supported_models: [
+ {
+ name: 'claude-sonnet-4-5',
+ platform: 'anthropic',
+ pricing: null
+ }
+ ]
+ }
+ ]
+ }
+]
+
+describe('modelMarket', () => {
+ it('按分组聚合渠道与模型,不把同一模型跨分组合并成一行', () => {
+ const items = buildModelMarketItems(channels)
+
+ expect(items.map((item) => item.group.name)).toEqual(['Claude', 'OpenAI VIP', 'OpenAI Pro'])
+
+ const pro = items.find((item) => item.group.name === 'OpenAI Pro')
+ const vip = items.find((item) => item.group.name === 'OpenAI VIP')
+
+ expect(pro).toMatchObject({
+ platform: 'openai',
+ channel_count: 1,
+ model_count: 2,
+ has_pricing: true
+ })
+ expect(pro?.channels.map((ch) => ch.name)).toEqual(['标准渠道'])
+ expect(pro?.models.map((model) => model.name)).toEqual(['gpt-4o-mini', 'gpt-image-2'])
+
+ expect(vip).toMatchObject({
+ platform: 'openai',
+ channel_count: 1,
+ model_count: 1,
+ has_pricing: false
+ })
+ expect(vip?.channels.map((ch) => ch.name)).toEqual(['专属渠道'])
+ expect(vip?.models.map((model) => model.name)).toEqual(['GPT-4O-MINI'])
+ })
+
+ it('同一分组跨渠道同模型不同定价时只保留定价状态,避免展示任意单一价格', () => {
+ const items = buildModelMarketItems([
+ channels[0],
+ {
+ name: '备用渠道',
+ description: '不同价格',
+ platforms: [
+ {
+ platform: 'openai',
+ groups: channels[0].platforms[0].groups,
+ supported_models: [
+ {
+ name: 'gpt-4o-mini',
+ platform: 'openai',
+ pricing: {
+ billing_mode: 'token',
+ input_price: 0.000009,
+ output_price: 0.000012,
+ cache_write_price: null,
+ cache_read_price: null,
+ image_output_price: null,
+ per_request_price: null,
+ intervals: []
+ }
+ }
+ ]
+ }
+ ]
+ }
+ ])
+
+ const pro = items.find((item) => item.group.name === 'OpenAI Pro')
+ const model = pro?.models.find((m) => m.name === 'gpt-4o-mini')
+
+ expect(pro).toMatchObject({
+ channel_count: 2,
+ model_count: 2,
+ has_pricing: true
+ })
+ expect(model).toMatchObject({
+ name: 'gpt-4o-mini',
+ platform: 'openai',
+ pricing: null,
+ has_pricing: true,
+ pricing_conflict: true
+ })
+
+ expect(filterModelMarketItems(items, { search: '', platform: '', channel: '', pricing: 'with' })[0]?.models.map((m) => m.name)).toEqual([
+ 'gpt-4o-mini'
+ ])
+ })
+
+ it('同一分组跨渠道模型集合不同,按渠道过滤时只保留该渠道模型', () => {
+ const sharedGroup = channels[0].platforms[0].groups[0]
+ const items = buildModelMarketItems([
+ {
+ name: '渠道 A',
+ description: '只支持 A 模型',
+ platforms: [
+ {
+ platform: 'openai',
+ groups: [sharedGroup],
+ supported_models: [
+ {
+ name: 'model-a',
+ platform: 'openai',
+ pricing: null
+ }
+ ]
+ }
+ ]
+ },
+ {
+ name: '渠道 B',
+ description: '只支持 B 模型',
+ platforms: [
+ {
+ platform: 'openai',
+ groups: [sharedGroup],
+ supported_models: [
+ {
+ name: 'model-b',
+ platform: 'openai',
+ pricing: null
+ }
+ ]
+ }
+ ]
+ }
+ ])
+
+ const filtered = filterModelMarketItems(items, { search: '', platform: '', channel: '渠道 A', pricing: 'all' })
+
+ expect(filtered).toHaveLength(1)
+ expect(filtered[0]?.channels.map((channel) => channel.name)).toEqual(['渠道 A'])
+ expect(filtered[0]?.models.map((model) => model.name)).toEqual(['model-a'])
+ })
+
+ it('生成稳定的筛选项', () => {
+ const items = buildModelMarketItems(channels)
+ const options = getModelMarketFilterOptions(items)
+
+ expect(options.platforms).toEqual(['anthropic', 'openai'])
+ expect(options.channels).toEqual(['标准渠道', '专属渠道'])
+ })
+
+ it('按搜索、平台、渠道和定价状态过滤分组,并裁剪不匹配的模型', () => {
+ const items = buildModelMarketItems(channels)
+
+ expect(filterModelMarketItems(items, { search: 'sonnet', platform: '', channel: '', pricing: 'all' }).map((item) => item.group.name)).toEqual(['Claude'])
+ expect(filterModelMarketItems(items, { search: '', platform: 'anthropic', channel: '', pricing: 'all' }).map((item) => item.group.name)).toEqual(['Claude'])
+ expect(filterModelMarketItems(items, { search: '', platform: '', channel: '专属渠道', pricing: 'all' }).map((item) => item.group.name)).toEqual(['Claude', 'OpenAI VIP'])
+
+ const withPricing = filterModelMarketItems(items, { search: '', platform: '', channel: '', pricing: 'with' })
+ expect(withPricing.map((item) => item.group.name)).toEqual(['OpenAI Pro'])
+ expect(withPricing[0]?.models.map((model) => model.name)).toEqual(['gpt-4o-mini'])
+
+ const withoutPricing = filterModelMarketItems(items, { search: '', platform: '', channel: '', pricing: 'without' })
+ expect(withoutPricing.map((item) => item.group.name)).toEqual(['Claude', 'OpenAI VIP', 'OpenAI Pro'])
+ expect(withoutPricing.find((item) => item.group.name === 'OpenAI Pro')?.models.map((model) => model.name)).toEqual(['gpt-image-2'])
+ })
+})
diff --git a/frontend/src/utils/modelMarket.ts b/frontend/src/utils/modelMarket.ts
new file mode 100644
index 00000000000..1827058fe59
--- /dev/null
+++ b/frontend/src/utils/modelMarket.ts
@@ -0,0 +1,277 @@
+import type {
+ UserAvailableChannel,
+ UserAvailableGroup,
+ UserSupportedModel
+} from '@/api/channels'
+
+export type ModelMarketPricingFilter = 'all' | 'with' | 'without'
+
+export interface ModelMarketChannelRef {
+ name: string
+ description: string
+}
+
+export interface ModelMarketItem {
+ group: UserAvailableGroup
+ platform: string
+ channels: ModelMarketChannelRef[]
+ models: ModelMarketModel[]
+ channel_count: number
+ model_count: number
+ has_pricing: boolean
+}
+
+export interface ModelMarketModel extends UserSupportedModel {
+ channels: ModelMarketChannelRef[]
+ has_pricing: boolean
+ pricing_conflict: boolean
+}
+
+export interface ModelMarketFilters {
+ search: string
+ platform: string
+ channel: string
+ pricing: ModelMarketPricingFilter
+}
+
+export interface ModelMarketFilterOptions {
+ platforms: string[]
+ channels: string[]
+}
+
+interface ModelAccumulator extends ModelMarketItem {
+ modelPricingKeys: Map
+}
+
+export function buildModelMarketItems(channels: UserAvailableChannel[]): ModelMarketItem[] {
+ const byGroup = new Map()
+
+ for (const channel of channels) {
+ for (const section of channel.platforms) {
+ for (const group of section.groups) {
+ const item = getOrCreateGroupItem(byGroup, group, section.platform)
+ addChannel(item, channel)
+ addModels(item, section.supported_models, section.platform, channel)
+ }
+ }
+ }
+
+ return Array.from(byGroup.values())
+ .map(finalizeGroupItem)
+ .filter((item) => item.model_count > 0)
+ .sort(compareModelMarketItems)
+}
+
+export function filterModelMarketItems(items: ModelMarketItem[], filters: ModelMarketFilters): ModelMarketItem[] {
+ const search = filters.search.trim().toLowerCase()
+ return items
+ .map((item) => filterGroupItem(item, filters, search))
+ .filter((item): item is ModelMarketItem => item !== null)
+}
+
+export function getModelMarketFilterOptions(items: ModelMarketItem[]): ModelMarketFilterOptions {
+ return {
+ platforms: sortStrings(unique(items.map((item) => item.platform).filter(Boolean))),
+ channels: sortStrings(unique(items.flatMap((item) => item.channels.map((channel) => channel.name))))
+ }
+}
+
+export function countModelMarketModels(items: ModelMarketItem[]): number {
+ return items.reduce((count, item) => count + item.models.length, 0)
+}
+
+function getOrCreateGroupItem(
+ byGroup: Map,
+ group: UserAvailableGroup,
+ platform: string
+): ModelAccumulator {
+ let item = byGroup.get(group.id)
+ if (!item) {
+ item = {
+ group,
+ platform: group.platform || platform,
+ channels: [],
+ models: [],
+ channel_count: 0,
+ model_count: 0,
+ has_pricing: false,
+ modelPricingKeys: new Map()
+ }
+ byGroup.set(group.id, item)
+ }
+ return item
+}
+
+function addChannel(item: ModelMarketItem, channel: UserAvailableChannel) {
+ if (item.channels.some((existing) => existing.name === channel.name)) return
+ item.channels.push({
+ name: channel.name,
+ description: channel.description || ''
+ })
+}
+
+function addModels(
+ item: ModelAccumulator,
+ models: UserSupportedModel[],
+ platform: string,
+ channel: UserAvailableChannel
+) {
+ for (const model of models) {
+ const normalizedModel = {
+ ...model,
+ platform: model.platform || platform,
+ channels: [createChannelRef(channel)],
+ pricing_conflict: false,
+ has_pricing: model.pricing != null
+ }
+ const key = modelKey(normalizedModel.platform, normalizedModel.name)
+ const existingIndex = item.models.findIndex((existing) => modelKey(existing.platform, existing.name) === key)
+ if (existingIndex === -1) {
+ item.models.push(normalizedModel)
+ item.modelPricingKeys.set(key, pricingKey(normalizedModel))
+ } else {
+ const existing = item.models[existingIndex]
+ addModelChannel(existing, channel)
+ const previousPricingKey = item.modelPricingKeys.get(key) ?? null
+ const nextPricingKey = pricingKey(normalizedModel)
+ if (previousPricingKey !== nextPricingKey) {
+ item.models[existingIndex] = {
+ ...existing,
+ pricing: null,
+ has_pricing: existing.has_pricing || normalizedModel.has_pricing,
+ pricing_conflict: true
+ }
+ item.modelPricingKeys.set(key, null)
+ } else if (existing.pricing == null && normalizedModel.pricing != null) {
+ item.models[existingIndex] = {
+ ...existing,
+ ...normalizedModel,
+ channels: existing.channels,
+ has_pricing: true
+ }
+ }
+ }
+ if (normalizedModel.has_pricing) {
+ item.has_pricing = true
+ }
+ }
+}
+
+function finalizeGroupItem(item: ModelAccumulator): ModelMarketItem {
+ return {
+ group: item.group,
+ platform: item.platform,
+ channels: sortChannels(item.channels),
+ models: sortModels(item.models.map((model) => ({ ...model, channels: sortChannels(model.channels) }))),
+ channel_count: item.channels.length,
+ model_count: item.models.length,
+ has_pricing: item.has_pricing
+ }
+}
+
+function filterGroupItem(
+ item: ModelMarketItem,
+ filters: ModelMarketFilters,
+ search: string
+): ModelMarketItem | null {
+ if (filters.platform && item.platform !== filters.platform) return null
+
+ const models = item.models.filter((model) => modelMatches(model, item, filters, search))
+ if (models.length === 0) return null
+ const channels = channelsForModels(item.channels, models, filters.channel)
+
+ return {
+ ...item,
+ channels,
+ models,
+ channel_count: channels.length,
+ model_count: models.length,
+ has_pricing: models.some((model) => model.has_pricing)
+ }
+}
+
+function modelMatches(
+ model: ModelMarketModel,
+ item: ModelMarketItem,
+ filters: ModelMarketFilters,
+ search: string
+): boolean {
+ if (filters.channel && !model.channels.some((channel) => channel.name === filters.channel)) return false
+ if (filters.pricing === 'with' && !model.has_pricing) return false
+ if (filters.pricing === 'without' && model.has_pricing) return false
+ if (!search) return true
+
+ return (
+ item.group.name.toLowerCase().includes(search) ||
+ item.platform.toLowerCase().includes(search) ||
+ model.name.toLowerCase().includes(search) ||
+ model.platform.toLowerCase().includes(search) ||
+ model.channels.some((channel) =>
+ channel.name.toLowerCase().includes(search) ||
+ channel.description.toLowerCase().includes(search)
+ )
+ )
+}
+
+function channelsForModels(
+ channels: ModelMarketChannelRef[],
+ models: ModelMarketModel[],
+ channelFilter: string
+): ModelMarketChannelRef[] {
+ const modelChannelNames = new Set(models.flatMap((model) => model.channels.map((channel) => channel.name)))
+ return channels.filter((channel) =>
+ (!channelFilter || channel.name === channelFilter) &&
+ modelChannelNames.has(channel.name)
+ )
+}
+
+function sortChannels(channels: ModelMarketChannelRef[]): ModelMarketChannelRef[] {
+ return [...channels].sort((a, b) => compareStrings(a.name, b.name))
+}
+
+function createChannelRef(channel: UserAvailableChannel): ModelMarketChannelRef {
+ return {
+ name: channel.name,
+ description: channel.description || ''
+ }
+}
+
+function addModelChannel(model: ModelMarketModel, channel: UserAvailableChannel) {
+ if (model.channels.some((existing) => existing.name === channel.name)) return
+ model.channels.push(createChannelRef(channel))
+}
+
+function sortModels(models: ModelMarketModel[]): ModelMarketModel[] {
+ return [...models].sort((a, b) => {
+ const platform = compareStrings(a.platform, b.platform)
+ if (platform !== 0) return platform
+ return compareStrings(a.name, b.name)
+ })
+}
+
+function compareModelMarketItems(a: ModelMarketItem, b: ModelMarketItem): number {
+ const platform = compareStrings(a.platform, b.platform)
+ if (platform !== 0) return platform
+ if (a.group.is_exclusive !== b.group.is_exclusive) return a.group.is_exclusive ? -1 : 1
+ return compareStrings(a.group.name, b.group.name)
+}
+
+function modelKey(platform: string, model: string): string {
+ return `${platform.trim().toLowerCase()}::${model.trim().toLowerCase()}`
+}
+
+function pricingKey(model: UserSupportedModel): string | null {
+ return model.pricing == null ? null : JSON.stringify(model.pricing)
+}
+
+function unique(values: string[]): string[] {
+ return Array.from(new Set(values))
+}
+
+function sortStrings(values: string[]): string[] {
+ return [...values].sort(compareStrings)
+}
+
+function compareStrings(a: string, b: string): number {
+ return a.localeCompare(b)
+}
diff --git a/frontend/src/views/user/ModelMarketView.vue b/frontend/src/views/user/ModelMarketView.vue
new file mode 100644
index 00000000000..5fc361f0f5b
--- /dev/null
+++ b/frontend/src/views/user/ModelMarketView.vue
@@ -0,0 +1,235 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('modelMarket.summary.groups', { count: filteredItems.length }) }}
+
+
+ {{ t('modelMarket.summary.models', { count: filteredModelCount }) }}
+
+
+ {{ t('modelMarket.summary.channels', { count: channels.length }) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | {{ t('modelMarket.columns.group') }} |
+ {{ t('modelMarket.columns.platform') }} |
+ {{ t('modelMarket.columns.channels') }} |
+ {{ t('modelMarket.columns.models') }} |
+ {{ t('modelMarket.columns.pricing') }} |
+
+
+
+
+
+ |
+
+ |
+
+
+
+
+
+ |
+
+ {{ t('modelMarket.empty') }}
+ |
+
+
+
+
+
+ |
+
+ |
+
+
+
+
+ {{ item.platform }}
+
+ |
+
+
+
+
+ {{ channel.name }}
+
+
+ |
+
+
+
+
+
+ |
+
+
+
+ {{ item.has_pricing ? t('modelMarket.pricingConfigured') : t('availableChannels.noPricing') }}
+
+ |
+
+
+
+
+
+
+
+
+
+
From 3196731de675cd753923f306294cc9e41ac465ff Mon Sep 17 00:00:00 2001
From: miaoheng <2020745908@qq.com>
Date: Fri, 29 May 2026 15:20:25 +0800
Subject: [PATCH 4/4] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E6=A8=A1=E5=9E=8B?=
=?UTF-8?q?=E6=8E=A2=E6=B5=8B=E4=B8=8E=E6=A8=A1=E5=9E=8B=E5=B9=BF=E5=9C=BA?=
=?UTF-8?q?=E5=AE=A1=E6=9F=A5=E9=97=AE=E9=A2=98?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../handler/available_channel_handler.go | 39 +++++++++++++-
.../handler/available_channel_handler_test.go | 49 +++++++++++++++++
.../service/account_model_probe_test.go | 16 ++++++
.../service/model_pricing_resolver_test.go | 4 +-
.../components/account/ModelProbeModal.vue | 23 ++++++--
.../account/ModelWhitelistSelector.vue | 11 +++-
.../account/__tests__/ModelProbeModal.spec.ts | 23 ++++++++
.../__tests__/ModelWhitelistSelector.spec.ts | 30 +++++++++++
frontend/src/i18n/locales/en.ts | 5 +-
frontend/src/i18n/locales/zh.ts | 5 +-
frontend/src/router/__tests__/guards.spec.ts | 54 +++++++++++++++++--
frontend/src/router/index.ts | 4 +-
12 files changed, 249 insertions(+), 14 deletions(-)
diff --git a/backend/internal/handler/available_channel_handler.go b/backend/internal/handler/available_channel_handler.go
index 8997a741e99..2afcc1fc108 100644
--- a/backend/internal/handler/available_channel_handler.go
+++ b/backend/internal/handler/available_channel_handler.go
@@ -144,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 {
@@ -156,7 +157,7 @@ func (h *AvailableChannelHandler) List(c *gin.Context) {
sections := buildPlatformSections(ch, visibleGroups)
if !ch.RestrictModels {
visibleRefs := filterAvailableGroupRefs(ch.Groups, allowedGroupIDs)
- groupModels, err := h.channelService.ListSupportedModelsForGroups(c.Request.Context(), visibleRefs)
+ groupModels, err := h.supportedModelsForGroups(c, visibleRefs, groupModelCache)
if err != nil {
response.ErrorFrom(c, err)
return
@@ -176,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 字母序稳定排序,便于前端等效比较与回归测试。
diff --git a/backend/internal/handler/available_channel_handler_test.go b/backend/internal/handler/available_channel_handler_test.go
index 4a6398f42a0..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"
@@ -231,6 +232,54 @@ func TestBuildPlatformSections_RestrictModelsKeepsConfiguredModelsOnly(t *testin
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 {
diff --git a/backend/internal/service/account_model_probe_test.go b/backend/internal/service/account_model_probe_test.go
index 196ed0a73a5..42f3b36a98d 100644
--- a/backend/internal/service/account_model_probe_test.go
+++ b/backend/internal/service/account_model_probe_test.go
@@ -74,6 +74,22 @@ func TestAccountTestService_ModelProbeOpenAIResponsesMinimalRequest(t *testing.T
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":[]}`),
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/frontend/src/components/account/ModelProbeModal.vue b/frontend/src/components/account/ModelProbeModal.vue
index ce5fc49fec3..790883479c0 100644
--- a/frontend/src/components/account/ModelProbeModal.vue
+++ b/frontend/src/components/account/ModelProbeModal.vue
@@ -53,7 +53,7 @@
>
- {{ discovering ? t('admin.accounts.modelProbe.discovering') : t('admin.accounts.modelProbe.discover') }}
+ {{ discovering ? discoveringLabel : discoverLabel }}