mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-09-11 06:30:21 +00:00
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:
+135
-33
@@ -16,6 +16,7 @@ import (
|
|||||||
"github.com/QuantumNous/new-api/model"
|
"github.com/QuantumNous/new-api/model"
|
||||||
relaychannel "github.com/QuantumNous/new-api/relay/channel"
|
relaychannel "github.com/QuantumNous/new-api/relay/channel"
|
||||||
"github.com/QuantumNous/new-api/relay/channel/ollama"
|
"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"
|
||||||
"github.com/QuantumNous/new-api/service/authz"
|
"github.com/QuantumNous/new-api/service/authz"
|
||||||
|
|
||||||
@@ -201,22 +202,29 @@ func buildFetchModelsHeaders(channel *model.Channel, key string) (http.Header, e
|
|||||||
headers = GetAuthHeader(key)
|
headers = GetAuthHeader(key)
|
||||||
}
|
}
|
||||||
|
|
||||||
headerOverride := channel.GetHeaderOverride()
|
if err := applyFetchModelsHeaderOverrides(channel, key, headers); err != nil {
|
||||||
for k, v := range headerOverride {
|
return nil, err
|
||||||
if relaychannel.IsHeaderPassthroughRuleKey(k) {
|
}
|
||||||
continue
|
return headers, nil
|
||||||
}
|
}
|
||||||
str, ok := v.(string)
|
|
||||||
if !ok {
|
func applyFetchModelsHeaderOverrides(channel *model.Channel, key string, headers http.Header) error {
|
||||||
return nil, fmt.Errorf("invalid header override for key %s", k)
|
info := &relaycommon.RelayInfo{
|
||||||
}
|
IsChannelTest: true,
|
||||||
if strings.Contains(str, "{api_key}") {
|
ChannelMeta: &relaycommon.ChannelMeta{
|
||||||
str = strings.ReplaceAll(str, "{api_key}", key)
|
ApiKey: key,
|
||||||
}
|
HeadersOverride: channel.GetHeaderOverride(),
|
||||||
headers.Set(k, str)
|
},
|
||||||
|
}
|
||||||
|
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) {
|
func FetchUpstreamModels(c *gin.Context) {
|
||||||
@@ -464,6 +472,10 @@ func validateTwoFactorAuth(twoFA *model.TwoFA, code string) bool {
|
|||||||
|
|
||||||
// validateChannel 通用的渠道校验函数
|
// validateChannel 通用的渠道校验函数
|
||||||
func validateChannel(channel *model.Channel, isAdd bool) error {
|
func validateChannel(channel *model.Channel, isAdd bool) error {
|
||||||
|
if channel == nil {
|
||||||
|
return fmt.Errorf("channel cannot be empty")
|
||||||
|
}
|
||||||
|
|
||||||
// 校验 channel settings
|
// 校验 channel settings
|
||||||
if err := channel.ValidateSettings(); err != nil {
|
if err := channel.ValidateSettings(); err != nil {
|
||||||
return fmt.Errorf("渠道额外设置[channel setting] 格式错误:%s", err.Error())
|
return fmt.Errorf("渠道额外设置[channel setting] 格式错误:%s", err.Error())
|
||||||
@@ -471,7 +483,7 @@ func validateChannel(channel *model.Channel, isAdd bool) error {
|
|||||||
|
|
||||||
// 如果是添加操作,检查 channel 和 key 是否为空
|
// 如果是添加操作,检查 channel 和 key 是否为空
|
||||||
if isAdd {
|
if isAdd {
|
||||||
if channel == nil || channel.Key == "" {
|
if channel.Key == "" {
|
||||||
return fmt.Errorf("channel cannot be empty")
|
return fmt.Errorf("channel cannot be empty")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1155,13 +1167,87 @@ func equalStringPtr(a, b *string) bool {
|
|||||||
return *a == *b
|
return *a == *b
|
||||||
}
|
}
|
||||||
|
|
||||||
func FetchModels(c *gin.Context) {
|
type fetchModelsRequest struct {
|
||||||
var req struct {
|
ChannelID int `json:"channel_id"`
|
||||||
BaseURL string `json:"base_url"`
|
BaseURL *string `json:"base_url"`
|
||||||
Type int `json:"type"`
|
Type int `json:"type"`
|
||||||
Key string `json:"key"`
|
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 {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{
|
c.JSON(http.StatusBadRequest, gin.H{
|
||||||
"success": false,
|
"success": false,
|
||||||
@@ -1170,21 +1256,37 @@ func FetchModels(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
baseURL := req.BaseURL
|
var channel *model.Channel
|
||||||
if baseURL == "" {
|
if req.Type == constant.ChannelTypeAdvancedCustom || req.ChannelID > 0 {
|
||||||
baseURL = constant.ChannelBaseURLs[req.Type]
|
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)
|
models, err := fetchChannelUpstreamModelIDs(channel)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
|||||||
@@ -2,8 +2,11 @@ package controller
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"regexp"
|
"regexp"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -14,9 +17,13 @@ import (
|
|||||||
"github.com/QuantumNous/new-api/constant"
|
"github.com/QuantumNous/new-api/constant"
|
||||||
"github.com/QuantumNous/new-api/dto"
|
"github.com/QuantumNous/new-api/dto"
|
||||||
"github.com/QuantumNous/new-api/model"
|
"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/gemini"
|
||||||
"github.com/QuantumNous/new-api/relay/channel/ollama"
|
"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/service"
|
||||||
|
"github.com/QuantumNous/new-api/types"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/samber/lo"
|
"github.com/samber/lo"
|
||||||
@@ -255,6 +262,76 @@ func getUpstreamModelUpdateMinCheckIntervalSeconds() int64 {
|
|||||||
return interval
|
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) {
|
func fetchChannelUpstreamModelIDs(channel *model.Channel) ([]string, error) {
|
||||||
baseURL := constant.ChannelBaseURLs[channel.Type]
|
baseURL := constant.ChannelBaseURLs[channel.Type]
|
||||||
if channel.GetBaseURL() != "" {
|
if channel.GetBaseURL() != "" {
|
||||||
@@ -285,6 +362,10 @@ func fetchChannelUpstreamModelIDs(channel *model.Channel) ([]string, error) {
|
|||||||
return normalizeModelNames(models), nil
|
return normalizeModelNames(models), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if channel.Type == constant.ChannelTypeAdvancedCustom {
|
||||||
|
return fetchAdvancedCustomUpstreamModelIDs(channel, baseURL)
|
||||||
|
}
|
||||||
|
|
||||||
if channel.Type == constant.ChannelTypeCodex {
|
if channel.Type == constant.ChannelTypeCodex {
|
||||||
return service.FetchCodexChannelModels(channel)
|
return service.FetchCodexChannelModels(channel)
|
||||||
}
|
}
|
||||||
@@ -323,29 +404,62 @@ func fetchChannelUpstreamModelIDs(channel *model.Channel) ([]string, error) {
|
|||||||
|
|
||||||
headers, err := buildFetchModelsHeaders(channel, key)
|
headers, err := buildFetchModelsHeaders(channel, key)
|
||||||
if err != nil {
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, sanitizeFetchModelsError(err, key)
|
||||||
}
|
}
|
||||||
|
|
||||||
var result OpenAIModelsResponse
|
var result OpenAIModelsResponse
|
||||||
if err := common.Unmarshal(body, &result); err != nil {
|
if err := common.Unmarshal(body, &result); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
ids := lo.Map(result.Data, func(item OpenAIModel, _ int) string {
|
ids := lo.Map(result.Data, func(item OpenAIModel, _ int) string {
|
||||||
if channel.Type == constant.ChannelTypeGemini {
|
if channel.Type == constant.ChannelTypeGemini {
|
||||||
return strings.TrimPrefix(item.ID, "models/")
|
return strings.TrimPrefix(item.ID, "models/")
|
||||||
}
|
}
|
||||||
return item.ID
|
return item.ID
|
||||||
})
|
})
|
||||||
|
|
||||||
return normalizeModelNames(ids), nil
|
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 {
|
func updateChannelUpstreamModelSettings(channel *model.Channel, settings dto.ChannelOtherSettings, updateModels bool) error {
|
||||||
channel.SetOtherSettings(settings)
|
channel.SetOtherSettings(settings)
|
||||||
updates := map[string]interface{}{
|
updates := map[string]interface{}{
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
package controller
|
package controller
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"strings"
|
"net/url"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/QuantumNous/new-api/common"
|
"github.com/QuantumNous/new-api/common"
|
||||||
@@ -14,6 +16,340 @@ import (
|
|||||||
"github.com/stretchr/testify/require"
|
"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) {
|
func TestFetchModelsUsesSharedChannelFetchBehavior(t *testing.T) {
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.URL.Path != "/v1/models" {
|
if r.URL.Path != "/v1/models" {
|
||||||
@@ -39,7 +375,7 @@ func TestFetchModelsUsesSharedChannelFetchBehavior(t *testing.T) {
|
|||||||
|
|
||||||
recorder := httptest.NewRecorder()
|
recorder := httptest.NewRecorder()
|
||||||
ctx, _ := gin.CreateTestContext(recorder)
|
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")
|
ctx.Request.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
FetchModels(ctx)
|
FetchModels(ctx)
|
||||||
|
|||||||
+23
-4
@@ -2967,13 +2967,32 @@
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"base_url": {
|
"base_url": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"description": "上游基础地址。编辑预览时显式空字符串表示清除已保存值,省略则沿用已保存值;相对上游路径要求非空的完整基础地址。"
|
||||||
},
|
},
|
||||||
"type": {
|
"type": {
|
||||||
"type": "integer"
|
"type": "integer",
|
||||||
|
"description": "渠道类型。高级自定义渠道为 58。"
|
||||||
},
|
},
|
||||||
"key": {
|
"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": []
|
"Combination1243": []
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -112,6 +112,9 @@ const (
|
|||||||
advancedCustomEndpointPathEmbeddings = "/v1/embeddings"
|
advancedCustomEndpointPathEmbeddings = "/v1/embeddings"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// AdvancedCustomModelListPath identifies the optional OpenAI Models discovery route.
|
||||||
|
const AdvancedCustomModelListPath = "/v1/models"
|
||||||
|
|
||||||
// MatchPath returns the first route whose IncomingPath matches requestPath.
|
// MatchPath returns the first route whose IncomingPath matches requestPath.
|
||||||
// Matching mirrors the relay adaptor: exact match, {model} placeholder, and
|
// Matching mirrors the relay adaptor: exact match, {model} placeholder, and
|
||||||
// :generateContent <-> :streamGenerateContent equivalence.
|
// :generateContent <-> :streamGenerateContent equivalence.
|
||||||
@@ -143,6 +146,20 @@ func (c *AdvancedCustomConfig) MatchPathForModel(requestPath string, model strin
|
|||||||
return AdvancedCustomRoute{}, false
|
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.
|
// SupportsPath reports whether any route matches requestPath.
|
||||||
func (c *AdvancedCustomConfig) SupportsPath(requestPath string) bool {
|
func (c *AdvancedCustomConfig) SupportsPath(requestPath string) bool {
|
||||||
_, ok := c.MatchPath(requestPath)
|
_, ok := c.MatchPath(requestPath)
|
||||||
@@ -307,6 +324,7 @@ func (c *AdvancedCustomConfig) Validate() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
paths := make(map[string]*advancedCustomPathModelState, len(c.Routes))
|
paths := make(map[string]*advancedCustomPathModelState, len(c.Routes))
|
||||||
|
modelListRouteIndex := -1
|
||||||
for i := range c.Routes {
|
for i := range c.Routes {
|
||||||
route := c.Routes[i]
|
route := c.Routes[i]
|
||||||
route.IncomingPath = strings.TrimSpace(route.IncomingPath)
|
route.IncomingPath = strings.TrimSpace(route.IncomingPath)
|
||||||
@@ -325,6 +343,21 @@ func (c *AdvancedCustomConfig) Validate() error {
|
|||||||
if strings.Contains(route.IncomingPath, "?") {
|
if strings.Contains(route.IncomingPath, "?") {
|
||||||
return fmt.Errorf("advanced_custom.advanced_routes[%d].incoming_path must not include query", i)
|
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 {
|
if err := validateAdvancedCustomRouteModels(i, route.IncomingPath, route.Models, paths); err != nil {
|
||||||
return err
|
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) {
|
func TestAdvancedCustomValidateDuplicateIncomingPathWithDisjointModels(t *testing.T) {
|
||||||
config := &AdvancedCustomConfig{
|
config := &AdvancedCustomConfig{
|
||||||
Routes: []AdvancedCustomRoute{
|
Routes: []AdvancedCustomRoute{
|
||||||
|
|||||||
@@ -962,6 +962,11 @@ func (channel *Channel) ValidateSettings() error {
|
|||||||
return err
|
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
|
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)
|
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 {
|
func (a *Adaptor) SetupRequestHeader(c *gin.Context, header *http.Header, info *relaycommon.RelayInfo) error {
|
||||||
if err := a.resolve(c, info); err != nil {
|
if err := a.resolve(c, info); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -343,11 +388,15 @@ func incomingRequestPath(c *gin.Context, info *relaycommon.RelayInfo) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *Adaptor) routeURL(info *relaycommon.RelayInfo) (string, error) {
|
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 {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
if shouldUseGeminiStreamURL(a.converter, info) {
|
if shouldUseGeminiStreamURL(converter, info) {
|
||||||
useGeminiStreamGenerateContentURL(parsedURL)
|
useGeminiStreamGenerateContentURL(parsedURL)
|
||||||
}
|
}
|
||||||
if info != nil && info.RelayMode == relayconstant.RelayModeRealtime {
|
if info != nil && info.RelayMode == relayconstant.RelayModeRealtime {
|
||||||
@@ -358,9 +407,9 @@ func (a *Adaptor) routeURL(info *relaycommon.RelayInfo) (string, error) {
|
|||||||
parsedURL.Scheme = "ws"
|
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 := 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()
|
parsedURL.RawQuery = query.Encode()
|
||||||
}
|
}
|
||||||
return parsedURL.String(), nil
|
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) {
|
func TestAdaptorConvertsResponsesRequestToOpenAIChatUpstream(t *testing.T) {
|
||||||
adaptor := &Adaptor{}
|
adaptor := &Adaptor{}
|
||||||
info := advancedCustomRelayInfo(&dto.AdvancedCustomConfig{
|
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: {
|
export async function fetchModels(data: {
|
||||||
base_url: string
|
base_url: string
|
||||||
type: number
|
type: number
|
||||||
key: string
|
key?: string
|
||||||
|
channel_id?: number
|
||||||
|
advanced_custom?: string
|
||||||
|
header_override?: string
|
||||||
|
proxy?: string
|
||||||
}): Promise<FetchModelsResponse> {
|
}): Promise<FetchModelsResponse> {
|
||||||
const res = await api.post(
|
const res = await api.post(
|
||||||
'/api/channel/fetch_models',
|
'/api/channel/fetch_models',
|
||||||
|
|||||||
+155
-61
@@ -67,6 +67,7 @@ import {
|
|||||||
ADVANCED_CUSTOM_AUTH_MODE_OPTIONS,
|
ADVANCED_CUSTOM_AUTH_MODE_OPTIONS,
|
||||||
ADVANCED_CUSTOM_CONVERTER_OPTIONS,
|
ADVANCED_CUSTOM_CONVERTER_OPTIONS,
|
||||||
ADVANCED_CUSTOM_INCOMING_PATH_OPTIONS,
|
ADVANCED_CUSTOM_INCOMING_PATH_OPTIONS,
|
||||||
|
ADVANCED_CUSTOM_MODEL_LIST_PATH,
|
||||||
ADVANCED_CUSTOM_TEMPLATE_OPTIONS,
|
ADVANCED_CUSTOM_TEMPLATE_OPTIONS,
|
||||||
type AdvancedCustomAuthMode,
|
type AdvancedCustomAuthMode,
|
||||||
buildAdvancedCustomAuth,
|
buildAdvancedCustomAuth,
|
||||||
@@ -215,6 +216,17 @@ export function AdvancedCustomEditorDialog({
|
|||||||
[routeKeys, routes]
|
[routeKeys, routes]
|
||||||
)
|
)
|
||||||
const routeGroups = useMemo(() => buildRouteGroups(routeRows), [routeRows])
|
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(
|
const validationError = useMemo(
|
||||||
() => validateAdvancedCustomConfig(normalizedConfig),
|
() => validateAdvancedCustomConfig(normalizedConfig),
|
||||||
[normalizedConfig]
|
[normalizedConfig]
|
||||||
@@ -250,14 +262,19 @@ export function AdvancedCustomEditorDialog({
|
|||||||
setRouteKeys(nextRouteKeys)
|
setRouteKeys(nextRouteKeys)
|
||||||
}
|
}
|
||||||
|
|
||||||
const addRoute = () => {
|
const addRoute = (incomingPath: string | null) => {
|
||||||
|
if (!incomingPath || usedIncomingPaths.has(incomingPath)) return
|
||||||
setConfig((current) => {
|
setConfig((current) => {
|
||||||
const next = normalizeAdvancedCustomConfig(current)
|
const next = normalizeAdvancedCustomConfig(current)
|
||||||
return {
|
return {
|
||||||
...next,
|
...next,
|
||||||
advanced_routes: [
|
advanced_routes: [
|
||||||
...(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) => {
|
const nextRoutes = routes.map((route, routeIndex) => {
|
||||||
if (!groupRouteIndexes.has(routeIndex)) return route
|
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'
|
const converter = route.converter || 'none'
|
||||||
return {
|
return {
|
||||||
...route,
|
...route,
|
||||||
@@ -592,15 +618,45 @@ export function AdvancedCustomEditorDialog({
|
|||||||
{editMode === 'visual' ? (
|
{editMode === 'visual' ? (
|
||||||
<div className='flex flex-col gap-4 p-4 lg:gap-3'>
|
<div className='flex flex-col gap-4 p-4 lg:gap-3'>
|
||||||
<div className='flex justify-end border-y py-4 lg:py-2'>
|
<div className='flex justify-end border-y py-4 lg:py-2'>
|
||||||
<Button
|
<Select
|
||||||
type='button'
|
items={availableIncomingPathOptions.map((option) => option.value)}
|
||||||
variant='outline'
|
value={null}
|
||||||
size='sm'
|
onValueChange={(incomingPath) => {
|
||||||
onClick={addRoute}
|
if (typeof incomingPath === 'string') {
|
||||||
|
addRoute(incomingPath)
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Plus data-icon='inline-start' />
|
<SelectTrigger
|
||||||
{t('Add route')}
|
size='sm'
|
||||||
</Button>
|
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>
|
</div>
|
||||||
|
|
||||||
{validationError ? (
|
{validationError ? (
|
||||||
@@ -635,6 +691,7 @@ export function AdvancedCustomEditorDialog({
|
|||||||
<RouteGroupEditor
|
<RouteGroupEditor
|
||||||
key={routeGroup.incomingPath || 'advanced-custom-empty-path'}
|
key={routeGroup.incomingPath || 'advanced-custom-empty-path'}
|
||||||
group={routeGroup}
|
group={routeGroup}
|
||||||
|
usedIncomingPaths={usedIncomingPaths}
|
||||||
validationError={validationError}
|
validationError={validationError}
|
||||||
onAddRoute={() =>
|
onAddRoute={() =>
|
||||||
addRouteForIncomingPath(routeGroup.incomingPath)
|
addRouteForIncomingPath(routeGroup.incomingPath)
|
||||||
@@ -690,6 +747,7 @@ export function AdvancedCustomEditorDialog({
|
|||||||
|
|
||||||
function RouteGroupEditor({
|
function RouteGroupEditor({
|
||||||
group,
|
group,
|
||||||
|
usedIncomingPaths,
|
||||||
validationError,
|
validationError,
|
||||||
onAddRoute,
|
onAddRoute,
|
||||||
onIncomingPathChange,
|
onIncomingPathChange,
|
||||||
@@ -699,6 +757,7 @@ function RouteGroupEditor({
|
|||||||
onRouteChange,
|
onRouteChange,
|
||||||
}: {
|
}: {
|
||||||
group: AdvancedCustomRouteGroup
|
group: AdvancedCustomRouteGroup
|
||||||
|
usedIncomingPaths: ReadonlySet<string>
|
||||||
validationError: ReturnType<typeof validateAdvancedCustomConfig>
|
validationError: ReturnType<typeof validateAdvancedCustomConfig>
|
||||||
onAddRoute: () => void
|
onAddRoute: () => void
|
||||||
onIncomingPathChange: (incomingPath: string | null) => void
|
onIncomingPathChange: (incomingPath: string | null) => void
|
||||||
@@ -709,6 +768,7 @@ function RouteGroupEditor({
|
|||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const incomingPath = group.incomingPath || '/v1/chat/completions'
|
const incomingPath = group.incomingPath || '/v1/chat/completions'
|
||||||
|
const isModelListGroup = incomingPath === ADVANCED_CUSTOM_MODEL_LIST_PATH
|
||||||
const incomingPathLabel = getAdvancedCustomIncomingPathLabel(incomingPath)
|
const incomingPathLabel = getAdvancedCustomIncomingPathLabel(incomingPath)
|
||||||
const catchAllRoute = group.routeRows.find((routeRow) =>
|
const catchAllRoute = group.routeRows.find((routeRow) =>
|
||||||
isCatchAllRoute(routeRow.route)
|
isCatchAllRoute(routeRow.route)
|
||||||
@@ -741,17 +801,21 @@ function RouteGroupEditor({
|
|||||||
<Badge variant='secondary'>
|
<Badge variant='secondary'>
|
||||||
{group.routeRows.length} {t('Routes')}
|
{group.routeRows.length} {t('Routes')}
|
||||||
</Badge>
|
</Badge>
|
||||||
<Badge variant={hasCatchAll ? 'outline' : 'secondary'}>
|
{isModelListGroup ? (
|
||||||
{hasCatchAll ? t('Fallback route') : t('Model-scoped only')}
|
<Badge variant='outline'>{t('OpenAI Models')}</Badge>
|
||||||
</Badge>
|
) : (
|
||||||
{!catchAllIsLast ? (
|
<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>
|
<Badge variant='destructive'>{t('Fallback must be last')}</Badge>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<Select value={incomingPath} onValueChange={onIncomingPathChange}>
|
<Select value={incomingPath} onValueChange={onIncomingPathChange}>
|
||||||
<SelectTrigger className='h-9 max-w-full lg:max-w-[420px]'>
|
<SelectTrigger className='h-9 max-w-full lg:max-w-[420px]'>
|
||||||
<SelectValue className='min-w-0 truncate'>
|
<SelectValue className='min-w-0 truncate'>
|
||||||
{incomingPathLabel}
|
{t(incomingPathLabel)}
|
||||||
</SelectValue>
|
</SelectValue>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent
|
<SelectContent
|
||||||
@@ -763,10 +827,16 @@ function RouteGroupEditor({
|
|||||||
<SelectItem
|
<SelectItem
|
||||||
key={option.value}
|
key={option.value}
|
||||||
value={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}
|
className={longSelectItemClass}
|
||||||
>
|
>
|
||||||
<div className='flex min-w-0 flex-col gap-1 leading-snug whitespace-normal'>
|
<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'>
|
<span className='text-muted-foreground font-mono text-xs break-all'>
|
||||||
{option.value}
|
{option.value}
|
||||||
</span>
|
</span>
|
||||||
@@ -778,17 +848,28 @@ function RouteGroupEditor({
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button type='button' variant='outline' size='sm' onClick={onAddRoute}>
|
{!isModelListGroup ? (
|
||||||
<Plus data-icon='inline-start' />
|
<Button
|
||||||
{t('Add split')}
|
type='button'
|
||||||
</Button>
|
variant='outline'
|
||||||
|
size='sm'
|
||||||
|
onClick={onAddRoute}
|
||||||
|
>
|
||||||
|
<Plus data-icon='inline-start' />
|
||||||
|
{t('Add split')}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='border-t px-3 py-2'>
|
<div className='border-t px-3 py-2'>
|
||||||
<p className='text-muted-foreground text-xs leading-relaxed'>
|
<p className='text-muted-foreground text-xs leading-relaxed'>
|
||||||
{t(
|
{isModelListGroup
|
||||||
'Routes with the same incoming path are split by client model rules. Unmatched requests use the final fallback.'
|
? 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>
|
</p>
|
||||||
{groupHasError && validationError ? (
|
{groupHasError && validationError ? (
|
||||||
<p className='text-destructive mt-1 text-xs'>
|
<p className='text-destructive mt-1 text-xs'>
|
||||||
@@ -880,6 +961,7 @@ function RouteEditor({
|
|||||||
const authMode = getAdvancedCustomAuthMode(route)
|
const authMode = getAdvancedCustomAuthMode(route)
|
||||||
const incomingPath =
|
const incomingPath =
|
||||||
route.incoming_path || getDefaultAdvancedCustomIncomingPath(converter)
|
route.incoming_path || getDefaultAdvancedCustomIncomingPath(converter)
|
||||||
|
const isModelListRoute = incomingPath === ADVANCED_CUSTOM_MODEL_LIST_PATH
|
||||||
const converterOptions = useMemo(
|
const converterOptions = useMemo(
|
||||||
() => getAdvancedCustomConverterOptions(incomingPath),
|
() => getAdvancedCustomConverterOptions(incomingPath),
|
||||||
[incomingPath]
|
[incomingPath]
|
||||||
@@ -897,7 +979,7 @@ function RouteEditor({
|
|||||||
const ConverterVisualIcon = isNativeConverter ? ArrowRight : Shuffle
|
const ConverterVisualIcon = isNativeConverter ? ArrowRight : Shuffle
|
||||||
const modelsInputValue = route.models?.join(', ') || ''
|
const modelsInputValue = route.models?.join(', ') || ''
|
||||||
const parsedRouteModels = parseAdvancedCustomRouteModels(modelsInputValue)
|
const parsedRouteModels = parseAdvancedCustomRouteModels(modelsInputValue)
|
||||||
const isFallback = parsedRouteModels.length === 0
|
const isFallback = !isModelListRoute && parsedRouteModels.length === 0
|
||||||
|
|
||||||
const setConverter = (nextConverter: AdvancedCustomConverter) => {
|
const setConverter = (nextConverter: AdvancedCustomConverter) => {
|
||||||
let nextIncomingPath = incomingPath
|
let nextIncomingPath = incomingPath
|
||||||
@@ -965,7 +1047,10 @@ function RouteEditor({
|
|||||||
<div className='text-sm font-medium'>
|
<div className='text-sm font-medium'>
|
||||||
{t('Route')} {index + 1}
|
{t('Route')} {index + 1}
|
||||||
</div>
|
</div>
|
||||||
{isFallback ? (
|
{isModelListRoute ? (
|
||||||
|
<Badge variant='outline'>{t('OpenAI Models')}</Badge>
|
||||||
|
) : null}
|
||||||
|
{!isModelListRoute && isFallback ? (
|
||||||
<Badge variant='outline'>{t('Fallback')}</Badge>
|
<Badge variant='outline'>{t('Fallback')}</Badge>
|
||||||
) : null}
|
) : null}
|
||||||
<TooltipProvider delay={100}>
|
<TooltipProvider delay={100}>
|
||||||
@@ -1033,42 +1118,50 @@ function RouteEditor({
|
|||||||
className='lg:gap-1'
|
className='lg:gap-1'
|
||||||
labelClassName='lg:sr-only'
|
labelClassName='lg:sr-only'
|
||||||
>
|
>
|
||||||
<Input
|
{isModelListRoute && parsedRouteModels.length === 0 ? (
|
||||||
value={modelsInputValue}
|
<div className='flex h-9 items-center'>
|
||||||
onChange={(event) => setModelsInput(event.target.value)}
|
<Badge variant='outline'>{t('OpenAI Models')}</Badge>
|
||||||
onBlur={(event) => normalizeModelsInput(event.target.value)}
|
</div>
|
||||||
placeholder={
|
) : (
|
||||||
isFallback
|
<>
|
||||||
? t('Leave empty for fallback')
|
<Input
|
||||||
: t('e.g. gpt-4o, gemini-2.5-flash')
|
value={modelsInputValue}
|
||||||
}
|
onChange={(event) => setModelsInput(event.target.value)}
|
||||||
aria-invalid={Boolean(errorMessage)}
|
onBlur={(event) => normalizeModelsInput(event.target.value)}
|
||||||
/>
|
placeholder={
|
||||||
<div className='flex flex-wrap gap-1'>
|
isFallback
|
||||||
{isFallback ? (
|
? t('Leave empty for fallback')
|
||||||
<Badge variant='outline'>{t('Fallback')}</Badge>
|
: t('e.g. gpt-4o, gemini-2.5-flash')
|
||||||
) : (
|
}
|
||||||
parsedRouteModels.map((model) => {
|
aria-invalid={Boolean(errorMessage)}
|
||||||
const ruleKind = getAdvancedCustomModelRuleKind(model)
|
/>
|
||||||
const displayModel =
|
<div className='flex flex-wrap gap-1'>
|
||||||
ruleKind === 'regex'
|
{isFallback ? (
|
||||||
? getAdvancedCustomRegexModelPattern(model) || model
|
<Badge variant='outline'>{t('Fallback')}</Badge>
|
||||||
: model
|
) : (
|
||||||
return (
|
parsedRouteModels.map((model) => {
|
||||||
<Badge
|
const ruleKind = getAdvancedCustomModelRuleKind(model)
|
||||||
key={model}
|
const displayModel =
|
||||||
variant={ruleKind === 'regex' ? 'outline' : 'secondary'}
|
ruleKind === 'regex'
|
||||||
className='max-w-full gap-1.5 font-mono'
|
? getAdvancedCustomRegexModelPattern(model) || model
|
||||||
>
|
: model
|
||||||
<span className='font-sans text-[10px] font-semibold tracking-normal uppercase'>
|
return (
|
||||||
{t(ruleKind === 'regex' ? 'Regex' : 'Exact')}
|
<Badge
|
||||||
</span>
|
key={model}
|
||||||
<span className='truncate'>{displayModel}</span>
|
variant={ruleKind === 'regex' ? 'outline' : 'secondary'}
|
||||||
</Badge>
|
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')}
|
||||||
</div>
|
</span>
|
||||||
|
<span className='truncate'>{displayModel}</span>
|
||||||
|
</Badge>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</FieldBlock>
|
</FieldBlock>
|
||||||
|
|
||||||
<FieldBlock
|
<FieldBlock
|
||||||
@@ -1100,6 +1193,7 @@ function RouteEditor({
|
|||||||
>
|
>
|
||||||
<Select
|
<Select
|
||||||
value={converter}
|
value={converter}
|
||||||
|
disabled={isModelListRoute && converter === 'none'}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
setConverter(value as AdvancedCustomConverter)
|
setConverter(value as AdvancedCustomConverter)
|
||||||
}
|
}
|
||||||
|
|||||||
+31
-13
@@ -759,6 +759,9 @@ export function ChannelMutateDrawer({
|
|||||||
const currentUpstreamModelUpdateIgnoredModels = form.watch(
|
const currentUpstreamModelUpdateIgnoredModels = form.watch(
|
||||||
'upstream_model_update_ignored_models'
|
'upstream_model_update_ignored_models'
|
||||||
)
|
)
|
||||||
|
const shouldPreviewUnsavedModels =
|
||||||
|
!isEditing ||
|
||||||
|
(currentType === CHANNEL_TYPE_ADVANCED_CUSTOM && canEditSensitive)
|
||||||
const {
|
const {
|
||||||
unlocked: doubaoApiEditUnlocked,
|
unlocked: doubaoApiEditUnlocked,
|
||||||
handleClick: handleApiConfigSecretClick,
|
handleClick: handleApiConfigSecretClick,
|
||||||
@@ -866,7 +869,7 @@ export function ChannelMutateDrawer({
|
|||||||
advancedCustomRouteTypeLabels.length
|
advancedCustomRouteTypeLabels.length
|
||||||
const advancedCustomRouteTypeTitle =
|
const advancedCustomRouteTypeTitle =
|
||||||
hiddenAdvancedCustomRouteTypeCount > 0
|
hiddenAdvancedCustomRouteTypeCount > 0
|
||||||
? advancedCustomStats.routeTypeLabels.join(', ')
|
? advancedCustomStats.routeTypeLabels.map((label) => t(label)).join(', ')
|
||||||
: undefined
|
: undefined
|
||||||
|
|
||||||
// Get all models list
|
// Get all models list
|
||||||
@@ -1421,8 +1424,8 @@ export function ChannelMutateDrawer({
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// For creation mode, validate key before opening dialog
|
// Advanced Custom may use a model discovery route with no authentication.
|
||||||
if (!isEditing) {
|
if (!isEditing && type !== CHANNEL_TYPE_ADVANCED_CUSTOM) {
|
||||||
const key = form.getValues('key')
|
const key = form.getValues('key')
|
||||||
if (!key?.trim()) {
|
if (!key?.trim()) {
|
||||||
toast.error(t('Please enter API key first'))
|
toast.error(t('Please enter API key first'))
|
||||||
@@ -1433,20 +1436,30 @@ export function ChannelMutateDrawer({
|
|||||||
setFetchModelsDialogOpen(true)
|
setFetchModelsDialogOpen(true)
|
||||||
}, [isEditing, canEditSensitive, form, t])
|
}, [isEditing, canEditSensitive, form, t])
|
||||||
|
|
||||||
const createModeFetcher = useCallback(async (): Promise<string[]> => {
|
const formPreviewFetcher = useCallback(async (): Promise<string[]> => {
|
||||||
if (!canEditSensitive) {
|
if (!canEditSensitive) {
|
||||||
throw new Error(t("You don't have necessary permission"))
|
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({
|
const response = await fetchModels({
|
||||||
type: form.getValues('type'),
|
type,
|
||||||
key: form.getValues('key'),
|
key: isEditing ? undefined : form.getValues('key'),
|
||||||
|
channel_id: editingAdvancedCustom ? channelId || undefined : undefined,
|
||||||
base_url: form.getValues('base_url') || '',
|
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) {
|
if (response.success && response.data) {
|
||||||
return response.data
|
return response.data
|
||||||
}
|
}
|
||||||
throw new Error(response.message || 'No models fetched from upstream')
|
throw new Error(response.message || t('No models fetched from upstream'))
|
||||||
}, [canEditSensitive, form, t])
|
}, [canEditSensitive, channelId, form, isEditing, t])
|
||||||
|
|
||||||
// Handle model operations
|
// Handle model operations
|
||||||
const handleFillRelatedModels = useCallback(() => {
|
const handleFillRelatedModels = useCallback(() => {
|
||||||
@@ -2787,10 +2800,10 @@ export function ChannelMutateDrawer({
|
|||||||
key={label}
|
key={label}
|
||||||
variant='outline'
|
variant='outline'
|
||||||
className='max-w-[12rem]'
|
className='max-w-[12rem]'
|
||||||
title={label}
|
title={t(label)}
|
||||||
>
|
>
|
||||||
<span className='truncate'>
|
<span className='truncate'>
|
||||||
{label}
|
{t(label)}
|
||||||
</span>
|
</span>
|
||||||
</Badge>
|
</Badge>
|
||||||
)
|
)
|
||||||
@@ -4514,6 +4527,7 @@ export function ChannelMutateDrawer({
|
|||||||
'Periodically check for upstream model changes'
|
'Periodically check for upstream model changes'
|
||||||
)}
|
)}
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
</div>
|
</div>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Switch
|
<Switch
|
||||||
@@ -4682,10 +4696,14 @@ export function ChannelMutateDrawer({
|
|||||||
}}
|
}}
|
||||||
redirectModels={redirectModelList}
|
redirectModels={redirectModelList}
|
||||||
redirectSourceModels={redirectModelKeyList}
|
redirectSourceModels={redirectModelKeyList}
|
||||||
customFetcher={!isEditing ? createModeFetcher : undefined}
|
customFetcher={
|
||||||
channelName={!isEditing ? currentName?.trim() : undefined}
|
shouldPreviewUnsavedModels ? formPreviewFetcher : undefined
|
||||||
|
}
|
||||||
|
channelName={
|
||||||
|
shouldPreviewUnsavedModels ? currentName?.trim() : undefined
|
||||||
|
}
|
||||||
existingModelsOverride={
|
existingModelsOverride={
|
||||||
!isEditing
|
shouldPreviewUnsavedModels
|
||||||
? parseModelsString(form.getValues('models') || '')
|
? parseModelsString(form.getValues('models') || '')
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -377,7 +377,7 @@ export const FIELD_DESCRIPTIONS = {
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
export const MODEL_FETCHABLE_TYPES = new Set([
|
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> = {
|
export const TYPE_TO_KEY_PROMPT: Record<number, string> = {
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import type {
|
|||||||
} from '../types'
|
} from '../types'
|
||||||
|
|
||||||
export const CHANNEL_TYPE_ADVANCED_CUSTOM = 58
|
export const CHANNEL_TYPE_ADVANCED_CUSTOM = 58
|
||||||
|
export const ADVANCED_CUSTOM_MODEL_LIST_PATH = '/v1/models'
|
||||||
|
|
||||||
export const ADVANCED_CUSTOM_CONVERTER_OPTIONS: Array<{
|
export const ADVANCED_CUSTOM_CONVERTER_OPTIONS: Array<{
|
||||||
value: AdvancedCustomConverter
|
value: AdvancedCustomConverter
|
||||||
@@ -104,6 +105,10 @@ export const ADVANCED_CUSTOM_INCOMING_PATH_OPTIONS: AdvancedCustomIncomingPathOp
|
|||||||
value: '/v1/responses/compact',
|
value: '/v1/responses/compact',
|
||||||
label: 'OpenAI Responses Compact',
|
label: 'OpenAI Responses Compact',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
value: ADVANCED_CUSTOM_MODEL_LIST_PATH,
|
||||||
|
label: 'OpenAI Models',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
value: '/v1/embeddings',
|
value: '/v1/embeddings',
|
||||||
label: 'OpenAI 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> = {
|
const ADVANCED_CUSTOM_ROUTE_SUMMARY_LABELS: Record<string, string> = {
|
||||||
'/v1/chat/completions': 'OpenAI Chat',
|
'/v1/chat/completions': 'OpenAI Chat',
|
||||||
|
[ADVANCED_CUSTOM_MODEL_LIST_PATH]: 'OpenAI Models',
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AdvancedCustomValidationError = {
|
export type AdvancedCustomValidationError = {
|
||||||
@@ -537,6 +543,7 @@ export function validateAdvancedCustomConfig(
|
|||||||
string,
|
string,
|
||||||
{ catchAllIndex: number | null; models: Map<string, number> }
|
{ catchAllIndex: number | null; models: Map<string, number> }
|
||||||
>()
|
>()
|
||||||
|
let modelListRouteIndex: number | null = null
|
||||||
for (let index = 0; index < routes.length; index += 1) {
|
for (let index = 0; index < routes.length; index += 1) {
|
||||||
const route = routes[index]
|
const route = routes[index]
|
||||||
const incomingPath = route.incoming_path?.trim() || ''
|
const incomingPath = route.incoming_path?.trim() || ''
|
||||||
@@ -556,6 +563,33 @@ export function validateAdvancedCustomConfig(
|
|||||||
message: 'Incoming path must not include query',
|
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(
|
const routeModelsError = validateAdvancedCustomRouteModels(
|
||||||
index,
|
index,
|
||||||
incomingPath,
|
incomingPath,
|
||||||
@@ -594,6 +628,16 @@ export function validateAdvancedCustomConfig(
|
|||||||
return null
|
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(
|
export function advancedCustomConfigUsesRelativeUpstreamPath(
|
||||||
config: AdvancedCustomConfig | null
|
config: AdvancedCustomConfig | null
|
||||||
): boolean {
|
): boolean {
|
||||||
|
|||||||
+30
-10
@@ -27,6 +27,7 @@ import type { Channel } from '../types'
|
|||||||
import {
|
import {
|
||||||
CHANNEL_TYPE_ADVANCED_CUSTOM,
|
CHANNEL_TYPE_ADVANCED_CUSTOM,
|
||||||
advancedCustomConfigUsesRelativeUpstreamPath,
|
advancedCustomConfigUsesRelativeUpstreamPath,
|
||||||
|
hasValidAdvancedCustomModelListRoute,
|
||||||
parseAdvancedCustomConfig,
|
parseAdvancedCustomConfig,
|
||||||
stringifyAdvancedCustomConfig,
|
stringifyAdvancedCustomConfig,
|
||||||
validateAdvancedCustomConfig,
|
validateAdvancedCustomConfig,
|
||||||
@@ -238,6 +239,16 @@ export const channelFormSchema = z
|
|||||||
'Base URL is required when an advanced route uses an upstream path'
|
'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()) {
|
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
|
formData.allow_include_obfuscation === true
|
||||||
settingsObj.allow_inference_geo = formData.allow_inference_geo === true
|
settingsObj.allow_inference_geo = formData.allow_inference_geo === true
|
||||||
} else {
|
} else {
|
||||||
if ('disable_store' in settingsObj) delete settingsObj.disable_store
|
if ('disable_store' in settingsObj) {
|
||||||
if ('allow_safety_identifier' in settingsObj)
|
delete settingsObj.disable_store
|
||||||
|
}
|
||||||
|
if ('allow_safety_identifier' in settingsObj) {
|
||||||
delete settingsObj.allow_safety_identifier
|
delete settingsObj.allow_safety_identifier
|
||||||
if ('allow_include_obfuscation' in settingsObj)
|
}
|
||||||
|
if ('allow_include_obfuscation' in settingsObj) {
|
||||||
delete settingsObj.allow_include_obfuscation
|
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
|
delete settingsObj.allow_inference_geo
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Anthropic (type 14): claude_beta_query, allow_inference_geo, allow_speed
|
// 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.allow_speed = formData.allow_speed === true
|
||||||
settingsObj.claude_beta_query = formData.claude_beta_query === true
|
settingsObj.claude_beta_query = formData.claude_beta_query === true
|
||||||
} else {
|
} else {
|
||||||
if ('allow_speed' in settingsObj) delete settingsObj.allow_speed
|
if ('allow_speed' in settingsObj) {
|
||||||
if ('claude_beta_query' in settingsObj) delete settingsObj.claude_beta_query
|
delete settingsObj.allow_speed
|
||||||
|
}
|
||||||
|
if ('claude_beta_query' in settingsObj) {
|
||||||
|
delete settingsObj.claude_beta_query
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
settingsObj.disable_task_polling_sleep =
|
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_auto_sync_enabled =
|
||||||
settingsObj.upstream_model_update_check_enabled === true &&
|
settingsObj.upstream_model_update_check_enabled === true &&
|
||||||
formData.upstream_model_update_auto_sync_enabled === true
|
formData.upstream_model_update_auto_sync_enabled === true
|
||||||
settingsObj.upstream_model_update_ignored_models = Array.from(
|
settingsObj.upstream_model_update_ignored_models = [
|
||||||
new Set(
|
...new Set(
|
||||||
String(formData.upstream_model_update_ignored_models || '')
|
String(formData.upstream_model_update_ignored_models || '')
|
||||||
.split(',')
|
.split(',')
|
||||||
.map((model) => model.trim())
|
.map((model) => model.trim())
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
)
|
),
|
||||||
)
|
]
|
||||||
if (
|
if (
|
||||||
!Array.isArray(settingsObj.upstream_model_update_last_detected_models) ||
|
!Array.isArray(settingsObj.upstream_model_update_last_detected_models) ||
|
||||||
settingsObj.upstream_model_update_check_enabled !== true
|
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 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 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 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 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": "Only successful requests",
|
||||||
"Only successful requests count toward this limit.": "Only successful requests count toward this limit.",
|
"Only successful requests count toward this limit.": "Only successful requests count toward this limit.",
|
||||||
@@ -3116,6 +3117,11 @@
|
|||||||
"OpenAI Embeddings": "OpenAI Embeddings",
|
"OpenAI Embeddings": "OpenAI Embeddings",
|
||||||
"OpenAI Image Edits": "OpenAI Image Edits",
|
"OpenAI Image Edits": "OpenAI Image Edits",
|
||||||
"OpenAI Image Generations": "OpenAI Image Generations",
|
"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": "OpenAI Organization",
|
||||||
"OpenAI Organization ID (optional)": "OpenAI Organization ID (optional)",
|
"OpenAI Organization ID (optional)": "OpenAI Organization ID (optional)",
|
||||||
"OpenAI Realtime": "OpenAI Realtime",
|
"OpenAI Realtime": "OpenAI Realtime",
|
||||||
@@ -4528,6 +4534,7 @@
|
|||||||
"This plan does not allow balance redemption": "This plan does not allow balance redemption",
|
"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 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 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 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 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",
|
"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 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 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 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 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": "Uniquement les requêtes réussies",
|
||||||
"Only successful requests count toward this limit.": "Seules les requêtes réussies comptent pour cette limite.",
|
"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 Embeddings": "OpenAI Embeddings",
|
||||||
"OpenAI Image Edits": "OpenAI Image Edits",
|
"OpenAI Image Edits": "OpenAI Image Edits",
|
||||||
"OpenAI Image Generations": "OpenAI Image Generations",
|
"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": "Organisation OpenAI",
|
||||||
"OpenAI Organization ID (optional)": "Identifiant d'organisation OpenAI (optionnel)",
|
"OpenAI Organization ID (optional)": "Identifiant d'organisation OpenAI (optionnel)",
|
||||||
"OpenAI Realtime": "OpenAI Realtime",
|
"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 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 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 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 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 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",
|
"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 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 Mine": "自分のみ",
|
||||||
"Only one catch-all route is allowed for the same incoming path": "同じ入力パスではキャッチオールルートは1つだけ許可されます",
|
"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 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 埋め込み",
|
"OpenAI Embeddings": "OpenAI 埋め込み",
|
||||||
"OpenAI Image Edits": "OpenAI 画像編集",
|
"OpenAI Image Edits": "OpenAI 画像編集",
|
||||||
"OpenAI Image Generations": "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": "OpenAI組織",
|
||||||
"OpenAI Organization ID (optional)": "OpenAI 組織 ID (オプション)",
|
"OpenAI Organization ID (optional)": "OpenAI 組織 ID (オプション)",
|
||||||
"OpenAI Realtime": "OpenAI リアルタイム",
|
"OpenAI Realtime": "OpenAI リアルタイム",
|
||||||
@@ -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.": "この操作はこのチャンネルから失敗した {{count}} 個のモデルを削除します。元に戻せません。",
|
"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 site currently has {{count}} models enabled": "このサイトでは現在 {{count}} 個のモデルが有効です",
|
||||||
"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.": "Введите только origin сайта, например https://api.example.com. Не добавляйте пути, например /api/user/epay/notify. Оставьте пустым, чтобы использовать адрес сервера.",
|
"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 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": "Допускается только один маршрут моделей OpenAI",
|
||||||
"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",
|
"OpenAI Embeddings": "Векторные представления OpenAI",
|
||||||
"OpenAI Image Edits": "Редактирование изображений OpenAI",
|
"OpenAI Image Edits": "Редактирование изображений OpenAI",
|
||||||
"OpenAI Image Generations": "Генерация изображений 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": "Организация OpenAI",
|
||||||
"OpenAI Organization ID (optional)": "Идентификатор организации OpenAI (необязательно)",
|
"OpenAI Organization ID (optional)": "Идентификатор организации OpenAI (необязательно)",
|
||||||
"OpenAI Realtime": "Реальное время OpenAI",
|
"OpenAI Realtime": "Реальное время OpenAI",
|
||||||
@@ -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.": "Это удалит {{count}} неуспешных моделей из этого канала. Действие необратимо.",
|
"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 site currently has {{count}} models enabled": "На этом сайте сейчас включено моделей: {{count}}",
|
||||||
"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.": "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 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 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 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 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": "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.",
|
"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 Embeddings": "OpenAI Embeddings",
|
||||||
"OpenAI Image Edits": "OpenAI Image Edits",
|
"OpenAI Image Edits": "OpenAI Image Edits",
|
||||||
"OpenAI Image Generations": "OpenAI Image Generations",
|
"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": "Tổ chức OpenAI",
|
||||||
"OpenAI Organization ID (optional)": "ID Tổ chức OpenAI (tùy chọn)",
|
"OpenAI Organization ID (optional)": "ID Tổ chức OpenAI (tùy chọn)",
|
||||||
"OpenAI Realtime": "OpenAI Realtime",
|
"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 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 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 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 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 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",
|
"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 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 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": "僅允許設定一條 OpenAI 模型路由",
|
||||||
"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 嵌入",
|
"OpenAI Embeddings": "OpenAI 嵌入",
|
||||||
"OpenAI Image Edits": "OpenAI 圖像編輯",
|
"OpenAI Image Edits": "OpenAI 圖像編輯",
|
||||||
"OpenAI Image Generations": "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": "OpenAI 組織",
|
||||||
"OpenAI Organization ID (optional)": "OpenAI 組織 ID(可選)",
|
"OpenAI Organization ID (optional)": "OpenAI 組織 ID(可選)",
|
||||||
"OpenAI Realtime": "OpenAI 實時",
|
"OpenAI Realtime": "OpenAI 實時",
|
||||||
@@ -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.": "此操作將從該渠道移除 {{count}} 個測試失敗的模型,且無法撤銷。",
|
"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 site currently has {{count}} models enabled": "本站目前已啟用模型,總計 {{count}} 個",
|
||||||
"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.": "只填写站点根域名,例如 https://api.example.com。不要填写 /api/user/epay/notify 这类路径。留空则使用服务器地址。",
|
"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 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": "仅允许配置一条 OpenAI 模型路由",
|
||||||
"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 嵌入",
|
"OpenAI Embeddings": "OpenAI 嵌入",
|
||||||
"OpenAI Image Edits": "OpenAI 图像编辑",
|
"OpenAI Image Edits": "OpenAI 图像编辑",
|
||||||
"OpenAI Image Generations": "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": "OpenAI 组织",
|
||||||
"OpenAI Organization ID (optional)": "OpenAI 组织 ID(可选)",
|
"OpenAI Organization ID (optional)": "OpenAI 组织 ID(可选)",
|
||||||
"OpenAI Realtime": "OpenAI 实时",
|
"OpenAI Realtime": "OpenAI 实时",
|
||||||
@@ -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.": "此操作将从该渠道移除 {{count}} 个测试失败的模型,且无法撤销。",
|
"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 site currently has {{count}} models enabled": "本站当前已启用模型,总计 {{count}} 个",
|
||||||
"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
+8
@@ -530,6 +530,14 @@ export const STATIC_I18N_KEYS = [
|
|||||||
'Batch detection failed',
|
'Batch detection failed',
|
||||||
'Batch detection complete: {{channels}} channels, {{add}} to add, {{remove}} to remove, {{fails}} 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)
|
// Dashboard flow stages (labels/descriptions passed to t at runtime)
|
||||||
'User',
|
'User',
|
||||||
'Node',
|
'Node',
|
||||||
|
|||||||
Reference in New Issue
Block a user