Commit 3f2c0aed by Seefs Committed by GitHub

feat: advanced custom channel (#5590)

parent 21d4d18d
......@@ -75,6 +75,8 @@ func ChannelType2APIType(channelType int) (int, bool) {
apiType = constant.APITypeReplicate
case constant.ChannelTypeCodex:
apiType = constant.APITypeCodex
case constant.ChannelTypeAdvancedCustom:
apiType = constant.APITypeAdvancedCustom
}
if apiType == -1 {
return constant.APITypeOpenAI, false
......
......@@ -36,5 +36,6 @@ const (
APITypeMiniMax
APITypeReplicate
APITypeCodex
APITypeAdvancedCustom
APITypeDummy // this one is only for count, do not add any channel after this
)
......@@ -55,6 +55,7 @@ const (
ChannelTypeSora = 55
ChannelTypeReplicate = 56
ChannelTypeCodex = 57
ChannelTypeAdvancedCustom = 58
ChannelTypeDummy // this one is only for count, do not add any channel after this
)
......@@ -118,6 +119,7 @@ var ChannelBaseURLs = []string{
"https://api.openai.com", //55
"https://api.replicate.com", //56
"https://chatgpt.com", //57
"", //58
}
var ChannelTypeNames = map[int]string{
......@@ -175,6 +177,7 @@ var ChannelTypeNames = map[int]string{
ChannelTypeSora: "Sora",
ChannelTypeReplicate: "Replicate",
ChannelTypeCodex: "ChatGPT Subscription (Codex)",
ChannelTypeAdvancedCustom: "Advanced Custom",
}
func GetChannelTypeName(channelType int) string {
......
package dto
import (
"fmt"
"net/url"
"strings"
)
type ChannelSettings struct {
ForceFormat bool `json:"force_format,omitempty"`
ThinkingToContent bool `json:"thinking_to_content,omitempty"`
......@@ -24,23 +30,24 @@ const (
)
type ChannelOtherSettings struct {
AzureResponsesVersion string `json:"azure_responses_version,omitempty"`
VertexKeyType VertexKeyType `json:"vertex_key_type,omitempty"` // "json" or "api_key"
OpenRouterEnterprise *bool `json:"openrouter_enterprise,omitempty"`
ClaudeBetaQuery bool `json:"claude_beta_query,omitempty"` // Claude 渠道是否强制追加 ?beta=true
AllowServiceTier bool `json:"allow_service_tier,omitempty"` // 是否允许 service_tier 透传(默认过滤以避免额外计费)
AllowInferenceGeo bool `json:"allow_inference_geo,omitempty"` // 是否允许 inference_geo 透传(仅 Claude,默认过滤以满足数据驻留合规
AllowSpeed bool `json:"allow_speed,omitempty"` // 是否允许 speed 透传(仅 Claude,默认过滤以避免意外切换推理速度模式)
AllowSafetyIdentifier bool `json:"allow_safety_identifier,omitempty"` // 是否允许 safety_identifier 透传(默认过滤以保护用户隐私)
DisableStore bool `json:"disable_store,omitempty"` // 是否禁用 store 透传(默认允许透传,禁用后可能导致 Codex 无法使用)
AllowIncludeObfuscation bool `json:"allow_include_obfuscation,omitempty"` // 是否允许 stream_options.include_obfuscation 透传(默认过滤以避免关闭流混淆保护)
AwsKeyType AwsKeyType `json:"aws_key_type,omitempty"`
UpstreamModelUpdateCheckEnabled bool `json:"upstream_model_update_check_enabled,omitempty"` // 是否检测上游模型更新
UpstreamModelUpdateAutoSyncEnabled bool `json:"upstream_model_update_auto_sync_enabled,omitempty"` // 是否自动同步上游模型更新
UpstreamModelUpdateLastCheckTime int64 `json:"upstream_model_update_last_check_time,omitempty"` // 上次检测时间
UpstreamModelUpdateLastDetectedModels []string `json:"upstream_model_update_last_detected_models,omitempty"` // 上次检测到的可加入模型
UpstreamModelUpdateLastRemovedModels []string `json:"upstream_model_update_last_removed_models,omitempty"` // 上次检测到的可删除模型
UpstreamModelUpdateIgnoredModels []string `json:"upstream_model_update_ignored_models,omitempty"` // 手动忽略的模型
AzureResponsesVersion string `json:"azure_responses_version,omitempty"`
VertexKeyType VertexKeyType `json:"vertex_key_type,omitempty"` // "json" or "api_key"
OpenRouterEnterprise *bool `json:"openrouter_enterprise,omitempty"`
ClaudeBetaQuery bool `json:"claude_beta_query,omitempty"` // Claude 渠道是否强制追加 ?beta=true
AllowServiceTier bool `json:"allow_service_tier,omitempty"` // 是否允许 service_tier 透传(默认过滤以避免额外计费)
AllowInferenceGeo bool `json:"allow_inference_geo,omitempty"` // 是否允许 inference_geo 透传(仅 Claude,默认过滤以满足数据驻留合规
AllowSpeed bool `json:"allow_speed,omitempty"` // 是否允许 speed 透传(仅 Claude,默认过滤以避免意外切换推理速度模式)
AllowSafetyIdentifier bool `json:"allow_safety_identifier,omitempty"` // 是否允许 safety_identifier 透传(默认过滤以保护用户隐私)
DisableStore bool `json:"disable_store,omitempty"` // 是否禁用 store 透传(默认允许透传,禁用后可能导致 Codex 无法使用)
AllowIncludeObfuscation bool `json:"allow_include_obfuscation,omitempty"` // 是否允许 stream_options.include_obfuscation 透传(默认过滤以避免关闭流混淆保护)
AwsKeyType AwsKeyType `json:"aws_key_type,omitempty"`
UpstreamModelUpdateCheckEnabled bool `json:"upstream_model_update_check_enabled,omitempty"` // 是否检测上游模型更新
UpstreamModelUpdateAutoSyncEnabled bool `json:"upstream_model_update_auto_sync_enabled,omitempty"` // 是否自动同步上游模型更新
UpstreamModelUpdateLastCheckTime int64 `json:"upstream_model_update_last_check_time,omitempty"` // 上次检测时间
UpstreamModelUpdateLastDetectedModels []string `json:"upstream_model_update_last_detected_models,omitempty"` // 上次检测到的可加入模型
UpstreamModelUpdateLastRemovedModels []string `json:"upstream_model_update_last_removed_models,omitempty"` // 上次检测到的可删除模型
UpstreamModelUpdateIgnoredModels []string `json:"upstream_model_update_ignored_models,omitempty"` // 手动忽略的模型
AdvancedCustom *AdvancedCustomConfig `json:"advanced_custom,omitempty"`
}
func (s *ChannelOtherSettings) IsOpenRouterEnterprise() bool {
......@@ -49,3 +56,168 @@ func (s *ChannelOtherSettings) IsOpenRouterEnterprise() bool {
}
return *s.OpenRouterEnterprise
}
const (
AdvancedCustomConverterNone = "none"
AdvancedCustomConverterAnthropicMessagesToOpenAIChatCompletions = "anthropic_messages_to_openai_chat_completions"
AdvancedCustomConverterOpenAIChatCompletionsToAnthropicMessages = "openai_chat_completions_to_anthropic_messages"
AdvancedCustomConverterOpenAIChatCompletionsToOpenAIResponses = "openai_chat_completions_to_openai_responses"
AdvancedCustomConverterGeminiGenerateContentToOpenAIChatCompletions = "gemini_generate_content_to_openai_chat_completions"
AdvancedCustomConverterOpenAIChatCompletionsToGeminiGenerateContent = "openai_chat_completions_to_gemini_generate_content"
)
const (
AdvancedCustomAuthTypeNone = "none"
AdvancedCustomAuthTypeHeader = "header"
AdvancedCustomAuthTypeQuery = "query"
)
type AdvancedCustomConfig struct {
Routes []AdvancedCustomRoute `json:"advanced_routes,omitempty"`
Fallback AdvancedCustomFallback `json:"advanced_fallback,omitempty"`
}
type AdvancedCustomRoute struct {
IncomingPath string `json:"incoming_path,omitempty"`
UpstreamPath string `json:"upstream_path,omitempty"`
Converter string `json:"converter,omitempty"`
Auth *AdvancedCustomRouteAuth `json:"auth,omitempty"`
}
type AdvancedCustomFallback struct {
Enabled bool `json:"enabled,omitempty"`
}
type AdvancedCustomRouteAuth struct {
Type string `json:"type,omitempty"`
Name string `json:"name,omitempty"`
Value string `json:"value,omitempty"`
}
func IsAdvancedCustomConverterAllowed(converter string) bool {
switch converter {
case AdvancedCustomConverterNone,
AdvancedCustomConverterAnthropicMessagesToOpenAIChatCompletions,
AdvancedCustomConverterOpenAIChatCompletionsToAnthropicMessages,
AdvancedCustomConverterOpenAIChatCompletionsToOpenAIResponses,
AdvancedCustomConverterGeminiGenerateContentToOpenAIChatCompletions,
AdvancedCustomConverterOpenAIChatCompletionsToGeminiGenerateContent:
return true
default:
return false
}
}
func (c *AdvancedCustomConfig) Validate() error {
if c == nil {
return fmt.Errorf("advanced_custom is required")
}
if len(c.Routes) == 0 && !c.Fallback.Enabled {
return fmt.Errorf("advanced_custom requires at least one route or enabled fallback")
}
seenPaths := make(map[string]struct{}, len(c.Routes))
for i := range c.Routes {
route := c.Routes[i]
route.IncomingPath = strings.TrimSpace(route.IncomingPath)
upstreamPath := strings.TrimSpace(route.UpstreamPath)
route.Converter = strings.TrimSpace(route.Converter)
if route.Converter == "" {
route.Converter = AdvancedCustomConverterNone
}
if route.IncomingPath == "" {
return fmt.Errorf("advanced_custom.advanced_routes[%d].incoming_path is required", i)
}
if !strings.HasPrefix(route.IncomingPath, "/") {
return fmt.Errorf("advanced_custom.advanced_routes[%d].incoming_path must start with /", i)
}
if strings.Contains(route.IncomingPath, "?") {
return fmt.Errorf("advanced_custom.advanced_routes[%d].incoming_path must not include query", i)
}
if _, exists := seenPaths[route.IncomingPath]; exists {
return fmt.Errorf("advanced_custom.advanced_routes[%d].incoming_path must be unique: %s", i, route.IncomingPath)
}
seenPaths[route.IncomingPath] = struct{}{}
if upstreamPath == "" {
return fmt.Errorf("advanced_custom.advanced_routes[%d].upstream_path is required", i)
}
if err := validateAdvancedCustomUpstreamTarget(i, upstreamPath); err != nil {
return err
}
if !IsAdvancedCustomConverterAllowed(route.Converter) {
return fmt.Errorf("advanced_custom.advanced_routes[%d].converter is not registered: %s", i, route.Converter)
}
if err := validateAdvancedCustomConverterPath(i, route.IncomingPath, route.Converter); err != nil {
return err
}
if err := validateAdvancedCustomRouteAuth(i, route.Auth); err != nil {
return err
}
}
return nil
}
func validateAdvancedCustomUpstreamTarget(index int, upstreamPath string) error {
if strings.HasPrefix(upstreamPath, "/") {
if strings.HasPrefix(upstreamPath, "//") {
return fmt.Errorf("advanced_custom.advanced_routes[%d].upstream_path must be a full URL or a path starting with /", index)
}
return nil
}
parsedURL, err := url.Parse(upstreamPath)
if err != nil || parsedURL.Scheme == "" || parsedURL.Host == "" {
return fmt.Errorf("advanced_custom.advanced_routes[%d].upstream_path must be a full URL or a path starting with /", index)
}
if !strings.EqualFold(parsedURL.Scheme, "http") && !strings.EqualFold(parsedURL.Scheme, "https") {
return fmt.Errorf("advanced_custom.advanced_routes[%d].upstream_path must use http or https", index)
}
return nil
}
func validateAdvancedCustomConverterPath(index int, incomingPath string, converter string) error {
switch converter {
case AdvancedCustomConverterNone:
return nil
case AdvancedCustomConverterAnthropicMessagesToOpenAIChatCompletions:
if incomingPath == "/v1/messages" {
return nil
}
case AdvancedCustomConverterOpenAIChatCompletionsToAnthropicMessages,
AdvancedCustomConverterOpenAIChatCompletionsToOpenAIResponses,
AdvancedCustomConverterOpenAIChatCompletionsToGeminiGenerateContent:
if incomingPath == "/v1/chat/completions" {
return nil
}
case AdvancedCustomConverterGeminiGenerateContentToOpenAIChatCompletions:
if strings.Contains(incomingPath, ":generateContent") || strings.Contains(incomingPath, ":streamGenerateContent") {
return nil
}
}
return fmt.Errorf("advanced_custom.advanced_routes[%d].converter does not match incoming_path: %s", index, converter)
}
func validateAdvancedCustomRouteAuth(index int, auth *AdvancedCustomRouteAuth) error {
if auth == nil {
return nil
}
authType := strings.TrimSpace(auth.Type)
switch authType {
case AdvancedCustomAuthTypeNone:
return nil
case AdvancedCustomAuthTypeHeader, AdvancedCustomAuthTypeQuery:
if strings.TrimSpace(auth.Name) == "" {
return fmt.Errorf("advanced_custom.advanced_routes[%d].auth.name is required", index)
}
if strings.TrimSpace(auth.Value) == "" {
return fmt.Errorf("advanced_custom.advanced_routes[%d].auth.value is required", index)
}
return nil
default:
return fmt.Errorf("advanced_custom.advanced_routes[%d].auth.type is invalid: %s", index, auth.Type)
}
}
......@@ -945,6 +945,26 @@ func (channel *Channel) ValidateSettings() error {
return err
}
}
channelOtherSettings := &dto.ChannelOtherSettings{}
if channel.OtherSettings != "" {
err := common.UnmarshalJsonStr(channel.OtherSettings, channelOtherSettings)
if err != nil {
return err
}
}
if channel.Type == constant.ChannelTypeAdvancedCustom {
if channelOtherSettings.AdvancedCustom == nil {
return fmt.Errorf("advanced_custom is required")
}
if channelOtherSettings.AdvancedCustom.Fallback.Enabled && (channel.BaseURL == nil || strings.TrimSpace(*channel.BaseURL) == "") {
return fmt.Errorf("base_url is required when advanced_custom advanced_fallback is enabled")
}
}
if channelOtherSettings.AdvancedCustom != nil {
if err := channelOtherSettings.AdvancedCustom.Validate(); err != nil {
return err
}
}
return nil
}
......
package advancedcustom
import (
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/dto"
"github.com/QuantumNous/new-api/relay/channel"
"github.com/QuantumNous/new-api/relay/channel/claude"
"github.com/QuantumNous/new-api/relay/channel/gemini"
"github.com/QuantumNous/new-api/relay/channel/openai"
relaycommon "github.com/QuantumNous/new-api/relay/common"
relayconstant "github.com/QuantumNous/new-api/relay/constant"
"github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/types"
"github.com/gin-gonic/gin"
"github.com/samber/lo"
)
const ChannelName = "advanced_custom"
const advancedCustomModelPlaceholder = "{model}"
type Adaptor struct {
openaiAdaptor openai.Adaptor
claudeAdaptor claude.Adaptor
geminiAdaptor gemini.Adaptor
resolved bool
fallback bool
converted bool
route dto.AdvancedCustomRoute
converter string
}
func (a *Adaptor) Init(info *relaycommon.RelayInfo) {
a.openaiAdaptor.Init(info)
a.claudeAdaptor.Init(info)
a.geminiAdaptor.Init(info)
}
func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeneralOpenAIRequest) (any, error) {
converter, err := a.resolveForConversion(c, info)
if err != nil {
return nil, err
}
if a.fallback || converter == dto.AdvancedCustomConverterNone {
return a.convertOpenAICompatibleRequest(c, info, request)
}
switch converter {
case dto.AdvancedCustomConverterOpenAIChatCompletionsToAnthropicMessages:
return a.claudeAdaptor.ConvertOpenAIRequest(c, info, request)
case dto.AdvancedCustomConverterOpenAIChatCompletionsToOpenAIResponses:
if request == nil {
return nil, errors.New("request is nil")
}
return service.ChatCompletionsRequestToResponsesRequest(request)
case dto.AdvancedCustomConverterOpenAIChatCompletionsToGeminiGenerateContent:
return a.geminiAdaptor.ConvertOpenAIRequest(c, info, request)
default:
return nil, fmt.Errorf("converter %q does not support OpenAI chat completions requests", converter)
}
}
func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.ClaudeRequest) (any, error) {
converter, err := a.resolveForConversion(c, info)
if err != nil {
return nil, err
}
if a.fallback {
return a.convertClaudeToOpenAICompatibleRequest(c, info, request)
}
switch converter {
case dto.AdvancedCustomConverterNone:
return a.claudeAdaptor.ConvertClaudeRequest(c, info, request)
case dto.AdvancedCustomConverterAnthropicMessagesToOpenAIChatCompletions:
return a.convertClaudeToOpenAICompatibleRequest(c, info, request)
default:
return nil, fmt.Errorf("converter %q does not support Anthropic Messages requests", converter)
}
}
func (a *Adaptor) ConvertGeminiRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeminiChatRequest) (any, error) {
converter, err := a.resolveForConversion(c, info)
if err != nil {
return nil, err
}
if a.fallback {
return a.convertGeminiToOpenAICompatibleRequest(c, info, request)
}
switch converter {
case dto.AdvancedCustomConverterNone:
return a.geminiAdaptor.ConvertGeminiRequest(c, info, request)
case dto.AdvancedCustomConverterGeminiGenerateContentToOpenAIChatCompletions:
return a.convertGeminiToOpenAICompatibleRequest(c, info, request)
default:
return nil, fmt.Errorf("converter %q does not support Gemini generateContent requests", converter)
}
}
func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) {
converter, err := a.resolveForConversion(c, info)
if err != nil {
return nil, err
}
if converter != dto.AdvancedCustomConverterNone {
return nil, fmt.Errorf("converter %q does not support OpenAI Responses requests", converter)
}
return a.convertOpenAICompatibleResponsesRequest(c, info, request)
}
func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.EmbeddingRequest) (any, error) {
converter, err := a.resolveForConversion(c, info)
if err != nil {
return nil, err
}
if converter != dto.AdvancedCustomConverterNone {
return nil, fmt.Errorf("converter %q does not support embedding requests", converter)
}
return a.convertOpenAICompatibleEmbeddingRequest(c, info, request)
}
func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) {
converter, err := a.resolveForConversion(c, info)
if err != nil {
return nil, err
}
if converter != dto.AdvancedCustomConverterNone {
return nil, fmt.Errorf("converter %q does not support audio requests", converter)
}
return a.convertOpenAICompatibleAudioRequest(c, info, request)
}
func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error) {
converter, err := a.resolveForConversion(c, info)
if err != nil {
return nil, err
}
if converter != dto.AdvancedCustomConverterNone {
return nil, fmt.Errorf("converter %q does not support image requests", converter)
}
return a.convertOpenAICompatibleImageRequest(c, info, request)
}
func (a *Adaptor) ConvertRerankRequest(c *gin.Context, relayMode int, request dto.RerankRequest) (any, error) {
a.converted = true
return a.openaiAdaptor.ConvertRerankRequest(c, relayMode, request)
}
func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
if err := a.resolve(nil, info); err != nil {
return "", err
}
if a.fallback {
return a.withTemporaryChannelType(info, constant.ChannelTypeOpenAI, func() (string, error) {
return a.openaiAdaptor.GetRequestURL(info)
})
}
return a.routeURL(info)
}
func (a *Adaptor) SetupRequestHeader(c *gin.Context, header *http.Header, info *relaycommon.RelayInfo) error {
if err := a.resolve(c, info); err != nil {
return err
}
if a.fallback {
old := info.ChannelType
info.ChannelType = constant.ChannelTypeOpenAI
err := a.openaiAdaptor.SetupRequestHeader(c, header, info)
info.ChannelType = old
return err
}
channel.SetupApiRequestHeader(info, c, header)
auth := a.route.Auth
if auth == nil {
header.Set("Authorization", "Bearer "+info.ApiKey)
} else {
switch strings.TrimSpace(auth.Type) {
case dto.AdvancedCustomAuthTypeNone:
case dto.AdvancedCustomAuthTypeHeader:
header.Set(strings.TrimSpace(auth.Name), applyAuthTemplate(auth.Value, info.ApiKey))
case dto.AdvancedCustomAuthTypeQuery:
default:
return fmt.Errorf("invalid advanced custom auth type: %s", auth.Type)
}
}
if shouldApplyClaudeHeaders(a.converter, info) {
applyClaudeHeaders(c, header, info)
}
return nil
}
func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (any, error) {
if err := a.resolve(c, info); err != nil {
return nil, err
}
if !a.converted && (a.fallback || a.converter != dto.AdvancedCustomConverterNone) {
return nil, errors.New("advanced custom converter routes cannot be used with pass-through request body")
}
if info.RelayMode == relayconstant.RelayModeAudioTranscription ||
info.RelayMode == relayconstant.RelayModeAudioTranslation ||
(info.RelayMode == relayconstant.RelayModeImagesEdits && !isJSONRequest(c)) {
return channel.DoFormRequest(a, c, info, requestBody)
}
if info.RelayMode == relayconstant.RelayModeRealtime {
return channel.DoWssRequest(a, c, info, requestBody)
}
return channel.DoApiRequest(a, c, info, requestBody)
}
func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.NewAPIError) {
if err := a.resolve(c, info); err != nil {
return nil, types.NewOpenAIError(err, types.ErrorCodeInvalidRequest, http.StatusBadRequest, types.ErrOptionWithSkipRetry())
}
if a.fallback {
return a.openaiAdaptor.DoResponse(c, resp, info)
}
switch a.converter {
case dto.AdvancedCustomConverterNone:
return a.doNativeResponse(c, resp, info)
case dto.AdvancedCustomConverterAnthropicMessagesToOpenAIChatCompletions,
dto.AdvancedCustomConverterGeminiGenerateContentToOpenAIChatCompletions:
return a.openaiAdaptor.DoResponse(c, resp, info)
case dto.AdvancedCustomConverterOpenAIChatCompletionsToAnthropicMessages:
return a.claudeAdaptor.DoResponse(c, resp, info)
case dto.AdvancedCustomConverterOpenAIChatCompletionsToGeminiGenerateContent:
return a.geminiAdaptor.DoResponse(c, resp, info)
case dto.AdvancedCustomConverterOpenAIChatCompletionsToOpenAIResponses:
if info.IsStream {
return openai.OaiResponsesToChatStreamHandler(c, info, resp)
}
return openai.OaiResponsesToChatHandler(c, info, resp)
default:
return nil, types.NewOpenAIError(fmt.Errorf("unsupported advanced custom converter: %s", a.converter), types.ErrorCodeInvalidRequest, http.StatusBadRequest, types.ErrOptionWithSkipRetry())
}
}
func (a *Adaptor) GetModelList() []string {
models := make([]string, 0, len(openai.ModelList)+len(claude.ModelList)+len(gemini.ModelList))
models = append(models, openai.ModelList...)
models = append(models, claude.ModelList...)
models = append(models, gemini.ModelList...)
return lo.Uniq(models)
}
func (a *Adaptor) GetChannelName() string {
return ChannelName
}
func (a *Adaptor) doNativeResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (any, *types.NewAPIError) {
switch info.RelayFormat {
case types.RelayFormatClaude:
return a.claudeAdaptor.DoResponse(c, resp, info)
case types.RelayFormatGemini:
return a.geminiAdaptor.DoResponse(c, resp, info)
default:
return a.openaiAdaptor.DoResponse(c, resp, info)
}
}
func (a *Adaptor) resolveForConversion(c *gin.Context, info *relaycommon.RelayInfo) (string, error) {
if err := a.resolve(c, info); err != nil {
return "", err
}
a.converted = true
return a.converter, nil
}
func (a *Adaptor) resolve(c *gin.Context, info *relaycommon.RelayInfo) error {
if a.resolved {
return nil
}
if info == nil {
return errors.New("missing relay info")
}
config := info.ChannelOtherSettings.AdvancedCustom
if config == nil {
return errors.New("advanced_custom is required")
}
if err := config.Validate(); err != nil {
return err
}
incomingPath := incomingRequestPath(c, info)
route, ok := lo.Find(config.Routes, func(route dto.AdvancedCustomRoute) bool {
return matchIncomingPath(strings.TrimSpace(route.IncomingPath), incomingPath)
})
if ok {
route.Converter = strings.TrimSpace(route.Converter)
if route.Converter == "" {
route.Converter = dto.AdvancedCustomConverterNone
}
a.route = route
a.converter = route.Converter
a.resolved = true
return nil
}
if config.Fallback.Enabled {
a.fallback = true
a.converter = dto.AdvancedCustomConverterNone
a.resolved = true
return nil
}
return fmt.Errorf("advanced custom route not found for path: %s", incomingPath)
}
func incomingRequestPath(c *gin.Context, info *relaycommon.RelayInfo) string {
if c != nil && c.Request != nil && c.Request.URL != nil {
return c.Request.URL.Path
}
if info == nil {
return ""
}
return strings.Split(info.RequestURLPath, "?")[0]
}
func matchIncomingPath(configuredPath string, requestPath string) bool {
if matchIncomingPathTemplate(configuredPath, requestPath) {
return true
}
if strings.Contains(configuredPath, ":generateContent") {
streamPath := strings.Replace(configuredPath, ":generateContent", ":streamGenerateContent", 1)
return matchIncomingPathTemplate(streamPath, requestPath)
}
return false
}
func matchIncomingPathTemplate(configuredPath string, requestPath string) bool {
if !strings.Contains(configuredPath, advancedCustomModelPlaceholder) {
return configuredPath == requestPath
}
parts := strings.Split(configuredPath, advancedCustomModelPlaceholder)
if len(parts) != 2 {
return false
}
if !strings.HasPrefix(requestPath, parts[0]) || !strings.HasSuffix(requestPath, parts[1]) {
return false
}
model := strings.TrimSuffix(strings.TrimPrefix(requestPath, parts[0]), parts[1])
return model != "" && !strings.Contains(model, "/")
}
func (a *Adaptor) routeURL(info *relaycommon.RelayInfo) (string, error) {
parsedURL, err := resolveUpstreamTargetURL(applyUpstreamPathTemplate(strings.TrimSpace(a.route.UpstreamPath), info), info)
if err != nil {
return "", err
}
if shouldUseGeminiStreamURL(a.converter, info) {
useGeminiStreamGenerateContentURL(parsedURL)
}
if info != nil && info.RelayMode == relayconstant.RelayModeRealtime {
switch parsedURL.Scheme {
case "https":
parsedURL.Scheme = "wss"
case "http":
parsedURL.Scheme = "ws"
}
}
if a.route.Auth != nil && strings.TrimSpace(a.route.Auth.Type) == dto.AdvancedCustomAuthTypeQuery {
query := parsedURL.Query()
query.Set(strings.TrimSpace(a.route.Auth.Name), applyAuthTemplate(a.route.Auth.Value, info.ApiKey))
parsedURL.RawQuery = query.Encode()
}
return parsedURL.String(), nil
}
func resolveUpstreamTargetURL(upstreamPath string, info *relaycommon.RelayInfo) (*url.URL, error) {
if strings.HasPrefix(upstreamPath, "/") {
if strings.HasPrefix(upstreamPath, "//") {
return nil, errors.New("advanced custom upstream path must be a full URL or a path starting with /")
}
if info == nil || strings.TrimSpace(info.ChannelBaseUrl) == "" {
return nil, errors.New("channel base URL is required when advanced custom upstream path is relative")
}
return joinBaseURLAndUpstreamPath(info.ChannelBaseUrl, upstreamPath)
}
parsedURL, err := url.Parse(upstreamPath)
if err != nil {
return nil, err
}
if parsedURL.Scheme == "" || parsedURL.Host == "" {
return nil, errors.New("advanced custom upstream path must be a full URL or a path starting with /")
}
if !strings.EqualFold(parsedURL.Scheme, "http") && !strings.EqualFold(parsedURL.Scheme, "https") {
return nil, errors.New("advanced custom upstream path must use http or https")
}
return parsedURL, nil
}
func joinBaseURLAndUpstreamPath(baseURL string, upstreamPath string) (*url.URL, error) {
parsedBaseURL, err := url.Parse(strings.TrimSpace(baseURL))
if err != nil {
return nil, err
}
if parsedBaseURL.Scheme == "" || parsedBaseURL.Host == "" {
return nil, errors.New("channel base URL must be a full URL when advanced custom upstream path is relative")
}
if !strings.EqualFold(parsedBaseURL.Scheme, "http") && !strings.EqualFold(parsedBaseURL.Scheme, "https") {
return nil, errors.New("channel base URL must use http or https when advanced custom upstream path is relative")
}
parsedPath, err := url.Parse(upstreamPath)
if err != nil {
return nil, err
}
parsedBaseURL.Path = strings.TrimRight(parsedBaseURL.Path, "/") + "/" + strings.TrimLeft(parsedPath.Path, "/")
parsedBaseURL.RawPath = ""
parsedBaseURL.RawQuery = parsedPath.RawQuery
parsedBaseURL.Fragment = parsedPath.Fragment
return parsedBaseURL, nil
}
func applyUpstreamPathTemplate(upstreamPath string, info *relaycommon.RelayInfo) string {
if info == nil {
return upstreamPath
}
return strings.ReplaceAll(upstreamPath, advancedCustomModelPlaceholder, info.UpstreamModelName)
}
func shouldUseGeminiStreamURL(converter string, info *relaycommon.RelayInfo) bool {
return info != nil &&
info.IsStream &&
converter == dto.AdvancedCustomConverterOpenAIChatCompletionsToGeminiGenerateContent
}
func useGeminiStreamGenerateContentURL(parsedURL *url.URL) {
if strings.Contains(parsedURL.Path, ":generateContent") {
parsedURL.Path = strings.Replace(parsedURL.Path, ":generateContent", ":streamGenerateContent", 1)
}
if strings.Contains(parsedURL.Path, ":streamGenerateContent") {
query := parsedURL.Query()
query.Set("alt", "sse")
parsedURL.RawQuery = query.Encode()
}
}
func shouldApplyClaudeHeaders(converter string, info *relaycommon.RelayInfo) bool {
return converter == dto.AdvancedCustomConverterOpenAIChatCompletionsToAnthropicMessages ||
(converter == dto.AdvancedCustomConverterNone && info != nil && info.RelayFormat == types.RelayFormatClaude)
}
func applyClaudeHeaders(c *gin.Context, header *http.Header, info *relaycommon.RelayInfo) {
anthropicVersion := ""
if c != nil && c.Request != nil {
anthropicVersion = c.Request.Header.Get("anthropic-version")
}
if anthropicVersion == "" {
anthropicVersion = "2023-06-01"
}
header.Set("anthropic-version", anthropicVersion)
if c != nil {
claude.CommonClaudeHeadersOperation(c, header, info)
}
}
func applyAuthTemplate(template string, apiKey string) string {
return strings.ReplaceAll(template, "{api_key}", apiKey)
}
func isJSONRequest(c *gin.Context) bool {
if c == nil || c.Request == nil {
return false
}
return strings.Contains(strings.ToLower(c.Request.Header.Get("Content-Type")), "application/json")
}
func (a *Adaptor) convertOpenAICompatibleRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeneralOpenAIRequest) (any, error) {
old := info.ChannelType
info.ChannelType = constant.ChannelTypeOpenAI
converted, err := a.openaiAdaptor.ConvertOpenAIRequest(c, info, request)
info.ChannelType = old
return converted, err
}
func (a *Adaptor) convertClaudeToOpenAICompatibleRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.ClaudeRequest) (any, error) {
old := info.ChannelType
info.ChannelType = constant.ChannelTypeOpenAI
converted, err := a.openaiAdaptor.ConvertClaudeRequest(c, info, request)
info.ChannelType = old
return converted, err
}
func (a *Adaptor) convertGeminiToOpenAICompatibleRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeminiChatRequest) (any, error) {
old := info.ChannelType
info.ChannelType = constant.ChannelTypeOpenAI
converted, err := a.openaiAdaptor.ConvertGeminiRequest(c, info, request)
info.ChannelType = old
return converted, err
}
func (a *Adaptor) convertOpenAICompatibleResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) {
old := info.ChannelType
info.ChannelType = constant.ChannelTypeOpenAI
converted, err := a.openaiAdaptor.ConvertOpenAIResponsesRequest(c, info, request)
info.ChannelType = old
return converted, err
}
func (a *Adaptor) convertOpenAICompatibleEmbeddingRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.EmbeddingRequest) (any, error) {
old := info.ChannelType
info.ChannelType = constant.ChannelTypeOpenAI
converted, err := a.openaiAdaptor.ConvertEmbeddingRequest(c, info, request)
info.ChannelType = old
return converted, err
}
func (a *Adaptor) convertOpenAICompatibleAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) {
old := info.ChannelType
info.ChannelType = constant.ChannelTypeOpenAI
converted, err := a.openaiAdaptor.ConvertAudioRequest(c, info, request)
info.ChannelType = old
return converted, err
}
func (a *Adaptor) convertOpenAICompatibleImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error) {
old := info.ChannelType
info.ChannelType = constant.ChannelTypeOpenAI
converted, err := a.openaiAdaptor.ConvertImageRequest(c, info, request)
info.ChannelType = old
return converted, err
}
func (a *Adaptor) withTemporaryChannelType(info *relaycommon.RelayInfo, channelType int, fn func() (string, error)) (string, error) {
old := info.ChannelType
info.ChannelType = channelType
value, err := fn()
info.ChannelType = old
return value, err
}
package advancedcustom
import (
"net/http"
"net/http/httptest"
"net/url"
"testing"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/dto"
relaycommon "github.com/QuantumNous/new-api/relay/common"
relayconstant "github.com/QuantumNous/new-api/relay/constant"
"github.com/QuantumNous/new-api/types"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestAdaptorUsesExactRouteAndQueryAuth(t *testing.T) {
adaptor := &Adaptor{}
info := advancedCustomRelayInfo(&dto.AdvancedCustomConfig{
Routes: []dto.AdvancedCustomRoute{
{
IncomingPath: "/v1/messages",
UpstreamPath: "https://upstream.example/v1/chat/completions?existing=1",
Converter: dto.AdvancedCustomConverterAnthropicMessagesToOpenAIChatCompletions,
Auth: &dto.AdvancedCustomRouteAuth{
Type: dto.AdvancedCustomAuthTypeQuery,
Name: "api_key",
Value: "{api_key}",
},
},
},
})
info.RequestURLPath = "/v1/messages?client=1"
requestURL, err := adaptor.GetRequestURL(info)
require.NoError(t, err)
parsedURL, err := url.Parse(requestURL)
require.NoError(t, err)
assert.Equal(t, "https", parsedURL.Scheme)
assert.Equal(t, "upstream.example", parsedURL.Host)
assert.Equal(t, "/v1/chat/completions", parsedURL.Path)
assert.Equal(t, "1", parsedURL.Query().Get("existing"))
assert.Equal(t, "sk-test", parsedURL.Query().Get("api_key"))
}
func TestAdaptorJoinsUpstreamPathWithChannelBaseURL(t *testing.T) {
adaptor := &Adaptor{}
info := advancedCustomRelayInfo(&dto.AdvancedCustomConfig{
Routes: []dto.AdvancedCustomRoute{
{
IncomingPath: "/v1/chat/completions",
UpstreamPath: "/proxy/v1/chat/completions?existing=1",
Converter: dto.AdvancedCustomConverterNone,
Auth: &dto.AdvancedCustomRouteAuth{
Type: dto.AdvancedCustomAuthTypeQuery,
Name: "api_key",
Value: "{api_key}",
},
},
},
})
info.ChannelBaseUrl = "https://gateway.example/base"
requestURL, err := adaptor.GetRequestURL(info)
require.NoError(t, err)
parsedURL, err := url.Parse(requestURL)
require.NoError(t, err)
assert.Equal(t, "https", parsedURL.Scheme)
assert.Equal(t, "gateway.example", parsedURL.Host)
assert.Equal(t, "/base/proxy/v1/chat/completions", parsedURL.Path)
assert.Equal(t, "1", parsedURL.Query().Get("existing"))
assert.Equal(t, "sk-test", parsedURL.Query().Get("api_key"))
}
func TestAdaptorReturnsErrorWhenUpstreamPathNeedsMissingBaseURL(t *testing.T) {
adaptor := &Adaptor{}
info := advancedCustomRelayInfo(&dto.AdvancedCustomConfig{
Routes: []dto.AdvancedCustomRoute{
{
IncomingPath: "/v1/chat/completions",
UpstreamPath: "/v1/chat/completions",
Converter: dto.AdvancedCustomConverterNone,
},
},
})
info.ChannelBaseUrl = ""
_, err := adaptor.GetRequestURL(info)
require.Error(t, err)
assert.Contains(t, err.Error(), "base URL is required")
}
func TestAdaptorSetupRequestHeaderUsesDefaultBearerAuth(t *testing.T) {
adaptor := &Adaptor{}
info := advancedCustomRelayInfo(&dto.AdvancedCustomConfig{
Routes: []dto.AdvancedCustomRoute{
{
IncomingPath: "/v1/chat/completions",
UpstreamPath: "https://upstream.example/v1/chat/completions",
Converter: dto.AdvancedCustomConverterNone,
},
},
})
c := advancedCustomGinContext("/v1/chat/completions")
header := http.Header{}
require.NoError(t, adaptor.SetupRequestHeader(c, &header, info))
assert.Equal(t, "Bearer sk-test", header.Get("Authorization"))
}
func TestAdaptorSetupRequestHeaderUsesConfiguredHeaderAuth(t *testing.T) {
adaptor := &Adaptor{}
info := advancedCustomRelayInfo(&dto.AdvancedCustomConfig{
Routes: []dto.AdvancedCustomRoute{
{
IncomingPath: "/v1/chat/completions",
UpstreamPath: "https://upstream.example/v1/chat/completions",
Converter: dto.AdvancedCustomConverterNone,
Auth: &dto.AdvancedCustomRouteAuth{
Type: dto.AdvancedCustomAuthTypeHeader,
Name: "x-api-key",
Value: "{api_key}",
},
},
},
})
c := advancedCustomGinContext("/v1/chat/completions")
header := http.Header{}
require.NoError(t, adaptor.SetupRequestHeader(c, &header, info))
assert.Empty(t, header.Get("Authorization"))
assert.Equal(t, "sk-test", header.Get("x-api-key"))
}
func TestAdaptorSetupRequestHeaderAddsClaudeDefaultHeaders(t *testing.T) {
adaptor := &Adaptor{}
info := advancedCustomRelayInfo(&dto.AdvancedCustomConfig{
Routes: []dto.AdvancedCustomRoute{
{
IncomingPath: "/v1/messages",
UpstreamPath: "https://api.anthropic.com/v1/messages",
Converter: dto.AdvancedCustomConverterNone,
Auth: &dto.AdvancedCustomRouteAuth{
Type: dto.AdvancedCustomAuthTypeHeader,
Name: "x-api-key",
Value: "{api_key}",
},
},
},
})
info.RelayFormat = types.RelayFormatClaude
c := advancedCustomGinContext("/v1/messages")
header := http.Header{}
require.NoError(t, adaptor.SetupRequestHeader(c, &header, info))
assert.Equal(t, "sk-test", header.Get("x-api-key"))
assert.Equal(t, "2023-06-01", header.Get("anthropic-version"))
}
func TestAdaptorReturnsErrorWhenNoRouteAndFallbackDisabled(t *testing.T) {
adaptor := &Adaptor{}
info := advancedCustomRelayInfo(&dto.AdvancedCustomConfig{
Routes: []dto.AdvancedCustomRoute{
{
IncomingPath: "/v1/messages",
UpstreamPath: "https://upstream.example/v1/chat/completions",
Converter: dto.AdvancedCustomConverterAnthropicMessagesToOpenAIChatCompletions,
},
},
})
info.RequestURLPath = "/v1/chat/completions"
_, err := adaptor.GetRequestURL(info)
require.Error(t, err)
assert.Contains(t, err.Error(), "route not found")
}
func TestAdaptorFallbackUsesOpenAICompatibleBaseURL(t *testing.T) {
adaptor := &Adaptor{}
info := advancedCustomRelayInfo(&dto.AdvancedCustomConfig{
Fallback: dto.AdvancedCustomFallback{Enabled: true},
})
info.RequestURLPath = "/v1/messages"
info.RelayFormat = types.RelayFormatClaude
requestURL, err := adaptor.GetRequestURL(info)
require.NoError(t, err)
assert.Equal(t, "https://fallback.example/v1/chat/completions", requestURL)
}
func TestAdaptorReplacesModelPlaceholderInRouteURL(t *testing.T) {
adaptor := &Adaptor{}
info := advancedCustomRelayInfo(&dto.AdvancedCustomConfig{
Routes: []dto.AdvancedCustomRoute{
{
IncomingPath: "/v1/chat/completions",
UpstreamPath: "https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent",
Converter: dto.AdvancedCustomConverterOpenAIChatCompletionsToGeminiGenerateContent,
Auth: &dto.AdvancedCustomRouteAuth{
Type: dto.AdvancedCustomAuthTypeQuery,
Name: "key",
Value: "{api_key}",
},
},
},
})
info.UpstreamModelName = "gemini-2.5-flash"
requestURL, err := adaptor.GetRequestURL(info)
require.NoError(t, err)
parsedURL, err := url.Parse(requestURL)
require.NoError(t, err)
assert.Equal(t, "/v1beta/models/gemini-2.5-flash:generateContent", parsedURL.Path)
assert.Equal(t, "sk-test", parsedURL.Query().Get("key"))
assert.Empty(t, parsedURL.Query().Get("alt"))
}
func TestAdaptorSwitchesGeminiGenerateContentURLForStream(t *testing.T) {
adaptor := &Adaptor{}
info := advancedCustomRelayInfo(&dto.AdvancedCustomConfig{
Routes: []dto.AdvancedCustomRoute{
{
IncomingPath: "/v1/chat/completions",
UpstreamPath: "https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent?existing=1",
Converter: dto.AdvancedCustomConverterOpenAIChatCompletionsToGeminiGenerateContent,
Auth: &dto.AdvancedCustomRouteAuth{
Type: dto.AdvancedCustomAuthTypeQuery,
Name: "key",
Value: "{api_key}",
},
},
},
})
info.UpstreamModelName = "gemini-2.5-pro"
info.IsStream = true
requestURL, err := adaptor.GetRequestURL(info)
require.NoError(t, err)
parsedURL, err := url.Parse(requestURL)
require.NoError(t, err)
assert.Equal(t, "/v1beta/models/gemini-2.5-pro:streamGenerateContent", parsedURL.Path)
assert.Equal(t, "sse", parsedURL.Query().Get("alt"))
assert.Equal(t, "1", parsedURL.Query().Get("existing"))
assert.Equal(t, "sk-test", parsedURL.Query().Get("key"))
}
func TestAdaptorMatchesGeminiIncomingPathTemplate(t *testing.T) {
tests := []struct {
name string
requestURLPath string
wantRequestPath string
}{
{
name: "generate content",
requestURLPath: "/v1beta/models/gemini-2.5-flash:generateContent",
wantRequestPath: "/v1/chat/completions",
},
{
name: "stream generate content",
requestURLPath: "/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse",
wantRequestPath: "/v1/chat/completions",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
adaptor := &Adaptor{}
info := advancedCustomRelayInfo(&dto.AdvancedCustomConfig{
Routes: []dto.AdvancedCustomRoute{
{
IncomingPath: "/v1beta/models/{model}:generateContent",
UpstreamPath: "https://upstream.example/v1/chat/completions",
Converter: dto.AdvancedCustomConverterGeminiGenerateContentToOpenAIChatCompletions,
},
},
})
info.RequestURLPath = tt.requestURLPath
requestURL, err := adaptor.GetRequestURL(info)
require.NoError(t, err)
parsedURL, err := url.Parse(requestURL)
require.NoError(t, err)
assert.Equal(t, tt.wantRequestPath, parsedURL.Path)
})
}
}
func advancedCustomRelayInfo(config *dto.AdvancedCustomConfig) *relaycommon.RelayInfo {
return &relaycommon.RelayInfo{
RelayFormat: types.RelayFormatOpenAI,
RelayMode: relayconstant.RelayModeChatCompletions,
RequestURLPath: "/v1/chat/completions",
ChannelMeta: &relaycommon.ChannelMeta{
ApiKey: "sk-test",
ChannelBaseUrl: "https://fallback.example",
ChannelType: constant.ChannelTypeAdvancedCustom,
ChannelOtherSettings: dto.ChannelOtherSettings{
AdvancedCustom: config,
},
},
}
}
func advancedCustomGinContext(path string) *gin.Context {
gin.SetMode(gin.TestMode)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest(http.MethodPost, path, nil)
c.Request.Header.Set("Content-Type", "application/json")
return c
}
......@@ -318,24 +318,25 @@ func (info *RelayInfo) ToString() string {
// 定义支持流式选项的通道类型
var streamSupportedChannels = map[int]bool{
constant.ChannelTypeOpenAI: true,
constant.ChannelTypeAnthropic: true,
constant.ChannelTypeAws: true,
constant.ChannelTypeGemini: true,
constant.ChannelCloudflare: true,
constant.ChannelTypeAzure: true,
constant.ChannelTypeVolcEngine: true,
constant.ChannelTypeOllama: true,
constant.ChannelTypeXai: true,
constant.ChannelTypeDeepSeek: true,
constant.ChannelTypeBaiduV2: true,
constant.ChannelTypeZhipu_v4: true,
constant.ChannelTypeAli: true,
constant.ChannelTypeSubmodel: true,
constant.ChannelTypeCodex: true,
constant.ChannelTypeMoonshot: true,
constant.ChannelTypeMiniMax: true,
constant.ChannelTypeSiliconFlow: true,
constant.ChannelTypeOpenAI: true,
constant.ChannelTypeAnthropic: true,
constant.ChannelTypeAws: true,
constant.ChannelTypeGemini: true,
constant.ChannelCloudflare: true,
constant.ChannelTypeAzure: true,
constant.ChannelTypeVolcEngine: true,
constant.ChannelTypeOllama: true,
constant.ChannelTypeXai: true,
constant.ChannelTypeDeepSeek: true,
constant.ChannelTypeBaiduV2: true,
constant.ChannelTypeZhipu_v4: true,
constant.ChannelTypeAli: true,
constant.ChannelTypeSubmodel: true,
constant.ChannelTypeCodex: true,
constant.ChannelTypeMoonshot: true,
constant.ChannelTypeMiniMax: true,
constant.ChannelTypeSiliconFlow: true,
constant.ChannelTypeAdvancedCustom: true,
}
func GenRelayInfoWs(c *gin.Context, ws *websocket.Conn) *RelayInfo {
......
......@@ -5,6 +5,7 @@ import (
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/relay/channel"
"github.com/QuantumNous/new-api/relay/channel/advancedcustom"
"github.com/QuantumNous/new-api/relay/channel/ali"
"github.com/QuantumNous/new-api/relay/channel/aws"
"github.com/QuantumNous/new-api/relay/channel/baidu"
......@@ -120,6 +121,8 @@ func GetAdaptor(apiType int) channel.Adaptor {
return &replicate.Adaptor{}
case constant.APITypeCodex:
return &codex.Adaptor{}
case constant.APITypeAdvancedCustom:
return &advancedcustom.Adaptor{}
}
return nil
}
......
......@@ -159,7 +159,10 @@ function SelectItem({
)}
{...props}
>
<SelectPrimitive.ItemText className='flex flex-1 shrink-0 gap-2 whitespace-nowrap'>
<SelectPrimitive.ItemText
data-slot='select-item-text'
className='flex flex-1 shrink-0 gap-2 whitespace-nowrap'
>
{children}
</SelectPrimitive.ItemText>
<SelectPrimitive.ItemIndicator
......
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { type ReactNode, useMemo, useState } from 'react'
import { Check, Plus, Trash2 } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Separator } from '@/components/ui/separator'
import { Switch } from '@/components/ui/switch'
import { Textarea } from '@/components/ui/textarea'
import { Dialog } from '@/components/dialog'
import {
ADVANCED_CUSTOM_AUTH_MODE_OPTIONS,
ADVANCED_CUSTOM_CONVERTER_OPTIONS,
ADVANCED_CUSTOM_TEMPLATE_OPTIONS,
type AdvancedCustomAuthMode,
buildAdvancedCustomAuth,
createAdvancedCustomConfig,
createAdvancedCustomRoute,
getAdvancedCustomAuthMode,
getAdvancedCustomIncomingPathLabel,
getAdvancedCustomIncomingPathOptions,
getAdvancedCustomTemplateConfig,
getAdvancedCustomUpstreamPathPlaceholder,
getDefaultAdvancedCustomIncomingPath,
isAdvancedCustomIncomingPathAllowed,
normalizeAdvancedCustomConfig,
parseAdvancedCustomConfig,
stringifyAdvancedCustomConfig,
validateAdvancedCustomConfig,
} from '../../lib/advanced-custom'
import type {
AdvancedCustomAuthType,
AdvancedCustomConfig,
AdvancedCustomConverter,
AdvancedCustomRoute,
} from '../../types'
type AdvancedCustomEditorDialogProps = {
open: boolean
value: string
onOpenChange: (open: boolean) => void
onSave: (value: string) => void
}
type AdvancedCustomEditMode = 'visual' | 'json'
const longSelectContentClass = 'w-[360px] max-w-[calc(100vw-2rem)]'
const longSelectItemClass =
'items-start py-2 [&_[data-slot=select-item-text]]:min-w-0 [&_[data-slot=select-item-text]]:shrink [&_[data-slot=select-item-text]]:whitespace-normal'
function getOptionLabel(
options: ReadonlyArray<{ value: string; label: string }>,
value: string
) {
return options.find((option) => option.value === value)?.label || value
}
export function AdvancedCustomEditorDialog({
open,
value,
onOpenChange,
onSave,
}: AdvancedCustomEditorDialogProps) {
const { t } = useTranslation()
const [config, setConfig] = useState<AdvancedCustomConfig>(
() => parseAdvancedCustomConfig(value) || createAdvancedCustomConfig()
)
const [editMode, setEditMode] = useState<AdvancedCustomEditMode>('visual')
const [jsonText, setJsonText] = useState(() =>
stringifyAdvancedCustomConfig(
parseAdvancedCustomConfig(value) || createAdvancedCustomConfig()
)
)
const [jsonError, setJsonError] = useState('')
const [templateKey, setTemplateKey] = useState(
ADVANCED_CUSTOM_TEMPLATE_OPTIONS[0]?.value || ''
)
const templateLabel = useMemo(
() => getOptionLabel(ADVANCED_CUSTOM_TEMPLATE_OPTIONS, templateKey),
[templateKey]
)
const normalizedConfig = useMemo(
() => normalizeAdvancedCustomConfig(config),
[config]
)
const routes = normalizedConfig.advanced_routes || []
const validationError = useMemo(
() => validateAdvancedCustomConfig(normalizedConfig),
[normalizedConfig]
)
const updateRoute = (index: number, patch: Partial<AdvancedCustomRoute>) => {
setConfig((current) => {
const next = normalizeAdvancedCustomConfig(current)
const nextRoutes = [...(next.advanced_routes || [])]
nextRoutes[index] = { ...nextRoutes[index], ...patch }
return { ...next, advanced_routes: nextRoutes }
})
}
const addRoute = () => {
setConfig((current) => {
const next = normalizeAdvancedCustomConfig(current)
return {
...next,
advanced_routes: [
...(next.advanced_routes || []),
createAdvancedCustomRoute(),
],
}
})
}
const removeRoute = (index: number) => {
setConfig((current) => {
const next = normalizeAdvancedCustomConfig(current)
return {
...next,
advanced_routes: (next.advanced_routes || []).filter(
(_, routeIndex) => routeIndex !== index
),
}
})
}
const setFallbackEnabled = (enabled: boolean) => {
setConfig((current) => ({
...normalizeAdvancedCustomConfig(current),
advanced_fallback: { enabled },
}))
}
const parseJsonEditorConfig = (): AdvancedCustomConfig | null => {
const parsed = parseAdvancedCustomConfig(jsonText)
if (!parsed) {
setJsonError(t('Invalid JSON'))
return null
}
const error = validateAdvancedCustomConfig(parsed)
if (error) {
setJsonError(t(error.message))
return null
}
setJsonError('')
return parsed
}
const switchToVisualMode = () => {
const parsed = parseJsonEditorConfig()
if (!parsed) return
setConfig(parsed)
setEditMode('visual')
}
const switchToJsonMode = () => {
setJsonText(stringifyAdvancedCustomConfig(normalizedConfig))
setJsonError('')
setEditMode('json')
}
const handleJsonChange = (nextValue: string) => {
setJsonText(nextValue)
if (jsonError) setJsonError('')
}
const formatJson = () => {
const parsed = parseJsonEditorConfig()
if (!parsed) return
setJsonText(stringifyAdvancedCustomConfig(parsed))
}
const applyTemplate = (mode: 'fill' | 'append') => {
const templateConfig = getAdvancedCustomTemplateConfig(templateKey)
let nextConfig = templateConfig
if (mode === 'append') {
const baseConfig =
editMode === 'json' ? parseJsonEditorConfig() : normalizedConfig
if (!baseConfig) return
const base = normalizeAdvancedCustomConfig(baseConfig)
const template = normalizeAdvancedCustomConfig(templateConfig)
nextConfig = {
advanced_routes: [
...(base.advanced_routes || []),
...(template.advanced_routes || []),
],
advanced_fallback: {
enabled:
base.advanced_fallback?.enabled === true ||
template.advanced_fallback?.enabled === true,
},
}
}
const normalized = normalizeAdvancedCustomConfig(nextConfig)
setConfig(normalized)
setJsonText(stringifyAdvancedCustomConfig(normalized))
setJsonError('')
}
const saveConfig = () => {
if (editMode === 'json') {
const parsed = parseJsonEditorConfig()
if (!parsed) {
toast.error(t('Please fix JSON errors before saving'))
return
}
onSave(stringifyAdvancedCustomConfig(parsed))
onOpenChange(false)
return
}
if (validationError) {
toast.error(t(validationError.message))
return
}
onSave(stringifyAdvancedCustomConfig(normalizedConfig))
onOpenChange(false)
}
return (
<Dialog
open={open}
onOpenChange={onOpenChange}
title={t('Advanced Custom Routes')}
description={t('Advanced Custom')}
contentClassName='flex max-h-[90vh] flex-col gap-0 p-0 sm:max-w-5xl'
headerClassName='border-b px-6 py-4'
footerClassName='border-t px-6 py-4'
contentHeight='70vh'
footer={
<>
<Button
type='button'
variant='outline'
onClick={() => onOpenChange(false)}
>
{t('Cancel')}
</Button>
<Button type='button' onClick={saveConfig}>
<Check className='mr-2 h-4 w-4' />
{t('Save changes')}
</Button>
</>
}
>
<div className='bg-muted/30 border-b px-4 py-3'>
<div className='flex flex-wrap items-center gap-2'>
<span className='text-muted-foreground text-xs font-medium'>
{t('Mode')}
</span>
<Button
type='button'
variant={editMode === 'visual' ? 'default' : 'outline'}
size='sm'
onClick={switchToVisualMode}
>
{t('Visual')}
</Button>
<Button
type='button'
variant={editMode === 'json' ? 'default' : 'outline'}
size='sm'
onClick={switchToJsonMode}
>
{t('JSON Text')}
</Button>
<div className='bg-border mx-1 h-5 w-px' />
<span className='text-muted-foreground text-xs font-medium'>
{t('Template')}
</span>
<Select
value={templateKey}
onValueChange={(nextValue) =>
setTemplateKey(
nextValue || ADVANCED_CUSTOM_TEMPLATE_OPTIONS[0]?.value || ''
)
}
>
<SelectTrigger className='h-8 min-w-[260px] max-w-full flex-1 sm:w-[320px]'>
<SelectValue className='min-w-0 truncate'>
{t(templateLabel)}
</SelectValue>
</SelectTrigger>
<SelectContent
alignItemWithTrigger={false}
className={longSelectContentClass}
>
<SelectGroup>
{ADVANCED_CUSTOM_TEMPLATE_OPTIONS.map((option) => (
<SelectItem
key={option.value}
value={option.value}
className={longSelectItemClass}
>
<span className='min-w-0 whitespace-normal break-words leading-snug'>
{t(option.label)}
</span>
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<Button
type='button'
variant='outline'
size='sm'
onClick={() => applyTemplate('fill')}
>
{t('Fill Template')}
</Button>
<Button
type='button'
variant='ghost'
size='sm'
onClick={() => applyTemplate('append')}
>
{t('Append Template')}
</Button>
</div>
</div>
{editMode === 'visual' ? (
<div className='flex flex-col gap-5 p-4'>
<div className='flex flex-col gap-3 border-y py-4 sm:flex-row sm:items-center sm:justify-between'>
<div className='flex items-center gap-3'>
<Switch
checked={normalizedConfig.advanced_fallback?.enabled === true}
onCheckedChange={setFallbackEnabled}
/>
<div className='space-y-1'>
<div className='text-sm font-medium'>
{t('Fallback routing')}
</div>
<div className='text-muted-foreground max-w-2xl text-xs leading-relaxed'>
{t(
'When enabled, requests that do not match any advanced route are forwarded to the channel base URL. When disabled, unmatched requests return an error.'
)}
</div>
</div>
</div>
<Button
type='button'
variant='outline'
size='sm'
onClick={addRoute}
>
<Plus className='mr-2 h-4 w-4' />
{t('Add route')}
</Button>
</div>
{validationError ? (
<Alert variant='destructive'>
<AlertDescription>
{validationError.routeIndex !== undefined
? `${t('Route')} ${validationError.routeIndex + 1}: `
: ''}
{t(validationError.message)}
</AlertDescription>
</Alert>
) : null}
<div className='flex flex-col gap-4'>
{routes.map((route, index) => (
<RouteEditor
key={index}
route={route}
index={index}
onChange={(patch) => updateRoute(index, patch)}
onRemove={() => removeRoute(index)}
/>
))}
</div>
</div>
) : (
<div className='p-4'>
<div className='mb-2 flex items-center gap-2'>
<Button
type='button'
variant='outline'
size='sm'
onClick={formatJson}
>
{t('Format')}
</Button>
<span className='text-muted-foreground text-xs'>
{t('Advanced text editing')}
</span>
</div>
<Textarea
value={jsonText}
onChange={(event) => handleJsonChange(event.target.value)}
placeholder={stringifyAdvancedCustomConfig(
getAdvancedCustomTemplateConfig(templateKey)
)}
rows={22}
className='min-h-[420px] font-mono text-xs'
/>
<p className='text-muted-foreground mt-2 text-xs'>
{t('Edit JSON text directly. Format will be validated on save.')}
</p>
{jsonError ? (
<p className='text-destructive mt-1 text-xs'>{jsonError}</p>
) : null}
</div>
)}
</Dialog>
)
}
function RouteEditor({
route,
index,
onChange,
onRemove,
}: {
route: AdvancedCustomRoute
index: number
onChange: (patch: Partial<AdvancedCustomRoute>) => void
onRemove: () => void
}) {
const { t } = useTranslation()
const converter = route.converter || 'none'
const authMode = getAdvancedCustomAuthMode(route)
const incomingPath =
route.incoming_path || getDefaultAdvancedCustomIncomingPath(converter)
const incomingPathOptions = useMemo(
() => getAdvancedCustomIncomingPathOptions(converter),
[converter]
)
const incomingPathLabel = getAdvancedCustomIncomingPathLabel(incomingPath)
const converterLabel =
getOptionLabel(ADVANCED_CUSTOM_CONVERTER_OPTIONS, converter)
const authLabel = getOptionLabel(ADVANCED_CUSTOM_AUTH_MODE_OPTIONS, authMode)
const setConverter = (nextConverter: AdvancedCustomConverter) => {
const patch: Partial<AdvancedCustomRoute> = { converter: nextConverter }
if (!isAdvancedCustomIncomingPathAllowed(incomingPath, nextConverter)) {
patch.incoming_path = getDefaultAdvancedCustomIncomingPath(nextConverter)
}
onChange(patch)
}
const setAuthMode = (mode: AdvancedCustomAuthMode) => {
onChange({ auth: buildAdvancedCustomAuth(mode, route.auth) })
}
const updateAuth = (
field: Exclude<keyof NonNullable<AdvancedCustomRoute['auth']>, 'type'>,
value: string
) => {
const currentAuth = route.auth
if (!currentAuth || currentAuth.type === 'none') return
onChange({
auth: {
type: currentAuth.type as AdvancedCustomAuthType,
name: currentAuth.name || '',
value: currentAuth.value || '',
[field]: value,
},
})
}
return (
<div className='border-border flex flex-col gap-4 rounded-md border p-4'>
<div className='flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between'>
<div className='min-w-0 space-y-2'>
<div className='flex flex-wrap items-center gap-2'>
<div className='text-sm font-medium'>
{t('Route')} {index + 1}
</div>
<Badge variant='secondary'>{t(converterLabel)}</Badge>
</div>
</div>
<Button type='button' variant='ghost' size='icon' onClick={onRemove}>
<Trash2 className='h-4 w-4' />
<span className='sr-only'>{t('Delete')}</span>
</Button>
</div>
<div className='grid gap-4 md:grid-cols-2'>
<FieldBlock label={t('Incoming path')}>
<Select
value={incomingPath}
onValueChange={(value) =>
onChange({
incoming_path:
value || getDefaultAdvancedCustomIncomingPath(converter),
})
}
>
<SelectTrigger className='w-full max-w-full'>
<SelectValue className='min-w-0 truncate'>
{`${t(incomingPathLabel)} · ${incomingPath}`}
</SelectValue>
</SelectTrigger>
<SelectContent
alignItemWithTrigger={false}
className={longSelectContentClass}
>
<SelectGroup>
{incomingPathOptions.map((option) => (
<SelectItem
key={option.value}
value={option.value}
className={longSelectItemClass}
>
<div className='flex min-w-0 flex-col gap-1 whitespace-normal leading-snug'>
<span>{t(option.label)}</span>
<span className='text-muted-foreground break-all font-mono text-xs'>
{option.value}
</span>
</div>
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</FieldBlock>
<FieldBlock label={t('Upstream path')}>
<Input
value={route.upstream_path || ''}
onChange={(event) =>
onChange({
upstream_path: event.target.value,
})
}
placeholder={getAdvancedCustomUpstreamPathPlaceholder(converter)}
/>
<p className='text-muted-foreground text-xs leading-relaxed'>
{t(
'Use a path to append it to the channel Base URL, or enter a full URL to override the Base URL for this route.'
)}
</p>
</FieldBlock>
</div>
<div className='grid gap-4 md:grid-cols-2'>
<FieldBlock label={t('Converter')}>
<Select
value={converter}
onValueChange={(value) =>
setConverter(value as AdvancedCustomConverter)
}
>
<SelectTrigger className='w-full max-w-full'>
<SelectValue className='min-w-0 truncate'>
{t(converterLabel)}
</SelectValue>
</SelectTrigger>
<SelectContent
alignItemWithTrigger={false}
className={longSelectContentClass}
>
<SelectGroup>
{ADVANCED_CUSTOM_CONVERTER_OPTIONS.map((option) => (
<SelectItem
key={option.value}
value={option.value}
className={longSelectItemClass}
>
<span className='min-w-0 whitespace-normal break-words leading-snug'>
{t(option.label)}
</span>
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</FieldBlock>
<FieldBlock label={t('Auth')}>
<Select
value={authMode}
onValueChange={(value) =>
setAuthMode(value as AdvancedCustomAuthMode)
}
>
<SelectTrigger className='w-full max-w-full'>
<SelectValue className='min-w-0 truncate'>
{t(authLabel)}
</SelectValue>
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
{ADVANCED_CUSTOM_AUTH_MODE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{t(option.label)}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</FieldBlock>
</div>
{authMode === 'header' || authMode === 'query' ? (
<>
<Separator />
<div className='grid gap-4 md:grid-cols-2'>
<FieldBlock label={t('Auth name')}>
<Input
value={route.auth?.name || ''}
onChange={(event) => updateAuth('name', event.target.value)}
placeholder={
authMode === 'header' ? 'Authorization' : 'api_key'
}
/>
</FieldBlock>
<FieldBlock label={t('Auth value')}>
<Input
value={route.auth?.value || ''}
onChange={(event) => updateAuth('value', event.target.value)}
placeholder={
authMode === 'header' ? 'Bearer {api_key}' : '{api_key}'
}
/>
</FieldBlock>
</div>
</>
) : null}
</div>
)
}
function FieldBlock({
label,
children,
}: {
label: string
children: ReactNode
}) {
return (
<div className='flex min-w-0 flex-col gap-2'>
<span className='text-sm font-medium'>{label}</span>
{children}
</div>
)
}
......@@ -125,8 +125,10 @@ import {
import { useChannelMutateForm } from '../../hooks/use-channel-mutate-form'
import {
CHANNEL_FORM_DEFAULT_VALUES,
CHANNEL_TYPE_ADVANCED_CUSTOM,
channelFormSchema,
channelsQueryKeys,
getAdvancedCustomStats,
transformChannelToFormDefaults,
type ChannelFormValues,
deduplicateKeys,
......@@ -147,6 +149,7 @@ import {
} from '../../lib/status-code-risk-guard'
import type { Channel } from '../../types'
import { useChannels } from '../channels-provider'
import { AdvancedCustomEditorDialog } from '../dialogs/advanced-custom-editor-dialog'
import { FetchModelsDialog } from '../dialogs/fetch-models-dialog'
import {
MissingModelsConfirmationDialog,
......@@ -205,6 +208,7 @@ function hasAdvancedSettingsValues(values: ChannelFormValues): boolean {
return Boolean(
values.param_override?.trim() ||
values.header_override?.trim() ||
values.advanced_custom?.trim() ||
values.status_code_mapping?.trim() ||
values.tag?.trim() ||
values.remark?.trim() ||
......@@ -298,6 +302,8 @@ export function ChannelMutateDrawer({
>(null)
const [advancedSettingsOpen, setAdvancedSettingsOpen] = useState(false)
const [paramOverrideEditorOpen, setParamOverrideEditorOpen] = useState(false)
const [advancedCustomEditorOpen, setAdvancedCustomEditorOpen] =
useState(false)
const isEditing = Boolean(currentRow)
const channelId = currentRow?.id ?? null
......@@ -375,6 +381,7 @@ export function ChannelMutateDrawer({
'upstream_model_update_check_enabled'
)
const currentSettings = form.watch('settings')
const currentAdvancedCustom = form.watch('advanced_custom')
const {
unlocked: doubaoApiEditUnlocked,
handleClick: handleApiConfigSecretClick,
......@@ -407,6 +414,11 @@ export function ChannelMutateDrawer({
[supportsMultiKeyAddMode]
)
const advancedCustomStats = useMemo(
() => getAdvancedCustomStats(currentAdvancedCustom),
[currentAdvancedCustom]
)
// Get all models list
const allModelsList = useMemo(
() => allModelsData?.data?.map((model) => model.id).filter(Boolean) || [],
......@@ -1817,6 +1829,62 @@ export function ChannelMutateDrawer({
/>
)}
{currentType === CHANNEL_TYPE_ADVANCED_CUSTOM && (
<FormField
control={form.control}
name='advanced_custom'
render={({ field }) => (
<FormItem className='space-y-3 border-y py-4'>
<div className='flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between'>
<div className='space-y-2'>
<FormLabel>
{t('Advanced Custom Routes')}
</FormLabel>
<div className='flex flex-wrap gap-2'>
<Badge variant='secondary'>
{t('Routes')}:{' '}
{advancedCustomStats.routeCount}
</Badge>
<Badge
variant={
advancedCustomStats.fallbackEnabled
? 'default'
: 'outline'
}
>
{t('Fallback')}:{' '}
{advancedCustomStats.fallbackEnabled
? t('Enabled')
: t('Disabled')}
</Badge>
{!advancedCustomStats.valid && (
<Badge variant='destructive'>
{t('Incomplete')}
</Badge>
)}
</div>
</div>
<Button
type='button'
variant='outline'
size='sm'
onClick={() =>
setAdvancedCustomEditorOpen(true)
}
>
<Route className='mr-2 h-4 w-4' />
{t('Configure routes')}
</Button>
</div>
<FormControl>
<input type='hidden' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<ChannelAuthSection>
{!isEditing && (
<FormField
......@@ -3423,6 +3491,20 @@ export function ChannelMutateDrawer({
/>
)}
{advancedCustomEditorOpen && (
<AdvancedCustomEditorDialog
open={advancedCustomEditorOpen}
value={form.watch('advanced_custom') || ''}
onOpenChange={setAdvancedCustomEditorOpen}
onSave={(nextValue) => {
form.setValue('advanced_custom', nextValue, {
shouldDirty: true,
shouldValidate: true,
})
}}
/>
)}
{/* Fetch Models Dialog */}
<FetchModelsDialog
open={fetchModelsDialogOpen}
......
......@@ -76,12 +76,13 @@ export const CHANNEL_TYPES = {
55: 'Sora',
56: 'Replicate',
57: 'ChatGPT Subscription (Codex)',
58: 'Advanced Custom',
} as const
const CHANNEL_TYPE_DISPLAY_ORDER: number[] = [
1, 14, 33, 24, 43, 3, 41, 48, 42, 34, 20, 4, 40, 27, 25, 17, 26, 15, 46, 23,
18, 45, 31, 35, 49, 19, 47, 37, 38, 39, 11, 8, 57, 22, 21, 44, 2, 5, 36, 50,
51, 52, 53, 54, 55, 56,
18, 45, 31, 35, 49, 19, 47, 37, 38, 39, 11, 8, 58, 57, 22, 21, 44, 2, 5, 36,
50, 51, 52, 53, 54, 55, 56,
]
export const CHANNEL_TYPE_OPTIONS: { value: number; label: string }[] = (() => {
......
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import type {
AdvancedCustomAuthType,
AdvancedCustomConfig,
AdvancedCustomConverter,
AdvancedCustomRoute,
AdvancedCustomRouteAuth,
} from '../types'
export const CHANNEL_TYPE_ADVANCED_CUSTOM = 58
export const ADVANCED_CUSTOM_CONVERTER_OPTIONS: Array<{
value: AdvancedCustomConverter
label: string
}> = [
{ value: 'none', label: 'Native forwarding' },
{
value: 'anthropic_messages_to_openai_chat_completions',
label: 'Anthropic Messages to OpenAI Chat',
},
{
value: 'openai_chat_completions_to_anthropic_messages',
label: 'OpenAI Chat to Anthropic Messages',
},
{
value: 'openai_chat_completions_to_openai_responses',
label: 'OpenAI Chat to OpenAI Responses',
},
{
value: 'gemini_generate_content_to_openai_chat_completions',
label: 'Gemini Generate Content to OpenAI Chat',
},
{
value: 'openai_chat_completions_to_gemini_generate_content',
label: 'OpenAI Chat to Gemini Generate Content',
},
]
export type AdvancedCustomAuthMode = 'default' | AdvancedCustomAuthType
export const ADVANCED_CUSTOM_AUTH_MODE_OPTIONS: Array<{
value: AdvancedCustomAuthMode
label: string
}> = [
{ value: 'default', label: 'Default Bearer' },
{ value: 'none', label: 'No Auth' },
{ value: 'header', label: 'Header' },
{ value: 'query', label: 'Query' },
]
export type AdvancedCustomIncomingPathOption = {
value: string
label: string
}
export const ADVANCED_CUSTOM_INCOMING_PATH_OPTIONS: AdvancedCustomIncomingPathOption[] =
[
{
value: '/v1/chat/completions',
label: 'OpenAI Chat Completions',
},
{
value: '/v1/completions',
label: 'OpenAI Completions',
},
{
value: '/v1/responses',
label: 'OpenAI Responses',
},
{
value: '/v1/responses/compact',
label: 'OpenAI Responses Compact',
},
{
value: '/v1/embeddings',
label: 'OpenAI Embeddings',
},
{
value: '/v1/images/generations',
label: 'OpenAI Image Generations',
},
{
value: '/v1/images/edits',
label: 'OpenAI Image Edits',
},
{
value: '/v1/audio/speech',
label: 'OpenAI Audio Speech',
},
{
value: '/v1/audio/transcriptions',
label: 'OpenAI Audio Transcriptions',
},
{
value: '/v1/audio/translations',
label: 'OpenAI Audio Translations',
},
{
value: '/v1/rerank',
label: 'OpenAI Rerank',
},
{
value: '/v1/realtime',
label: 'OpenAI Realtime',
},
{
value: '/v1/messages',
label: 'Claude Messages',
},
{
value: '/v1beta/models/{model}:generateContent',
label: 'Gemini Generate Content',
},
{
value: '/v1beta/models/{model}:embedContent',
label: 'Gemini Embed Content',
},
{
value: '/v1beta/models/{model}:batchEmbedContents',
label: 'Gemini Batch Embed Contents',
},
]
export type AdvancedCustomValidationError = {
message: string
routeIndex?: number
}
export type AdvancedCustomTemplateOption = {
value: string
label: string
config: AdvancedCustomConfig
}
const bearerHeaderAuth = (): AdvancedCustomRouteAuth => ({
type: 'header',
name: 'Authorization',
value: 'Bearer {api_key}',
})
const apiKeyHeaderAuth = (): AdvancedCustomRouteAuth => ({
type: 'header',
name: 'x-api-key',
value: '{api_key}',
})
const geminiQueryAuth = (): AdvancedCustomRouteAuth => ({
type: 'query',
name: 'key',
value: '{api_key}',
})
export const ADVANCED_CUSTOM_TEMPLATE_OPTIONS: AdvancedCustomTemplateOption[] =
[
{
value: 'official_openai_chat',
label: 'Official OpenAI Chat',
config: {
advanced_routes: [
{
incoming_path: '/v1/chat/completions',
upstream_path: 'https://api.openai.com/v1/chat/completions',
converter: 'none',
auth: bearerHeaderAuth(),
},
],
advanced_fallback: { enabled: false },
},
},
{
value: 'official_openai_responses',
label: 'Official OpenAI Responses',
config: {
advanced_routes: [
{
incoming_path: '/v1/responses',
upstream_path: 'https://api.openai.com/v1/responses',
converter: 'none',
auth: bearerHeaderAuth(),
},
],
advanced_fallback: { enabled: false },
},
},
{
value: 'official_openai_embeddings',
label: 'Official OpenAI Embeddings',
config: {
advanced_routes: [
{
incoming_path: '/v1/embeddings',
upstream_path: 'https://api.openai.com/v1/embeddings',
converter: 'none',
auth: bearerHeaderAuth(),
},
],
advanced_fallback: { enabled: false },
},
},
{
value: 'official_openai_images',
label: 'Official OpenAI Images',
config: {
advanced_routes: [
{
incoming_path: '/v1/images/generations',
upstream_path: 'https://api.openai.com/v1/images/generations',
converter: 'none',
auth: bearerHeaderAuth(),
},
{
incoming_path: '/v1/images/edits',
upstream_path: 'https://api.openai.com/v1/images/edits',
converter: 'none',
auth: bearerHeaderAuth(),
},
],
advanced_fallback: { enabled: false },
},
},
{
value: 'official_claude_messages',
label: 'Official Claude Messages',
config: {
advanced_routes: [
{
incoming_path: '/v1/messages',
upstream_path: 'https://api.anthropic.com/v1/messages',
converter: 'none',
auth: apiKeyHeaderAuth(),
},
],
advanced_fallback: { enabled: false },
},
},
{
value: 'official_gemini_native',
label: 'Official Gemini Native',
config: {
advanced_routes: [
{
incoming_path: '/v1beta/models/{model}:generateContent',
upstream_path:
'https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent',
converter: 'none',
auth: geminiQueryAuth(),
},
{
incoming_path: '/v1beta/models/{model}:embedContent',
upstream_path:
'https://generativelanguage.googleapis.com/v1beta/models/{model}:embedContent',
converter: 'none',
auth: geminiQueryAuth(),
},
{
incoming_path: '/v1beta/models/{model}:batchEmbedContents',
upstream_path:
'https://generativelanguage.googleapis.com/v1beta/models/{model}:batchEmbedContents',
converter: 'none',
auth: geminiQueryAuth(),
},
],
advanced_fallback: { enabled: false },
},
},
{
value: 'official_gemini_from_openai_chat',
label: 'Official Gemini from OpenAI Chat',
config: {
advanced_routes: [
{
incoming_path: '/v1/chat/completions',
upstream_path:
'https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent',
converter: 'openai_chat_completions_to_gemini_generate_content',
auth: geminiQueryAuth(),
},
],
advanced_fallback: { enabled: false },
},
},
]
export function cloneAdvancedCustomConfig(
config: AdvancedCustomConfig
): AdvancedCustomConfig {
return JSON.parse(JSON.stringify(config)) as AdvancedCustomConfig
}
export function getAdvancedCustomTemplateConfig(
templateKey: string
): AdvancedCustomConfig {
const template =
ADVANCED_CUSTOM_TEMPLATE_OPTIONS.find(
(option) => option.value === templateKey
) || ADVANCED_CUSTOM_TEMPLATE_OPTIONS[0]
return cloneAdvancedCustomConfig(template.config)
}
export function createAdvancedCustomRoute(): AdvancedCustomRoute {
return {
incoming_path: '/v1/chat/completions',
upstream_path: '/v1/chat/completions',
converter: 'none',
}
}
export function createAdvancedCustomConfig(): AdvancedCustomConfig {
return {
advanced_routes: [createAdvancedCustomRoute()],
advanced_fallback: { enabled: false },
}
}
export function getAdvancedCustomUpstreamPathPlaceholder(
converter: AdvancedCustomConverter
): string {
if (converter === 'openai_chat_completions_to_gemini_generate_content') {
return '/v1beta/models/{model}:generateContent'
}
if (converter === 'openai_chat_completions_to_anthropic_messages') {
return '/v1/messages'
}
return '/v1/chat/completions'
}
export function getAdvancedCustomIncomingPathOptions(
converter: AdvancedCustomConverter
): AdvancedCustomIncomingPathOption[] {
return ADVANCED_CUSTOM_INCOMING_PATH_OPTIONS.filter((option) =>
isConverterPathAllowed(option.value, converter)
)
}
export function getDefaultAdvancedCustomIncomingPath(
converter: AdvancedCustomConverter
): string {
return (
getAdvancedCustomIncomingPathOptions(converter)[0]?.value ||
'/v1/chat/completions'
)
}
export function isAdvancedCustomIncomingPathAllowed(
incomingPath: string,
converter: AdvancedCustomConverter
): boolean {
return getAdvancedCustomIncomingPathOptions(converter).some(
(option) => option.value === incomingPath
)
}
export function getAdvancedCustomIncomingPathLabel(value: string): string {
return (
ADVANCED_CUSTOM_INCOMING_PATH_OPTIONS.find(
(option) => option.value === value
)?.label || value
)
}
export function parseAdvancedCustomConfig(
value: string | undefined
): AdvancedCustomConfig | null {
if (!value?.trim()) return null
try {
const parsed = JSON.parse(value)
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
return null
}
return normalizeAdvancedCustomConfig(parsed as AdvancedCustomConfig)
} catch {
return null
}
}
export function stringifyAdvancedCustomConfig(
config: AdvancedCustomConfig
): string {
return JSON.stringify(normalizeAdvancedCustomConfig(config), null, 2)
}
export function normalizeAdvancedCustomConfig(
config: AdvancedCustomConfig
): AdvancedCustomConfig {
const routes = Array.isArray(config.advanced_routes)
? config.advanced_routes.map(normalizeAdvancedCustomRoute)
: []
return {
advanced_routes: routes,
advanced_fallback: {
enabled: config.advanced_fallback?.enabled === true,
},
}
}
export function validateAdvancedCustomConfig(
config: AdvancedCustomConfig | null
): AdvancedCustomValidationError | null {
if (!config) {
return { message: 'Advanced custom configuration is required' }
}
const normalized = normalizeAdvancedCustomConfig(config)
const routes = normalized.advanced_routes || []
const fallbackEnabled = normalized.advanced_fallback?.enabled === true
if (routes.length === 0 && !fallbackEnabled) {
return {
message:
'Advanced custom configuration requires at least one route or fallback',
}
}
const seenPaths = new Set<string>()
for (let index = 0; index < routes.length; index += 1) {
const route = routes[index]
const incomingPath = route.incoming_path?.trim() || ''
const upstreamPath = getAdvancedCustomRouteUpstreamPath(route)
const converter = route.converter || 'none'
if (!incomingPath) {
return { routeIndex: index, message: 'Incoming path is required' }
}
if (!incomingPath.startsWith('/')) {
return { routeIndex: index, message: 'Incoming path must start with /' }
}
if (incomingPath.includes('?')) {
return {
routeIndex: index,
message: 'Incoming path must not include query',
}
}
if (seenPaths.has(incomingPath)) {
return { routeIndex: index, message: 'Incoming path must be unique' }
}
seenPaths.add(incomingPath)
if (!upstreamPath) {
return { routeIndex: index, message: 'Upstream path is required' }
}
if (!isFullHttpURLOrAbsolutePath(upstreamPath)) {
return {
routeIndex: index,
message: 'Upstream path must be a full URL or a path starting with /',
}
}
if (!isAdvancedCustomConverter(converter)) {
return { routeIndex: index, message: 'Converter is not registered' }
}
if (!isConverterPathAllowed(incomingPath, converter)) {
return {
routeIndex: index,
message: 'Converter does not match incoming path',
}
}
const authError = validateRouteAuth(route.auth)
if (authError) {
return { routeIndex: index, message: authError }
}
}
return null
}
export function advancedCustomConfigUsesRelativeUpstreamPath(
config: AdvancedCustomConfig | null
): boolean {
if (!config) return false
const normalized = normalizeAdvancedCustomConfig(config)
return (normalized.advanced_routes || []).some((route) =>
getAdvancedCustomRouteUpstreamPath(route).startsWith('/')
)
}
export function getAdvancedCustomStats(value: string | undefined): {
routeCount: number
fallbackEnabled: boolean
valid: boolean
} {
const config = parseAdvancedCustomConfig(value)
if (!config) {
return { routeCount: 0, fallbackEnabled: false, valid: false }
}
const normalized = normalizeAdvancedCustomConfig(config)
return {
routeCount: normalized.advanced_routes?.length || 0,
fallbackEnabled: normalized.advanced_fallback?.enabled === true,
valid: validateAdvancedCustomConfig(normalized) === null,
}
}
export function getAdvancedCustomAuthMode(
route: AdvancedCustomRoute
): AdvancedCustomAuthMode {
return route.auth?.type || 'default'
}
export function buildAdvancedCustomAuth(
mode: AdvancedCustomAuthMode,
previousAuth: AdvancedCustomRouteAuth | undefined
): AdvancedCustomRouteAuth | undefined {
if (mode === 'default') return undefined
if (mode === 'none') return { type: 'none' }
if (mode === 'header') {
return {
type: 'header',
name: previousAuth?.name || 'Authorization',
value: previousAuth?.value || 'Bearer {api_key}',
}
}
return {
type: 'query',
name: previousAuth?.name || 'api_key',
value: previousAuth?.value || '{api_key}',
}
}
function normalizeAdvancedCustomRoute(
route: AdvancedCustomRoute
): AdvancedCustomRoute {
const nextRoute: AdvancedCustomRoute = {
incoming_path: route.incoming_path || '',
upstream_path: getAdvancedCustomRouteUpstreamPath(route),
converter: route.converter || 'none',
}
if (route.auth) {
nextRoute.auth = {
type: route.auth.type,
name: route.auth.name || '',
value: route.auth.value || '',
}
}
return nextRoute
}
function getAdvancedCustomRouteUpstreamPath(route: AdvancedCustomRoute): string {
return (route.upstream_path || '').trim()
}
function isFullHttpURLOrAbsolutePath(value: string): boolean {
if (value.startsWith('/')) return !value.startsWith('//')
try {
const parsed = new URL(value)
return (
Boolean(parsed.host) &&
(parsed.protocol === 'http:' || parsed.protocol === 'https:')
)
} catch {
return false
}
}
function isAdvancedCustomConverter(
value: string
): value is AdvancedCustomConverter {
return ADVANCED_CUSTOM_CONVERTER_OPTIONS.some(
(option) => option.value === value
)
}
function isConverterPathAllowed(
incomingPath: string,
converter: AdvancedCustomConverter
): boolean {
if (converter === 'none') return true
if (converter === 'anthropic_messages_to_openai_chat_completions') {
return incomingPath === '/v1/messages'
}
if (
converter === 'openai_chat_completions_to_anthropic_messages' ||
converter === 'openai_chat_completions_to_openai_responses' ||
converter === 'openai_chat_completions_to_gemini_generate_content'
) {
return incomingPath === '/v1/chat/completions'
}
return (
incomingPath.includes(':generateContent') ||
incomingPath.includes(':streamGenerateContent')
)
}
function validateRouteAuth(
auth: AdvancedCustomRouteAuth | undefined
): string | null {
if (!auth) return null
if (auth.type === 'none') return null
if (auth.type !== 'header' && auth.type !== 'query') {
return 'Auth type is invalid'
}
if (!auth.name?.trim()) {
return 'Auth name is required'
}
if (!auth.value?.trim()) {
return 'Auth value is required'
}
return null
}
......@@ -33,6 +33,7 @@ const ADVANCED_SETTINGS_FIELDS = new Set<FieldPath<ChannelFormValues>>([
'param_override',
'header_override',
'status_code_mapping',
'advanced_custom',
'force_format',
'thinking_to_content',
'pass_through_body_enabled',
......
......@@ -23,6 +23,13 @@ import {
MODEL_FETCHABLE_TYPES,
} from '../constants'
import type { Channel } from '../types'
import {
CHANNEL_TYPE_ADVANCED_CUSTOM,
advancedCustomConfigUsesRelativeUpstreamPath,
parseAdvancedCustomConfig,
stringifyAdvancedCustomConfig,
validateAdvancedCustomConfig,
} from './advanced-custom'
// ============================================================================
// Form Validation Schema
......@@ -169,6 +176,7 @@ export const channelFormSchema = z
.string()
.optional()
.refine(isOptionalJsonObject, ERROR_MESSAGES.INVALID_JSON),
advanced_custom: z.string().optional(),
other: z.string().optional(),
// Multi-key options (not sent to backend directly)
multi_key_mode: z.enum(['single', 'batch', 'multi_to_single']).optional(),
......@@ -209,6 +217,37 @@ export const channelFormSchema = z
)
}
if (data.type === CHANNEL_TYPE_ADVANCED_CUSTOM) {
const advancedCustomConfig = parseAdvancedCustomConfig(
data.advanced_custom
)
const advancedCustomError =
validateAdvancedCustomConfig(advancedCustomConfig)
if (advancedCustomError) {
addRequiredIssue(ctx, 'advanced_custom', advancedCustomError.message)
}
if (
advancedCustomConfig?.advanced_fallback?.enabled === true &&
!data.base_url?.trim()
) {
addRequiredIssue(
ctx,
'base_url',
'Base URL is required when fallback is enabled'
)
}
if (
advancedCustomConfigUsesRelativeUpstreamPath(advancedCustomConfig) &&
!data.base_url?.trim()
) {
addRequiredIssue(
ctx,
'base_url',
'Base URL is required when an advanced route uses an upstream path'
)
}
}
if ([3, 18, 21, 39, 41, 49].includes(data.type) && !data.other?.trim()) {
addRequiredIssue(
ctx,
......@@ -316,6 +355,7 @@ export const CHANNEL_FORM_DEFAULT_VALUES: ChannelFormValues = {
upstream_model_update_check_enabled: false,
upstream_model_update_auto_sync_enabled: false,
upstream_model_update_ignored_models: '',
advanced_custom: '',
}
// ============================================================================
......@@ -370,6 +410,7 @@ export function transformChannelToFormDefaults(
let upstreamModelUpdateCheckEnabled = false
let upstreamModelUpdateAutoSyncEnabled = false
let upstreamModelUpdateIgnoredModels = ''
let advancedCustom = ''
if (channel.settings) {
try {
......@@ -394,6 +435,9 @@ export function transformChannelToFormDefaults(
)
? parsed.upstream_model_update_ignored_models.join(',')
: ''
if (parsed.advanced_custom) {
advancedCustom = stringifyAdvancedCustomConfig(parsed.advanced_custom)
}
} catch (error) {
// eslint-disable-next-line no-console
console.error('Failed to parse channel settings:', error)
......@@ -443,6 +487,7 @@ export function transformChannelToFormDefaults(
upstream_model_update_check_enabled: upstreamModelUpdateCheckEnabled,
upstream_model_update_auto_sync_enabled: upstreamModelUpdateAutoSyncEnabled,
upstream_model_update_ignored_models: upstreamModelUpdateIgnoredModels,
advanced_custom: advancedCustom,
}
}
......@@ -567,6 +612,17 @@ function buildSettingsJSON(formData: ChannelFormValues): string {
}
}
if (formData.type === CHANNEL_TYPE_ADVANCED_CUSTOM) {
const advancedCustomConfig = parseAdvancedCustomConfig(
formData.advanced_custom
)
if (advancedCustomConfig) {
settingsObj.advanced_custom = advancedCustomConfig
}
} else if ('advanced_custom' in settingsObj) {
delete settingsObj.advanced_custom
}
return JSON.stringify(settingsObj)
}
......
......@@ -134,6 +134,16 @@ export const CHANNEL_TYPE_CONFIGS: Record<number, ChannelTypeConfig> = {
baseUrl: 'Default: https://api.replicate.com',
},
},
58: {
id: 58,
name: CHANNEL_TYPES[58],
icon: 'openai',
hints: {
baseUrl: 'Fallback base URL',
key: 'Used by route auth templates',
models: 'Models exposed by this channel',
},
},
}
/**
......
......@@ -51,6 +51,7 @@ export function getChannelTypeIcon(type: number): string {
6: 'OpenAI', // OpenAIMax
7: 'OpenAI', // OhMyGPT
8: 'OpenAI', // Custom
58: 'OpenAI', // Advanced Custom
3: 'Azure', // Azure
// Anthropic
......
......@@ -18,6 +18,7 @@ For commercial licensing, please contact support@quantumnous.com
*/
// Re-export all library functions
export * from './channel-actions'
export * from './advanced-custom'
export * from './channel-form-errors'
export * from './channel-form'
export * from './channel-type-config'
......
......@@ -105,8 +105,41 @@ export interface ChannelOtherSettings {
upstream_model_update_ignored_models?: string[]
upstream_model_update_last_check_time?: number
upstream_model_update_last_detected_models?: string[]
advanced_custom?: AdvancedCustomConfig
}
export interface AdvancedCustomConfig {
advanced_routes?: AdvancedCustomRoute[]
advanced_fallback?: AdvancedCustomFallback
}
export interface AdvancedCustomRoute {
incoming_path?: string
upstream_path?: string
converter?: AdvancedCustomConverter
auth?: AdvancedCustomRouteAuth
}
export interface AdvancedCustomFallback {
enabled?: boolean
}
export interface AdvancedCustomRouteAuth {
type?: AdvancedCustomAuthType
name?: string
value?: string
}
export type AdvancedCustomConverter =
| 'none'
| 'anthropic_messages_to_openai_chat_completions'
| 'openai_chat_completions_to_anthropic_messages'
| 'openai_chat_completions_to_openai_responses'
| 'gemini_generate_content_to_openai_chat_completions'
| 'openai_chat_completions_to_gemini_generate_content'
export type AdvancedCustomAuthType = 'none' | 'header' | 'query'
// ============================================================================
// API Response Types
// ============================================================================
......
......@@ -193,6 +193,7 @@
"Add Provider": "Add Provider",
"Add Quota": "Add Quota",
"Add ratio override": "Add ratio override",
"Add route": "Add route",
"Add Row": "Add Row",
"Add Rule": "Add Rule",
"Add rule group": "Add rule group",
......@@ -228,6 +229,10 @@
"Administrator username": "Administrator username",
"Advanced": "Advanced",
"Advanced Configuration": "Advanced Configuration",
"Advanced Custom": "Advanced Custom",
"Advanced custom configuration is required": "Advanced custom configuration is required",
"Advanced custom configuration requires at least one route or fallback": "Advanced custom configuration requires at least one route or fallback",
"Advanced Custom Routes": "Advanced Custom Routes",
"Advanced options": "Advanced options",
"Advanced Options": "Advanced Options",
"Advanced platform configuration.": "Advanced platform configuration.",
......@@ -340,6 +345,7 @@
"Answer": "Answer",
"Answers for common access and billing questions": "Answers for common access and billing questions",
"Anthropic": "Anthropic",
"Anthropic Messages to OpenAI Chat": "Anthropic Messages to OpenAI Chat",
"Any Match (OR)": "Any Match (OR)",
"API": "API",
"API Access": "API Access",
......@@ -442,8 +448,14 @@
"Audio Preview": "Audio Preview",
"Audio ratio": "Audio ratio",
"Audio Tokens": "Audio Tokens",
"Auth": "Auth",
"Auth configured": "Auth configured",
"Auth name": "Auth name",
"Auth name is required": "Auth name is required",
"Auth Style": "Auth Style",
"Auth type is invalid": "Auth type is invalid",
"Auth value": "Auth value",
"Auth value is required": "Auth value is required",
"auth.resetPasswordConfirm.backToLogin": "Return to login",
"auth.resetPasswordConfirm.confirm": "Confirm reset password",
"auth.resetPasswordConfirm.description": "Confirm the reset request to generate a new password.",
......@@ -529,6 +541,8 @@
"Base rate limit windows for this account.": "Base rate limit windows for this account.",
"Base URL": "Base URL",
"Base URL is required for this channel type": "Base URL is required for this channel type",
"Base URL is required when an advanced route uses an upstream path": "Base URL is required when an advanced route uses an upstream path",
"Base URL is required when fallback is enabled": "Base URL is required when fallback is enabled",
"Base URL of your Uptime Kuma instance": "Base URL of your Uptime Kuma instance",
"Basic Authentication": "Basic Authentication",
"Basic Configuration": "Basic Configuration",
......@@ -667,6 +681,8 @@
"Changes are written to the settings draft on save.": "Changes are written to the settings draft on save.",
"Changing...": "Changing...",
"Channel": "Channel",
"Channel {{name}}": "Channel {{name}}",
"Channel {{name}} model {{model}}": "Channel {{name}} model {{model}}",
"Channel Affinity": "Channel Affinity",
"Channel affinity reuses the last successful channel based on keys extracted from the request context or JSON body.": "Channel affinity reuses the last successful channel based on keys extracted from the request context or JSON body.",
"Channel Affinity: Upstream Cache Hit": "Channel Affinity: Upstream Cache Hit",
......@@ -680,8 +696,6 @@
"Channel key": "Channel key",
"Channel key unlocked": "Channel key unlocked",
"Channel models": "Channel models",
"Channel {{name}}": "Channel {{name}}",
"Channel {{name}} model {{model}}": "Channel {{name}} model {{model}}",
"Channel name is required": "Channel name is required",
"Channel test completed": "Channel test completed",
"Channel type is required": "Channel type is required",
......@@ -741,6 +755,7 @@
"Classic (Legacy Frontend)": "Classic (Legacy Frontend)",
"Claude": "Claude",
"Claude CLI Header Passthrough": "Claude CLI Header Passthrough",
"Claude Messages": "Claude Messages",
"Clean": "Clean",
"Clean history logs": "Clean history logs",
"Clean logs": "Clean logs",
......@@ -884,6 +899,7 @@
"Configure per-tool unit prices ($/1K calls). Per-request models do not incur additional tool fees.": "Configure per-tool unit prices ($/1K calls). Per-request models do not incur additional tool fees.",
"Configure pricing ratios for a specific model.": "Configure pricing ratios for a specific model.",
"Configure rate limiting rules for a specific user group.": "Configure rate limiting rules for a specific user group.",
"Configure routes": "Configure routes",
"Configure the ratio for this group.": "Configure the ratio for this group.",
"Configure upstream providers and routing.": "Configure upstream providers and routing.",
"Configure Waffo Pancake hosted checkout integration for USD-priced top-ups": "Configure Waffo Pancake hosted checkout integration for USD-priced top-ups",
......@@ -962,6 +978,9 @@
"Convert reasoning_content to <think> tag in content": "Convert reasoning_content to <think> tag in content",
"Convert string to lowercase": "Convert string to lowercase",
"Convert string to uppercase": "Convert string to uppercase",
"Converter": "Converter",
"Converter does not match incoming path": "Converter does not match incoming path",
"Converter is not registered": "Converter is not registered",
"Copied": "Copied",
"Copied {{count}} key(s)": "Copied {{count}} key(s)",
"Copied channel (source ID: {{sourceId}}) to {{name}} (new ID: {{id}})": "Copied channel (source ID: {{sourceId}}) to {{name}} (new ID: {{id}})",
......@@ -1145,6 +1164,7 @@
"Default / range": "Default / range",
"Default API Version *": "Default API Version *",
"Default API version for this channel": "Default API version for this channel",
"Default Bearer": "Default Bearer",
"Default Collapse Sidebar": "Default Collapse Sidebar",
"Default consumption chart": "Default consumption chart",
"Default Max Tokens": "Default Max Tokens",
......@@ -1754,6 +1774,9 @@
"Failed to update user": "Failed to update user",
"Failure keywords": "Failure keywords",
"Fair": "Fair",
"Fallback": "Fallback",
"Fallback base URL": "Fallback base URL",
"Fallback routing": "Fallback routing",
"Fallback tier": "Fallback tier",
"FAQ": "FAQ",
"FAQ added. Click \"Save Settings\" to apply.": "FAQ added. Click \"Save Settings\" to apply.",
......@@ -1893,6 +1916,10 @@
"GC executed": "GC executed",
"GC execution failed": "GC execution failed",
"Gemini": "Gemini",
"Gemini Batch Embed Contents": "Gemini Batch Embed Contents",
"Gemini Embed Content": "Gemini Embed Content",
"Gemini Generate Content": "Gemini Generate Content",
"Gemini Generate Content to OpenAI Chat": "Gemini Generate Content to OpenAI Chat",
"Gemini Image 4K": "Gemini Image 4K",
"Gemini will continue to auto-detect thinking mode even with the adapter disabled. Enable this only when you need finer control over pricing and budgeting.": "Gemini will continue to auto-detect thinking mode even with the adapter disabled. Enable this only when you need finer control over pricing and budgeting.",
"General": "General",
......@@ -2095,6 +2122,12 @@
"Include Rule Name": "Include Rule Name",
"Includes request rules": "Includes request rules",
"Including failed requests, 0 = unlimited": "Including failed requests, 0 = unlimited",
"Incoming path": "Incoming path",
"Incoming path is required": "Incoming path is required",
"Incoming path must be unique": "Incoming path must be unique",
"Incoming path must not include query": "Incoming path must not include query",
"Incoming path must start with /": "Incoming path must start with /",
"Incomplete": "Incomplete",
"Increased user quota by {{quota}}": "Increased user quota by {{quota}}",
"Index": "Index",
"Initial quota given to new users": "Initial quota given to new users",
......@@ -2408,6 +2441,7 @@
"Mode": "Mode",
"model": "model",
"Model": "Model",
"Model {{model}}": "Model {{model}}",
"Model Access": "Model Access",
"Model Analytics": "Model Analytics",
"Model Analytics Defaults": "Model Analytics Defaults",
......@@ -2432,7 +2466,6 @@
"Model mapping must be a JSON object with string values": "Model mapping must be a JSON object with string values",
"Model mapping must be valid JSON": "Model mapping must be valid JSON",
"Model mapping values must be strings": "Model mapping values must be strings",
"Model {{model}}": "Model {{model}}",
"Model name": "Model name",
"Model Name": "Model Name",
"Model Name *": "Model Name *",
......@@ -2471,6 +2504,7 @@
"Models climbing the leaderboard fastest": "Models climbing the leaderboard fastest",
"Models directory": "Models directory",
"Models Directory": "Models Directory",
"Models exposed by this channel": "Models exposed by this channel",
"Models fetched successfully": "Models fetched successfully",
"Models filled to form": "Models filled to form",
"Models listed here will not automatically append or remove -thinking / -nothinking suffixes.": "Models listed here will not automatically append or remove -thinking / -nothinking suffixes.",
......@@ -2551,6 +2585,7 @@
"Name, provider type, and availability.": "Name, provider type, and availability.",
"name@example.com": "name@example.com",
"Native format": "Native format",
"Native forwarding": "Native forwarding",
"Need a redemption code?": "Need a redemption code?",
"Needs API key": "Needs API key",
"Nested JSON defining per-group rules for adding (+:), removing (-:), or appending usable groups.": "Nested JSON defining per-group rules for adding (+:), removing (-:), or appending usable groups.",
......@@ -2593,6 +2628,7 @@
"No API routes configured": "No API routes configured",
"No app usage data available for this model.": "No app usage data available for this model.",
"No apps match the selected filters": "No apps match the selected filters",
"No Auth": "No Auth",
"No available models": "No available models",
"No available Web chat links": "No available Web chat links",
"No backup": "No backup",
......@@ -2767,7 +2803,14 @@
"of 3:": "of 3:",
"off": "off",
"Official": "Official",
"Official Claude Messages": "Official Claude Messages",
"Official documentation": "Official documentation",
"Official Gemini from OpenAI Chat": "Official Gemini from OpenAI Chat",
"Official Gemini Native": "Official Gemini Native",
"Official OpenAI Chat": "Official OpenAI Chat",
"Official OpenAI Embeddings": "Official OpenAI Embeddings",
"Official OpenAI Images": "Official OpenAI Images",
"Official OpenAI Responses": "Official OpenAI Responses",
"Official Repository": "Official Repository",
"Official Sync": "Official Sync",
"OhMyGPT": "OhMyGPT",
......@@ -2814,9 +2857,24 @@
"Open theme settings": "Open theme settings",
"Open weights": "Open weights",
"OpenAI": "OpenAI",
"OpenAI Audio Speech": "OpenAI Audio Speech",
"OpenAI Audio Transcriptions": "OpenAI Audio Transcriptions",
"OpenAI Audio Translations": "OpenAI Audio Translations",
"OpenAI Chat Completions": "OpenAI Chat Completions",
"OpenAI Chat to Anthropic Messages": "OpenAI Chat to Anthropic Messages",
"OpenAI Chat to Gemini Generate Content": "OpenAI Chat to Gemini Generate Content",
"OpenAI Chat to OpenAI Responses": "OpenAI Chat to OpenAI Responses",
"OpenAI Completions": "OpenAI Completions",
"OpenAI Compatible": "OpenAI Compatible",
"OpenAI Embeddings": "OpenAI Embeddings",
"OpenAI Image Edits": "OpenAI Image Edits",
"OpenAI Image Generations": "OpenAI Image Generations",
"OpenAI Organization": "OpenAI Organization",
"OpenAI Organization ID (optional)": "OpenAI Organization ID (optional)",
"OpenAI Realtime": "OpenAI Realtime",
"OpenAI Rerank": "OpenAI Rerank",
"OpenAI Responses": "OpenAI Responses",
"OpenAI Responses Compact": "OpenAI Responses Compact",
"OpenAI, Anthropic, etc.": "OpenAI, Anthropic, etc.",
"OpenAI, Anthropic, Google, etc.": "OpenAI, Anthropic, Google, etc.",
"OpenAIMax": "OpenAIMax",
......@@ -3217,6 +3275,7 @@
"QR code is not configured. Please contact support.": "QR code is not configured. Please contact support.",
"Quantity": "Quantity",
"QuantumNous": "QuantumNous",
"Query": "Query",
"Query Balance": "Query Balance",
"Query Param": "Query Param",
"Querying...": "Querying...",
......@@ -3454,8 +3513,8 @@
"Resolve Conflicts": "Resolve Conflicts",
"Resource Configuration": "Resource Configuration",
"Response": "Response",
"Response time: {{duration}}": "Response time: {{duration}}",
"Response Time": "Response Time",
"Response time: {{duration}}": "Response time: {{duration}}",
"Responses API Version": "Responses API Version",
"Restore defaults": "Restore defaults",
"Restrict user model request frequency (may impact high concurrency performance)": "Restrict user model request frequency (may impact high concurrency performance)",
......@@ -3493,6 +3552,7 @@
"Route Description": "Route Description",
"Route is required": "Route is required",
"Route, auth, and balance check in one place": "Route, auth, and balance check in one place",
"Routes": "Routes",
"Routing & Overrides": "Routing & Overrides",
"Routing Strategy": "Routing Strategy",
"Rows per page": "Rows per page",
......@@ -4349,12 +4409,17 @@
"Upstream Model Updates": "Upstream Model Updates",
"Upstream model updates applied: {{added}} added, {{removed}} removed, {{ignored}} ignored this time, {{totalIgnored}} total ignored models": "Upstream model updates applied: {{added}} added, {{removed}} removed, {{ignored}} ignored this time, {{totalIgnored}} total ignored models",
"Upstream price sync": "Upstream price sync",
"Upstream path": "Upstream path",
"Upstream path is required": "Upstream path is required",
"Upstream path must be a full URL or a path starting with /": "Upstream path must be a full URL or a path starting with /",
"Upstream prices fetched successfully": "Upstream prices fetched successfully",
"Upstream ratios fetched successfully": "Upstream ratios fetched successfully",
"Upstream Request ID": "Upstream Request ID",
"Upstream Response": "Upstream Response",
"upstream services integrated": "upstream services integrated",
"Upstream Updates": "Upstream Updates",
"Upstream URL": "Upstream URL",
"Upstream URL must be a full URL": "Upstream URL must be a full URL",
"uptime": "uptime",
"Uptime": "Uptime",
"Uptime (30d)": "Uptime (30d)",
......@@ -4378,6 +4443,7 @@
"USD price per 1M input tokens.": "USD price per 1M input tokens.",
"USD price per 1M tokens.": "USD price per 1M tokens.",
"Use +: to add a group, -: to remove a default selectable group, or no prefix to append a group.": "Use +: to add a group, -: to remove a default selectable group, or no prefix to append a group.",
"Use a path to append it to the channel Base URL, or enter a full URL to override the Base URL for this route.": "Use a path to append it to the channel Base URL, or enter a full URL to override the Base URL for this route.",
"Use a compatible browser or device with biometric authentication or a security key to register a Passkey.": "Use a compatible browser or device with biometric authentication or a security key to register a Passkey.",
"Use authenticator code": "Use authenticator code",
"Use backup code": "Use backup code",
......@@ -4396,6 +4462,7 @@
"Used": "Used",
"Used / Remaining": "Used / Remaining",
"Used as SuccessURL on the new product. You'll be prompted to confirm if left blank.": "Used as SuccessURL on the new product. You'll be prompted to confirm if left blank.",
"Used by route auth templates": "Used by route auth templates",
"Used for load balancing. Higher weight = more requests": "Used for load balancing. Higher weight = more requests",
"Used in URLs and API routes": "Used in URLs and API routes",
"Used Quota": "Used Quota",
......@@ -4590,6 +4657,7 @@
"When enabled, Midjourney callbacks are accepted (reveals server IP).": "When enabled, Midjourney callbacks are accepted (reveals server IP).",
"When enabled, newly created tokens start in the first auto group.": "When enabled, newly created tokens start in the first auto group.",
"When enabled, prompts are scanned before reaching upstream models.": "When enabled, prompts are scanned before reaching upstream models.",
"When enabled, requests that do not match any advanced route are forwarded to the channel base URL. When disabled, unmatched requests return an error.": "When enabled, requests that do not match any advanced route are forwarded to the channel base URL. When disabled, unmatched requests return an error.",
"When enabled, the store field will be blocked": "When enabled, the store field will be blocked",
"When enabled, users can pick this group when creating tokens.": "When enabled, users can pick this group when creating tokens.",
"When enabled, violation requests will incur additional charges.": "When enabled, violation requests will incur additional charges.",
......
......@@ -193,6 +193,7 @@
"Add Provider": "Ajouter un fournisseur",
"Add Quota": "Ajouter un quota",
"Add ratio override": "Ajouter un remplacement de ratio",
"Add route": "Ajouter une route",
"Add Row": "Ajouter une ligne",
"Add Rule": "Ajouter une règle",
"Add rule group": "Ajouter un groupe de règles",
......@@ -228,6 +229,10 @@
"Administrator username": "Nom d'utilisateur administrateur",
"Advanced": "Avancé",
"Advanced Configuration": "Configuration avancée",
"Advanced Custom": "Personnalisé avancé",
"Advanced custom configuration is required": "La configuration personnalisée avancée est requise",
"Advanced custom configuration requires at least one route or fallback": "La configuration personnalisée avancée nécessite au moins une route ou un fallback",
"Advanced Custom Routes": "Routes personnalisées avancées",
"Advanced options": "Options avancées",
"Advanced Options": "Options avancées",
"Advanced platform configuration.": "Configuration avancée de la plateforme.",
......@@ -340,6 +345,7 @@
"Answer": "Réponse",
"Answers for common access and billing questions": "Réponses aux questions courantes sur l'accès et la facturation",
"Anthropic": "Anthropic",
"Anthropic Messages to OpenAI Chat": "Anthropic Messages vers OpenAI Chat",
"Any Match (OR)": "N'importe laquelle (OR)",
"API": "API",
"API Access": "Accès API",
......@@ -442,8 +448,14 @@
"Audio Preview": "Aperçu audio",
"Audio ratio": "Ratio audio",
"Audio Tokens": "Jetons audio",
"Auth": "Auth",
"Auth configured": "Authentification configurée",
"Auth name": "Nom auth",
"Auth name is required": "Le nom auth est requis",
"Auth Style": "Style d'authentification",
"Auth type is invalid": "Le type auth est invalide",
"Auth value": "Valeur auth",
"Auth value is required": "La valeur auth est requise",
"auth.resetPasswordConfirm.backToLogin": "Retour à la connexion",
"auth.resetPasswordConfirm.confirm": "Confirmer la réinitialisation du mot de passe",
"auth.resetPasswordConfirm.description": "Confirmez la demande de réinitialisation pour générer un nouveau mot de passe.",
......@@ -529,6 +541,8 @@
"Base rate limit windows for this account.": "Fenêtres de limitation de débit de base pour ce compte.",
"Base URL": "URL de base",
"Base URL is required for this channel type": "L'URL de base est requise pour ce type de canal",
"Base URL is required when an advanced route uses an upstream path": "La Base URL est requise lorsqu’une route avancée utilise un chemin amont",
"Base URL is required when fallback is enabled": "La Base URL est requise lorsque le fallback est active",
"Base URL of your Uptime Kuma instance": "URL de base de votre instance Uptime Kuma",
"Basic Authentication": "Authentification de base",
"Basic Configuration": "Configuration de base",
......@@ -667,6 +681,8 @@
"Changes are written to the settings draft on save.": "Les modifications sont écrites dans le brouillon des paramètres lors de l’enregistrement.",
"Changing...": "Modification en cours...",
"Channel": "Canal",
"Channel {{name}}": "Canal {{name}}",
"Channel {{name}} model {{model}}": "Canal {{name}} modèle {{model}}",
"Channel Affinity": "Affinité de canal",
"Channel affinity reuses the last successful channel based on keys extracted from the request context or JSON body.": "L'affinité de canal réutilise le dernier canal ayant réussi, en se basant sur les clés extraites du contexte de la requête ou du corps JSON.",
"Channel Affinity: Upstream Cache Hit": "Affinité de canal : hit de cache en amont",
......@@ -680,8 +696,6 @@
"Channel key": "Clé du canal",
"Channel key unlocked": "Clé de canal déverrouillée",
"Channel models": "Modèles de canaux",
"Channel {{name}}": "Canal {{name}}",
"Channel {{name}} model {{model}}": "Canal {{name}} modèle {{model}}",
"Channel name is required": "Le nom du canal est requis",
"Channel test completed": "Test du canal terminé",
"Channel type is required": "Le type de canal est requis",
......@@ -741,6 +755,7 @@
"Classic (Legacy Frontend)": "Classique (Ancien frontend)",
"Claude": "Claude",
"Claude CLI Header Passthrough": "Passthrough en-tête Claude CLI",
"Claude Messages": "Claude Messages",
"Clean": "Sans conflit",
"Clean history logs": "Nettoyer les journaux d'historique",
"Clean logs": "Nettoyer les logs",
......@@ -884,6 +899,7 @@
"Configure per-tool unit prices ($/1K calls). Per-request models do not incur additional tool fees.": "Définissez le prix unitaire de chaque outil ($/1K appels). Les modèles facturés à la requête n'entraînent pas de frais d'outils supplémentaires.",
"Configure pricing ratios for a specific model.": "Configurer les ratios de tarification pour un modèle spécifique.",
"Configure rate limiting rules for a specific user group.": "Configurer les règles de limitation de débit pour un groupe d'utilisateurs spécifique.",
"Configure routes": "Configurer les routes",
"Configure the ratio for this group.": "Configurer le ratio pour ce groupe.",
"Configure upstream providers and routing.": "Configurer les fournisseurs en amont et le routage.",
"Configure Waffo Pancake hosted checkout integration for USD-priced top-ups": "Configurer l'intégration du parcours de paiement hébergé Waffo Pancake pour les rechargements en USD",
......@@ -962,6 +978,9 @@
"Convert reasoning_content to <think> tag in content": "Convertir reasoning_content en balise <think> dans content",
"Convert string to lowercase": "Convertir la chaîne en minuscules",
"Convert string to uppercase": "Convertir la chaîne en majuscules",
"Converter": "Convertisseur",
"Converter does not match incoming path": "Le convertisseur ne correspond pas au chemin entrant",
"Converter is not registered": "Le convertisseur n’est pas enregistre",
"Copied": "Copié",
"Copied {{count}} key(s)": "{{count}} clé(s) copiée(s)",
"Copied channel (source ID: {{sourceId}}) to {{name}} (new ID: {{id}})": "Canal copié (ID source : {{sourceId}}) vers {{name}} (nouvel ID : {{id}})",
......@@ -1145,6 +1164,7 @@
"Default / range": "Défaut / plage",
"Default API Version *": "Version API par défaut *",
"Default API version for this channel": "Version API par défaut pour ce canal",
"Default Bearer": "Bearer par defaut",
"Default Collapse Sidebar": "Réduire la barre latérale par défaut",
"Default consumption chart": "Graphique de consommation par défaut",
"Default Max Tokens": "Jetons max par défaut",
......@@ -1754,6 +1774,9 @@
"Failed to update user": "Échec de la mise à jour de l'utilisateur",
"Failure keywords": "Mots-clés d'échec",
"Fair": "Correct",
"Fallback": "Fallback",
"Fallback base URL": "Base URL de fallback",
"Fallback routing": "Routage fallback",
"Fallback tier": "Palier de repli",
"FAQ": "FAQ",
"FAQ added. Click \"Save Settings\" to apply.": "FAQ ajouté. Cliquez sur \"Enregistrer les paramètres\" pour appliquer.",
......@@ -1893,6 +1916,10 @@
"GC executed": "GC exécuté",
"GC execution failed": "Échec de l'exécution du GC",
"Gemini": "Gemini",
"Gemini Batch Embed Contents": "Gemini Batch Embed Contents",
"Gemini Embed Content": "Gemini Embed Content",
"Gemini Generate Content": "Gemini Generate Content",
"Gemini Generate Content to OpenAI Chat": "Gemini Generate Content vers OpenAI Chat",
"Gemini Image 4K": "Gemini Image 4K",
"Gemini will continue to auto-detect thinking mode even with the adapter disabled. Enable this only when you need finer control over pricing and budgeting.": "Gemini continuera à détecter automatiquement le mode de pensée même avec l'adaptateur désactivé. Activez ceci uniquement lorsque vous avez besoin d'un contrôle plus fin sur la tarification et le budget.",
"General": "Général",
......@@ -2095,6 +2122,12 @@
"Include Rule Name": "Inclure le nom de la règle",
"Includes request rules": "Inclut des règles de requête",
"Including failed requests, 0 = unlimited": "Y compris les requêtes échouées, 0 = illimité",
"Incoming path": "Chemin entrant",
"Incoming path is required": "Le chemin entrant est requis",
"Incoming path must be unique": "Le chemin entrant doit etre unique",
"Incoming path must not include query": "Le chemin entrant ne doit pas inclure de query",
"Incoming path must start with /": "Le chemin entrant doit commencer par /",
"Incomplete": "Incomplet",
"Increased user quota by {{quota}}": "Quota de l'utilisateur augmenté de {{quota}}",
"Index": "Index",
"Initial quota given to new users": "Quota initial donné aux nouveaux utilisateurs",
......@@ -2408,6 +2441,7 @@
"Mode": "Mode",
"model": "modèle",
"Model": "Modèle",
"Model {{model}}": "Modèle {{model}}",
"Model Access": "Accès au modèle",
"Model Analytics": "Analyse des modèles",
"Model Analytics Defaults": "Paramètres par défaut de l’analyse des modèles",
......@@ -2432,7 +2466,6 @@
"Model mapping must be a JSON object with string values": "Le mappage de modèles doit être un objet JSON avec des valeurs de chaîne",
"Model mapping must be valid JSON": "La cartographie des modèles doit être un JSON valide",
"Model mapping values must be strings": "Les valeurs du mappage de modèles doivent être des chaînes",
"Model {{model}}": "Modèle {{model}}",
"Model name": "Nom du modèle",
"Model Name": "Nom du modèle",
"Model Name *": "Nom du modèle *",
......@@ -2471,6 +2504,7 @@
"Models climbing the leaderboard fastest": "Modèles qui grimpent le plus vite au classement",
"Models directory": "Répertoire des modèles",
"Models Directory": "Répertoire des modèles",
"Models exposed by this channel": "Modeles exposes par ce canal",
"Models fetched successfully": "Modèles récupérés avec succès",
"Models filled to form": "Modèles remplis pour le formulaire",
"Models listed here will not automatically append or remove -thinking / -nothinking suffixes.": "Les modèles listés ici n'ajouteront ni ne supprimeront automatiquement les suffixes -thinking / -nothinking.",
......@@ -2551,6 +2585,7 @@
"Name, provider type, and availability.": "Nom, type de fournisseur et disponibilité.",
"name@example.com": "name@example.com",
"Native format": "Format natif",
"Native forwarding": "Transfert natif",
"Need a redemption code?": "Besoin d'un code d'échange ?",
"Needs API key": "Clé API requise",
"Nested JSON defining per-group rules for adding (+:), removing (-:), or appending usable groups.": "JSON imbriqué définissant des règles par groupe pour ajouter (+:), supprimer (-:), ou ajouter des groupes utilisables.",
......@@ -2593,6 +2628,7 @@
"No API routes configured": "Aucune route API configurée",
"No app usage data available for this model.": "Aucune donnée d'utilisation d'application n'est disponible pour ce modèle.",
"No apps match the selected filters": "Aucune application ne correspond aux filtres",
"No Auth": "Sans auth",
"No available models": "Aucun modèle disponible",
"No available Web chat links": "Aucun lien de chat Web disponible",
"No backup": "Pas de sauvegarde",
......@@ -2767,7 +2803,14 @@
"of 3:": "sur 3 :",
"off": "désactivé",
"Official": "Officiel",
"Official Claude Messages": "Messages Claude officiels",
"Official documentation": "Documentation officielle",
"Official Gemini from OpenAI Chat": "Gemini officiel depuis OpenAI Chat",
"Official Gemini Native": "Gemini natif officiel",
"Official OpenAI Chat": "OpenAI Chat officiel",
"Official OpenAI Embeddings": "Embeddings OpenAI officiels",
"Official OpenAI Images": "Images OpenAI officielles",
"Official OpenAI Responses": "Responses OpenAI officiel",
"Official Repository": "Dépôt officiel",
"Official Sync": "Synchronisation Officielle",
"OhMyGPT": "OhMyGPT",
......@@ -2814,9 +2857,24 @@
"Open theme settings": "Ouvrir les paramètres du thème",
"Open weights": "Poids ouverts",
"OpenAI": "OpenAI",
"OpenAI Audio Speech": "OpenAI Audio Speech",
"OpenAI Audio Transcriptions": "OpenAI Audio Transcriptions",
"OpenAI Audio Translations": "OpenAI Audio Translations",
"OpenAI Chat Completions": "OpenAI Chat Completions",
"OpenAI Chat to Anthropic Messages": "OpenAI Chat vers Anthropic Messages",
"OpenAI Chat to Gemini Generate Content": "OpenAI Chat vers Gemini Generate Content",
"OpenAI Chat to OpenAI Responses": "OpenAI Chat vers OpenAI Responses",
"OpenAI Completions": "OpenAI Completions",
"OpenAI Compatible": "Compatible OpenAI",
"OpenAI Embeddings": "OpenAI Embeddings",
"OpenAI Image Edits": "OpenAI Image Edits",
"OpenAI Image Generations": "OpenAI Image Generations",
"OpenAI Organization": "Organisation OpenAI",
"OpenAI Organization ID (optional)": "Identifiant d'organisation OpenAI (optionnel)",
"OpenAI Realtime": "OpenAI Realtime",
"OpenAI Rerank": "OpenAI Rerank",
"OpenAI Responses": "OpenAI Responses",
"OpenAI Responses Compact": "OpenAI Responses Compact",
"OpenAI, Anthropic, etc.": "OpenAI, Anthropic, etc.",
"OpenAI, Anthropic, Google, etc.": "OpenAI, Anthropic, Google, etc.",
"OpenAIMax": "OpenAIMax",
......@@ -3217,6 +3275,7 @@
"QR code is not configured. Please contact support.": "Le code QR n'est pas configuré. Veuillez contacter le support.",
"Quantity": "Quantité",
"QuantumNous": "QuantumNous",
"Query": "Query",
"Query Balance": "Solde des requêtes",
"Query Param": "Paramètre de requête",
"Querying...": "Recherche en cours...",
......@@ -3454,8 +3513,8 @@
"Resolve Conflicts": "Résoudre les conflits",
"Resource Configuration": "Configuration des ressources",
"Response": "Réponse",
"Response time: {{duration}}": "Temps de réponse : {{duration}}",
"Response Time": "Temps de réponse",
"Response time: {{duration}}": "Temps de réponse : {{duration}}",
"Responses API Version": "Version de l'API des réponses",
"Restore defaults": "Restaurer les paramètres par défaut",
"Restrict user model request frequency (may impact high concurrency performance)": "Restreindre la fréquence des requêtes du modèle utilisateur (peut impacter les performances en cas de forte concurrence)",
......@@ -3493,6 +3552,7 @@
"Route Description": "Description de la route",
"Route is required": "La route est requise",
"Route, auth, and balance check in one place": "Routage, authentification et solde au même endroit",
"Routes": "Routes",
"Routing & Overrides": "Routage et surcharges",
"Routing Strategy": "Stratégie de routage",
"Rows per page": "Lignes par page",
......@@ -4349,12 +4409,17 @@
"Upstream Model Updates": "Mises à jour des modèles en amont",
"Upstream model updates applied: {{added}} added, {{removed}} removed, {{ignored}} ignored this time, {{totalIgnored}} total ignored models": "Mises à jour des modèles en amont appliquées : {{added}} ajoutés, {{removed}} supprimés, {{ignored}} ignorés cette fois, {{totalIgnored}} modèles ignorés au total",
"Upstream price sync": "Synchronisation amont des prix",
"Upstream path": "Chemin amont",
"Upstream path is required": "Le chemin amont est requis",
"Upstream path must be a full URL or a path starting with /": "Le chemin amont doit être une URL complète ou un chemin commençant par /",
"Upstream prices fetched successfully": "Prix amont récupérés avec succès",
"Upstream ratios fetched successfully": "Ratios en amont récupérés avec succès",
"Upstream Request ID": "ID de requête en amont",
"Upstream Response": "Réponse amont",
"upstream services integrated": "services en amont intégrés",
"Upstream Updates": "Mises à jour en amont",
"Upstream URL": "URL amont",
"Upstream URL must be a full URL": "L’URL amont doit etre une URL complete",
"uptime": "disponibilité",
"Uptime": "Temps de fonctionnement",
"Uptime (30d)": "Disponibilité (30 j)",
......@@ -4378,6 +4443,7 @@
"USD price per 1M input tokens.": "Prix en USD par million de tokens d’entrée.",
"USD price per 1M tokens.": "Prix en USD par million de tokens.",
"Use +: to add a group, -: to remove a default selectable group, or no prefix to append a group.": "Utilisez +: pour ajouter un groupe, -: pour supprimer un groupe sélectionnable par défaut, ou aucun préfixe pour annexer un groupe.",
"Use a path to append it to the channel Base URL, or enter a full URL to override the Base URL for this route.": "Utilisez un chemin pour l’ajouter à la Base URL du canal, ou saisissez une URL complète pour remplacer la Base URL pour cette route.",
"Use a compatible browser or device with biometric authentication or a security key to register a Passkey.": "Utilisez un navigateur ou un appareil compatible avec l'authentification biométrique ou une clé de sécurité pour enregistrer une clé d'accès (Passkey).",
"Use authenticator code": "Utiliser le code de l'authentificateur",
"Use backup code": "Utiliser un code de secours",
......@@ -4396,6 +4462,7 @@
"Used": "Utilisé",
"Used / Remaining": "Utilisé / Restant",
"Used as SuccessURL on the new product. You'll be prompted to confirm if left blank.": "Utilisé comme SuccessURL sur le nouveau produit. Une confirmation vous sera demandée si ce champ est laissé vide.",
"Used by route auth templates": "Utilise par les modeles auth de route",
"Used for load balancing. Higher weight = more requests": "Utilisé pour l'équilibrage de charge. Poids plus élevé = plus de requêtes",
"Used in URLs and API routes": "Utilisé dans les URLs et les routes API",
"Used Quota": "Quota utilisé",
......@@ -4590,6 +4657,7 @@
"When enabled, Midjourney callbacks are accepted (reveals server IP).": "Lorsque activé, les callbacks Midjourney sont acceptés (révèle l'IP du serveur).",
"When enabled, newly created tokens start in the first auto group.": "Lorsqu'elle est activée, les jetons nouvellement créés commencent dans le premier groupe automatique.",
"When enabled, prompts are scanned before reaching upstream models.": "Lorsqu'elle est activée, les invites sont scannées avant d'atteindre les modèles en amont.",
"When enabled, requests that do not match any advanced route are forwarded to the channel base URL. When disabled, unmatched requests return an error.": "Lorsque cette option est activée, les requêtes qui ne correspondent à aucune route avancée sont transférées vers l'URL de base du canal. Lorsqu'elle est désactivée, les requêtes sans correspondance renvoient une erreur.",
"When enabled, the store field will be blocked": "Lorsqu'il est activé, le champ de la boutique sera bloqué",
"When enabled, users can pick this group when creating tokens.": "Une fois activé, les utilisateurs peuvent choisir ce groupe lors de la création de jetons.",
"When enabled, violation requests will incur additional charges.": "Lorsqu'activé, les requêtes en violation entraîneront des frais supplémentaires.",
......
......@@ -193,6 +193,7 @@
"Add Provider": "プロバイダーを追加",
"Add Quota": "クォータを追加",
"Add ratio override": "倍率オーバーライドを追加",
"Add route": "ルートを追加",
"Add Row": "行を追加",
"Add Rule": "ルールを追加",
"Add rule group": "ルールグループを追加",
......@@ -228,6 +229,10 @@
"Administrator username": "管理者ユーザー名",
"Advanced": "高度な設定",
"Advanced Configuration": "詳細設定",
"Advanced Custom": "高度なカスタム",
"Advanced custom configuration is required": "高度なカスタム設定が必要です",
"Advanced custom configuration requires at least one route or fallback": "高度なカスタム設定には少なくとも1つのルートまたはフォールバックが必要です",
"Advanced Custom Routes": "高度なカスタムルート",
"Advanced options": "詳細オプション",
"Advanced Options": "詳細オプション",
"Advanced platform configuration.": "高度なプラットフォーム設定。",
......@@ -340,6 +345,7 @@
"Answer": "回答",
"Answers for common access and billing questions": "アクセスと請求に関するよくある質問への回答",
"Anthropic": "Anthropic",
"Anthropic Messages to OpenAI Chat": "Anthropic Messages から OpenAI Chat",
"Any Match (OR)": "いずれか一致(OR)",
"API": "API",
"API Access": "API アクセス",
......@@ -442,8 +448,14 @@
"Audio Preview": "音声プレビュー",
"Audio ratio": "音声倍率",
"Audio Tokens": "音声トークン",
"Auth": "認証",
"Auth configured": "認証設定済み",
"Auth name": "認証名",
"Auth name is required": "認証名は必須です",
"Auth Style": "認証スタイル",
"Auth type is invalid": "認証タイプが無効です",
"Auth value": "認証値",
"Auth value is required": "認証値は必須です",
"auth.resetPasswordConfirm.backToLogin": "ログインに戻る",
"auth.resetPasswordConfirm.confirm": "パスワードリセットを確認",
"auth.resetPasswordConfirm.description": "新しいパスワードを生成するには、リセット要求を確認してください。",
......@@ -529,6 +541,8 @@
"Base rate limit windows for this account.": "このアカウント向けの基本レート制限ウィンドウ。",
"Base URL": "ベースURL",
"Base URL is required for this channel type": "このチャネルタイプには Base URL が必要です",
"Base URL is required when an advanced route uses an upstream path": "高度なルートで上流パスを使う場合は Base URL が必要です",
"Base URL is required when fallback is enabled": "フォールバックを有効にする場合は Base URL が必要です",
"Base URL of your Uptime Kuma instance": "Uptime KumaインスタンスのベースURL",
"Basic Authentication": "基本認証",
"Basic Configuration": "基本設定",
......@@ -667,6 +681,8 @@
"Changes are written to the settings draft on save.": "保存すると変更は設定ドラフトに書き込まれます。",
"Changing...": "変更中...",
"Channel": "チャネル",
"Channel {{name}}": "チャネル {{name}}",
"Channel {{name}} model {{model}}": "チャネル {{name}} モデル {{model}}",
"Channel Affinity": "チャネルアフィニティ",
"Channel affinity reuses the last successful channel based on keys extracted from the request context or JSON body.": "チャネルアフィニティは、リクエストコンテキストまたは JSON Body から抽出したキーに基づいて、前回成功したチャネルを優先的に再利用します。",
"Channel Affinity: Upstream Cache Hit": "チャネルアフィニティ:上流キャッシュヒット",
......@@ -680,8 +696,6 @@
"Channel key": "チャネルキー",
"Channel key unlocked": "チャネルキーが解除されました",
"Channel models": "チャネルモデル",
"Channel {{name}}": "チャネル {{name}}",
"Channel {{name}} model {{model}}": "チャネル {{name}} モデル {{model}}",
"Channel name is required": "チャネル名が必要です",
"Channel test completed": "チャネルテストが完了しました",
"Channel type is required": "チャネルタイプが必要です",
......@@ -741,6 +755,7 @@
"Classic (Legacy Frontend)": "クラシック(旧フロントエンド)",
"Claude": "Claude",
"Claude CLI Header Passthrough": "Claude CLI ヘッダーパススルー",
"Claude Messages": "Claude メッセージ",
"Clean": "問題なし",
"Clean history logs": "履歴ログをクリーンアップ",
"Clean logs": "ログをクリア",
......@@ -884,6 +899,7 @@
"Configure per-tool unit prices ($/1K calls). Per-request models do not incur additional tool fees.": "ツールごとの単価($/1K 回)を設定します。リクエスト課金モデルでは追加工具料金はかかりません。",
"Configure pricing ratios for a specific model.": "特定のモデルの料金比率を設定します。",
"Configure rate limiting rules for a specific user group.": "特定のユーザーグループのレート制限ルールを設定します。",
"Configure routes": "ルートを設定",
"Configure the ratio for this group.": "このグループの比率を設定します。",
"Configure upstream providers and routing.": "アップストリームプロバイダーとルーティングを設定。",
"Configure Waffo Pancake hosted checkout integration for USD-priced top-ups": "USD 建てのチャージ用に Waffo Pancake のホスト型チェックアウト連携を設定",
......@@ -962,6 +978,9 @@
"Convert reasoning_content to <think> tag in content": "content内のreasoning_contentを<think>タグに変換",
"Convert string to lowercase": "文字列を小文字に変換",
"Convert string to uppercase": "文字列を大文字に変換",
"Converter": "コンバーター",
"Converter does not match incoming path": "コンバーターが受信パスと一致しません",
"Converter is not registered": "コンバーターが登録されていません",
"Copied": "コピーしました",
"Copied {{count}} key(s)": "{{count}} 件のキーをコピーしました",
"Copied channel (source ID: {{sourceId}}) to {{name}} (new ID: {{id}})": "チャネルを複製しました(元 ID: {{sourceId}})→ {{name}}(新 ID: {{id}})",
......@@ -1145,6 +1164,7 @@
"Default / range": "デフォルト / 範囲",
"Default API Version *": "デフォルトのAPIバージョン *",
"Default API version for this channel": "このチャネルのデフォルトのAPIバージョン",
"Default Bearer": "既定の Bearer",
"Default Collapse Sidebar": "デフォルトのサイドバー折りたたみ",
"Default consumption chart": "デフォルトの消費チャート",
"Default Max Tokens": "デフォルトの最大トークン",
......@@ -1754,6 +1774,9 @@
"Failed to update user": "ユーザーの更新に失敗しました",
"Failure keywords": "失敗キーワード",
"Fair": "公平",
"Fallback": "フォールバック",
"Fallback base URL": "フォールバック Base URL",
"Fallback routing": "フォールバックルーティング",
"Fallback tier": "フォールバック段階",
"FAQ": "FAQ",
"FAQ added. Click \"Save Settings\" to apply.": "FAQ が追加されました。「設定を保存」をクリックして適用してください。",
......@@ -1893,6 +1916,10 @@
"GC executed": "GC 実行完了",
"GC execution failed": "GC 実行失敗",
"Gemini": "Gemini",
"Gemini Batch Embed Contents": "Gemini 一括埋め込みコンテンツ",
"Gemini Embed Content": "Gemini 埋め込みコンテンツ",
"Gemini Generate Content": "Gemini コンテンツ生成",
"Gemini Generate Content to OpenAI Chat": "Gemini Generate Content から OpenAI Chat",
"Gemini Image 4K": "Gemini Image 4K",
"Gemini will continue to auto-detect thinking mode even with the adapter disabled. Enable this only when you need finer control over pricing and budgeting.": "アダプターが無効になっていても、Geminiは思考モードを自動検出します。価格設定と予算編成をより細かく制御する必要がある場合にのみ、これを有効にしてください。",
"General": "一般",
......@@ -2095,6 +2122,12 @@
"Include Rule Name": "ルール名を含む",
"Includes request rules": "リクエストルールを含む",
"Including failed requests, 0 = unlimited": "失敗したリクエストを含む、0 = 無制限",
"Incoming path": "受信パス",
"Incoming path is required": "受信パスは必須です",
"Incoming path must be unique": "受信パスは一意である必要があります",
"Incoming path must not include query": "受信パスに query を含めることはできません",
"Incoming path must start with /": "受信パスは / で始める必要があります",
"Incomplete": "未完了",
"Increased user quota by {{quota}}": "ユーザーのクォータを {{quota}} 増やしました",
"Index": "インデックス",
"Initial quota given to new users": "新規ユーザーに付与される初期クォータ",
......@@ -2408,6 +2441,7 @@
"Mode": "モード",
"model": "モデル",
"Model": "モデル",
"Model {{model}}": "モデル {{model}}",
"Model Access": "モデルアクセス",
"Model Analytics": "モデル分析",
"Model Analytics Defaults": "モデル分析のデフォルト設定",
......@@ -2432,7 +2466,6 @@
"Model mapping must be a JSON object with string values": "モデルマッピングは文字列値を持つ JSON オブジェクトである必要があります",
"Model mapping must be valid JSON": "モデルマッピングは有効な JSON である必要があります",
"Model mapping values must be strings": "モデルマッピングの値は文字列である必要があります",
"Model {{model}}": "モデル {{model}}",
"Model name": "モデル名",
"Model Name": "モデル名",
"Model Name *": "モデル名 *",
......@@ -2471,6 +2504,7 @@
"Models climbing the leaderboard fastest": "ランキングで最も急上昇しているモデル",
"Models directory": "モデルディレクトリ",
"Models Directory": "モデルディレクトリ",
"Models exposed by this channel": "このチャンネルで公開するモデル",
"Models fetched successfully": "モデルが正常に取得されました",
"Models filled to form": "フォームにモデルが記入されました",
"Models listed here will not automatically append or remove -thinking / -nothinking suffixes.": "ここに記載されたモデルは、-thinking / -nothinking サフィックスの自動付与・削除を行いません。",
......@@ -2551,6 +2585,7 @@
"Name, provider type, and availability.": "名前、プロバイダー種別、利用可否。",
"name@example.com": "name@example.com",
"Native format": "ネイティブ形式",
"Native forwarding": "ネイティブ転送",
"Need a redemption code?": "引き換えコードが必要ですか?",
"Needs API key": "API キーが必要",
"Nested JSON defining per-group rules for adding (+:), removing (-:), or appending usable groups.": "追加 (+:)、削除 (-:)、または使用可能なグループの追加を行うグループごとのルールを定義するネストされたJSON。",
......@@ -2593,6 +2628,7 @@
"No API routes configured": "APIルートが設定されていません",
"No app usage data available for this model.": "このモデルのアプリ利用データはまだありません。",
"No apps match the selected filters": "条件に一致するアプリはありません",
"No Auth": "認証なし",
"No available models": "利用可能なモデルがありません",
"No available Web chat links": "利用可能なWebチャットリンクがありません",
"No backup": "バックアップなし",
......@@ -2767,7 +2803,14 @@
"of 3:": "3のうち:",
"off": "オフ",
"Official": "公式",
"Official Claude Messages": "公式 Claude Messages",
"Official documentation": "公式ドキュメント",
"Official Gemini from OpenAI Chat": "OpenAI Chat から公式 Gemini",
"Official Gemini Native": "公式 Gemini ネイティブ",
"Official OpenAI Chat": "公式 OpenAI Chat",
"Official OpenAI Embeddings": "公式 OpenAI Embeddings",
"Official OpenAI Images": "公式 OpenAI Images",
"Official OpenAI Responses": "公式 OpenAI Responses",
"Official Repository": "公式リポジトリ",
"Official Sync": "公式同期",
"OhMyGPT": "OhMyGPT",
......@@ -2814,9 +2857,24 @@
"Open theme settings": "テーマ設定を開く",
"Open weights": "公開ウェイト",
"OpenAI": "OpenAI",
"OpenAI Audio Speech": "OpenAI 音声生成",
"OpenAI Audio Transcriptions": "OpenAI 音声文字起こし",
"OpenAI Audio Translations": "OpenAI 音声翻訳",
"OpenAI Chat Completions": "OpenAI チャット補完",
"OpenAI Chat to Anthropic Messages": "OpenAI Chat から Anthropic Messages",
"OpenAI Chat to Gemini Generate Content": "OpenAI Chat から Gemini Generate Content",
"OpenAI Chat to OpenAI Responses": "OpenAI Chat から OpenAI Responses",
"OpenAI Completions": "OpenAI 補完",
"OpenAI Compatible": "OpenAI互換",
"OpenAI Embeddings": "OpenAI 埋め込み",
"OpenAI Image Edits": "OpenAI 画像編集",
"OpenAI Image Generations": "OpenAI 画像生成",
"OpenAI Organization": "OpenAI組織",
"OpenAI Organization ID (optional)": "OpenAI 組織 ID (オプション)",
"OpenAI Realtime": "OpenAI リアルタイム",
"OpenAI Rerank": "OpenAI 再ランク付け",
"OpenAI Responses": "OpenAI レスポンス",
"OpenAI Responses Compact": "OpenAI レスポンス圧縮",
"OpenAI, Anthropic, etc.": "OpenAI、Anthropicなど",
"OpenAI, Anthropic, Google, etc.": "OpenAI、Anthropic、Googleなど",
"OpenAIMax": "OpenAIMax",
......@@ -3217,6 +3275,7 @@
"QR code is not configured. Please contact support.": "QRコードが設定されていません。サポートにお問い合わせください。",
"Quantity": "数量",
"QuantumNous": "QuantumNous",
"Query": "Query",
"Query Balance": "クエリ残高",
"Query Param": "クエリパラメータ",
"Querying...": "クエリ中...",
......@@ -3454,8 +3513,8 @@
"Resolve Conflicts": "競合を解決",
"Resource Configuration": "リソース設定",
"Response": "レスポンス",
"Response time: {{duration}}": "応答時間: {{duration}}",
"Response Time": "応答時間",
"Response time: {{duration}}": "応答時間: {{duration}}",
"Responses API Version": "応答APIバージョン",
"Restore defaults": "既定に戻す",
"Restrict user model request frequency (may impact high concurrency performance)": "ユーザーモデルのリクエスト頻度を制限する(高並行性パフォーマンスに影響を与える可能性があります)",
......@@ -3493,6 +3552,7 @@
"Route Description": "ルートの説明",
"Route is required": "ルートは必須です",
"Route, auth, and balance check in one place": "ルート、認証、残高確認を一か所に集約",
"Routes": "ルート",
"Routing & Overrides": "ルーティングと上書き",
"Routing Strategy": "ルーティング戦略",
"Rows per page": "ページあたりの行数",
......@@ -4349,12 +4409,17 @@
"Upstream Model Updates": "上流モデルの更新",
"Upstream model updates applied: {{added}} added, {{removed}} removed, {{ignored}} ignored this time, {{totalIgnored}} total ignored models": "上流モデル更新を処理しました:{{added}} 個追加、{{removed}} 個削除、今回 {{ignored}} 個無視、合計 {{totalIgnored}} 個の無視モデル",
"Upstream price sync": "アップストリーム価格同期",
"Upstream path": "上流パス",
"Upstream path is required": "上流パスは必須です",
"Upstream path must be a full URL or a path starting with /": "上流パスは完全な URL、または / で始まるパスである必要があります",
"Upstream prices fetched successfully": "上流価格を正常に取得しました",
"Upstream ratios fetched successfully": "アップストリーム比率が正常に取得されました",
"Upstream Request ID": "上流リクエストID",
"Upstream Response": "アップストリームレスポンス",
"upstream services integrated": "アップストリームサービス連携",
"Upstream Updates": "アップストリーム更新",
"Upstream URL": "上流 URL",
"Upstream URL must be a full URL": "上流 URL は完全な URL である必要があります",
"uptime": "稼働時間",
"Uptime": "稼働時間",
"Uptime (30d)": "稼働率 (30 日)",
......@@ -4378,6 +4443,7 @@
"USD price per 1M input tokens.": "100万入力トークンあたりのUSD価格。",
"USD price per 1M tokens.": "100万トークンあたりのUSD価格。",
"Use +: to add a group, -: to remove a default selectable group, or no prefix to append a group.": "+: はグループ追加、-: はデフォルト選択可能グループの削除、接頭辞なしはグループ追記に使います。",
"Use a path to append it to the channel Base URL, or enter a full URL to override the Base URL for this route.": "パスを入力するとチャネルの Base URL に追加されます。完全な URL を入力すると、このルートでは Base URL を使わずその URL を使用します。",
"Use a compatible browser or device with biometric authentication or a security key to register a Passkey.": "生体認証またはセキュリティキーを備えた互換性のあるブラウザまたはデバイスを使用して、パスキーを登録してください。",
"Use authenticator code": "認証コードを使用",
"Use backup code": "バックアップコードを使用",
......@@ -4396,6 +4462,7 @@
"Used": "使用済み",
"Used / Remaining": "使用済み / 残り",
"Used as SuccessURL on the new product. You'll be prompted to confirm if left blank.": "新しい商品の SuccessURL として使用されます。空のままにすると確認を求められます。",
"Used by route auth templates": "ルート認証テンプレートで使用",
"Used for load balancing. Higher weight = more requests": "ロードバランシングに使用されます。重みが高いほどリクエスト数が増えます",
"Used in URLs and API routes": "URLとAPIルートで使用されます",
"Used Quota": "使用済みクォータ",
......@@ -4590,6 +4657,7 @@
"When enabled, Midjourney callbacks are accepted (reveals server IP).": "有効にすると、Midjourney のコールバックを受け入れます (サーバーの IP を公開します)。",
"When enabled, newly created tokens start in the first auto group.": "有効にすると、新しく作成されたトークンは最初の自動グループで開始されます。",
"When enabled, prompts are scanned before reaching upstream models.": "有効にすると、プロンプトはアップストリームモデルに到達する前にスキャンされます。",
"When enabled, requests that do not match any advanced route are forwarded to the channel base URL. When disabled, unmatched requests return an error.": "有効にすると、高度なルートに一致しないリクエストはチャネルのベース URL へ転送されます。無効の場合、一致しないリクエストはエラーを返します。",
"When enabled, the store field will be blocked": "有効にすると、ストアフィールドはブロックされます",
"When enabled, users can pick this group when creating tokens.": "有効にすると、ユーザーはトークン作成時にこのグループを選択できます。",
"When enabled, violation requests will incur additional charges.": "有効にすると、違反リクエストに追加料金が発生します。",
......
......@@ -193,6 +193,7 @@
"Add Provider": "Добавить поставщика",
"Add Quota": "Добавить квоту",
"Add ratio override": "Добавить переопределение коэффициента",
"Add route": "Добавить маршрут",
"Add Row": "Добавить строку",
"Add Rule": "Добавить правило",
"Add rule group": "Добавить группу правил",
......@@ -228,6 +229,10 @@
"Administrator username": "Имя пользователя администратора",
"Advanced": "Расширенные",
"Advanced Configuration": "Расширенная конфигурация",
"Advanced Custom": "Расширенный пользовательский",
"Advanced custom configuration is required": "Требуется расширенная пользовательская конфигурация",
"Advanced custom configuration requires at least one route or fallback": "Расширенной пользовательской конфигурации нужен хотя бы один маршрут или fallback",
"Advanced Custom Routes": "Расширенные пользовательские маршруты",
"Advanced options": "Расширенные параметры",
"Advanced Options": "Расширенные параметры",
"Advanced platform configuration.": "Расширенная настройка платформы.",
......@@ -340,6 +345,7 @@
"Answer": "Ответ",
"Answers for common access and billing questions": "Ответы на частые вопросы о доступе и оплате",
"Anthropic": "Anthropic",
"Anthropic Messages to OpenAI Chat": "Anthropic Messages в OpenAI Chat",
"Any Match (OR)": "Любое совпадение (OR)",
"API": "API",
"API Access": "Доступ к API",
......@@ -442,8 +448,14 @@
"Audio Preview": "Предпросмотр аудио",
"Audio ratio": "Коэффициент аудио",
"Audio Tokens": "Аудио токены",
"Auth": "Аутентификация",
"Auth configured": "Аутентификация настроена",
"Auth name": "Имя auth",
"Auth name is required": "Имя auth обязательно",
"Auth Style": "Стиль аутентификации",
"Auth type is invalid": "Тип auth недействителен",
"Auth value": "Значение auth",
"Auth value is required": "Значение auth обязательно",
"auth.resetPasswordConfirm.backToLogin": "Вернуться ко входу",
"auth.resetPasswordConfirm.confirm": "Подтвердить сброс пароля",
"auth.resetPasswordConfirm.description": "Подтвердите запрос на сброс, чтобы создать новый пароль.",
......@@ -529,6 +541,8 @@
"Base rate limit windows for this account.": "Окна базовых лимитов для этого аккаунта.",
"Base URL": "Адрес API",
"Base URL is required for this channel type": "Для этого типа канала требуется Base URL",
"Base URL is required when an advanced route uses an upstream path": "Base URL требуется, когда расширенный маршрут использует путь upstream",
"Base URL is required when fallback is enabled": "Base URL обязателен при включенном fallback",
"Base URL of your Uptime Kuma instance": "Базовый URL вашего экземпляра Uptime Kuma",
"Basic Authentication": "Базовая аутентификация",
"Basic Configuration": "Базовая конфигурация",
......@@ -667,6 +681,8 @@
"Changes are written to the settings draft on save.": "Изменения будут записаны в черновик настроек при сохранении.",
"Changing...": "Изменение...",
"Channel": "Канал",
"Channel {{name}}": "Канал {{name}}",
"Channel {{name}} model {{model}}": "Канал {{name}}, модель {{model}}",
"Channel Affinity": "Привязка к каналу",
"Channel affinity reuses the last successful channel based on keys extracted from the request context or JSON body.": "Привязка к каналу повторно использует последний успешный канал на основе ключей, извлечённых из контекста запроса или тела JSON.",
"Channel Affinity: Upstream Cache Hit": "Привязка к каналу: попадание в кэш upstream",
......@@ -680,8 +696,6 @@
"Channel key": "Ключ канала",
"Channel key unlocked": "Ключ канала разблокирован",
"Channel models": "Модели каналов",
"Channel {{name}}": "Канал {{name}}",
"Channel {{name}} model {{model}}": "Канал {{name}}, модель {{model}}",
"Channel name is required": "Имя канала обязательно",
"Channel test completed": "Тест канала завершён",
"Channel type is required": "Тип канала обязателен",
......@@ -741,6 +755,7 @@
"Classic (Legacy Frontend)": "Классический (Старый интерфейс)",
"Claude": "Клод",
"Claude CLI Header Passthrough": "Проброс заголовков Claude CLI",
"Claude Messages": "Сообщения Claude",
"Clean": "Без конфликта",
"Clean history logs": "Очистить журналы истории",
"Clean logs": "Очистить логи",
......@@ -884,6 +899,7 @@
"Configure per-tool unit prices ($/1K calls). Per-request models do not incur additional tool fees.": "Настройте стоимость единицы на инструмент ($/1K вызовов). Для моделей с оплатой за запрос доп. плата за инструменты не взимается.",
"Configure pricing ratios for a specific model.": "Настроить коэффициенты ценообразования для конкретной модели.",
"Configure rate limiting rules for a specific user group.": "Настроить правила ограничения скорости для конкретной группы пользователей.",
"Configure routes": "Настроить маршруты",
"Configure the ratio for this group.": "Настроить коэффициент для этой группы.",
"Configure upstream providers and routing.": "Настроить провайдеров верхнего уровня и маршрутизацию.",
"Configure Waffo Pancake hosted checkout integration for USD-priced top-ups": "Настроить хостовую интеграцию Waffo Pancake (hosted checkout) для пополнений в USD",
......@@ -962,6 +978,9 @@
"Convert reasoning_content to <think> tag in content": "Преобразовать reasoning_content в тег <think> в content",
"Convert string to lowercase": "Преобразовать строку в нижний регистр",
"Convert string to uppercase": "Преобразовать строку в верхний регистр",
"Converter": "Конвертер",
"Converter does not match incoming path": "Конвертер не соответствует входящему пути",
"Converter is not registered": "Конвертер не зарегистрирован",
"Copied": "Скопировано",
"Copied {{count}} key(s)": "Скопировано {{count}} ключ(ей)",
"Copied channel (source ID: {{sourceId}}) to {{name}} (new ID: {{id}})": "Канал скопирован (исходный ID: {{sourceId}}) в {{name}} (новый ID: {{id}})",
......@@ -1145,6 +1164,7 @@
"Default / range": "По умолчанию / диапазон",
"Default API Version *": "Версия API по умолчанию *",
"Default API version for this channel": "Версия API по умолчанию для этого канала",
"Default Bearer": "Bearer по умолчанию",
"Default Collapse Sidebar": "Сворачивать боковую панель по умолчанию",
"Default consumption chart": "График потребления по умолчанию",
"Default Max Tokens": "Максимальное количество токенов по умолчанию",
......@@ -1754,6 +1774,9 @@
"Failed to update user": "Не удалось обновить пользователя",
"Failure keywords": "Ключевые слова сбоя",
"Fair": "Удовлетворительно",
"Fallback": "Резерв",
"Fallback base URL": "Base URL fallback",
"Fallback routing": "Fallback-маршрутизация",
"Fallback tier": "Резервный уровень",
"FAQ": "Часто задаваемые вопросы",
"FAQ added. Click \"Save Settings\" to apply.": "FAQ добавлен. Нажмите \"Сохранить настройки\" чтобы применить.",
......@@ -1893,6 +1916,10 @@
"GC executed": "GC выполнен",
"GC execution failed": "Ошибка выполнения GC",
"Gemini": "Gemini",
"Gemini Batch Embed Contents": "Пакетные embeddings Gemini",
"Gemini Embed Content": "Embedding контента Gemini",
"Gemini Generate Content": "Генерация контента Gemini",
"Gemini Generate Content to OpenAI Chat": "Gemini Generate Content в OpenAI Chat",
"Gemini Image 4K": "Gemini Image 4K",
"Gemini will continue to auto-detect thinking mode even with the adapter disabled. Enable this only when you need finer control over pricing and budgeting.": "Gemini продолжит автоматически определять режим мышления, даже если адаптер отключен. Включайте это только тогда, когда вам нужен более тонкий контроль над ценообразованием и бюджетированием.",
"General": "Общие",
......@@ -2095,6 +2122,12 @@
"Include Rule Name": "Включить имя правила",
"Includes request rules": "Включает правила запросов",
"Including failed requests, 0 = unlimited": "Включая неудачные запросы, 0 = без ограничений",
"Incoming path": "Входящий путь",
"Incoming path is required": "Входящий путь обязателен",
"Incoming path must be unique": "Входящий путь должен быть уникальным",
"Incoming path must not include query": "Входящий путь не должен содержать query",
"Incoming path must start with /": "Входящий путь должен начинаться с /",
"Incomplete": "Не завершено",
"Increased user quota by {{quota}}": "Квота пользователя увеличена на {{quota}}",
"Index": "Индекс",
"Initial quota given to new users": "Начальная квота, предоставляемая новым пользователям",
......@@ -2408,6 +2441,7 @@
"Mode": "Режим",
"model": "модель",
"Model": "Модель",
"Model {{model}}": "Модель {{model}}",
"Model Access": "Доступ к моделям",
"Model Analytics": "Аналитика моделей",
"Model Analytics Defaults": "Настройки аналитики моделей по умолчанию",
......@@ -2432,7 +2466,6 @@
"Model mapping must be a JSON object with string values": "Сопоставление моделей должно быть JSON-объектом со строковыми значениями",
"Model mapping must be valid JSON": "Сопоставление моделей должно быть допустимым JSON",
"Model mapping values must be strings": "Значения сопоставления моделей должны быть строками",
"Model {{model}}": "Модель {{model}}",
"Model name": "Имя модели",
"Model Name": "Название модели",
"Model Name *": "Имя модели *",
......@@ -2471,6 +2504,7 @@
"Models climbing the leaderboard fastest": "Модели, быстрее всех растущие в рейтинге",
"Models directory": "Каталог моделей",
"Models Directory": "Каталог моделей",
"Models exposed by this channel": "Модели, доступные через этот канал",
"Models fetched successfully": "Модели успешно получены",
"Models filled to form": "Модели заполнены в форму",
"Models listed here will not automatically append or remove -thinking / -nothinking suffixes.": "Модели из этого списка не будут автоматически добавлять или удалять суффиксы -thinking / -nothinking.",
......@@ -2551,6 +2585,7 @@
"Name, provider type, and availability.": "Название, тип провайдера и доступность.",
"name@example.com": "name@example.com",
"Native format": "Собственный формат",
"Native forwarding": "Нативная пересылка",
"Need a redemption code?": "Нужен код активации?",
"Needs API key": "Нужен API-ключ",
"Nested JSON defining per-group rules for adding (+:), removing (-:), or appending usable groups.": "Вложенный JSON, определяющий правила для каждой группы для добавления (+:), удаления (-:) или добавления используемых групп.",
......@@ -2593,6 +2628,7 @@
"No API routes configured": "Нет настроенных маршрутов API",
"No app usage data available for this model.": "Данные об использовании приложений для этой модели пока недоступны.",
"No apps match the selected filters": "Нет приложений, соответствующих фильтрам",
"No Auth": "Без auth",
"No available models": "Нет доступных моделей",
"No available Web chat links": "Нет доступных веб-ссылок для чата",
"No backup": "Нет резервной копии",
......@@ -2767,7 +2803,14 @@
"of 3:": "из 3:",
"off": "выкл.",
"Official": "Официальный",
"Official Claude Messages": "Официальные Claude Messages",
"Official documentation": "Официальная документация",
"Official Gemini from OpenAI Chat": "Официальный Gemini из OpenAI Chat",
"Official Gemini Native": "Официальный Gemini Native",
"Official OpenAI Chat": "Официальный OpenAI Chat",
"Official OpenAI Embeddings": "Официальные OpenAI Embeddings",
"Official OpenAI Images": "Официальные OpenAI Images",
"Official OpenAI Responses": "Официальный OpenAI Responses",
"Official Repository": "Официальный репозиторий",
"Official Sync": "Официальная синхронизация",
"OhMyGPT": "OhMyGPT",
......@@ -2814,9 +2857,24 @@
"Open theme settings": "Открыть настройки темы",
"Open weights": "Открытые веса",
"OpenAI": "OpenAI",
"OpenAI Audio Speech": "Синтез речи OpenAI",
"OpenAI Audio Transcriptions": "Транскрибация аудио OpenAI",
"OpenAI Audio Translations": "Перевод аудио OpenAI",
"OpenAI Chat Completions": "Чат-завершения OpenAI",
"OpenAI Chat to Anthropic Messages": "OpenAI Chat в Anthropic Messages",
"OpenAI Chat to Gemini Generate Content": "OpenAI Chat в Gemini Generate Content",
"OpenAI Chat to OpenAI Responses": "OpenAI Chat в OpenAI Responses",
"OpenAI Completions": "Завершения OpenAI",
"OpenAI Compatible": "Совместимо с OpenAI",
"OpenAI Embeddings": "Векторные представления OpenAI",
"OpenAI Image Edits": "Редактирование изображений OpenAI",
"OpenAI Image Generations": "Генерация изображений OpenAI",
"OpenAI Organization": "Организация OpenAI",
"OpenAI Organization ID (optional)": "Идентификатор организации OpenAI (необязательно)",
"OpenAI Realtime": "Реальное время OpenAI",
"OpenAI Rerank": "Реранжирование OpenAI",
"OpenAI Responses": "Ответы OpenAI",
"OpenAI Responses Compact": "Компактные ответы OpenAI",
"OpenAI, Anthropic, etc.": "OpenAI, Anthropic и т.д.",
"OpenAI, Anthropic, Google, etc.": "OpenAI, Anthropic, Google и т.д.",
"OpenAIMax": "OpenAIMax",
......@@ -3217,6 +3275,7 @@
"QR code is not configured. Please contact support.": "QR-код не настроен. Пожалуйста, свяжитесь со службой поддержки.",
"Quantity": "Количество",
"QuantumNous": "QuantumNous",
"Query": "Query",
"Query Balance": "Баланс запросов",
"Query Param": "Параметр запроса",
"Querying...": "Выполняется запрос...",
......@@ -3454,8 +3513,8 @@
"Resolve Conflicts": "Разрешить конфликты",
"Resource Configuration": "Конфигурация ресурсов",
"Response": "Ответ",
"Response time: {{duration}}": "Время ответа: {{duration}}",
"Response Time": "Время ответа",
"Response time: {{duration}}": "Время ответа: {{duration}}",
"Responses API Version": "Версия API ответов",
"Restore defaults": "Сбросить к значениям по умолчанию",
"Restrict user model request frequency (may impact high concurrency performance)": "Ограничить частоту запросов пользовательских моделей (может повлиять на производительность при высокой конкуренции)",
......@@ -3493,6 +3552,7 @@
"Route Description": "Описание маршрута",
"Route is required": "Маршрут обязателен",
"Route, auth, and balance check in one place": "Маршрут, аутентификация и баланс в одном месте",
"Routes": "Маршруты",
"Routing & Overrides": "Маршрутизация и переопределения",
"Routing Strategy": "Стратегия маршрутизации",
"Rows per page": "Строк на страницу",
......@@ -4349,12 +4409,17 @@
"Upstream Model Updates": "Обновления моделей источника",
"Upstream model updates applied: {{added}} added, {{removed}} removed, {{ignored}} ignored this time, {{totalIgnored}} total ignored models": "Обновления моделей применены: {{added}} добавлено, {{removed}} удалено, {{ignored}} проигнорировано, всего {{totalIgnored}} проигнорированных моделей",
"Upstream price sync": "Синхронизация цен upstream",
"Upstream path": "Путь upstream",
"Upstream path is required": "Путь upstream обязателен",
"Upstream path must be a full URL or a path starting with /": "Путь upstream должен быть полным URL или путем, начинающимся с /",
"Upstream prices fetched successfully": "Цены провайдера успешно получены",
"Upstream ratios fetched successfully": "Коэффициенты upstream успешно получены",
"Upstream Request ID": "ID вышестоящего запроса",
"Upstream Response": "Ответ Upstream",
"upstream services integrated": "интеграций с вышестоящими сервисами",
"Upstream Updates": "Обновления вышестоящих моделей",
"Upstream URL": "URL вышестоящего сервиса",
"Upstream URL must be a full URL": "Upstream URL должен быть полным URL",
"uptime": "время бесперебойной работы",
"Uptime": "Время работы",
"Uptime (30d)": "Доступность (30 дн.)",
......@@ -4378,6 +4443,7 @@
"USD price per 1M input tokens.": "Цена в USD за 1 млн входных токенов.",
"USD price per 1M tokens.": "Цена в USD за 1 млн токенов.",
"Use +: to add a group, -: to remove a default selectable group, or no prefix to append a group.": "Используйте +: для добавления группы, -: для удаления выбираемой по умолчанию группы, без префикса — для добавления в конец.",
"Use a path to append it to the channel Base URL, or enter a full URL to override the Base URL for this route.": "Укажите путь, чтобы добавить его к Base URL канала, или введите полный URL, чтобы переопределить Base URL для этого маршрута.",
"Use a compatible browser or device with biometric authentication or a security key to register a Passkey.": "Используйте совместимый браузер или устройство с биометрической аутентификацией или ключ безопасности для регистрации ключа доступа.",
"Use authenticator code": "Использовать код аутентификатора",
"Use backup code": "Использовать резервный код",
......@@ -4396,6 +4462,7 @@
"Used": "Использовано",
"Used / Remaining": "Использовано / Осталось",
"Used as SuccessURL on the new product. You'll be prompted to confirm if left blank.": "Используется как SuccessURL для нового продукта. Если оставить пустым, потребуется подтверждение.",
"Used by route auth templates": "Используется шаблонами auth маршрута",
"Used for load balancing. Higher weight = more requests": "Используется для балансировки нагрузки. Больший вес = больше запросов",
"Used in URLs and API routes": "Используется в URL и маршрутах API",
"Used Quota": "Лимит потребления",
......@@ -4590,6 +4657,7 @@
"When enabled, Midjourney callbacks are accepted (reveals server IP).": "При включении принимаются обратные вызовы Midjourney (раскрывает IP сервера).",
"When enabled, newly created tokens start in the first auto group.": "При включении вновь созданные токены начинаются в первой автогруппе.",
"When enabled, prompts are scanned before reaching upstream models.": "При включении запросы сканируются перед достижением вышестоящих моделей.",
"When enabled, requests that do not match any advanced route are forwarded to the channel base URL. When disabled, unmatched requests return an error.": "Если включено, запросы, не соответствующие ни одному расширенному маршруту, перенаправляются на базовый URL канала. Если отключено, несопоставленные запросы возвращают ошибку.",
"When enabled, the store field will be blocked": "Если включено, поле магазина будет заблокировано",
"When enabled, users can pick this group when creating tokens.": "Если включено, пользователи могут выбрать эту группу при создании токенов.",
"When enabled, violation requests will incur additional charges.": "При включении за нарушения будут начисляться дополнительные расходы.",
......
......@@ -193,6 +193,7 @@
"Add Provider": "Thêm nhà cung cấp",
"Add Quota": "Thêm Hạn mức",
"Add ratio override": "Thêm ghi đè tỷ lệ",
"Add route": "Thêm route",
"Add Row": "Thêm Hàng",
"Add Rule": "Thêm quy tắc",
"Add rule group": "Thêm nhóm quy tắc",
......@@ -228,6 +229,10 @@
"Administrator username": "Tên người dùng quản trị viên",
"Advanced": "Nâng cao",
"Advanced Configuration": "Cấu hình nâng cao",
"Advanced Custom": "Tùy chỉnh nâng cao",
"Advanced custom configuration is required": "Bắt buộc cấu hình tùy chỉnh nâng cao",
"Advanced custom configuration requires at least one route or fallback": "Cấu hình tùy chỉnh nâng cao cần ít nhất một route hoặc fallback",
"Advanced Custom Routes": "Route tùy chỉnh nâng cao",
"Advanced options": "Tùy chọn nâng cao",
"Advanced Options": "Tùy chọn nâng cao",
"Advanced platform configuration.": "Cấu hình nền tảng nâng cao.",
......@@ -340,6 +345,7 @@
"Answer": "Trả lời",
"Answers for common access and billing questions": "Câu trả lời cho các câu hỏi thường gặp về truy cập và thanh toán",
"Anthropic": "Anthropic",
"Anthropic Messages to OpenAI Chat": "Anthropic Messages sang OpenAI Chat",
"Any Match (OR)": "Bất kỳ khớp (OR)",
"API": "API",
"API Access": "Truy cập API",
......@@ -442,8 +448,14 @@
"Audio Preview": "Xem trước âm thanh",
"Audio ratio": "Tỷ lệ âm thanh",
"Audio Tokens": "Token âm thanh",
"Auth": "Xác thực",
"Auth configured": "Đã cấu hình xác thực",
"Auth name": "Tên xác thực",
"Auth name is required": "Bắt buộc tên xác thực",
"Auth Style": "Kiểu xác thực",
"Auth type is invalid": "Loại xác thực không hợp lệ",
"Auth value": "Giá trị xác thực",
"Auth value is required": "Bắt buộc giá trị xác thực",
"auth.resetPasswordConfirm.backToLogin": "Quay lại đăng nhập",
"auth.resetPasswordConfirm.confirm": "Xác nhận đặt lại mật khẩu",
"auth.resetPasswordConfirm.description": "Xác nhận yêu cầu đặt lại để tạo mật khẩu mới.",
......@@ -529,6 +541,8 @@
"Base rate limit windows for this account.": "Cửa sổ giới hạn tốc độ cơ bản cho tài khoản này.",
"Base URL": "URL cơ sở",
"Base URL is required for this channel type": "Loại kênh này yêu cầu Base URL",
"Base URL is required when an advanced route uses an upstream path": "Cần Base URL khi tuyến nâng cao dùng đường dẫn upstream",
"Base URL is required when fallback is enabled": "Cần Base URL khi bật fallback",
"Base URL of your Uptime Kuma instance": "URL cơ sở của phiên bản Uptime Kuma của bạn",
"Basic Authentication": "Xác thực cơ bản",
"Basic Configuration": "Cấu hình cơ bản",
......@@ -667,6 +681,8 @@
"Changes are written to the settings draft on save.": "Các thay đổi sẽ được ghi vào bản nháp cài đặt khi lưu.",
"Changing...": "Đang thay đổi...",
"Channel": "Kênh",
"Channel {{name}}": "Kênh {{name}}",
"Channel {{name}} model {{model}}": "Kênh {{name}} mô hình {{model}}",
"Channel Affinity": "Ưu tiên kênh",
"Channel affinity reuses the last successful channel based on keys extracted from the request context or JSON body.": "Ưu tiên kênh sẽ sử dụng lại kênh thành công gần nhất dựa trên các khóa được trích xuất từ ngữ cảnh yêu cầu hoặc JSON body.",
"Channel Affinity: Upstream Cache Hit": "Ưu tiên kênh: Cache hit từ upstream",
......@@ -680,8 +696,6 @@
"Channel key": "Khóa kênh",
"Channel key unlocked": "Khóa kênh đã được mở khóa",
"Channel models": "Mô hình kênh",
"Channel {{name}}": "Kênh {{name}}",
"Channel {{name}} model {{model}}": "Kênh {{name}} mô hình {{model}}",
"Channel name is required": "Tên kênh là bắt buộc",
"Channel test completed": "Kiểm tra kênh hoàn tất",
"Channel type is required": "Loại kênh là bắt buộc",
......@@ -741,6 +755,7 @@
"Classic (Legacy Frontend)": "Cổ điển (Frontend cũ)",
"Claude": "Claude",
"Claude CLI Header Passthrough": "Chuyển tiếp header Claude CLI",
"Claude Messages": "Claude Messages",
"Clean": "Không xung đột",
"Clean history logs": "Xóa nhật ký lịch sử",
"Clean logs": "Dọn dẹp nhật ký",
......@@ -884,6 +899,7 @@
"Configure per-tool unit prices ($/1K calls). Per-request models do not incur additional tool fees.": "Cấu hình giá theo từng công cụ ($/1K lần gọi). Mô hình tính phí theo request không phát sinh thêm phí công cụ.",
"Configure pricing ratios for a specific model.": "Cấu hình tỷ lệ định giá cho một mô hình cụ thể.",
"Configure rate limiting rules for a specific user group.": "Cấu hình quy tắc giới hạn tốc độ cho một nhóm người dùng cụ thể.",
"Configure routes": "Cấu hình route",
"Configure the ratio for this group.": "Cấu hình tỷ lệ cho nhóm này.",
"Configure upstream providers and routing.": "Cấu hình nhà cung cấp upstream và định tuyến.",
"Configure Waffo Pancake hosted checkout integration for USD-priced top-ups": "Cấu hình tích hợp thanh toán Waffo Pancake (hosted checkout) cho nạp tiền theo USD",
......@@ -962,6 +978,9 @@
"Convert reasoning_content to <think> tag in content": "Chuyển đổi reasoning_content thành thẻ <think> trong nội dung",
"Convert string to lowercase": "Chuyển chuỗi sang chữ thường",
"Convert string to uppercase": "Chuyển chuỗi sang chữ hoa",
"Converter": "Bộ chuyển đổi",
"Converter does not match incoming path": "Bộ chuyển đổi không khớp path đầu vào",
"Converter is not registered": "Bộ chuyển đổi chưa được đăng ký",
"Copied": "Đã sao chép",
"Copied {{count}} key(s)": "Đã sao chép {{count}} khóa",
"Copied channel (source ID: {{sourceId}}) to {{name}} (new ID: {{id}})": "Đã sao chép kênh (ID nguồn: {{sourceId}}) thành {{name}} (ID mới: {{id}})",
......@@ -1145,6 +1164,7 @@
"Default / range": "Mặc định / khoảng",
"Default API Version *": "Phiên bản API mặc định *",
"Default API version for this channel": "Phiên bản API mặc định cho kênh này",
"Default Bearer": "Bearer mặc định",
"Default Collapse Sidebar": "Mặc định Thu gọn Thanh bên",
"Default consumption chart": "Biểu đồ tiêu thụ mặc định",
"Default Max Tokens": "Tokens Tối đa Mặc định",
......@@ -1754,6 +1774,9 @@
"Failed to update user": "Không thể cập nhật người dùng",
"Failure keywords": "Từ khóa thất bại",
"Fair": "Công bằng",
"Fallback": "Fallback",
"Fallback base URL": "Base URL fallback",
"Fallback routing": "Định tuyến fallback",
"Fallback tier": "Tầng dự phòng",
"FAQ": "FAQ",
"FAQ added. Click \"Save Settings\" to apply.": "Đã thêm FAQ. Nhấp \"Lưu cài đặt\" để áp dụng.",
......@@ -1893,6 +1916,10 @@
"GC executed": "GC đã thực thi",
"GC execution failed": "Thực thi GC thất bại",
"Gemini": "Song Tử",
"Gemini Batch Embed Contents": "Gemini Batch Embed Contents",
"Gemini Embed Content": "Gemini Embed Content",
"Gemini Generate Content": "Gemini Generate Content",
"Gemini Generate Content to OpenAI Chat": "Gemini Generate Content sang OpenAI Chat",
"Gemini Image 4K": "Gemini Image 4K",
"Gemini will continue to auto-detect thinking mode even with the adapter disabled. Enable this only when you need finer control over pricing and budgeting.": "Gemini sẽ tiếp tục tự động phát hiện chế độ suy nghĩ ngay cả khi bộ điều hợp bị tắt. Chỉ bật tính năng này khi bạn cần kiểm soát chi tiết hơn về giá cả và lập ngân sách.",
"General": "Chung",
......@@ -2095,6 +2122,12 @@
"Include Rule Name": "Bao gồm tên quy tắc",
"Includes request rules": "Bao gồm quy tắc yêu cầu",
"Including failed requests, 0 = unlimited": "Bao gồm các yêu cầu thất bại, 0 = không giới hạn",
"Incoming path": "Path đầu vào",
"Incoming path is required": "Bắt buộc path đầu vào",
"Incoming path must be unique": "Path đầu vào phải duy nhất",
"Incoming path must not include query": "Path đầu vào không được chứa query",
"Incoming path must start with /": "Path đầu vào phải bắt đầu bằng /",
"Incomplete": "Chưa hoàn tất",
"Increased user quota by {{quota}}": "Đã tăng hạn mức người dùng thêm {{quota}}",
"Index": "Chỉ mục",
"Initial quota given to new users": "Hạn mức ban đầu cấp cho người dùng mới",
......@@ -2408,6 +2441,7 @@
"Mode": "Chế độ",
"model": "mô hình",
"Model": "Mô hình",
"Model {{model}}": "Mô hình {{model}}",
"Model Access": "Truy cập mô hình",
"Model Analytics": "Phân tích mô hình",
"Model Analytics Defaults": "Mặc định phân tích mô hình",
......@@ -2432,7 +2466,6 @@
"Model mapping must be a JSON object with string values": "Ánh xạ mô hình phải là đối tượng JSON với giá trị chuỗi",
"Model mapping must be valid JSON": "Ánh xạ mô hình phải là JSON hợp lệ",
"Model mapping values must be strings": "Giá trị ánh xạ mô hình phải là chuỗi",
"Model {{model}}": "Mô hình {{model}}",
"Model name": "Tên mẫu",
"Model Name": "Tên mẫu",
"Model Name *": "Tên mẫu *",
......@@ -2471,6 +2504,7 @@
"Models climbing the leaderboard fastest": "Mô hình tăng hạng nhanh nhất",
"Models directory": "Thư mục mô hình",
"Models Directory": "Danh mục mô hình",
"Models exposed by this channel": "Model được kênh này công bố",
"Models fetched successfully": "Các mô hình đã được tải thành công",
"Models filled to form": "Mô hình đã được điền vào biểu mẫu",
"Models listed here will not automatically append or remove -thinking / -nothinking suffixes.": "Các mô hình được liệt kê ở đây sẽ không tự động thêm hoặc xóa hậu tố -thinking / -nothinking.",
......@@ -2551,6 +2585,7 @@
"Name, provider type, and availability.": "Tên, loại nhà cung cấp và trạng thái khả dụng.",
"name@example.com": "name@example.com",
"Native format": "Định dạng gốc",
"Native forwarding": "Chuyển tiếp nguyên bản",
"Need a redemption code?": "Cần mã đổi thưởng?",
"Needs API key": "Cần khóa API",
"Nested JSON defining per-group rules for adding (+:), removing (-:), or appending usable groups.": "JSON lồng nhau xác định quy tắc theo nhóm để thêm (+:), xóa (-:), hoặc nối các nhóm có thể sử dụng.",
......@@ -2593,6 +2628,7 @@
"No API routes configured": "Chưa có tuyến API nào được cấu hình",
"No app usage data available for this model.": "Chưa có dữ liệu sử dụng ứng dụng cho mô hình này.",
"No apps match the selected filters": "Không có ứng dụng phù hợp bộ lọc",
"No Auth": "Không xác thực",
"No available models": "Không có mô hình khả dụng",
"No available Web chat links": "Không có liên kết Web chat khả dụng",
"No backup": "Chưa sao lưu",
......@@ -2767,7 +2803,14 @@
"of 3:": "of 3:",
"off": "off",
"Official": "Chính thức",
"Official Claude Messages": "Claude Messages chính thức",
"Official documentation": "Tài liệu chính thức",
"Official Gemini from OpenAI Chat": "Gemini chính thức từ OpenAI Chat",
"Official Gemini Native": "Gemini native chính thức",
"Official OpenAI Chat": "OpenAI Chat chính thức",
"Official OpenAI Embeddings": "OpenAI Embeddings chính thức",
"Official OpenAI Images": "OpenAI Images chính thức",
"Official OpenAI Responses": "OpenAI Responses chính thức",
"Official Repository": "Kho lưu trữ chính thức",
"Official Sync": "Official sync",
"OhMyGPT": "OhMyGPT",
......@@ -2814,9 +2857,24 @@
"Open theme settings": "Mở cài đặt giao diện",
"Open weights": "Trọng số mở",
"OpenAI": "OpenAI",
"OpenAI Audio Speech": "OpenAI Audio Speech",
"OpenAI Audio Transcriptions": "OpenAI Audio Transcriptions",
"OpenAI Audio Translations": "OpenAI Audio Translations",
"OpenAI Chat Completions": "OpenAI Chat Completions",
"OpenAI Chat to Anthropic Messages": "OpenAI Chat sang Anthropic Messages",
"OpenAI Chat to Gemini Generate Content": "OpenAI Chat sang Gemini Generate Content",
"OpenAI Chat to OpenAI Responses": "OpenAI Chat sang OpenAI Responses",
"OpenAI Completions": "OpenAI Completions",
"OpenAI Compatible": "Tương thích OpenAI",
"OpenAI Embeddings": "OpenAI Embeddings",
"OpenAI Image Edits": "OpenAI Image Edits",
"OpenAI Image Generations": "OpenAI Image Generations",
"OpenAI Organization": "Tổ chức OpenAI",
"OpenAI Organization ID (optional)": "ID Tổ chức OpenAI (tùy chọn)",
"OpenAI Realtime": "OpenAI Realtime",
"OpenAI Rerank": "OpenAI Rerank",
"OpenAI Responses": "OpenAI Responses",
"OpenAI Responses Compact": "OpenAI Responses Compact",
"OpenAI, Anthropic, etc.": "OpenAI, Anthropic, v.v.",
"OpenAI, Anthropic, Google, etc.": "OpenAI, Anthropic, Google, v.v.",
"OpenAIMax": "OpenAIMax",
......@@ -3217,6 +3275,7 @@
"QR code is not configured. Please contact support.": "Mã QR chưa được cấu hình. Vui lòng liên hệ bộ phận hỗ trợ.",
"Quantity": "Số lượng",
"QuantumNous": "QuantumNous",
"Query": "Query",
"Query Balance": "Truy vấn số dư",
"Query Param": "Query param",
"Querying...": "Đang truy vấn...",
......@@ -3454,8 +3513,8 @@
"Resolve Conflicts": "Giải quyết Xung đột",
"Resource Configuration": "Cấu hình tài nguyên",
"Response": "Phản hồi",
"Response time: {{duration}}": "Thời gian phản hồi: {{duration}}",
"Response Time": "Thời gian phản hồi",
"Response time: {{duration}}": "Thời gian phản hồi: {{duration}}",
"Responses API Version": "Phiên bản API Phản hồi",
"Restore defaults": "Khôi phục mặc định",
"Restrict user model request frequency (may impact high concurrency performance)": "Hạn chế tần suất yêu cầu mô hình người dùng (có thể ảnh hưởng đến hiệu suất khi có độ đồng thời cao)",
......@@ -3493,6 +3552,7 @@
"Route Description": "Mô tả lộ trình",
"Route is required": "Đường dẫn là bắt buộc",
"Route, auth, and balance check in one place": "Kiểm tra tuyến, xác thực và số dư ở cùng một nơi",
"Routes": "Route",
"Routing & Overrides": "Định tuyến & ghi đè",
"Routing Strategy": "Chiến lược định tuyến",
"Rows per page": "Số hàng trên trang",
......@@ -4349,12 +4409,17 @@
"Upstream Model Updates": "Cập nhật mô hình upstream",
"Upstream model updates applied: {{added}} added, {{removed}} removed, {{ignored}} ignored this time, {{totalIgnored}} total ignored models": "Đã áp dụng cập nhật mô hình upstream: {{added}} đã thêm, {{removed}} đã xóa, {{ignored}} bỏ qua lần này, {{totalIgnored}} tổng mô hình đã bỏ qua",
"Upstream price sync": "Đồng bộ giá thượng nguồn",
"Upstream path": "Đường dẫn upstream",
"Upstream path is required": "Cần nhập đường dẫn upstream",
"Upstream path must be a full URL or a path starting with /": "Đường dẫn upstream phải là URL đầy đủ hoặc đường dẫn bắt đầu bằng /",
"Upstream prices fetched successfully": "Lấy giá upstream thành công",
"Upstream ratios fetched successfully": "Đã lấy tỷ lệ upstream thành công",
"Upstream Request ID": "ID yêu cầu thượng nguồn",
"Upstream Response": "Upstream feedback",
"upstream services integrated": "dịch vụ thượng nguồn tích hợp",
"Upstream Updates": "Cập nhật nguồn",
"Upstream URL": "URL upstream",
"Upstream URL must be a full URL": "URL upstream phải là URL đầy đủ",
"uptime": "thời gian hoạt động",
"Uptime": "Thời gian hoạt động",
"Uptime (30d)": "Thời gian hoạt động (30 ngày)",
......@@ -4378,6 +4443,7 @@
"USD price per 1M input tokens.": "Giá USD cho mỗi 1 triệu token đầu vào.",
"USD price per 1M tokens.": "Giá USD cho mỗi 1 triệu token.",
"Use +: to add a group, -: to remove a default selectable group, or no prefix to append a group.": "Dùng +: để thêm nhóm, -: để xóa nhóm có thể chọn mặc định, hoặc không có tiền tố để nối nhóm.",
"Use a path to append it to the channel Base URL, or enter a full URL to override the Base URL for this route.": "Dùng đường dẫn để nối vào Base URL của kênh, hoặc nhập URL đầy đủ để ghi đè Base URL cho tuyến này.",
"Use a compatible browser or device with biometric authentication or a security key to register a Passkey.": "Sử dụng trình duyệt hoặc thiết bị tương thích có xác thực sinh trắc học hoặc khóa bảo mật để đăng ký Khóa truy cập.",
"Use authenticator code": "Sử dụng mã xác thực",
"Use backup code": "Sử dụng mã dự phòng",
......@@ -4396,6 +4462,7 @@
"Used": "Đã sử dụng",
"Used / Remaining": "Đã dùng / Còn lại",
"Used as SuccessURL on the new product. You'll be prompted to confirm if left blank.": "Được dùng làm SuccessURL trên sản phẩm mới. Bạn sẽ được yêu cầu xác nhận nếu để trống.",
"Used by route auth templates": "Dùng trong mẫu xác thực route",
"Used for load balancing. Higher weight = more requests": "Được sử dụng để cân bằng tải. Trọng số càng cao = càng nhiều yêu cầu",
"Used in URLs and API routes": "Sử dụng trong URL và các tuyến API",
"Used Quota": "Hạn mức đã sử dụng",
......@@ -4590,6 +4657,7 @@
"When enabled, Midjourney callbacks are accepted (reveals server IP).": "Khi được bật, các callback của Midjourney được chấp nhận (lộ IP máy chủ).",
"When enabled, newly created tokens start in the first auto group.": "Khi được bật, các token mới được tạo sẽ bắt đầu trong nhóm tự động đầu tiên.",
"When enabled, prompts are scanned before reaching upstream models.": "Khi được bật,",
"When enabled, requests that do not match any advanced route are forwarded to the channel base URL. When disabled, unmatched requests return an error.": "Khi bật, các yêu cầu không khớp với tuyến nâng cao nào sẽ được chuyển tiếp đến URL cơ sở của kênh. Khi tắt, các yêu cầu không khớp sẽ trả về lỗi.",
"When enabled, the store field will be blocked": "Khi được bật, trường store sẽ bị chặn",
"When enabled, users can pick this group when creating tokens.": "Khi bật, người dùng có thể chọn nhóm này khi tạo token.",
"When enabled, violation requests will incur additional charges.": "Khi bật, các yêu cầu vi phạm sẽ phải chịu phí bổ sung.",
......
......@@ -193,6 +193,7 @@
"Add Provider": "添加提供商",
"Add Quota": "添加配额",
"Add ratio override": "添加倍率覆盖",
"Add route": "添加路由",
"Add Row": "添加行",
"Add Rule": "添加规则",
"Add rule group": "新增规则组",
......@@ -228,6 +229,10 @@
"Administrator username": "管理员用户名",
"Advanced": "高级",
"Advanced Configuration": "高级配置",
"Advanced Custom": "高级自定义",
"Advanced custom configuration is required": "必须配置高级自定义",
"Advanced custom configuration requires at least one route or fallback": "高级自定义至少需要一个路由或启用兜底",
"Advanced Custom Routes": "高级自定义路由",
"Advanced options": "高级选项",
"Advanced Options": "高级选项",
"Advanced platform configuration.": "高级平台配置。",
......@@ -340,6 +345,7 @@
"Answer": "答案",
"Answers for common access and billing questions": "访问与计费常见问题解答",
"Anthropic": "Anthropic",
"Anthropic Messages to OpenAI Chat": "Anthropic Messages 到 OpenAI Chat",
"Any Match (OR)": "任一满足(OR)",
"API": "API",
"API Access": "API 访问",
......@@ -442,8 +448,14 @@
"Audio Preview": "音乐预览",
"Audio ratio": "音频倍率",
"Audio Tokens": "语音 Token",
"Auth": "认证",
"Auth configured": "认证已配置",
"Auth name": "认证名称",
"Auth name is required": "认证名称不能为空",
"Auth Style": "认证方式",
"Auth type is invalid": "认证类型无效",
"Auth value": "认证值",
"Auth value is required": "认证值不能为空",
"auth.resetPasswordConfirm.backToLogin": "返回登录",
"auth.resetPasswordConfirm.confirm": "确认重置密码",
"auth.resetPasswordConfirm.description": "确认重置请求以生成新密码。",
......@@ -529,6 +541,8 @@
"Base rate limit windows for this account.": "当前账号的基础额度窗口。",
"Base URL": "API 地址",
"Base URL is required for this channel type": "此渠道类型需要填写 Base URL",
"Base URL is required when an advanced route uses an upstream path": "高级路由使用上游路径时必须填写 Base URL",
"Base URL is required when fallback is enabled": "启用兜底时必须填写 Base URL",
"Base URL of your Uptime Kuma instance": "您的 Uptime Kuma 实例的基础 URL",
"Basic Authentication": "基本身份验证",
"Basic Configuration": "基本配置",
......@@ -667,6 +681,8 @@
"Changes are written to the settings draft on save.": "保存后会写入设置草稿。",
"Changing...": "修改中...",
"Channel": "渠道",
"Channel {{name}}": "渠道 {{name}}",
"Channel {{name}} model {{model}}": "渠道 {{name}} 模型 {{model}}",
"Channel Affinity": "渠道亲和性",
"Channel affinity reuses the last successful channel based on keys extracted from the request context or JSON body.": "渠道亲和性会基于从请求上下文或 JSON Body 提取的 Key,优先复用上一次成功的渠道。",
"Channel Affinity: Upstream Cache Hit": "渠道亲和性:上游缓存命中",
......@@ -680,8 +696,6 @@
"Channel key": "渠道密钥",
"Channel key unlocked": "渠道密钥已解锁",
"Channel models": "渠道模型",
"Channel {{name}}": "渠道 {{name}}",
"Channel {{name}} model {{model}}": "渠道 {{name}} 模型 {{model}}",
"Channel name is required": "渠道名称是必填的",
"Channel test completed": "渠道测试完成",
"Channel type is required": "渠道类型是必填的",
......@@ -741,6 +755,7 @@
"Classic (Legacy Frontend)": "经典前端",
"Claude": "Claude",
"Claude CLI Header Passthrough": "Claude CLI 请求头透传",
"Claude Messages": "Claude 消息",
"Clean": "无冲突",
"Clean history logs": "清理历史日志",
"Clean logs": "清理日志",
......@@ -884,6 +899,7 @@
"Configure per-tool unit prices ($/1K calls). Per-request models do not incur additional tool fees.": "为每个工具配置单价($/1K 次调用)。按请求计费的模型不额外收取工具费用。",
"Configure pricing ratios for a specific model.": "配置特定模型的定价比例。",
"Configure rate limiting rules for a specific user group.": "配置特定用户分组的速率限制规则。",
"Configure routes": "配置路由",
"Configure the ratio for this group.": "配置此分组的比例。",
"Configure upstream providers and routing.": "配置上游提供者和路由。",
"Configure Waffo Pancake hosted checkout integration for USD-priced top-ups": "配置 Waffo Pancake 托管结账,用于美元计价的充值",
......@@ -962,6 +978,9 @@
"Convert reasoning_content to <think> tag in content": "将 reasoning_content 转换为 content 中的 <think> 标签",
"Convert string to lowercase": "把字符串转成小写",
"Convert string to uppercase": "把字符串转成大写",
"Converter": "转换器",
"Converter does not match incoming path": "转换器与入口路径不匹配",
"Converter is not registered": "转换器未注册",
"Copied": "已复制",
"Copied {{count}} key(s)": "已复制 {{count}} 个密钥",
"Copied channel (source ID: {{sourceId}}) to {{name}} (new ID: {{id}})": "复制渠道(源 ID: {{sourceId}})为 {{name}}(新 ID: {{id}})",
......@@ -1145,6 +1164,7 @@
"Default / range": "默认值 / 范围",
"Default API Version *": "默认 API 版本 *",
"Default API version for this channel": "此渠道的默认 API 版本",
"Default Bearer": "默认 Bearer",
"Default Collapse Sidebar": "默认折叠侧边栏",
"Default consumption chart": "默认消耗分布图",
"Default Max Tokens": "默认最大 Token 数",
......@@ -1754,6 +1774,9 @@
"Failed to update user": "更新用户失败",
"Failure keywords": "失败关键词",
"Fair": "公平",
"Fallback": "兜底",
"Fallback base URL": "兜底 Base URL",
"Fallback routing": "兜底路由",
"Fallback tier": "兜底阶梯",
"FAQ": "常见问答",
"FAQ added. Click \"Save Settings\" to apply.": "FAQ 已添加。点击 \"保存设置\" 以应用。",
......@@ -1893,6 +1916,10 @@
"GC executed": "GC 已执行",
"GC execution failed": "GC 执行失败",
"Gemini": "Gemini",
"Gemini Batch Embed Contents": "Gemini 批量嵌入内容",
"Gemini Embed Content": "Gemini 嵌入内容",
"Gemini Generate Content": "Gemini 内容生成",
"Gemini Generate Content to OpenAI Chat": "Gemini Generate Content 到 OpenAI Chat",
"Gemini Image 4K": "Gemini 图片 4K",
"Gemini will continue to auto-detect thinking mode even with the adapter disabled. Enable this only when you need finer control over pricing and budgeting.": "即使禁用适配器,Gemini 也会继续自动检测思维模式。仅当您需要对定价和预算进行更精细的控制时才启用此选项。",
"General": "常规",
......@@ -2095,6 +2122,12 @@
"Include Rule Name": "包含规则名",
"Includes request rules": "包含请求规则",
"Including failed requests, 0 = unlimited": "包括失败的请求,0 = 无限制",
"Incoming path": "入口路径",
"Incoming path is required": "入口路径不能为空",
"Incoming path must be unique": "入口路径必须唯一",
"Incoming path must not include query": "入口路径不能包含 query",
"Incoming path must start with /": "入口路径必须以 / 开头",
"Incomplete": "未完成",
"Increased user quota by {{quota}}": "增加用户额度 {{quota}}",
"Index": "索引",
"Initial quota given to new users": "授予新用户的初始配额",
......@@ -2408,6 +2441,7 @@
"Mode": "模式",
"model": "模型",
"Model": "模型",
"Model {{model}}": "模型 {{model}}",
"Model Access": "模型访问",
"Model Analytics": "模型数据分析",
"Model Analytics Defaults": "模型分析默认设置",
......@@ -2432,7 +2466,6 @@
"Model mapping must be a JSON object with string values": "模型映射必须是值为字符串的 JSON 对象",
"Model mapping must be valid JSON": "模型映射必须是有效的 JSON",
"Model mapping values must be strings": "模型映射的值必须是字符串",
"Model {{model}}": "模型 {{model}}",
"Model name": "模型名称",
"Model Name": "模型名称",
"Model Name *": "模型名称 *",
......@@ -2471,6 +2504,7 @@
"Models climbing the leaderboard fastest": "排行榜上升最快的模型",
"Models directory": "模型目录",
"Models Directory": "模型目录",
"Models exposed by this channel": "此渠道暴露的模型",
"Models fetched successfully": "模型获取成功",
"Models filled to form": "模型已填充到表单",
"Models listed here will not automatically append or remove -thinking / -nothinking suffixes.": "此处列出的模型不会自动添加或移除 -thinking / -nothinking 后缀。",
......@@ -2551,6 +2585,7 @@
"Name, provider type, and availability.": "名称、供应商类型和可用状态。",
"name@example.com": "name@example.com",
"Native format": "原生格式",
"Native forwarding": "原生转发",
"Need a redemption code?": "需要兑换码?",
"Needs API key": "需要 API 密钥",
"Nested JSON defining per-group rules for adding (+:), removing (-:), or appending usable groups.": "嵌套 JSON,定义按分组添加(+:)、移除(-:)或追加可用分组的规则。",
......@@ -2593,6 +2628,7 @@
"No API routes configured": "未配置 API 路由",
"No app usage data available for this model.": "该模型暂无应用使用数据。",
"No apps match the selected filters": "没有匹配筛选条件的应用",
"No Auth": "无认证",
"No available models": "没有可用模型",
"No available Web chat links": "没有可用的 Web 聊天链接",
"No backup": "无备份",
......@@ -2767,7 +2803,14 @@
"of 3:": "共 3 个:",
"off": "关闭",
"Official": "官方",
"Official Claude Messages": "官方 Claude Messages",
"Official documentation": "官方说明",
"Official Gemini from OpenAI Chat": "官方 Gemini(OpenAI Chat 入口)",
"Official Gemini Native": "官方 Gemini 原生",
"Official OpenAI Chat": "官方 OpenAI Chat",
"Official OpenAI Embeddings": "官方 OpenAI Embeddings",
"Official OpenAI Images": "官方 OpenAI Images",
"Official OpenAI Responses": "官方 OpenAI Responses",
"Official Repository": "官方仓库",
"Official Sync": "官方同步",
"OhMyGPT": "OhMyGPT",
......@@ -2814,9 +2857,24 @@
"Open theme settings": "打开主题设置",
"Open weights": "开放权重",
"OpenAI": "OpenAI",
"OpenAI Audio Speech": "OpenAI 语音生成",
"OpenAI Audio Transcriptions": "OpenAI 音频转录",
"OpenAI Audio Translations": "OpenAI 音频翻译",
"OpenAI Chat Completions": "OpenAI 聊天补全",
"OpenAI Chat to Anthropic Messages": "OpenAI Chat 到 Anthropic Messages",
"OpenAI Chat to Gemini Generate Content": "OpenAI Chat 到 Gemini Generate Content",
"OpenAI Chat to OpenAI Responses": "OpenAI Chat 到 OpenAI Responses",
"OpenAI Completions": "OpenAI 文本补全",
"OpenAI Compatible": "兼容 OpenAI",
"OpenAI Embeddings": "OpenAI 嵌入",
"OpenAI Image Edits": "OpenAI 图像编辑",
"OpenAI Image Generations": "OpenAI 图像生成",
"OpenAI Organization": "OpenAI 组织",
"OpenAI Organization ID (optional)": "OpenAI 组织 ID(可选)",
"OpenAI Realtime": "OpenAI 实时",
"OpenAI Rerank": "OpenAI 重排序",
"OpenAI Responses": "OpenAI 响应",
"OpenAI Responses Compact": "OpenAI 响应压缩",
"OpenAI, Anthropic, etc.": "OpenAI、Anthropic 等",
"OpenAI, Anthropic, Google, etc.": "OpenAI、Anthropic、Google 等",
"OpenAIMax": "OpenAIMax",
......@@ -3217,6 +3275,7 @@
"QR code is not configured. Please contact support.": "二维码未配置。请联系支持人员。",
"Quantity": "数量",
"QuantumNous": "QuantumNous",
"Query": "Query",
"Query Balance": "查询余额",
"Query Param": "请求参数",
"Querying...": "正在查询...",
......@@ -3454,8 +3513,8 @@
"Resolve Conflicts": "解决冲突",
"Resource Configuration": "资源配置",
"Response": "响应",
"Response time: {{duration}}": "响应时间:{{duration}}",
"Response Time": "响应时间",
"Response time: {{duration}}": "响应时间:{{duration}}",
"Responses API Version": "响应 API 版本",
"Restore defaults": "恢复默认",
"Restrict user model request frequency (may impact high concurrency performance)": "限制用户模型请求频率(可能会影响高并发性能)",
......@@ -3493,6 +3552,7 @@
"Route Description": "路由描述",
"Route is required": "路由为必填项",
"Route, auth, and balance check in one place": "路由、认证和余额检查集中展示",
"Routes": "路由",
"Routing & Overrides": "路由与覆盖",
"Routing Strategy": "路由策略",
"Rows per page": "每页行数",
......@@ -4349,12 +4409,17 @@
"Upstream Model Updates": "上游模型更新",
"Upstream model updates applied: {{added}} added, {{removed}} removed, {{ignored}} ignored this time, {{totalIgnored}} total ignored models": "已处理上游模型更新:加入 {{added}} 个,删除 {{removed}} 个,本次忽略 {{ignored}} 个,当前已忽略模型 {{totalIgnored}} 个",
"Upstream price sync": "上游价格同步",
"Upstream path": "上游路径",
"Upstream path is required": "上游路径不能为空",
"Upstream path must be a full URL or a path starting with /": "上游路径必须是完整 URL,或以 / 开头的路径",
"Upstream prices fetched successfully": "已成功获取上游价格",
"Upstream ratios fetched successfully": "上游比率获取成功",
"Upstream Request ID": "上游请求 ID",
"Upstream Response": "上游返回",
"upstream services integrated": "上游服务适配",
"Upstream Updates": "上游更新",
"Upstream URL": "上游 URL",
"Upstream URL must be a full URL": "上游 URL 必须是完整 URL",
"uptime": "正常运行时间",
"Uptime": "运行时间",
"Uptime (30d)": "可用率 (30 天)",
......@@ -4378,6 +4443,7 @@
"USD price per 1M input tokens.": "每 100 万输入 token 的美元价格。",
"USD price per 1M tokens.": "每 100 万 token 的美元价格。",
"Use +: to add a group, -: to remove a default selectable group, or no prefix to append a group.": "使用 +: 添加分组,使用 -: 移除默认可选分组,不加前缀则追加分组。",
"Use a path to append it to the channel Base URL, or enter a full URL to override the Base URL for this route.": "填写以 / 开头的路径时会自动拼接渠道 Base URL;填写完整 URL 时,此路由会直接使用该 URL。",
"Use a compatible browser or device with biometric authentication or a security key to register a Passkey.": "请使用支持生物识别认证或安全密钥的兼容浏览器或设备来注册通行密钥。",
"Use authenticator code": "使用验证器代码",
"Use backup code": "使用备用代码",
......@@ -4396,6 +4462,7 @@
"Used": "已使用",
"Used / Remaining": "已使用 / 剩余",
"Used as SuccessURL on the new product. You'll be prompted to confirm if left blank.": "用作新产品的 SuccessURL。若留空,系统会要求你确认。",
"Used by route auth templates": "用于路由认证模板",
"Used for load balancing. Higher weight = more requests": "用于负载均衡。权重越高 = 请求越多",
"Used in URLs and API routes": "用于URL和API路由",
"Used Quota": "消耗额度",
......@@ -4590,6 +4657,7 @@
"When enabled, Midjourney callbacks are accepted (reveals server IP).": "启用时,接受 Midjourney 回调 (会泄露服务器 IP)。",
"When enabled, newly created tokens start in the first auto group.": "启用后,新创建的令牌将从第一个自动分组开始。",
"When enabled, prompts are scanned before reaching upstream models.": "启用后,提示将在到达上游模型之前被扫描。",
"When enabled, requests that do not match any advanced route are forwarded to the channel base URL. When disabled, unmatched requests return an error.": "开启后,未命中任何高级路由的请求会转发到该渠道的 Base URL;关闭时,未匹配请求会返回错误。",
"When enabled, the store field will be blocked": "开启后将阻止 store 字段透传",
"When enabled, users can pick this group when creating tokens.": "启用后,用户创建令牌时可以选择该分组。",
"When enabled, violation requests will incur additional charges.": "开启后,违规请求将额外扣费。",
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment