diff --git a/controller/channel-test.go b/controller/channel-test.go index 895099a10627..3ac156c3d7bd 100644 --- a/controller/channel-test.go +++ b/controller/channel-test.go @@ -69,7 +69,7 @@ func resolveChannelTestUserID(c *gin.Context) (int, error) { return rootUser.Id, nil } -func testChannel(ctx context.Context, channel *model.Channel, testUserID int, testModel string, endpointType string, isStream bool) testResult { +func testChannel(ctx context.Context, channel *model.Channel, testUserID int, testModel string, endpointType string, isStream bool, keyIndex *int) testResult { if ctx == nil { ctx = context.Background() } @@ -168,7 +168,12 @@ func testChannel(ctx context.Context, channel *model.Channel, testUserID int, te group, _ := model.GetUserGroup(testUserID, false) c.Set("group", group) - newAPIError := middleware.SetupContextForSelectedChannel(c, channel, testModel) + var newAPIError *types.NewAPIError + if keyIndex == nil { + newAPIError = middleware.SetupContextForSelectedChannel(c, channel, testModel) + } else { + newAPIError = middleware.SetupContextForSelectedChannelKey(c, channel, testModel, *keyIndex) + } if newAPIError != nil { return testResult{ context: c, @@ -867,7 +872,7 @@ func TestChannel(c *gin.Context) { if c.Request != nil { requestCtx = c.Request.Context() } - result := testChannel(requestCtx, channel, testUserID, testModel, endpointType, isStream) + result := testChannel(requestCtx, channel, testUserID, testModel, endpointType, isStream, nil) if result.localErr != nil { resp := gin.H{ "success": false, @@ -911,10 +916,64 @@ type channelTestSummary struct { } func testChannelForHealthCheck(ctx context.Context, channel *model.Channel, testUserID int, allowDisable bool, disableThreshold int64) channelTestSummary { + // Normal selection cannot choose a key after every key has been auto-disabled, + // so recovery checks probe those keys explicitly and leave manual disables alone. + if channel.Status == common.ChannelStatusAutoDisabled && channel.ChannelInfo.IsMultiKey { + keyIndexes := multiKeyRecoveryIndexes(channel) + if len(keyIndexes) > 0 { + keySummary := channelTestSummary{} + for _, keyIndex := range keyIndexes { + result := testChannelKeyForHealthCheck(ctx, channel, testUserID, allowDisable, disableThreshold, &keyIndex) + keySummary.Tested += result.Tested + keySummary.Succeeded += result.Succeeded + keySummary.Failed += result.Failed + keySummary.Disabled += result.Disabled + keySummary.Enabled += result.Enabled + if ctx.Err() != nil { + break + } + } + summary := channelTestSummary{} + if keySummary.Tested > 0 { + summary.Tested = 1 + if keySummary.Succeeded > 0 { + summary.Succeeded = 1 + } else { + summary.Failed = 1 + } + } + if keySummary.Disabled > 0 { + summary.Disabled = 1 + } + if keySummary.Enabled > 0 { + summary.Enabled = 1 + } + return summary + } + } + return testChannelKeyForHealthCheck(ctx, channel, testUserID, allowDisable, disableThreshold, nil) +} + +func multiKeyRecoveryIndexes(channel *model.Channel) []int { + if channel == nil || channel.Status != common.ChannelStatusAutoDisabled || + !channel.ChannelInfo.IsMultiKey || !channel.ChannelInfo.MultiKeyAutoRecovery { + return nil + } + keys := channel.GetKeys() + indexes := make([]int, 0, len(keys)) + for index := range keys { + if channel.ChannelInfo.MultiKeyStatusList[index] == common.ChannelStatusAutoDisabled { + indexes = append(indexes, index) + } + } + return indexes +} + +func testChannelKeyForHealthCheck(ctx context.Context, channel *model.Channel, testUserID int, allowDisable bool, disableThreshold int64, keyIndex *int) channelTestSummary { summary := channelTestSummary{} isChannelEnabled := channel.Status == common.ChannelStatusEnabled tik := time.Now() - result := testChannel(ctx, channel, testUserID, "", "", shouldUseStreamForAutomaticChannelTest(channel)) + result := testChannel(ctx, channel, testUserID, "", "", shouldUseStreamForAutomaticChannelTest(channel), keyIndex) milliseconds := time.Since(tik).Milliseconds() if ctx.Err() != nil { return summary diff --git a/controller/channel.go b/controller/channel.go index 19ddca8e6a07..01250cccd8dd 100644 --- a/controller/channel.go +++ b/controller/channel.go @@ -589,6 +589,7 @@ func RefreshCodexChannelCredential(c *gin.Context) { type AddChannelRequest struct { Mode string `json:"mode"` MultiKeyMode constant.MultiKeyMode `json:"multi_key_mode"` + MultiKeyAutoRecovery bool `json:"multi_key_auto_recovery"` BatchAddSetKeyPrefix2Name bool `json:"batch_add_set_key_prefix_2_name"` Channel *model.Channel `json:"channel"` } @@ -657,6 +658,7 @@ func AddChannel(c *gin.Context) { case "multi_to_single": addChannelRequest.Channel.ChannelInfo.IsMultiKey = true addChannelRequest.Channel.ChannelInfo.MultiKeyMode = addChannelRequest.MultiKeyMode + addChannelRequest.Channel.ChannelInfo.MultiKeyAutoRecovery = addChannelRequest.MultiKeyAutoRecovery if addChannelRequest.Channel.Type == constant.ChannelTypeVertexAi && addChannelRequest.Channel.GetOtherSettings().VertexKeyType != dto.VertexKeyTypeAPIKey { array, err := getVertexArrayKeys(addChannelRequest.Channel.Key) if err != nil { @@ -954,8 +956,9 @@ func DeleteChannelBatch(c *gin.Context) { type PatchChannel struct { model.Channel - MultiKeyMode *string `json:"multi_key_mode"` - KeyMode *string `json:"key_mode"` // 多key模式下密钥覆盖或者追加 + MultiKeyMode *string `json:"multi_key_mode"` + MultiKeyAutoRecovery *bool `json:"multi_key_auto_recovery"` + KeyMode *string `json:"key_mode"` // 多key模式下密钥覆盖或者追加 } type ChannelStatusRequest struct { @@ -1036,6 +1039,9 @@ func UpdateChannel(c *gin.Context) { if channel.MultiKeyMode != nil && *channel.MultiKeyMode != "" { channel.ChannelInfo.MultiKeyMode = constant.MultiKeyMode(*channel.MultiKeyMode) } + if channel.MultiKeyAutoRecovery != nil && channel.ChannelInfo.IsMultiKey { + channel.ChannelInfo.MultiKeyAutoRecovery = *channel.MultiKeyAutoRecovery + } // 处理多key模式下的密钥追加/覆盖逻辑 if channel.KeyMode != nil && channel.ChannelInfo.IsMultiKey { diff --git a/controller/channel_authz.go b/controller/channel_authz.go index f85ffef92769..45b150b137d9 100644 --- a/controller/channel_authz.go +++ b/controller/channel_authz.go @@ -118,19 +118,20 @@ func clearChannelReadOnlyFields(channel *PatchChannel, requestData map[string]an // to the fail-closed branch and is treated as sensitive. The // TestChannelFieldsAreClassified guard test enforces this. var channelNonSensitiveFields = map[string]struct{}{ - "id": {}, - "test_model": {}, - "name": {}, - "weight": {}, - "models": {}, - "group": {}, - "model_mapping": {}, - "status_code_mapping": {}, - "priority": {}, - "auto_ban": {}, - "other_info": {}, - "tag": {}, - "remark": {}, - "channel_info": {}, - "multi_key_mode": {}, + "id": {}, + "test_model": {}, + "name": {}, + "weight": {}, + "models": {}, + "group": {}, + "model_mapping": {}, + "status_code_mapping": {}, + "priority": {}, + "auto_ban": {}, + "other_info": {}, + "tag": {}, + "remark": {}, + "channel_info": {}, + "multi_key_mode": {}, + "multi_key_auto_recovery": {}, } diff --git a/controller/channel_test_internal_test.go b/controller/channel_test_internal_test.go index 85da7f7bab5e..cd7ff54a6f69 100644 --- a/controller/channel_test_internal_test.go +++ b/controller/channel_test_internal_test.go @@ -341,6 +341,43 @@ func TestSelectChannelsForAutomaticTestAutoBanOnlyUsesEligibleChannels(t *testin require.Equal(t, 3, selected[1].Id) } +func TestMultiKeyRecoveryIndexesSkipsManuallyDisabledKeys(t *testing.T) { + channel := &model.Channel{ + Status: common.ChannelStatusAutoDisabled, + Key: "key-a\nkey-b\nkey-c", + ChannelInfo: model.ChannelInfo{ + IsMultiKey: true, + MultiKeyAutoRecovery: true, + MultiKeyStatusList: map[int]int{ + 0: common.ChannelStatusAutoDisabled, + 1: common.ChannelStatusManuallyDisabled, + }, + }, + } + + indexes := multiKeyRecoveryIndexes(channel) + + assert.Equal(t, []int{0}, indexes) +} + +func TestAutoDisabledMultiKeyRecoveryRequiresChannelOption(t *testing.T) { + channel := &model.Channel{ + Status: common.ChannelStatusAutoDisabled, + Key: "key-a\nkey-b", + ChannelInfo: model.ChannelInfo{ + IsMultiKey: true, + MultiKeyStatusList: map[int]int{ + 0: common.ChannelStatusAutoDisabled, + 1: common.ChannelStatusAutoDisabled, + }, + }, + } + + assert.Empty(t, multiKeyRecoveryIndexes(channel)) + channel.ChannelInfo.MultiKeyAutoRecovery = true + assert.Equal(t, []int{0, 1}, multiKeyRecoveryIndexes(channel)) +} + func TestRunChannelTestWorkersHonorsConfiguredConcurrency(t *testing.T) { originalInterval := common.RequestInterval common.RequestInterval = 0 diff --git a/middleware/distributor.go b/middleware/distributor.go index e61bea44aa3f..eeca7594d592 100644 --- a/middleware/distributor.go +++ b/middleware/distributor.go @@ -577,6 +577,16 @@ func getTaskOriginModelName(c *gin.Context) string { } func SetupContextForSelectedChannel(c *gin.Context, channel *model.Channel, modelName string) *types.NewAPIError { + return setupContextForSelectedChannel(c, channel, modelName, nil) +} + +// SetupContextForSelectedChannelKey is used by channel health checks that must +// probe a specific disabled key without changing normal request key selection. +func SetupContextForSelectedChannelKey(c *gin.Context, channel *model.Channel, modelName string, keyIndex int) *types.NewAPIError { + return setupContextForSelectedChannel(c, channel, modelName, &keyIndex) +} + +func setupContextForSelectedChannel(c *gin.Context, channel *model.Channel, modelName string, keyIndex *int) *types.NewAPIError { c.Set("original_model", modelName) // for retry expectedPlugin := c.GetString("expected_task_plugin_key") if channel == nil { @@ -640,9 +650,21 @@ func SetupContextForSelectedChannel(c *gin.Context, channel *model.Channel, mode common.SetContextKey(c, constant.ContextKeyChannelModelMapping, channel.GetModelMapping()) common.SetContextKey(c, constant.ContextKeyChannelStatusCodeMapping, channel.GetStatusCodeMapping()) - key, index, newAPIError := channel.GetNextEnabledKey() - if newAPIError != nil { - return newAPIError + key := "" + index := 0 + if keyIndex == nil { + var newAPIError *types.NewAPIError + key, index, newAPIError = channel.GetNextEnabledKey() + if newAPIError != nil { + return newAPIError + } + } else { + keys := channel.GetKeys() + if !channel.ChannelInfo.IsMultiKey || *keyIndex < 0 || *keyIndex >= len(keys) { + return types.NewError(errors.New("invalid channel key index"), types.ErrorCodeChannelNoAvailableKey) + } + key = keys[*keyIndex] + index = *keyIndex } if channel.ChannelInfo.IsMultiKey { common.SetContextKey(c, constant.ContextKeyChannelIsMultiKey, true) diff --git a/middleware/distributor_test.go b/middleware/distributor_test.go index 10c500b0adaf..4c18ca764bff 100644 --- a/middleware/distributor_test.go +++ b/middleware/distributor_test.go @@ -2,8 +2,10 @@ package middleware import ( "fmt" + "net/http/httptest" "testing" + "github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/constant" "github.com/QuantumNous/new-api/model" "github.com/QuantumNous/new-api/pkg/jsplugin" @@ -147,3 +149,44 @@ export const protocols = {openai_responses: { }}; `, key, key, channelType) } + +func TestSetupContextForSelectedChannelKeyCanSelectAutoDisabledKey(t *testing.T) { + gin.SetMode(gin.TestMode) + channel := &model.Channel{ + Key: "key-a\nkey-b", + ChannelInfo: model.ChannelInfo{ + IsMultiKey: true, + MultiKeyStatusList: map[int]int{ + 0: common.ChannelStatusAutoDisabled, + 1: common.ChannelStatusAutoDisabled, + }, + }, + } + + ctx, _ := gin.CreateTestContext(httptest.NewRecorder()) + err := SetupContextForSelectedChannelKey(ctx, channel, "gpt-test", 1) + + require.Nil(t, err) + assert.Equal(t, "key-b", common.GetContextKeyString(ctx, constant.ContextKeyChannelKey)) + assert.Equal(t, 1, common.GetContextKeyInt(ctx, constant.ContextKeyChannelMultiKeyIndex)) +} + +func TestSetupContextForSelectedChannelStillRejectsAllDisabledKeys(t *testing.T) { + gin.SetMode(gin.TestMode) + channel := &model.Channel{ + Key: "key-a\nkey-b", + ChannelInfo: model.ChannelInfo{ + IsMultiKey: true, + MultiKeyStatusList: map[int]int{ + 0: common.ChannelStatusAutoDisabled, + 1: common.ChannelStatusAutoDisabled, + }, + }, + } + + ctx, _ := gin.CreateTestContext(httptest.NewRecorder()) + err := SetupContextForSelectedChannel(ctx, channel, "gpt-test") + + require.NotNil(t, err) + assert.Contains(t, err.Error(), "no enabled keys") +} diff --git a/model/channel.go b/model/channel.go index 705c852b7a89..e8273ef22ec0 100644 --- a/model/channel.go +++ b/model/channel.go @@ -67,6 +67,7 @@ type ChannelInfo struct { MultiKeyDisabledTime map[int]int64 `json:"multi_key_disabled_time,omitempty"` // key禁用时间列表,key index -> time MultiKeyPollingIndex int `json:"multi_key_polling_index"` // 多Key模式下轮询的key索引 MultiKeyMode constant.MultiKeyMode `json:"multi_key_mode"` + MultiKeyAutoRecovery bool `json:"multi_key_auto_recovery,omitempty"` } type ChannelSortOptions struct { @@ -174,6 +175,10 @@ func (c ChannelInfo) Value() (driver.Value, error) { // Scan implements sql.Scanner interface func (c *ChannelInfo) Scan(value interface{}) error { + if value == nil { + *c = ChannelInfo{} + return nil + } return common.Unmarshal(jsonScanBytes(value), c) } @@ -782,7 +787,7 @@ func UpdateChannelStatus(channelId int, usingKey string, status int, reason stri if err != nil { return false } else { - if channel.Status == status { + if channel.Status == status && (!channel.ChannelInfo.IsMultiKey || usingKey == "") { return false } diff --git a/model/channel_status_test.go b/model/channel_status_test.go index e4ad86f8133c..52aa9cf38dd8 100644 --- a/model/channel_status_test.go +++ b/model/channel_status_test.go @@ -51,6 +51,33 @@ func TestUpdateChannelStatusPersistsMultiKeyState(t *testing.T) { assert.Equal(t, 1, stored.ChannelInfo.MultiKeyPollingIndex) } +func TestUpdateChannelStatusEnablesRemainingKeyAfterChannelRecovery(t *testing.T) { + setupChannelStatusTest(t) + + channel := Channel{ + Name: "multi-key-recovery", + Key: "key-a\nkey-b", + Status: common.ChannelStatusAutoDisabled, + ChannelInfo: ChannelInfo{ + IsMultiKey: true, + MultiKeySize: 2, + MultiKeyStatusList: map[int]int{ + 0: common.ChannelStatusAutoDisabled, + 1: common.ChannelStatusAutoDisabled, + }, + }, + } + require.NoError(t, DB.Create(&channel).Error) + + require.True(t, UpdateChannelStatus(channel.Id, "key-a", common.ChannelStatusEnabled, "")) + require.True(t, UpdateChannelStatus(channel.Id, "key-b", common.ChannelStatusEnabled, "")) + + var stored Channel + require.NoError(t, DB.First(&stored, channel.Id).Error) + assert.Equal(t, common.ChannelStatusEnabled, stored.Status) + assert.Empty(t, stored.ChannelInfo.MultiKeyStatusList) +} + func TestSaveStatusStateFromSingleKeySnapshotPreservesUnownedColumns(t *testing.T) { setupChannelStatusTest(t) diff --git a/model/json_column_test.go b/model/json_column_test.go index 59272629d0e1..2365cbd37141 100644 --- a/model/json_column_test.go +++ b/model/json_column_test.go @@ -92,3 +92,10 @@ func TestJSONColumnScannersAcceptStringAndBytes(t *testing.T) { }) } } + +func TestChannelInfoScannerAcceptsNull(t *testing.T) { + info := ChannelInfo{IsMultiKey: true, MultiKeySize: 2} + + require.NoError(t, info.Scan(nil)) + assert.Equal(t, ChannelInfo{}, info) +} diff --git a/web/src/features/channels/api.ts b/web/src/features/channels/api.ts index fa13a186c5a0..2f71409240fa 100644 --- a/web/src/features/channels/api.ts +++ b/web/src/features/channels/api.ts @@ -38,6 +38,7 @@ import type { SearchChannelsParams, SearchChannelsResponse, TagOperationParams, + UpdateChannelRequest, } from './types' const channelActionConfig = ( @@ -139,7 +140,7 @@ export async function createChannel( */ export async function updateChannel( id: number, - data: Partial + data: UpdateChannelRequest ): Promise<{ success: boolean; message?: string; data?: Channel }> { const res = await api.put( '/api/channel/', diff --git a/web/src/features/channels/components/drawers/channel-mutate-drawer.tsx b/web/src/features/channels/components/drawers/channel-mutate-drawer.tsx index 93f6a0a1763e..ea753dc7b7da 100644 --- a/web/src/features/channels/components/drawers/channel-mutate-drawer.tsx +++ b/web/src/features/channels/components/drawers/channel-mutate-drawer.tsx @@ -331,7 +331,10 @@ function hasConfiguredOverrideValue(value: unknown): boolean { return true } -function hasAdvancedSettingsValues(values: ChannelFormValues): boolean { +function hasAdvancedSettingsValues( + values: ChannelFormValues, + isMultiKeyChannel = false +): boolean { return Boolean( hasConfiguredOverrideValue(values.param_override) || hasConfiguredOverrideValue(values.header_override) || @@ -341,6 +344,8 @@ function hasAdvancedSettingsValues(values: ChannelFormValues): boolean { values.remark?.trim() || values.priority || values.weight || + ((isMultiKeyChannel || values.multi_key_mode === 'multi_to_single') && + values.multi_key_auto_recovery) || values.proxy?.trim() || values.system_prompt?.trim() || values.force_format || @@ -749,6 +754,7 @@ export function ChannelMutateDrawer({ const currentWeight = form.watch('weight') const currentTestModel = form.watch('test_model') const currentAutoBan = form.watch('auto_ban') + const currentMultiKeyAutoRecovery = form.watch('multi_key_auto_recovery') const currentTag = form.watch('tag') const currentRemark = form.watch('remark') const currentStatusCodeMapping = form.watch('status_code_mapping') @@ -778,6 +784,8 @@ export function ChannelMutateDrawer({ const currentUpstreamModelUpdateIgnoredModels = form.watch( 'upstream_model_update_ignored_models' ) + const showMultiKeyAutoRecovery = + isMultiKeyChannel || (!isEditing && multiKeyMode === 'multi_to_single') const shouldPreviewUnsavedModels = !isEditing || (currentType === CHANNEL_TYPE_ADVANCED_CUSTOM && canEditSensitive) @@ -1022,7 +1030,8 @@ export function ChannelMutateDrawer({ currentPriority || currentWeight || currentTestModel?.trim() || - (currentAutoBan ?? 1) !== 1 + (currentAutoBan ?? 1) !== 1 || + (showMultiKeyAutoRecovery && currentMultiKeyAutoRecovery) ) const internalNotesConfigured = Boolean( currentTag?.trim() || currentRemark?.trim() @@ -1269,7 +1278,11 @@ export function ChannelMutateDrawer({ const defaults = transformChannelToFormDefaults(channelData.data) form.reset(defaults) setAdvancedSettingsOpen( - readAdvancedSettingsPreference() || hasAdvancedSettingsValues(defaults) + readAdvancedSettingsPreference() || + hasAdvancedSettingsValues( + defaults, + channelData.data.channel_info.is_multi_key + ) ) // Store initial values for comparison initialModelsRef.current = parseModelsString( @@ -3832,6 +3845,33 @@ export function ChannelMutateDrawer({ )} /> + + {showMultiKeyAutoRecovery && ( + ( + +
+ + {t('Multi-Key Auto Recovery')} + + + {t( + FIELD_DESCRIPTIONS.MULTI_KEY_AUTO_RECOVERY + )} + +
+ + + +
+ )} + /> + )}
. + +For commercial licensing, please contact support@quantumnous.com +*/ +import { describe, expect, test } from 'vitest' + +import type { Channel } from '../../types' +import { + CHANNEL_FORM_DEFAULT_VALUES, + channelFormSchema, + transformChannelToFormDefaults, + transformFormDataToCreatePayload, + transformFormDataToUpdatePayload, +} from '../channel-form' + +describe('multi-key auto recovery payload', () => { + test('sends the option only when creating a multi-key channel', () => { + const multiKeyPayload = transformFormDataToCreatePayload({ + ...CHANNEL_FORM_DEFAULT_VALUES, + multi_key_mode: 'multi_to_single', + multi_key_auto_recovery: true, + }) + const singleKeyPayload = transformFormDataToCreatePayload({ + ...CHANNEL_FORM_DEFAULT_VALUES, + multi_key_mode: 'single', + multi_key_auto_recovery: true, + }) + + expect(multiKeyPayload.multi_key_auto_recovery).toBe(true) + expect(singleKeyPayload.multi_key_auto_recovery).toBeUndefined() + }) + + test('sends an explicit boolean when updating a multi-key channel', () => { + const enabledPayload = transformFormDataToUpdatePayload( + { + ...CHANNEL_FORM_DEFAULT_VALUES, + multi_key_mode: 'multi_to_single', + multi_key_auto_recovery: true, + }, + 1, + true + ) + const disabledPayload = transformFormDataToUpdatePayload( + { + ...CHANNEL_FORM_DEFAULT_VALUES, + multi_key_mode: 'multi_to_single', + multi_key_auto_recovery: false, + }, + 1, + true + ) + const singleKeyPayload = transformFormDataToUpdatePayload( + { + ...CHANNEL_FORM_DEFAULT_VALUES, + multi_key_auto_recovery: true, + }, + 1 + ) + + expect(enabledPayload.multi_key_auto_recovery).toBe(true) + expect(disabledPayload.multi_key_auto_recovery).toBe(false) + expect(singleKeyPayload.multi_key_auto_recovery).toBeUndefined() + }) + + test.each([ + { + type: 57, + settings: '{}', + other: '', + }, + { + type: 41, + settings: '{"vertex_key_type":"api_key"}', + other: '{}', + }, + ])('keeps an existing multi-key type $type editable', (channelFields) => { + const channel = { + id: 1, + name: 'Existing multi-key channel', + models: 'test-model', + group: 'default', + status: 1, + channel_info: { + is_multi_key: true, + multi_key_size: 2, + multi_key_polling_index: 0, + multi_key_mode: 'random', + multi_key_auto_recovery: true, + }, + ...channelFields, + } as Channel + + const defaults = transformChannelToFormDefaults(channel) + const unsupportedCreate = channelFormSchema.safeParse({ + ...defaults, + multi_key_mode: 'multi_to_single', + }) + + expect(defaults.multi_key_mode).toBe('single') + expect(channelFormSchema.safeParse(defaults).success).toBe(true) + expect(unsupportedCreate.success).toBe(false) + if (!unsupportedCreate.success) { + expect( + unsupportedCreate.error.issues.some( + (issue) => issue.path[0] === 'multi_key_mode' + ) + ).toBe(true) + } + }) +}) diff --git a/web/src/features/channels/lib/channel-form.ts b/web/src/features/channels/lib/channel-form.ts index f31a5c424ee0..95601f8c5072 100644 --- a/web/src/features/channels/lib/channel-form.ts +++ b/web/src/features/channels/lib/channel-form.ts @@ -28,7 +28,7 @@ import { MODEL_FETCHABLE_TYPES, OPENAI_FIELD_PASSTHROUGH_TYPES, } from '../constants' -import type { Channel } from '../types' +import type { AddChannelRequest, Channel, UpdateChannelRequest } from '../types' import { CHANNEL_TYPE_ADVANCED_CUSTOM, advancedCustomConfigUsesRelativeUpstreamPath, @@ -252,6 +252,7 @@ export const channelFormSchema = z // Multi-key options (not sent to backend directly) multi_key_mode: z.enum(['single', 'batch', 'multi_to_single']).optional(), multi_key_type: z.enum(['random', 'polling']).optional(), + multi_key_auto_recovery: z.boolean().optional(), batch_add_set_key_prefix_2_name: z.boolean().optional(), key_mode: z.enum(['append', 'replace']).optional(), // For editing multi-key channels // Channel extra settings (stored in setting JSON, not sent directly) @@ -436,6 +437,7 @@ export const CHANNEL_FORM_DEFAULT_VALUES: ChannelFormValues = { other: '', multi_key_mode: 'single', multi_key_type: 'random', + multi_key_auto_recovery: false, batch_add_set_key_prefix_2_name: false, key_mode: 'append', // Channel extra settings @@ -589,6 +591,8 @@ export function transformChannelToFormDefaults( other: channel.other || '', multi_key_mode: 'single', multi_key_type: channel.channel_info.multi_key_mode || 'random', + multi_key_auto_recovery: + channel.channel_info.multi_key_auto_recovery || false, batch_add_set_key_prefix_2_name: false, key_mode: 'append', // Default to append mode for editing multi-key channels // Channel extra settings @@ -792,12 +796,9 @@ function normalizeBaseUrl(value: string | undefined): string { /** * Transform form data to API payload for creating channel */ -export function transformFormDataToCreatePayload(formData: ChannelFormValues): { - mode: 'single' | 'batch' | 'multi_to_single' - multi_key_mode?: 'random' | 'polling' - batch_add_set_key_prefix_2_name?: boolean - channel: Partial -} { +export function transformFormDataToCreatePayload( + formData: ChannelFormValues +): AddChannelRequest { const mode = formData.multi_key_mode || 'single' const channel: Partial = { @@ -835,6 +836,8 @@ export function transformFormDataToCreatePayload(formData: ChannelFormValues): { mode, multi_key_mode: mode === 'multi_to_single' ? formData.multi_key_type : undefined, + multi_key_auto_recovery: + mode === 'multi_to_single' ? formData.multi_key_auto_recovery : undefined, batch_add_set_key_prefix_2_name: mode === 'batch' ? formData.batch_add_set_key_prefix_2_name : undefined, channel, @@ -846,9 +849,10 @@ export function transformFormDataToCreatePayload(formData: ChannelFormValues): { */ export function transformFormDataToUpdatePayload( formData: ChannelFormValues, - channelId: number -): Partial { - const payload: Partial = { + channelId: number, + isMultiKeyChannel = false +): UpdateChannelRequest { + const payload: UpdateChannelRequest = { id: channelId, name: formData.name, type: formData.type, @@ -894,6 +898,10 @@ export function transformFormDataToUpdatePayload( payload.param_override = formData.param_override || '' payload.header_override = formData.header_override || '' + if (isMultiKeyChannel) { + payload.multi_key_auto_recovery = formData.multi_key_auto_recovery || false + } + return payload } diff --git a/web/src/features/channels/types.ts b/web/src/features/channels/types.ts index 6b53c336f238..665542b10bbe 100644 --- a/web/src/features/channels/types.ts +++ b/web/src/features/channels/types.ts @@ -30,6 +30,7 @@ export const channelInfoSchema = z.object({ multi_key_disabled_time: z.record(z.string(), z.number()).optional(), multi_key_polling_index: z.number().default(0), multi_key_mode: z.enum(['random', 'polling']).default('random'), + multi_key_auto_recovery: z.boolean().default(false), }) export type ChannelInfo = z.infer @@ -69,6 +70,7 @@ export const channelSchema = z.object({ multi_key_size: 0, multi_key_polling_index: 0, multi_key_mode: 'random', + multi_key_auto_recovery: false, }), settings: z.string().default('{}'), // other_settings JSON }) @@ -365,6 +367,7 @@ export interface ChannelFormData { // Multi-key specific multi_key_mode?: 'single' | 'batch' | 'multi_to_single' multi_key_type?: 'random' | 'polling' + multi_key_auto_recovery?: boolean batch_add_set_key_prefix_2_name?: boolean } @@ -375,6 +378,11 @@ export interface ChannelFormData { export interface AddChannelRequest { mode: 'single' | 'batch' | 'multi_to_single' multi_key_mode?: 'random' | 'polling' + multi_key_auto_recovery?: boolean batch_add_set_key_prefix_2_name?: boolean channel: Partial } + +export type UpdateChannelRequest = Partial & { + multi_key_auto_recovery?: boolean +} diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 63f6efe3b75d..dd9d9f494ea8 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -5537,6 +5537,8 @@ "Zero retention": "Zero retention", "Zhipu": "Zhipu", "Zhipu V4": "Zhipu V4", - "Zoom": "Zoom" + "Zoom": "Zoom", + "Multi-Key Auto Recovery": "Multi-Key Auto Recovery", + "After the channel is auto-disabled, channel tests probe each auto-disabled key and restore the keys that are available again. Manually disabled keys are not tested. One check may make multiple upstream requests.": "After the channel is auto-disabled, channel tests probe each auto-disabled key and restore the keys that are available again. Manually disabled keys are not tested. One check may make multiple upstream requests." } -} \ No newline at end of file +} diff --git a/web/src/i18n/locales/fr.json b/web/src/i18n/locales/fr.json index 176d3100cc4e..a0d14450d05c 100644 --- a/web/src/i18n/locales/fr.json +++ b/web/src/i18n/locales/fr.json @@ -5537,6 +5537,8 @@ "Zero retention": "Aucune rétention", "Zhipu": "Zhipu", "Zhipu V4": "Zhipu V4", - "Zoom": "Zoom" + "Zoom": "Zoom", + "Multi-Key Auto Recovery": "Récupération automatique multi-clés", + "After the channel is auto-disabled, channel tests probe each auto-disabled key and restore the keys that are available again. Manually disabled keys are not tested. One check may make multiple upstream requests.": "Après la désactivation automatique du canal, les tests vérifient chaque clé désactivée automatiquement et rétablissent celles qui sont de nouveau disponibles. Les clés désactivées manuellement ne sont pas testées. Une vérification peut générer plusieurs requêtes en amont." } -} \ No newline at end of file +} diff --git a/web/src/i18n/locales/ja.json b/web/src/i18n/locales/ja.json index e07847df1b16..12b30f74ec38 100644 --- a/web/src/i18n/locales/ja.json +++ b/web/src/i18n/locales/ja.json @@ -5537,6 +5537,8 @@ "Zero retention": "データ保持なし", "Zhipu": "Zhipu", "Zhipu V4": "Zhipu V 4", - "Zoom": "ズーム" + "Zoom": "ズーム", + "Multi-Key Auto Recovery": "複数キーの自動復旧", + "After the channel is auto-disabled, channel tests probe each auto-disabled key and restore the keys that are available again. Manually disabled keys are not tested. One check may make multiple upstream requests.": "チャネルが自動無効化された後、チャネルテストは自動無効化された各キーを検査し、再び利用可能になったキーを復旧します。手動で無効化されたキーはテストされません。1回のチェックで複数のアップストリームリクエストが発生する場合があります。" } -} \ No newline at end of file +} diff --git a/web/src/i18n/locales/ru.json b/web/src/i18n/locales/ru.json index 51e85754ec5a..f37b7dda09dc 100644 --- a/web/src/i18n/locales/ru.json +++ b/web/src/i18n/locales/ru.json @@ -5537,6 +5537,8 @@ "Zero retention": "Без хранения данных", "Zhipu": "Zhipu", "Zhipu V4": "Zhipu V4", - "Zoom": "Zoom" + "Zoom": "Zoom", + "Multi-Key Auto Recovery": "Автовосстановление нескольких ключей", + "After the channel is auto-disabled, channel tests probe each auto-disabled key and restore the keys that are available again. Manually disabled keys are not tested. One check may make multiple upstream requests.": "После автоматического отключения канала тесты проверяют каждый автоматически отключённый ключ и восстанавливают снова доступные ключи. Отключённые вручную ключи не тестируются. Одна проверка может создать несколько запросов к вышестоящему сервису." } -} \ No newline at end of file +} diff --git a/web/src/i18n/locales/vi.json b/web/src/i18n/locales/vi.json index 1a1ab235e659..34bc669853d1 100644 --- a/web/src/i18n/locales/vi.json +++ b/web/src/i18n/locales/vi.json @@ -5537,6 +5537,8 @@ "Zero retention": "Không lưu dữ liệu", "Zhipu": "Zhipu", "Zhipu V4": "Zhipu V4", - "Zoom": "Zoom" + "Zoom": "Zoom", + "Multi-Key Auto Recovery": "Tự động khôi phục nhiều khóa", + "After the channel is auto-disabled, channel tests probe each auto-disabled key and restore the keys that are available again. Manually disabled keys are not tested. One check may make multiple upstream requests.": "Sau khi kênh bị tự động vô hiệu hóa, kiểm tra kênh sẽ lần lượt thử từng khóa bị tự động vô hiệu hóa và khôi phục các khóa đã hoạt động trở lại. Khóa bị vô hiệu hóa thủ công sẽ không được kiểm tra. Một lần kiểm tra có thể tạo nhiều yêu cầu đến upstream." } -} \ No newline at end of file +} diff --git a/web/src/i18n/locales/zh-TW.json b/web/src/i18n/locales/zh-TW.json index cdb42a32eb78..64c525f4aeb1 100644 --- a/web/src/i18n/locales/zh-TW.json +++ b/web/src/i18n/locales/zh-TW.json @@ -5537,6 +5537,8 @@ "Zero retention": "零數據保留", "Zhipu": "智譜", "Zhipu V4": "智譜 V4", - "Zoom": "縮放" + "Zoom": "縮放", + "Multi-Key Auto Recovery": "多金鑰自動恢復", + "After the channel is auto-disabled, channel tests probe each auto-disabled key and restore the keys that are available again. Manually disabled keys are not tested. One check may make multiple upstream requests.": "渠道被自動停用後,渠道測試將逐一探測自動停用的金鑰,並恢復已重新可用的金鑰。手動停用的金鑰不會被測試。一次檢查可能產生多次上游請求。" } -} \ No newline at end of file +} diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 183e97661c9e..db96db71f607 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -5537,6 +5537,8 @@ "Zero retention": "零数据保留", "Zhipu": "智谱", "Zhipu V4": "智谱 V4", - "Zoom": "缩放" + "Zoom": "缩放", + "Multi-Key Auto Recovery": "多密钥自动恢复", + "After the channel is auto-disabled, channel tests probe each auto-disabled key and restore the keys that are available again. Manually disabled keys are not tested. One check may make multiple upstream requests.": "渠道被自动禁用后,渠道测试将逐个探测自动禁用的密钥,并恢复已重新可用的密钥。手动禁用的密钥不会被测试。一次检查可能产生多次上游请求。" } -} \ No newline at end of file +} diff --git a/web/src/i18n/static-keys.ts b/web/src/i18n/static-keys.ts index f4807fa5f417..c02b69137b73 100644 --- a/web/src/i18n/static-keys.ts +++ b/web/src/i18n/static-keys.ts @@ -538,6 +538,10 @@ export const STATIC_I18N_KEYS = [ 'OpenAI Models upstream path must not contain {model}', 'OpenAI Models route is required to enable upstream model checks', + // Multi-key channel recovery + 'Multi-Key Auto Recovery', + 'After the channel is auto-disabled, channel tests probe each auto-disabled key and restore the keys that are available again. Manually disabled keys are not tested. One check may make multiple upstream requests.', + // Dashboard flow stages (labels/descriptions passed to t at runtime) 'User', 'Node',