From a6cf42c0f1602b9e4c7fe4e439352a3e83a967af Mon Sep 17 00:00:00 2001 From: Seefs <40468931+seefs001@users.noreply.github.com> Date: Sat, 18 Jul 2026 13:39:53 +0800 Subject: [PATCH] feat: support upstream model fetch for advanced custom channels (#5971) * feat: support upstream model fetch for advanced custom channels * fix: add advanced custom routes as separate groups * fix: select advanced custom route entry before adding --------- Co-authored-by: CaIon --- controller/channel.go | 168 +++++++-- controller/channel_upstream_update.go | 124 ++++++- controller/channel_upstream_update_test.go | 340 +++++++++++++++++- docs/openapi/api.json | 27 +- dto/channel_settings.go | 33 ++ dto/channel_settings_test.go | 88 +++++ model/channel.go | 5 + model/channel_settings_test.go | 68 ++++ relay/channel/advancedcustom/adaptor.go | 57 ++- relay/channel/advancedcustom/adaptor_test.go | 138 +++++++ web/default/src/features/channels/api.ts | 8 +- .../dialogs/advanced-custom-editor-dialog.tsx | 216 +++++++---- .../drawers/channel-mutate-drawer.tsx | 44 ++- .../src/features/channels/constants.ts | 2 +- .../features/channels/lib/advanced-custom.ts | 44 +++ .../src/features/channels/lib/channel-form.ts | 40 ++- web/default/src/i18n/locales/en.json | 7 + web/default/src/i18n/locales/fr.json | 7 + web/default/src/i18n/locales/ja.json | 7 + web/default/src/i18n/locales/ru.json | 7 + web/default/src/i18n/locales/vi.json | 7 + web/default/src/i18n/locales/zh-TW.json | 7 + web/default/src/i18n/locales/zh.json | 7 + web/default/src/i18n/static-keys.ts | 8 + 24 files changed, 1324 insertions(+), 135 deletions(-) create mode 100644 model/channel_settings_test.go diff --git a/controller/channel.go b/controller/channel.go index 2438e59d..a00011a9 100644 --- a/controller/channel.go +++ b/controller/channel.go @@ -16,6 +16,7 @@ import ( "github.com/QuantumNous/new-api/model" relaychannel "github.com/QuantumNous/new-api/relay/channel" "github.com/QuantumNous/new-api/relay/channel/ollama" + relaycommon "github.com/QuantumNous/new-api/relay/common" "github.com/QuantumNous/new-api/service" "github.com/QuantumNous/new-api/service/authz" @@ -201,22 +202,29 @@ func buildFetchModelsHeaders(channel *model.Channel, key string) (http.Header, e headers = GetAuthHeader(key) } - headerOverride := channel.GetHeaderOverride() - for k, v := range headerOverride { - if relaychannel.IsHeaderPassthroughRuleKey(k) { - continue - } - str, ok := v.(string) - if !ok { - return nil, fmt.Errorf("invalid header override for key %s", k) - } - if strings.Contains(str, "{api_key}") { - str = strings.ReplaceAll(str, "{api_key}", key) - } - headers.Set(k, str) + if err := applyFetchModelsHeaderOverrides(channel, key, headers); err != nil { + return nil, err + } + return headers, nil +} + +func applyFetchModelsHeaderOverrides(channel *model.Channel, key string, headers http.Header) error { + info := &relaycommon.RelayInfo{ + IsChannelTest: true, + ChannelMeta: &relaycommon.ChannelMeta{ + ApiKey: key, + HeadersOverride: channel.GetHeaderOverride(), + }, + } + overrides, err := relaychannel.ResolveHeaderOverride(info, nil) + if err != nil { + return err + } + for name, value := range overrides { + headers.Set(name, value) } - return headers, nil + return nil } func FetchUpstreamModels(c *gin.Context) { @@ -464,6 +472,10 @@ func validateTwoFactorAuth(twoFA *model.TwoFA, code string) bool { // validateChannel 通用的渠道校验函数 func validateChannel(channel *model.Channel, isAdd bool) error { + if channel == nil { + return fmt.Errorf("channel cannot be empty") + } + // 校验 channel settings if err := channel.ValidateSettings(); err != nil { return fmt.Errorf("渠道额外设置[channel setting] 格式错误:%s", err.Error()) @@ -471,7 +483,7 @@ func validateChannel(channel *model.Channel, isAdd bool) error { // 如果是添加操作,检查 channel 和 key 是否为空 if isAdd { - if channel == nil || channel.Key == "" { + if channel.Key == "" { return fmt.Errorf("channel cannot be empty") } @@ -1155,13 +1167,87 @@ func equalStringPtr(a, b *string) bool { return *a == *b } -func FetchModels(c *gin.Context) { - var req struct { - BaseURL string `json:"base_url"` - Type int `json:"type"` - Key string `json:"key"` +type fetchModelsRequest struct { + ChannelID int `json:"channel_id"` + BaseURL *string `json:"base_url"` + Type int `json:"type"` + Key string `json:"key"` + AdvancedCustom *string `json:"advanced_custom"` + HeaderOverride *string `json:"header_override"` + Proxy *string `json:"proxy"` +} + +func buildAdvancedCustomModelPreviewChannel(req fetchModelsRequest) (*model.Channel, error) { + var channel *model.Channel + if req.ChannelID > 0 { + savedChannel, err := model.GetChannelById(req.ChannelID, true) + if err != nil { + return nil, err + } + if savedChannel.Type != constant.ChannelTypeAdvancedCustom { + return nil, fmt.Errorf("channel %d is not an advanced custom channel", req.ChannelID) + } + channel = savedChannel + } else { + key := strings.TrimSpace(req.Key) + if key != "" { + key = strings.Split(key, "\n")[0] + } + channel = &model.Channel{ + Type: req.Type, + Key: key, + } } + if channel.Type != constant.ChannelTypeAdvancedCustom { + return nil, fmt.Errorf("channel type must be advanced custom") + } + if req.BaseURL != nil { + baseURL := strings.TrimSpace(*req.BaseURL) + channel.BaseURL = &baseURL + } + + settings := channel.GetOtherSettings() + if req.AdvancedCustom != nil { + rawConfig := strings.TrimSpace(*req.AdvancedCustom) + if rawConfig == "" { + return nil, fmt.Errorf("advanced_custom is required") + } + var config dto.AdvancedCustomConfig + if err := common.UnmarshalJsonStr(rawConfig, &config); err != nil { + return nil, err + } + settings.AdvancedCustom = &config + } else if req.ChannelID <= 0 { + return nil, fmt.Errorf("advanced_custom is required") + } + channel.SetOtherSettings(settings) + + if req.HeaderOverride != nil { + rawHeaderOverride := strings.TrimSpace(*req.HeaderOverride) + if rawHeaderOverride != "" { + var headerOverride map[string]any + if err := common.UnmarshalJsonStr(rawHeaderOverride, &headerOverride); err != nil { + return nil, fmt.Errorf("header_override must be a JSON object: %w", err) + } + } + channel.HeaderOverride = &rawHeaderOverride + } + if req.Proxy != nil { + channelSettings := channel.GetSetting() + channelSettings.Proxy = strings.TrimSpace(*req.Proxy) + channel.SetSetting(channelSettings) + } + + if err := validateChannel(channel, false); err != nil { + return nil, err + } + return channel, nil +} + +func FetchModels(c *gin.Context) { + var req fetchModelsRequest + if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{ "success": false, @@ -1170,21 +1256,37 @@ func FetchModels(c *gin.Context) { return } - baseURL := req.BaseURL - if baseURL == "" { - baseURL = constant.ChannelBaseURLs[req.Type] + var channel *model.Channel + if req.Type == constant.ChannelTypeAdvancedCustom || req.ChannelID > 0 { + var err error + channel, err = buildAdvancedCustomModelPreviewChannel(req) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + } else { + baseURL := "" + if req.BaseURL != nil { + baseURL = strings.TrimSpace(*req.BaseURL) + } + if baseURL == "" { + baseURL = constant.ChannelBaseURLs[req.Type] + } + + key := strings.TrimSpace(req.Key) + if req.Type != constant.ChannelTypeCodex { + key = strings.Split(key, "\n")[0] + } + channel = &model.Channel{ + Type: req.Type, + Key: key, + BaseURL: &baseURL, + } } - key := strings.TrimSpace(req.Key) - if req.Type != constant.ChannelTypeCodex { - key = strings.Split(key, "\n")[0] - } - - channel := &model.Channel{ - Type: req.Type, - Key: key, - BaseURL: &baseURL, - } models, err := fetchChannelUpstreamModelIDs(channel) if err != nil { c.JSON(http.StatusOK, gin.H{ diff --git a/controller/channel_upstream_update.go b/controller/channel_upstream_update.go index bfcb8cdb..2c63a691 100644 --- a/controller/channel_upstream_update.go +++ b/controller/channel_upstream_update.go @@ -2,8 +2,11 @@ package controller import ( "context" + "errors" "fmt" + "io" "net/http" + "net/url" "regexp" "slices" "strings" @@ -14,9 +17,13 @@ import ( "github.com/QuantumNous/new-api/constant" "github.com/QuantumNous/new-api/dto" "github.com/QuantumNous/new-api/model" + "github.com/QuantumNous/new-api/relay/channel/advancedcustom" "github.com/QuantumNous/new-api/relay/channel/gemini" "github.com/QuantumNous/new-api/relay/channel/ollama" + relaycommon "github.com/QuantumNous/new-api/relay/common" + relayconstant "github.com/QuantumNous/new-api/relay/constant" "github.com/QuantumNous/new-api/service" + "github.com/QuantumNous/new-api/types" "github.com/gin-gonic/gin" "github.com/samber/lo" @@ -255,6 +262,76 @@ func getUpstreamModelUpdateMinCheckIntervalSeconds() int64 { return interval } +func parseOpenAIModelIDs(body []byte) ([]string, error) { + var result struct { + Data *[]OpenAIModel `json:"data"` + } + if err := common.Unmarshal(body, &result); err != nil { + return nil, fmt.Errorf("invalid OpenAI Models response: %w", err) + } + if result.Data == nil { + return nil, fmt.Errorf("invalid OpenAI Models response: data is required") + } + ids := normalizeModelNames(lo.Map(*result.Data, func(item OpenAIModel, _ int) string { + return item.ID + })) + if len(ids) == 0 { + return nil, fmt.Errorf("OpenAI Models response contains no valid model IDs") + } + return ids, nil +} + +func sanitizeFetchModelsError(err error, key string) error { + if err == nil { + return nil + } + + // net/http includes the complete request URL in url.Error. Discovery routes + // may put the API key in a custom query name or value, so never return that + // wrapper to an API client. + var urlErr *url.Error + if errors.As(err, &urlErr) && urlErr.Err != nil { + err = urlErr.Err + } + + message := err.Error() + key = strings.TrimSpace(key) + if key != "" { + message = strings.ReplaceAll(message, key, "[REDACTED]") + message = strings.ReplaceAll(message, url.QueryEscape(key), "[REDACTED]") + message = strings.ReplaceAll(message, url.PathEscape(key), "[REDACTED]") + } + return errors.New(message) +} + +func getFetchModelsResponseBody(method string, requestURL string, channel *model.Channel, headers http.Header) ([]byte, error) { + request, err := http.NewRequest(method, requestURL, nil) + if err != nil { + return nil, err + } + for name, values := range headers { + for _, value := range values { + request.Header.Add(name, value) + } + if strings.EqualFold(name, "Host") { + request.Host = headers.Get(name) + } + } + client, err := service.NewProxyHttpClient(channel.GetSetting().Proxy) + if err != nil { + return nil, err + } + response, err := client.Do(request) + if err != nil { + return nil, err + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + return nil, fmt.Errorf("status code: %d", response.StatusCode) + } + return io.ReadAll(response.Body) +} + func fetchChannelUpstreamModelIDs(channel *model.Channel) ([]string, error) { baseURL := constant.ChannelBaseURLs[channel.Type] if channel.GetBaseURL() != "" { @@ -285,6 +362,10 @@ func fetchChannelUpstreamModelIDs(channel *model.Channel) ([]string, error) { return normalizeModelNames(models), nil } + if channel.Type == constant.ChannelTypeAdvancedCustom { + return fetchAdvancedCustomUpstreamModelIDs(channel, baseURL) + } + if channel.Type == constant.ChannelTypeCodex { return service.FetchCodexChannelModels(channel) } @@ -323,29 +404,62 @@ func fetchChannelUpstreamModelIDs(channel *model.Channel) ([]string, error) { headers, err := buildFetchModelsHeaders(channel, key) if err != nil { - return nil, err + return nil, sanitizeFetchModelsError(err, key) } - body, err := GetResponseBody(http.MethodGet, url, channel, headers) + body, err := getFetchModelsResponseBody(http.MethodGet, url, channel, headers) if err != nil { - return nil, err + return nil, sanitizeFetchModelsError(err, key) } var result OpenAIModelsResponse if err := common.Unmarshal(body, &result); err != nil { return nil, err } - ids := lo.Map(result.Data, func(item OpenAIModel, _ int) string { if channel.Type == constant.ChannelTypeGemini { return strings.TrimPrefix(item.ID, "models/") } return item.ID }) - return normalizeModelNames(ids), nil } +func fetchAdvancedCustomUpstreamModelIDs(channel *model.Channel, baseURL string) ([]string, error) { + key, _, apiErr := channel.GetNextEnabledKey() + if apiErr != nil { + return nil, fmt.Errorf("获取渠道密钥失败: %w", apiErr) + } + key = strings.TrimSpace(key) + + info := &relaycommon.RelayInfo{ + RelayFormat: types.RelayFormatOpenAI, + RelayMode: relayconstant.RelayModeUnknown, + RequestURLPath: dto.AdvancedCustomModelListPath, + ChannelMeta: &relaycommon.ChannelMeta{ + ChannelType: constant.ChannelTypeAdvancedCustom, + ChannelBaseUrl: baseURL, + ApiKey: key, + ChannelOtherSettings: channel.GetOtherSettings(), + }, + } + + adaptor := &advancedcustom.Adaptor{} + url, headers, err := adaptor.BuildModelListRequest(info) + if err != nil { + return nil, sanitizeFetchModelsError(err, key) + } + if err := applyFetchModelsHeaderOverrides(channel, key, headers); err != nil { + return nil, sanitizeFetchModelsError(err, key) + } + + body, err := getFetchModelsResponseBody(http.MethodGet, url, channel, headers) + if err != nil { + return nil, sanitizeFetchModelsError(err, key) + } + return parseOpenAIModelIDs(body) +} + func updateChannelUpstreamModelSettings(channel *model.Channel, settings dto.ChannelOtherSettings, updateModels bool) error { channel.SetOtherSettings(settings) updates := map[string]interface{}{ diff --git a/controller/channel_upstream_update_test.go b/controller/channel_upstream_update_test.go index 1f353d7b..9265c93b 100644 --- a/controller/channel_upstream_update_test.go +++ b/controller/channel_upstream_update_test.go @@ -1,9 +1,11 @@ package controller import ( + "bytes" + "errors" "net/http" "net/http/httptest" - "strings" + "net/url" "testing" "github.com/QuantumNous/new-api/common" @@ -14,6 +16,340 @@ import ( "github.com/stretchr/testify/require" ) +func newAdvancedCustomModelListChannel(baseURL string, key string, upstreamPath string, auth *dto.AdvancedCustomRouteAuth) *model.Channel { + config := &dto.AdvancedCustomConfig{ + Routes: []dto.AdvancedCustomRoute{ + { + IncomingPath: dto.AdvancedCustomModelListPath, + UpstreamPath: upstreamPath, + Converter: "none", + Auth: auth, + }, + }, + } + channel := &model.Channel{ + Type: constant.ChannelTypeAdvancedCustom, + Key: key, + BaseURL: &baseURL, + } + channel.SetOtherSettings(dto.ChannelOtherSettings{AdvancedCustom: config}) + return channel +} + +func TestParseOpenAIModelIDsStrictResponseContract(t *testing.T) { + tests := []struct { + name string + body string + want []string + wantError string + }{ + {name: "malformed JSON", body: `{"data":`, wantError: "invalid OpenAI Models response"}, + {name: "missing data", body: `{"object":"list"}`, wantError: "data is required"}, + {name: "null data", body: `{"data":null}`, wantError: "data is required"}, + {name: "empty data", body: `{"data":[]}`, wantError: "no valid model IDs"}, + {name: "all IDs empty", body: `{"data":[{"id":""},{"id":" "}]}`, wantError: "no valid model IDs"}, + { + name: "filters empty IDs and normalizes valid IDs", + body: `{"data":[{"id":" gpt-4.1 "},{"id":""},{"id":"gpt-4.1"},{"id":"o3"}]}`, + want: []string{"gpt-4.1", "o3"}, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + models, err := parseOpenAIModelIDs([]byte(test.body)) + if test.wantError != "" { + require.ErrorContains(t, err, test.wantError) + require.Nil(t, models) + return + } + require.NoError(t, err) + require.Equal(t, test.want, models) + }) + } +} + +func TestFetchAdvancedCustomModelsAppliesHeaderOverrideAfterRouteAuth(t *testing.T) { + type receivedRequest struct { + Headers http.Header + Host string + } + received := make(chan receivedRequest, 1) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + received <- receivedRequest{Headers: r.Header.Clone(), Host: r.Host} + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":[{"id":"gpt-4.1"}]}`)) + })) + defer server.Close() + + channel := newAdvancedCustomModelListChannel(server.URL, "secret-key", "/provider/models", &dto.AdvancedCustomRouteAuth{ + Type: dto.AdvancedCustomAuthTypeHeader, + Name: "X-Route-Key", + Value: "route-{api_key}", + }) + headerOverride := `{ + "X-Route-Key":"global-{api_key}", + "X-Static":"static-value", + "X-Client":"{client_header:X-Client}", + "Host":"models.example.test", + "*":"" + }` + channel.HeaderOverride = &headerOverride + + models, err := fetchChannelUpstreamModelIDs(channel) + require.NoError(t, err) + require.Equal(t, []string{"gpt-4.1"}, models) + + request := <-received + require.Equal(t, "global-secret-key", request.Headers.Get("X-Route-Key")) + require.Equal(t, "static-value", request.Headers.Get("X-Static")) + require.Empty(t, request.Headers.Get("X-Client")) + require.Equal(t, "models.example.test", request.Host) +} + +func TestFetchAdvancedCustomModelsUsesEnabledSavedMultiKey(t *testing.T) { + authorization := make(chan string, 1) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + authorization <- r.Header.Get("Authorization") + _, _ = w.Write([]byte(`{"data":[{"id":"gpt-4.1-mini"}]}`)) + })) + defer server.Close() + + channel := newAdvancedCustomModelListChannel(server.URL, "disabled-key\nenabled-key", "/v1/models", nil) + channel.ChannelInfo = model.ChannelInfo{ + IsMultiKey: true, + MultiKeyStatusList: map[int]int{ + 0: common.ChannelStatusManuallyDisabled, + 1: common.ChannelStatusEnabled, + }, + } + + models, err := fetchChannelUpstreamModelIDs(channel) + require.NoError(t, err) + require.Equal(t, []string{"gpt-4.1-mini"}, models) + require.Equal(t, "Bearer enabled-key", <-authorization) +} + +func TestFetchAdvancedCustomModelsRejectsNonOKResponse(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusBadGateway) + _, _ = w.Write([]byte(`{"data":[{"id":"must-not-be-used"}]}`)) + })) + defer server.Close() + + channel := newAdvancedCustomModelListChannel(server.URL, "secret-key", "/v1/models", nil) + models, err := fetchChannelUpstreamModelIDs(channel) + require.ErrorContains(t, err, "status code: 502") + require.Nil(t, models) +} + +func TestFetchAdvancedCustomModelsRedactsQueryKeyFromTransportErrors(t *testing.T) { + const secret = "secret key/+" + server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) + baseURL := server.URL + server.Close() + + channel := newAdvancedCustomModelListChannel(baseURL, secret, "/v1/models", &dto.AdvancedCustomRouteAuth{ + Type: dto.AdvancedCustomAuthTypeQuery, + Name: "custom-token", + Value: "prefix-{api_key}", + }) + + _, err := fetchChannelUpstreamModelIDs(channel) + require.Error(t, err) + require.NotContains(t, err.Error(), secret) + require.NotContains(t, err.Error(), "custom-token") + require.NotContains(t, err.Error(), "prefix-") + + direct := sanitizeFetchModelsError(&url.Error{ + Op: http.MethodGet, + URL: baseURL + "/v1/models?custom-token=prefix-" + url.QueryEscape(secret), + Err: errors.New("connection refused"), + }, secret) + require.EqualError(t, direct, "connection refused") +} + +func TestFetchOrdinaryOpenAIModelsKeepsExistingEmptyDataBehavior(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"object":"list"}`)) + })) + defer server.Close() + + baseURL := server.URL + channel := &model.Channel{ + Type: constant.ChannelTypeOpenAI, + Key: "ordinary-key", + BaseURL: &baseURL, + } + models, err := fetchChannelUpstreamModelIDs(channel) + require.NoError(t, err) + require.Empty(t, models) +} + +func TestFetchModelsAdvancedCustomCreatePreview(t *testing.T) { + receivedAuthorization := make(chan string, 1) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + receivedAuthorization <- r.Header.Get("Authorization") + _, _ = w.Write([]byte(`{"data":[{"id":"preview-model"}]}`)) + })) + defer server.Close() + + config := dto.AdvancedCustomConfig{Routes: []dto.AdvancedCustomRoute{{ + IncomingPath: dto.AdvancedCustomModelListPath, + UpstreamPath: "/preview/models", + Converter: "none", + }}} + configBytes, err := common.Marshal(config) + require.NoError(t, err) + rawConfig := string(configBytes) + baseURL := server.URL + emptyProxy := "" + req := fetchModelsRequest{ + BaseURL: &baseURL, + Type: constant.ChannelTypeAdvancedCustom, + Key: "create-preview-key", + AdvancedCustom: &rawConfig, + Proxy: &emptyProxy, + } + body, err := common.Marshal(req) + require.NoError(t, err) + + recorder := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(recorder) + ctx.Request = httptest.NewRequest(http.MethodPost, "/api/channel/fetch_models", bytes.NewReader(body)) + ctx.Request.Header.Set("Content-Type", "application/json") + FetchModels(ctx) + + var response struct { + Success bool `json:"success"` + Message string `json:"message"` + Data []string `json:"data"` + } + require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response)) + require.True(t, response.Success, response.Message) + require.Equal(t, []string{"preview-model"}, response.Data) + require.Equal(t, "Bearer create-preview-key", <-receivedAuthorization) +} + +func TestFetchModelsAdvancedCustomEditPreviewUsesSavedKeyAndExplicitClears(t *testing.T) { + db := setupModelListControllerTestDB(t) + receivedHeaders := make(chan http.Header, 1) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + receivedHeaders <- r.Header.Clone() + _, _ = w.Write([]byte(`{"data":[{"id":"edited-preview-model"}]}`)) + })) + defer server.Close() + + savedChannel := newAdvancedCustomModelListChannel("http://127.0.0.1:1", "disabled-saved-key\nenabled-saved-key", "/saved/models", nil) + savedChannel.Name = "saved advanced channel" + savedChannel.Models = "old-model" + savedChannel.ChannelInfo = model.ChannelInfo{ + IsMultiKey: true, + MultiKeyStatusList: map[int]int{ + 0: common.ChannelStatusManuallyDisabled, + 1: common.ChannelStatusEnabled, + }, + } + savedHeaderOverride := `{"X-Saved":"must-not-be-sent"}` + savedChannel.HeaderOverride = &savedHeaderOverride + savedChannel.SetSetting(dto.ChannelSettings{Proxy: "http://127.0.0.1:1"}) + require.NoError(t, db.Create(savedChannel).Error) + + preserved, err := buildAdvancedCustomModelPreviewChannel(fetchModelsRequest{ChannelID: savedChannel.Id}) + require.NoError(t, err) + require.Equal(t, "http://127.0.0.1:1", preserved.GetBaseURL()) + require.Equal(t, savedHeaderOverride, *preserved.HeaderOverride) + require.Equal(t, "http://127.0.0.1:1", preserved.GetSetting().Proxy) + + previewConfig := dto.AdvancedCustomConfig{Routes: []dto.AdvancedCustomRoute{{ + IncomingPath: dto.AdvancedCustomModelListPath, + UpstreamPath: "/edited/models", + Converter: "none", + }}} + configBytes, err := common.Marshal(previewConfig) + require.NoError(t, err) + rawConfig := string(configBytes) + baseURL := server.URL + explicitEmpty := "" + req := fetchModelsRequest{ + ChannelID: savedChannel.Id, + BaseURL: &baseURL, + Type: constant.ChannelTypeAdvancedCustom, + Key: "request-key-must-be-ignored", + AdvancedCustom: &rawConfig, + HeaderOverride: &explicitEmpty, + Proxy: &explicitEmpty, + } + cleared, err := buildAdvancedCustomModelPreviewChannel(fetchModelsRequest{ + ChannelID: savedChannel.Id, + BaseURL: &explicitEmpty, + AdvancedCustom: &rawConfig, + HeaderOverride: &explicitEmpty, + Proxy: &explicitEmpty, + }) + require.NoError(t, err) + require.NotNil(t, cleared.BaseURL) + require.Empty(t, *cleared.BaseURL) + require.NotNil(t, cleared.HeaderOverride) + require.Empty(t, *cleared.HeaderOverride) + require.Empty(t, cleared.GetSetting().Proxy) + + body, err := common.Marshal(req) + require.NoError(t, err) + + recorder := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(recorder) + ctx.Request = httptest.NewRequest(http.MethodPost, "/api/channel/fetch_models", bytes.NewReader(body)) + ctx.Request.Header.Set("Content-Type", "application/json") + FetchModels(ctx) + + var response struct { + Success bool `json:"success"` + Message string `json:"message"` + Data []string `json:"data"` + } + require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response)) + require.True(t, response.Success, response.Message) + require.Equal(t, []string{"edited-preview-model"}, response.Data) + require.NotContains(t, recorder.Body.String(), "enabled-saved-key") + require.NotContains(t, recorder.Body.String(), "request-key-must-be-ignored") + + headers := <-receivedHeaders + require.Equal(t, "Bearer enabled-saved-key", headers.Get("Authorization")) + require.Empty(t, headers.Get("X-Saved")) +} + +func TestFailedAdvancedCustomDetectionDoesNotStageFullRemoval(t *testing.T) { + db := setupModelListControllerTestDB(t) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"data":[]}`)) + })) + defer server.Close() + + channel := newAdvancedCustomModelListChannel(server.URL, "secret-key", "/v1/models", nil) + channel.Name = "empty discovery response" + channel.Models = "gpt-4.1,o3" + settings := channel.GetOtherSettings() + settings.UpstreamModelUpdateCheckEnabled = true + settings.UpstreamModelUpdateAutoSyncEnabled = true + channel.SetOtherSettings(settings) + require.NoError(t, db.Create(channel).Error) + + modelsChanged, autoAdded, err := checkAndPersistChannelUpstreamModelUpdates(channel, &settings, true, true) + require.ErrorContains(t, err, "no valid model IDs") + require.False(t, modelsChanged) + require.Zero(t, autoAdded) + require.Empty(t, settings.UpstreamModelUpdateLastDetectedModels) + require.Empty(t, settings.UpstreamModelUpdateLastRemovedModels) + + reloaded, err := model.GetChannelById(channel.Id, true) + require.NoError(t, err) + persistedSettings := reloaded.GetOtherSettings() + require.Empty(t, persistedSettings.UpstreamModelUpdateLastDetectedModels) + require.Empty(t, persistedSettings.UpstreamModelUpdateLastRemovedModels) + require.Equal(t, "gpt-4.1,o3", reloaded.Models) +} + func TestFetchModelsUsesSharedChannelFetchBehavior(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/v1/models" { @@ -39,7 +375,7 @@ func TestFetchModelsUsesSharedChannelFetchBehavior(t *testing.T) { recorder := httptest.NewRecorder() ctx, _ := gin.CreateTestContext(recorder) - ctx.Request = httptest.NewRequest(http.MethodPost, "/api/channel/fetch_models", strings.NewReader(string(body))) + ctx.Request = httptest.NewRequest(http.MethodPost, "/api/channel/fetch_models", bytes.NewReader(body)) ctx.Request.Header.Set("Content-Type", "application/json") FetchModels(ctx) diff --git a/docs/openapi/api.json b/docs/openapi/api.json index 6ee8a739..493c61e4 100644 --- a/docs/openapi/api.json +++ b/docs/openapi/api.json @@ -2967,13 +2967,32 @@ "type": "object", "properties": { "base_url": { - "type": "string" + "type": "string", + "description": "上游基础地址。编辑预览时显式空字符串表示清除已保存值,省略则沿用已保存值;相对上游路径要求非空的完整基础地址。" }, "type": { - "type": "integer" + "type": "integer", + "description": "渠道类型。高级自定义渠道为 58。" }, "key": { - "type": "string" + "type": "string", + "description": "新建渠道预览使用的 API 密钥。提供 channel_id 时忽略此字段,并在服务端使用渠道已保存的单密钥或多密钥配置。" + }, + "channel_id": { + "type": "integer", + "description": "可选的已保存渠道 ID。用于在不向前端返回密钥的情况下预览尚未保存的高级自定义配置。" + }, + "advanced_custom": { + "type": "string", + "description": "可选的高级自定义配置 JSON 字符串。新建高级自定义渠道时必填;编辑预览时覆盖已保存配置,省略则沿用已保存配置。模型发现仅支持显式的 /v1/models 路由和 OpenAI data[].id 响应。" + }, + "header_override": { + "type": "string", + "description": "可选的全局请求头覆盖 JSON 字符串。编辑预览时覆盖已保存值,显式空字符串表示清除,省略则沿用。" + }, + "proxy": { + "type": "string", + "description": "可选的网络代理。编辑预览时覆盖已保存值,显式空字符串表示清除,省略则沿用。" } } } @@ -7815,4 +7834,4 @@ "Combination1243": [] } ] -} \ No newline at end of file +} diff --git a/dto/channel_settings.go b/dto/channel_settings.go index dbcfd318..c92a3f98 100644 --- a/dto/channel_settings.go +++ b/dto/channel_settings.go @@ -112,6 +112,9 @@ const ( advancedCustomEndpointPathEmbeddings = "/v1/embeddings" ) +// AdvancedCustomModelListPath identifies the optional OpenAI Models discovery route. +const AdvancedCustomModelListPath = "/v1/models" + // MatchPath returns the first route whose IncomingPath matches requestPath. // Matching mirrors the relay adaptor: exact match, {model} placeholder, and // :generateContent <-> :streamGenerateContent equivalence. @@ -143,6 +146,20 @@ func (c *AdvancedCustomConfig) MatchPathForModel(requestPath string, model strin return AdvancedCustomRoute{}, false } +// ModelListRoute returns the explicitly configured OpenAI Models discovery route. +// Template routes that merely happen to match /v1/models are not discovery routes. +func (c *AdvancedCustomConfig) ModelListRoute() (AdvancedCustomRoute, bool) { + if c == nil { + return AdvancedCustomRoute{}, false + } + for _, route := range c.Routes { + if strings.TrimSpace(route.IncomingPath) == AdvancedCustomModelListPath { + return route, true + } + } + return AdvancedCustomRoute{}, false +} + // SupportsPath reports whether any route matches requestPath. func (c *AdvancedCustomConfig) SupportsPath(requestPath string) bool { _, ok := c.MatchPath(requestPath) @@ -307,6 +324,7 @@ func (c *AdvancedCustomConfig) Validate() error { } paths := make(map[string]*advancedCustomPathModelState, len(c.Routes)) + modelListRouteIndex := -1 for i := range c.Routes { route := c.Routes[i] route.IncomingPath = strings.TrimSpace(route.IncomingPath) @@ -325,6 +343,21 @@ func (c *AdvancedCustomConfig) Validate() error { if strings.Contains(route.IncomingPath, "?") { return fmt.Errorf("advanced_custom.advanced_routes[%d].incoming_path must not include query", i) } + if route.IncomingPath == AdvancedCustomModelListPath { + if modelListRouteIndex >= 0 { + return fmt.Errorf("advanced_custom.advanced_routes[%d] duplicates the /v1/models route at advanced_routes[%d]", i, modelListRouteIndex) + } + modelListRouteIndex = i + if len(normalizeAdvancedCustomRouteModels(route.Models)) > 0 { + return fmt.Errorf("advanced_custom.advanced_routes[%d].models must be empty for /v1/models", i) + } + if route.Converter != advancedCustomConverterNone { + return fmt.Errorf("advanced_custom.advanced_routes[%d].converter must be none for /v1/models", i) + } + if strings.Contains(upstreamPath, advancedCustomModelPlaceholder) { + return fmt.Errorf("advanced_custom.advanced_routes[%d].upstream_path must not contain %s for /v1/models", i, advancedCustomModelPlaceholder) + } + } if err := validateAdvancedCustomRouteModels(i, route.IncomingPath, route.Models, paths); err != nil { return err } diff --git a/dto/channel_settings_test.go b/dto/channel_settings_test.go index 4d6ccc0f..080863be 100644 --- a/dto/channel_settings_test.go +++ b/dto/channel_settings_test.go @@ -58,6 +58,94 @@ func TestAdvancedCustomValidateResponsesToChatConverterPath(t *testing.T) { } } +func TestAdvancedCustomValidateModelListRouteConstraints(t *testing.T) { + valid := &AdvancedCustomConfig{ + Routes: []AdvancedCustomRoute{ + { + IncomingPath: AdvancedCustomModelListPath, + UpstreamPath: "https://upstream.example/custom/models", + Converter: advancedCustomConverterNone, + }, + }, + } + require.NoError(t, valid.Validate()) + + tests := []struct { + name string + routes []AdvancedCustomRoute + want string + }{ + { + name: "model matching rules", + routes: []AdvancedCustomRoute{ + { + IncomingPath: AdvancedCustomModelListPath, + UpstreamPath: "/v1/models", + Models: []string{"gpt-4o"}, + }, + }, + want: "models must be empty", + }, + { + name: "converter", + routes: []AdvancedCustomRoute{ + { + IncomingPath: AdvancedCustomModelListPath, + UpstreamPath: "/v1/models", + Converter: advancedCustomConverterOpenAIChatToOpenAIResponses, + }, + }, + want: "converter must be none", + }, + { + name: "model placeholder", + routes: []AdvancedCustomRoute{ + { + IncomingPath: AdvancedCustomModelListPath, + UpstreamPath: "/v1/models/{model}", + }, + }, + want: "upstream_path must not contain {model}", + }, + { + name: "duplicate routes", + routes: []AdvancedCustomRoute{ + {IncomingPath: AdvancedCustomModelListPath, UpstreamPath: "/v1/models"}, + {IncomingPath: AdvancedCustomModelListPath, UpstreamPath: "/provider/models"}, + }, + want: "duplicates the /v1/models route", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := (&AdvancedCustomConfig{Routes: tt.routes}).Validate() + require.Error(t, err) + assert.Contains(t, err.Error(), tt.want) + }) + } +} + +func TestAdvancedCustomModelListRouteRequiresExactIncomingPath(t *testing.T) { + config := &AdvancedCustomConfig{ + Routes: []AdvancedCustomRoute{ + { + IncomingPath: "/v1/{model}", + UpstreamPath: "/generic/{model}", + }, + { + IncomingPath: AdvancedCustomModelListPath, + UpstreamPath: "/provider/models", + }, + }, + } + require.NoError(t, config.Validate()) + + route, ok := config.ModelListRoute() + require.True(t, ok) + assert.Equal(t, "/provider/models", route.UpstreamPath) +} + func TestAdvancedCustomValidateDuplicateIncomingPathWithDisjointModels(t *testing.T) { config := &AdvancedCustomConfig{ Routes: []AdvancedCustomRoute{ diff --git a/model/channel.go b/model/channel.go index dbd1deae..7326f28c 100644 --- a/model/channel.go +++ b/model/channel.go @@ -962,6 +962,11 @@ func (channel *Channel) ValidateSettings() error { return err } } + if channel.Type == constant.ChannelTypeAdvancedCustom && channelOtherSettings.UpstreamModelUpdateCheckEnabled { + if _, ok := channelOtherSettings.AdvancedCustom.ModelListRoute(); !ok { + return fmt.Errorf("advanced custom channels require a %s route when upstream model update checks are enabled", dto.AdvancedCustomModelListPath) + } + } return nil } diff --git a/model/channel_settings_test.go b/model/channel_settings_test.go new file mode 100644 index 00000000..c4974faf --- /dev/null +++ b/model/channel_settings_test.go @@ -0,0 +1,68 @@ +package model + +import ( + "testing" + + "github.com/QuantumNous/new-api/constant" + "github.com/QuantumNous/new-api/dto" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAdvancedCustomChannelRequiresModelListRouteOnlyWhenUpdateChecksEnabled(t *testing.T) { + inferenceRoute := dto.AdvancedCustomRoute{ + IncomingPath: "/v1/chat/completions", + UpstreamPath: "/v1/chat/completions", + Converter: "none", + } + + tests := []struct { + name string + checksEnabled bool + routes []dto.AdvancedCustomRoute + wantErr string + }{ + { + name: "legacy channel without discovery route remains valid", + routes: []dto.AdvancedCustomRoute{inferenceRoute}, + }, + { + name: "enabled checks require discovery route", + checksEnabled: true, + routes: []dto.AdvancedCustomRoute{inferenceRoute}, + wantErr: dto.AdvancedCustomModelListPath, + }, + { + name: "enabled checks accept discovery route", + checksEnabled: true, + routes: []dto.AdvancedCustomRoute{ + inferenceRoute, + { + IncomingPath: dto.AdvancedCustomModelListPath, + UpstreamPath: dto.AdvancedCustomModelListPath, + Converter: "none", + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + channel := &Channel{Type: constant.ChannelTypeAdvancedCustom} + channel.SetOtherSettings(dto.ChannelOtherSettings{ + UpstreamModelUpdateCheckEnabled: tt.checksEnabled, + AdvancedCustom: &dto.AdvancedCustomConfig{ + Routes: tt.routes, + }, + }) + + err := channel.ValidateSettings() + if tt.wantErr == "" { + require.NoError(t, err) + return + } + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + }) + } +} diff --git a/relay/channel/advancedcustom/adaptor.go b/relay/channel/advancedcustom/adaptor.go index 8f6a71dd..f6bf6145 100644 --- a/relay/channel/advancedcustom/adaptor.go +++ b/relay/channel/advancedcustom/adaptor.go @@ -193,6 +193,51 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { return a.routeURL(info) } +func (a *Adaptor) BuildModelListRequest(info *relaycommon.RelayInfo) (string, http.Header, error) { + if info == nil { + return "", nil, errors.New("missing relay info") + } + config := info.ChannelOtherSettings.AdvancedCustom + if config == nil { + return "", nil, errors.New("advanced_custom is required") + } + if err := config.Validate(); err != nil { + return "", nil, err + } + route, ok := config.ModelListRoute() + if !ok { + return "", nil, errors.New("advanced custom channel does not configure a /v1/models route") + } + converter := strings.TrimSpace(route.Converter) + if converter == "" { + converter = relayconvert.ConverterNone + } + if converter != relayconvert.ConverterNone { + return "", nil, fmt.Errorf("converter %q does not support model list requests", converter) + } + + requestURL, err := buildRouteURL(route, converter, info) + if err != nil { + return "", nil, err + } + + header := http.Header{} + auth := route.Auth + if auth == nil { + header.Set("Authorization", "Bearer "+info.ApiKey) + return requestURL, header, nil + } + + switch strings.TrimSpace(auth.Type) { + case dto.AdvancedCustomAuthTypeNone, dto.AdvancedCustomAuthTypeQuery: + case dto.AdvancedCustomAuthTypeHeader: + header.Set(strings.TrimSpace(auth.Name), applyAuthTemplate(auth.Value, info.ApiKey)) + default: + return "", nil, fmt.Errorf("invalid advanced custom auth type: %s", auth.Type) + } + return requestURL, header, nil +} + func (a *Adaptor) SetupRequestHeader(c *gin.Context, header *http.Header, info *relaycommon.RelayInfo) error { if err := a.resolve(c, info); err != nil { return err @@ -343,11 +388,15 @@ func incomingRequestPath(c *gin.Context, info *relaycommon.RelayInfo) string { } func (a *Adaptor) routeURL(info *relaycommon.RelayInfo) (string, error) { - parsedURL, err := resolveUpstreamTargetURL(applyUpstreamPathTemplate(strings.TrimSpace(a.route.UpstreamPath), info), info) + return buildRouteURL(a.route, a.converter, info) +} + +func buildRouteURL(route dto.AdvancedCustomRoute, converter string, info *relaycommon.RelayInfo) (string, error) { + parsedURL, err := resolveUpstreamTargetURL(applyUpstreamPathTemplate(strings.TrimSpace(route.UpstreamPath), info), info) if err != nil { return "", err } - if shouldUseGeminiStreamURL(a.converter, info) { + if shouldUseGeminiStreamURL(converter, info) { useGeminiStreamGenerateContentURL(parsedURL) } if info != nil && info.RelayMode == relayconstant.RelayModeRealtime { @@ -358,9 +407,9 @@ func (a *Adaptor) routeURL(info *relaycommon.RelayInfo) (string, error) { parsedURL.Scheme = "ws" } } - if a.route.Auth != nil && strings.TrimSpace(a.route.Auth.Type) == dto.AdvancedCustomAuthTypeQuery { + if route.Auth != nil && strings.TrimSpace(route.Auth.Type) == dto.AdvancedCustomAuthTypeQuery { query := parsedURL.Query() - query.Set(strings.TrimSpace(a.route.Auth.Name), applyAuthTemplate(a.route.Auth.Value, info.ApiKey)) + query.Set(strings.TrimSpace(route.Auth.Name), applyAuthTemplate(route.Auth.Value, info.ApiKey)) parsedURL.RawQuery = query.Encode() } return parsedURL.String(), nil diff --git a/relay/channel/advancedcustom/adaptor_test.go b/relay/channel/advancedcustom/adaptor_test.go index ef71301e..6f59ed22 100644 --- a/relay/channel/advancedcustom/adaptor_test.go +++ b/relay/channel/advancedcustom/adaptor_test.go @@ -284,6 +284,144 @@ func TestAdaptorMatchesGeminiIncomingPathTemplate(t *testing.T) { } } +func TestAdaptorBuildModelListRequestUsesConfiguredRouteAuth(t *testing.T) { + adaptor := &Adaptor{} + info := advancedCustomRelayInfo(&dto.AdvancedCustomConfig{ + Routes: []dto.AdvancedCustomRoute{ + { + IncomingPath: "/v1/models", + UpstreamPath: "/provider/models", + Converter: relayconvert.ConverterNone, + Auth: &dto.AdvancedCustomRouteAuth{ + Type: dto.AdvancedCustomAuthTypeHeader, + Name: "x-api-key", + Value: "token {api_key}", + }, + }, + }, + }) + info.RequestURLPath = "/v1/models" + + requestURL, header, err := adaptor.BuildModelListRequest(info) + require.NoError(t, err) + + parsedURL, err := url.Parse(requestURL) + require.NoError(t, err) + assert.Equal(t, "fallback.example", parsedURL.Host) + assert.Equal(t, "/provider/models", parsedURL.Path) + assert.Equal(t, "token sk-test", header.Get("x-api-key")) + assert.Empty(t, header.Get("Authorization")) +} + +func TestAdaptorBuildModelListRequestUsesConfiguredQueryAuth(t *testing.T) { + adaptor := &Adaptor{} + info := advancedCustomRelayInfo(&dto.AdvancedCustomConfig{ + Routes: []dto.AdvancedCustomRoute{ + { + IncomingPath: "/v1/models", + UpstreamPath: "https://upstream.example/v1/models?existing=1", + Converter: relayconvert.ConverterNone, + Auth: &dto.AdvancedCustomRouteAuth{ + Type: dto.AdvancedCustomAuthTypeQuery, + Name: "key", + Value: "{api_key}", + }, + }, + }, + }) + info.RequestURLPath = "/v1/models" + + requestURL, header, err := adaptor.BuildModelListRequest(info) + require.NoError(t, err) + + parsedURL, err := url.Parse(requestURL) + require.NoError(t, err) + assert.Equal(t, "upstream.example", parsedURL.Host) + assert.Equal(t, "/v1/models", parsedURL.Path) + assert.Equal(t, "1", parsedURL.Query().Get("existing")) + assert.Equal(t, "sk-test", parsedURL.Query().Get("key")) + assert.Empty(t, header.Get("Authorization")) +} + +func TestAdaptorBuildModelListRequestDefaultAndNoAuth(t *testing.T) { + tests := []struct { + name string + auth *dto.AdvancedCustomRouteAuth + wantAuthorization string + }{ + { + name: "default bearer", + wantAuthorization: "Bearer sk-test", + }, + { + name: "no authentication", + auth: &dto.AdvancedCustomRouteAuth{ + Type: dto.AdvancedCustomAuthTypeNone, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + info := advancedCustomRelayInfo(&dto.AdvancedCustomConfig{ + Routes: []dto.AdvancedCustomRoute{ + { + IncomingPath: dto.AdvancedCustomModelListPath, + UpstreamPath: "/provider/models", + Auth: tt.auth, + }, + }, + }) + info.RequestURLPath = "/unrelated/path" + + requestURL, header, err := (&Adaptor{}).BuildModelListRequest(info) + require.NoError(t, err) + assert.Equal(t, "https://fallback.example/provider/models", requestURL) + assert.Equal(t, tt.wantAuthorization, header.Get("Authorization")) + }) + } +} + +func TestAdaptorBuildModelListRequestDoesNotReuseRelayRoute(t *testing.T) { + adaptor := &Adaptor{} + info := advancedCustomRelayInfo(&dto.AdvancedCustomConfig{ + Routes: []dto.AdvancedCustomRoute{ + { + IncomingPath: "/v1/chat/completions", + UpstreamPath: "/chat", + }, + { + IncomingPath: dto.AdvancedCustomModelListPath, + UpstreamPath: "/provider/models", + }, + }, + }) + + chatURL, err := adaptor.GetRequestURL(info) + require.NoError(t, err) + assert.Equal(t, "https://fallback.example/chat", chatURL) + + modelURL, header, err := adaptor.BuildModelListRequest(info) + require.NoError(t, err) + assert.Equal(t, "https://fallback.example/provider/models", modelURL) + assert.Equal(t, "Bearer sk-test", header.Get("Authorization")) +} + +func TestAdaptorBuildModelListRequestRequiresConfiguredRoute(t *testing.T) { + info := advancedCustomRelayInfo(&dto.AdvancedCustomConfig{ + Routes: []dto.AdvancedCustomRoute{ + { + IncomingPath: "/v1/chat/completions", + UpstreamPath: "/v1/chat/completions", + }, + }, + }) + + _, _, err := (&Adaptor{}).BuildModelListRequest(info) + require.Error(t, err) + assert.Contains(t, err.Error(), "does not configure a /v1/models route") +} + func TestAdaptorConvertsResponsesRequestToOpenAIChatUpstream(t *testing.T) { adaptor := &Adaptor{} info := advancedCustomRelayInfo(&dto.AdvancedCustomConfig{ diff --git a/web/default/src/features/channels/api.ts b/web/default/src/features/channels/api.ts index ba8a67ef..09bad1f9 100644 --- a/web/default/src/features/channels/api.ts +++ b/web/default/src/features/channels/api.ts @@ -523,12 +523,16 @@ export async function getTagModels( // ============================================================================ /** - * Fetch models from a custom endpoint (for testing before creating channel) + * Fetch models from the current unsaved channel form configuration. */ export async function fetchModels(data: { base_url: string type: number - key: string + key?: string + channel_id?: number + advanced_custom?: string + header_override?: string + proxy?: string }): Promise { const res = await api.post( '/api/channel/fetch_models', diff --git a/web/default/src/features/channels/components/dialogs/advanced-custom-editor-dialog.tsx b/web/default/src/features/channels/components/dialogs/advanced-custom-editor-dialog.tsx index 8dc4222d..de114e9d 100644 --- a/web/default/src/features/channels/components/dialogs/advanced-custom-editor-dialog.tsx +++ b/web/default/src/features/channels/components/dialogs/advanced-custom-editor-dialog.tsx @@ -67,6 +67,7 @@ import { ADVANCED_CUSTOM_AUTH_MODE_OPTIONS, ADVANCED_CUSTOM_CONVERTER_OPTIONS, ADVANCED_CUSTOM_INCOMING_PATH_OPTIONS, + ADVANCED_CUSTOM_MODEL_LIST_PATH, ADVANCED_CUSTOM_TEMPLATE_OPTIONS, type AdvancedCustomAuthMode, buildAdvancedCustomAuth, @@ -215,6 +216,17 @@ export function AdvancedCustomEditorDialog({ [routeKeys, routes] ) const routeGroups = useMemo(() => buildRouteGroups(routeRows), [routeRows]) + const usedIncomingPaths = useMemo( + () => new Set(routeGroups.map((routeGroup) => routeGroup.incomingPath)), + [routeGroups] + ) + const availableIncomingPathOptions = useMemo( + () => + ADVANCED_CUSTOM_INCOMING_PATH_OPTIONS.filter( + (option) => !usedIncomingPaths.has(option.value) + ), + [usedIncomingPaths] + ) const validationError = useMemo( () => validateAdvancedCustomConfig(normalizedConfig), [normalizedConfig] @@ -250,14 +262,19 @@ export function AdvancedCustomEditorDialog({ setRouteKeys(nextRouteKeys) } - const addRoute = () => { + const addRoute = (incomingPath: string | null) => { + if (!incomingPath || usedIncomingPaths.has(incomingPath)) return setConfig((current) => { const next = normalizeAdvancedCustomConfig(current) return { ...next, advanced_routes: [ ...(next.advanced_routes || []), - createAdvancedCustomRoute(), + { + ...createAdvancedCustomRoute(), + incoming_path: incomingPath, + upstream_path: incomingPath, + }, ], } }) @@ -308,6 +325,15 @@ export function AdvancedCustomEditorDialog({ ) const nextRoutes = routes.map((route, routeIndex) => { if (!groupRouteIndexes.has(routeIndex)) return route + if (resolvedIncomingPath === ADVANCED_CUSTOM_MODEL_LIST_PATH) { + return { + ...route, + incoming_path: resolvedIncomingPath, + upstream_path: ADVANCED_CUSTOM_MODEL_LIST_PATH, + converter: 'none' as const, + models: [], + } + } const converter = route.converter || 'none' return { ...route, @@ -592,15 +618,45 @@ export function AdvancedCustomEditorDialog({ {editMode === 'visual' ? (
- + + + + + + + {availableIncomingPathOptions.map((option) => ( + +
+ {t(option.label)} + + {option.value} + +
+
+ ))} +
+
+
{validationError ? ( @@ -635,6 +691,7 @@ export function AdvancedCustomEditorDialog({ addRouteForIncomingPath(routeGroup.incomingPath) @@ -690,6 +747,7 @@ export function AdvancedCustomEditorDialog({ function RouteGroupEditor({ group, + usedIncomingPaths, validationError, onAddRoute, onIncomingPathChange, @@ -699,6 +757,7 @@ function RouteGroupEditor({ onRouteChange, }: { group: AdvancedCustomRouteGroup + usedIncomingPaths: ReadonlySet validationError: ReturnType onAddRoute: () => void onIncomingPathChange: (incomingPath: string | null) => void @@ -709,6 +768,7 @@ function RouteGroupEditor({ }) { const { t } = useTranslation() const incomingPath = group.incomingPath || '/v1/chat/completions' + const isModelListGroup = incomingPath === ADVANCED_CUSTOM_MODEL_LIST_PATH const incomingPathLabel = getAdvancedCustomIncomingPathLabel(incomingPath) const catchAllRoute = group.routeRows.find((routeRow) => isCatchAllRoute(routeRow.route) @@ -741,17 +801,21 @@ function RouteGroupEditor({ {group.routeRows.length} {t('Routes')} - - {hasCatchAll ? t('Fallback route') : t('Model-scoped only')} - - {!catchAllIsLast ? ( + {isModelListGroup ? ( + {t('OpenAI Models')} + ) : ( + + {hasCatchAll ? t('Fallback route') : t('Model-scoped only')} + + )} + {!isModelListGroup && !catchAllIsLast ? ( {t('Fallback must be last')} ) : null}
- + {!isModelListGroup ? ( + + ) : null}

- {t( - 'Routes with the same incoming path are split by client model rules. Unmatched requests use the final fallback.' - )} + {isModelListGroup + ? t( + 'This route discovers upstream OpenAI models and cannot be split or matched by client model rules.' + ) + : t( + 'Routes with the same incoming path are split by client model rules. Unmatched requests use the final fallback.' + )}

{groupHasError && validationError ? (

@@ -880,6 +961,7 @@ function RouteEditor({ const authMode = getAdvancedCustomAuthMode(route) const incomingPath = route.incoming_path || getDefaultAdvancedCustomIncomingPath(converter) + const isModelListRoute = incomingPath === ADVANCED_CUSTOM_MODEL_LIST_PATH const converterOptions = useMemo( () => getAdvancedCustomConverterOptions(incomingPath), [incomingPath] @@ -897,7 +979,7 @@ function RouteEditor({ const ConverterVisualIcon = isNativeConverter ? ArrowRight : Shuffle const modelsInputValue = route.models?.join(', ') || '' const parsedRouteModels = parseAdvancedCustomRouteModels(modelsInputValue) - const isFallback = parsedRouteModels.length === 0 + const isFallback = !isModelListRoute && parsedRouteModels.length === 0 const setConverter = (nextConverter: AdvancedCustomConverter) => { let nextIncomingPath = incomingPath @@ -965,7 +1047,10 @@ function RouteEditor({

{t('Route')} {index + 1}
- {isFallback ? ( + {isModelListRoute ? ( + {t('OpenAI Models')} + ) : null} + {!isModelListRoute && isFallback ? ( {t('Fallback')} ) : null} @@ -1033,42 +1118,50 @@ function RouteEditor({ className='lg:gap-1' labelClassName='lg:sr-only' > - setModelsInput(event.target.value)} - onBlur={(event) => normalizeModelsInput(event.target.value)} - placeholder={ - isFallback - ? t('Leave empty for fallback') - : t('e.g. gpt-4o, gemini-2.5-flash') - } - aria-invalid={Boolean(errorMessage)} - /> -
- {isFallback ? ( - {t('Fallback')} - ) : ( - parsedRouteModels.map((model) => { - const ruleKind = getAdvancedCustomModelRuleKind(model) - const displayModel = - ruleKind === 'regex' - ? getAdvancedCustomRegexModelPattern(model) || model - : model - return ( - - - {t(ruleKind === 'regex' ? 'Regex' : 'Exact')} - - {displayModel} - - ) - }) - )} -
+ {isModelListRoute && parsedRouteModels.length === 0 ? ( +
+ {t('OpenAI Models')} +
+ ) : ( + <> + setModelsInput(event.target.value)} + onBlur={(event) => normalizeModelsInput(event.target.value)} + placeholder={ + isFallback + ? t('Leave empty for fallback') + : t('e.g. gpt-4o, gemini-2.5-flash') + } + aria-invalid={Boolean(errorMessage)} + /> +
+ {isFallback ? ( + {t('Fallback')} + ) : ( + parsedRouteModels.map((model) => { + const ruleKind = getAdvancedCustomModelRuleKind(model) + const displayModel = + ruleKind === 'regex' + ? getAdvancedCustomRegexModelPattern(model) || model + : model + return ( + + + {t(ruleKind === 'regex' ? 'Regex' : 'Exact')} + + {displayModel} + + ) + }) + )} +
+ + )}