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

+ +
+ + +

+ {{ + t( + "admin.settings.gatewayForwarding.openAIImagesResponsesReasoningEffortHint", + ) + }} +

+
+