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:
+122
-20
@@ -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
|
||||
if err := applyFetchModelsHeaderOverrides(channel, key, headers); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
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)
|
||||
return headers, nil
|
||||
}
|
||||
|
||||
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 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 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,7 +1256,22 @@ func FetchModels(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
baseURL := req.BaseURL
|
||||
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]
|
||||
}
|
||||
@@ -1179,12 +1280,13 @@ func FetchModels(c *gin.Context) {
|
||||
if req.Type != constant.ChannelTypeCodex {
|
||||
key = strings.Split(key, "\n")[0]
|
||||
}
|
||||
|
||||
channel := &model.Channel{
|
||||
channel = &model.Channel{
|
||||
Type: req.Type,
|
||||
Key: key,
|
||||
BaseURL: &baseURL,
|
||||
}
|
||||
}
|
||||
|
||||
models, err := fetchChannelUpstreamModelIDs(channel)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
|
||||
@@ -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{}{
|
||||
|
||||
@@ -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)
|
||||
|
||||
+22
-3
@@ -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": "可选的网络代理。编辑预览时覆盖已保存值,显式空字符串表示清除,省略则沿用。"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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',
|
||||
|
||||
+109
-15
@@ -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'
|
||||
<Select
|
||||
items={availableIncomingPathOptions.map((option) => option.value)}
|
||||
value={null}
|
||||
onValueChange={(incomingPath) => {
|
||||
if (typeof incomingPath === 'string') {
|
||||
addRoute(incomingPath)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger
|
||||
size='sm'
|
||||
onClick={addRoute}
|
||||
disabled={availableIncomingPathOptions.length === 0}
|
||||
>
|
||||
<Plus data-icon='inline-start' />
|
||||
{t('Add route')}
|
||||
</Button>
|
||||
<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>
|
||||
{isModelListGroup ? (
|
||||
<Badge variant='outline'>{t('OpenAI Models')}</Badge>
|
||||
) : (
|
||||
<Badge variant={hasCatchAll ? 'outline' : 'secondary'}>
|
||||
{hasCatchAll ? t('Fallback route') : t('Model-scoped only')}
|
||||
</Badge>
|
||||
{!catchAllIsLast ? (
|
||||
)}
|
||||
{!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,15 +848,26 @@ function RouteGroupEditor({
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<Button type='button' variant='outline' size='sm' onClick={onAddRoute}>
|
||||
{!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(
|
||||
{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>
|
||||
@@ -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,6 +1118,12 @@ function RouteEditor({
|
||||
className='lg:gap-1'
|
||||
labelClassName='lg:sr-only'
|
||||
>
|
||||
{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)}
|
||||
@@ -1069,6 +1160,8 @@ function RouteEditor({
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</FieldBlock>
|
||||
|
||||
<FieldBlock
|
||||
@@ -1100,6 +1193,7 @@ function RouteEditor({
|
||||
>
|
||||
<Select
|
||||
value={converter}
|
||||
disabled={isModelListRoute && converter === 'none'}
|
||||
onValueChange={(value) =>
|
||||
setConverter(value as AdvancedCustomConverter)
|
||||
}
|
||||
|
||||
+31
-13
@@ -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
@@ -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
@@ -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,14 +574,19 @@ 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
|
||||
if (formData.type === 14) {
|
||||
@@ -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
|
||||
|
||||
Vendored
+7
@@ -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",
|
||||
|
||||
Vendored
+7
@@ -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 l’origine du site, par exemple https://api.example.com. N’ajoutez aucun chemin comme /api/user/epay/notify. Laissez vide pour utiliser l’adresse 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",
|
||||
|
||||
Vendored
+7
@@ -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": "このトークングループ",
|
||||
|
||||
Vendored
+7
@@ -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": "эта группа токенов",
|
||||
|
||||
Vendored
+7
@@ -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
@@ -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": "此令牌分組",
|
||||
|
||||
Vendored
+7
@@ -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": "此令牌分组",
|
||||
|
||||
Vendored
+8
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user