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 @@ + + + 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 }}