From 74888784be2d750cd2a1bd2e27ccce1a042ff14b Mon Sep 17 00:00:00 2001 From: FlamesCN Date: Sun, 12 Apr 2026 22:32:58 +0800 Subject: [PATCH 1/6] Restore auto-disabled Codex channels through scheduled retests Codex accounts can recover quota after being auto-disabled, but the existing scheduler skipped disabled channels and the stream-test preference only lived in the temporary test modal state. This change persists a per-channel default stream test flag, adds a monitor setting that lets scheduled tests include auto-disabled channels while still skipping manually disabled ones, and wires the existing auto-enable flow so recovered channels can come back online without manual retesting. Constraint: Reuse existing channel settings JSON and monitor settings without schema changes Rejected: Keep recovery manual via per-channel retest | still leaves recovered Codex accounts offline until an operator notices Confidence: high Scope-risk: moderate Reversibility: clean Directive: Manually disabled channels are intentionally excluded from automatic recovery; do not widen this without product review Tested: go test ./controller ./setting/operation_setting Tested: bun x prettier --check src/components/settings/OperationSetting.jsx src/components/table/channels/modals/EditChannelModal.jsx src/components/table/channels/modals/ModelTestModal.jsx src/hooks/channels/useChannelsData.jsx src/pages/Setting/Operation/SettingsMonitoring.jsx Tested: bun run build Not-tested: go test ./... | existing unrelated failures remain in relay/channel/claude and relay/helper (cherry picked from commit 36db0113bcbab7065706a888292bbd34aa7dce43) --- controller/channel-test.go | 41 +- controller/channel_test_logic_test.go | 90 + dto/channel_settings.go | 1 + setting/operation_setting/monitor_setting.go | 10 +- .../components/settings/OperationSetting.jsx | 1 + .../channels/modals/EditChannelModal.jsx | 2817 ++++++++++------- .../table/channels/modals/ModelTestModal.jsx | 12 +- web/src/hooks/channels/useChannelsData.jsx | 34 +- .../Setting/Operation/SettingsMonitoring.jsx | 26 + 9 files changed, 1778 insertions(+), 1254 deletions(-) create mode 100644 controller/channel_test_logic_test.go diff --git a/controller/channel-test.go b/controller/channel-test.go index bdd67d27a90d..06fb8b39ebcb 100644 --- a/controller/channel-test.go +++ b/controller/channel-test.go @@ -42,6 +42,26 @@ type testResult struct { newAPIError *types.NewAPIError } +func resolveChannelTestStream(channel *model.Channel, streamOverride *bool) bool { + if streamOverride != nil { + return *streamOverride + } + if channel == nil { + return false + } + return channel.GetOtherSettings().TestStreamEnabled +} + +func shouldSkipChannelAutoTest(channel *model.Channel, includeAutoDisabled bool) bool { + if channel == nil { + return true + } + if channel.Status == common.ChannelStatusManuallyDisabled { + return true + } + return channel.Status == common.ChannelStatusAutoDisabled && !includeAutoDisabled +} + func normalizeChannelTestEndpoint(channel *model.Channel, modelName, endpointType string) string { normalized := strings.TrimSpace(endpointType) if normalized != "" { @@ -56,7 +76,7 @@ func normalizeChannelTestEndpoint(channel *model.Channel, modelName, endpointTyp return normalized } -func testChannel(channel *model.Channel, testModel string, endpointType string, isStream bool) testResult { +func testChannel(channel *model.Channel, testModel string, endpointType string, streamOverride *bool) testResult { tik := time.Now() var unsupportedTestChannelTypes = []int{ constant.ChannelTypeMidjourney, @@ -134,6 +154,7 @@ func testChannel(channel *model.Channel, testModel string, endpointType string, if strings.HasPrefix(requestPath, "/v1/responses/compact") { testModel = ratio_setting.WithCompactModelSuffix(testModel) } + isStream := resolveChannelTestStream(channel, streamOverride) c.Request = &http.Request{ Method: "POST", @@ -752,9 +773,13 @@ func TestChannel(c *gin.Context) { //}() testModel := c.Query("model") endpointType := c.Query("endpoint_type") - isStream, _ := strconv.ParseBool(c.Query("stream")) + var streamOverride *bool + if raw, exists := c.GetQuery("stream"); exists { + parsed, _ := strconv.ParseBool(raw) + streamOverride = lo.ToPtr(parsed) + } tik := time.Now() - result := testChannel(channel, testModel, endpointType, isStream) + result := testChannel(channel, testModel, endpointType, streamOverride) if result.localErr != nil { c.JSON(http.StatusOK, gin.H{ "success": false, @@ -785,7 +810,7 @@ func TestChannel(c *gin.Context) { var testAllChannelsLock sync.Mutex var testAllChannelsRunning bool = false -func testAllChannels(notify bool) error { +func testAllChannels(notify bool, includeAutoDisabled bool) error { testAllChannelsLock.Lock() if testAllChannelsRunning { @@ -811,12 +836,12 @@ func testAllChannels(notify bool) error { }() for _, channel := range channels { - if channel.Status == common.ChannelStatusManuallyDisabled { + if shouldSkipChannelAutoTest(channel, includeAutoDisabled) { continue } isChannelEnabled := channel.Status == common.ChannelStatusEnabled tik := time.Now() - result := testChannel(channel, "", "", false) + result := testChannel(channel, "", "", nil) tok := time.Now() milliseconds := tok.Sub(tik).Milliseconds() @@ -858,7 +883,7 @@ func testAllChannels(notify bool) error { } func TestAllChannels(c *gin.Context) { - err := testAllChannels(true) + err := testAllChannels(true, true) if err != nil { common.ApiError(c, err) return @@ -887,7 +912,7 @@ func AutomaticallyTestChannels() { time.Sleep(time.Duration(int(math.Round(frequency))) * time.Minute) common.SysLog(fmt.Sprintf("automatically test channels with interval %f minutes", frequency)) common.SysLog("automatically testing all channels") - _ = testAllChannels(false) + _ = testAllChannels(false, operation_setting.GetMonitorSetting().AutoTestAutoDisabledChannelsEnabled) common.SysLog("automatically channel test finished") if !operation_setting.GetMonitorSetting().AutoTestChannelEnabled { break diff --git a/controller/channel_test_logic_test.go b/controller/channel_test_logic_test.go new file mode 100644 index 000000000000..55ca3543ed2c --- /dev/null +++ b/controller/channel_test_logic_test.go @@ -0,0 +1,90 @@ +package controller + +import ( + "testing" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/model" +) + +func TestResolveChannelTestStream(t *testing.T) { + settingsBytes, err := common.Marshal(dto.ChannelOtherSettings{ + TestStreamEnabled: true, + }) + if err != nil { + t.Fatalf("marshal settings failed: %v", err) + } + + channel := &model.Channel{OtherSettings: string(settingsBytes)} + if !resolveChannelTestStream(channel, nil) { + t.Fatal("expected channel default stream test setting to be used when override is nil") + } + + overrideFalse := false + if resolveChannelTestStream(channel, &overrideFalse) { + t.Fatal("expected explicit false override to disable stream test") + } + + overrideTrue := true + if !resolveChannelTestStream(channel, &overrideTrue) { + t.Fatal("expected explicit true override to enable stream test") + } +} + +func TestShouldSkipChannelAutoTest(t *testing.T) { + tests := []struct { + name string + channel *model.Channel + includeAutoDisabled bool + want bool + }{ + { + name: "nil channel", + channel: nil, + includeAutoDisabled: true, + want: true, + }, + { + name: "manual disabled always skipped", + channel: &model.Channel{ + Status: common.ChannelStatusManuallyDisabled, + }, + includeAutoDisabled: true, + want: true, + }, + { + name: "auto disabled skipped when disabled in monitor setting", + channel: &model.Channel{ + Status: common.ChannelStatusAutoDisabled, + }, + includeAutoDisabled: false, + want: true, + }, + { + name: "auto disabled included when enabled in monitor setting", + channel: &model.Channel{ + Status: common.ChannelStatusAutoDisabled, + }, + includeAutoDisabled: true, + want: false, + }, + { + name: "enabled channel is included", + channel: &model.Channel{ + Status: common.ChannelStatusEnabled, + }, + includeAutoDisabled: false, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := shouldSkipChannelAutoTest(tt.channel, tt.includeAutoDisabled) + if got != tt.want { + t.Fatalf("shouldSkipChannelAutoTest() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/dto/channel_settings.go b/dto/channel_settings.go index 8d7466d25966..3e9c61c45733 100644 --- a/dto/channel_settings.go +++ b/dto/channel_settings.go @@ -33,6 +33,7 @@ type ChannelOtherSettings struct { AllowSafetyIdentifier bool `json:"allow_safety_identifier,omitempty"` // 是否允许 safety_identifier 透传(默认过滤以保护用户隐私) DisableStore bool `json:"disable_store,omitempty"` // 是否禁用 store 透传(默认允许透传,禁用后可能导致 Codex 无法使用) AllowIncludeObfuscation bool `json:"allow_include_obfuscation,omitempty"` // 是否允许 stream_options.include_obfuscation 透传(默认过滤以避免关闭流混淆保护) + TestStreamEnabled bool `json:"test_stream_enabled,omitempty"` // 渠道测试默认是否使用流式请求 AwsKeyType AwsKeyType `json:"aws_key_type,omitempty"` UpstreamModelUpdateCheckEnabled bool `json:"upstream_model_update_check_enabled,omitempty"` // 是否检测上游模型更新 UpstreamModelUpdateAutoSyncEnabled bool `json:"upstream_model_update_auto_sync_enabled,omitempty"` // 是否自动同步上游模型更新 diff --git a/setting/operation_setting/monitor_setting.go b/setting/operation_setting/monitor_setting.go index 541e25f8a105..154a5ca580fa 100644 --- a/setting/operation_setting/monitor_setting.go +++ b/setting/operation_setting/monitor_setting.go @@ -8,14 +8,16 @@ import ( ) type MonitorSetting struct { - AutoTestChannelEnabled bool `json:"auto_test_channel_enabled"` - AutoTestChannelMinutes float64 `json:"auto_test_channel_minutes"` + AutoTestChannelEnabled bool `json:"auto_test_channel_enabled"` + AutoTestChannelMinutes float64 `json:"auto_test_channel_minutes"` + AutoTestAutoDisabledChannelsEnabled bool `json:"auto_test_auto_disabled_channels_enabled"` } // 默认配置 var monitorSetting = MonitorSetting{ - AutoTestChannelEnabled: false, - AutoTestChannelMinutes: 10, + AutoTestChannelEnabled: false, + AutoTestChannelMinutes: 10, + AutoTestAutoDisabledChannelsEnabled: true, } func init() { diff --git a/web/src/components/settings/OperationSetting.jsx b/web/src/components/settings/OperationSetting.jsx index 8585a3e90278..bb047ee09aa9 100644 --- a/web/src/components/settings/OperationSetting.jsx +++ b/web/src/components/settings/OperationSetting.jsx @@ -75,6 +75,7 @@ const OperationSetting = () => { '100-199,300-399,401-407,409-499,500-503,505-523,525-599', 'monitor_setting.auto_test_channel_enabled': false, 'monitor_setting.auto_test_channel_minutes': 10 /* 签到设置 */, + 'monitor_setting.auto_test_auto_disabled_channels_enabled': true, 'checkin_setting.enabled': false, 'checkin_setting.min_quota': 1000, 'checkin_setting.max_quota': 10000, diff --git a/web/src/components/table/channels/modals/EditChannelModal.jsx b/web/src/components/table/channels/modals/EditChannelModal.jsx index 899e290594b8..ae1140cb8e4f 100644 --- a/web/src/components/table/channels/modals/EditChannelModal.jsx +++ b/web/src/components/table/channels/modals/EditChannelModal.jsx @@ -27,7 +27,10 @@ import { verifyJSON, } from '../../../../helpers'; import { useIsMobile } from '../../../../hooks/common/useIsMobile'; -import { CHANNEL_OPTIONS, MODEL_FETCHABLE_CHANNEL_TYPES } from '../../../../constants'; +import { + CHANNEL_OPTIONS, + MODEL_FETCHABLE_CHANNEL_TYPES, +} from '../../../../constants'; import { SideSheet, Space, @@ -205,6 +208,7 @@ const EditChannelModal = (props) => { // 字段透传控制默认值 allow_service_tier: false, disable_store: false, // false = 允许透传(默认开启) + test_stream_enabled: false, allow_safety_identifier: false, allow_include_obfuscation: false, allow_inference_geo: false, @@ -280,7 +284,8 @@ const EditChannelModal = (props) => { [inputs.upstream_model_update_last_detected_models], ); const upstreamDetectedModelsPreview = useMemo( - () => upstreamDetectedModels.slice(0, UPSTREAM_DETECTED_MODEL_PREVIEW_LIMIT), + () => + upstreamDetectedModels.slice(0, UPSTREAM_DETECTED_MODEL_PREVIEW_LIMIT), [upstreamDetectedModels], ); const upstreamDetectedModelsOmittedCount = @@ -313,9 +318,7 @@ const EditChannelModal = (props) => { return { tagLabel: t('不更改'), tagColor: 'grey', - preview: t( - '此项可选,用于覆盖请求参数。不支持覆盖 stream 参数', - ), + preview: t('此项可选,用于覆盖请求参数。不支持覆盖 stream 参数'), }; } if (!verifyJSON(raw)) { @@ -884,6 +887,8 @@ const EditChannelModal = (props) => { // 读取字段透传控制设置 data.allow_service_tier = parsedSettings.allow_service_tier || false; data.disable_store = parsedSettings.disable_store || false; + data.test_stream_enabled = + parsedSettings.test_stream_enabled || false; data.allow_safety_identifier = parsedSettings.allow_safety_identifier || false; data.allow_include_obfuscation = @@ -916,6 +921,7 @@ const EditChannelModal = (props) => { data.is_enterprise_account = false; data.allow_service_tier = false; data.disable_store = false; + data.test_stream_enabled = false; data.allow_safety_identifier = false; data.allow_include_obfuscation = false; data.allow_inference_geo = false; @@ -933,6 +939,7 @@ const EditChannelModal = (props) => { data.is_enterprise_account = false; data.allow_service_tier = false; data.disable_store = false; + data.test_stream_enabled = false; data.allow_safety_identifier = false; data.allow_include_obfuscation = false; data.allow_inference_geo = false; @@ -1014,6 +1021,7 @@ const EditChannelModal = (props) => { data.thinking_to_content || data.pass_through_body_enabled || data.force_format || + data.test_stream_enabled || data.claude_beta_query || data.system_prompt_override; if (hasAdvancedValues) { @@ -1312,12 +1320,15 @@ const EditChannelModal = (props) => { } else { formApiRef.current?.setValues(getInitValues()); try { - navigator?.clipboard?.readText()?.then((text) => { - const parsed = parseChannelConnectionString(text); - if (parsed) { - setClipboardConfig(parsed); - } - }).catch(() => {}); + navigator?.clipboard + ?.readText() + ?.then((text) => { + const parsed = parseChannelConnectionString(text); + if (parsed) { + setClipboardConfig(parsed); + } + }) + .catch(() => {}); } catch {} } fetchModelGroups(); @@ -1325,7 +1336,8 @@ const EditChannelModal = (props) => { setUseManualInput(false); // 编辑模式下恢复用户偏好,创建模式一律折叠 setAdvancedSettingsOpen( - isEdit && localStorage.getItem(ADVANCED_SETTINGS_EXPANDED_KEY) === 'true' + isEdit && + localStorage.getItem(ADVANCED_SETTINGS_EXPANDED_KEY) === 'true', ); } else { // 统一的模态框关闭重置逻辑 @@ -1779,6 +1791,7 @@ const EditChannelModal = (props) => { settings.claude_beta_query = localInputs.claude_beta_query === true; } } + settings.test_stream_enabled = localInputs.test_stream_enabled === true; settings.upstream_model_update_check_enabled = localInputs.upstream_model_update_check_enabled === true; @@ -1820,6 +1833,7 @@ const EditChannelModal = (props) => { // 清理字段透传控制的临时字段 delete localInputs.allow_service_tier; delete localInputs.disable_store; + delete localInputs.test_stream_enabled; delete localInputs.allow_safety_identifier; delete localInputs.allow_include_obfuscation; delete localInputs.allow_inference_geo; @@ -2189,92 +2203,97 @@ const EditChannelModal = (props) => {
{/* Upstream Model Management Section */} {MODEL_FETCHABLE_CHANNEL_TYPES.has(inputs.type) && ( -
- - {t('上游模型管理')} - +
+ + {t('上游模型管理')} + - - handleChannelOtherSettingsChange( - 'upstream_model_update_check_enabled', - value, - ) - } - extraText={t( - '开启后由后端定时任务检测该渠道上游模型变化', - )} - /> - - handleChannelOtherSettingsChange('upstream_model_update_auto_sync_enabled', value) - } - extraText={t('开启后检测到新增模型会自动加入当前渠道模型列表')} - /> - - handleInputChange( - 'upstream_model_update_ignored_models', - value, - ) - } - showClear - /> -
- {t('上次检测时间')}:  - {formatUnixTime( - inputs.upstream_model_update_last_check_time, - )} -
-
- {t('上次检测到可加入模型')}:  - {upstreamDetectedModels.length === 0 ? ( - t('暂无') - ) : ( - <> - - {upstreamDetectedModels.join(', ')} -
- } - > - - {upstreamDetectedModelsPreview.join(', ')} + + handleChannelOtherSettingsChange( + 'upstream_model_update_check_enabled', + value, + ) + } + extraText={t( + '开启后由后端定时任务检测该渠道上游模型变化', + )} + /> + + handleChannelOtherSettingsChange( + 'upstream_model_update_auto_sync_enabled', + value, + ) + } + extraText={t( + '开启后检测到新增模型会自动加入当前渠道模型列表', + )} + /> + + handleInputChange( + 'upstream_model_update_ignored_models', + value, + ) + } + showClear + /> +
+ {t('上次检测时间')}:  + {formatUnixTime( + inputs.upstream_model_update_last_check_time, + )} +
+
+ {t('上次检测到可加入模型')}:  + {upstreamDetectedModels.length === 0 ? ( + t('暂无') + ) : ( + <> + + {upstreamDetectedModels.join(', ')} +
+ } + > + + {upstreamDetectedModelsPreview.join(', ')} + + + + {upstreamDetectedModelsOmittedCount > 0 + ? t('(共 {{total}} 个,省略 {{omit}} 个)', { + total: upstreamDetectedModels.length, + omit: upstreamDetectedModelsOmittedCount, + }) + : t('(共 {{total}} 个)', { + total: upstreamDetectedModels.length, + })} - - - {upstreamDetectedModelsOmittedCount > 0 - ? t('(共 {{total}} 个,省略 {{omit}} 个)', { - total: upstreamDetectedModels.length, - omit: upstreamDetectedModelsOmittedCount, - }) - : t('(共 {{total}} 个)', { - total: upstreamDetectedModels.length, - })} - - - )} + + )} +
-
)} {/* Request Config Section */} @@ -2285,7 +2304,9 @@ const EditChannelModal = (props) => {
- {t('参数覆盖')} + + {t('参数覆盖')} +
@@ -2445,7 +2501,9 @@ const EditChannelModal = (props) => { label={t('渠道优先级')} placeholder={t('渠道优先级')} min={0} - onNumberChange={(value) => handleInputChange('priority', value)} + onNumberChange={(value) => + handleInputChange('priority', value) + } style={{ width: '100%' }} /> @@ -2455,7 +2513,9 @@ const EditChannelModal = (props) => { label={t('渠道权重')} placeholder={t('渠道权重')} min={0} - onNumberChange={(value) => handleInputChange('weight', value)} + onNumberChange={(value) => + handleInputChange('weight', value) + } style={{ width: '100%' }} /> @@ -2466,10 +2526,68 @@ const EditChannelModal = (props) => {
{t('字段透传控制')}
- handleChannelOtherSettingsChange('allow_service_tier', value)} extraText={t('service_tier 字段用于指定服务层级,允许透传可能导致实际计费高于预期。默认关闭以避免额外费用')} /> - handleChannelOtherSettingsChange('disable_store', value)} extraText={t('store 字段用于授权 OpenAI 存储请求数据以评估和优化产品。默认关闭,开启后可能导致 Codex 无法正常使用')} /> - handleChannelOtherSettingsChange('allow_safety_identifier', value)} extraText={t('safety_identifier 字段用于帮助 OpenAI 识别可能违反使用政策的应用程序用户。默认关闭以保护用户隐私')} /> - handleChannelOtherSettingsChange('allow_include_obfuscation', value)} extraText={t('include_obfuscation 用于控制 Responses 流混淆字段。默认关闭以避免客户端关闭该安全保护')} /> + + handleChannelOtherSettingsChange( + 'allow_service_tier', + value, + ) + } + extraText={t( + 'service_tier 字段用于指定服务层级,允许透传可能导致实际计费高于预期。默认关闭以避免额外费用', + )} + /> + + handleChannelOtherSettingsChange( + 'disable_store', + value, + ) + } + extraText={t( + 'store 字段用于授权 OpenAI 存储请求数据以评估和优化产品。默认关闭,开启后可能导致 Codex 无法正常使用', + )} + /> + + handleChannelOtherSettingsChange( + 'allow_safety_identifier', + value, + ) + } + extraText={t( + 'safety_identifier 字段用于帮助 OpenAI 识别可能违反使用政策的应用程序用户。默认关闭以保护用户隐私', + )} + /> + + handleChannelOtherSettingsChange( + 'allow_include_obfuscation', + value, + ) + } + extraText={t( + 'include_obfuscation 用于控制 Responses 流混淆字段。默认关闭以避免客户端关闭该安全保护', + )} + /> )} @@ -2478,8 +2596,36 @@ const EditChannelModal = (props) => {
{t('字段透传控制')}
- handleChannelOtherSettingsChange('allow_service_tier', value)} extraText={t('service_tier 字段用于指定服务层级,允许透传可能导致实际计费高于预期。默认关闭以避免额外费用')} /> - handleChannelOtherSettingsChange('allow_inference_geo', value)} extraText={t('inference_geo 字段用于控制 Claude 数据驻留推理区域。默认关闭以避免未经授权透传地域信息')} /> + + handleChannelOtherSettingsChange( + 'allow_service_tier', + value, + ) + } + extraText={t( + 'service_tier 字段用于指定服务层级,允许透传可能导致实际计费高于预期。默认关闭以避免额外费用', + )} + /> + + handleChannelOtherSettingsChange( + 'allow_inference_geo', + value, + ) + } + extraText={t( + 'inference_geo 字段用于控制 Claude 数据驻留推理区域。默认关闭以避免未经授权透传地域信息', + )} + /> )}
@@ -2491,458 +2637,409 @@ const EditChannelModal = (props) => { {inputs.type === 14 && ( - handleChannelOtherSettingsChange('claude_beta_query', value)} extraText={t('开启后,该渠道请求 Claude 时将强制追加 ?beta=true(无需客户端手动传参)')} /> + + handleChannelOtherSettingsChange( + 'claude_beta_query', + value, + ) + } + extraText={t( + '开启后,该渠道请求 Claude 时将强制追加 ?beta=true(无需客户端手动传参)', + )} + /> )} {inputs.type === 1 && ( - handleChannelSettingsChange('force_format', value)} extraText={t('强制将响应格式化为 OpenAI 标准格式(只适用于OpenAI渠道类型)')} /> + + handleChannelSettingsChange('force_format', value) + } + extraText={t( + '强制将响应格式化为 OpenAI 标准格式(只适用于OpenAI渠道类型)', + )} + /> )} - handleChannelSettingsChange('thinking_to_content', value)} extraText={t('将 reasoning_content 转换为 标签拼接到内容中')} /> - handleChannelSettingsChange('pass_through_body_enabled', value)} extraText={t('启用请求体透传功能')} /> + + handleChannelOtherSettingsChange( + 'test_stream_enabled', + value, + ) + } + extraText={t( + '保存该渠道的测试流式开关;单测、批量测试和定时测试都会默认使用这个值', + )} + /> + + handleChannelSettingsChange('thinking_to_content', value) + } + extraText={t( + '将 reasoning_content 转换为 标签拼接到内容中', + )} + /> + + handleChannelSettingsChange( + 'pass_through_body_enabled', + value, + ) + } + extraText={t('启用请求体透传功能')} + /> - handleChannelSettingsChange('proxy', value)} showClear extraText={t('用于配置网络代理,支持 socks5 协议')} /> + + handleChannelSettingsChange('proxy', value) + } + showClear + extraText={t('用于配置网络代理,支持 socks5 协议')} + /> - handleChannelSettingsChange('system_prompt', value)} autosize showClear extraText={t('用户优先:如果用户在请求中指定了系统提示词,将优先使用用户的设置')} /> - handleChannelSettingsChange('system_prompt_override', value)} extraText={t('如果用户请求中包含系统提示词,则使用此设置拼接到用户的系统提示词前面')} /> + + handleChannelSettingsChange('system_prompt', value) + } + autosize + showClear + extraText={t( + '用户优先:如果用户在请求中指定了系统提示词,将优先使用用户的设置', + )} + /> + + handleChannelSettingsChange( + 'system_prompt_override', + value, + ) + } + extraText={t( + '如果用户请求中包含系统提示词,则使用此设置拼接到用户的系统提示词前面', + )} + /> ); return ( - <> - -
- {!isEdit && clipboardConfig && ( - - {t('检测到剪贴板中的连接信息')} -
- - -
-
- } - /> - )} - {/* Core Configuration Card - Always Visible */} - - {/* Header */} -
- - - -
- - {t('核心配置')} - -
- {t('创建渠道所需的基本信息')} -
-
-
- - {isIonetChannel && ( + <> + +
+ {!isEdit && clipboardConfig && ( - - {ionetMetadata?.deployment_id && ( - - )} - - - )} - - setChannelSearchValue(value)} - renderOptionItem={renderChannelOption} - onChange={(value) => handleInputChange('type', value)} - disabled={isIonetLocked} - /> - - {inputs.type === 57 && ( - - )} - - {inputs.type === 20 && ( - { - setIsEnterpriseAccount(value); - handleInputChange('is_enterprise_account', value); - }} - extraText={t( - '企业账户为特殊返回格式,需要特殊处理,如果非企业账户,请勿勾选', - )} - initValue={inputs.is_enterprise_account} + className='ec-dbcd0a3c01b55203' + description={ +
+ {t('检测到剪贴板中的连接信息')} +
+ + +
+
+ } /> )} + {/* Core Configuration Card - Always Visible */} + + {/* Header */} +
+ + + +
+ + {t('核心配置')} + +
+ {t('创建渠道所需的基本信息')} +
+
+
- handleInputChange('name', value)} - autoComplete='new-password' - /> + {isIonetChannel && ( + + + {ionetMetadata?.deployment_id && ( + + )} + + + )} + + setChannelSearchValue(value)} + renderOptionItem={renderChannelOption} + onChange={(value) => handleInputChange('type', value)} + disabled={isIonetLocked} + /> + + {inputs.type === 57 && ( + + )} + + {inputs.type === 20 && ( + { + setIsEnterpriseAccount(value); + handleInputChange('is_enterprise_account', value); + }} + extraText={t( + '企业账户为特殊返回格式,需要特殊处理,如果非企业账户,请勿勾选', + )} + initValue={inputs.is_enterprise_account} + /> + )} + + handleInputChange('name', value)} + autoComplete='new-password' + /> + + {inputs.type === 33 && ( + <> + { + handleChannelOtherSettingsChange( + 'aws_key_type', + value, + ); + }} + extraText={t( + 'AK/SK 模式:使用 AccessKey 和 SecretAccessKey;API Key 模式:使用 API Key', + )} + /> + + )} - {inputs.type === 33 && ( - <> + {inputs.type === 41 && ( { + // 更新设置中的 vertex_key_type handleChannelOtherSettingsChange( - 'aws_key_type', + 'vertex_key_type', value, ); - }} - extraText={t( - 'AK/SK 模式:使用 AccessKey 和 SecretAccessKey;API Key 模式:使用 API Key', - )} - /> - - )} - - {inputs.type === 41 && ( - { - // 更新设置中的 vertex_key_type - handleChannelOtherSettingsChange( - 'vertex_key_type', - value, - ); - // 切换为 api_key 时,关闭批量与手动/文件切换,并清理已选文件 - if (value === 'api_key') { - setBatch(false); - setUseManualInput(false); - setVertexKeys([]); - setVertexFileList([]); - if (formApiRef.current) { - formApiRef.current.setValue('vertex_files', []); + // 切换为 api_key 时,关闭批量与手动/文件切换,并清理已选文件 + if (value === 'api_key') { + setBatch(false); + setUseManualInput(false); + setVertexKeys([]); + setVertexFileList([]); + if (formApiRef.current) { + formApiRef.current.setValue('vertex_files', []); + } } - } - }} - extraText={ - inputs.vertex_key_type === 'api_key' - ? t('API Key 模式下不支持批量创建') - : t('JSON 模式支持手动输入或上传服务账号 JSON') - } - /> - )} - {batch ? ( - inputs.type === 41 && - (inputs.vertex_key_type || 'json') === 'json' ? ( - } - dragMainText={t('点击上传文件或拖拽文件到这里')} - dragSubText={t('仅支持 JSON 文件,支持多文件')} - style={{ marginTop: 10 }} - uploadTrigger='custom' - beforeUpload={() => false} - onChange={handleVertexUploadChange} - fileList={vertexFileList} - rules={ - isEdit - ? [] - : [ - { - required: true, - message: t('请上传密钥文件'), - }, - ] - } - extraText={batchExtra} - /> - ) : ( - handleInputChange('key', value)} - disabled={isIonetLocked} + }} extraText={ -
- {isEdit && - isMultiKeyChannel && - keyMode === 'append' && ( - - {t( - '追加模式:新密钥将添加到现有密钥列表的末尾', - )} - - )} - {isEdit && ( - - )} - {batchExtra} -
+ inputs.vertex_key_type === 'api_key' + ? t('API Key 模式下不支持批量创建') + : t('JSON 模式支持手动输入或上传服务账号 JSON') } - showClear /> - ) - ) : ( - <> - {inputs.type === 57 ? ( - <> - - handleInputChange('key', value) - } - disabled={isIonetLocked} - extraText={ -
- - {t( - '仅支持 JSON 对象,必须包含 access_token 与 account_id', - )} - - - - - {isEdit && ( - - )} - - {isEdit && ( - - )} - {batchExtra} - -
- } - autosize - showClear - /> - - setCodexOAuthModalVisible(false)} - onSuccess={handleCodexOAuthGenerated} - /> - - ) : inputs.type === 41 && - (inputs.vertex_key_type || 'json') === 'json' ? ( - <> - {!batch && ( -
- - {t('密钥输入方式')} - - - + )} + {batch ? ( + inputs.type === 41 && + (inputs.vertex_key_type || 'json') === 'json' ? ( + } + dragMainText={t('点击上传文件或拖拽文件到这里')} + dragSubText={t('仅支持 JSON 文件,支持多文件')} + style={{ marginTop: 10 }} + uploadTrigger='custom' + beforeUpload={() => false} + onChange={handleVertexUploadChange} + fileList={vertexFileList} + rules={ + isEdit + ? [] + : [ + { + required: true, + message: t('请上传密钥文件'), + }, + ] + } + extraText={batchExtra} + /> + ) : ( + + handleInputChange('key', value) + } + disabled={isIonetLocked} + extraText={ +
+ {isEdit && + isMultiKeyChannel && + keyMode === 'append' && ( + + {t( + '追加模式:新密钥将添加到现有密钥列表的末尾', + )} + + )} + {isEdit && ( - -
- )} - - {batch && ( - - )} - - {useManualInput && !batch ? ( + {batchExtra} +
+ } + showClear + /> + ) + ) : ( + <> + {inputs.type === 57 ? ( + <> { : t('密钥') } placeholder={t( - '请输入 JSON 格式的密钥内容,例如:\n{\n "type": "service_account",\n "project_id": "your-project-id",\n "private_key_id": "...",\n "private_key": "...",\n "client_email": "...",\n "client_id": "...",\n "auth_uri": "...",\n "token_uri": "...",\n "auth_provider_x509_cert_url": "...",\n "client_x509_cert_url": "..."\n}', + '请输入 JSON 格式的 OAuth 凭据,例如:\n{\n "access_token": "...",\n "account_id": "..." \n}', )} rules={ isEdit @@ -2969,788 +3066,1048 @@ const EditChannelModal = (props) => { onChange={(value) => handleInputChange('key', value) } + disabled={isIonetLocked} extraText={ -
+
- {t('请输入完整的 JSON 格式密钥内容')} + {t( + '仅支持 JSON 对象,必须包含 access_token 与 account_id', + )} - {isEdit && - isMultiKeyChannel && - keyMode === 'append' && ( - - {t( - '追加模式:新密钥将添加到现有密钥列表的末尾', - )} - + + + + {isEdit && ( + )} - {isEdit && ( - )} - {batchExtra} + {isEdit && ( + + )} + {batchExtra} +
} autosize showClear /> - ) : ( - } - dragMainText={t('点击上传文件或拖拽文件到这里')} - dragSubText={t('仅支持 JSON 文件')} - style={{ marginTop: 10 }} - uploadTrigger='custom' - beforeUpload={() => false} - onChange={handleVertexUploadChange} - fileList={vertexFileList} - rules={ - isEdit - ? [] - : [ - { - required: true, - message: t('请上传密钥文件'), - }, - ] + + + setCodexOAuthModalVisible(false) } - extraText={batchExtra} + onSuccess={handleCodexOAuthGenerated} /> - )} - - ) : ( - - handleInputChange('key', value) - } - extraText={ -
- {isEdit && - isMultiKeyChannel && - keyMode === 'append' && ( - - {t( - '追加模式:新密钥将添加到现有密钥列表的末尾', + + ) : inputs.type === 41 && + (inputs.vertex_key_type || 'json') === 'json' ? ( + <> + {!batch && ( +
+ + {t('密钥输入方式')} + + + + + +
+ )} + + {batch && ( + + )} + + {useManualInput && !batch ? ( + + handleInputChange('key', value) + } + extraText={ +
+ + {t('请输入完整的 JSON 格式密钥内容')} + + {isEdit && + isMultiKeyChannel && + keyMode === 'append' && ( + + {t( + '追加模式:新密钥将添加到现有密钥列表的末尾', + )} + + )} + {isEdit && ( + )} - + {batchExtra} +
+ } + autosize + showClear + /> + ) : ( + } + dragMainText={t( + '点击上传文件或拖拽文件到这里', )} - {isEdit && ( - - )} - {batchExtra} -
- } - showClear - /> - )} - - )} + dragSubText={t('仅支持 JSON 文件')} + style={{ marginTop: 10 }} + uploadTrigger='custom' + beforeUpload={() => false} + onChange={handleVertexUploadChange} + fileList={vertexFileList} + rules={ + isEdit + ? [] + : [ + { + required: true, + message: t('请上传密钥文件'), + }, + ] + } + extraText={batchExtra} + /> + )} + + ) : ( + + handleInputChange('key', value) + } + extraText={ +
+ {isEdit && + isMultiKeyChannel && + keyMode === 'append' && ( + + {t( + '追加模式:新密钥将添加到现有密钥列表的末尾', + )} + + )} + {isEdit && ( + + )} + {batchExtra} +
+ } + showClear + /> + )} + + )} - {isEdit && isMultiKeyChannel && ( - setKeyMode(value)} - extraText={ - - {keyMode === 'replace' - ? t('覆盖模式:将完全替换现有的所有密钥') - : t('追加模式:将新密钥添加到现有密钥列表末尾')} - - } - /> - )} - {batch && multiToSingle && ( - <> + {isEdit && isMultiKeyChannel && ( { - setMultiKeyMode(value); - handleInputChange('multi_key_mode', value); - }} + value={keyMode} + onChange={(value) => setKeyMode(value)} + extraText={ + + {keyMode === 'replace' + ? t('覆盖模式:将完全替换现有的所有密钥') + : t('追加模式:将新密钥添加到现有密钥列表末尾')} + + } /> - {inputs.multi_key_mode === 'polling' && ( - + { + setMultiKeyMode(value); + handleInputChange('multi_key_mode', value); + }} /> - )} - - )} - - {inputs.type === 18 && ( - handleInputChange('other', value)} - showClear - /> - )} - - {inputs.type === 41 && ( - handleInputChange('other', value)} - rules={[ - { required: true, message: t('请填写部署地区') }, - ]} - template={REGION_EXAMPLE} - templateLabel={t('填入模板')} - editorType='region' - formApi={formApiRef.current} - extraText={t('设置默认地区和特定模型的专用地区')} - /> - )} - - {inputs.type === 21 && ( - handleInputChange('other', value)} - showClear - /> - )} + {inputs.multi_key_mode === 'polling' && ( + + )} + + )} - {inputs.type === 39 && ( - handleInputChange('other', value)} - showClear - /> - )} + {inputs.type === 18 && ( + + handleInputChange('other', value) + } + showClear + /> + )} - {inputs.type === 49 && ( - handleInputChange('other', value)} - showClear - /> - )} + {inputs.type === 41 && ( + + handleInputChange('other', value) + } + rules={[ + { required: true, message: t('请填写部署地区') }, + ]} + template={REGION_EXAMPLE} + templateLabel={t('填入模板')} + editorType='region' + formApi={formApiRef.current} + extraText={t('设置默认地区和特定模型的专用地区')} + /> + )} - {inputs.type === 1 && ( - - handleInputChange('openai_organization', value) - } - /> - )} + {inputs.type === 21 && ( + + handleInputChange('other', value) + } + showClear + /> + )} - {/* API Configuration Section */} - {showApiConfigCard && ( -
+ {inputs.type === 39 && ( + + handleInputChange('other', value) + } + showClear + /> + )} - {inputs.type === 40 && ( - - {t('邀请链接')}: - - window.open( - 'https://cloud.siliconflow.cn/i/hij0YNTZ', - ) - } - > - https://cloud.siliconflow.cn/i/hij0YNTZ - -
+ {inputs.type === 49 && ( + + handleInputChange('other', value) } - className='!rounded-lg' + showClear /> )} - {inputs.type === 3 && ( - <> - -
- - handleInputChange('base_url', value) - } - showClear - disabled={isIonetLocked} - /> -
-
- - handleInputChange('other', value) - } - showClear - /> -
-
- - handleChannelOtherSettingsChange( - 'azure_responses_version', - value, - ) - } - showClear - /> -
- + {inputs.type === 1 && ( + + handleInputChange('openai_organization', value) + } + /> )} - {inputs.type === 8 && ( - <> - -
- - handleInputChange('base_url', value) + {/* API Configuration Section */} + {showApiConfigCard && ( +
+ {inputs.type === 40 && ( + + {t('邀请链接')}: + + window.open( + 'https://cloud.siliconflow.cn/i/hij0YNTZ', + ) + } + > + https://cloud.siliconflow.cn/i/hij0YNTZ + +
} - showClear - disabled={isIonetLocked} + className='!rounded-lg' /> -
- - )} + )} - {inputs.type === 37 && ( - + +
+ + handleInputChange('base_url', value) + } + showClear + disabled={isIonetLocked} + /> +
+
+ + handleInputChange('other', value) + } + showClear + /> +
+
+ + handleChannelOtherSettingsChange( + 'azure_responses_version', + value, + ) + } + showClear + /> +
+ )} - className='!rounded-lg' - /> - )} - {inputs.type !== 3 && - inputs.type !== 8 && - inputs.type !== 22 && - inputs.type !== 36 && - (inputs.type !== 45 || doubaoApiEditUnlocked) && ( -
- - handleInputChange('base_url', value) - } - showClear - disabled={isIonetLocked} - extraText={t( - '对于官方渠道,new-api已经内置地址,除非是第三方代理站点或者Azure的特殊接入地址,否则不需要填写', + {inputs.type === 8 && ( + <> + +
+ + handleInputChange('base_url', value) + } + showClear + disabled={isIonetLocked} + /> +
+ + )} + + {inputs.type === 37 && ( + -
- )} + )} - {inputs.type === 22 && ( -
- + + handleInputChange('base_url', value) + } + showClear + disabled={isIonetLocked} + extraText={t( + '对于官方渠道,new-api已经内置地址,除非是第三方代理站点或者Azure的特殊接入地址,否则不需要填写', + )} + /> +
)} - onChange={(value) => - handleInputChange('base_url', value) - } - showClear - disabled={isIonetLocked} - /> -
- )} - {inputs.type === 36 && ( -
- - handleInputChange('base_url', value) - } - showClear - disabled={isIonetLocked} - /> -
- )} + {inputs.type === 22 && ( +
+ + handleInputChange('base_url', value) + } + showClear + disabled={isIonetLocked} + /> +
+ )} - {inputs.type === 45 && !doubaoApiEditUnlocked && ( -
- - handleInputChange('base_url', value) - } - optionList={[ - { - value: 'https://ark.cn-beijing.volces.com', - label: 'https://ark.cn-beijing.volces.com', - }, - { - value: - 'https://ark.ap-southeast.bytepluses.com', - label: - 'https://ark.ap-southeast.bytepluses.com', - }, - { - value: DEPRECATED_DOUBAO_CODING_PLAN_BASE_URL, - label: doubaoCodingPlanOptionLabel, - disabled: !canKeepDeprecatedDoubaoCodingPlan, - }, - ]} - defaultValue='https://ark.cn-beijing.volces.com' - disabled={isIonetLocked} - /> + {inputs.type === 36 && ( +
+ + handleInputChange('base_url', value) + } + showClear + disabled={isIonetLocked} + /> +
+ )} + + {inputs.type === 45 && !doubaoApiEditUnlocked && ( +
+ + handleInputChange('base_url', value) + } + optionList={[ + { + value: 'https://ark.cn-beijing.volces.com', + label: 'https://ark.cn-beijing.volces.com', + }, + { + value: + 'https://ark.ap-southeast.bytepluses.com', + label: + 'https://ark.ap-southeast.bytepluses.com', + }, + { + value: + DEPRECATED_DOUBAO_CODING_PLAN_BASE_URL, + label: doubaoCodingPlanOptionLabel, + disabled: + !canKeepDeprecatedDoubaoCodingPlan, + }, + ]} + defaultValue='https://ark.cn-beijing.volces.com' + disabled={isIonetLocked} + /> +
+ )}
)} -
- )} - {/* Model Selection - Part of Core Config */} - setModelSearchValue(value)} - innerBottomSlot={ - modelSearchHintText ? ( - - {modelSearchHintText} - - ) : null - } - style={{ width: '100%' }} - onChange={(value) => handleInputChange('models', value)} - renderSelectedItem={(optionNode) => { - const modelName = String(optionNode?.value ?? ''); - return { - isRenderInTag: true, - content: ( - { - e.stopPropagation(); - const ok = await copy(modelName); - if (ok) { - showSuccess( - t('已复制:{{name}}', { name: modelName }), - ); - } else { - showError(t('复制失败')); - } - }} - > - {optionNode.label || modelName} - - ), - }; - }} - extraText={ - - - {MODEL_FETCHABLE_CHANNEL_TYPES.has(inputs.type) && ( + {/* Model Selection - Part of Core Config */} + setModelSearchValue(value)} + innerBottomSlot={ + modelSearchHintText ? ( + + {modelSearchHintText} + + ) : null + } + style={{ width: '100%' }} + onChange={(value) => handleInputChange('models', value)} + renderSelectedItem={(optionNode) => { + const modelName = String(optionNode?.value ?? ''); + return { + isRenderInTag: true, + content: ( + { + e.stopPropagation(); + const ok = await copy(modelName); + if (ok) { + showSuccess( + t('已复制:{{name}}', { + name: modelName, + }), + ); + } else { + showError(t('复制失败')); + } + }} + > + {optionNode.label || modelName} + + ), + }; + }} + extraText={ + - )} - handleInputChange('models', fullModels) }, - ...(inputs.type === 4 && isEdit ? [{ node: 'item', name: t('Ollama 模型管理'), onClick: () => setOllamaModalVisible(true) }] : []), - { node: 'divider' }, - { node: 'item', name: t('复制所有模型'), onClick: () => { - if (inputs.models.length === 0) { showInfo(t('没有模型可以复制')); return; } - try { copy(inputs.models.join(',')); showSuccess(t('模型列表已复制到剪贴板')); } catch (error) { showError(t('复制失败')); } - }}, - { node: 'item', name: t('清除所有模型'), type: 'danger', onClick: () => handleInputChange('models', []) }, - ...((modelGroups && modelGroups.length > 0) ? [ + {MODEL_FETCHABLE_CHANNEL_TYPES.has(inputs.type) && ( + + )} + + handleInputChange('models', fullModels), + }, + ...(inputs.type === 4 && isEdit + ? [ + { + node: 'item', + name: t('Ollama 模型管理'), + onClick: () => + setOllamaModalVisible(true), + }, + ] + : []), { node: 'divider' }, - ...modelGroups.map((group) => ({ + { node: 'item', - name: group.name, + name: t('复制所有模型'), onClick: () => { - let items = []; + if (inputs.models.length === 0) { + showInfo(t('没有模型可以复制')); + return; + } try { - if (Array.isArray(group.items)) { items = group.items; } - else if (typeof group.items === 'string') { - const parsed = JSON.parse(group.items || '[]'); - if (Array.isArray(parsed)) items = parsed; - } - } catch {} - const current = formApiRef.current?.getValue('models') || inputs.models || []; - const merged = Array.from(new Set([...current, ...items].map((m) => (m || '').trim()).filter(Boolean))); - handleInputChange('models', merged); + copy(inputs.models.join(',')); + showSuccess(t('模型列表已复制到剪贴板')); + } catch (error) { + showError(t('复制失败')); + } }, - })), - ] : []), - ]} + }, + { + node: 'item', + name: t('清除所有模型'), + type: 'danger', + onClick: () => + handleInputChange('models', []), + }, + ...(modelGroups && modelGroups.length > 0 + ? [ + { node: 'divider' }, + ...modelGroups.map((group) => ({ + node: 'item', + name: group.name, + onClick: () => { + let items = []; + try { + if (Array.isArray(group.items)) { + items = group.items; + } else if ( + typeof group.items === 'string' + ) { + const parsed = JSON.parse( + group.items || '[]', + ); + if (Array.isArray(parsed)) + items = parsed; + } + } catch {} + const current = + formApiRef.current?.getValue( + 'models', + ) || + inputs.models || + []; + const merged = Array.from( + new Set( + [...current, ...items] + .map((m) => (m || '').trim()) + .filter(Boolean), + ), + ); + handleInputChange('models', merged); + }, + })), + ] + : []), + ]} + > + + + + } + /> + + {/* Custom Model Name - Core Config */} + setCustomModel(value.trim())} + value={customModel} + suffix={ + - - - } - /> + {t('填入')} + + } + /> - {/* Custom Model Name - Core Config */} - setCustomModel(value.trim())} - value={customModel} - suffix={ - - } - /> + {/* Groups - Core Config */} + handleInputChange('groups', value)} + /> - {/* Groups - Core Config */} - handleInputChange('groups', value)} - /> + {/* Model Mapping - Core Config */} + + handleInputChange('model_mapping', value) + } + template={MODEL_MAPPING_EXAMPLE} + templateLabel={t('填入模板')} + editorType='keyValue' + formApi={formApiRef.current} + renderStringValueSuffix={({ pairKey, value }) => { + if (!MODEL_FETCHABLE_CHANNEL_TYPES.has(inputs.type)) { + return null; + } + const disabled = !String(pairKey ?? '').trim(); + return ( + +