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 <i@caion.me>
This commit is contained in:
Seefs
2026-07-18 13:39:53 +08:00
committed by GitHub
co-authored by CaIon
parent 57746fc972
commit a6cf42c0f1
24 changed files with 1324 additions and 135 deletions
+135 -33
View File
@@ -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{
+119 -5
View File
@@ -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{}{
+338 -2
View File
@@ -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)
+23 -4
View File
@@ -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": []
}
]
}
}
+33
View File
@@ -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
}
+88
View File
@@ -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{
+5
View File
@@ -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
}
+68
View File
@@ -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)
})
}
}
+53 -4
View File
@@ -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
@@ -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{
+6 -2
View File
@@ -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<FetchModelsResponse> {
const res = await api.post(
'/api/channel/fetch_models',
@@ -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' ? (
<div className='flex flex-col gap-4 p-4 lg:gap-3'>
<div className='flex justify-end border-y py-4 lg:py-2'>
<Button
type='button'
variant='outline'
size='sm'
onClick={addRoute}
<Select
items={availableIncomingPathOptions.map((option) => option.value)}
value={null}
onValueChange={(incomingPath) => {
if (typeof incomingPath === 'string') {
addRoute(incomingPath)
}
}}
>
<Plus data-icon='inline-start' />
{t('Add route')}
</Button>
<SelectTrigger
size='sm'
disabled={availableIncomingPathOptions.length === 0}
>
<Plus data-icon='inline-start' />
<SelectValue placeholder={t('Add route')} />
</SelectTrigger>
<SelectContent
align='end'
alignItemWithTrigger={false}
className={longSelectContentClass}
>
<SelectGroup>
{availableIncomingPathOptions.map((option) => (
<SelectItem
key={option.value}
value={option.value}
className={longSelectItemClass}
>
<div className='flex min-w-0 flex-col gap-1 leading-snug whitespace-normal'>
<span>{t(option.label)}</span>
<span className='text-muted-foreground font-mono text-xs break-all'>
{option.value}
</span>
</div>
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div>
{validationError ? (
@@ -635,6 +691,7 @@ export function AdvancedCustomEditorDialog({
<RouteGroupEditor
key={routeGroup.incomingPath || 'advanced-custom-empty-path'}
group={routeGroup}
usedIncomingPaths={usedIncomingPaths}
validationError={validationError}
onAddRoute={() =>
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<string>
validationError: ReturnType<typeof validateAdvancedCustomConfig>
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({
<Badge variant='secondary'>
{group.routeRows.length} {t('Routes')}
</Badge>
<Badge variant={hasCatchAll ? 'outline' : 'secondary'}>
{hasCatchAll ? t('Fallback route') : t('Model-scoped only')}
</Badge>
{!catchAllIsLast ? (
{isModelListGroup ? (
<Badge variant='outline'>{t('OpenAI Models')}</Badge>
) : (
<Badge variant={hasCatchAll ? 'outline' : 'secondary'}>
{hasCatchAll ? t('Fallback route') : t('Model-scoped only')}
</Badge>
)}
{!isModelListGroup && !catchAllIsLast ? (
<Badge variant='destructive'>{t('Fallback must be last')}</Badge>
) : null}
</div>
<Select value={incomingPath} onValueChange={onIncomingPathChange}>
<SelectTrigger className='h-9 max-w-full lg:max-w-[420px]'>
<SelectValue className='min-w-0 truncate'>
{incomingPathLabel}
{t(incomingPathLabel)}
</SelectValue>
</SelectTrigger>
<SelectContent
@@ -763,10 +827,16 @@ function RouteGroupEditor({
<SelectItem
key={option.value}
value={option.value}
disabled={
(option.value !== incomingPath &&
usedIncomingPaths.has(option.value)) ||
(option.value === ADVANCED_CUSTOM_MODEL_LIST_PATH &&
group.routeRows.length > 1)
}
className={longSelectItemClass}
>
<div className='flex min-w-0 flex-col gap-1 leading-snug whitespace-normal'>
<span>{option.label}</span>
<span>{t(option.label)}</span>
<span className='text-muted-foreground font-mono text-xs break-all'>
{option.value}
</span>
@@ -778,17 +848,28 @@ function RouteGroupEditor({
</Select>
</div>
<Button type='button' variant='outline' size='sm' onClick={onAddRoute}>
<Plus data-icon='inline-start' />
{t('Add split')}
</Button>
{!isModelListGroup ? (
<Button
type='button'
variant='outline'
size='sm'
onClick={onAddRoute}
>
<Plus data-icon='inline-start' />
{t('Add split')}
</Button>
) : null}
</div>
<div className='border-t px-3 py-2'>
<p className='text-muted-foreground text-xs leading-relaxed'>
{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.'
)}
</p>
{groupHasError && validationError ? (
<p className='text-destructive mt-1 text-xs'>
@@ -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({
<div className='text-sm font-medium'>
{t('Route')} {index + 1}
</div>
{isFallback ? (
{isModelListRoute ? (
<Badge variant='outline'>{t('OpenAI Models')}</Badge>
) : null}
{!isModelListRoute && isFallback ? (
<Badge variant='outline'>{t('Fallback')}</Badge>
) : null}
<TooltipProvider delay={100}>
@@ -1033,42 +1118,50 @@ function RouteEditor({
className='lg:gap-1'
labelClassName='lg:sr-only'
>
<Input
value={modelsInputValue}
onChange={(event) => 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)}
/>
<div className='flex flex-wrap gap-1'>
{isFallback ? (
<Badge variant='outline'>{t('Fallback')}</Badge>
) : (
parsedRouteModels.map((model) => {
const ruleKind = getAdvancedCustomModelRuleKind(model)
const displayModel =
ruleKind === 'regex'
? getAdvancedCustomRegexModelPattern(model) || model
: model
return (
<Badge
key={model}
variant={ruleKind === 'regex' ? 'outline' : 'secondary'}
className='max-w-full gap-1.5 font-mono'
>
<span className='font-sans text-[10px] font-semibold tracking-normal uppercase'>
{t(ruleKind === 'regex' ? 'Regex' : 'Exact')}
</span>
<span className='truncate'>{displayModel}</span>
</Badge>
)
})
)}
</div>
{isModelListRoute && parsedRouteModels.length === 0 ? (
<div className='flex h-9 items-center'>
<Badge variant='outline'>{t('OpenAI Models')}</Badge>
</div>
) : (
<>
<Input
value={modelsInputValue}
onChange={(event) => 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)}
/>
<div className='flex flex-wrap gap-1'>
{isFallback ? (
<Badge variant='outline'>{t('Fallback')}</Badge>
) : (
parsedRouteModels.map((model) => {
const ruleKind = getAdvancedCustomModelRuleKind(model)
const displayModel =
ruleKind === 'regex'
? getAdvancedCustomRegexModelPattern(model) || model
: model
return (
<Badge
key={model}
variant={ruleKind === 'regex' ? 'outline' : 'secondary'}
className='max-w-full gap-1.5 font-mono'
>
<span className='font-sans text-[10px] font-semibold tracking-normal uppercase'>
{t(ruleKind === 'regex' ? 'Regex' : 'Exact')}
</span>
<span className='truncate'>{displayModel}</span>
</Badge>
)
})
)}
</div>
</>
)}
</FieldBlock>
<FieldBlock
@@ -1100,6 +1193,7 @@ function RouteEditor({
>
<Select
value={converter}
disabled={isModelListRoute && converter === 'none'}
onValueChange={(value) =>
setConverter(value as AdvancedCustomConverter)
}
@@ -759,6 +759,9 @@ export function ChannelMutateDrawer({
const currentUpstreamModelUpdateIgnoredModels = form.watch(
'upstream_model_update_ignored_models'
)
const shouldPreviewUnsavedModels =
!isEditing ||
(currentType === CHANNEL_TYPE_ADVANCED_CUSTOM && canEditSensitive)
const {
unlocked: doubaoApiEditUnlocked,
handleClick: handleApiConfigSecretClick,
@@ -866,7 +869,7 @@ export function ChannelMutateDrawer({
advancedCustomRouteTypeLabels.length
const advancedCustomRouteTypeTitle =
hiddenAdvancedCustomRouteTypeCount > 0
? advancedCustomStats.routeTypeLabels.join(', ')
? advancedCustomStats.routeTypeLabels.map((label) => t(label)).join(', ')
: undefined
// Get all models list
@@ -1421,8 +1424,8 @@ export function ChannelMutateDrawer({
return
}
// For creation mode, validate key before opening dialog
if (!isEditing) {
// Advanced Custom may use a model discovery route with no authentication.
if (!isEditing && type !== CHANNEL_TYPE_ADVANCED_CUSTOM) {
const key = form.getValues('key')
if (!key?.trim()) {
toast.error(t('Please enter API key first'))
@@ -1433,20 +1436,30 @@ export function ChannelMutateDrawer({
setFetchModelsDialogOpen(true)
}, [isEditing, canEditSensitive, form, t])
const createModeFetcher = useCallback(async (): Promise<string[]> => {
const formPreviewFetcher = useCallback(async (): Promise<string[]> => {
if (!canEditSensitive) {
throw new Error(t("You don't have necessary permission"))
}
const type = form.getValues('type')
const editingAdvancedCustom =
isEditing && type === CHANNEL_TYPE_ADVANCED_CUSTOM
if (editingAdvancedCustom && channelId === null) {
throw new Error(t('No channel selected'))
}
const response = await fetchModels({
type: form.getValues('type'),
key: form.getValues('key'),
type,
key: isEditing ? undefined : form.getValues('key'),
channel_id: editingAdvancedCustom ? channelId || undefined : undefined,
base_url: form.getValues('base_url') || '',
advanced_custom: form.getValues('advanced_custom'),
header_override: form.getValues('header_override'),
proxy: form.getValues('proxy'),
})
if (response.success && response.data) {
return response.data
}
throw new Error(response.message || 'No models fetched from upstream')
}, [canEditSensitive, form, t])
throw new Error(response.message || t('No models fetched from upstream'))
}, [canEditSensitive, channelId, form, isEditing, t])
// Handle model operations
const handleFillRelatedModels = useCallback(() => {
@@ -2787,10 +2800,10 @@ export function ChannelMutateDrawer({
key={label}
variant='outline'
className='max-w-[12rem]'
title={label}
title={t(label)}
>
<span className='truncate'>
{label}
{t(label)}
</span>
</Badge>
)
@@ -4514,6 +4527,7 @@ export function ChannelMutateDrawer({
'Periodically check for upstream model changes'
)}
</FormDescription>
<FormMessage />
</div>
<FormControl>
<Switch
@@ -4682,10 +4696,14 @@ export function ChannelMutateDrawer({
}}
redirectModels={redirectModelList}
redirectSourceModels={redirectModelKeyList}
customFetcher={!isEditing ? createModeFetcher : undefined}
channelName={!isEditing ? currentName?.trim() : undefined}
customFetcher={
shouldPreviewUnsavedModels ? formPreviewFetcher : undefined
}
channelName={
shouldPreviewUnsavedModels ? currentName?.trim() : undefined
}
existingModelsOverride={
!isEditing
shouldPreviewUnsavedModels
? parseModelsString(form.getValues('models') || '')
: undefined
}
+1 -1
View File
@@ -377,7 +377,7 @@ export const FIELD_DESCRIPTIONS = {
// ============================================================================
export const MODEL_FETCHABLE_TYPES = new Set([
1, 4, 14, 17, 20, 23, 24, 25, 26, 27, 31, 34, 35, 40, 42, 43, 47, 48, 57,
1, 4, 14, 17, 20, 23, 24, 25, 26, 27, 31, 34, 35, 40, 42, 43, 47, 48, 57, 58,
])
export const TYPE_TO_KEY_PROMPT: Record<number, string> = {
@@ -25,6 +25,7 @@ import type {
} from '../types'
export const CHANNEL_TYPE_ADVANCED_CUSTOM = 58
export const ADVANCED_CUSTOM_MODEL_LIST_PATH = '/v1/models'
export const ADVANCED_CUSTOM_CONVERTER_OPTIONS: Array<{
value: AdvancedCustomConverter
@@ -104,6 +105,10 @@ export const ADVANCED_CUSTOM_INCOMING_PATH_OPTIONS: AdvancedCustomIncomingPathOp
value: '/v1/responses/compact',
label: 'OpenAI Responses Compact',
},
{
value: ADVANCED_CUSTOM_MODEL_LIST_PATH,
label: 'OpenAI Models',
},
{
value: '/v1/embeddings',
label: 'OpenAI Embeddings',
@@ -160,6 +165,7 @@ export const ADVANCED_CUSTOM_INCOMING_PATH_OPTIONS: AdvancedCustomIncomingPathOp
const ADVANCED_CUSTOM_ROUTE_SUMMARY_LABELS: Record<string, string> = {
'/v1/chat/completions': 'OpenAI Chat',
[ADVANCED_CUSTOM_MODEL_LIST_PATH]: 'OpenAI Models',
}
export type AdvancedCustomValidationError = {
@@ -537,6 +543,7 @@ export function validateAdvancedCustomConfig(
string,
{ catchAllIndex: number | null; models: Map<string, number> }
>()
let modelListRouteIndex: number | null = null
for (let index = 0; index < routes.length; index += 1) {
const route = routes[index]
const incomingPath = route.incoming_path?.trim() || ''
@@ -556,6 +563,33 @@ export function validateAdvancedCustomConfig(
message: 'Incoming path must not include query',
}
}
if (incomingPath === ADVANCED_CUSTOM_MODEL_LIST_PATH) {
if (modelListRouteIndex !== null) {
return {
routeIndex: index,
message: 'Only one OpenAI Models route is allowed',
}
}
modelListRouteIndex = index
if (routeModels.length > 0) {
return {
routeIndex: index,
message: 'OpenAI Models route does not support client model rules',
}
}
if (converter !== 'none') {
return {
routeIndex: index,
message: 'OpenAI Models route must use native forwarding',
}
}
if (upstreamPath.includes('{model}')) {
return {
routeIndex: index,
message: 'OpenAI Models upstream path must not contain {model}',
}
}
}
const routeModelsError = validateAdvancedCustomRouteModels(
index,
incomingPath,
@@ -594,6 +628,16 @@ export function validateAdvancedCustomConfig(
return null
}
export function hasValidAdvancedCustomModelListRoute(
config: AdvancedCustomConfig | null
): boolean {
if (!config || validateAdvancedCustomConfig(config)) return false
const normalized = normalizeAdvancedCustomConfig(config)
return (normalized.advanced_routes || []).some(
(route) => route.incoming_path?.trim() === ADVANCED_CUSTOM_MODEL_LIST_PATH
)
}
export function advancedCustomConfigUsesRelativeUpstreamPath(
config: AdvancedCustomConfig | null
): boolean {
+30 -10
View File
@@ -27,6 +27,7 @@ import type { Channel } from '../types'
import {
CHANNEL_TYPE_ADVANCED_CUSTOM,
advancedCustomConfigUsesRelativeUpstreamPath,
hasValidAdvancedCustomModelListRoute,
parseAdvancedCustomConfig,
stringifyAdvancedCustomConfig,
validateAdvancedCustomConfig,
@@ -238,6 +239,16 @@ export const channelFormSchema = z
'Base URL is required when an advanced route uses an upstream path'
)
}
if (
data.upstream_model_update_check_enabled === true &&
!hasValidAdvancedCustomModelListRoute(advancedCustomConfig)
) {
addRequiredIssue(
ctx,
'upstream_model_update_check_enabled',
'OpenAI Models route is required to enable upstream model checks'
)
}
}
if ([3, 18, 21, 39, 41, 49].includes(data.type) && !data.other?.trim()) {
@@ -563,13 +574,18 @@ function buildSettingsJSON(formData: ChannelFormValues): string {
formData.allow_include_obfuscation === true
settingsObj.allow_inference_geo = formData.allow_inference_geo === true
} else {
if ('disable_store' in settingsObj) delete settingsObj.disable_store
if ('allow_safety_identifier' in settingsObj)
if ('disable_store' in settingsObj) {
delete settingsObj.disable_store
}
if ('allow_safety_identifier' in settingsObj) {
delete settingsObj.allow_safety_identifier
if ('allow_include_obfuscation' in settingsObj)
}
if ('allow_include_obfuscation' in settingsObj) {
delete settingsObj.allow_include_obfuscation
if (formData.type !== 14 && 'allow_inference_geo' in settingsObj)
}
if (formData.type !== 14 && 'allow_inference_geo' in settingsObj) {
delete settingsObj.allow_inference_geo
}
}
// Anthropic (type 14): claude_beta_query, allow_inference_geo, allow_speed
@@ -578,8 +594,12 @@ function buildSettingsJSON(formData: ChannelFormValues): string {
settingsObj.allow_speed = formData.allow_speed === true
settingsObj.claude_beta_query = formData.claude_beta_query === true
} else {
if ('allow_speed' in settingsObj) delete settingsObj.allow_speed
if ('claude_beta_query' in settingsObj) delete settingsObj.claude_beta_query
if ('allow_speed' in settingsObj) {
delete settingsObj.allow_speed
}
if ('claude_beta_query' in settingsObj) {
delete settingsObj.claude_beta_query
}
}
settingsObj.disable_task_polling_sleep =
@@ -592,14 +612,14 @@ function buildSettingsJSON(formData: ChannelFormValues): string {
settingsObj.upstream_model_update_auto_sync_enabled =
settingsObj.upstream_model_update_check_enabled === true &&
formData.upstream_model_update_auto_sync_enabled === true
settingsObj.upstream_model_update_ignored_models = Array.from(
new Set(
settingsObj.upstream_model_update_ignored_models = [
...new Set(
String(formData.upstream_model_update_ignored_models || '')
.split(',')
.map((model) => model.trim())
.filter(Boolean)
)
)
),
]
if (
!Array.isArray(settingsObj.upstream_model_update_last_detected_models) ||
settingsObj.upstream_model_update_check_enabled !== true
+7
View File
@@ -3084,6 +3084,7 @@
"Only enter the site origin, for example https://api.example.com. Do not include any path such as /api/user/epay/notify. Leave blank to use the server address.": "Only enter the site origin, for example https://api.example.com. Do not include any path such as /api/user/epay/notify. Leave blank to use the server address.",
"Only Mine": "Only Mine",
"Only one catch-all route is allowed for the same incoming path": "Only one catch-all route is allowed for the same incoming path",
"Only one OpenAI Models route is allowed": "Only one OpenAI Models route is allowed",
"Only selected fields will be overwritten. You can re-run the sync wizard if new conflicts appear.": "Only selected fields will be overwritten. You can re-run the sync wizard if new conflicts appear.",
"Only successful requests": "Only successful requests",
"Only successful requests count toward this limit.": "Only successful requests count toward this limit.",
@@ -3116,6 +3117,11 @@
"OpenAI Embeddings": "OpenAI Embeddings",
"OpenAI Image Edits": "OpenAI Image Edits",
"OpenAI Image Generations": "OpenAI Image Generations",
"OpenAI Models": "OpenAI Models",
"OpenAI Models route does not support client model rules": "OpenAI Models route does not support client model rules",
"OpenAI Models route is required to enable upstream model checks": "OpenAI Models route is required to enable upstream model checks",
"OpenAI Models route must use native forwarding": "OpenAI Models route must use native forwarding",
"OpenAI Models upstream path must not contain {model}": "OpenAI Models upstream path must not contain {model}",
"OpenAI Organization": "OpenAI Organization",
"OpenAI Organization ID (optional)": "OpenAI Organization ID (optional)",
"OpenAI Realtime": "OpenAI Realtime",
@@ -4528,6 +4534,7 @@
"This plan does not allow balance redemption": "This plan does not allow balance redemption",
"This project must be used in compliance with the": "This project must be used in compliance with the",
"This removes {{count}} failed models from this channel. This action cannot be undone.": "This removes {{count}} failed models from this channel. This action cannot be undone.",
"This route discovers upstream OpenAI models and cannot be split or matched by client model rules.": "This route discovers upstream OpenAI models and cannot be split or matched by client model rules.",
"This site currently has {{count}} models enabled": "This site currently has {{count}} models enabled",
"This tier catches any request that did not match earlier tiers.": "This tier catches any request that did not match earlier tiers.",
"this token group": "this token group",
+7
View File
@@ -3084,6 +3084,7 @@
"Only enter the site origin, for example https://api.example.com. Do not include any path such as /api/user/epay/notify. Leave blank to use the server address.": "Saisissez uniquement lorigine du site, par exemple https://api.example.com. Najoutez aucun chemin comme /api/user/epay/notify. Laissez vide pour utiliser ladresse du serveur.",
"Only Mine": "Uniquement les miens",
"Only one catch-all route is allowed for the same incoming path": "Un seul routage de secours est autorisé pour le même chemin d'entrée",
"Only one OpenAI Models route is allowed": "Une seule route Modèles OpenAI est autorisée",
"Only selected fields will be overwritten. You can re-run the sync wizard if new conflicts appear.": "Seuls les champs sélectionnés seront écrasés. Vous pouvez relancer l'assistant de synchronisation si de nouveaux conflits apparaissent.",
"Only successful requests": "Uniquement les requêtes réussies",
"Only successful requests count toward this limit.": "Seules les requêtes réussies comptent pour cette limite.",
@@ -3116,6 +3117,11 @@
"OpenAI Embeddings": "OpenAI Embeddings",
"OpenAI Image Edits": "OpenAI Image Edits",
"OpenAI Image Generations": "OpenAI Image Generations",
"OpenAI Models": "Modèles OpenAI",
"OpenAI Models route does not support client model rules": "La route Modèles OpenAI ne prend pas en charge les règles de modèles clients",
"OpenAI Models route is required to enable upstream model checks": "La route Modèles OpenAI est requise pour activer la vérification des modèles en amont",
"OpenAI Models route must use native forwarding": "La route Modèles OpenAI doit utiliser le transfert natif",
"OpenAI Models upstream path must not contain {model}": "Le chemin amont de la route Modèles OpenAI ne doit pas contenir {model}",
"OpenAI Organization": "Organisation OpenAI",
"OpenAI Organization ID (optional)": "Identifiant d'organisation OpenAI (optionnel)",
"OpenAI Realtime": "OpenAI Realtime",
@@ -4528,6 +4534,7 @@
"This plan does not allow balance redemption": "Ce forfait ne permet pas le paiement avec le solde",
"This project must be used in compliance with the": "Ce projet doit être utilisé conformément aux",
"This removes {{count}} failed models from this channel. This action cannot be undone.": "Cela supprime {{count}} modèles en échec de ce canal. Cette action est irréversible.",
"This route discovers upstream OpenAI models and cannot be split or matched by client model rules.": "Cette route découvre les modèles OpenAI en amont et ne peut être ni divisée ni associée par des règles de modèles clients.",
"This site currently has {{count}} models enabled": "Ce site compte actuellement {{count}} modèles activés",
"This tier catches any request that did not match earlier tiers.": "Ce palier récupère toute requête qui ne correspond à aucun palier précédent.",
"this token group": "ce groupe de jetons",
+7
View File
@@ -3084,6 +3084,7 @@
"Only enter the site origin, for example https://api.example.com. Do not include any path such as /api/user/epay/notify. Leave blank to use the server address.": "サイトのオリジンのみを入力してください。例: https://api.example.com。/api/user/epay/notify などのパスは含めないでください。空欄の場合はサーバーアドレスを使用します。",
"Only Mine": "自分のみ",
"Only one catch-all route is allowed for the same incoming path": "同じ入力パスではキャッチオールルートは1つだけ許可されます",
"Only one OpenAI Models route is allowed": "OpenAI モデルルートは 1 つだけ設定できます",
"Only selected fields will be overwritten. You can re-run the sync wizard if new conflicts appear.": "選択されたフィールドのみが上書きされます。新しい競合が発生した場合は、同期ウィザードを再実行できます。",
"Only successful requests": "成功したリクエストのみ",
"Only successful requests count toward this limit.": "成功したリクエストのみがこの制限にカウントされます。",
@@ -3116,6 +3117,11 @@
"OpenAI Embeddings": "OpenAI 埋め込み",
"OpenAI Image Edits": "OpenAI 画像編集",
"OpenAI Image Generations": "OpenAI 画像生成",
"OpenAI Models": "OpenAI モデル",
"OpenAI Models route does not support client model rules": "OpenAI モデルルートはクライアントモデルルールに対応していません",
"OpenAI Models route is required to enable upstream model checks": "アップストリームモデルの確認を有効にするには OpenAI モデルルートが必要です",
"OpenAI Models route must use native forwarding": "OpenAI モデルルートではネイティブ転送を使用する必要があります",
"OpenAI Models upstream path must not contain {model}": "OpenAI モデルのアップストリームパスに {model} を含めることはできません",
"OpenAI Organization": "OpenAI組織",
"OpenAI Organization ID (optional)": "OpenAI 組織 ID (オプション)",
"OpenAI Realtime": "OpenAI リアルタイム",
@@ -4528,6 +4534,7 @@
"This plan does not allow balance redemption": "このプランでは残高での交換は許可されていません",
"This project must be used in compliance with the": "このプロジェクトは、以下を遵守して使用する必要があります",
"This removes {{count}} failed models from this channel. This action cannot be undone.": "この操作はこのチャンネルから失敗した {{count}} 個のモデルを削除します。元に戻せません。",
"This route discovers upstream OpenAI models and cannot be split or matched by client model rules.": "このルートはアップストリームの OpenAI モデルを検出するためのもので、分割やクライアントモデルルールによる照合はできません。",
"This site currently has {{count}} models enabled": "このサイトでは現在 {{count}} 個のモデルが有効です",
"This tier catches any request that did not match earlier tiers.": "この段階は、前の段階に一致しなかったすべてのリクエストを受け取ります。",
"this token group": "このトークングループ",
+7
View File
@@ -3084,6 +3084,7 @@
"Only enter the site origin, for example https://api.example.com. Do not include any path such as /api/user/epay/notify. Leave blank to use the server address.": "Введите только origin сайта, например https://api.example.com. Не добавляйте пути, например /api/user/epay/notify. Оставьте пустым, чтобы использовать адрес сервера.",
"Only Mine": "Только мои",
"Only one catch-all route is allowed for the same incoming path": "Для одного входного пути разрешен только один резервный маршрут",
"Only one OpenAI Models route is allowed": "Допускается только один маршрут моделей OpenAI",
"Only selected fields will be overwritten. You can re-run the sync wizard if new conflicts appear.": "Будут перезаписаны только выбранные поля. Вы можете повторно запустить мастер синхронизации, если появятся новые конфликты.",
"Only successful requests": "Только успешные запросы",
"Only successful requests count toward this limit.": "Только успешные запросы учитываются в этом лимите.",
@@ -3116,6 +3117,11 @@
"OpenAI Embeddings": "Векторные представления OpenAI",
"OpenAI Image Edits": "Редактирование изображений OpenAI",
"OpenAI Image Generations": "Генерация изображений OpenAI",
"OpenAI Models": "Модели OpenAI",
"OpenAI Models route does not support client model rules": "Маршрут моделей OpenAI не поддерживает правила клиентских моделей",
"OpenAI Models route is required to enable upstream model checks": "Для проверки моделей вышестоящего сервиса требуется маршрут моделей OpenAI",
"OpenAI Models route must use native forwarding": "Маршрут моделей OpenAI должен использовать прямую передачу",
"OpenAI Models upstream path must not contain {model}": "Путь вышестоящего сервиса для моделей OpenAI не должен содержать {model}",
"OpenAI Organization": "Организация OpenAI",
"OpenAI Organization ID (optional)": "Идентификатор организации OpenAI (необязательно)",
"OpenAI Realtime": "Реальное время OpenAI",
@@ -4528,6 +4534,7 @@
"This plan does not allow balance redemption": "Этот план не разрешает оплату балансом",
"This project must be used in compliance with the": "Этот проект должен использоваться в соответствии с",
"This removes {{count}} failed models from this channel. This action cannot be undone.": "Это удалит {{count}} неуспешных моделей из этого канала. Действие необратимо.",
"This route discovers upstream OpenAI models and cannot be split or matched by client model rules.": "Этот маршрут обнаруживает модели OpenAI вышестоящего сервиса; его нельзя разделять или сопоставлять по правилам клиентских моделей.",
"This site currently has {{count}} models enabled": "На этом сайте сейчас включено моделей: {{count}}",
"This tier catches any request that did not match earlier tiers.": "Этот уровень обрабатывает все запросы, которые не совпали с предыдущими уровнями.",
"this token group": "эта группа токенов",
+7
View File
@@ -3084,6 +3084,7 @@
"Only enter the site origin, for example https://api.example.com. Do not include any path such as /api/user/epay/notify. Leave blank to use the server address.": "Chỉ nhập origin của trang, ví dụ https://api.example.com. Không nhập đường dẫn như /api/user/epay/notify. Để trống để dùng địa chỉ máy chủ.",
"Only Mine": "Chỉ của tôi",
"Only one catch-all route is allowed for the same incoming path": "Mỗi đường dẫn đầu vào chỉ được có một tuyến dự phòng",
"Only one OpenAI Models route is allowed": "Chỉ được phép có một tuyến Mô hình OpenAI",
"Only selected fields will be overwritten. You can re-run the sync wizard if new conflicts appear.": "Chỉ các trường được chọn sẽ bị ghi đè. Bạn có thể chạy lại trình hướng dẫn đồng bộ hóa nếu có xung đột mới xuất hiện.",
"Only successful requests": "Chỉ các yêu cầu thành công",
"Only successful requests count toward this limit.": "Chỉ những yêu cầu thành công mới được tính vào giới hạn này.",
@@ -3116,6 +3117,11 @@
"OpenAI Embeddings": "OpenAI Embeddings",
"OpenAI Image Edits": "OpenAI Image Edits",
"OpenAI Image Generations": "OpenAI Image Generations",
"OpenAI Models": "Mô hình OpenAI",
"OpenAI Models route does not support client model rules": "Tuyến Mô hình OpenAI không hỗ trợ quy tắc mô hình phía máy khách",
"OpenAI Models route is required to enable upstream model checks": "Cần có tuyến Mô hình OpenAI để bật kiểm tra mô hình thượng nguồn",
"OpenAI Models route must use native forwarding": "Tuyến Mô hình OpenAI phải dùng chuyển tiếp nguyên bản",
"OpenAI Models upstream path must not contain {model}": "Đường dẫn thượng nguồn của Mô hình OpenAI không được chứa {model}",
"OpenAI Organization": "Tổ chức OpenAI",
"OpenAI Organization ID (optional)": "ID Tổ chức OpenAI (tùy chọn)",
"OpenAI Realtime": "OpenAI Realtime",
@@ -4528,6 +4534,7 @@
"This plan does not allow balance redemption": "Gói này không cho phép thanh toán bằng số dư",
"This project must be used in compliance with the": "Dự án này phải được sử dụng tuân thủ theo",
"This removes {{count}} failed models from this channel. This action cannot be undone.": "Thao tác này sẽ xóa {{count}} mô hình thất bại khỏi kênh này. Không thể hoàn tác.",
"This route discovers upstream OpenAI models and cannot be split or matched by client model rules.": "Tuyến này khám phá các mô hình OpenAI thượng nguồn và không thể tách hoặc đối sánh bằng quy tắc mô hình phía máy khách.",
"This site currently has {{count}} models enabled": "Trang này hiện đã bật {{count}} mô hình",
"This tier catches any request that did not match earlier tiers.": "Tầng này bắt mọi yêu cầu không khớp với các tầng trước.",
"this token group": "nhóm token này",
+7
View File
@@ -3084,6 +3084,7 @@
"Only enter the site origin, for example https://api.example.com. Do not include any path such as /api/user/epay/notify. Leave blank to use the server address.": "只填寫站點根域名,例如 https://api.example.com。不要填寫 /api/user/epay/notify 這類路徑。留空則使用伺服器地址。",
"Only Mine": "僅自己",
"Only one catch-all route is allowed for the same incoming path": "同一入口路徑只允許一個兜底路由",
"Only one OpenAI Models route is allowed": "僅允許設定一條 OpenAI 模型路由",
"Only selected fields will be overwritten. You can re-run the sync wizard if new conflicts appear.": "僅選定的欄位將會被覆蓋。如果出現新的衝突,您可以重新執行同步精靈。",
"Only successful requests": "僅成功的請求",
"Only successful requests count toward this limit.": "僅成功的請求計入此限制。",
@@ -3116,6 +3117,11 @@
"OpenAI Embeddings": "OpenAI 嵌入",
"OpenAI Image Edits": "OpenAI 圖像編輯",
"OpenAI Image Generations": "OpenAI 圖像生成",
"OpenAI Models": "OpenAI 模型",
"OpenAI Models route does not support client model rules": "OpenAI 模型路由不支援用戶端模型規則",
"OpenAI Models route is required to enable upstream model checks": "啟用上游模型檢查必須設定 OpenAI 模型路由",
"OpenAI Models route must use native forwarding": "OpenAI 模型路由必須使用原生轉發",
"OpenAI Models upstream path must not contain {model}": "OpenAI 模型上游路徑不得包含 {model}",
"OpenAI Organization": "OpenAI 組織",
"OpenAI Organization ID (optional)": "OpenAI 組織 ID(可選)",
"OpenAI Realtime": "OpenAI 實時",
@@ -4528,6 +4534,7 @@
"This plan does not allow balance redemption": "該套餐不允許使用餘額兌換",
"This project must be used in compliance with the": "此項目的使用必須遵守",
"This removes {{count}} failed models from this channel. This action cannot be undone.": "此操作將從該渠道移除 {{count}} 個測試失敗的模型,且無法撤銷。",
"This route discovers upstream OpenAI models and cannot be split or matched by client model rules.": "此路由用於探索上游 OpenAI 模型,無法拆分或使用用戶端模型規則配對。",
"This site currently has {{count}} models enabled": "本站目前已啟用模型,總計 {{count}} 個",
"This tier catches any request that did not match earlier tiers.": "此階梯會兜底處理未匹配前面階梯的請求。",
"this token group": "此令牌分組",
+7
View File
@@ -3084,6 +3084,7 @@
"Only enter the site origin, for example https://api.example.com. Do not include any path such as /api/user/epay/notify. Leave blank to use the server address.": "只填写站点根域名,例如 https://api.example.com。不要填写 /api/user/epay/notify 这类路径。留空则使用服务器地址。",
"Only Mine": "仅自己",
"Only one catch-all route is allowed for the same incoming path": "同一入口路径只允许一个兜底路由",
"Only one OpenAI Models route is allowed": "仅允许配置一条 OpenAI 模型路由",
"Only selected fields will be overwritten. You can re-run the sync wizard if new conflicts appear.": "仅选定的字段将被覆盖。如果出现新的冲突,您可以重新运行同步向导。",
"Only successful requests": "仅成功的请求",
"Only successful requests count toward this limit.": "仅成功的请求计入此限制。",
@@ -3116,6 +3117,11 @@
"OpenAI Embeddings": "OpenAI 嵌入",
"OpenAI Image Edits": "OpenAI 图像编辑",
"OpenAI Image Generations": "OpenAI 图像生成",
"OpenAI Models": "OpenAI 模型",
"OpenAI Models route does not support client model rules": "OpenAI 模型路由不支持客户端模型规则",
"OpenAI Models route is required to enable upstream model checks": "启用上游模型检查必须配置 OpenAI 模型路由",
"OpenAI Models route must use native forwarding": "OpenAI 模型路由必须使用原生转发",
"OpenAI Models upstream path must not contain {model}": "OpenAI 模型上游路径不能包含 {model}",
"OpenAI Organization": "OpenAI 组织",
"OpenAI Organization ID (optional)": "OpenAI 组织 ID(可选)",
"OpenAI Realtime": "OpenAI 实时",
@@ -4528,6 +4534,7 @@
"This plan does not allow balance redemption": "该套餐不允许使用余额兑换",
"This project must be used in compliance with the": "此项目的使用必须遵守",
"This removes {{count}} failed models from this channel. This action cannot be undone.": "此操作将从该渠道移除 {{count}} 个测试失败的模型,且无法撤销。",
"This route discovers upstream OpenAI models and cannot be split or matched by client model rules.": "此路由用于发现上游 OpenAI 模型,不能拆分或使用客户端模型规则匹配。",
"This site currently has {{count}} models enabled": "本站当前已启用模型,总计 {{count}} 个",
"This tier catches any request that did not match earlier tiers.": "此阶梯会兜底处理未匹配前面阶梯的请求。",
"this token group": "此令牌分组",
+8
View File
@@ -530,6 +530,14 @@ export const STATIC_I18N_KEYS = [
'Batch detection failed',
'Batch detection complete: {{channels}} channels, {{add}} to add, {{remove}} to remove, {{fails}} failed',
// Advanced Custom model discovery
'OpenAI Models',
'Only one OpenAI Models route is allowed',
'OpenAI Models route does not support client model rules',
'OpenAI Models route must use native forwarding',
'OpenAI Models upstream path must not contain {model}',
'OpenAI Models route is required to enable upstream model checks',
// Dashboard flow stages (labels/descriptions passed to t at runtime)
'User',
'Node',