Commit 522456c3 by ccran

feat: add CTyun support.

parent 8fc7c360
......@@ -44,3 +44,5 @@ relaykit/relayconvert/chat_responses_live_local_test.go
service/openaicompat/chat_responses_live_local_test.go
go.work
go.work.sum
outputs/
\ No newline at end of file
......@@ -79,6 +79,8 @@ func ChannelType2APIType(channelType int) (int, bool) {
apiType = constant.APITypeAdvancedCustom
case constant.ChannelTypeSub2API:
apiType = constant.APITypeSub2API
case constant.ChannelTypeCTyun:
apiType = constant.APITypeCTyun
case constant.ChannelTypeNewAPI:
apiType = constant.APITypeNewAPI
}
......
package common
import "github.com/QuantumNous/new-api/constant"
import (
"strings"
"github.com/QuantumNous/new-api/constant"
)
// GetEndpointTypesByChannelType 获取渠道最优先端点类型(所有的渠道都支持 OpenAI 端点)
func GetEndpointTypesByChannelType(channelType int, modelName string) []constant.EndpointType {
var endpointTypes []constant.EndpointType
switch channelType {
case constant.ChannelTypeCTyun:
name := strings.ToLower(modelName)
switch {
case strings.Contains(name, "rerank"):
return []constant.EndpointType{constant.EndpointTypeJinaRerank}
case strings.Contains(name, "embedding"), strings.HasPrefix(name, "bge-m3"), strings.HasPrefix(name, "bge-large"):
return []constant.EndpointType{constant.EndpointTypeEmbeddings}
case strings.Contains(name, "seedream"), strings.Contains(name, "qwen-image"), strings.HasPrefix(name, "wan") && strings.Contains(name, "image"):
return []constant.EndpointType{constant.EndpointTypeImageGeneration}
case strings.Contains(name, "seedance"), strings.HasPrefix(name, "cdance2.0"), strings.Contains(name, "minimax-h3"), strings.HasPrefix(name, "happyhorse"), strings.HasPrefix(name, "wan"):
return []constant.EndpointType{constant.EndpointTypeOpenAIVideo}
default:
endpointTypes = []constant.EndpointType{constant.EndpointTypeOpenAI}
}
case constant.ChannelTypeJina:
endpointTypes = []constant.EndpointType{constant.EndpointTypeJinaRerank}
//case constant.ChannelTypeMidjourney, constant.ChannelTypeMidjourneyPlus:
......
......@@ -39,5 +39,6 @@ const (
APITypeAdvancedCustom
APITypeSub2API
APITypeNewAPI
APITypeCTyun
APITypeDummy // this one is only for count, do not add any channel after this
)
......@@ -59,6 +59,7 @@ const (
ChannelTypeSub2API = 59
ChannelTypeNewAPI = 60
ChannelTypeTaskPlugin = 61
ChannelTypeCTyun = 62
ChannelTypeDummy // this one is only for count, do not add any channel after this
)
......@@ -126,6 +127,7 @@ var ChannelBaseURLs = []string{
"", //59
"", //60
"", //61
"https://ai.ctaigw.cn", //62
}
func GetChannelBaseURL(channelType int) string {
......@@ -194,6 +196,7 @@ var ChannelTypeNames = map[int]string{
ChannelTypeSub2API: "Sub2API",
ChannelTypeNewAPI: "New API",
ChannelTypeTaskPlugin: "Task Plugin",
ChannelTypeCTyun: "CTyun",
}
func GetChannelTypeName(channelType int) string {
......
......@@ -448,16 +448,22 @@ func fetchChannelUpstreamModelIDs(channel *model.Channel) ([]string, error) {
return nil, sanitizeAdvancedCustomRequestError(err, key, url)
}
var result OpenAIModelsResponse
var result struct {
Data []struct {
ID string `json:"id"`
} `json:"data"`
}
if err := common.Unmarshal(body, &result); err != nil {
return nil, err
}
ids := lo.Map(result.Data, func(item OpenAIModel, _ int) string {
ids := make([]string, 0, len(result.Data))
for _, item := range result.Data {
id := item.ID
if channel.Type == constant.ChannelTypeGemini {
return strings.TrimPrefix(item.ID, "models/")
id = strings.TrimPrefix(id, "models/")
}
return item.ID
})
ids = append(ids, id)
}
return normalizeModelNames(ids), nil
}
......
......@@ -604,3 +604,17 @@ func TestDetectAllChannelUpstreamModelUpdatesRejectsExistingActiveTask(t *testin
require.Contains(t, recorder.Body.String(), existing.TaskID)
require.Contains(t, recorder.Body.String(), "已有模型更新任务正在运行或等待中")
}
func TestFetchCTyunModelsIgnoresNonstandardCreatedMetadata(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/v1/models", r.URL.Path)
assert.Equal(t, "Bearer app-key", r.Header.Get("Authorization"))
_, err := w.Write([]byte(`{"data":[{"id":"qwen3","created":""},{"id":"bge","created":123},{"id":"qwen3","created":null}]}`))
assert.NoError(t, err)
}))
defer server.Close()
channel := &model.Channel{Type: constant.ChannelTypeCTyun, Key: "app-key", BaseURL: &server.URL}
models, err := fetchChannelUpstreamModelIDs(channel)
require.NoError(t, err)
assert.Equal(t, []string{"qwen3", "bge"}, models)
}
......@@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"io"
"net/http"
"strconv"
"strings"
......@@ -18,21 +19,25 @@ import (
const maxModelImportFileSize = 10 << 20
type modelImportRowResult struct {
Row int `json:"row"`
ModelName string `json:"model_name,omitempty"`
Status string `json:"status"`
Message string `json:"message,omitempty"`
Row int `json:"row"`
ModelName string `json:"model_name,omitempty"`
Status string `json:"status"`
Message string `json:"message,omitempty"`
PricingStatus string `json:"pricing_status,omitempty"`
PricingMessage string `json:"pricing_message,omitempty"`
}
type modelImportResult struct {
Created int `json:"created"`
Updated int `json:"updated"`
Skipped int `json:"skipped"`
Failed int `json:"failed"`
Rows []modelImportRowResult `json:"rows"`
PricingUpdated int `json:"pricing_updated"`
PricingFailed int `json:"pricing_failed"`
Created int `json:"created"`
Updated int `json:"updated"`
Skipped int `json:"skipped"`
Failed int `json:"failed"`
Rows []modelImportRowResult `json:"rows"`
}
// ImportModelsMeta imports model metadata from the first worksheet of an xlsx file.
// ImportModelsMeta imports model metadata and optional pricing from the first worksheet of an xlsx file.
func ImportModelsMeta(c *gin.Context) {
fileHeader, err := c.FormFile("file")
if err != nil {
......@@ -70,6 +75,9 @@ func ImportModelsMeta(c *gin.Context) {
headers := make(map[string]int)
for i, header := range rows[0] {
normalized := normalizeModelImportHeader(header)
if normalized == "" {
normalized = normalizeModelPricingImportHeader(header)
}
if normalized != "" {
headers[normalized] = i
}
......@@ -79,9 +87,18 @@ func ImportModelsMeta(c *gin.Context) {
return
}
// Reject unauthorized price writes before importing any metadata.
if c.GetInt("role") != common.RoleRootUser {
for _, row := range rows[1:] {
if hasModelImportPricing(row, headers) {
c.JSON(http.StatusForbidden, gin.H{"success": false, "message": "Model pricing is managed by a super administrator."})
return
}
}
}
overwrite := strings.EqualFold(c.Query("overwrite"), "true")
result := importModelRows(rows[1:], headers, overwrite)
if result.Created > 0 || result.Updated > 0 {
if result.Created > 0 || result.Updated > 0 || result.PricingUpdated > 0 {
model.RefreshPricing()
}
common.ApiSuccess(c, result)
......@@ -91,6 +108,7 @@ func importModelRows(rows [][]string, headers map[string]int, overwrite bool) mo
result := modelImportResult{
Rows: make([]modelImportRowResult, 0, len(rows)),
}
pricingByRow := make(map[int]modelPricingImportValues)
vendorIDByName := make(map[string]int)
for i, row := range rows {
......@@ -99,6 +117,23 @@ func importModelRows(rows [][]string, headers map[string]int, overwrite bool) mo
continue
}
if hasModelImportPricing(row, headers) {
pricing, err := buildModelPricingFromImportRow(row, headers)
if err == nil {
var nameRule int
nameRule, err = parseModelNameRule(getImportCell(row, headers, "name_rule"))
if err == nil && nameRule != model.NameRuleExact {
err = fmt.Errorf("定价仅支持精确模型名称")
}
}
if err != nil {
result.Failed++
result.Rows = append(result.Rows, modelImportRowResult{Row: rowNumber, ModelName: pricing.modelName, Status: "failed", Message: err.Error()})
continue
}
pricingByRow[rowNumber] = pricing
}
m, err := buildModelFromImportRow(row, headers, vendorIDByName)
rowResult := modelImportRowResult{Row: rowNumber, ModelName: m.ModelName}
if err != nil {
......@@ -164,9 +199,33 @@ func importModelRows(rows [][]string, headers map[string]int, overwrite bool) mo
result.Rows = append(result.Rows, rowResult)
}
for i := range result.Rows {
row := &result.Rows[i]
pricing, exists := pricingByRow[row.Row]
if !exists || (row.Status != "created" && row.Status != "updated") {
continue
}
if err := saveImportedModelPricing([]modelPricingImportValues{pricing}); err != nil {
row.PricingStatus = "failed"
row.PricingMessage = err.Error()
result.PricingFailed++
} else {
row.PricingStatus = "updated"
result.PricingUpdated++
}
}
return result
}
func hasModelImportPricing(row []string, headers map[string]int) bool {
for _, key := range []string{"billing_expr", "input_price", "completion_price", "cache_price", "create_cache_price", "image_price", "audio_input_price", "audio_output_price", "fixed_price"} {
if getImportCell(row, headers, key) != "" {
return true
}
}
return false
}
func buildModelFromImportRow(row []string, headers map[string]int, vendorIDByName map[string]int) (model.Model, error) {
m := model.Model{
Status: 1,
......
......@@ -24,12 +24,14 @@ type modelPricingImportResult struct {
Updated int `json:"updated"`
PerToken int `json:"per_token"`
PerRequest int `json:"per_request"`
TieredExpr int `json:"tiered_expr"`
Failed int `json:"failed"`
Rows []modelPricingImportRowResult `json:"rows"`
}
type modelPricingImportValues struct {
modelName string
billingExpr string
inputPrice *float64
completionPrice *float64
cachePrice *float64
......@@ -129,7 +131,10 @@ func parseModelPricingImportRows(rows [][]string, headers map[string]int) (model
continue
}
if item.fixedPrice != nil {
if item.billingExpr != "" {
rowResult.Mode = "tiered_expr"
result.TieredExpr++
} else if item.fixedPrice != nil {
rowResult.Mode = "per_request"
result.PerRequest++
} else {
......@@ -152,6 +157,18 @@ func buildModelPricingFromImportRow(row []string, headers map[string]int) (model
if item.modelName == "" {
return item, fmt.Errorf("模型名称不能为空")
}
item.billingExpr = getImportCell(row, headers, "billing_expr")
if item.billingExpr != "" {
for _, key := range []string{"input_price", "completion_price", "cache_price", "create_cache_price", "image_price", "audio_input_price", "audio_output_price", "fixed_price"} {
if getImportCell(row, headers, key) != "" {
return item, fmt.Errorf("billing_expr 不能与 %s 同时填写", key)
}
}
return item, model.ValidateModelPricing(item.modelName, model.PricingValues{
"billing_setting.billing_mode": "tiered_expr",
"billing_setting.billing_expr": item.billingExpr,
})
}
var err error
item.inputPrice, err = parseOptionalImportPrice(row, headers, "input_price")
......@@ -238,13 +255,15 @@ func normalizeModelPricingImportHeader(header string) string {
key = strings.ReplaceAll(key, "-", "_")
key = strings.ReplaceAll(key, "/", "_")
switch key {
case "billing_expr", "billingexpr", "计费表达式", "表达式":
return "billing_expr"
case "model_name", "modelname", "模型名称", "模型名", "名称":
return "model_name"
case "input_price", "inputprice", "prompt_price", "promptprice", "按量计费", "输入价格", "提示价格":
return "input_price"
case "completion_price", "completionprice", "output_price", "outputprice", "输出价格", "补全价格":
return "completion_price"
case "cache_price", "cacheprice", "cache_read_price", "缓存价格", "缓存命中价格":
case "cache_price", "cacheprice", "cache_read_price", "缓存价格", "缓存命中价格", "缓存价格(元_百万token)":
return "cache_price"
case "create_cache_price", "createcacheprice", "cache_write_price", "缓存创建价格", "缓存写入价格":
return "create_cache_price"
......@@ -285,8 +304,13 @@ func saveImportedModelPricing(values []modelPricingImportValues) error {
imageRatioMap := make(map[string]float64)
audioRatioMap := make(map[string]float64)
audioCompletionRatioMap := make(map[string]float64)
billingExpressions := make(map[string]string)
for _, item := range values {
billingExpressions[item.modelName] = item.billingExpr
if item.billingExpr != "" {
continue
}
if item.fixedPrice != nil {
modelPriceMap[item.modelName] = *item.fixedPrice
delete(modelRatioMap, item.modelName)
......@@ -335,6 +359,12 @@ func saveImportedModelPricing(values []modelPricingImportValues) error {
pricing[key] = value
}
}
if expression := billingExpressions[entry.ModelName]; expression != "" {
pricing = model.PricingValues{
"billing_setting.billing_mode": "tiered_expr",
"billing_setting.billing_expr": expression,
}
}
changes = append(changes, model.ModelPricingChange{
ModelName: entry.ModelName,
ExpectedVersion: entry.Version,
......
package controller
import (
"bytes"
"math"
"mime/multipart"
"net/http"
"net/http/httptest"
"os"
"testing"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/pkg/billingexpr"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/xuri/excelize/v2"
)
func TestImportExpressionPricing(t *testing.T) {
expression := `len < 32768 ? tier("short", p * 2.2784 + c * 11.392 + cr * 0.45568) : len < 131072 ? tier("medium", p * 3.4176 + c * 17.088 + cr * 0.68352) : tier("long", p * 6.8352 + c * 34.176 + cr * 1.36704)`
headers := map[string]int{"model_name": 0, "billing_expr": 1, "input_price": 2}
result, items, err := parseModelPricingImportRows([][]string{
{"doubao", expression}, {"invalid", "p *"}, {"negative", "-1"}, {"conflict", expression, "0"},
}, headers)
require.NoError(t, err)
assert.Equal(t, 1, result.TieredExpr)
assert.Equal(t, 1, result.Updated)
assert.Equal(t, 3, result.Failed)
require.Len(t, items, 1)
assert.Equal(t, "tiered_expr", result.Rows[0].Mode)
assert.Equal(t, "billing_expr", normalizeModelPricingImportHeader("计费表达式"))
assert.Equal(t, "cache_price", normalizeModelPricingImportHeader("缓存价格(元/百万token)"))
for _, tc := range []struct{ length, input, output, cache float64 }{
{32767, 2.2784, 11.392, 0.45568}, {32768, 3.4176, 17.088, 0.68352},
{131071, 3.4176, 17.088, 0.68352}, {131072, 6.8352, 34.176, 1.36704},
} {
cost, _, err := billingexpr.RunExpr(items[0].billingExpr, billingexpr.TokenParams{Len: tc.length, P: 10, CR: tc.length - 10, C: 100})
require.NoError(t, err)
assert.InDelta(t, 10*tc.input+100*tc.output+(tc.length-10)*tc.cache, cost, 1e-8)
}
for _, dialect := range []struct{ kind, env string }{{"sqlite", ""}, {"mysql", "TEST_MYSQL_DSN"}, {"postgres", "TEST_POSTGRES_DSN"}} {
t.Run(dialect.kind, func(t *testing.T) {
dsn := os.Getenv(dialect.env)
if dialect.env != "" && dsn == "" {
t.Skip("set " + dialect.env + " to run database verification")
}
modelManagementDB(t, dialect.kind, dsn)
fixed := 0.5
require.NoError(t, saveImportedModelPricing([]modelPricingImportValues{{modelName: "doubao", fixedPrice: &fixed}}))
for range 2 {
require.NoError(t, saveImportedModelPricing(items))
}
snapshot, err := model.GetModelPricingSnapshot([]string{"doubao"})
require.NoError(t, err)
assert.Equal(t, model.PricingValues{"billing_setting.billing_mode": "tiered_expr", "billing_setting.billing_expr": expression}, snapshot.Entries[0].Configured)
require.NoError(t, saveImportedModelPricing([]modelPricingImportValues{items[0], {modelName: "doubao", fixedPrice: &fixed}}))
snapshot, err = model.GetModelPricingSnapshot([]string{"doubao"})
require.NoError(t, err)
assert.Equal(t, model.PricingValues{"billing_setting.billing_mode": "ratio", "ModelPrice": fixed}, snapshot.Entries[0].Configured)
})
}
}
func TestImportedPricingUsesModelPricingTransactions(t *testing.T) {
modelManagementDB(t, "sqlite", "")
require.NoError(t, model.UpdateModelPricingOptions(map[string]string{
......@@ -58,3 +112,111 @@ func TestImportPriceRejectsNonFiniteValues(t *testing.T) {
require.NotNil(t, price)
assert.Equal(t, 0.0, *price)
}
func TestModelMetadataExcelPricing(t *testing.T) {
for _, dialect := range []struct{ kind, env string }{{"sqlite", ""}, {"mysql", "TEST_MYSQL_DSN"}, {"postgres", "TEST_POSTGRES_DSN"}} {
t.Run(dialect.kind, func(t *testing.T) {
dsn := os.Getenv(dialect.env)
if dialect.env != "" && dsn == "" {
t.Skip("set " + dialect.env)
}
db := modelManagementDB(t, dialect.kind, dsn)
workbook := excelize.NewFile()
defer workbook.Close()
rows := [][]any{
{"模型名称", "description", "输入价格", "输出价格", "fixed_price", "billing_expr", "name_rule"},
{"excel-token", "token", 2, 6},
{"excel-fixed", "fixed", "", "", 0},
{"excel-expression", "expression", "", "", "", "p * 3 + c * 9"},
{"excel-metadata", "metadata"},
{"excel-invalid", "invalid", -1},
{"excel-pattern", "pattern", 2, "", "", "", "prefix"},
}
for i, row := range rows {
cell, err := excelize.CoordinatesToCellName(1, i+1)
require.NoError(t, err)
require.NoError(t, workbook.SetSheetRow("Sheet1", cell, &row))
}
payload, err := workbook.WriteToBuffer()
require.NoError(t, err)
// The same upload must not let metadata administrators write root-only prices.
for _, tc := range []struct {
role int
overwrite bool
created, updated, skipped, priced int
}{
{common.RoleAdminUser, false, 0, 0, 0, 0},
{common.RoleRootUser, false, 4, 0, 0, 3},
{common.RoleRootUser, false, 0, 0, 4, 0},
{common.RoleRootUser, true, 0, 4, 0, 3},
} {
var body bytes.Buffer
writer := multipart.NewWriter(&body)
file, err := writer.CreateFormFile("file", "models.xlsx")
require.NoError(t, err)
_, err = file.Write(payload.Bytes())
require.NoError(t, err)
require.NoError(t, writer.Close())
recorder := httptest.NewRecorder()
context, _ := gin.CreateTestContext(recorder)
path := "/api/models/import"
if tc.overwrite {
path += "?overwrite=true"
}
context.Request = httptest.NewRequest(http.MethodPost, path, &body)
context.Request.Header.Set("Content-Type", writer.FormDataContentType())
context.Set("role", tc.role)
ImportModelsMeta(context)
if tc.role == common.RoleAdminUser {
assert.Equal(t, http.StatusForbidden, recorder.Code)
var count int64
require.NoError(t, db.Model(&model.Model{}).Count(&count).Error)
assert.Zero(t, count)
continue
}
var response struct {
Success bool
Data modelImportResult
}
require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response))
require.True(t, response.Success, recorder.Body.String())
assert.Equal(t, tc.created, response.Data.Created)
assert.Equal(t, tc.updated, response.Data.Updated)
assert.Equal(t, tc.skipped, response.Data.Skipped)
assert.Equal(t, tc.priced, response.Data.PricingUpdated)
assert.Equal(t, 2, response.Data.Failed)
assert.Zero(t, response.Data.PricingFailed)
}
snapshot, err := model.GetModelPricingSnapshot([]string{"excel-token", "excel-fixed", "excel-expression", "excel-metadata"})
require.NoError(t, err)
entries := map[string]model.PricingValues{}
for _, entry := range snapshot.Entries {
entries[entry.ModelName] = entry.Configured
}
assert.Equal(t, 1.0, entries["excel-token"]["ModelRatio"])
assert.Equal(t, 3.0, entries["excel-token"]["CompletionRatio"])
assert.Equal(t, 0.0, entries["excel-fixed"]["ModelPrice"])
assert.Equal(t, "p * 3 + c * 9", entries["excel-expression"]["billing_setting.billing_expr"])
assert.Empty(t, entries["excel-metadata"])
headers := map[string]int{"model_name": 0, "input_price": 1}
result := importModelRows([][]string{{"excel-token", ""}}, headers, true)
assert.Equal(t, 1, result.Updated)
assert.Zero(t, result.PricingUpdated)
snapshot, err = model.GetModelPricingSnapshot([]string{"excel-token"})
require.NoError(t, err)
assert.Equal(t, entries["excel-token"], snapshot.Entries[0].Configured)
// Metadata success must not hide a subsequent pricing save failure.
headers["completion_price"] = 2
result = importModelRows([][]string{{"excel-invalid-ratio", "1e-300", "1e300"}}, headers, false)
assert.Equal(t, 1, result.Created)
assert.Zero(t, result.PricingUpdated)
assert.Equal(t, 1, result.PricingFailed)
require.Len(t, result.Rows, 1)
assert.Equal(t, "created", result.Rows[0].Status)
assert.Equal(t, "failed", result.Rows[0].PricingStatus)
assert.NotEmpty(t, result.Rows[0].PricingMessage)
})
}
}
......@@ -18,6 +18,7 @@ import (
pluginruntime "github.com/QuantumNous/new-api/pkg/jsplugin"
perfmetrics "github.com/QuantumNous/new-api/pkg/perf_metrics"
"github.com/QuantumNous/new-api/relay"
"github.com/QuantumNous/new-api/relay/channel/ctyun"
"github.com/QuantumNous/new-api/relay/channel/task/taskcommon"
relaycommon "github.com/QuantumNous/new-api/relay/common"
relayconstant "github.com/QuantumNous/new-api/relay/constant"
......@@ -128,6 +129,15 @@ func Relay(c *gin.Context, relayFormat types.RelayFormat) {
return
}
if common.GetContextKeyInt(c, constant.ContextKeyChannelType) == constant.ChannelTypeCTyun {
if image, ok := request.(*dto.ImageRequest); ok {
if err := ctyun.NormalizeImageCount(image); err != nil {
newAPIError = types.NewError(err, types.ErrorCodeInvalidRequest, types.ErrOptionWithStatusCode(http.StatusBadRequest), types.ErrOptionWithSkipRetry())
return
}
}
}
needSensitiveCheck := setting.ShouldCheckPromptSensitive()
needCountToken := constant.CountToken
// Avoid building huge CombineText (strings.Join) when token counting and sensitive check are both disabled.
......
......@@ -18,6 +18,7 @@ import (
)
type Pricing struct {
APIExamples []PricingAPIExample `json:"api_examples,omitempty"`
ModelName string `json:"model_name"`
Description string `json:"description,omitempty"`
Icon string `json:"icon,omitempty"`
......@@ -233,17 +234,14 @@ func updatePricing() {
//这里使用切片而不是Set,因为一个模型可能支持多个端点类型,并且第一个端点是优先使用端点
modelSupportEndpointsStr := make(map[string][]string)
advancedCustomConfigs := loadPricingAdvancedCustomConfigs(enableAbilities)
apiExamples := buildPricingAPIExamples(enableAbilities, advancedCustomConfigs)
// 先根据已有能力填充原生端点
for _, ability := range enableAbilities {
endpoints := modelSupportEndpointsStr[ability.Model]
channelTypes := getPricingEndpointTypesForAbility(ability, advancedCustomConfigs)
for _, channelType := range channelTypes {
if !common.StringsContains(endpoints, string(channelType)) {
endpoints = append(endpoints, string(channelType))
}
// Derive both the endpoint list and the example dialects from the same
// enabled abilities, including mapped aliases and custom-channel routes.
for modelName, examples := range apiExamples {
for _, example := range examples {
modelSupportEndpointsStr[modelName] = appendPricingEndpoint(modelSupportEndpointsStr[modelName], string(example.Endpoint))
}
modelSupportEndpointsStr[ability.Model] = endpoints
}
// 再补充模型自定义端点:若配置有效则追加到已有推断,不再裁剪渠道真实能力
......@@ -320,6 +318,7 @@ func updatePricing() {
for model, groups := range modelGroupsMap {
pricing := Pricing{
ModelName: model,
APIExamples: apiExamples[model],
EnableGroup: groups.Items(),
SupportedEndpointTypes: modelSupportEndpointTypes[model],
}
......
package model
import (
"slices"
"strings"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/relaykit/dto"
)
// PricingAPIExample describes a downstream request dialect, never a channel
// credential, channel ID, upstream URL or private mapped model ID.
type PricingAPIExample struct {
Endpoint constant.EndpointType `json:"endpoint"`
Profile string `json:"profile"`
Providers []string `json:"providers"`
Groups []string `json:"groups"`
}
func pricingAPIExampleProfile(channelType int, original, upstream string, endpoint constant.EndpointType) string {
name := strings.ToLower(upstream)
origin := strings.ToLower(original)
if channelType == constant.ChannelTypeDoubaoVideo && endpoint == constant.EndpointTypeOpenAIVideo {
if !strings.Contains(name, "seedance") {
name = origin
}
if strings.Contains(name, "seedance") {
if strings.Contains(name, "2.0") {
return "seedance2"
}
return "seedance"
}
}
if channelType != constant.ChannelTypeCTyun {
return "standard"
}
switch endpoint {
case constant.EndpointTypeOpenAIVideo:
// Match the task plugin's upstream-first family resolution.
if !strings.Contains(name, "seedance") && !strings.HasPrefix(name, "cdance2.0") && !strings.Contains(name, "minimax-h3") && !strings.HasPrefix(name, "wan") && !strings.HasPrefix(name, "happyhorse") {
name = origin
}
switch {
case strings.Contains(name, "minimax-h3"):
return "ctyun-h3"
case strings.Contains(name, "seedance"), strings.HasPrefix(name, "cdance2.0"):
if strings.Contains(name, "2.0") {
return "seedance2"
}
return "seedance"
case strings.HasPrefix(name, "wan3.0"):
return "ctyun-wan3"
case strings.HasPrefix(name, "wan"), strings.HasPrefix(name, "happyhorse"):
if strings.HasSuffix(name, "-r2v") {
if strings.HasPrefix(name, "happyhorse") {
return "ctyun-happyhorse-reference"
}
if strings.HasPrefix(name, "wan2.7") {
return "ctyun-wan-reference"
}
return "standard"
}
if strings.HasSuffix(name, "-i2v") {
return "ctyun-wan-image"
}
return "ctyun-wan"
}
case constant.EndpointTypeImageGeneration, constant.EndpointTypeJinaRerank, constant.EndpointTypeEmbeddings:
// Match the synchronous adapter's fallback for opaque upstream IDs.
if !strings.Contains(name, "text-embedding") && !strings.Contains(name, "qwen") && !strings.Contains(name, "gte") && !strings.Contains(name, "wan") && !strings.Contains(name, "seedream") {
name = origin
}
switch endpoint {
case constant.EndpointTypeImageGeneration:
if strings.Contains(name, "seedream") {
if strings.Contains(name, "5.0-pro") {
return "ctyun-seedream-pro"
}
return "ctyun-seedream"
}
if strings.Contains(name, "qwen") || strings.Contains(name, "wan") {
if strings.Contains(name, "edit") {
return "ctyun-message-image-edit"
}
return "ctyun-message-image"
}
case constant.EndpointTypeJinaRerank:
if strings.Contains(name, "qwen") {
return "ctyun-qwen-rerank"
}
if strings.Contains(name, "gte") {
return "ctyun-gte-rerank"
}
case constant.EndpointTypeEmbeddings:
if strings.Contains(name, "vl-embedding") {
return "ctyun-vl-embedding"
}
}
}
return "standard"
}
func buildPricingAPIExamples(abilities []AbilityWithChannel, configs map[int]*dto.AdvancedCustomConfig) map[string][]PricingAPIExample {
result := make(map[string][]PricingAPIExample)
// CacheGetChannel also supports deployments with the memory cache disabled.
// Snapshot each mapping once; do not return channel objects to API callers.
mappings := make(map[int]map[string]string)
for _, ability := range abilities {
if _, loaded := mappings[ability.ChannelId]; !loaded {
var mapping map[string]string
if ability.ChannelType == constant.ChannelTypeCTyun || ability.ChannelType == constant.ChannelTypeDoubaoVideo {
if channel, err := CacheGetChannel(ability.ChannelId); err == nil {
_ = common.UnmarshalJsonStr(channel.GetModelMapping(), &mapping)
}
}
mappings[ability.ChannelId] = mapping
}
upstream, cycle := followChannelModelMapping(mappings[ability.ChannelId], ability.Model)
if cycle {
continue
}
endpoints := getPricingEndpointTypesForAbility(ability, configs)
if ability.ChannelType == constant.ChannelTypeDoubaoVideo && (strings.Contains(strings.ToLower(upstream), "seedance") || strings.Contains(strings.ToLower(ability.Model), "seedance")) {
endpoints = []constant.EndpointType{constant.EndpointTypeOpenAIVideo}
}
// Model aliases may not contain a modality name. Resolve the mapped name
// for display, while keeping the original name in every client request.
if ability.ChannelType == constant.ChannelTypeCTyun && upstream != ability.Model {
mappedEndpoints := common.GetEndpointTypesByChannelType(ability.ChannelType, upstream)
if !slices.Equal(mappedEndpoints, []constant.EndpointType{constant.EndpointTypeOpenAI}) {
endpoints = mappedEndpoints
}
}
for _, endpoint := range endpoints {
profile := pricingAPIExampleProfile(ability.ChannelType, ability.Model, upstream, endpoint)
examples := result[ability.Model]
index := slices.IndexFunc(examples, func(example PricingAPIExample) bool {
return example.Endpoint == endpoint && example.Profile == profile
})
if index < 0 {
examples = append(examples, PricingAPIExample{Endpoint: endpoint, Profile: profile})
index = len(examples) - 1
}
provider := constant.GetChannelTypeName(ability.ChannelType)
if !slices.Contains(examples[index].Providers, provider) {
examples[index].Providers = append(examples[index].Providers, provider)
}
if !slices.Contains(examples[index].Groups, ability.Group) {
examples[index].Groups = append(examples[index].Groups, ability.Group)
}
result[ability.Model] = examples
}
}
for _, examples := range result {
for i := range examples {
slices.Sort(examples[i].Providers)
slices.Sort(examples[i].Groups)
}
}
return result
}
......@@ -292,3 +292,111 @@ func TestCacheUpdateChannelSyncsAdvancedCustomConfig(t *testing.T) {
assert.Nil(t, channel2advancedCustomConfig[401])
}
func TestPricingCTyunEndpointTypesFollowEnabledChannels(t *testing.T) {
resetPricingEndpointTestTables(t)
insertPricingEndpointChannel(t, 701, constant.ChannelTypeCTyun, dto.ChannelOtherSettings{})
insertPricingEndpointChannel(t, 702, constant.ChannelTypeOpenAI, dto.ChannelOtherSettings{})
for _, name := range []string{"minimax-h3", "cdance2.0-0807", "qwen3-vl-embedding", "bge-m3", "qwen3-rerank", "qwen-image", "shared-chat"} {
insertPricingEndpointAbility(t, 701, name)
}
insertPricingEndpointAbility(t, 702, "shared-chat")
insertPricingEndpointAbility(t, 702, "ordinary-chat")
InitChannelCache()
pricing := make(map[string]Pricing)
for _, item := range GetPricing() {
pricing[item.ModelName] = item
}
for name, endpoint := range map[string]constant.EndpointType{
"minimax-h3": constant.EndpointTypeOpenAIVideo,
"cdance2.0-0807": constant.EndpointTypeOpenAIVideo,
"qwen3-vl-embedding": constant.EndpointTypeEmbeddings,
"bge-m3": constant.EndpointTypeEmbeddings,
"qwen3-rerank": constant.EndpointTypeJinaRerank,
"qwen-image": constant.EndpointTypeImageGeneration,
} {
assert.Equal(t, []constant.EndpointType{endpoint}, pricing[name].SupportedEndpointTypes, name)
}
}
func TestPricingAPIExamplesResolveMappingsAndMergeDialects(t *testing.T) {
resetPricingEndpointTestTables(t)
for _, id := range []int{801, 802} {
insertPricingEndpointChannel(t, id, constant.ChannelTypeCTyun, dto.ChannelOtherSettings{})
}
insertPricingEndpointChannel(t, 803, constant.ChannelTypeOpenAI, dto.ChannelOtherSettings{})
mapping := `{"public-video":"intermediate","intermediate":"minimax-h3","minimax-h3":"minimax-h3","shared-seedance":"doubao-seedance-2.0","public-image":"qwen-image-edit","public-cdance":"cdance2.0-fast-0807"}`
require.NoError(t, DB.Model(&Channel{}).Where("id IN ?", []int{801, 802}).Update("model_mapping", mapping).Error)
insertPricingEndpointChannel(t, 804, constant.ChannelTypeDoubaoVideo, dto.ChannelOtherSettings{})
require.NoError(t, DB.Model(&Channel{}).Where("id = ?", 804).Update("model_mapping", mapping).Error)
for _, id := range []int{801, 802, 804} {
insertPricingEndpointAbility(t, id, "shared-seedance")
}
insertPricingEndpointAbility(t, 801, "public-image")
insertPricingEndpointAbility(t, 801, "public-cdance")
for _, id := range []int{801, 802, 803} {
insertPricingEndpointAbility(t, id, "public-video")
}
InitChannelCache()
for _, memoryCache := range []bool{true, false} {
common.MemoryCacheEnabled = memoryCache
InvalidatePricingCache()
var item Pricing
for _, p := range GetPricing() {
if p.ModelName == "public-video" {
item = p
}
if p.ModelName == "shared-seedance" {
require.Len(t, p.APIExamples, 1)
assert.Equal(t, "seedance2", p.APIExamples[0].Profile)
assert.Equal(t, []string{"CTyun", "DoubaoVideo"}, p.APIExamples[0].Providers)
}
if p.ModelName == "public-cdance" {
require.Len(t, p.APIExamples, 1)
assert.Equal(t, "seedance2", p.APIExamples[0].Profile)
assert.Equal(t, []constant.EndpointType{constant.EndpointTypeOpenAIVideo}, p.SupportedEndpointTypes)
}
if p.ModelName == "public-image" {
require.Len(t, p.APIExamples, 1)
assert.Equal(t, "ctyun-message-image-edit", p.APIExamples[0].Profile)
assert.Equal(t, []constant.EndpointType{constant.EndpointTypeImageGeneration}, p.SupportedEndpointTypes)
}
}
require.NotEmpty(t, item.APIExamples)
var videoExamples []PricingAPIExample
for _, e := range item.APIExamples {
if e.Endpoint == constant.EndpointTypeOpenAIVideo {
videoExamples = append(videoExamples, e)
}
}
require.Len(t, videoExamples, 1)
assert.Equal(t, "ctyun-h3", videoExamples[0].Profile)
assert.Equal(t, []string{"CTyun"}, videoExamples[0].Providers)
assert.Equal(t, []string{"default"}, videoExamples[0].Groups)
raw, err := common.Marshal(item.APIExamples)
require.NoError(t, err)
assert.NotContains(t, string(raw), "intermediate")
assert.NotContains(t, string(raw), "key-801")
}
common.MemoryCacheEnabled = true
}
func TestPricingAPIExampleProfilesRespectReferenceCapabilities(t *testing.T) {
for _, tc := range []struct {
model string
profile string
}{
{"minimax-h3", "ctyun-h3"},
{"cdance2.0-0807", "seedance2"},
{"cdance2.0-0813", "seedance2"},
{"cdance2.0-fast-0807", "seedance2"},
{"CDANCE2.0-mini-0807", "seedance2"},
{"wan3.0-video", "ctyun-wan3"},
{"wan2.7-r2v", "ctyun-wan-reference"},
{"happyhorse-1.1-r2v", "ctyun-happyhorse-reference"},
} {
assert.Equal(t, tc.profile, pricingAPIExampleProfile(constant.ChannelTypeCTyun, tc.model, tc.model, constant.EndpointTypeOpenAIVideo))
}
assert.Equal(t, "seedance2", pricingAPIExampleProfile(constant.ChannelTypeCTyun, "cdance2.0-0807", "opaque-id", constant.EndpointTypeOpenAIVideo))
assert.Equal(t, pricingAPIExampleProfile(constant.ChannelTypeCTyun, "doubao-seedance-2.0", "doubao-seedance-2.0", constant.EndpointTypeOpenAIVideo), pricingAPIExampleProfile(constant.ChannelTypeDoubaoVideo, "doubao-seedance-2.0", "doubao-seedance-2.0", constant.EndpointTypeOpenAIVideo))
}
......@@ -10,7 +10,7 @@ import (
"github.com/stretchr/testify/require"
)
var expectedKeys = []string{"alibaba", "doubao", "google", "hailuo", "jimeng", "kling", "sora", "sunoapi", "vertex-ai", "vidu"}
var expectedKeys = []string{"alibaba", "ctyun", "doubao", "google", "hailuo", "jimeng", "kling", "sora", "sunoapi", "vertex-ai", "vidu"}
func TestBuiltInVendorPluginsDeclareNativeRoutesAndLegacyChannelTypes(t *testing.T) {
generation := jsplugin.DefaultRegistry.Generation()
......
// CTyun TokenHub task protocols. Persistence, reservations, polling and
// idempotent settlement are owned by the host task runtime.
export const meta = {
apiVersion: 1,
key: "ctyun",
name: "CTyun Video",
description: { en: "CTyun TokenHub Seedance, MiniMax H3, Wan and HappyHorse video", zh: "天翼云 TokenHub Seedance、MiniMax H3、万相和 HappyHorse 视频" },
version: "1.0.1",
author: { name: "QuantumNous" },
channelTypes: [62],
models: [
"cdance2.0-fast-0807",
"cdance2.0-mini-0807",
"cdance2.0-0807",
"cdance2.0-0813",
"doubao-seedance-2.0",
"doubao-seedance-1.5-pro",
"MiniMax-H3",
"MiniMax-H3-Max",
"wan3.0-video",
"wan3.0-video-prime",
"wan2.7-t2v",
"wan2.7-i2v",
"wan2.7-r2v",
"happyhorse-1.0-t2v",
"happyhorse-1.1-t2v",
"happyhorse-1.1-i2v",
"happyhorse-1.1-r2v",
],
fetchMode: "per_task",
usageExamples: [
{ label: "Seedance 720P 5s", facts: { tokens: 108000, seconds: 5, input_video_seconds: 0, input_audio_seconds: 0, input_images: 0, resolution: "720P", video_input: "none" } },
],
protocols: [{ name: "openai_responses", supports: ["stream", "sync", "background"] }, "openai_video"],
usageSchema: {
tokens: { type: "number", unit: "token", description: { en: "Seedance billing tokens", zh: "Seedance 计费 Token" } },
seconds: { type: "number", unit: "second", description: { en: "Output video duration", zh: "输出视频秒数" } },
input_video_seconds: { type: "number", unit: "second", description: { en: "Input video duration", zh: "输入视频秒数" } },
input_audio_seconds: { type: "number", unit: "second", description: { en: "Input audio duration", zh: "输入音频秒数" } },
input_images: { type: "number", unit: "count", description: { en: "Input image count", zh: "输入图片数量" } },
resolution: { enum: ["480P", "720P", "768P", "1080P", "2K"], description: { en: "Output resolution", zh: "输出分辨率" } },
video_input: { enum: ["none", "video"], description: { en: "Reference video input", zh: "参考视频输入" } },
},
};
function family(ctx) {
for (const name of [ctx.upstreamModel, ctx.model]) {
const model = String(name || "").toLowerCase();
if (model.includes("seedance") || model.startsWith("cdance2.0")) return "seedance";
if (model.includes("minimax-h3")) return "h3";
if (model.startsWith("wan") || model.startsWith("happyhorse")) return "wan";
}
throw new Error("unsupported CTyun video model; use a model family name before mapping to an opaque model ID");
}
function object(value, name) {
if (value === undefined) return {};
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(name + " must be an object");
return value;
}
function videoRequest(ctx) {
const req = object(ctx.requestBody, "request");
const metadata = object(req.metadata, "metadata");
const kind = family(ctx);
const modelName = String(ctx.model || req.model || "").toLowerCase();
const input = Object.assign({}, object(metadata.input, "metadata.input"), object(req.input, "input"));
const parameters = Object.assign({}, object(metadata.parameters, "metadata.parameters"), object(req.parameters, "parameters"));
const body = Object.assign({}, metadata);
delete body.input;
delete body.parameters;
body.model = ctx.upstreamModel || req.model;
const prompt = req.prompt === undefined ? input.prompt : req.prompt;
const images = Array.isArray(req.images) ? req.images.slice() : [];
for (const image of [req.image, req.input_reference]) if (image !== undefined && image !== "") images.push(image);
if (images.some((image) => typeof image !== "string" || !image)) throw new Error("image references must be URL strings");
let seconds = req.seconds;
if (seconds === undefined) seconds = req.duration;
if (seconds === undefined) seconds = kind === "wan" ? parameters.duration : body.duration;
if (seconds === undefined) seconds = 5;
seconds = Number(seconds);
const minimum = kind === "h3" ? 4 : modelName.startsWith("happyhorse") ? 3 : 2;
const maximum = modelName.startsWith("wan3.0") ? 30 : 15;
if (!Number.isInteger(seconds) || seconds < minimum || seconds > maximum)
throw new Error("duration must be an integer between " + minimum + " and " + maximum);
// Validate every alternative duration, including metadata fields that would
// otherwise be hidden by a valid top-level seconds value.
for (const value of [req.seconds, req.duration, metadata.duration, parameters.duration]) {
if (value !== undefined && (!Number.isInteger(Number(value)) || Number(value) < minimum || Number(value) > maximum))
throw new Error("invalid video duration");
}
let resolution = req.resolution || req.size || parameters.resolution || body.resolution || (kind === "h3" ? "768P" : "1080P");
resolution = String(resolution).toUpperCase();
const resolutions = kind === "h3" ? ["480P", "768P", "2K"] : ["480P", "720P", "1080P"];
if (!resolutions.includes(resolution)) throw new Error("unsupported video resolution");
const seed = parameters.seed === undefined ? body.seed : parameters.seed;
if (seed !== undefined && (!Number.isInteger(Number(seed)) || Number(seed) < 0 || Number(seed) > 2147483647))
throw new Error("seed must be between 0 and 2147483647");
if (kind === "wan") {
if (prompt !== undefined) input.prompt = prompt;
if (images.length) {
if (modelName.startsWith("wan3.0")) {
input.media = images.map((url, index) => ({ type: index === 0 ? "first_frame" : "last_frame", url: url }));
if (images.length > 2) throw new Error("at most two frame images are supported; use input.media for reference inputs");
} else {
if (images.length > 2) throw new Error("at most two frame images are supported");
input.img_url = images[0];
if (images.length === 2) input.last_frame_url = images[1];
}
}
if (!String(input.prompt || "").trim() && (!Array.isArray(input.media) || !input.media.length)) throw new Error("prompt or input.media is required");
if (input.media !== undefined) {
if (!Array.isArray(input.media)) throw new Error("input.media must be an array");
const counts = {};
const limits = { first_frame: 1, last_frame: 1, reference_image: 10, reference_video: 5, reference_audio: 5, file: 1, link: 1 };
for (const item of input.media) {
if (!item || !limits[item.type] || typeof item.url !== "string" || !item.url) throw new Error("invalid media input");
counts[item.type] = (counts[item.type] || 0) + 1;
if (counts[item.type] > limits[item.type]) throw new Error("too many " + item.type + " inputs");
}
if (counts.last_frame && !counts.first_frame) throw new Error("last_frame requires first_frame");
if ((counts.first_frame || counts.last_frame) && (counts.reference_image || counts.reference_video || counts.reference_audio))
throw new Error("frame and reference media cannot be mixed");
if (counts.file && counts.link) throw new Error("file and link inputs cannot be mixed");
}
parameters.duration = seconds;
parameters.resolution = resolution;
return { model: body.model, input: input, parameters: parameters };
}
const rawContent = req.content === undefined ? metadata.content : req.content;
if (rawContent !== undefined && !Array.isArray(rawContent)) throw new Error("content must be an array");
const content = (rawContent || []).slice();
if (String(prompt || "").trim()) content.push({ type: "text", text: prompt });
for (let i = 0; i < images.length; i++) content.push({ type: "image_url", image_url: { url: images[i] }, role: i === 0 ? "first_frame" : "last_frame" });
const counts = { image_url: 0, video_url: 0, audio_url: 0, text: 0 };
let hasFrames = false;
let hasReferences = false;
let firstFrames = 0;
let lastFrames = 0;
for (const item of content) {
if (!item || !Object.prototype.hasOwnProperty.call(counts, item.type)) throw new Error("invalid content type");
counts[item.type]++;
if (item.type === "text") {
if (typeof item.text !== "string" || !item.text.trim()) throw new Error("text must not be empty");
} else {
if (!item[item.type] || typeof item[item.type].url !== "string" || !item[item.type].url) throw new Error("media URL is required");
if (item.role === "first_frame") {
firstFrames++;
hasFrames = true;
} else if (item.role === "last_frame") {
lastFrames++;
hasFrames = true;
} else hasReferences = true;
}
}
if (!counts.text) throw new Error("a text prompt is required");
if (firstFrames > 1 || lastFrames > 1 || (lastFrames && !firstFrames)) throw new Error("invalid first/last frame combination");
if (hasFrames && hasReferences) throw new Error("frame and reference inputs cannot be mixed");
if (counts.image_url > 9 || counts.video_url > 3 || counts.audio_url > 3) throw new Error("too many media references");
body.content = content;
body.duration = seconds;
body.resolution = kind === "seedance" ? resolution.toLowerCase() : resolution;
if (req.ratio !== undefined) body.ratio = req.ratio;
if (kind === "h3") {
body.ratio = hasFrames ? "adaptive" : body.ratio || "16:9";
if (!["adaptive", "21:9", "16:9", "4:3", "1:1", "3:4", "9:16"].includes(body.ratio)) throw new Error("unsupported H3 ratio");
if (!hasReferences && !hasFrames && body.ratio === "adaptive") throw new Error("text-to-video requires an explicit ratio");
}
return body;
}
export function buildSubmitRequest(ctx) {
const body = videoRequest(ctx);
const kind = family(ctx);
let path = "/v1/contents/generations/tasks";
if (kind === "h3") path = "/v1/video_generation";
if (kind === "wan") path = "/v1/services/aigc/video-generation/video-synthesis";
const headers = { "Content-Type": "application/json", Authorization: "Bearer " + ctx.apiKey };
if (kind === "wan") headers["X-DashScope-Async"] = "enable";
return { url: String(ctx.baseUrl).replace(/\/$/, "") + path, method: "POST", headers: headers, body: body };
}
export function parseSubmitResponse(ctx, resp) {
const body = resp.body || {};
if (body.error || (body.code && String(body.code) !== "0")) throw new Error((body.error && body.error.message) || body.message || "CTyun submission failed");
const id = body.id || body.task_id || (body.output && body.output.task_id);
if (!id) throw new Error("missing task ID");
return { taskId: String(id), taskData: body };
}
export function buildQueryRequest(ctx) {
const kind = family(ctx);
let path = "/v1/contents/generations/tasks/";
if (kind === "h3") path = "/v1/query/video_generation/";
if (kind === "wan") path = "/v1/tasks/";
return {
url: String(ctx.baseUrl).replace(/\/$/, "") + path + encodeURIComponent(ctx.taskId),
method: "GET",
headers: { Authorization: "Bearer " + ctx.apiKey },
};
}
export function parseTaskResult(ctx, body) {
const task = body.task || body.output || body;
if (!task.status && !task.task_status && (body.error || (body.code && String(body.code) !== "0")))
throw new Error((body.error && body.error.message) || body.message || "CTyun task query failed");
const rawStatus = String(task.task_status || task.status || "").toUpperCase();
const statuses = {
PENDING: "QUEUED",
QUEUED: "QUEUED",
RUNNING: "IN_PROGRESS",
PROCESSING: "IN_PROGRESS",
SUCCEEDED: "SUCCESS",
FAILED: "FAILURE",
CANCELED: "FAILURE",
CANCELLED: "FAILURE",
EXPIRED: "FAILURE",
};
const status = statuses[rawStatus] || "UNKNOWN";
const result = { status: status, progress: status === "SUCCESS" || status === "FAILURE" ? "100%" : "0%" };
if (status === "SUCCESS") {
result.url = task.video_url || (task.content && (task.content.video_url || task.content.url)) || "";
if (!result.url) throw new Error("successful video task has no result URL");
}
if (status === "FAILURE") result.reason = task.message || (task.error && task.error.message) || rawStatus;
return result;
}
export function extractUsage(ctx) {
if (ctx.usagePurpose === "billing_ratios") return null;
const body = videoRequest(ctx);
const kind = family(ctx);
const parameters = body.parameters || body;
const content = body.content || (body.input && body.input.media) || [];
const imageCount = content.filter(
(item) => item.type === "image_url" || item.type === "first_frame" || item.type === "last_frame" || item.type === "reference_image"
).length;
const video = content.some((item) => item.type === "video_url" || item.type === "reference_video");
const audio = content.some((item) => item.type === "audio_url" || item.type === "reference_audio");
const facts = {
tokens: 0,
seconds: parameters.duration,
resolution: String(parameters.resolution).toUpperCase(),
video_input: video ? "video" : "none",
input_images: imageCount,
input_video_seconds: video ? 15 : 0,
input_audio_seconds: audio ? 15 : 0,
};
if (body.input && body.input.img_url) facts.input_images++;
if (body.input && body.input.last_frame_url) facts.input_images++;
if (kind === "seedance") {
// Same conservative reservation formula as the Doubao plugin; actual
// upstream tokens replace this estimate at settlement.
const pixels = { "480P": 854 * 480, "720P": 1280 * 720, "1080P": 1920 * 1080 };
facts.tokens = Math.ceil(((facts.seconds + facts.input_video_seconds) * pixels[facts.resolution] * 24) / 1024);
}
return facts;
}
export function extractUsageOnComplete(ctx, result, body) {
if (!body || result.status !== "SUCCESS") return {};
const task = body.task || body.output || body;
const usage = body.usage || task.usage || {};
const facts = {};
let outputSeconds = usage.output_seconds;
if (outputSeconds === undefined) outputSeconds = usage.output_video_duration;
if (outputSeconds === undefined) outputSeconds = usage.duration;
if (outputSeconds === undefined) outputSeconds = task.duration;
const measured = {
seconds: outputSeconds,
input_video_seconds: usage.input_seconds === undefined ? usage.input_video_duration : usage.input_seconds,
input_audio_seconds: usage.input_audio_seconds,
input_images: usage.input_image_count,
};
for (const key of Object.keys(measured)) {
if (measured[key] !== undefined) {
const value = Number(measured[key]);
if (!Number.isFinite(value) || value < 0) throw new Error("invalid upstream usage: " + key);
facts[key] = value;
}
}
if (family(ctx) === "seedance") {
const value = usage.total_tokens === undefined ? usage.completion_tokens : usage.total_tokens;
if (value !== undefined) {
const tokens = Number(value);
if (!Number.isFinite(tokens) || tokens < 0) throw new Error("invalid upstream tokens");
facts.tokens = tokens;
}
}
const resolution = String(usage.SR || task.resolution || "")
.toUpperCase()
.replace(/^(480|720|768|1080)$/, "$1P");
if (meta.usageSchema.resolution.enum.includes(resolution)) facts.resolution = resolution;
return facts;
}
function artifactURL(task) {
const data = task.data || {};
const body = data.task || data.output || data;
return body.video_url || (body.content && (body.content.video_url || body.content.url)) || "";
}
export function listArtifacts(task) {
return task.status === "SUCCESS" && artifactURL(task) ? [{ key: "video", type: "video", mimeType: "video/mp4" }] : [];
}
export function buildContentRequest(ctx) {
if (ctx.artifactKey !== "video" || !artifactURL(ctx)) throw new Error("artifact_not_found");
return { url: artifactURL(ctx), method: ctx.clientRequest.method, credentialless: true };
}
export const protocols = {
openai_video: {
decodeRequest: function (ctx) {
if (!ctx.body || ctx.body.kind !== "json") throw new Error("CTyun video expects a JSON request with URL references");
const req = object(ctx.body.value, "request");
return {
kind: "submit",
model: ctx.model,
action: req.image || req.input_reference || (req.images && req.images.length) ? "image_to_video" : "text_to_video",
requestBody: Object.assign({}, req, { model: ctx.model }),
};
},
render: function (ctx, task) {
const statuses = { QUEUED: "queued", NOT_START: "queued", IN_PROGRESS: "in_progress", SUCCESS: "completed", FAILURE: "failed" };
const result = {
id: task.task_id,
object: "video",
model: task.properties ? task.properties.origin_model_name : "",
status: statuses[task.status] || "unknown",
progress: Number(String(task.progress || "0").replace("%", "")),
created_at: task.created_at,
completed_at: task.updated_at,
};
if (task.status === "FAILURE") result.error = { code: "task_failed", message: task.fail_reason || "video generation failed" };
return result;
},
},
};
function responsesVideoText(ctx) {
const artifact = ctx.artifacts && ctx.artifacts.video;
const url = artifact && artifact.url;
if (!url) throw new Error("video artifact is unavailable");
const escaped = String(url).replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
return '<video controls src="' + escaped + '"></video>';
}
protocols.openai_responses = {
decodeRequest: function (ctx) {
if (!ctx.body || ctx.body.kind !== "json") throw new Error("JSON body required");
const req = object(ctx.body.value, "request");
if (typeof req.input !== "string" && !Array.isArray(req.input)) throw new Error("input must be a string or array");
const texts = [];
const images = [];
if (typeof req.input === "string") texts.push(req.input);
else {
for (const item of req.input) {
if (!item || typeof item !== "object") throw new Error("invalid Responses input");
const content = item.content === undefined ? [item] : item.content;
if (!Array.isArray(content)) throw new Error("message content must be an array");
for (const part of content) {
if (part && (part.type === "input_text" || part.type === "text") && typeof part.text === "string") texts.push(part.text);
else if (part && part.type === "input_image" && typeof part.image_url === "string") images.push(part.image_url);
else throw new Error("unsupported video Responses input content");
}
}
}
const body = Object.assign({}, req, { model: ctx.model, prompt: texts.join("\n") });
delete body.input;
if (images.length) body.images = images;
return { kind: "submit", model: ctx.model, action: images.length ? "image_to_video" : "text_to_video", requestBody: body };
},
renderEvents: function (ctx, task, previousState) {
const state = { status: task.status, progress: task.progress };
if (task.status === "SUCCESS")
return {
events: previousState && previousState.status === task.status ? [] : [{ type: "output", data: responsesVideoText(ctx) }],
state: state,
done: true,
};
if (task.status === "FAILURE")
return { events: [{ type: "error", code: "task_failed", message: task.fail_reason || "video generation failed" }], state: state, done: true };
if (previousState && previousState.status === task.status && previousState.progress === task.progress) return { events: [], state: state, done: false };
return { events: [{ type: "progress", message: String(task.status || "queued").toLowerCase() }], state: state, done: false };
},
renderFinal: function (ctx, task) {
if (task.status !== "SUCCESS") throw new Error(task.fail_reason || "video generation failed");
return {
output: [
{
type: "message",
status: "completed",
role: "assistant",
content: [{ type: "output_text", text: responsesVideoText(ctx), annotations: [], logprobs: [] }],
},
],
metadata: { vendor: "ctyun" },
};
},
};
......@@ -186,7 +186,7 @@ func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInf
if isSyncImageModel(info.UpstreamModelName) {
a.IsSyncImageModel = true
}
aliRequest, err := oaiImage2AliImageRequest(info, request, a.IsSyncImageModel)
aliRequest, err := ConvertImageGenerationRequest(info, request, a.IsSyncImageModel)
if err != nil {
return nil, fmt.Errorf("convert image request to async ali image request failed: %w", err)
}
......@@ -211,7 +211,7 @@ func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInf
}
return aliRequest, nil
} else {
aliRequest, err := oaiImage2AliImageRequest(info, request, a.IsSyncImageModel)
aliRequest, err := ConvertImageGenerationRequest(info, request, a.IsSyncImageModel)
if err != nil {
return nil, fmt.Errorf("convert image request to async ali image request failed: %w", err)
}
......
......@@ -171,17 +171,19 @@ type AliImageRequest struct {
}
type AliImageParameters struct {
Size string `json:"size,omitempty"`
N int `json:"n,omitempty"`
Steps string `json:"steps,omitempty"`
Scale string `json:"scale,omitempty"`
Watermark *bool `json:"watermark,omitempty"`
PromptExtend *bool `json:"prompt_extend,omitempty"`
ThinkingMode *bool `json:"thinking_mode,omitempty"`
EnableSequential *bool `json:"enable_sequential,omitempty"`
BboxList any `json:"bbox_list,omitempty"`
ColorPalette any `json:"color_palette,omitempty"`
Seed *int `json:"seed,omitempty"`
NegativePrompt *string `json:"negative_prompt,omitempty"`
EnableInterleave *bool `json:"enable_interleave,omitempty"`
Size string `json:"size,omitempty"`
N int `json:"n,omitempty"`
Steps string `json:"steps,omitempty"`
Scale string `json:"scale,omitempty"`
Watermark *bool `json:"watermark,omitempty"`
PromptExtend *bool `json:"prompt_extend,omitempty"`
ThinkingMode *bool `json:"thinking_mode,omitempty"`
EnableSequential *bool `json:"enable_sequential,omitempty"`
BboxList any `json:"bbox_list,omitempty"`
ColorPalette any `json:"color_palette,omitempty"`
Seed *int `json:"seed,omitempty"`
}
func (p *AliImageParameters) PromptExtendValue() bool {
......
......@@ -21,7 +21,9 @@ import (
"github.com/samber/lo"
)
func oaiImage2AliImageRequest(info *relaycommon.RelayInfo, request dto.ImageRequest, isSync bool) (*AliImageRequest, error) {
// ConvertImageGenerationRequest translates the common image request into the
// DashScope input/parameters envelope. isSync selects message-based input.
func ConvertImageGenerationRequest(info *relaycommon.RelayInfo, request dto.ImageRequest, isSync bool) (*AliImageRequest, error) {
var imageRequest AliImageRequest
imageRequest.Model = request.Model
imageRequest.ResponseFormat = request.ResponseFormat
......
package ctyun
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/relay/channel"
"github.com/QuantumNous/new-api/relay/channel/ali"
"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/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/types"
"github.com/QuantumNous/new-api/service"
"github.com/gin-gonic/gin"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
// CTyun exposes several provider protocols behind one AppKey. Keep routing
// separate from the mapped model ID sent upstream (which may be opaque).
type Adaptor struct {
openai.Adaptor
image ali.Adaptor
model string
embeddingCount int
embeddingDimension int
}
func (a *Adaptor) Init(info *relaycommon.RelayInfo) {
a.Adaptor.Init(info)
a.model = strings.ToLower(info.UpstreamModelName)
if !strings.Contains(a.model, "text-embedding") && !strings.Contains(a.model, "qwen") && !strings.Contains(a.model, "gte") && !strings.Contains(a.model, "wan") && !strings.Contains(a.model, "seedream") {
a.model = strings.ToLower(info.OriginModelName)
}
}
func (a *Adaptor) GetChannelName() string { return "CTyun" }
func (a *Adaptor) GetModelList() []string { return []string{} }
func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
base := strings.TrimRight(info.ChannelBaseUrl, "/")
if base == "" {
base = constant.GetChannelBaseURL(constant.ChannelTypeCTyun)
}
path := ""
switch info.RelayMode {
case relayconstant.RelayModeChatCompletions:
path = "/v1/chat/completions"
case relayconstant.RelayModeEmbeddings:
path = "/v1/embeddings"
if strings.Contains(a.model, "vl-embedding") {
path = "/v1/services/embeddings/multimodal-embedding/multimodal-embedding"
}
case relayconstant.RelayModeRerank:
path = "/v1/rerank"
if strings.Contains(a.model, "qwen") {
path = "/v1/reranks"
}
if strings.Contains(a.model, "gte") {
path = "/v1/services/rerank/text-rerank/text-rerank"
}
case relayconstant.RelayModeImagesGenerations, relayconstant.RelayModeImagesEdits:
path = "/v1/images/generations"
if strings.Contains(a.model, "qwen") || strings.Contains(a.model, "wan") {
path = "/v1/services/aigc/multimodal-generation/generation"
}
default:
return "", fmt.Errorf("unsupported CTyun relay mode: %d", info.RelayMode)
}
return base + path, nil
}
func (a *Adaptor) SetupRequestHeader(c *gin.Context, h *http.Header, info *relaycommon.RelayInfo) error {
channel.SetupApiRequestHeader(info, c, h)
h.Set("Authorization", "Bearer "+info.ApiKey)
h.Set("Content-Type", "application/json")
return nil
}
func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, body io.Reader) (any, error) {
return channel.DoApiRequest(a, c, info, body)
}
func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.RelayInfo, req dto.EmbeddingRequest) (any, error) {
if strings.Contains(a.model, "vl-embedding") {
return a.convertMultimodalEmbedding(req)
}
var texts []string
switch input := req.Input.(type) {
case string:
texts = []string{input}
case []string:
texts = input
case []any:
for _, item := range input {
text, ok := item.(string)
if !ok {
return nil, invalidRequest("CTyun embedding input must contain strings")
}
texts = append(texts, text)
}
default:
return nil, invalidRequest("CTyun embedding input must be a string or string array")
}
if len(texts) == 0 {
return nil, invalidRequest("embedding input is empty")
}
if strings.Contains(a.model, "text-embedding") && len(texts) > 10 {
return nil, invalidRequest("CTyun text-embedding accepts at most 10 inputs")
}
if req.Dimensions != nil && *req.Dimensions <= 0 {
return nil, invalidRequest("dimensions must be positive")
}
if req.EncodingFormat != "" && req.EncodingFormat != "float" && req.EncodingFormat != "base64" {
return nil, invalidRequest("unsupported encoding_format")
}
return req, nil
}
func (a *Adaptor) ConvertRerankRequest(c *gin.Context, mode int, req dto.RerankRequest) (any, error) {
if strings.TrimSpace(req.Query) == "" || len(req.Documents) == 0 {
return nil, invalidRequest("query and documents are required")
}
if req.TopN != nil && *req.TopN <= 0 {
return nil, invalidRequest("top_n must be positive")
}
if req.TruncatePromptTokens != nil && (*req.TruncatePromptTokens < 1 || *req.TruncatePromptTokens > 8192) {
return nil, invalidRequest("truncate_prompt_tokens must be between 1 and 8192")
}
if (strings.Contains(a.model, "qwen") || strings.Contains(a.model, "gte")) && len(req.Documents) > 500 {
return nil, invalidRequest("CTyun rerank accepts at most 500 documents")
}
documents := make([]any, len(req.Documents))
for i, doc := range req.Documents {
switch value := doc.(type) {
case string:
documents[i] = value
case map[string]any:
text, ok := value["text"].(string)
if !ok {
return nil, invalidRequest("documents[%d].text must be a string", i)
}
documents[i] = text
default:
return nil, invalidRequest("documents[%d] must be text or an object with text", i)
}
}
req.Documents = documents
if strings.Contains(a.model, "gte") {
return &ali.AliRerankRequest{Model: req.Model, Input: ali.AliRerankInput{Query: req.Query, Documents: documents}, Parameters: ali.AliRerankParameters{TopN: req.TopN, ReturnDocuments: req.ReturnDocuments}}, nil
}
if strings.Contains(a.model, "qwen") {
return struct {
Model string `json:"model"`
Query string `json:"query"`
Documents []any `json:"documents"`
TopN *int `json:"top_n,omitempty"`
Instruct *string `json:"instruct,omitempty"`
}{req.Model, req.Query, documents, req.TopN, req.Instruct}, nil
}
return req, nil
}
// NormalizeImageCount runs before pricing so native count fields cannot bypass
// the reservation. It is also used by conversion for retries and direct callers.
func NormalizeImageCount(req *dto.ImageRequest) error {
if req.Extra == nil {
req.Extra = make(map[string]json.RawMessage)
}
n := uint(1)
if req.N != nil {
n = *req.N
}
if raw, ok := req.Extra["parameters"]; ok {
var parameters map[string]json.RawMessage
if err := common.Unmarshal(raw, &parameters); err != nil || parameters == nil {
return invalidRequest("parameters must be an object")
}
if rawN, ok := parameters["n"]; ok {
if err := common.Unmarshal(rawN, &n); err != nil || string(rawN) == "null" {
return invalidRequest("invalid parameters.n")
}
}
if n < 1 || n > dto.MaxImageN {
return invalidRequest("n must be between 1 and %d", dto.MaxImageN)
}
parameters["n"], _ = common.Marshal(n)
req.Extra["parameters"], _ = common.Marshal(parameters)
}
if raw, ok := req.Extra["sequential_image_generation_options"]; ok {
var options struct {
MaxImages *uint `json:"max_images"`
}
if err := common.Unmarshal(raw, &options); err != nil {
return invalidRequest("invalid sequential_image_generation_options")
}
if options.MaxImages != nil {
n = *options.MaxImages
}
}
if gjson.GetBytes(req.Extra["sequential_image_generation"], "@this").String() == "auto" {
if _, ok := req.Extra["sequential_image_generation_options"]; !ok {
n = 15
}
req.Extra["sequential_image_generation_options"], _ = common.Marshal(map[string]uint{"max_images": n})
}
if n < 1 || n > dto.MaxImageN {
return invalidRequest("image count must be between 1 and %d", dto.MaxImageN)
}
req.N = &n
return nil
}
func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInfo, req dto.ImageRequest) (any, error) {
if err := NormalizeImageCount(&req); err != nil {
return nil, err
}
if strings.Contains(a.model, "qwen") || strings.Contains(a.model, "wan") {
if info.IsStream {
return nil, invalidRequest("CTyun message-based image generation currently requires stream=false")
}
// The CTyun Qwen endpoint supports synchronous output; also recognize a
// task envelope in DoResponse when the service chooses asynchronous output.
a.image.IsSyncImageModel = true
if req.Extra == nil {
req.Extra = make(map[string]json.RawMessage)
}
var converted any
var err error
if strings.Contains(c.Request.Header.Get("Content-Type"), "multipart/form-data") {
converted, err = a.image.ConvertImageRequest(c, info, req)
} else {
converted, err = ali.ConvertImageGenerationRequest(info, req, true)
}
if err != nil {
return nil, invalidRequest("%v", err)
}
imageReq := converted.(*ali.AliImageRequest)
if strings.Contains(a.model, "qwen") && imageReq.Parameters.N > 6 {
return nil, invalidRequest("CTyun Qwen image accepts at most 6 images")
}
if imageReq.Parameters.Size != "" && !(strings.Contains(a.model, "wan") && (imageReq.Parameters.Size == "1K" || imageReq.Parameters.Size == "2K" || imageReq.Parameters.Size == "4K")) {
width, height, ok := strings.Cut(imageReq.Parameters.Size, "*")
w, we := strconv.Atoi(width)
h, he := strconv.Atoi(height)
if !ok || we != nil || he != nil || w < 512 || w > 2048 || h < 512 || h > 2048 {
return nil, invalidRequest("Qwen image size must have width and height between 512 and 2048")
}
}
if imageReq.Parameters.Seed != nil && (*imageReq.Parameters.Seed < 0 || *imageReq.Parameters.Seed > 2147483647) {
return nil, invalidRequest("seed must be between 0 and 2147483647")
}
if len(req.Image) > 0 && len(req.Extra["input"]) == 0 {
var images []string
if err := common.Unmarshal(req.Image, &images); err != nil {
var image string
if err := common.Unmarshal(req.Image, &image); err != nil {
return nil, invalidRequest("image must be a URL or an array of URLs")
}
images = []string{image}
}
content := make([]ali.AliMediaContent, 0, len(images)+1)
for _, image := range images {
content = append(content, ali.AliMediaContent{Image: image})
}
content = append(content, ali.AliMediaContent{Text: req.Prompt})
imageReq.Input = ali.AliImageInput{Messages: []ali.AliMessage{{Role: "user", Content: content}}}
}
return converted, nil
}
if strings.Contains(a.model, "seedream") {
if strings.Contains(a.model, "5.0-pro") && *req.N > 1 {
return nil, invalidRequest("Seedream 5.0 pro supports one image per request")
}
if strings.Contains(a.model, "5.0-pro") && info.IsStream {
return nil, invalidRequest("Seedream 5.0 pro does not support streaming")
}
if *req.N > 1 {
req.Extra["sequential_image_generation"] = json.RawMessage(`"auto"`)
req.Extra["sequential_image_generation_options"], _ = common.Marshal(map[string]uint{"max_images": *req.N})
}
delete(req.Extra, "guidance_scale")
if !strings.Contains(a.model, "5.0-lite") {
delete(req.Extra, "tools")
req.OutputFormat = nil
}
if strings.Contains(a.model, "5.0-pro") {
delete(req.Extra, "sequential_image_generation")
delete(req.Extra, "sequential_image_generation_options")
}
if info.PriceData.UsePrice {
info.PriceData.AddOtherRatio("n", float64(*req.N))
}
req.N = nil // Seedream uses max_images, not the OpenAI n field.
raw, err := common.Marshal(req)
if err != nil {
return nil, err
}
var body map[string]json.RawMessage
if err := common.Unmarshal(raw, &body); err != nil {
return nil, err
}
for key, value := range req.Extra {
body[key] = value
}
return body, nil
}
return req, nil
}
func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (any, *types.NewAPIError) {
for _, key := range []string{"x-request-id", "x-ctyun-request-id"} {
if id := resp.Header.Get(key); id != "" {
c.Header(key, id)
}
}
if info.IsStream {
return a.Adaptor.DoResponse(c, resp, info)
}
body, err := io.ReadAll(resp.Body)
resp.Body.Close()
if err != nil {
return nil, types.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusBadGateway)
}
code := gjson.GetBytes(body, "code")
upstreamError := gjson.GetBytes(body, "error")
if (code.Exists() && code.String() != "" && code.String() != "0") || (upstreamError.Exists() && upstreamError.Type != gjson.Null) {
message := gjson.GetBytes(body, "message").String()
if upstreamError.Exists() {
message = upstreamError.Get("message").String()
code = upstreamError.Get("code")
}
return nil, types.WithOpenAIError(types.OpenAIError{Type: "ctyun_error", Code: code.String(), Message: message}, http.StatusBadGateway)
}
if id := gjson.GetBytes(body, "request_id").String(); id != "" {
c.Header("x-request-id", id)
}
if info.RelayMode == relayconstant.RelayModeRerank {
var result struct {
Results []dto.RerankResponseResult `json:"results"`
Output struct {
Results []dto.RerankResponseResult `json:"results"`
} `json:"output"`
Usage *dto.Usage `json:"usage"`
}
if err := common.Unmarshal(body, &result); err != nil {
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusBadGateway)
}
if strings.Contains(a.model, "gte") {
result.Results = result.Output.Results
}
if result.Results == nil {
return nil, types.NewOpenAIError(fmt.Errorf("missing rerank results"), types.ErrorCodeBadResponseBody, http.StatusBadGateway)
}
seen := make(map[int]bool)
for i := range result.Results {
item := &result.Results[i]
if item.Index < 0 || item.Index >= len(info.Documents) || seen[item.Index] {
return nil, types.NewOpenAIError(fmt.Errorf("invalid rerank result index"), types.ErrorCodeBadResponseBody, http.StatusBadGateway)
}
seen[item.Index] = true
item.Document = nil
if info.ReturnDocuments {
item.Document = info.Documents[item.Index]
if text, ok := item.Document.(string); ok {
item.Document = dto.RerankDocument{Text: text}
}
}
}
if result.Usage == nil {
result.Usage = &dto.Usage{}
}
if !gjson.GetBytes(body, "usage.total_tokens").Exists() {
result.Usage.TotalTokens = result.Usage.PromptTokens
if !gjson.GetBytes(body, "usage.prompt_tokens").Exists() {
result.Usage.TotalTokens = info.GetEstimatePromptTokens()
c.Header("x-new-api-usage-estimated", "true")
}
}
if result.Usage.TotalTokens < 0 {
return nil, types.NewOpenAIError(fmt.Errorf("invalid rerank token usage"), types.ErrorCodeBadResponseBody, http.StatusBadGateway)
}
result.Usage.PromptTokens = result.Usage.TotalTokens
c.JSON(http.StatusOK, dto.RerankResponse{Results: result.Results, Usage: *result.Usage})
return result.Usage, nil
}
if info.RelayMode == relayconstant.RelayModeEmbeddings && strings.Contains(a.model, "vl-embedding") {
return a.multimodalEmbeddingResponse(c, body, info)
}
if info.RelayMode == relayconstant.RelayModeEmbeddings && gjson.GetBytes(body, "usage.prompt_tokens").Int() == 0 {
if total := gjson.GetBytes(body, "usage.total_tokens"); total.Exists() {
body, err = sjson.SetBytes(body, "usage.prompt_tokens", total.Int())
if err != nil {
return nil, types.NewError(err, types.ErrorCodeBadResponseBody)
}
}
}
resp.Body = io.NopCloser(bytes.NewReader(body))
if (info.RelayMode == relayconstant.RelayModeImagesGenerations || info.RelayMode == relayconstant.RelayModeImagesEdits) && (strings.Contains(a.model, "qwen") || strings.Contains(a.model, "wan")) {
if taskID := gjson.GetBytes(body, "output.task_id").String(); taskID != "" {
resp.Body.Close()
taskBody, pollErr := a.waitImageTask(c, info, taskID)
if pollErr != nil {
return nil, types.NewOpenAIError(pollErr, types.ErrorCodeBadResponseBody, http.StatusBadGateway, types.ErrOptionWithSkipRetry())
}
resp.Body = io.NopCloser(bytes.NewReader(taskBody))
}
a.image.IsSyncImageModel = true
return a.image.DoResponse(c, resp, info)
}
return a.Adaptor.DoResponse(c, resp, info)
}
// waitImageTask uses the channel transport and request deadline. A task ID means
// submission succeeded, so failures here must not resubmit a paid generation.
func (a *Adaptor) waitImageTask(c *gin.Context, info *relaycommon.RelayInfo, taskID string) ([]byte, error) {
ctx, cancel := context.WithTimeout(c.Request.Context(), 5*time.Minute)
defer cancel()
client, err := service.GetHttpClientWithProxySettings(info.ChannelSetting.Proxy, info.ChannelSetting)
if err != nil {
return nil, err
}
base := strings.TrimRight(info.ChannelBaseUrl, "/")
if base == "" {
base = constant.GetChannelBaseURL(constant.ChannelTypeCTyun)
}
for {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, base+"/v1/tasks/"+url.PathEscape(taskID), nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+info.ApiKey)
resp, err := client.Do(req)
if err != nil {
return nil, err
}
body, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil {
return nil, readErr
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("CTyun image task query returned HTTP %d", resp.StatusCode)
}
status := gjson.GetBytes(body, "output.task_status").String()
switch status {
case "SUCCEEDED":
return body, nil
case "PENDING", "RUNNING":
default:
return nil, fmt.Errorf("CTyun image task %s: %s", status, gjson.GetBytes(body, "output.message").String())
}
timer := time.NewTimer(2 * time.Second)
select {
case <-ctx.Done():
timer.Stop()
return nil, ctx.Err()
case <-timer.C:
}
}
}
func invalidRequest(format string, args ...any) *types.NewAPIError {
return types.NewErrorWithStatusCode(fmt.Errorf(format, args...), types.ErrorCodeInvalidRequest, http.StatusBadRequest, types.ErrOptionWithSkipRetry())
}
package ctyun_test
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/pkg/billingexpr"
"github.com/QuantumNous/new-api/pkg/jsplugin"
"github.com/QuantumNous/new-api/plugins"
"github.com/QuantumNous/new-api/relay/channel/ctyun"
relaycommon "github.com/QuantumNous/new-api/relay/common"
relayconstant "github.com/QuantumNous/new-api/relay/constant"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/types"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func fixture(t *testing.T, model string, mode int) (*ctyun.Adaptor, *relaycommon.RelayInfo, *gin.Context, *httptest.ResponseRecorder) {
t.Helper()
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
c.Request.Header.Set("Content-Type", "application/json")
info := &relaycommon.RelayInfo{RerankerInfo: &relaycommon.RerankerInfo{}, OriginModelName: model, RelayMode: mode, RelayFormat: types.RelayFormatOpenAI, StartTime: time.Now(), ChannelMeta: &relaycommon.ChannelMeta{ChannelType: constant.ChannelTypeCTyun, UpstreamModelName: "opaque-model-id", ApiKey: "test-app-key", SupportStreamOptions: true}}
info.SetEstimatePromptTokens(13)
a := &ctyun.Adaptor{}
a.Init(info)
return a, info, c, recorder
}
func response(body string) *http.Response {
return &http.Response{StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"application/json"}}, Body: io.NopCloser(strings.NewReader(body))}
}
func TestCTyunMappedModelProtocols(t *testing.T) {
cases := []struct {
model string
mode int
path string
}{
{"qwen3", relayconstant.RelayModeChatCompletions, "/v1/chat/completions"},
{"text-embedding-v4", relayconstant.RelayModeEmbeddings, "/v1/embeddings"},
{"bge-reranker-v2-m3", relayconstant.RelayModeRerank, "/v1/rerank"},
{"qwen3-rerank", relayconstant.RelayModeRerank, "/v1/reranks"},
{"gte-rerank-v2", relayconstant.RelayModeRerank, "/v1/services/rerank/text-rerank/text-rerank"},
{"doubao-seedream-4.5", relayconstant.RelayModeImagesGenerations, "/v1/images/generations"},
{"qwen-image-2.0-pro", relayconstant.RelayModeImagesGenerations, "/v1/services/aigc/multimodal-generation/generation"},
{"wan2.7-image-pro", relayconstant.RelayModeImagesGenerations, "/v1/services/aigc/multimodal-generation/generation"},
{"qwen3-vl-embedding", relayconstant.RelayModeEmbeddings, "/v1/services/embeddings/multimodal-embedding/multimodal-embedding"},
}
for _, tc := range cases {
t.Run(tc.model, func(t *testing.T) {
a, info, c, _ := fixture(t, tc.model, tc.mode)
url, err := a.GetRequestURL(info)
require.NoError(t, err)
assert.Equal(t, "https://ai.ctaigw.cn"+tc.path, url)
info.ChannelBaseUrl = "https://ai.ctaigw.com/eu/"
url, err = a.GetRequestURL(info)
require.NoError(t, err)
assert.Equal(t, "https://ai.ctaigw.com/eu"+tc.path, url)
h := make(http.Header)
require.NoError(t, a.SetupRequestHeader(c, &h, info))
assert.Equal(t, "Bearer test-app-key", h.Get("Authorization"))
})
}
}
func TestCTyunRerankConversionsAndUsage(t *testing.T) {
for _, model := range []string{"bge-reranker-v2-m3", "qwen3-rerank", "gte-rerank-v2"} {
t.Run(model, func(t *testing.T) {
a, info, c, recorder := fixture(t, model, relayconstant.RelayModeRerank)
keep := false
top := 2
instruct := "retrieve passages"
req := dto.RerankRequest{Model: info.UpstreamModelName, Query: "query", Documents: []any{"first", map[string]any{"text": "second", "title": "kept"}}, TopN: &top, ReturnDocuments: &keep, Instruct: &instruct}
converted, err := a.ConvertRerankRequest(c, info.RelayMode, req)
require.NoError(t, err)
raw, err := common.Marshal(converted)
require.NoError(t, err)
assert.Equal(t, info.UpstreamModelName, gjson.GetBytes(raw, "model").String())
if model == "gte-rerank-v2" {
assert.Equal(t, "second", gjson.GetBytes(raw, "input.documents.1").String())
assert.Equal(t, "false", gjson.GetBytes(raw, "parameters.return_documents").Raw)
} else {
assert.Equal(t, "second", gjson.GetBytes(raw, "documents.1").String())
}
if model == "qwen3-rerank" {
assert.Equal(t, instruct, gjson.GetBytes(raw, "instruct").String())
assert.False(t, gjson.GetBytes(raw, "return_documents").Exists())
}
info.Documents = req.Documents
info.ReturnDocuments = true
body := `{"results":[{"index":1,"relevance_score":0.8}],"usage":{"total_tokens":29}}`
if model == "bge-reranker-v2-m3" {
body = `{"code":0,"results":[{"index":1,"relevance_score":0.8}]}`
}
if model == "gte-rerank-v2" {
body = `{"output":{"results":[{"index":1,"relevance_score":0.8}]},"usage":{"total_tokens":29}}`
}
usage, apiErr := a.DoResponse(c, response(body), info)
require.Nil(t, apiErr)
expected := 29
if model == "bge-reranker-v2-m3" {
expected = 13
assert.Equal(t, "true", recorder.Header().Get("x-new-api-usage-estimated"))
}
assert.Equal(t, expected, usage.(*dto.Usage).PromptTokens)
assert.Equal(t, "kept", gjson.Get(recorder.Body.String(), "results.0.document.title").String())
})
}
for _, body := range []string{`{"results":[{"index":-1}]}`, `{"results":[{"index":1}]}`, `{"results":[{"index":0},{"index":0}]}`, `{"error":{"message":"limited","code":"rate_limit_exceeded"}}`} {
a, info, c, w := fixture(t, "bge", relayconstant.RelayModeRerank)
info.Documents = []any{"a"}
_, err := a.DoResponse(c, response(body), info)
require.NotNil(t, err)
assert.Empty(t, w.Body.String())
}
}
func TestCTyunEmbeddingPreservesBase64AndTotalUsage(t *testing.T) {
a, info, c, w := fixture(t, "text-embedding-v4", relayconstant.RelayModeEmbeddings)
_, err := a.ConvertEmbeddingRequest(c, info, dto.EmbeddingRequest{Input: []any{"a", 42}})
require.Error(t, err)
input := make([]string, 11)
_, err = a.ConvertEmbeddingRequest(c, info, dto.EmbeddingRequest{Input: input})
require.Error(t, err)
usage, apiErr := a.DoResponse(c, response(`{"object":"list","data":[{"index":0,"embedding":"AACAPw=="}],"usage":{"total_tokens":17}}`), info)
require.Nil(t, apiErr)
assert.Equal(t, 17, usage.(*dto.Usage).PromptTokens)
assert.Equal(t, "AACAPw==", gjson.Get(w.Body.String(), "data.0.embedding").String())
}
func TestCTyunMultimodalEmbeddingUsage(t *testing.T) {
a, info, c, w := fixture(t, "qwen3-vl-embedding", relayconstant.RelayModeEmbeddings)
req := dto.EmbeddingRequest{Model: info.UpstreamModelName, Input: map[string]any{"contents": []any{map[string]any{"image": "https://example.com/a.png"}}}, Parameters: json.RawMessage(`{"dimension":256,"enable_fusion":false,"fps":0}`)}
converted, err := a.ConvertEmbeddingRequest(c, info, req)
require.NoError(t, err)
raw, err := common.Marshal(converted)
require.NoError(t, err)
assert.Equal(t, "false", gjson.GetBytes(raw, "parameters.enable_fusion").Raw)
assert.Equal(t, "0", gjson.GetBytes(raw, "parameters.fps").Raw)
vector := make([]float64, 256)
vector[0] = 0.5
body, err := common.Marshal(map[string]any{"output": map[string]any{"embeddings": []any{map[string]any{"index": 0, "embedding": vector}}}, "usage": map[string]int{"input_tokens": 43, "image_tokens": 1247, "total_tokens": 1290}})
require.NoError(t, err)
usage, apiErr := a.DoResponse(c, response(string(body)), info)
require.Nil(t, apiErr)
assert.Equal(t, 1290, usage.(*dto.Usage).PromptTokens)
assert.Equal(t, 1247, usage.(*dto.Usage).PromptTokensDetails.ImageTokens)
assert.Equal(t, 0.5, gjson.Get(w.Body.String(), "data.0.embedding.0").Float())
}
func TestCTyunImagesPreserveParametersAndPoll(t *testing.T) {
for _, name := range []string{"qwen-image-2.0-pro", "wan2.7-image-pro"} {
t.Run(name, func(t *testing.T) {
a, info, c, _ := fixture(t, name, relayconstant.RelayModeImagesGenerations)
var req dto.ImageRequest
require.NoError(t, common.Unmarshal([]byte(`{"model":"opaque-model-id","prompt":"paint","image":["https://example.com/a.png"],"parameters":{"n":2,"size":"1024*1024","seed":0,"watermark":false}}`), &req))
converted, err := a.ConvertImageRequest(c, info, req)
require.NoError(t, err)
raw, err := common.Marshal(converted)
require.NoError(t, err)
assert.Equal(t, "0", gjson.GetBytes(raw, "parameters.seed").Raw)
assert.Equal(t, "false", gjson.GetBytes(raw, "parameters.watermark").Raw)
assert.Equal(t, "https://example.com/a.png", gjson.GetBytes(raw, "input.messages.0.content.0.image").String())
})
}
requests := make(chan string, 1)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests <- r.URL.Path
assert.Equal(t, "Bearer test-app-key", r.Header.Get("Authorization"))
fmt.Fprint(w, `{"output":{"task_status":"SUCCEEDED","choices":[{"message":{"content":[{"image":"https://example.com/result.png"}]}}]},"usage":{"image_count":1}}`)
}))
defer server.Close()
a, info, c, w := fixture(t, "qwen-image-2.0-pro", relayconstant.RelayModeImagesGenerations)
info.ChannelBaseUrl = server.URL
info.PriceData.UsePrice = true
usage, err := a.DoResponse(c, response(`{"output":{"task_id":"upstream-123","task_status":"PENDING"}}`), info)
require.Nil(t, err)
require.NotNil(t, usage)
assert.Equal(t, "/v1/tasks/upstream-123", <-requests)
assert.Equal(t, "https://example.com/result.png", gjson.Get(w.Body.String(), "data.0.url").String())
assert.Equal(t, float64(1), info.PriceData.OtherRatios()["n"])
}
func TestCTyunChatStreamUsageAndErrors(t *testing.T) {
oldTimeout := constant.StreamingTimeout
constant.StreamingTimeout = 30
t.Cleanup(func() { constant.StreamingTimeout = oldTimeout })
for _, tc := range []struct {
name, body string
failed bool
}{
{"usage-only last chunk", "data: {\"id\":\"chat-1\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"hi\"}}]}\n\ndata: {\"choices\":[],\"usage\":{\"prompt_tokens\":2,\"completion_tokens\":3,\"total_tokens\":5}}\n\ndata: [DONE]\n\n", false},
{"HTTP 200 error", "data: {\"error\":{\"code\":\"rate_limit_exceeded\",\"message\":\"limited\"}}\n\n", true},
} {
t.Run(tc.name, func(t *testing.T) {
a, info, c, w := fixture(t, "qwen3", relayconstant.RelayModeChatCompletions)
info.IsStream = true
resp := response(tc.body)
resp.Header.Set("Content-Type", "text/event-stream")
usage, err := a.DoResponse(c, resp, info)
if tc.failed {
require.NotNil(t, err)
assert.True(t, types.IsSkipRetryError(err))
assert.NotContains(t, w.Body.String(), "[DONE]")
return
}
require.Nil(t, err)
assert.Equal(t, 5, usage.(*dto.Usage).TotalTokens)
assert.Contains(t, w.Body.String(), "hi")
})
}
}
func TestCTyunVideoSubmissionPollingAndSettlementFacts(t *testing.T) {
source, err := plugins.Source("ctyun")
require.NoError(t, err)
registry := jsplugin.NewRegistry()
plugin, err := registry.RegisterFactory(source, jsplugin.Options{Key: "ctyun"})
require.NoError(t, err)
for _, tc := range []struct {
model, path, query, submit, result string
tokens float64
}{
{"doubao-seedance-2.0", "/v1/contents/generations/tasks", "/v1/contents/generations/tasks/job", "{\"id\":\"job\"}", `{"status":"succeeded","content":{"video_url":"https://example.com/video.mp4"},"usage":{"total_tokens":99}}`, 99},
{"MiniMax-H3", "/v1/video_generation", "/v1/query/video_generation/job", `{"task_id":"job"}`, `{"task":{"status":"succeeded","content":{"url":"https://example.com/video.mp4"},"usage":{"output_seconds":5,"input_seconds":0,"input_image_count":0}}}`, 0},
{"wan3.0-video", "/v1/services/aigc/video-generation/video-synthesis", "/v1/tasks/job", `{"output":{"task_id":"job"}}`, `{"output":{"task_status":"SUCCEEDED","video_url":"https://example.com/video.mp4"},"usage":{"output_video_duration":5,"input_video_duration":0}}`, 0},
{"happyhorse-1.1-t2v", "/v1/services/aigc/video-generation/video-synthesis", "/v1/tasks/job", `{"output":{"task_id":"job"}}`, `{"output":{"task_status":"SUCCEEDED","video_url":"https://example.com/video.mp4"},"usage":{"output_video_duration":5}}`, 0},
} {
t.Run(tc.model, func(t *testing.T) {
ctx := map[string]any{"model": tc.model, "upstreamModel": "opaque-model-id", "baseUrl": "https://ai.ctaigw.cn", "apiKey": "app", "taskId": "job", "requestBody": map[string]any{"model": tc.model, "prompt": "waves", "seconds": 5}}
submit, callErr := plugin.Engine.Call(t.Context(), "buildSubmitRequest", ctx)
require.NoError(t, callErr)
raw, err := common.Marshal(submit)
require.NoError(t, err)
assert.Equal(t, "https://ai.ctaigw.cn"+tc.path, gjson.GetBytes(raw, "url").String())
assert.Equal(t, "opaque-model-id", gjson.GetBytes(raw, "body.model").String())
query, callErr := plugin.Engine.Call(t.Context(), "buildQueryRequest", ctx)
require.NoError(t, callErr)
raw, err = common.Marshal(query)
require.NoError(t, err)
assert.Equal(t, "https://ai.ctaigw.cn"+tc.query, gjson.GetBytes(raw, "url").String())
var submitBody, body map[string]any
require.NoError(t, common.Unmarshal([]byte(tc.submit), &submitBody))
require.NoError(t, common.Unmarshal([]byte(tc.result), &body))
parsed, callErr := plugin.Engine.Call(t.Context(), "parseSubmitResponse", ctx, map[string]any{"body": submitBody})
require.NoError(t, callErr)
assert.Equal(t, "job", parsed.(map[string]any)["taskId"])
result, callErr := plugin.Engine.Call(t.Context(), "parseTaskResult", ctx, body)
require.NoError(t, callErr)
assert.Equal(t, "SUCCESS", result.(map[string]any)["status"])
facts, callErr := plugin.Engine.Call(t.Context(), "extractUsageOnComplete", ctx, result, body)
require.NoError(t, callErr)
if tc.tokens > 0 {
raw, err = common.Marshal(facts)
require.NoError(t, err)
assert.Equal(t, tc.tokens, gjson.GetBytes(raw, "tokens").Float())
}
again, callErr := plugin.Engine.Call(t.Context(), "parseTaskResult", ctx, body)
require.NoError(t, callErr)
assert.Equal(t, result, again)
})
}
for _, status := range []string{"failed", "cancelled", "expired"} {
result, callErr := plugin.Engine.Call(t.Context(), "parseTaskResult", map[string]any{}, map[string]any{"status": status, "error": map[string]any{"message": "failed"}})
require.NoError(t, callErr)
assert.Equal(t, "FAILURE", result.(map[string]any)["status"])
}
_, err = plugin.Engine.Call(t.Context(), "buildSubmitRequest", map[string]any{"model": "MiniMax-H3", "requestBody": map[string]any{"prompt": "waves", "seconds": 5, "metadata": map[string]any{"duration": 1e20}}})
require.Error(t, err)
}
func TestCTyunCdanceRoutingAndTieredSettlement(t *testing.T) {
source, err := plugins.Source("ctyun")
require.NoError(t, err)
registry := jsplugin.NewRegistry()
plugin, err := registry.RegisterFactory(source, jsplugin.Options{Key: "ctyun"})
require.NoError(t, err)
for _, name := range []string{"cdance2.0-fast-0807", "cdance2.0-mini-0807", "cdance2.0-0807", "cdance2.0-0813"} {
t.Run(name, func(t *testing.T) {
_, found := registry.Generation().LookupEndpoint("POST", "/v1/videos", name)
require.True(t, found)
// The family must also work after an administrator maps a display alias.
ctx := map[string]any{"model": "custom-video", "upstreamModel": name, "baseUrl": "https://ai.ctaigw.cn", "apiKey": "app", "taskId": "job", "requestBody": map[string]any{"prompt": "waves", "seconds": 4, "size": "720P"}}
submit, callErr := plugin.Engine.Call(t.Context(), "buildSubmitRequest", ctx)
require.NoError(t, callErr)
raw, err := common.Marshal(submit)
require.NoError(t, err)
assert.Equal(t, "https://ai.ctaigw.cn/v1/contents/generations/tasks", gjson.GetBytes(raw, "url").String())
assert.Equal(t, name, gjson.GetBytes(raw, "body.model").String())
assert.Equal(t, "720p", gjson.GetBytes(raw, "body.resolution").String())
query, callErr := plugin.Engine.Call(t.Context(), "buildQueryRequest", ctx)
require.NoError(t, callErr)
assert.Equal(t, "https://ai.ctaigw.cn/v1/contents/generations/tasks/job", query.(map[string]any)["url"])
})
}
standard := `u("resolution") == "1080P" && u("video_input") == "video" ? tier("1080P_video", u("tokens") * 28.861 / 1000000) : u("resolution") == "1080P" ? tier("1080P_none", u("tokens") * 47.481 / 1000000) : u("video_input") == "video" ? tier("480P_720P_video", u("tokens") * 26.068 / 1000000) : tier("480P_720P_none", u("tokens") * 42.826 / 1000000)`
mini := `u("video_input") == "video" ? tier("video", u("tokens") * 16.0524 / 1000000) : tier("none", u("tokens") * 26.3718 / 1000000)`
for _, tc := range []struct {
model, resolution, expression string
video bool
quota int
}{
{"cdance2.0-0807", "480P", standard, false, 2141300},
{"cdance2.0-0807", "480P", standard, true, 1303400},
{"cdance2.0-0807", "720P", standard, false, 2141300},
{"cdance2.0-0807", "720P", standard, true, 1303400},
{"cdance2.0-0807", "1080P", standard, false, 2374050},
{"cdance2.0-0807", "1080P", standard, true, 1443050},
{"cdance2.0-mini-0807", "720P", mini, false, 1318590},
{"cdance2.0-mini-0807", "480P", mini, true, 802620},
} {
t.Run(fmt.Sprintf("%s/%s/video=%t", tc.model, tc.resolution, tc.video), func(t *testing.T) {
request := map[string]any{"prompt": "waves", "seconds": 4, "size": tc.resolution}
if tc.video {
request["metadata"] = map[string]any{"content": []any{map[string]any{"type": "video_url", "video_url": map[string]any{"url": "https://example.com/input.mp4"}, "role": "reference_video"}}}
}
ctx := map[string]any{"model": tc.model, "requestBody": request}
value, callErr := plugin.Engine.Call(t.Context(), "extractUsage", ctx)
require.NoError(t, callErr)
facts := value.(map[string]any)
assert.Equal(t, map[bool]string{false: "none", true: "video"}[tc.video], facts["video_input"])
body := map[string]any{"status": "succeeded", "duration": 3, "resolution": strings.ToLower(tc.resolution), "usage": map[string]any{"total_tokens": 100000}}
value, callErr = plugin.Engine.Call(t.Context(), "extractUsageOnComplete", ctx, map[string]any{"status": "SUCCESS"}, body)
require.NoError(t, callErr)
completed := value.(map[string]any)
assert.NotContains(t, completed, "video_input", "completion without content must preserve the submission tier")
for key, value := range completed {
facts[key] = value
}
raw, err := common.Marshal(facts)
require.NoError(t, err)
assert.Equal(t, int64(3), gjson.GetBytes(raw, "seconds").Int())
result, err := billingexpr.ComputeTieredQuotaWithRequest(&billingexpr.BillingSnapshot{ExprString: tc.expression, ExprHash: billingexpr.ExprHashString(tc.expression), GroupRatio: 1, QuotaPerUnit: 500000, ExprVersion: 1, TaskUsageBilling: true}, billingexpr.TokenParams{}, billingexpr.RequestInput{Usage: facts})
require.NoError(t, err)
assert.Equal(t, tc.quota, result.ActualQuotaAfterGroup)
})
}
}
func TestCTyunImageReservationAndPartialResults(t *testing.T) {
for _, tc := range []struct {
name, extra string
count uint
invalid bool
}{
{"native count", `{"parameters":{"n":6}}`, 6, false},
{"native zero", `{"parameters":{"n":0}}`, 0, true},
{"native overflow", `{"parameters":{"n":18446744073709551615}}`, 0, true},
{"group default", `{"sequential_image_generation":"auto"}`, 15, false},
{"group count", `{"sequential_image_generation":"auto","sequential_image_generation_options":{"max_images":4}}`, 4, false},
} {
t.Run(tc.name, func(t *testing.T) {
var req dto.ImageRequest
require.NoError(t, common.UnmarshalJsonStr(tc.extra, &req))
err := ctyun.NormalizeImageCount(&req)
if tc.invalid {
require.Error(t, err)
return
}
require.NoError(t, err)
require.NotNil(t, req.N)
assert.Equal(t, tc.count, *req.N)
a, info, c, _ := fixture(t, "seedream-4.0", relayconstant.RelayModeImagesGenerations)
if tc.name == "native count" {
a, info, c, _ = fixture(t, "qwen-image", relayconstant.RelayModeImagesGenerations)
}
converted, err := a.ConvertImageRequest(c, info, req)
require.NoError(t, err)
raw, err := common.Marshal(converted)
require.NoError(t, err)
path := "sequential_image_generation_options.max_images"
if tc.name == "native count" {
path = "parameters.n"
}
assert.Equal(t, uint64(tc.count), gjson.GetBytes(raw, path).Uint())
})
}
a, info, c, _ := fixture(t, "seedream-4.0", relayconstant.RelayModeImagesGenerations)
info.PriceData.UsePrice = true
info.PriceData.AddOtherRatio("n", 4)
_, apiErr := a.DoResponse(c, response(`{"data":[{"url":"https://example.com/1.png"},{"error":{"message":"failed"}},{"b64_json":"aW1hZ2U="}],"usage":{"output_tokens":20}}`), info)
require.Nil(t, apiErr)
assert.Equal(t, 2.0, info.PriceData.OtherRatios()["n"])
a, info, c, _ = fixture(t, "seedream-4.0", relayconstant.RelayModeImagesGenerations)
_, apiErr = a.DoResponse(c, response(`{"data":[{"error":{"message":"failed"}}]}`), info)
require.NotNil(t, apiErr)
}
func TestCTyunImageGroupStream(t *testing.T) {
oldTimeout := constant.StreamingTimeout
constant.StreamingTimeout = 30
t.Cleanup(func() { constant.StreamingTimeout = oldTimeout })
for _, tc := range []struct {
name, body string
failed bool
}{
{"two successes and group completion", "data: {\"type\":\"image_generation.partial_succeeded\",\"url\":\"https://example.com/1.png\"}\n\ndata: {\"type\":\"image_generation.partial_succeeded\",\"url\":\"https://example.com/2.png\"}\n\ndata: {\"type\":\"image_generation.completed\",\"usage\":{\"generated_images\":2,\"output_tokens\":20}}\n\ndata: [DONE]\n\n", false},
{"business error", "data: {\"code\":\"Failed\",\"message\":\"generation failed\"}\n\n", true},
} {
t.Run(tc.name, func(t *testing.T) {
a, info, c, _ := fixture(t, "seedream-4.0", relayconstant.RelayModeImagesGenerations)
info.IsStream = true
info.PriceData.UsePrice = true
info.PriceData.AddOtherRatio("n", 4)
resp := response(tc.body)
resp.Header.Set("Content-Type", "text/event-stream")
usage, apiErr := a.DoResponse(c, resp, info)
if tc.failed {
require.NotNil(t, apiErr)
assert.True(t, types.IsSkipRetryError(apiErr))
return
}
require.Nil(t, apiErr)
assert.Equal(t, 2.0, info.PriceData.OtherRatios()["n"])
assert.Equal(t, 20, usage.(*dto.Usage).CompletionTokens)
})
}
}
package ctyun
import (
"fmt"
"net/http"
"slices"
"github.com/QuantumNous/new-api/common"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/types"
"github.com/gin-gonic/gin"
)
type multimodalEmbeddingParameters struct {
Dimension *int `json:"dimension,omitempty"`
EnableFusion *bool `json:"enable_fusion,omitempty"`
OutputType *string `json:"output_type,omitempty"`
Instruct *string `json:"instruct,omitempty"`
FPS *float64 `json:"fps,omitempty"`
}
func (a *Adaptor) convertMultimodalEmbedding(req dto.EmbeddingRequest) (any, error) {
if req.EncodingFormat != "" && req.EncodingFormat != "float" {
return nil, invalidRequest("CTyun multimodal embedding supports only float encoding")
}
var input struct {
Contents []map[string]any `json:"contents"`
}
switch value := req.Input.(type) {
case string:
input.Contents = []map[string]any{{"text": value}}
default:
raw, err := common.Marshal(value)
if err != nil {
return nil, err
}
if err = common.Unmarshal(raw, &input); err != nil {
return nil, invalidRequest("multimodal input must contain a contents array")
}
}
if len(input.Contents) == 0 {
return nil, invalidRequest("input.contents is empty")
}
for _, content := range input.Contents {
count := 0
for _, key := range []string{"text", "image", "video"} {
if value, exists := content[key]; exists {
text, ok := value.(string)
if !ok || text == "" {
return nil, invalidRequest("input.contents.%s must be a nonempty string", key)
}
count++
}
}
if count != 1 {
return nil, invalidRequest("each input content must contain exactly one modality")
}
}
parameters := multimodalEmbeddingParameters{Dimension: req.Dimensions}
if len(req.Parameters) > 0 {
if err := common.Unmarshal(req.Parameters, &parameters); err != nil {
return nil, invalidRequest("invalid multimodal parameters: %v", err)
}
}
if parameters.Dimension != nil && !slices.Contains([]int{2560, 2048, 1536, 1024, 768, 512, 256}, *parameters.Dimension) {
return nil, invalidRequest("unsupported embedding dimension")
}
if parameters.OutputType != nil && *parameters.OutputType != "dense" {
return nil, invalidRequest("output_type must be dense")
}
if parameters.FPS != nil && (*parameters.FPS < 0 || *parameters.FPS > 1) {
return nil, invalidRequest("fps must be between 0 and 1")
}
a.embeddingCount = len(input.Contents)
a.embeddingDimension = 2560
if parameters.Dimension != nil {
a.embeddingDimension = *parameters.Dimension
}
return struct {
Model string `json:"model"`
Input any `json:"input"`
Parameters multimodalEmbeddingParameters `json:"parameters"`
}{req.Model, input, parameters}, nil
}
func (a *Adaptor) multimodalEmbeddingResponse(c *gin.Context, body []byte, info *relaycommon.RelayInfo) (any, *types.NewAPIError) {
var upstream struct {
Output struct {
Embeddings []dto.EmbeddingResponseItem `json:"embeddings"`
} `json:"output"`
Usage struct {
InputTokens int `json:"input_tokens"`
ImageTokens int `json:"image_tokens"`
TotalTokens int `json:"total_tokens"`
} `json:"usage"`
}
if err := common.Unmarshal(body, &upstream); err != nil {
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusBadGateway)
}
if len(upstream.Output.Embeddings) == 0 || upstream.Usage.InputTokens < 0 || upstream.Usage.ImageTokens < 0 || upstream.Usage.TotalTokens < 0 {
return nil, types.NewOpenAIError(fmt.Errorf("invalid multimodal embedding response"), types.ErrorCodeBadResponseBody, http.StatusBadGateway)
}
seen := make(map[int]bool)
for i := range upstream.Output.Embeddings {
item := &upstream.Output.Embeddings[i]
if item.Index < 0 || item.Index >= a.embeddingCount || seen[item.Index] || len(item.Embedding) != a.embeddingDimension {
return nil, types.NewOpenAIError(fmt.Errorf("invalid embedding index or dimension"), types.ErrorCodeBadResponseBody, http.StatusBadGateway)
}
seen[item.Index] = true
item.Object = "embedding"
}
total, clamp := common.QuotaFromFloatChecked(float64(upstream.Usage.InputTokens) + float64(upstream.Usage.ImageTokens))
if clamp != nil {
info.QuotaClamp = clamp
}
usage := dto.Usage{PromptTokens: total, TotalTokens: total}
usage.PromptTokensDetails.ImageTokens = upstream.Usage.ImageTokens
response := dto.EmbeddingResponse{Object: "list", Model: info.OriginModelName, Data: upstream.Output.Embeddings, Usage: usage}
c.JSON(http.StatusOK, response)
return &usage, nil
}
......@@ -17,6 +17,7 @@ import (
"github.com/QuantumNous/new-api/service"
"github.com/gin-gonic/gin"
"github.com/tidwall/gjson"
)
func sendStreamData(c *gin.Context, info *relaycommon.RelayInfo, data string, forceFormat bool, thinkToContent bool) error {
......@@ -117,6 +118,7 @@ func OaiStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Re
var toolCount int
var usage = &dto.Usage{}
var lastStreamData string
var upstreamStreamError *types.NewAPIError
var secondLastStreamData string // 保留倒数第二个stream data;部分兼容网关把完整usage放在倒数第二个事件
seenStreamToolCalls := make(map[string]struct{})
var streamFunctionCallNames []string
......@@ -128,6 +130,20 @@ func OaiStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Re
sr.Error(err)
}
}
if info.ChannelType == constant.ChannelTypeCTyun {
errorData := gjson.Get(data, "error")
code := gjson.Get(data, "code")
if (errorData.Exists() && errorData.Type != gjson.Null) || (code.Exists() && code.String() != "" && code.String() != "0") {
message := gjson.Get(data, "message").String()
if errorData.Exists() {
message = errorData.Get("message").String()
code = errorData.Get("code")
}
upstreamStreamError = types.WithOpenAIError(types.OpenAIError{Type: "ctyun_error", Message: message, Code: code.String()}, http.StatusBadGateway, types.ErrOptionWithSkipRetry())
sr.Stop(fmt.Errorf("CTyun stream error: %s", message))
return
}
}
if len(data) > 0 {
if lastStreamData != "" {
secondLastStreamData = lastStreamData
......@@ -142,6 +158,10 @@ func OaiStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Re
}
})
if upstreamStreamError != nil {
return nil, upstreamStreamError
}
// 处理最后的响应
shouldSendLastResp := true
if err := handleLastResponse(lastStreamData, &responseId, &createAt, &systemFingerprint, &model, &usage,
......
......@@ -10,6 +10,7 @@ import (
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/logger"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/relay/helper"
......@@ -49,7 +50,19 @@ func OpenaiImageHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.
return nil, types.WithOpenAIError(*oaiError, resp.StatusCode)
}
updateOpenAIImageCount(info, gjson.GetBytes(responseBody, "data.#").Int())
count := gjson.GetBytes(responseBody, "data.#").Int()
if info.ChannelType == constant.ChannelTypeCTyun {
count = 0
for _, item := range gjson.GetBytes(responseBody, "data").Array() {
if item.Get("url").String() != "" || item.Get("b64_json").String() != "" {
count++
}
}
if count == 0 {
return nil, types.NewOpenAIError(fmt.Errorf("CTyun returned no successful images"), types.ErrorCodeBadResponseBody, http.StatusBadGateway, types.ErrOptionWithSkipRetry())
}
}
updateOpenAIImageCount(info, count)
// 写入新的 response body
service.IOCopyBytesGracefully(c, resp, responseBody)
......@@ -112,10 +125,14 @@ func OpenaiImageStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp
usage := &dto.Usage{}
var lastStreamData []byte
var completedImages int64
var ctyunStreamError error
helper.StreamScannerHandler(c, resp, info, func(data string, sr *helper.StreamResult) {
raw := common.StringToByteSlice(data)
lastStreamData = raw
if info.ChannelType == constant.ChannelTypeCTyun && (isOpenAIImageStreamErrorEvent(raw) || (gjson.GetBytes(raw, "code").Exists() && gjson.GetBytes(raw, "code").String() != "0" && gjson.GetBytes(raw, "code").String() != "")) {
ctyunStreamError = fmt.Errorf("%s", extractOpenAIImageStreamErrorMessage(raw))
}
if isOpenAIImageStreamErrorEvent(raw) {
// Record the error as a soft error; the scanner drives the final
// EndReason. HasErrors() flags the failure for logging/handling.
......@@ -130,7 +147,16 @@ func OpenaiImageStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp
if service.ValidUsage(&chunk.Usage) {
usage = &chunk.Usage
}
if chunk.Type == "image_generation.completed" || chunk.Type == "image_edit.completed" {
if info.ChannelType == constant.ChannelTypeCTyun {
// Seedream emits one completion for the entire group, unlike
// OpenAI's per-image completion events.
if chunk.Type == "image_generation.partial_succeeded" {
completedImages++
}
if count := gjson.GetBytes(raw, "usage.generated_images"); count.Exists() && count.Int() > 0 && count.Int() <= int64(dto.MaxImageN) {
completedImages = count.Int()
}
} else if chunk.Type == "image_generation.completed" || chunk.Type == "image_edit.completed" {
completedImages++
}
}
......@@ -145,6 +171,9 @@ func OpenaiImageStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp
helper.Done(c)
}
if ctyunStreamError != nil && completedImages == 0 {
return nil, types.NewOpenAIError(ctyunStreamError, types.ErrorCodeBadResponseBody, http.StatusBadGateway, types.ErrOptionWithSkipRetry())
}
applyUsagePostProcessing(info, usage, lastStreamData)
// Only trust completedImages when upstream finished the stream (done/eof).
// On client-side aborts (client_gone, or handler_stop from a failed client
......
......@@ -356,6 +356,7 @@ func (info *RelayInfo) ToString() string {
// 定义支持流式选项的通道类型
var streamSupportedChannels = map[int]bool{
constant.ChannelTypeCTyun: true,
constant.ChannelTypeOpenAI: true,
constant.ChannelTypeAnthropic: true,
constant.ChannelTypeAws: true,
......
......@@ -18,6 +18,7 @@ import (
"github.com/QuantumNous/new-api/relay/channel/codex"
"github.com/QuantumNous/new-api/relay/channel/cohere"
"github.com/QuantumNous/new-api/relay/channel/coze"
"github.com/QuantumNous/new-api/relay/channel/ctyun"
"github.com/QuantumNous/new-api/relay/channel/deepseek"
"github.com/QuantumNous/new-api/relay/channel/dify"
"github.com/QuantumNous/new-api/relay/channel/gemini"
......@@ -49,6 +50,8 @@ import (
func GetAdaptor(apiType int) channel.Adaptor {
switch apiType {
case constant.APITypeCTyun:
return &ctyun.Adaptor{}
case constant.APITypeAli:
return &ali.Adaptor{}
case constant.APITypeAnthropic:
......@@ -139,6 +142,7 @@ func GetTaskPlatform(c *gin.Context) constant.TaskPlatform {
}
var taskPluginKeys = map[constant.TaskPlatform]string{
constant.TaskPlatform(strconv.Itoa(constant.ChannelTypeCTyun)): "ctyun",
constant.TaskPlatformSuno: "sunoapi",
constant.TaskPlatform(strconv.Itoa(constant.ChannelTypeAli)): "alibaba",
constant.TaskPlatform(strconv.Itoa(constant.ChannelTypeKling)): "kling",
......
package dto
import (
"encoding/json"
"net/http"
"strings"
......@@ -19,16 +20,17 @@ type EmbeddingOptions struct {
}
type EmbeddingRequest struct {
Model string `json:"model"`
Input any `json:"input"`
EncodingFormat string `json:"encoding_format,omitempty"`
Dimensions *int `json:"dimensions,omitempty"`
User string `json:"user,omitempty"`
Seed *float64 `json:"seed,omitempty"`
Temperature *float64 `json:"temperature,omitempty"`
TopP *float64 `json:"top_p,omitempty"`
FrequencyPenalty *float64 `json:"frequency_penalty,omitempty"`
PresencePenalty *float64 `json:"presence_penalty,omitempty"`
Parameters json.RawMessage `json:"parameters,omitempty"`
Model string `json:"model"`
Input any `json:"input"`
EncodingFormat string `json:"encoding_format,omitempty"`
Dimensions *int `json:"dimensions,omitempty"`
User string `json:"user,omitempty"`
Seed *float64 `json:"seed,omitempty"`
Temperature *float64 `json:"temperature,omitempty"`
TopP *float64 `json:"top_p,omitempty"`
FrequencyPenalty *float64 `json:"frequency_penalty,omitempty"`
PresencePenalty *float64 `json:"presence_penalty,omitempty"`
}
func (r *EmbeddingRequest) GetTokenCountMeta() *types.TokenCountMeta {
......
......@@ -9,13 +9,15 @@ import (
)
type RerankRequest struct {
Documents []any `json:"documents"`
Query string `json:"query"`
Model string `json:"model"`
TopN *int `json:"top_n,omitempty"`
ReturnDocuments *bool `json:"return_documents,omitempty"`
MaxChunkPerDoc *int `json:"max_chunk_per_doc,omitempty"`
OverLapTokens *int `json:"overlap_tokens,omitempty"`
Instruct *string `json:"instruct,omitempty"`
TruncatePromptTokens *int `json:"truncate_prompt_tokens,omitempty"`
Documents []any `json:"documents"`
Query string `json:"query"`
Model string `json:"model"`
TopN *int `json:"top_n,omitempty"`
ReturnDocuments *bool `json:"return_documents,omitempty"`
MaxChunkPerDoc *int `json:"max_chunk_per_doc,omitempty"`
OverLapTokens *int `json:"overlap_tokens,omitempty"`
}
func (r *RerankRequest) IsStream(c *http.Request) bool {
......
......@@ -84,10 +84,11 @@ export const CHANNEL_TYPES = {
59: 'Sub2API',
60: 'New API',
61: 'Task Plugin',
62: 'CTyun',
} as const
const CHANNEL_TYPE_DISPLAY_ORDER: number[] = [
1, 14, 33, 24, 43, 3, 41, 48, 60, 58, 61, 42, 34, 20, 4, 40, 27, 25, 17, 26,
1, 14, 33, 24, 43, 3, 41, 48, 60, 62, 58, 61, 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, 59, 22, 21,
44, 2, 5, 36, 50, 51, 52, 53, 54, 55, 56,
]
......
......@@ -25,6 +25,9 @@ import {
} from '../../constants'
describe('channel type options for task plugin bind', () => {
test('offers CTyun without requiring a custom task plugin binding', () => {
expect(channelTypeOptionsForTaskPluginBind(false)).toContainEqual({ value: 62, label: 'CTyun' })
})
test('hides the task plugin type when the caller cannot bind', () => {
const options = channelTypeOptionsForTaskPluginBind(false)
......
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { cleanup, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { Toaster, toast } from 'sonner'
import { afterEach, expect, it, vi } from 'vitest'
import { api } from '@/lib/api'
import { ImportModelsDialog } from '../components/dialogs/import-models-dialog'
afterEach(() => {
toast.dismiss()
cleanup()
})
it.each([false, true])(
'imports metadata and pricing with overwrite=%s and refreshes prices',
async (overwrite) => {
const post = vi.spyOn(api, 'post').mockResolvedValue({
data: {
success: true,
data: {
created: 1,
updated: 0,
skipped: 0,
failed: 0,
pricing_updated: 1,
pricing_failed: 0,
rows: [],
},
},
})
const client = new QueryClient({
defaultOptions: { mutations: { retry: false } },
})
const invalidate = vi.spyOn(client, 'invalidateQueries')
const close = vi.fn()
render(
<QueryClientProvider client={client}>
<ImportModelsDialog open onOpenChange={close} />
</QueryClientProvider>
)
const user = userEvent.setup()
const file = new File(['workbook'], 'models.xlsx', {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
})
await user.upload(screen.getByLabelText('Excel file'), file)
if (overwrite) {
await user.click(
screen.getByRole('checkbox', { name: 'Overwrite existing models' })
)
}
await user.click(screen.getByRole('button', { name: 'Import' }))
await waitFor(() => expect(close).toHaveBeenCalledWith(false))
expect(post).toHaveBeenCalledWith(
'/api/models/import',
expect.any(FormData),
{
params: overwrite ? { overwrite: true } : undefined,
skipBusinessError: true,
}
)
expect((post.mock.calls[0][1] as FormData).get('file')).toBe(file)
expect(invalidate).toHaveBeenCalledWith({
queryKey: ['model-pricing-config'],
})
expect(invalidate).toHaveBeenCalledWith({ queryKey: ['pricing'] })
}
)
it('keeps the upload open when the server rejects pricing permissions', async () => {
vi.spyOn(api, 'post').mockResolvedValue({
data: {
success: false,
message: 'Model pricing is managed by a super administrator.',
},
})
const client = new QueryClient({
defaultOptions: { mutations: { retry: false } },
})
const close = vi.fn()
render(
<QueryClientProvider client={client}>
<ImportModelsDialog open onOpenChange={close} />
</QueryClientProvider>
)
const user = userEvent.setup()
await user.upload(
screen.getByLabelText('Excel file'),
new File(['workbook'], 'models.xlsx', {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
})
)
await user.click(screen.getByRole('button', { name: 'Import' }))
await waitFor(() =>
expect(screen.getByRole('button', { name: 'Import' })).toBeEnabled()
)
expect(close).not.toHaveBeenCalled()
expect(
(screen.getByLabelText('Excel file') as HTMLInputElement).files?.[0].name
).toBe('models.xlsx')
})
it('reports a pricing failure separately when model metadata was imported', async () => {
vi.spyOn(api, 'post').mockResolvedValue({
data: {
success: true,
data: {
created: 1,
updated: 0,
skipped: 0,
failed: 0,
pricing_updated: 0,
pricing_failed: 1,
rows: [
{
row: 2,
model_name: 'example-model',
status: 'created',
pricing_status: 'failed',
pricing_message: 'Storage unavailable',
},
],
},
},
})
const client = new QueryClient({
defaultOptions: { mutations: { retry: false } },
})
render(
<QueryClientProvider client={client}>
<Toaster />
<ImportModelsDialog open onOpenChange={() => {}} />
</QueryClientProvider>
)
const user = userEvent.setup()
await user.upload(
screen.getByLabelText('Excel file'),
new File(['workbook'], 'models.xlsx', {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
})
)
await user.click(screen.getByRole('button', { name: 'Import' }))
expect(
await screen.findByText('Pricing import: 0 updated, 1 failed.')
).toBeInTheDocument()
expect(
await screen.findByText('Failed models: example-model')
).toBeInTheDocument()
})
......@@ -25,6 +25,7 @@ import { toast } from 'sonner'
import { ExcelImportDialog } from '@/components/excel-import-dialog'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { invalidateModelPricing } from '@/features/model-pricing/api'
import { importModels } from '../../api'
import { modelsQueryKeys, vendorsQueryKeys } from '../../lib'
......@@ -63,8 +64,20 @@ export function ImportModelsDialog(props: ImportModelsDialogProps) {
}
)
)
if (result?.pricing_updated || result?.pricing_failed) {
toast[result.pricing_failed ? 'error' : 'success'](
t('Pricing import: {{updated}} updated, {{failed}} failed.', {
updated: result.pricing_updated,
failed: result.pricing_failed,
})
)
}
const failedModels = result?.rows
.filter((row) => row.status === 'failed' && row.model_name)
.filter(
(row) =>
(row.status === 'failed' || row.pricing_status === 'failed') &&
row.model_name
)
.map((row) => row.model_name)
if (failedModels?.length) {
toast.error(
......@@ -73,6 +86,7 @@ export function ImportModelsDialog(props: ImportModelsDialogProps) {
}
await Promise.all([
queryClient.invalidateQueries({ queryKey: modelsQueryKeys.lists() }),
invalidateModelPricing(queryClient),
queryClient.invalidateQueries({ queryKey: vendorsQueryKeys.lists() }),
])
return true
......@@ -88,7 +102,9 @@ export function ImportModelsDialog(props: ImportModelsDialogProps) {
open={props.open}
onOpenChange={props.onOpenChange}
title={t('Import models')}
description={t('Import model metadata from an Excel workbook.')}
description={t(
'Import model metadata and optional pricing from an Excel workbook.'
)}
isPending={isPending}
allowOverwrite
onImport={handleImport}
......@@ -102,6 +118,18 @@ export function ImportModelsDialog(props: ImportModelsDialogProps) {
)}
</AlertDescription>
</Alert>
<Alert>
<HugeiconsIcon icon={InformationCircleIcon} aria-hidden='true' />
<AlertTitle>{t('Pricing import requirements')}</AlertTitle>
<AlertDescription>
{t(
'The first row must contain headers, and model_name is required. For expression billing, provide billing_expr and leave all price columns blank. Use len for input-length tiers. Otherwise provide fixed_price or input_price; optional columns are completion_price, cache_price, create_cache_price, image_price, audio_input_price, and audio_output_price.'
)}{' '}
{t(
'Leave all price cells blank to keep pricing unchanged. Pricing requires a super administrator and exact model names. Existing models and their prices are skipped unless overwrite is enabled. Prices use USD per million tokens, or USD per request for fixed_price; no currency conversion is applied.'
)}
</AlertDescription>
</Alert>
</ExcelImportDialog>
)
}
......@@ -262,11 +262,15 @@ export interface ImportModelsResponse {
success: boolean
message?: string
data?: {
pricing_updated: number
pricing_failed: number
created: number
updated: number
skipped: number
failed: number
rows: Array<{
pricing_status?: string
pricing_message?: string
row: number
model_name?: string
status: 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 { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { cleanup, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { afterEach, describe, expect, it } from 'vitest'
import { ModelDetailsApi } from '../components/model-details-api'
import { exampleScenarios, profileRequest } from '../lib/api-example-profiles'
import { buildApiParameters, buildSample, type Lang } from '../lib/api-samples'
import type { PricingModel } from '../types'
const context = {
baseUrl: 'https://gateway.example.com',
apiKeyEnv: 'NEW_API_KEY',
modelName: 'minimax-h3',
endpointType: 'openai-video',
endpointPath: '/v1/videos',
}
describe('model API examples', () => {
it.each<Lang>(['curl', 'python', 'typescript', 'javascript'])(
'uses video fields and gateway task routes in %s',
(lang) => {
const sample = buildSample(lang, 'openai-video', context)
expect(sample).toContain('prompt')
expect(sample).toContain('/v1/videos')
expect(sample).not.toContain('messages')
}
)
it('uses query and documents for reranking', () => {
const sample = buildSample('curl', 'jina-rerank', {
...context,
modelName: 'qwen3-rerank',
endpointType: 'jina-rerank',
endpointPath: '/v1/rerank',
})
expect(sample).toContain('query')
expect(sample).toContain('documents')
expect(sample).not.toContain('"input"')
})
})
afterEach(cleanup)
it('shows only endpoint choices and updates parameters when switching', async () => {
const client = new QueryClient({
defaultOptions: { queries: { retry: false, staleTime: Infinity } },
})
client.setQueryData(['status'], {
server_address: 'https://gateway.example.com',
})
const legacyMetadata = { api_example_providers: ['ctyun', 'default'] }
const model: PricingModel = {
...legacyMetadata,
id: 1,
model_name: 'example-model',
quota_type: 0,
model_ratio: 1,
completion_ratio: 1,
enable_groups: ['default'],
supported_endpoint_types: ['openai', 'openai-video', 'jina-rerank'],
}
render(
<QueryClientProvider client={client}>
<ModelDetailsApi
model={model}
endpointMap={{
openai: { path: '/v1/chat/completions' },
'openai-video': { path: '/v1/videos' },
'jina-rerank': { path: '/v1/rerank' },
}}
/>
</QueryClientProvider>
)
const user = userEvent.setup()
expect(screen.queryByRole('tab', { name: 'CTyun' })).not.toBeInTheDocument()
expect(screen.queryByRole('tab', { name: 'Default' })).not.toBeInTheDocument()
await user.click(screen.getByRole('tab', { name: 'openai-video' }))
expect(screen.getByText('seconds')).toBeInTheDocument()
expect(screen.queryByText('temperature')).not.toBeInTheDocument()
await user.click(screen.getByRole('tab', { name: 'jina-rerank' }))
expect(screen.getByText('documents')).toBeInTheDocument()
expect(screen.queryByText('seconds')).not.toBeInTheDocument()
client.clear()
})
it.each([
['ctyun-h3', 'metadata', 'video_url'],
['ctyun-wan3', 'media', 'reference_video'],
])('uses the %s reference-video dialect', (profile, field, mediaType) => {
const sample = buildSample('curl', 'openai-video', {
...context,
profile,
scenario: 'video-reference',
})
expect(sample).toContain(`"${field}"`)
expect(sample).toContain(`"${mediaType}"`)
expect(sample).toContain('/v1/videos')
})
it('shows the channel filter only for distinct dialects and resets unsupported scenarios', async () => {
const client = new QueryClient({
defaultOptions: { queries: { retry: false, staleTime: Infinity } },
})
client.setQueryData(['status'], {
server_address: 'https://gateway.example.com',
})
const model: PricingModel = {
id: 2,
model_name: 'public-video',
quota_type: 0,
model_ratio: 1,
completion_ratio: 1,
enable_groups: ['default'],
supported_endpoint_types: ['openai-video'],
api_examples: [
{
endpoint: 'openai-video',
profile: 'ctyun-h3',
providers: ['CTyun'],
groups: ['a'],
},
{
endpoint: 'openai-video',
profile: 'ctyun-h3',
providers: ['CTyun'],
groups: ['b'],
},
{
endpoint: 'openai-video',
profile: 'ctyun-wan3',
providers: ['CTyun'],
groups: ['c'],
},
{
endpoint: 'openai-video',
profile: 'standard',
providers: ['OpenAI'],
groups: ['d'],
},
],
}
const endpointMap = { 'openai-video': { path: '/v1/videos' } }
const view = render(
<QueryClientProvider client={client}>
<ModelDetailsApi model={model} endpointMap={endpointMap} />
</QueryClientProvider>
)
const user = userEvent.setup()
expect(
screen.getByRole('tablist', { name: 'Example channel' })
).toBeInTheDocument()
expect(screen.getAllByRole('tab', { name: /MiniMax H3/ })).toHaveLength(1)
await user.click(screen.getByRole('tab', { name: 'Video reference' }))
expect(screen.getByText('metadata.content')).toBeInTheDocument()
await user.click(screen.getByRole('tab', { name: /Wan 3.0/ }))
expect(screen.getByText('metadata.input.media')).toBeInTheDocument()
expect(screen.queryByText('metadata.content')).not.toBeInTheDocument()
await user.click(screen.getByRole('tab', { name: /OpenAI/ }))
expect(
screen.queryByRole('tab', { name: 'Video reference' })
).not.toBeInTheDocument()
expect(screen.queryByText('metadata.input.media')).not.toBeInTheDocument()
view.rerender(
<QueryClientProvider client={client}>
<ModelDetailsApi
model={{ ...model, api_examples: model.api_examples?.slice(0, 2) }}
endpointMap={endpointMap}
/>
</QueryClientProvider>
)
expect(
screen.queryByRole('tablist', { name: 'Example channel' })
).not.toBeInTheDocument()
expect(
screen.getByRole('tab', { name: 'Video reference' })
).toBeInTheDocument()
client.clear()
})
it.each<Lang>(['curl', 'python', 'typescript', 'javascript'])(
'preserves client model names in %s adapted examples',
(lang) => {
const sample = buildSample(lang, 'openai-video', {
...context,
modelName: 'public-video',
profile: 'ctyun-h3',
scenario: 'video-reference',
})
expect(sample).toContain('public-video')
expect(sample).toContain('reference.mp4')
expect(sample).toContain('NEW_API_KEY')
expect(sample).not.toContain('ai.ctaigw')
}
)
it('limits HappyHorse references to images and keeps image editing and reranking fields distinct', () => {
expect(exampleScenarios('ctyun-happyhorse-reference')).toEqual([
'image-reference',
])
expect(
profileRequest(
'public-image',
'image-generation',
'ctyun-message-image-edit',
'image-reference'
)
).toMatchObject({ image: 'https://example.com/reference.jpg', stream: false })
const model = { model_name: 'public-rerank' } as PricingModel
expect(
buildApiParameters(model, 'jina-rerank', 'ctyun-qwen-rerank').map(
(p) => p.name
)
).toContain('instruct')
expect(
buildApiParameters(model, 'jina-rerank', 'ctyun-qwen-rerank').map(
(p) => p.name
)
).not.toContain('return_documents')
expect(
buildApiParameters(model, 'jina-rerank', 'ctyun-gte-rerank').map(
(p) => p.name
)
).toContain('return_documents')
expect(
profileRequest('public-vector', 'embeddings', 'ctyun-vl-embedding', 'basic')
).toMatchObject({
input: {
contents: [
{ text: 'A calm lake.' },
{ image: 'https://example.com/reference.jpg' },
],
},
})
})
......@@ -41,8 +41,15 @@ import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { useStatus } from '@/hooks/use-status'
import {
mergeAPIExamples,
exampleScenarios,
PROFILE_LABELS,
SCENARIO_LABELS,
type ExampleScenario,
} from '../lib/api-example-profiles'
import { buildApiParameters, buildSample, type Lang } from '../lib/api-samples'
import {
buildRateLimits,
buildSupportedParameters,
formatRateLimit,
type SupportedParameter,
} from '../lib/mock-stats'
......@@ -58,8 +65,6 @@ import type { PricingModel } from '../types'
// types the model actually supports. This keeps copy-pasted code accurate and
// provider-shaped (OpenAI vs Anthropic vs Gemini, etc.).
type Lang = 'curl' | 'python' | 'typescript' | 'javascript'
const LANG_LABELS: Record<Lang, string> = {
curl: 'cURL',
python: 'Python',
......@@ -74,368 +79,6 @@ const LANG_HIGHLIGHT: Record<Lang, BundledLanguage> = {
javascript: 'javascript',
}
type SampleContext = {
baseUrl: string
apiKeyEnv: string
modelName: string
endpointType: string
endpointPath: string
}
function buildChatSample(lang: Lang, ctx: SampleContext): string {
const url = `${ctx.baseUrl}${ctx.endpointPath}`
const isResponses = ctx.endpointType === 'openai-response'
const isReasoning = /^o[1-4]|reasoning|thinking|deepseek-r/i.test(
ctx.modelName
)
const userMessage = 'Explain quantum entanglement in one paragraph.'
const bodyJson = isResponses
? JSON.stringify({ model: ctx.modelName, input: userMessage }, null, 2)
: JSON.stringify(
{
model: ctx.modelName,
messages: [{ role: 'user', content: userMessage }],
...(isReasoning ? {} : { temperature: 0.7 }),
},
null,
2
)
const fnCall = isResponses ? 'responses.create' : 'chat.completions.create'
if (lang === 'curl') {
return [
`curl ${url} \\`,
` -H "Authorization: Bearer $${ctx.apiKeyEnv}" \\`,
` -H "Content-Type: application/json" \\`,
` -d '${bodyJson.replace(/\n/g, '\n ')}'`,
].join('\n')
}
if (lang === 'python') {
return [
'from openai import OpenAI',
'',
'client = OpenAI(',
` base_url="${ctx.baseUrl}/v1",`,
` api_key="<YOUR_API_KEY>",`,
')',
'',
isResponses
? `response = client.${fnCall}(\n model="${ctx.modelName}",\n input="${userMessage}",\n)\n\nprint(response.output_text)`
: `completion = client.${fnCall}(\n model="${ctx.modelName}",\n messages=[\n {"role": "user", "content": "${userMessage}"}\n ],\n)\n\nprint(completion.choices[0].message.content)`,
].join('\n')
}
if (lang === 'typescript') {
return [
`import OpenAI from 'openai'`,
'',
`const client = new OpenAI({`,
` baseURL: '${ctx.baseUrl}/v1',`,
` apiKey: process.env.${ctx.apiKeyEnv},`,
`})`,
'',
isResponses
? `const response = await client.${fnCall}({\n model: '${ctx.modelName}',\n input: '${userMessage}',\n})\n\nconsole.log(response.output_text)`
: `const completion = await client.${fnCall}({\n model: '${ctx.modelName}',\n messages: [{ role: 'user', content: '${userMessage}' }],\n})\n\nconsole.log(completion.choices[0].message.content)`,
].join('\n')
}
return [
`const response = await fetch('${url}', {`,
` method: 'POST',`,
` headers: {`,
` Authorization: \`Bearer \${process.env.${ctx.apiKeyEnv}}\`,`,
` 'Content-Type': 'application/json',`,
` },`,
` body: JSON.stringify(${bodyJson}),`,
`})`,
'',
`const data = await response.json()`,
`console.log(data)`,
].join('\n')
}
function buildAnthropicSample(lang: Lang, ctx: SampleContext): string {
const url = `${ctx.baseUrl}${ctx.endpointPath}`
const userMessage = 'Explain quantum entanglement in one paragraph.'
if (lang === 'curl') {
const body = JSON.stringify(
{
model: ctx.modelName,
max_tokens: 1024,
messages: [{ role: 'user', content: userMessage }],
},
null,
2
)
return [
`curl ${url} \\`,
` -H "x-api-key: $${ctx.apiKeyEnv}" \\`,
` -H "anthropic-version: 2023-06-01" \\`,
` -H "Content-Type: application/json" \\`,
` -d '${body.replace(/\n/g, '\n ')}'`,
].join('\n')
}
if (lang === 'python') {
return [
'import anthropic',
'',
'client = anthropic.Anthropic(',
` base_url="${ctx.baseUrl}",`,
` api_key="<YOUR_API_KEY>",`,
')',
'',
`message = client.messages.create(`,
` model="${ctx.modelName}",`,
` max_tokens=1024,`,
` messages=[{"role": "user", "content": "${userMessage}"}],`,
')',
'',
'print(message.content[0].text)',
].join('\n')
}
if (lang === 'typescript') {
return [
`import Anthropic from '@anthropic-ai/sdk'`,
'',
`const client = new Anthropic({`,
` baseURL: '${ctx.baseUrl}',`,
` apiKey: process.env.${ctx.apiKeyEnv},`,
`})`,
'',
`const message = await client.messages.create({`,
` model: '${ctx.modelName}',`,
` max_tokens: 1024,`,
` messages: [{ role: 'user', content: '${userMessage}' }],`,
`})`,
'',
`console.log(message.content[0].text)`,
].join('\n')
}
return [
`const response = await fetch('${url}', {`,
` method: 'POST',`,
` headers: {`,
` 'x-api-key': process.env.${ctx.apiKeyEnv},`,
` 'anthropic-version': '2023-06-01',`,
` 'Content-Type': 'application/json',`,
` },`,
` body: JSON.stringify({`,
` model: '${ctx.modelName}',`,
` max_tokens: 1024,`,
` messages: [{ role: 'user', content: '${userMessage}' }],`,
` }),`,
`})`,
'',
`const data = await response.json()`,
`console.log(data.content[0].text)`,
].join('\n')
}
function buildGeminiSample(lang: Lang, ctx: SampleContext): string {
const url = `${ctx.baseUrl}${ctx.endpointPath}?key=$${ctx.apiKeyEnv}`
const userMessage = 'Explain quantum entanglement in one paragraph.'
if (lang === 'curl') {
const body = JSON.stringify(
{ contents: [{ parts: [{ text: userMessage }] }] },
null,
2
)
return [
`curl '${url}' \\`,
` -H 'Content-Type: application/json' \\`,
` -d '${body.replace(/\n/g, '\n ')}'`,
].join('\n')
}
if (lang === 'python') {
return [
'import google.generativeai as genai',
'',
`genai.configure(api_key="<YOUR_API_KEY>")`,
'',
`model = genai.GenerativeModel("${ctx.modelName}")`,
`response = model.generate_content("${userMessage}")`,
'',
`print(response.text)`,
].join('\n')
}
if (lang === 'typescript') {
return [
`import { GoogleGenerativeAI } from '@google/generative-ai'`,
'',
`const genAI = new GoogleGenerativeAI(process.env.${ctx.apiKeyEnv}!)`,
`const model = genAI.getGenerativeModel({ model: '${ctx.modelName}' })`,
'',
`const result = await model.generateContent('${userMessage}')`,
`console.log(result.response.text())`,
].join('\n')
}
return [
`const response = await fetch('${url}', {`,
` method: 'POST',`,
` headers: { 'Content-Type': 'application/json' },`,
` body: JSON.stringify({`,
` contents: [{ parts: [{ text: '${userMessage}' }] }],`,
` }),`,
`})`,
'',
`const data = await response.json()`,
`console.log(data.candidates[0].content.parts[0].text)`,
].join('\n')
}
function buildEmbeddingSample(lang: Lang, ctx: SampleContext): string {
const url = `${ctx.baseUrl}${ctx.endpointPath}`
const text = 'The food was delicious and the waiter…'
if (lang === 'curl') {
const body = JSON.stringify({ model: ctx.modelName, input: text }, null, 2)
return [
`curl ${url} \\`,
` -H "Authorization: Bearer $${ctx.apiKeyEnv}" \\`,
` -H "Content-Type: application/json" \\`,
` -d '${body.replace(/\n/g, '\n ')}'`,
].join('\n')
}
if (lang === 'python') {
return [
'from openai import OpenAI',
'',
`client = OpenAI(base_url="${ctx.baseUrl}/v1", api_key="<YOUR_API_KEY>")`,
'',
'response = client.embeddings.create(',
` model="${ctx.modelName}",`,
` input="${text}",`,
')',
'',
'print(response.data[0].embedding[:8])',
].join('\n')
}
if (lang === 'typescript') {
return [
`import OpenAI from 'openai'`,
'',
`const client = new OpenAI({`,
` baseURL: '${ctx.baseUrl}/v1',`,
` apiKey: process.env.${ctx.apiKeyEnv},`,
`})`,
'',
`const response = await client.embeddings.create({`,
` model: '${ctx.modelName}',`,
` input: '${text}',`,
`})`,
'',
`console.log(response.data[0].embedding.slice(0, 8))`,
].join('\n')
}
return [
`const response = await fetch('${url}', {`,
` method: 'POST',`,
` headers: {`,
` Authorization: \`Bearer \${process.env.${ctx.apiKeyEnv}}\`,`,
` 'Content-Type': 'application/json',`,
` },`,
` body: JSON.stringify({`,
` model: '${ctx.modelName}',`,
` input: '${text}',`,
` }),`,
`})`,
'',
`const data = await response.json()`,
`console.log(data.data[0].embedding.slice(0, 8))`,
].join('\n')
}
function buildImageSample(lang: Lang, ctx: SampleContext): string {
const url = `${ctx.baseUrl}${ctx.endpointPath}`
const prompt = 'A serene koi pond at sunset, ukiyo-e style.'
if (lang === 'curl') {
const body = JSON.stringify(
{ model: ctx.modelName, prompt, size: '1024x1024', n: 1 },
null,
2
)
return [
`curl ${url} \\`,
` -H "Authorization: Bearer $${ctx.apiKeyEnv}" \\`,
` -H "Content-Type: application/json" \\`,
` -d '${body.replace(/\n/g, '\n ')}'`,
].join('\n')
}
if (lang === 'python') {
return [
'from openai import OpenAI',
'',
`client = OpenAI(base_url="${ctx.baseUrl}/v1", api_key="<YOUR_API_KEY>")`,
'',
'response = client.images.generate(',
` model="${ctx.modelName}",`,
` prompt="${prompt}",`,
` size="1024x1024",`,
` n=1,`,
')',
'',
'print(response.data[0].url)',
].join('\n')
}
if (lang === 'typescript') {
return [
`import OpenAI from 'openai'`,
'',
`const client = new OpenAI({`,
` baseURL: '${ctx.baseUrl}/v1',`,
` apiKey: process.env.${ctx.apiKeyEnv},`,
`})`,
'',
`const response = await client.images.generate({`,
` model: '${ctx.modelName}',`,
` prompt: '${prompt}',`,
` size: '1024x1024',`,
` n: 1,`,
`})`,
'',
`console.log(response.data[0].url)`,
].join('\n')
}
return [
`const response = await fetch('${url}', {`,
` method: 'POST',`,
` headers: {`,
` Authorization: \`Bearer \${process.env.${ctx.apiKeyEnv}}\`,`,
` 'Content-Type': 'application/json',`,
` },`,
` body: JSON.stringify({`,
` model: '${ctx.modelName}',`,
` prompt: '${prompt}',`,
` size: '1024x1024',`,
` n: 1,`,
` }),`,
`})`,
'',
`const data = await response.json()`,
`console.log(data.data[0].url)`,
].join('\n')
}
function buildSample(
lang: Lang,
endpointType: string,
ctx: SampleContext
): string {
if (endpointType === 'anthropic') return buildAnthropicSample(lang, ctx)
if (endpointType === 'gemini') return buildGeminiSample(lang, ctx)
if (endpointType === 'embeddings' || endpointType === 'jina-rerank')
return buildEmbeddingSample(lang, ctx)
if (endpointType === 'image-generation') return buildImageSample(lang, ctx)
return buildChatSample(lang, ctx)
}
// ---------------------------------------------------------------------------
// Code samples section
// ---------------------------------------------------------------------------
......@@ -446,7 +89,6 @@ function CodeSamplesSection(props: {
}) {
const { t } = useTranslation()
const { status } = useStatus()
const baseUrl = useMemo(() => {
const candidate =
(status as Record<string, unknown> | null)?.server_address ??
......@@ -483,6 +125,21 @@ function CodeSamplesSection(props: {
return endpoints.find((e) => e.type === endpointType) ?? endpoints[0]
}, [endpointType, endpoints])
const [selectedProfile, setSelectedProfile] = useState('')
const [selectedScenario, setSelectedScenario] =
useState<ExampleScenario>('basic')
const examples = mergeAPIExamples(
props.model.api_examples ?? [],
activeEndpoint?.type ?? ''
)
const example =
examples.find((item) => item.profile === selectedProfile) ?? examples[0]
const profile = example?.profile ?? 'standard'
const scenarios = exampleScenarios(profile)
const scenario = scenarios.includes(selectedScenario)
? selectedScenario
: scenarios[0]
if (endpoints.length === 0 || !activeEndpoint) {
return null
}
......@@ -492,59 +149,140 @@ function CodeSamplesSection(props: {
apiKeyEnv: 'NEW_API_KEY',
modelName: props.model.model_name || '',
endpointType: activeEndpoint.type,
endpointPath: activeEndpoint.path,
endpointPath:
profile === 'standard'
? activeEndpoint.path
: ((
{
'openai-video': '/v1/videos',
'image-generation': '/v1/images/generations',
'jina-rerank': '/v1/rerank',
embeddings: '/v1/embeddings',
} as Record<string, string>
)[activeEndpoint.type] ?? activeEndpoint.path),
profile,
scenario,
})
return (
<section>
<SectionTitle icon={ScrollText}>{t('Code samples')}</SectionTitle>
<div className='flex flex-wrap items-center gap-2'>
{endpoints.length > 1 && (
<Tabs value={endpointType} onValueChange={setEndpointType}>
<>
<section>
<SectionTitle icon={ScrollText}>{t('Code samples')}</SectionTitle>
<div className='flex flex-wrap items-center gap-2'>
{endpoints.length > 1 && (
<Tabs value={activeEndpoint.type} onValueChange={setEndpointType}>
<TabsList className='bg-muted/40 h-8 p-0.5'>
{endpoints.map((ep) => (
<TabsTrigger
key={ep.type}
value={ep.type}
className='h-7 px-2.5 text-xs'
>
{ep.type}
</TabsTrigger>
))}
</TabsList>
</Tabs>
)}
<Tabs
value={lang}
onValueChange={(v) => setLang(v as Lang)}
className='ml-auto'
>
<TabsList className='bg-muted/40 h-8 p-0.5'>
{endpoints.map((ep) => (
<TabsTrigger
key={ep.type}
value={ep.type}
className='h-7 px-2.5 text-xs'
>
{ep.type}
{(Object.keys(LANG_LABELS) as Lang[]).map((l) => (
<TabsTrigger key={l} value={l} className='h-7 px-2.5 text-xs'>
{LANG_LABELS[l]}
</TabsTrigger>
))}
</TabsList>
</Tabs>
)}
<Tabs
value={lang}
onValueChange={(v) => setLang(v as Lang)}
className='ml-auto'
>
<TabsList className='bg-muted/40 h-8 p-0.5'>
{(Object.keys(LANG_LABELS) as Lang[]).map((l) => (
<TabsTrigger key={l} value={l} className='h-7 px-2.5 text-xs'>
{LANG_LABELS[l]}
</TabsTrigger>
))}
</TabsList>
</Tabs>
</div>
</div>
<div className='mt-3'>
<CodeBlock code={code} language={LANG_HIGHLIGHT[lang]}>
<CodeBlockCopyButton />
</CodeBlock>
</div>
{examples.length > 1 && (
<div className='mt-3 space-y-2'>
<p className='text-sm font-medium'>{t('Example channel')}</p>
<Tabs value={profile} onValueChange={setSelectedProfile}>
<TabsList
className='h-auto flex-wrap'
aria-label={t('Example channel')}
>
{examples.map((item) => (
<TabsTrigger key={item.profile} value={item.profile}>
{item.providers.map((provider) => t(provider)).join(' / ')}{' '}
·{' '}
{item.profile === 'standard'
? t('Basic example')
: (PROFILE_LABELS[item.profile] ?? t('Basic example'))}
</TabsTrigger>
))}
</TabsList>
</Tabs>
</div>
)}
{scenarios.length > 1 && (
<Tabs
value={scenario}
onValueChange={(value) =>
setSelectedScenario(value as ExampleScenario)
}
className='mt-3'
>
<TabsList aria-label={t('Example scenario')}>
{scenarios.map((value) => (
<TabsTrigger key={value} value={value}>
{t(SCENARIO_LABELS[value])}
</TabsTrigger>
))}
</TabsList>
</Tabs>
)}
{example && (
<p className='text-muted-foreground mt-3 text-xs'>
{t(
'Example selection does not change request routing. Use a token group matching the example; channels in the same group may still use different formats.'
)}{' '}
{t('Group')}: {example.groups.join(', ')}
</p>
)}
{scenario !== 'basic' && (
<p className='text-muted-foreground mt-2 text-xs'>
{t(
'Replace example media URLs with accessible media supported by the model.'
)}
</p>
)}
<div className='mt-3'>
{code ? (
<CodeBlock code={code} language={LANG_HIGHLIGHT[lang]}>
<CodeBlockCopyButton />
</CodeBlock>
) : (
<p>{t('No example is available for this endpoint.')}</p>
)}
</div>
<p className='text-muted-foreground mt-2 text-xs'>
{t('Replace')}{' '}
<code className='bg-muted rounded px-1 py-0.5 font-mono text-[11px]'>
{'<YOUR_API_KEY>'}
</code>{' '}
{t('with the API key from your token settings.')}
</p>
</section>
<p className='text-muted-foreground mt-2 text-xs'>
{t('Replace')}{' '}
<code className='bg-muted rounded px-1 py-0.5 font-mono text-[11px]'>
{profile !== 'standard' ||
activeEndpoint.type === 'openai-video' ||
activeEndpoint.type === 'jina-rerank'
? 'NEW_API_KEY'
: '<YOUR_API_KEY>'}
</code>{' '}
{t('with the API key from your token settings.')}
</p>
</section>
<SupportedParametersSection
model={props.model}
endpointType={activeEndpoint.type}
profile={profile}
scenario={scenario}
/>
</>
)
}
......@@ -552,11 +290,22 @@ function CodeSamplesSection(props: {
// Supported parameters table
// ---------------------------------------------------------------------------
function SupportedParametersSection(props: { model: PricingModel }) {
function SupportedParametersSection(props: {
model: PricingModel
endpointType: string
profile: string
scenario: ExampleScenario
}) {
const { t } = useTranslation()
const params = useMemo(
() => buildSupportedParameters(props.model),
[props.model]
() =>
buildApiParameters(
props.model,
props.endpointType,
props.profile,
props.scenario
),
[props.model, props.endpointType, props.profile, props.scenario]
)
if (params.length === 0) return null
......@@ -626,39 +375,34 @@ function SupportedParametersSection(props: { model: PricingModel }) {
function ParamRangeCell(props: { param: SupportedParameter }) {
const { defaultValue, range, enumValues } = props.param
if (defaultValue !== undefined) {
return (
<div className='flex flex-wrap items-center gap-1'>
<span className='text-muted-foreground text-sm'>=</span>
<code className='bg-muted rounded px-1.5 py-0.5 font-mono text-sm'>
{String(defaultValue)}
</code>
{range && (
<span className='text-muted-foreground text-sm'>{range}</span>
)}
</div>
)
}
if (range) {
return (
<span className='text-muted-foreground font-mono text-sm'>{range}</span>
)
if (defaultValue === undefined && !range && !enumValues?.length) {
return <span className='text-muted-foreground/60 text-sm'></span>
}
if (enumValues && enumValues.length > 0) {
return (
<div className='flex flex-wrap gap-0.5'>
{enumValues.map((v) => (
return (
<div className='flex flex-wrap items-center gap-1'>
{defaultValue !== undefined && (
<>
<span className='text-muted-foreground text-sm'>=</span>
<code className='bg-muted rounded px-1.5 py-0.5 font-mono text-sm'>
{String(defaultValue)}
</code>
</>
)}
{range && (
<span className='text-muted-foreground font-mono text-sm'>{range}</span>
)}
{enumValues
?.filter((value) => value !== String(defaultValue))
.map((value) => (
<code
key={v}
key={value}
className='bg-muted text-muted-foreground rounded px-1.5 py-0.5 font-mono text-sm'
>
{v}
{value}
</code>
))}
</div>
)
}
return <span className='text-muted-foreground/60 text-sm'></span>
</div>
)
}
// ---------------------------------------------------------------------------
......@@ -766,7 +510,6 @@ export function ModelDetailsApi(props: {
<div className='space-y-6'>
<CodeSamplesSection model={props.model} endpointMap={props.endpointMap} />
<AuthSection />
<SupportedParametersSection model={props.model} />
<RateLimitsSection model={props.model} />
</div>
)
......
/*
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 { PricingAPIExample } from '../types'
import type { SupportedParameter } from './mock-stats'
export type ExampleScenario = 'basic' | 'image-reference' | 'video-reference'
export const SCENARIO_LABELS: Record<ExampleScenario, string> = {
basic: 'Basic example',
'image-reference': 'Image reference',
'video-reference': 'Video reference',
}
export const PROFILE_LABELS: Record<string, string> = {
standard: 'Basic example',
'ctyun-h3': 'MiniMax H3',
seedance: 'Seedance',
seedance2: 'Seedance 2.0',
'ctyun-wan': 'Wan / HappyHorse',
'ctyun-wan-image': 'Wan / HappyHorse I2V',
'ctyun-wan3': 'Wan 3.0',
'ctyun-wan-reference': 'Wan 2.7 R2V',
'ctyun-happyhorse-reference': 'HappyHorse R2V',
'ctyun-seedream': 'Seedream',
'ctyun-seedream-pro': 'Seedream 5.0 pro',
'ctyun-message-image': 'Qwen / Wan Image',
'ctyun-message-image-edit': 'Qwen / Wan Image Edit',
'ctyun-qwen-rerank': 'Qwen Rerank',
'ctyun-gte-rerank': 'GTE Rerank',
'ctyun-vl-embedding': 'Qwen VL Embedding',
}
// Merge by request dialect, not by channel identity. This also tolerates older
// servers returning duplicate entries for different groups or channels.
export function mergeAPIExamples(
examples: PricingAPIExample[],
endpoint: string
): PricingAPIExample[] {
const merged = new Map<string, PricingAPIExample>()
for (const example of examples) {
if (example.endpoint !== endpoint) continue
const previous = merged.get(example.profile)
merged.set(example.profile, {
...example,
providers: [
...new Set([...(previous?.providers ?? []), ...example.providers]),
].sort(),
groups: [
...new Set([...(previous?.groups ?? []), ...example.groups]),
].sort(),
})
}
return [...merged.values()]
}
export function exampleScenarios(profile: string): ExampleScenario[] {
if (['ctyun-wan-reference'].includes(profile)) {
return ['image-reference', 'video-reference']
}
if (
[
'ctyun-happyhorse-reference',
'ctyun-wan-image',
'ctyun-message-image-edit',
].includes(profile)
) {
return ['image-reference']
}
if (['ctyun-h3', 'seedance2', 'ctyun-wan3'].includes(profile)) {
return ['basic', 'image-reference', 'video-reference']
}
if (
[
'seedance',
'ctyun-seedream',
'ctyun-seedream-pro',
'ctyun-message-image',
].includes(profile)
) {
return ['basic', 'image-reference']
}
return ['basic']
}
export function profileRequest(
model: string,
endpoint: string,
profile: string,
scenario: ExampleScenario
): Record<string, unknown> | undefined {
if (!PROFILE_LABELS[profile] || profile === 'standard') return undefined
if (endpoint === 'openai-video') {
const wan = profile.includes('wan') || profile.includes('happyhorse')
const body: Record<string, unknown> = {
model,
prompt: 'A calm lake at sunset.',
seconds: '5',
resolution: profile === 'ctyun-h3' ? '768P' : '1080P',
}
const seedance = profile.startsWith('seedance')
if (seedance) {
delete body.resolution
body.metadata = { resolution: '1080p' }
}
if (profile === 'ctyun-h3') body.ratio = '16:9'
if (scenario !== 'basic') {
const video = scenario === 'video-reference'
const url = video
? 'https://example.com/reference.mp4'
: 'https://example.com/reference.jpg'
if (wan) {
if (profile === 'ctyun-wan-image') body.image = url
else {
body.metadata = {
input: {
media: [
{ type: video ? 'reference_video' : 'reference_image', url },
],
},
}
}
} else {
const type = video ? 'video_url' : 'image_url'
body.metadata = {
...(seedance ? { resolution: '1080p' } : {}),
content: [
{
type,
[type]: { url },
role: video ? 'reference_video' : 'first_frame',
},
],
}
}
}
return body
}
if (endpoint === 'image-generation') {
return {
model,
prompt: 'A calm lake at sunset.',
size: profile.includes('seedream') ? '2K' : '1024x1024',
n: 1,
stream: false,
...(scenario === 'image-reference'
? { image: 'https://example.com/reference.jpg' }
: {}),
}
}
if (endpoint === 'jina-rerank') {
return {
model,
query: 'What is the capital of China?',
documents: [
'Beijing is the capital of China.',
'Shanghai is a major city.',
],
top_n: 1,
...(profile === 'ctyun-qwen-rerank'
? { instruct: 'Rank documents by relevance to the query.' }
: { return_documents: true }),
}
}
if (endpoint === 'embeddings' && profile === 'ctyun-vl-embedding') {
return {
model,
input: {
contents: [
{ text: 'A calm lake.' },
{ image: 'https://example.com/reference.jpg' },
],
},
encoding_format: 'float',
dimensions: 1024,
}
}
return undefined
}
export function profileParameters(
base: SupportedParameter[],
endpoint: string,
profile: string,
scenario: ExampleScenario
): SupportedParameter[] {
if (!PROFILE_LABELS[profile] || profile === 'standard') return base
let params = base.map((p) => ({ ...p }))
if (endpoint === 'openai-video') {
params = params.filter((p) => p.name !== 'size')
params.push({
name: profile.startsWith('seedance')
? 'metadata.resolution'
: 'resolution',
type: 'enum',
enumValues:
profile === 'ctyun-h3'
? ['480P', '768P', '2K']
: ['480P', '720P', '1080P'],
descriptionKey: 'Output resolution',
})
if (profile === 'ctyun-h3') {
params.push({
name: 'ratio',
type: 'string',
descriptionKey:
'Output aspect ratio. H3 frame inputs force adaptive; text-only input requires an explicit ratio.',
})
}
if (scenario !== 'basic') {
const wan = profile.includes('wan') || profile.includes('happyhorse')
if (profile === 'ctyun-wan-image') {
params.push({
name: 'image',
type: 'string',
required: true,
descriptionKey:
'Input image URL or array of image URLs for image editing.',
})
} else {
params.push({
name: wan ? 'metadata.input.media' : 'metadata.content',
type: 'array',
required: true,
descriptionKey: wan
? 'Advanced video inputs: input.media contains typed media URLs; do not mix frame and reference inputs.'
: 'Advanced video inputs: content contains text, image_url, video_url or audio_url items with optional roles.',
})
}
}
}
if (endpoint === 'image-generation') {
if (scenario === 'image-reference') {
params.push({
name: 'image',
type: 'string',
required: true,
descriptionKey:
'Input image URL or array of image URLs for image editing.',
})
}
params.push({
name: 'stream',
type: 'boolean',
defaultValue: false,
descriptionKey:
'Qwen/Wan images and Seedream 5.0 pro require stream=false in this adapter.',
})
const size = params.find((p) => p.name === 'size')
if (size) {
size.descriptionKey = profile.includes('seedream')
? 'Use a model-supported image size, such as 2K for Seedream.'
: 'Use WIDTHxHEIGHT (512–2048 per side); Wan also accepts 1K, 2K and 4K. Native parameters.size uses WIDTH*HEIGHT.'
}
if (profile.includes('message-image')) {
params.push({
name: 'parameters',
type: 'object',
descriptionKey:
'Native image parameters override top-level size and watermark; include size, n and seed here when needed.',
})
}
}
if (endpoint === 'jina-rerank') {
params.push(
profile === 'ctyun-qwen-rerank'
? {
name: 'instruct',
type: 'string',
descriptionKey: 'Optional ranking instruction',
}
: {
name: 'return_documents',
type: 'boolean',
descriptionKey: 'Include document text in the ranked results',
}
)
}
if (profile === 'ctyun-vl-embedding') {
params = params.map((p) =>
p.name === 'input'
? {
...p,
type: 'object',
descriptionKey:
'Use input.contents; each item contains exactly one nonempty text, image or video string.',
}
: p
)
const encoding = params.find((p) => p.name === 'encoding_format')
if (encoding) encoding.enumValues = ['float']
}
return params
}
import type { PricingModel } from '../types'
/*
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 {
profileRequest,
profileParameters,
type ExampleScenario,
} from './api-example-profiles'
import { buildSupportedParameters, type SupportedParameter } from './mock-stats'
export type Lang = 'curl' | 'python' | 'typescript' | 'javascript'
export type SampleContext = {
baseUrl: string
apiKeyEnv: string
modelName: string
endpointType: string
endpointPath: string
profile?: string
scenario?: ExampleScenario
}
function buildChatSample(lang: Lang, ctx: SampleContext): string {
const url = `${ctx.baseUrl}${ctx.endpointPath}`
const isResponses = ctx.endpointType === 'openai-response'
const isReasoning = /^o[1-4]|reasoning|thinking|deepseek-r/i.test(
ctx.modelName
)
const userMessage = 'Explain quantum entanglement in one paragraph.'
const bodyJson = isResponses
? JSON.stringify({ model: ctx.modelName, input: userMessage }, null, 2)
: JSON.stringify(
{
model: ctx.modelName,
messages: [{ role: 'user', content: userMessage }],
...(isReasoning ? {} : { temperature: 0.7 }),
},
null,
2
)
const fnCall = isResponses ? 'responses.create' : 'chat.completions.create'
if (lang === 'curl') {
return [
`curl ${url} \\`,
` -H "Authorization: Bearer $${ctx.apiKeyEnv}" \\`,
` -H "Content-Type: application/json" \\`,
` -d '${bodyJson.replaceAll('\n', '\n ')}'`,
].join('\n')
}
if (lang === 'python') {
return [
'from openai import OpenAI',
'',
'client = OpenAI(',
` base_url="${ctx.baseUrl}/v1",`,
` api_key="<YOUR_API_KEY>",`,
')',
'',
isResponses
? `response = client.${fnCall}(\n model="${ctx.modelName}",\n input="${userMessage}",\n)\n\nprint(response.output_text)`
: `completion = client.${fnCall}(\n model="${ctx.modelName}",\n messages=[\n {"role": "user", "content": "${userMessage}"}\n ],\n)\n\nprint(completion.choices[0].message.content)`,
].join('\n')
}
if (lang === 'typescript') {
return [
`import OpenAI from 'openai'`,
'',
`const client = new OpenAI({`,
` baseURL: '${ctx.baseUrl}/v1',`,
` apiKey: process.env.${ctx.apiKeyEnv},`,
`})`,
'',
isResponses
? `const response = await client.${fnCall}({\n model: '${ctx.modelName}',\n input: '${userMessage}',\n})\n\nconsole.log(response.output_text)`
: `const completion = await client.${fnCall}({\n model: '${ctx.modelName}',\n messages: [{ role: 'user', content: '${userMessage}' }],\n})\n\nconsole.log(completion.choices[0].message.content)`,
].join('\n')
}
return [
`const response = await fetch('${url}', {`,
` method: 'POST',`,
` headers: {`,
` Authorization: \`Bearer \${process.env.${ctx.apiKeyEnv}}\`,`,
` 'Content-Type': 'application/json',`,
` },`,
` body: JSON.stringify(${bodyJson}),`,
`})`,
'',
`const data = await response.json()`,
`console.log(data)`,
].join('\n')
}
function buildAnthropicSample(lang: Lang, ctx: SampleContext): string {
const url = `${ctx.baseUrl}${ctx.endpointPath}`
const userMessage = 'Explain quantum entanglement in one paragraph.'
if (lang === 'curl') {
const body = JSON.stringify(
{
model: ctx.modelName,
max_tokens: 1024,
messages: [{ role: 'user', content: userMessage }],
},
null,
2
)
return [
`curl ${url} \\`,
` -H "x-api-key: $${ctx.apiKeyEnv}" \\`,
` -H "anthropic-version: 2023-06-01" \\`,
` -H "Content-Type: application/json" \\`,
` -d '${body.replaceAll('\n', '\n ')}'`,
].join('\n')
}
if (lang === 'python') {
return [
'import anthropic',
'',
'client = anthropic.Anthropic(',
` base_url="${ctx.baseUrl}",`,
` api_key="<YOUR_API_KEY>",`,
')',
'',
`message = client.messages.create(`,
` model="${ctx.modelName}",`,
` max_tokens=1024,`,
` messages=[{"role": "user", "content": "${userMessage}"}],`,
')',
'',
'print(message.content[0].text)',
].join('\n')
}
if (lang === 'typescript') {
return [
`import Anthropic from '@anthropic-ai/sdk'`,
'',
`const client = new Anthropic({`,
` baseURL: '${ctx.baseUrl}',`,
` apiKey: process.env.${ctx.apiKeyEnv},`,
`})`,
'',
`const message = await client.messages.create({`,
` model: '${ctx.modelName}',`,
` max_tokens: 1024,`,
` messages: [{ role: 'user', content: '${userMessage}' }],`,
`})`,
'',
`console.log(message.content[0].text)`,
].join('\n')
}
return [
`const response = await fetch('${url}', {`,
` method: 'POST',`,
` headers: {`,
` 'x-api-key': process.env.${ctx.apiKeyEnv},`,
` 'anthropic-version': '2023-06-01',`,
` 'Content-Type': 'application/json',`,
` },`,
` body: JSON.stringify({`,
` model: '${ctx.modelName}',`,
` max_tokens: 1024,`,
` messages: [{ role: 'user', content: '${userMessage}' }],`,
` }),`,
`})`,
'',
`const data = await response.json()`,
`console.log(data.content[0].text)`,
].join('\n')
}
function buildGeminiSample(lang: Lang, ctx: SampleContext): string {
const url = `${ctx.baseUrl}${ctx.endpointPath}?key=$${ctx.apiKeyEnv}`
const userMessage = 'Explain quantum entanglement in one paragraph.'
if (lang === 'curl') {
const body = JSON.stringify(
{ contents: [{ parts: [{ text: userMessage }] }] },
null,
2
)
return [
`curl '${url}' \\`,
` -H 'Content-Type: application/json' \\`,
` -d '${body.replaceAll('\n', '\n ')}'`,
].join('\n')
}
if (lang === 'python') {
return [
'import google.generativeai as genai',
'',
`genai.configure(api_key="<YOUR_API_KEY>")`,
'',
`model = genai.GenerativeModel("${ctx.modelName}")`,
`response = model.generate_content("${userMessage}")`,
'',
`print(response.text)`,
].join('\n')
}
if (lang === 'typescript') {
return [
`import { GoogleGenerativeAI } from '@google/generative-ai'`,
'',
`const genAI = new GoogleGenerativeAI(process.env.${ctx.apiKeyEnv}!)`,
`const model = genAI.getGenerativeModel({ model: '${ctx.modelName}' })`,
'',
`const result = await model.generateContent('${userMessage}')`,
`console.log(result.response.text())`,
].join('\n')
}
return [
`const response = await fetch('${url}', {`,
` method: 'POST',`,
` headers: { 'Content-Type': 'application/json' },`,
` body: JSON.stringify({`,
` contents: [{ parts: [{ text: '${userMessage}' }] }],`,
` }),`,
`})`,
'',
`const data = await response.json()`,
`console.log(data.candidates[0].content.parts[0].text)`,
].join('\n')
}
function buildEmbeddingSample(lang: Lang, ctx: SampleContext): string {
const url = `${ctx.baseUrl}${ctx.endpointPath}`
const text = 'The food was delicious and the waiter…'
if (lang === 'curl') {
const body = JSON.stringify({ model: ctx.modelName, input: text }, null, 2)
return [
`curl ${url} \\`,
` -H "Authorization: Bearer $${ctx.apiKeyEnv}" \\`,
` -H "Content-Type: application/json" \\`,
` -d '${body.replaceAll('\n', '\n ')}'`,
].join('\n')
}
if (lang === 'python') {
return [
'from openai import OpenAI',
'',
`client = OpenAI(base_url="${ctx.baseUrl}/v1", api_key="<YOUR_API_KEY>")`,
'',
'response = client.embeddings.create(',
` model="${ctx.modelName}",`,
` input="${text}",`,
')',
'',
'print(response.data[0].embedding[:8])',
].join('\n')
}
if (lang === 'typescript') {
return [
`import OpenAI from 'openai'`,
'',
`const client = new OpenAI({`,
` baseURL: '${ctx.baseUrl}/v1',`,
` apiKey: process.env.${ctx.apiKeyEnv},`,
`})`,
'',
`const response = await client.embeddings.create({`,
` model: '${ctx.modelName}',`,
` input: '${text}',`,
`})`,
'',
`console.log(response.data[0].embedding.slice(0, 8))`,
].join('\n')
}
return [
`const response = await fetch('${url}', {`,
` method: 'POST',`,
` headers: {`,
` Authorization: \`Bearer \${process.env.${ctx.apiKeyEnv}}\`,`,
` 'Content-Type': 'application/json',`,
` },`,
` body: JSON.stringify({`,
` model: '${ctx.modelName}',`,
` input: '${text}',`,
` }),`,
`})`,
'',
`const data = await response.json()`,
`console.log(data.data[0].embedding.slice(0, 8))`,
].join('\n')
}
function buildImageSample(lang: Lang, ctx: SampleContext): string {
const url = `${ctx.baseUrl}${ctx.endpointPath}`
const prompt = 'A serene koi pond at sunset, ukiyo-e style.'
if (lang === 'curl') {
const body = JSON.stringify(
{ model: ctx.modelName, prompt, size: '1024x1024', n: 1 },
null,
2
)
return [
`curl ${url} \\`,
` -H "Authorization: Bearer $${ctx.apiKeyEnv}" \\`,
` -H "Content-Type: application/json" \\`,
` -d '${body.replaceAll('\n', '\n ')}'`,
].join('\n')
}
if (lang === 'python') {
return [
'from openai import OpenAI',
'',
`client = OpenAI(base_url="${ctx.baseUrl}/v1", api_key="<YOUR_API_KEY>")`,
'',
'response = client.images.generate(',
` model="${ctx.modelName}",`,
` prompt="${prompt}",`,
` size="1024x1024",`,
` n=1,`,
')',
'',
'print(response.data[0].url)',
].join('\n')
}
if (lang === 'typescript') {
return [
`import OpenAI from 'openai'`,
'',
`const client = new OpenAI({`,
` baseURL: '${ctx.baseUrl}/v1',`,
` apiKey: process.env.${ctx.apiKeyEnv},`,
`})`,
'',
`const response = await client.images.generate({`,
` model: '${ctx.modelName}',`,
` prompt: '${prompt}',`,
` size: '1024x1024',`,
` n: 1,`,
`})`,
'',
`console.log(response.data[0].url)`,
].join('\n')
}
return [
`const response = await fetch('${url}', {`,
` method: 'POST',`,
` headers: {`,
` Authorization: \`Bearer \${process.env.${ctx.apiKeyEnv}}\`,`,
` 'Content-Type': 'application/json',`,
` },`,
` body: JSON.stringify({`,
` model: '${ctx.modelName}',`,
` prompt: '${prompt}',`,
` size: '1024x1024',`,
` n: 1,`,
` }),`,
`})`,
'',
`const data = await response.json()`,
`console.log(data.data[0].url)`,
].join('\n')
}
export function buildSample(
lang: Lang,
endpointType: string,
ctx: SampleContext
): string {
const adapted = profileRequest(
ctx.modelName,
endpointType,
ctx.profile ?? 'standard',
ctx.scenario ?? 'basic'
)
if (adapted) return buildJsonSample(lang, ctx, adapted)
if (endpointType === 'openai-video') {
return buildJsonSample(lang, ctx, {
model: ctx.modelName,
prompt: 'A calm lake at sunset.',
seconds: '4',
})
}
if (endpointType === 'jina-rerank') {
return buildJsonSample(lang, ctx, {
model: ctx.modelName,
query: 'What is the capital of China?',
documents: [
'Beijing is the capital of China.',
'Shanghai is a major city.',
],
top_n: 1,
})
}
if (endpointType === 'anthropic') return buildAnthropicSample(lang, ctx)
if (endpointType === 'gemini') return buildGeminiSample(lang, ctx)
if (endpointType === 'embeddings') return buildEmbeddingSample(lang, ctx)
if (endpointType === 'image-generation') return buildImageSample(lang, ctx)
if (endpointType === 'openai' || endpointType === 'openai-response') {
return buildChatSample(lang, ctx)
}
return ''
}
// Use the same JSON request fields across languages for video and reranking.
function buildJsonSample(
lang: Lang,
ctx: SampleContext,
body: Record<string, unknown>
): string {
const url = `${ctx.baseUrl}${ctx.endpointPath}`
const bodyJson = JSON.stringify(body, null, 2)
const video = ctx.endpointType === 'openai-video'
if (lang === 'curl') {
return [
`curl '${url}' \\`,
` -H "Authorization: Bearer $${ctx.apiKeyEnv}" \\`,
' -H "Content-Type: application/json" \\',
` -d '${bodyJson.replaceAll("'", "'\\''")}'`,
...(video
? [
'',
'# Set VIDEO_ID to the id returned above; repeat GET until completed or failed.',
"VIDEO_ID='<VIDEO_ID>'",
`curl "${ctx.baseUrl}/v1/videos/$VIDEO_ID" -H "Authorization: Bearer $${ctx.apiKeyEnv}"`,
'',
'# Download only after status is completed.',
`curl --fail "${ctx.baseUrl}/v1/videos/$VIDEO_ID/content" -H "Authorization: Bearer $${ctx.apiKeyEnv}" -o video.mp4`,
]
: []),
].join('\n')
}
if (lang === 'python') {
return [
'import json',
'import os',
...(video ? ['import time'] : []),
'import requests',
'',
`headers = {"Authorization": "Bearer " + os.environ["${ctx.apiKeyEnv}"]}`,
// JSON inside a JSON-escaped string is also a valid Python string literal.
`body = json.loads(${JSON.stringify(bodyJson)})`,
`response = requests.post(${JSON.stringify(url)}, headers=headers, json=body, timeout=300)`,
'response.raise_for_status()',
'data = response.json()',
...(video
? [
`task_url = ${JSON.stringify(`${ctx.baseUrl}/v1/videos/`)} + data["id"]`,
'for _ in range(120):',
' response = requests.get(task_url, headers=headers, timeout=60)',
' response.raise_for_status()',
' data = response.json()',
' if data["status"] == "failed":',
' raise RuntimeError(data.get("error", data))',
' if data["status"] == "completed":',
' break',
' time.sleep(5)',
'else:',
' raise TimeoutError("Video is still running; query the same task later.")',
'response = requests.get(task_url + "/content", headers=headers, timeout=300)',
'response.raise_for_status()',
'with open("video.mp4", "wb") as output:',
' output.write(response.content)',
]
: ['print(data)']),
].join('\n')
}
return [
...(video ? ["import { writeFile } from 'node:fs/promises'", ''] : []),
`const headers = { Authorization: \`Bearer \${process.env.${ctx.apiKeyEnv}}\`, 'Content-Type': 'application/json' }`,
`const response = await fetch(${JSON.stringify(url)}, {`,
" method: 'POST',",
' headers,',
` body: JSON.stringify(${bodyJson}),`,
'})',
'if (!response.ok) throw new Error(await response.text())',
`${video ? 'let' : 'const'} data = await response.json()`,
...(video
? [
`const taskUrl = ${JSON.stringify(`${ctx.baseUrl}/v1/videos/`)} + encodeURIComponent(data.id)`,
'for (let attempt = 0; attempt < 120; attempt++) {',
' const result = await fetch(taskUrl, { headers })',
' if (!result.ok) throw new Error(await result.text())',
' data = await result.json()',
" if (data.status === 'failed') throw new Error(JSON.stringify(data.error))",
" if (data.status === 'completed') break",
' await new Promise(resolve => setTimeout(resolve, 5000))',
'}',
"if (data.status !== 'completed') throw new Error('Video is still running; query the same task later.')",
"const content = await fetch(taskUrl + '/content', { headers })",
'if (!content.ok) throw new Error(await content.text())',
"await writeFile('video.mp4', Buffer.from(await content.arrayBuffer()))",
]
: ['console.log(data)']),
].join('\n')
}
export function buildApiParameters(
model: PricingModel,
endpoint: string,
profile = 'standard',
scenario: ExampleScenario = 'basic'
): SupportedParameter[] {
return profileParameters(
buildEndpointParameters(model, endpoint),
endpoint,
profile,
scenario
)
}
function buildEndpointParameters(
model: PricingModel,
endpoint: string
): SupportedParameter[] {
if (endpoint === 'openai-video') {
return [
{
name: 'prompt',
type: 'string',
required: true,
descriptionKey: 'Text description of the desired video',
},
{
name: 'seconds',
type: 'string',
descriptionKey: 'Video length in seconds',
},
{ name: 'size', type: 'string', descriptionKey: 'Output resolution' },
]
}
if (endpoint === 'jina-rerank') {
return [
{
name: 'query',
type: 'string',
required: true,
descriptionKey: 'Search query used to rank documents',
},
{
name: 'documents',
type: 'array',
required: true,
descriptionKey: 'Documents to rank: strings or objects containing text',
},
{
name: 'top_n',
type: 'integer',
descriptionKey: 'Maximum number of ranked results to return',
},
]
}
if (endpoint === 'embeddings') {
return [
{
name: 'input',
type: 'string',
required: true,
descriptionKey: 'Text or array of texts to embed',
},
{
name: 'dimensions',
type: 'integer',
descriptionKey: 'Truncate embeddings to this many dimensions',
},
{
name: 'encoding_format',
type: 'enum',
enumValues: ['float', 'base64'],
descriptionKey: 'Wire encoding for the embedding vectors',
},
]
}
if (endpoint === 'image-generation') {
return [
{
name: 'prompt',
type: 'string',
required: true,
descriptionKey: 'Text description of the desired image',
},
{ name: 'size', type: 'string', descriptionKey: 'Output image size' },
{
name: 'n',
type: 'integer',
descriptionKey: 'Number of images to generate',
},
{
name: 'response_format',
type: 'enum',
enumValues: ['url', 'b64_json'],
descriptionKey: 'How to deliver the resulting image',
},
]
}
return buildSupportedParameters(model)
}
......@@ -44,7 +44,15 @@ export type BillingUsageExample = {
facts: Record<string, string | number>
}
export type PricingAPIExample = {
endpoint: string
profile: string
providers: string[]
groups: string[]
}
export type PricingModel = {
api_examples?: PricingAPIExample[]
id: number
model_name: string
description?: string
......
......@@ -53,11 +53,12 @@ export function ModelPricingImportDialog(props: ModelPricingImportDialogProps) {
const result = response.data
toast.success(
t(
'Model pricing import completed: {{updated}} updated, {{perToken}} per-token, {{perRequest}} per-request, {{failed}} failed.',
'Model pricing import completed: {{updated}} updated, {{perToken}} per-token, {{perRequest}} per-request, {{tieredExpr}} expression, {{failed}} failed.',
{
updated: result?.updated ?? 0,
perToken: result?.per_token ?? 0,
perRequest: result?.per_request ?? 0,
tieredExpr: result?.tiered_expr ?? 0,
failed: result?.failed ?? 0,
}
)
......@@ -94,7 +95,7 @@ export function ModelPricingImportDialog(props: ModelPricingImportDialogProps) {
<AlertTitle>{t('Pricing import requirements')}</AlertTitle>
<AlertDescription>
{t(
'The first row must contain headers, and model_name is required. For per-request billing, provide fixed_price. Otherwise input_price is required; optional columns are completion_price, cache_price, create_cache_price, image_price, audio_input_price, and audio_output_price.'
'The first row must contain headers, and model_name is required. For expression billing, provide billing_expr and leave all price columns blank. Use len for input-length tiers. Otherwise provide fixed_price or input_price; optional columns are completion_price, cache_price, create_cache_price, image_price, audio_input_price, and audio_output_price.'
)}
</AlertDescription>
</Alert>
......@@ -103,7 +104,7 @@ export function ModelPricingImportDialog(props: ModelPricingImportDialogProps) {
<AlertTitle>{t('Existing pricing will be replaced')}</AlertTitle>
<AlertDescription>
{t(
'Importing replaces the billing mode for matching models: per-token billing clears fixed prices, while per-request billing clears ratio settings.'
'Importing replaces existing pricing for matching models. Expression billing clears fixed prices and ratios; other billing modes clear expressions. Prices and expression coefficients are imported as-is without currency conversion.'
)}
</AlertDescription>
</Alert>
......
......@@ -46,6 +46,7 @@ export type ImportModelPricingResponse = {
updated: number
per_token: number
per_request: number
tiered_expr: number
failed: number
rows: Array<{
row: number
......
......@@ -209,6 +209,7 @@
"Add a new user by providing necessary info.": "Add a new user by providing necessary info.",
"Add a new vendor to the system": "Add a new vendor to the system",
"Add a vendor or adjust your search.": "Add a vendor or adjust your search.",
"Add a watermark to the generated image": "Add a watermark to the generated image",
"Add an extra layer of security to your account": "Add an extra layer of security to your account",
"Add an index URL to browse installable plugins.": "Add an index URL to browse installable plugins.",
"Add and submit": "Add and submit",
......@@ -317,6 +318,8 @@
"Advanced platform configuration.": "Advanced platform configuration.",
"Advanced Settings": "Advanced Settings",
"Advanced text editing": "Advanced text editing",
"Advanced video inputs: content contains text, image_url, video_url or audio_url items with optional roles.": "Advanced video inputs: content contains text, image_url, video_url or audio_url items with optional roles.",
"Advanced video inputs: input.media contains typed media URLs; do not mix frame and reference inputs.": "Advanced video inputs: input.media contains typed media URLs; do not mix frame and reference inputs.",
"Aesthetic style": "Aesthetic style",
"Affected windows:": "Affected windows:",
"After clicking the button, you'll be asked to authorize the bot": "After clicking the button, you'll be asked to authorize the bot",
......@@ -707,6 +710,7 @@
"Base URL of your Uptime Kuma instance": "Base URL of your Uptime Kuma instance",
"Basic Authentication": "Basic Authentication",
"Basic Configuration": "Basic Configuration",
"Basic example": "Basic example",
"Basic Info": "Basic Info",
"Basic Information": "Basic Information",
"Basic Templates": "Basic Templates",
......@@ -1242,6 +1246,7 @@
"Controls visibility in the model square. Channel status and existing API access are unchanged.": "Controls visibility in the model square. Channel status and existing API access are unchanged.",
"Controls whether user verification (biometrics/PIN) is required during Passkey flows.": "Controls whether user verification (biometrics/PIN) is required during Passkey flows.",
"Conversation cleared": "Conversation cleared",
"Conversation messages": "Conversation messages",
"Conversion rate from USD to your custom currency": "Conversion rate from USD to your custom currency",
"Convert reasoning_content to <think> tag in content": "Convert reasoning_content to <think> tag in content",
"Convert string to lowercase": "Convert string to lowercase",
......@@ -1384,6 +1389,7 @@
"Creem products must be a JSON array": "Creem products must be a JSON array",
"Cross-group": "Cross-group",
"Cross-group retry": "Cross-group retry",
"CTyun": "CTyun",
"Currency": "Currency",
"Currency & Display": "Currency & Display",
"Current": "Current",
......@@ -1688,6 +1694,7 @@
"Docs": "Docs",
"Documentation Link": "Documentation Link",
"Documentation or external knowledge base.": "Documentation or external knowledge base.",
"Documents to rank: strings or objects containing text": "Documents to rank: strings or objects containing text",
"Does not check healthy channels. It only rechecks auto-disabled channels and restores them after they recover.": "Does not check healthy channels. It only rechecks auto-disabled channels and restores them after they recover.",
"does not exist or might have been removed.": "does not exist or might have been removed.",
"Domain": "Domain",
......@@ -2024,7 +2031,10 @@
"Example": "Example",
"Example (all channels):": "Example (all channels):",
"Example (specific channels):": "Example (specific channels):",
"Example channel": "Example channel",
"Example price": "Example price",
"Example scenario": "Example scenario",
"Example selection does not change request routing. Use a token group matching the example; channels in the same group may still use different formats.": "Example selection does not change request routing. Use a token group matching the example; channels in the same group may still use different formats.",
"Example spec": "Example spec",
"Example:": "Example:",
"example.com&#10;blocked-site.com": "example.com&#10;blocked-site.com",
......@@ -2343,6 +2353,7 @@
"Finish Time": "Finish Time",
"First API request": "First API request",
"First token": "First token",
"First-frame image URL. Use images for first and last frames; uploaded files are not supported by this video adapter.": "First-frame image URL. Use images for first and last frames; uploaded files are not supported by this video adapter.",
"First/Last Frame to Video": "First/Last Frame to Video",
"Fix Abilities": "Repair Channel Consistency",
"Fix order": "Fix order",
......@@ -2639,6 +2650,7 @@
"Ignore": "Ignore",
"Ignored upstream models": "Ignored upstream models",
"Image": "Image",
"Image count is bounded by new-api; the upstream model may impose a lower limit. Seedream groups are converted automatically.": "Image count is bounded by new-api; the upstream model may impose a lower limit. Seedream groups are converted automatically.",
"Image Generation": "Image Generation",
"Image In": "Image In",
"Image input": "Image input",
......@@ -2648,6 +2660,7 @@
"Image output price": "Image output price",
"Image Preview": "Image Preview",
"Image ratio": "Image ratio",
"Image reference": "Image reference",
"Image to Video": "Image to Video",
"Image Tokens": "Image Tokens",
"Imagine the pricing table has three groups: default (ratio 1.0), premium (ratio 0.5), and vip (ratio 0.8). Users whose account is in the vip group get user-level perks, and premium is a cheaper channel pool that users can pick for their tokens.": "Imagine the pricing table has three groups: default (ratio 1.0), premium (ratio 0.5), and vip (ratio 0.8). Users whose account is in the vip group get user-level perks, and premium is a cheaper channel pool that users can pick for their tokens.",
......@@ -2656,6 +2669,7 @@
"Import from URL": "Import from URL",
"Import mappings from an Excel workbook.": "Import mappings from an Excel workbook.",
"Import model mappings": "Import model mappings",
"Import model metadata and optional pricing from an Excel workbook.": "Import model metadata and optional pricing from an Excel workbook.",
"Import model metadata from an Excel workbook.": "Import model metadata from an Excel workbook.",
"Import model prices from an Excel workbook.": "Import model prices from an Excel workbook.",
"Import model pricing": "Import model pricing",
......@@ -2664,6 +2678,7 @@
"Important": "Important",
"Imported mappings replace existing mappings with the same model name.": "Imported mappings replace existing mappings with the same model name.",
"Importing metadata does not add channels, enable model access, or configure prices.": "Importing metadata does not add channels, enable model access, or configure prices.",
"Importing replaces existing pricing for matching models. Expression billing clears fixed prices and ratios; other billing modes clear expressions. Prices and expression coefficients are imported as-is without currency conversion.": "Importing replaces existing pricing for matching models. Expression billing clears fixed prices and ratios; other billing modes clear expressions. Prices and expression coefficients are imported as-is without currency conversion.",
"Importing replaces the billing mode for matching models: per-token billing clears fixed prices, while per-request billing clears ratio settings.": "Importing replaces the billing mode for matching models: per-token billing clears fixed prices, while per-request billing clears ratio settings.",
"Importing...": "Importing...",
"In BotFather, open Login Widget, register this callback URL, and copy the Client ID and Client Secret. Existing Telegram bindings will continue to work after configuration.": "In BotFather, open Login Widget, register this callback URL, and copy the Client ID and Client Secret. Existing Telegram bindings will continue to work after configuration.",
......@@ -2674,6 +2689,7 @@
"incident": "incident",
"incidents": "incidents",
"Incidents": "Incidents",
"Include document text in the ranked results": "Include document text in the ranked results",
"Include Group": "Include Group",
"Include Model": "Include Model",
"Include name": "Include name",
......@@ -2707,6 +2723,7 @@
"Initializing…": "Initializing…",
"Inpaint": "Inpaint",
"Input": "Input",
"Input image URL or array of image URLs for image editing.": "Input image URL or array of image URLs for image editing.",
"Input mode": "Input mode",
"Input price": "Input price",
"Input price is required before saving dependent prices.": "Input price is required before saving dependent prices.",
......@@ -2866,6 +2883,7 @@
"Learn more": "Learn more",
"Learn more:": "Learn more:",
"Leave": "Leave",
"Leave all price cells blank to keep pricing unchanged. Pricing requires a super administrator and exact model names. Existing models and their prices are skipped unless overwrite is enabled. Prices use USD per million tokens, or USD per request for fixed_price; no currency conversion is applied.": "Leave all price cells blank to keep pricing unchanged. Pricing requires a super administrator and exact model names. Existing models and their prices are skipped unless overwrite is enabled. Prices use USD per million tokens, or USD per request for fixed_price; no currency conversion is applied.",
"Leave blank to keep the existing credential": "Leave blank to keep the existing credential",
"Leave blank to keep the existing key": "Leave blank to keep the existing key",
"Leave blank unless rotating the secret": "Leave blank unless rotating the secret",
......@@ -3046,8 +3064,10 @@
"Maximum custom groups per token": "Maximum custom groups per token",
"Maximum input window": "Maximum input window",
"Maximum number of channels tested at the same time (1-32)": "Maximum number of channels tested at the same time (1-32)",
"Maximum number of ranked results to return": "Maximum number of ranked results to return",
"Maximum number of tokens each user can create. Default 1000. Setting too large may affect performance.": "Maximum number of tokens each user can create. Default 1000. Setting too large may affect performance.",
"Maximum number of tokens in the response": "Maximum number of tokens in the response",
"Maximum output tokens; the model context and gateway limits also apply.": "Maximum output tokens; the model context and gateway limits also apply.",
"Maximum quota amount awarded for check-in": "Maximum quota amount awarded for check-in",
"Maximum tokens including hidden reasoning tokens": "Maximum tokens including hidden reasoning tokens",
"Maximum tokens per response": "Maximum tokens per response",
......@@ -3162,6 +3182,7 @@
"Model prices reset successfully": "Model prices reset successfully",
"Model Pricing": "Model Pricing",
"Model pricing import completed: {{updated}} updated, {{perToken}} per-token, {{perRequest}} per-request, {{failed}} failed.": "Model pricing import completed: {{updated}} updated, {{perToken}} per-token, {{perRequest}} per-request, {{failed}} failed.",
"Model pricing import completed: {{updated}} updated, {{perToken}} per-token, {{perRequest}} per-request, {{tieredExpr}} expression, {{failed}} failed.": "Model pricing import completed: {{updated}} updated, {{perToken}} per-token, {{perRequest}} per-request, {{tieredExpr}} expression, {{failed}} failed.",
"Model pricing import failed": "Model pricing import failed",
"Model pricing is managed by a super administrator.": "Model pricing is managed by a super administrator.",
"Model pricing saved": "Model pricing saved",
......@@ -3256,6 +3277,7 @@
"Multi-user management with flexible permission allocation": "Multi-user management with flexible permission allocation",
"Multilingual translation and localisation": "Multilingual translation and localisation",
"Multimodal": "Multimodal",
"Multimodal options: dimension, enable_fusion, output_type (dense), instruct and fps (0–1). parameters.dimension overrides dimensions.": "Multimodal options: dimension, enable_fusion, output_type (dense), instruct and fps (0–1). parameters.dimension overrides dimensions.",
"Multiplier": "Multiplier",
"Multiplier applied when": "Multiplier applied when",
"Multiplier applied when {{userGroup}} uses {{targetGroup}}": "Multiplier applied when {{userGroup}} uses {{targetGroup}}",
......@@ -3290,6 +3312,8 @@
"Native format": "Native format",
"Native forwarding": "Native forwarding",
"Native Gemini routes plus OpenAI Chat and Responses compatibility forwarding.": "Native Gemini routes plus OpenAI Chat and Responses compatibility forwarding.",
"Native image parameters override top-level size and watermark; include size, n and seed here when needed.": "Native image parameters override top-level size and watermark; include size, n and seed here when needed.",
"Native input.messages overrides the prompt/image conversion.": "Native input.messages overrides the prompt/image conversion.",
"Native OpenAI routes plus optional Claude and Gemini compatibility routes.": "Native OpenAI routes plus optional Claude and Gemini compatibility routes.",
"Native routes": "Native routes",
"Need a redemption code?": "Need a redemption code?",
......@@ -3389,6 +3413,7 @@
"No encryption": "No encryption",
"No endpoints configured. Switch to JSON mode or add rows to define endpoints.": "No endpoints configured. Switch to JSON mode or add rows to define endpoints.",
"No endpoints inferred from channels": "No endpoints inferred from channels",
"No example is available for this endpoint.": "No example is available for this endpoint.",
"No extra domains declared": "No extra domains declared",
"No FAQ entries available": "No FAQ entries available",
"No FAQ entries yet. Click \"Add FAQ\" to create one.": "No FAQ entries yet. Click \"Add FAQ\" to create one.",
......@@ -3570,6 +3595,7 @@
"Notification Method": "Notification Method",
"Notifications": "Notifications",
"Now a user whose user group is vip creates tokens with different groups and makes one call with each:": "Now a user whose user group is vip creates tokens with different groups and makes one call with each:",
"Nucleus sampling probability": "Nucleus sampling probability",
"Nucleus sampling probability mass": "Nucleus sampling probability mass",
"Number": "Number",
"Number of codes to create": "Number of codes to create",
......@@ -3718,6 +3744,7 @@
"Optional note describing this version": "Optional note describing this version",
"Optional notes about this channel": "Optional notes about this channel",
"Optional notes about when to use this group": "Optional notes about when to use this group",
"Optional ranking instruction": "Optional ranking instruction",
"Optional ratio used when upstream cache hits occur.": "Optional ratio used when upstream cache hits occur.",
"Optional request-rule multiplier expression. Leave empty when no request rule applies.": "Optional request-rule multiplier expression. Leave empty when no request rule applies.",
"Optional rule description": "Optional rule description",
......@@ -3746,8 +3773,11 @@
"Outage": "Outage",
"Output": "Output",
"Output aspect ratio": "Output aspect ratio",
"Output aspect ratio. H3 frame inputs force adaptive; text-only input requires an explicit ratio.": "Output aspect ratio. H3 frame inputs force adaptive; text-only input requires an explicit ratio.",
"Output image format": "Output image format",
"Output image size": "Output image size",
"Output price": "Output price",
"Output resolution": "Output resolution",
"Output token price for generated tokens.": "Output token price for generated tokens.",
"Output tokens": "Output tokens",
"Output Tokens": "Output Tokens",
......@@ -4126,6 +4156,7 @@
"Pricing group example": "Pricing group example",
"Pricing groups": "Pricing groups",
"Pricing import requirements": "Pricing import requirements",
"Pricing import: {{updated}} updated, {{failed}} failed.": "Pricing import: {{updated}} updated, {{failed}} failed.",
"Pricing mode": "Pricing mode",
"Pricing must be a JSON object": "Pricing must be a JSON object",
"Pricing Ratios": "Pricing Ratios",
......@@ -4176,6 +4207,7 @@
"Provider created successfully": "Provider created successfully",
"Provider deleted successfully": "Provider deleted successfully",
"Provider Name": "Provider Name",
"Provider options are model-specific. Top-level seconds and resolution take precedence.": "Provider options are model-specific. Top-level seconds and resolution take precedence.",
"Provider type (OpenAI, Anthropic, etc.)": "Provider type (OpenAI, Anthropic, etc.)",
"Provider updated successfully": "Provider updated successfully",
"Provider-specific endpoint, account, and compatibility settings.": "Provider-specific endpoint, account, and compatibility settings.",
......@@ -4237,6 +4269,7 @@
"Quota update failed": "Quota update failed",
"Quota Warning Threshold": "Quota Warning Threshold",
"Quota:": "Quota:",
"Qwen/Wan images and Seedream 5.0 pro require stream=false in this adapter.": "Qwen/Wan images and Seedream 5.0 pro require stream=false in this adapter.",
"Radius": "Radius",
"Random": "Random",
"Randomly select a key from the pool for each request": "Randomly select a key from the pool for each request",
......@@ -4408,6 +4441,7 @@
"Replace": "Replace",
"Replace all existing keys": "Replace all existing keys",
"Replace channel models": "Replace channel models",
"Replace example media URLs with accessible media supported by the model.": "Replace example media URLs with accessible media supported by the model.",
"Replace forwarding routes?": "Replace forwarding routes?",
"Replace mode: Will completely replace all existing keys": "Replace mode: Will completely replace all existing keys",
"Replace With": "Replace With",
......@@ -4625,6 +4659,7 @@
"s": "s",
"Safety Settings": "Safety Settings",
"Same as Local": "Same as Local",
"Sampling temperature": "Sampling temperature",
"Sampling temperature; lower is more deterministic": "Sampling temperature; lower is more deterministic",
"Sandbox": "Sandbox",
"Sandbox mode": "Sandbox mode",
......@@ -4709,6 +4744,7 @@
"Search payment type keys...": "Search payment type keys...",
"Search payment types...": "Search payment types...",
"Search products...": "Search products...",
"Search query used to rank documents": "Search query used to rank documents",
"Search rules...": "Search rules...",
"Search tags...": "Search tags...",
"Search the public web at inference time": "Search the public web at inference time",
......@@ -5291,6 +5327,7 @@
"The exact model identifier as used in API requests.": "The exact model identifier as used in API requests.",
"The Excel workbook must contain a worksheet.": "The Excel workbook must contain a worksheet.",
"The Excel worksheet must contain the headers “模型名称” and “映射模型”.": "The Excel worksheet must contain the headers “模型名称” and “映射模型”.",
"The first row must contain headers, and model_name is required. For expression billing, provide billing_expr and leave all price columns blank. Use len for input-length tiers. Otherwise provide fixed_price or input_price; optional columns are completion_price, cache_price, create_cache_price, image_price, audio_input_price, and audio_output_price.": "The first row must contain headers, and model_name is required. For expression billing, provide billing_expr and leave all price columns blank. Use len for input-length tiers. Otherwise provide fixed_price or input_price; optional columns are completion_price, cache_price, create_cache_price, image_price, audio_input_price, and audio_output_price.",
"The first row must contain headers, and model_name is required. For per-request billing, provide fixed_price. Otherwise input_price is required; optional columns are completion_price, cache_price, create_cache_price, image_price, audio_input_price, and audio_output_price.": "The first row must contain headers, and model_name is required. For per-request billing, provide fixed_price. Otherwise input_price is required; optional columns are completion_price, cache_price, create_cache_price, image_price, audio_input_price, and audio_output_price.",
"The first row must contain headers, and model_name is required. Optional columns: description, icon, tags, vendor_name, endpoints, status, sync_official, name_rule.": "The first row must contain headers, and model_name is required. Optional columns: description, icon, tags, vendor_name, endpoints, status, sync_official, name_rule.",
"The first row must contain the headers “模型名称” and “映射模型”. Both columns are required.": "The first row must contain the headers “模型名称” and “映射模型”. Both columns are required.",
......@@ -5337,6 +5374,7 @@
"Theme Settings": "Theme Settings",
"There are both add and remove models pending, but you only selected one type. Confirm submitting only the selected items?": "There are both add and remove models pending, but you only selected one type. Confirm submitting only the selected items?",
"There is a rule for vip billed as premium → use its ratio 0.3": "There is a rule for vip billed as premium → use its ratio 0.3",
"These examples call new-api with a new-api token. CTyun AppKeys and regional upstream URLs belong in channel settings. Select a token group routed to CTyun; switching examples does not change routing.": "These examples call new-api with a new-api token. CTyun AppKeys and regional upstream URLs belong in channel settings. Select a token group routed to CTyun; switching examples does not change routing.",
"These models are still in your selection but were not returned by the upstream listing. Entries that are only model_mapping source aliases are omitted. Toggle to adjust before saving.": "These models are still in your selection but were not returned by the upstream listing. Entries that are only model_mapping source aliases are omitted. Toggle to adjust before saving.",
"These toggles affect whether certain request fields are passed through to the upstream provider.": "These toggles affect whether certain request fields are passed through to the upstream provider.",
"These values come from the source index and are shown for review only. The gateway admits the plugin based on the metadata compiled from its source.": "These values come from the source index and are shown for review only. The gateway admits the plugin based on the metadata compiled from its source.",
......@@ -5819,6 +5857,7 @@
"Use 8–128 characters.": "Use 8–128 characters.",
"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 a different stable value for each instance, then restart the service.": "Use a different stable value for each instance, then restart the service.",
"Use a model-supported image size, such as 2K for Seedream.": "Use a model-supported image size, such as 2K for Seedream.",
"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 authenticator code": "Use authenticator code",
"Use backup code": "Use backup code",
......@@ -5829,6 +5868,7 @@
"Use exact model names such as gpt-4o, or regex rules prefixed with re: such as re:^gemini-.": "Use exact model names such as gpt-4o, or regex rules prefixed with re: such as re:^gemini-.",
"Use expression pricing when a dependent price is non-zero and its base price is zero.": "Use expression pricing when a dependent price is non-zero and its base price is zero.",
"Use external tools to extend capabilities": "Use external tools to extend capabilities",
"Use input.contents; each item contains exactly one nonempty text, image or video string.": "Use input.contents; each item contains exactly one nonempty text, image or video string.",
"Use one available reset credit for this channel. The reset request is sent only after confirmation.": "Use one available reset credit for this channel. The reset request is sent only after confirmation.",
"Use one available reset credit to refresh the current Codex usage windows.": "Use one available reset credit to refresh the current Codex usage windows.",
"Use our unified OpenAI-compatible endpoint in your applications": "Use our unified OpenAI-compatible endpoint in your applications",
......@@ -5839,9 +5879,11 @@
"Use sidebar shortcut": "Use sidebar shortcut",
"Use the full-width table to scan prices, then select a row to edit it here.": "Use the full-width table to scan prices, then select a row to edit it here.",
"Use the group set on the token. If the token has no group, use the user group. The auto group tries the auto assignment order from top to bottom.": "Use the group set on the token. If the token has no group, use the user group. The auto group tries the auto assignment order from top to bottom.",
"Use the model name shown here; new-api applies the channel model mapping.": "Use the model name shown here; new-api applies the channel model mapping.",
"Use the pricing group table to manage the ratio and whether the group appears in the token creation dropdown.": "Use the pricing group table to manage the ratio and whether the group appears in the token creation dropdown.",
"Use this callback URL pattern when registering a custom OAuth provider.": "Use this callback URL pattern when registering a custom OAuth provider.",
"Use this token for API authentication": "Use this token for API authentication",
"Use WIDTHxHEIGHT (512–2048 per side); Wan also accepts 1K, 2K and 4K. Native parameters.size uses WIDTH*HEIGHT.": "Use WIDTHxHEIGHT (512–2048 per side); Wan also accepts 1K, 2K and 4K. Native parameters.size uses WIDTH*HEIGHT.",
"Use your Passkey": "Use your Passkey",
"used": "used",
"Used": "Used",
......@@ -5977,7 +6019,9 @@
"Vertex AI Key Format": "Vertex AI Key Format",
"Vertex AI service account key must be valid JSON": "Vertex AI service account key must be valid JSON",
"Video": "Video",
"Video duration; new-api converts seconds to the upstream duration field.": "Video duration; new-api converts seconds to the upstream duration field.",
"Video length in seconds": "Video length in seconds",
"Video reference": "Video reference",
"Video Remix": "Video Remix",
"Vidu": "Vidu",
"View": "View",
......
......@@ -209,6 +209,7 @@
"Add a new user by providing necessary info.": "Ajouter un nouvel utilisateur en fournissant les informations nécessaires.",
"Add a new vendor to the system": "Ajouter un nouveau fournisseur au système",
"Add a vendor or adjust your search.": "Ajoutez un fournisseur ou modifiez la recherche.",
"Add a watermark to the generated image": "Ajouter un filigrane à l’image générée",
"Add an extra layer of security to your account": "Ajouter une couche de sécurité supplémentaire à votre compte",
"Add an index URL to browse installable plugins.": "Ajoutez une URL d’index pour parcourir les plugins installables.",
"Add and submit": "Ajouter et soumettre",
......@@ -317,6 +318,8 @@
"Advanced platform configuration.": "Configuration avancée de la plateforme.",
"Advanced Settings": "Paramètres avancés",
"Advanced text editing": "Édition de texte avancée",
"Advanced video inputs: content contains text, image_url, video_url or audio_url items with optional roles.": "Entrées vidéo avancées : content contient des éléments text, image_url, video_url ou audio_url avec des rôles facultatifs.",
"Advanced video inputs: input.media contains typed media URLs; do not mix frame and reference inputs.": "Entrées vidéo avancées : input.media contient les URL multimédias typées ; ne mélangez pas les images clés et les références.",
"Aesthetic style": "Style esthétique",
"Affected windows:": "Fenêtres affectées :",
"After clicking the button, you'll be asked to authorize the bot": "Après avoir cliqué sur le bouton, il vous sera demandé d'autoriser le bot",
......@@ -707,6 +710,7 @@
"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",
"Basic example": "Exemple de base",
"Basic Info": "Informations de base",
"Basic Information": "Informations de base",
"Basic Templates": "Modèles de base",
......@@ -1242,6 +1246,7 @@
"Controls visibility in the model square. Channel status and existing API access are unchanged.": "Contrôle la visibilité dans la galerie des modèles sans changer les canaux ni les accès API existants.",
"Controls whether user verification (biometrics/PIN) is required during Passkey flows.": "Contrôle si la vérification de l'utilisateur (biométrie/PIN) est requise lors des flux de Passkey.",
"Conversation cleared": "Conversation effacée",
"Conversation messages": "Messages de conversation",
"Conversion rate from USD to your custom currency": "Taux de conversion de l'USD vers votre devise personnalisée",
"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",
......@@ -1384,6 +1389,7 @@
"Creem products must be a JSON array": "Les produits Creem doivent être un tableau JSON",
"Cross-group": "Inter-groupes",
"Cross-group retry": "Nouvelle tentative inter-groupes",
"CTyun": "CTyun",
"Currency": "Devise",
"Currency & Display": "Devise et affichage",
"Current": "Actuelle",
......@@ -1688,6 +1694,7 @@
"Docs": "Documents",
"Documentation Link": "Lien de la documentation",
"Documentation or external knowledge base.": "Documentation ou base de connaissances externe.",
"Documents to rank: strings or objects containing text": "Documents à classer : chaînes ou objets contenant text",
"Does not check healthy channels. It only rechecks auto-disabled channels and restores them after they recover.": "Ne vérifie pas les canaux opérationnels. Revérifie uniquement les canaux désactivés automatiquement et les réactive après leur rétablissement.",
"does not exist or might have been removed.": "n'existe pas ou a peut-être été supprimé.",
"Domain": "Domaine",
......@@ -2024,7 +2031,10 @@
"Example": "Exemple",
"Example (all channels):": "Exemple (tous les canaux) :",
"Example (specific channels):": "Exemple (canaux spécifiques) :",
"Example channel": "Canal de l’exemple",
"Example price": "Prix d'exemple",
"Example scenario": "Scénario de l’exemple",
"Example selection does not change request routing. Use a token group matching the example; channels in the same group may still use different formats.": "Choisir un exemple ne modifie pas le routage. Utilisez un groupe de jetons correspondant ; les canaux d’un même groupe peuvent avoir des formats différents.",
"Example spec": "Spécification d'exemple",
"Example:": "Exemple :",
"example.com&#10;blocked-site.com": "example.com&#10;blocked-site.com",
......@@ -2343,6 +2353,7 @@
"Finish Time": "Heure de fin",
"First API request": "Première requête API",
"First token": "1er token",
"First-frame image URL. Use images for first and last frames; uploaded files are not supported by this video adapter.": "URL de la première image. Utilisez images pour les images initiale et finale ; cet adaptateur ne prend pas en charge les fichiers téléversés.",
"First/Last Frame to Video": "Première/Dernière image vers vidéo",
"Fix Abilities": "Réparer la cohérence des canaux",
"Fix order": "Corriger l’ordre",
......@@ -2639,6 +2650,7 @@
"Ignore": "Ignorer",
"Ignored upstream models": "Modèles amont ignorés",
"Image": "Image",
"Image count is bounded by new-api; the upstream model may impose a lower limit. Seedream groups are converted automatically.": "new-api limite le nombre d’images ; le modèle peut imposer une limite inférieure. Les groupes Seedream sont convertis automatiquement.",
"Image Generation": "Génération d'images",
"Image In": "Entrée d’image",
"Image input": "Entrée image",
......@@ -2648,6 +2660,7 @@
"Image output price": "Prix de sortie image",
"Image Preview": "Aperçu de l'image",
"Image ratio": "Ratio d'image",
"Image reference": "Référence image",
"Image to Video": "Image vers vidéo",
"Image Tokens": "Tokens image",
"Imagine the pricing table has three groups: default (ratio 1.0), premium (ratio 0.5), and vip (ratio 0.8). Users whose account is in the vip group get user-level perks, and premium is a cheaper channel pool that users can pick for their tokens.": "Imaginez que le tableau tarifaire contient trois groupes : default (taux 1,0), premium (taux 0,5) et vip (taux 0,8). Les utilisateurs du groupe vip bénéficient d’avantages au niveau du compte, et premium est un pool de canaux moins cher que les utilisateurs peuvent choisir pour leurs jetons.",
......@@ -2656,6 +2669,7 @@
"Import from URL": "Importer depuis une URL",
"Import mappings from an Excel workbook.": "Importer des mappages depuis un classeur Excel.",
"Import model mappings": "Importer les mappages de modèles",
"Import model metadata and optional pricing from an Excel workbook.": "Importer les métadonnées des modèles et leurs tarifs facultatifs depuis Excel.",
"Import model metadata from an Excel workbook.": "Importer les métadonnées des modèles depuis un classeur Excel.",
"Import model prices from an Excel workbook.": "Importer les tarifs des modèles depuis un classeur Excel.",
"Import model pricing": "Importer les tarifs des modèles",
......@@ -2664,6 +2678,7 @@
"Important": "Important",
"Imported mappings replace existing mappings with the same model name.": "Les mappages importés remplacent les mappages existants du même nom de modèle.",
"Importing metadata does not add channels, enable model access, or configure prices.": "L’import ne crée pas de canaux, n’ouvre pas l’accès aux modèles et ne configure pas les prix.",
"Importing replaces existing pricing for matching models. Expression billing clears fixed prices and ratios; other billing modes clear expressions. Prices and expression coefficients are imported as-is without currency conversion.": "L’import remplace les tarifs des modèles correspondants. La facturation par expression efface les prix fixes et les ratios ; les autres modes effacent les expressions. Les prix et coefficients sont importés sans conversion monétaire.",
"Importing replaces the billing mode for matching models: per-token billing clears fixed prices, while per-request billing clears ratio settings.": "L’importation remplace le mode de facturation des modèles correspondants : la facturation au token efface les prix fixes, tandis que la facturation par requête efface les ratios.",
"Importing...": "Importation...",
"In BotFather, open Login Widget, register this callback URL, and copy the Client ID and Client Secret. Existing Telegram bindings will continue to work after configuration.": "Dans BotFather, ouvrez Login Widget, enregistrez cette URL de rappel et copiez le Client ID et le Client Secret. Les comptes Telegram déjà liés fonctionneront après la configuration.",
......@@ -2674,6 +2689,7 @@
"incident": "incident",
"incidents": "incidents",
"Incidents": "Incidents",
"Include document text in the ranked results": "Inclure le texte des documents dans les résultats",
"Include Group": "Inclure le groupe",
"Include Model": "Inclure le modèle",
"Include name": "Inclure le nom",
......@@ -2707,6 +2723,7 @@
"Initializing…": "Initialisation…",
"Inpaint": "Inpainting",
"Input": "Entrée",
"Input image URL or array of image URLs for image editing.": "URL d’image ou tableau d’URL pour la retouche d’images.",
"Input mode": "Mode d'entrée",
"Input price": "Prix d’entrée",
"Input price is required before saving dependent prices.": "Le prix d’entrée est requis avant d’enregistrer les prix dépendants.",
......@@ -2866,6 +2883,7 @@
"Learn more": "En savoir plus",
"Learn more:": "En savoir plus :",
"Leave": "Quitter",
"Leave all price cells blank to keep pricing unchanged. Pricing requires a super administrator and exact model names. Existing models and their prices are skipped unless overwrite is enabled. Prices use USD per million tokens, or USD per request for fixed_price; no currency conversion is applied.": "Laissez tous les prix vides pour conserver les tarifs. Leur import nécessite un super administrateur et des noms de modèles exacts. Sans écrasement, les modèles existants et leurs tarifs sont ignorés. Prix en USD par million de tokens, ou par requête pour fixed_price, sans conversion de devise.",
"Leave blank to keep the existing credential": "Laissez vide pour conserver l'identifiant existant",
"Leave blank to keep the existing key": "Laisser vide pour conserver la clé existante",
"Leave blank unless rotating the secret": "Laissez vide, sauf si vous faites pivoter le secret",
......@@ -3046,8 +3064,10 @@
"Maximum custom groups per token": "Nombre maximal de groupes personnalisés par jeton",
"Maximum input window": "Fenêtre d'entrée maximale",
"Maximum number of channels tested at the same time (1-32)": "Nombre maximal de canaux testés simultanément (1 à 32)",
"Maximum number of ranked results to return": "Nombre maximal de résultats classés à renvoyer",
"Maximum number of tokens each user can create. Default 1000. Setting too large may affect performance.": "Nombre maximum de jetons que chaque utilisateur peut créer. Par défaut 1000. Une valeur trop élevée peut affecter les performances.",
"Maximum number of tokens in the response": "Nombre maximum de jetons dans la réponse",
"Maximum output tokens; the model context and gateway limits also apply.": "Nombre maximal de tokens de sortie ; les limites du contexte et de la passerelle s’appliquent aussi.",
"Maximum quota amount awarded for check-in": "Montant maximum de quota attribué pour la connexion",
"Maximum tokens including hidden reasoning tokens": "Jetons maximum, y compris les jetons de raisonnement masqués",
"Maximum tokens per response": "Nombre maximal de jetons par réponse",
......@@ -3162,6 +3182,7 @@
"Model prices reset successfully": "Prix des modèles réinitialisés avec succès",
"Model Pricing": "Tarification des modèles",
"Model pricing import completed: {{updated}} updated, {{perToken}} per-token, {{perRequest}} per-request, {{failed}} failed.": "Importation des tarifs terminée : {{updated}} mis à jour, {{perToken}} au token, {{perRequest}} par requête, {{failed}} en échec.",
"Model pricing import completed: {{updated}} updated, {{perToken}} per-token, {{perRequest}} per-request, {{tieredExpr}} expression, {{failed}} failed.": "Import terminé : {{updated}} mis à jour, {{perToken}} par token, {{perRequest}} par requête, {{tieredExpr}} par expression, {{failed}} en échec.",
"Model pricing import failed": "Échec de l’importation des tarifs des modèles",
"Model pricing is managed by a super administrator.": "Les tarifs sont gérés par un super administrateur.",
"Model pricing saved": "Tarifs du modèle enregistrés",
......@@ -3256,6 +3277,7 @@
"Multi-user management with flexible permission allocation": "Gestion multi-utilisateurs avec attribution de permissions flexible",
"Multilingual translation and localisation": "Traduction multilingue et localisation",
"Multimodal": "Multimodal",
"Multimodal options: dimension, enable_fusion, output_type (dense), instruct and fps (0–1). parameters.dimension overrides dimensions.": "Options multimodales : dimension, enable_fusion, output_type (dense), instruct et fps (0–1). parameters.dimension prime sur dimensions.",
"Multiplier": "Multiplicateur",
"Multiplier applied when": "Multiplicateur appliqué lorsque",
"Multiplier applied when {{userGroup}} uses {{targetGroup}}": "Multiplicateur appliqué lorsque {{userGroup}} utilise {{targetGroup}}",
......@@ -3290,6 +3312,8 @@
"Native format": "Format natif",
"Native forwarding": "Transfert natif",
"Native Gemini routes plus OpenAI Chat and Responses compatibility forwarding.": "Routes Gemini natives avec transfert compatible OpenAI Chat et Responses.",
"Native image parameters override top-level size and watermark; include size, n and seed here when needed.": "Les parameters natifs remplacent size et watermark de premier niveau ; indiquez ici size, n et seed si nécessaire.",
"Native input.messages overrides the prompt/image conversion.": "input.messages natif remplace la conversion de prompt/image.",
"Native OpenAI routes plus optional Claude and Gemini compatibility routes.": "Routes OpenAI natives avec compatibilité Claude et Gemini en option.",
"Native routes": "Routes natives",
"Need a redemption code?": "Besoin d'un code d'échange ?",
......@@ -3389,6 +3413,7 @@
"No encryption": "Aucun chiffrement",
"No endpoints configured. Switch to JSON mode or add rows to define endpoints.": "Aucun point de terminaison configuré. Passez en mode JSON ou ajoutez des lignes pour définir les points de terminaison.",
"No endpoints inferred from channels": "Aucun point de terminaison déduit des canaux",
"No example is available for this endpoint.": "Aucun exemple disponible pour ce point de terminaison.",
"No extra domains declared": "Aucun domaine supplémentaire déclaré",
"No FAQ entries available": "Aucune entrée FAQ disponible",
"No FAQ entries yet. Click \"Add FAQ\" to create one.": "Aucune entrée FAQ pour l'instant. Cliquez sur \"Ajouter une FAQ\" pour en créer une.",
......@@ -3570,6 +3595,7 @@
"Notification Method": "Méthode de notification",
"Notifications": "Notifications",
"Now a user whose user group is vip creates tokens with different groups and makes one call with each:": "Un utilisateur du groupe vip crée maintenant des jetons avec différents groupes et effectue un appel avec chacun :",
"Nucleus sampling probability": "Probabilité d’échantillonnage nucleus",
"Nucleus sampling probability mass": "Masse probabiliste de l'échantillonnage nucleus",
"Number": "Nombre",
"Number of codes to create": "Nombre de codes à créer",
......@@ -3718,6 +3744,7 @@
"Optional note describing this version": "Note facultative décrivant cette version",
"Optional notes about this channel": "Notes optionnelles sur ce canal",
"Optional notes about when to use this group": "Notes optionnelles sur le moment d'utiliser ce groupe",
"Optional ranking instruction": "Instruction de classement facultative",
"Optional ratio used when upstream cache hits occur.": "Ratio optionnel utilisé en cas de succès du cache en amont.",
"Optional request-rule multiplier expression. Leave empty when no request rule applies.": "Expression multiplicatrice facultative pour les règles de requête. Laissez vide si aucune règle ne s'applique.",
"Optional rule description": "Description facultative de la règle",
......@@ -3746,8 +3773,11 @@
"Outage": "Interruption",
"Output": "Sortie",
"Output aspect ratio": "Format d'image de sortie",
"Output aspect ratio. H3 frame inputs force adaptive; text-only input requires an explicit ratio.": "Format de sortie. Les images clés H3 imposent adaptive ; une entrée texte seule exige un ratio explicite.",
"Output image format": "Format de l’image de sortie",
"Output image size": "Taille de l'image de sortie",
"Output price": "Prix de sortie",
"Output resolution": "Résolution de sortie",
"Output token price for generated tokens.": "Prix des tokens de sortie générés.",
"Output tokens": "Jetons de sortie",
"Output Tokens": "Tokens de sortie",
......@@ -4126,6 +4156,7 @@
"Pricing group example": "Exemple de groupe tarifaire",
"Pricing groups": "Groupes tarifaires",
"Pricing import requirements": "Exigences d’importation des tarifs",
"Pricing import: {{updated}} updated, {{failed}} failed.": "Tarifs : {{updated}} mis à jour, {{failed}} échecs.",
"Pricing mode": "Mode de tarification",
"Pricing must be a JSON object": "Les tarifs doivent être un objet JSON",
"Pricing Ratios": "Ratios de tarification",
......@@ -4176,6 +4207,7 @@
"Provider created successfully": "Fournisseur créé avec succès",
"Provider deleted successfully": "Fournisseur supprimé avec succès",
"Provider Name": "Nom du fournisseur",
"Provider options are model-specific. Top-level seconds and resolution take precedence.": "Les options dépendent du modèle. Les champs seconds et resolution de premier niveau sont prioritaires.",
"Provider type (OpenAI, Anthropic, etc.)": "Type de fournisseur (OpenAI, Anthropic, etc.)",
"Provider updated successfully": "Fournisseur mis à jour avec succès",
"Provider-specific endpoint, account, and compatibility settings.": "Paramètres de point d’accès, de compte et de compatibilité propres au fournisseur.",
......@@ -4237,6 +4269,7 @@
"Quota update failed": "Échec de la mise à jour du quota",
"Quota Warning Threshold": "Seuil d'avertissement de quota",
"Quota:": "Quota :",
"Qwen/Wan images and Seedream 5.0 pro require stream=false in this adapter.": "Cet adaptateur exige stream=false pour les images Qwen/Wan et Seedream 5.0 pro.",
"Radius": "Rayon",
"Random": "Aléatoire",
"Randomly select a key from the pool for each request": "Sélectionner aléatoirement une clé du pool pour chaque requête",
......@@ -4408,6 +4441,7 @@
"Replace": "Remplacer",
"Replace all existing keys": "Remplacer toutes les clés existantes",
"Replace channel models": "Remplacer les modèles du canal",
"Replace example media URLs with accessible media supported by the model.": "Remplacez les URL d’exemple par des médias accessibles et pris en charge par le modèle.",
"Replace forwarding routes?": "Remplacer les routes de transfert ?",
"Replace mode: Will completely replace all existing keys": "Mode remplacement : Remplacera complètement toutes les clés existantes",
"Replace With": "Remplacer par",
......@@ -4625,6 +4659,7 @@
"s": "s",
"Safety Settings": "Paramètres de sécurité",
"Same as Local": "Identique au local",
"Sampling temperature": "Température d’échantillonnage",
"Sampling temperature; lower is more deterministic": "Température d'échantillonnage ; plus c'est bas, plus c'est déterministe",
"Sandbox": "Bac à sable",
"Sandbox mode": "Mode sandbox",
......@@ -4709,6 +4744,7 @@
"Search payment type keys...": "Rechercher des clés de type de paiement...",
"Search payment types...": "Rechercher des types de paiement...",
"Search products...": "Rechercher des produits...",
"Search query used to rank documents": "Requête utilisée pour classer les documents",
"Search rules...": "Rechercher des règles…",
"Search tags...": "Rechercher des tags...",
"Search the public web at inference time": "Rechercher sur le web public lors de l'inférence",
......@@ -5291,6 +5327,7 @@
"The exact model identifier as used in API requests.": "L'identifiant exact du modèle tel qu'utilisé dans les requêtes API.",
"The Excel workbook must contain a worksheet.": "Le classeur Excel doit contenir une feuille de calcul.",
"The Excel worksheet must contain the headers “模型名称” and “映射模型”.": "La feuille Excel doit contenir les en-têtes « 模型名称 » et « 映射模型 ».",
"The first row must contain headers, and model_name is required. For expression billing, provide billing_expr and leave all price columns blank. Use len for input-length tiers. Otherwise provide fixed_price or input_price; optional columns are completion_price, cache_price, create_cache_price, image_price, audio_input_price, and audio_output_price.": "La première ligne doit contenir les en-têtes, dont model_name. Pour une facturation par expression, renseignez billing_expr et laissez toutes les colonnes de prix vides. Utilisez len pour les paliers de longueur d’entrée. Sinon, renseignez fixed_price ou input_price ; colonnes facultatives : completion_price, cache_price, create_cache_price, image_price, audio_input_price et audio_output_price.",
"The first row must contain headers, and model_name is required. For per-request billing, provide fixed_price. Otherwise input_price is required; optional columns are completion_price, cache_price, create_cache_price, image_price, audio_input_price, and audio_output_price.": "La première ligne doit contenir les en-têtes et model_name est obligatoire. Pour la facturation par requête, renseignez fixed_price. Sinon, input_price est obligatoire ; colonnes facultatives : completion_price, cache_price, create_cache_price, image_price, audio_input_price et audio_output_price.",
"The first row must contain headers, and model_name is required. Optional columns: description, icon, tags, vendor_name, endpoints, status, sync_official, name_rule.": "La première ligne doit contenir les en-têtes et model_name est obligatoire. Colonnes facultatives : description, icon, tags, vendor_name, endpoints, status, sync_official, name_rule.",
"The first row must contain the headers “模型名称” and “映射模型”. Both columns are required.": "La première ligne doit contenir les en-têtes « 模型名称 » et « 映射模型 ». Les deux colonnes sont obligatoires.",
......@@ -5337,6 +5374,7 @@
"Theme Settings": "Paramètres du thème",
"There are both add and remove models pending, but you only selected one type. Confirm submitting only the selected items?": "Il y a à la fois des modèles à ajouter et à supprimer, mais vous n'avez sélectionné qu'un seul type. Confirmer l'envoi uniquement des éléments sélectionnés ?",
"There is a rule for vip billed as premium → use its ratio 0.3": "Il existe une règle pour vip facturé sous premium → son taux 0,3 s’applique",
"These examples call new-api with a new-api token. CTyun AppKeys and regional upstream URLs belong in channel settings. Select a token group routed to CTyun; switching examples does not change routing.": "Ces exemples appellent new-api avec son jeton. Configurez l’AppKey CTyun et l’URL régionale dans le canal. Choisissez un groupe routé vers CTyun ; changer d’exemple ne modifie pas le routage.",
"These models are still in your selection but were not returned by the upstream listing. Entries that are only model_mapping source aliases are omitted. Toggle to adjust before saving.": "Ces modèles restent encore sélectionnés mais ne figurent pas dans la liste renvoyée par l'amont ; les noms qui sont uniquement des clés sources de model_mapping sont exclus. Modifiez la sélection avant d'enregistrer.",
"These toggles affect whether certain request fields are passed through to the upstream provider.": "Ces bascules déterminent si certains champs de demande sont transmis au fournisseur en amont.",
"These values come from the source index and are shown for review only. The gateway admits the plugin based on the metadata compiled from its source.": "Ces valeurs proviennent de l’index de la source et sont affichées à titre d’examen uniquement. La passerelle admet le plugin d’après les métadonnées compilées depuis son code.",
......@@ -5819,6 +5857,7 @@
"Use 8–128 characters.": "Utilisez 8 à 128 caractères.",
"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 a different stable value for each instance, then restart the service.": "Utilisez une valeur stable différente pour chaque instance, puis redémarrez le service.",
"Use a model-supported image size, such as 2K for Seedream.": "Utilisez une taille prise en charge par le modèle, par exemple 2K pour Seedream.",
"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 authenticator code": "Utiliser le code de l'authentificateur",
"Use backup code": "Utiliser un code de secours",
......@@ -5829,6 +5868,7 @@
"Use exact model names such as gpt-4o, or regex rules prefixed with re: such as re:^gemini-.": "Utilisez des noms de modèle exacts comme gpt-4o, ou des règles regex préfixées par re: comme re:^gemini-.",
"Use expression pricing when a dependent price is non-zero and its base price is zero.": "Utilisez une expression si un prix dépendant est non nul et son prix de base est nul.",
"Use external tools to extend capabilities": "Utiliser des outils externes pour étendre les capacités",
"Use input.contents; each item contains exactly one nonempty text, image or video string.": "Utilisez input.contents ; chaque élément contient exactement une chaîne text, image ou video non vide.",
"Use one available reset credit for this channel. The reset request is sent only after confirmation.": "Utilise un crédit de réinitialisation disponible pour ce canal. La demande n’est envoyée qu’après confirmation.",
"Use one available reset credit to refresh the current Codex usage windows.": "Utilise un crédit de réinitialisation disponible pour actualiser les fenêtres d’utilisation Codex actuelles.",
"Use our unified OpenAI-compatible endpoint in your applications": "Utilisez notre point de terminaison unifié compatible OpenAI dans vos applications",
......@@ -5839,9 +5879,11 @@
"Use sidebar shortcut": "Utiliser le raccourci de la barre latérale",
"Use the full-width table to scan prices, then select a row to edit it here.": "Parcourez les prix dans le tableau, puis sélectionnez une ligne pour la modifier ici.",
"Use the group set on the token. If the token has no group, use the user group. The auto group tries the auto assignment order from top to bottom.": "Utilisez le groupe défini sur le jeton. S’il n’en a pas, utilisez le groupe de l’utilisateur. Le groupe auto essaie l’ordre d’affectation automatique de haut en bas.",
"Use the model name shown here; new-api applies the channel model mapping.": "Utilisez le nom affiché ; new-api applique la correspondance des modèles du canal.",
"Use the pricing group table to manage the ratio and whether the group appears in the token creation dropdown.": "Utilisez le tableau des groupes tarifaires pour gérer le ratio et l’apparition du groupe dans la liste de création de jeton.",
"Use this callback URL pattern when registering a custom OAuth provider.": "Utilisez ce modèle d'URL de rappel lors de l'enregistrement d'un fournisseur OAuth personnalisé.",
"Use this token for API authentication": "Utilisez ce jeton pour l'authentification API",
"Use WIDTHxHEIGHT (512–2048 per side); Wan also accepts 1K, 2K and 4K. Native parameters.size uses WIDTH*HEIGHT.": "Utilisez LARGEURxHAUTEUR (512–2048 par côté) ; Wan accepte aussi 1K, 2K et 4K. parameters.size natif utilise LARGEUR*HAUTEUR.",
"Use your Passkey": "Utiliser votre clé d'accès (Passkey)",
"used": "utilisé",
"Used": "Utilisé",
......@@ -5977,7 +6019,9 @@
"Vertex AI Key Format": "Format de clé Vertex AI",
"Vertex AI service account key must be valid JSON": "La clé de compte de service Vertex AI doit être un JSON valide",
"Video": "Vidéo",
"Video duration; new-api converts seconds to the upstream duration field.": "Durée vidéo ; new-api convertit seconds en duration pour le fournisseur.",
"Video length in seconds": "Durée de la vidéo en secondes",
"Video reference": "Référence vidéo",
"Video Remix": "Remix vidéo",
"Vidu": "Vidu",
"View": "Afficher",
......
......@@ -209,6 +209,7 @@
"Add a new user by providing necessary info.": "必要な情報を提供して新しいユーザーを追加します。",
"Add a new vendor to the system": "システムに新しいベンダーを追加",
"Add a vendor or adjust your search.": "プロバイダーを追加するか検索条件を変更してください。",
"Add a watermark to the generated image": "生成画像に透かしを追加",
"Add an extra layer of security to your account": "アカウントにセキュリティの追加レイヤーを追加します",
"Add an index URL to browse installable plugins.": "インデックス URL を追加すると、インストール可能なプラグインを閲覧できます。",
"Add and submit": "追加して送信",
......@@ -317,6 +318,8 @@
"Advanced platform configuration.": "高度なプラットフォーム設定。",
"Advanced Settings": "詳細設定",
"Advanced text editing": "高度なテキスト編集",
"Advanced video inputs: content contains text, image_url, video_url or audio_url items with optional roles.": "高度な動画入力:content に text、image_url、video_url、audio_url を指定し、必要に応じて role を設定します。",
"Advanced video inputs: input.media contains typed media URLs; do not mix frame and reference inputs.": "高度な動画入力:input.media に種類付きのメディア URL を指定します。フレームと参照素材は併用できません。",
"Aesthetic style": "スタイル",
"Affected windows:": "対象ウィンドウ:",
"After clicking the button, you'll be asked to authorize the bot": "ボタンをクリックすると、ボットの認証を求められます",
......@@ -707,6 +710,7 @@
"Base URL of your Uptime Kuma instance": "Uptime KumaインスタンスのベースURL",
"Basic Authentication": "基本認証",
"Basic Configuration": "基本設定",
"Basic example": "基本例",
"Basic Info": "基本情報",
"Basic Information": "基本情報",
"Basic Templates": "基本テンプレート",
......@@ -1242,6 +1246,7 @@
"Controls visibility in the model square. Channel status and existing API access are unchanged.": "モデル広場での表示を制御します。チャネルの有効状態と既存の API アクセス権限には影響しません。",
"Controls whether user verification (biometrics/PIN) is required during Passkey flows.": "Passkeyフロー中にユーザー認証(生体認証/PIN)が必要かどうかを制御します。",
"Conversation cleared": "会話を消去しました",
"Conversation messages": "会話メッセージ",
"Conversion rate from USD to your custom currency": "USDからカスタム通貨への換算レート",
"Convert reasoning_content to <think> tag in content": "content内のreasoning_contentを<think>タグに変換",
"Convert string to lowercase": "文字列を小文字に変換",
......@@ -1384,6 +1389,7 @@
"Creem products must be a JSON array": "Creem 製品は JSON 配列でなければなりません",
"Cross-group": "グループ横断",
"Cross-group retry": "グループ横断リトライ",
"CTyun": "CTyun",
"Currency": "通貨",
"Currency & Display": "通貨と表示",
"Current": "現在",
......@@ -1688,6 +1694,7 @@
"Docs": "ドキュメント",
"Documentation Link": "ドキュメントリンク",
"Documentation or external knowledge base.": "ドキュメントまたは外部知識ベース。",
"Documents to rank: strings or objects containing text": "ランキング対象の文書:文字列または text を含むオブジェクト",
"Does not check healthy channels. It only rechecks auto-disabled channels and restores them after they recover.": "正常なチャネルはチェックしません。自動無効化されたチャネルのみを再チェックし、復旧後に再び有効化します。",
"does not exist or might have been removed.": "存在しないか、削除された可能性があります。",
"Domain": "ドメイン",
......@@ -2024,7 +2031,10 @@
"Example": "サンプル",
"Example (all channels):": "例(全チャネル):",
"Example (specific channels):": "例(特定チャネル):",
"Example channel": "例の対象チャネル",
"Example price": "例の価格",
"Example scenario": "利用シナリオ",
"Example selection does not change request routing. Use a token group matching the example; channels in the same group may still use different formats.": "例の選択はリクエストのルーティングを変更しません。例に合うトークングループを使用してください。同じグループのチャネルでも形式が異なる場合があります。",
"Example spec": "例の仕様",
"Example:": "例:",
"example.com&#10;blocked-site.com": "example.com &#10;blocked-site.com",
......@@ -2343,6 +2353,7 @@
"Finish Time": "完了時刻",
"First API request": "最初の API リクエスト",
"First token": "先頭トークン",
"First-frame image URL. Use images for first and last frames; uploaded files are not supported by this video adapter.": "開始フレームの画像 URL。開始・終了フレームには images を使用します。この動画アダプターはファイルのアップロードに対応していません。",
"First/Last Frame to Video": "先頭/末尾フレームから動画",
"Fix Abilities": "チャネル整合性を修復",
"Fix order": "順序を修正",
......@@ -2639,6 +2650,7 @@
"Ignore": "無視",
"Ignored upstream models": "無視する上流モデル",
"Image": "画像",
"Image count is bounded by new-api; the upstream model may impose a lower limit. Seedream groups are converted automatically.": "画像数には new-api の上限があり、上流モデルの制限がさらに低い場合があります。Seedream の組画像パラメーターは自動変換されます。",
"Image Generation": "画像生成",
"Image In": "画像入力",
"Image input": "画像入力",
......@@ -2648,6 +2660,7 @@
"Image output price": "画像出力価格",
"Image Preview": "画像プレビュー",
"Image ratio": "画像倍率",
"Image reference": "画像参照",
"Image to Video": "画像から動画",
"Image Tokens": "画像トークン",
"Imagine the pricing table has three groups: default (ratio 1.0), premium (ratio 0.5), and vip (ratio 0.8). Users whose account is in the vip group get user-level perks, and premium is a cheaper channel pool that users can pick for their tokens.": "料金表に3つのグループがあるとします:default(倍率 1.0)、premium(倍率 0.5)、vip(倍率 0.8)。アカウントが vip グループのユーザーはユーザーレベルの特典を受けられ、premium はユーザーがトークン用に選べる安いチャネルプールです。",
......@@ -2656,6 +2669,7 @@
"Import from URL": "URL からインポート",
"Import mappings from an Excel workbook.": "Excel ブックからモデルマッピングをインポートします。",
"Import model mappings": "モデルマッピングをインポート",
"Import model metadata and optional pricing from an Excel workbook.": "Excel からモデル情報と任意の料金をインポートします。",
"Import model metadata from an Excel workbook.": "Excel ワークブックからモデルのメタデータをインポートします。",
"Import model prices from an Excel workbook.": "Excel ワークブックからモデル料金をインポートします。",
"Import model pricing": "モデル料金をインポート",
......@@ -2664,6 +2678,7 @@
"Important": "重要",
"Imported mappings replace existing mappings with the same model name.": "インポートしたマッピングは、同名モデルの既存マッピングを置き換えます。",
"Importing metadata does not add channels, enable model access, or configure prices.": "メタデータのインポートでは、チャネルの追加、モデルへのアクセス許可、料金設定は行いません。",
"Importing replaces existing pricing for matching models. Expression billing clears fixed prices and ratios; other billing modes clear expressions. Prices and expression coefficients are imported as-is without currency conversion.": "インポートすると、一致するモデルの料金が置き換わります。式による課金では固定価格と倍率が削除され、他の課金方式では式が削除されます。価格と式の係数は通貨換算せず、そのまま取り込まれます。",
"Importing replaces the billing mode for matching models: per-token billing clears fixed prices, while per-request billing clears ratio settings.": "インポートすると該当モデルの課金方式が上書きされます。トークン単位課金では固定価格が削除され、リクエスト単位課金では倍率設定が削除されます。",
"Importing...": "インポート中...",
"In BotFather, open Login Widget, register this callback URL, and copy the Client ID and Client Secret. Existing Telegram bindings will continue to work after configuration.": "BotFather で Login Widget を開き、このコールバック URL を登録して Client ID と Client Secret をコピーしてください。設定後は既存の Telegram 連携を引き続き利用できます。",
......@@ -2674,6 +2689,7 @@
"incident": "件",
"incidents": "件",
"Incidents": "インシデント",
"Include document text in the ranked results": "ランキング結果に文書のテキストを含める",
"Include Group": "グループを含む",
"Include Model": "モデルを含む",
"Include name": "名前を含める",
......@@ -2707,6 +2723,7 @@
"Initializing…": "初期化中…",
"Inpaint": "インペイント",
"Input": "入力",
"Input image URL or array of image URLs for image editing.": "画像編集用の入力画像 URL または URL 配列。",
"Input mode": "入力モード",
"Input price": "入力価格",
"Input price is required before saving dependent prices.": "依存する価格を保存する前に入力価格が必要です。",
......@@ -2866,6 +2883,7 @@
"Learn more": "詳細はこちら",
"Learn more:": "詳細はこちら:",
"Leave": "退出",
"Leave all price cells blank to keep pricing unchanged. Pricing requires a super administrator and exact model names. Existing models and their prices are skipped unless overwrite is enabled. Prices use USD per million tokens, or USD per request for fixed_price; no currency conversion is applied.": "価格欄をすべて空欄にすると既存の料金を保持します。料金のインポートにはスーパー管理者権限と完全一致のモデル名が必要です。上書きを有効にしない限り、既存のモデルと料金はスキップされます。単位は百万トークンあたりの米ドル、fixed_price はリクエストあたりの米ドルです。通貨換算は行いません。",
"Leave blank to keep the existing credential": "既存の認証情報を保持するには、空白のままにしてください",
"Leave blank to keep the existing key": "空欄のままにすると既存のキーを保持します",
"Leave blank unless rotating the secret": "シークレットをローテーションする場合を除き、空白のままにしてください",
......@@ -3046,8 +3064,10 @@
"Maximum custom groups per token": "トークンごとのカスタムグループ上限",
"Maximum input window": "最大入力ウィンドウ",
"Maximum number of channels tested at the same time (1-32)": "同時にテストするチャンネルの最大数(1~32)",
"Maximum number of ranked results to return": "返すランキング結果の最大件数",
"Maximum number of tokens each user can create. Default 1000. Setting too large may affect performance.": "各ユーザーが作成できる最大トークン数。デフォルトは 1000。大きすぎる値はパフォーマンスに影響を与える可能性があります。",
"Maximum number of tokens in the response": "レスポンスの最大トークン数",
"Maximum output tokens; the model context and gateway limits also apply.": "最大出力トークン数。モデルのコンテキストとゲートウェイの制限も適用されます。",
"Maximum quota amount awarded for check-in": "チェックインで付与される最大クォータ量",
"Maximum tokens including hidden reasoning tokens": "隠れ推論トークンを含む最大トークン数",
"Maximum tokens per response": "1 回の応答あたりの最大トークン数",
......@@ -3162,6 +3182,7 @@
"Model prices reset successfully": "モデル価格が正常にリセットされました",
"Model Pricing": "モデル料金",
"Model pricing import completed: {{updated}} updated, {{perToken}} per-token, {{perRequest}} per-request, {{failed}} failed.": "モデル料金のインポートが完了しました:更新 {{updated}} 件、トークン単位 {{perToken}} 件、リクエスト単位 {{perRequest}} 件、失敗 {{failed}} 件。",
"Model pricing import completed: {{updated}} updated, {{perToken}} per-token, {{perRequest}} per-request, {{tieredExpr}} expression, {{failed}} failed.": "モデル料金のインポート完了:更新 {{updated}} 件、トークン単位 {{perToken}} 件、リクエスト単位 {{perRequest}} 件、式 {{tieredExpr}} 件、失敗 {{failed}} 件。",
"Model pricing import failed": "モデル料金のインポートに失敗しました",
"Model pricing is managed by a super administrator.": "モデル料金はスーパー管理者が管理します。",
"Model pricing saved": "モデル料金を保存しました",
......@@ -3256,6 +3277,7 @@
"Multi-user management with flexible permission allocation": "柔軟な権限割り当てが可能なマルチユーザー管理",
"Multilingual translation and localisation": "多言語翻訳とローカライズ",
"Multimodal": "マルチモーダル",
"Multimodal options: dimension, enable_fusion, output_type (dense), instruct and fps (0–1). parameters.dimension overrides dimensions.": "マルチモーダル設定:dimension、enable_fusion、output_type(dense)、instruct、fps(0–1)。parameters.dimension は dimensions より優先されます。",
"Multiplier": "乗数",
"Multiplier applied when": "乗数が適用されるとき",
"Multiplier applied when {{userGroup}} uses {{targetGroup}}": "{{userGroup}}が{{targetGroup}}を使用する際に適用される倍率",
......@@ -3290,6 +3312,8 @@
"Native format": "ネイティブ形式",
"Native forwarding": "ネイティブ転送",
"Native Gemini routes plus OpenAI Chat and Responses compatibility forwarding.": "Gemini ネイティブルートと OpenAI Chat / Responses 互換転送。",
"Native image parameters override top-level size and watermark; include size, n and seed here when needed.": "ネイティブ画像 parameters は最上位の size と watermark より優先されます。必要に応じて size、n、seed をここに設定します。",
"Native input.messages overrides the prompt/image conversion.": "ネイティブの input.messages は prompt/image の自動変換より優先されます。",
"Native OpenAI routes plus optional Claude and Gemini compatibility routes.": "OpenAI ネイティブルートと、任意の Claude / Gemini 互換ルート。",
"Native routes": "ネイティブルート",
"Need a redemption code?": "引き換えコードが必要ですか?",
......@@ -3389,6 +3413,7 @@
"No encryption": "暗号化なし",
"No endpoints configured. Switch to JSON mode or add rows to define endpoints.": "エンドポイントが設定されていません。JSONモードに切り替えるか、エンドポイントを定義するために行を追加してください。",
"No endpoints inferred from channels": "チャネルから取得したエンドポイントなし",
"No example is available for this endpoint.": "このエンドポイントの例はありません。",
"No extra domains declared": "追加ドメインの宣言なし",
"No FAQ entries available": "FAQエントリがありません",
"No FAQ entries yet. Click \"Add FAQ\" to create one.": "FAQエントリはまだありません。「FAQを追加」をクリックして作成してください。",
......@@ -3570,6 +3595,7 @@
"Notification Method": "通知方法",
"Notifications": "通知",
"Now a user whose user group is vip creates tokens with different groups and makes one call with each:": "ここで、ユーザーグループが vip のユーザーが異なるグループのトークンを作成し、それぞれ1回ずつ呼び出します:",
"Nucleus sampling probability": "Nucleus サンプリング確率",
"Nucleus sampling probability mass": "核サンプリングの累積確率",
"Number": "数値",
"Number of codes to create": "作成するコードの数",
......@@ -3718,6 +3744,7 @@
"Optional note describing this version": "このバージョンを説明する任意のメモ",
"Optional notes about this channel": "このチャネルに関するオプションのノート",
"Optional notes about when to use this group": "このグループを使用する時期に関するオプションのメモ",
"Optional ranking instruction": "任意のランキング指示",
"Optional ratio used when upstream cache hits occur.": "アップストリームキャッシュヒットが発生したときに使用されるオプションの比率。",
"Optional request-rule multiplier expression. Leave empty when no request rule applies.": "任意のリクエストルール乗数式です。ルールがない場合は空欄にしてください。",
"Optional rule description": "任意のルール説明",
......@@ -3746,8 +3773,11 @@
"Outage": "ダウンタイム",
"Output": "出力",
"Output aspect ratio": "出力アスペクト比",
"Output aspect ratio. H3 frame inputs force adaptive; text-only input requires an explicit ratio.": "出力アスペクト比。H3 のフレーム入力は adaptive 固定です。テキストのみの場合は比率の指定が必要です。",
"Output image format": "出力画像形式",
"Output image size": "出力画像サイズ",
"Output price": "出力価格",
"Output resolution": "出力解像度",
"Output token price for generated tokens.": "生成された出力トークンの価格。",
"Output tokens": "出力トークン",
"Output Tokens": "出力トークン",
......@@ -4126,6 +4156,7 @@
"Pricing group example": "料金グループの例",
"Pricing groups": "料金グループ",
"Pricing import requirements": "料金インポートの要件",
"Pricing import: {{updated}} updated, {{failed}} failed.": "料金のインポート:{{updated}} 件更新、{{failed}} 件失敗。",
"Pricing mode": "価格モード",
"Pricing must be a JSON object": "料金設定は JSON オブジェクトで指定してください",
"Pricing Ratios": "価格比率",
......@@ -4176,6 +4207,7 @@
"Provider created successfully": "プロバイダーの作成に成功しました",
"Provider deleted successfully": "プロバイダーの削除に成功しました",
"Provider Name": "プロバイダー名",
"Provider options are model-specific. Top-level seconds and resolution take precedence.": "上流のオプションはモデルごとに異なります。最上位の seconds と resolution が優先されます。",
"Provider type (OpenAI, Anthropic, etc.)": "プロバイダタイプ (OpenAI, Anthropic など)",
"Provider updated successfully": "プロバイダーが正常に更新されました",
"Provider-specific endpoint, account, and compatibility settings.": "プロバイダー固有のエンドポイント、アカウント、互換性設定です。",
......@@ -4237,6 +4269,7 @@
"Quota update failed": "クォータの更新に失敗しました",
"Quota Warning Threshold": "クォータ警告しきい値",
"Quota:": "クォータ:",
"Qwen/Wan images and Seedream 5.0 pro require stream=false in this adapter.": "このアダプターでは Qwen/Wan 画像と Seedream 5.0 pro に stream=false が必要です。",
"Radius": "角丸",
"Random": "ランダム",
"Randomly select a key from the pool for each request": "各リクエストごとにプールからランダムにキーを選択",
......@@ -4408,6 +4441,7 @@
"Replace": "置換",
"Replace all existing keys": "既存のすべてのキーを置き換える",
"Replace channel models": "チャネルモデルを置き換える",
"Replace example media URLs with accessible media supported by the model.": "例のメディア URL を、アクセス可能でモデルが対応するメディアの URL に置き換えてください。",
"Replace forwarding routes?": "転送ルートを置き換えますか?",
"Replace mode: Will completely replace all existing keys": "置換モード: 既存のすべてのキーを完全に置き換えます",
"Replace With": "置換後",
......@@ -4625,6 +4659,7 @@
"s": "s",
"Safety Settings": "安全設定",
"Same as Local": "ローカルと同じ",
"Sampling temperature": "サンプリング温度",
"Sampling temperature; lower is more deterministic": "サンプリング温度。低いほど決定論的になります",
"Sandbox": "サンドボックス",
"Sandbox mode": "サンドボックスモード",
......@@ -4709,6 +4744,7 @@
"Search payment type keys...": "支払いタイプキーを検索...",
"Search payment types...": "支払いタイプを検索...",
"Search products...": "商品を検索...",
"Search query used to rank documents": "文書のランキングに使う検索クエリ",
"Search rules...": "ルールを検索…",
"Search tags...": "タグを検索...",
"Search the public web at inference time": "推論時に公開ウェブを検索",
......@@ -5291,6 +5327,7 @@
"The exact model identifier as used in API requests.": "APIリクエストで使用される正確なモデル識別子。",
"The Excel workbook must contain a worksheet.": "Excel ブックにはワークシートが必要です。",
"The Excel worksheet must contain the headers “模型名称” and “映射模型”.": "Excel シートには「模型名称」と「映射模型」のヘッダーが必要です。",
"The first row must contain headers, and model_name is required. For expression billing, provide billing_expr and leave all price columns blank. Use len for input-length tiers. Otherwise provide fixed_price or input_price; optional columns are completion_price, cache_price, create_cache_price, image_price, audio_input_price, and audio_output_price.": "先頭行はヘッダーとし、model_name は必須です。式による課金では billing_expr を入力し、価格列をすべて空欄にしてください。入力長による段階分けには len を使います。それ以外は fixed_price または input_price を入力します。任意の列:completion_price、cache_price、create_cache_price、image_price、audio_input_price、audio_output_price。",
"The first row must contain headers, and model_name is required. For per-request billing, provide fixed_price. Otherwise input_price is required; optional columns are completion_price, cache_price, create_cache_price, image_price, audio_input_price, and audio_output_price.": "1 行目はヘッダーで、model_name は必須です。リクエスト単位課金では fixed_price を入力してください。それ以外では input_price が必須です。任意列:completion_price、cache_price、create_cache_price、image_price、audio_input_price、audio_output_price。",
"The first row must contain headers, and model_name is required. Optional columns: description, icon, tags, vendor_name, endpoints, status, sync_official, name_rule.": "1 行目はヘッダーで、model_name は必須です。任意列:description、icon、tags、vendor_name、endpoints、status、sync_official、name_rule。",
"The first row must contain the headers “模型名称” and “映射模型”. Both columns are required.": "最初の行には「模型名称」と「映射模型」のヘッダーが必要です。両方の列が必須です。",
......@@ -5337,6 +5374,7 @@
"Theme Settings": "テーマ設定",
"There are both add and remove models pending, but you only selected one type. Confirm submitting only the selected items?": "追加と削除の両方のモデルが保留中ですが、一方のタイプのみ選択されています。選択した項目のみ送信してよろしいですか?",
"There is a rule for vip billed as premium → use its ratio 0.3": "「vip が premium として課金」のルールあり → ルールの 0.3 を使用",
"These examples call new-api with a new-api token. CTyun AppKeys and regional upstream URLs belong in channel settings. Select a token group routed to CTyun; switching examples does not change routing.": "例では new-api トークンで new-api を呼び出します。CTyun AppKey と地域別 URL はチャネルに設定してください。CTyun にルーティングされるトークングループを選択します。例の切り替えはルーティングを変更しません。",
"These models are still in your selection but were not returned by the upstream listing. Entries that are only model_mapping source aliases are omitted. Toggle to adjust before saving.": "これらはまだ選択中ですが上流のリストにありません。model_mapping にのみソース別名として載る名前は除外されています。保存前に選択を調整してください。",
"These toggles affect whether certain request fields are passed through to the upstream provider.": "これらの切り替えは、特定の要求フィールドがアップストリームプロバイダーに渡されるかどうかに影響します。",
"These values come from the source index and are shown for review only. The gateway admits the plugin based on the metadata compiled from its source.": "これらの値はソースのインデックス由来で、確認のために表示しているだけです。ゲートウェイはソースからコンパイルされた実際のメタデータに基づいてプラグインを受け入れます。",
......@@ -5819,6 +5857,7 @@
"Use 8–128 characters.": "8~128文字で設定してください。",
"Use a compatible browser or device with biometric authentication or a security key to register a Passkey.": "生体認証またはセキュリティキーを備えた互換性のあるブラウザまたはデバイスを使用して、パスキーを登録してください。",
"Use a different stable value for each instance, then restart the service.": "インスタンスごとに異なる安定した値を使用し、その後サービスを再起動してください。",
"Use a model-supported image size, such as 2K for Seedream.": "モデルが対応する画像サイズを指定します(Seedream の 2K など)。",
"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 authenticator code": "認証コードを使用",
"Use backup code": "バックアップコードを使用",
......@@ -5829,6 +5868,7 @@
"Use exact model names such as gpt-4o, or regex rules prefixed with re: such as re:^gemini-.": "gpt-4o のような完全一致のモデル名、または re:^gemini- のように re: で始まる正規表現ルールを使えます。",
"Use expression pricing when a dependent price is non-zero and its base price is zero.": "基準料金がゼロで関連料金がゼロでない場合は、式による料金設定を使用してください。",
"Use external tools to extend capabilities": "外部ツールを利用して機能を拡張",
"Use input.contents; each item contains exactly one nonempty text, image or video string.": "input.contents を使用します。各要素には空でない text、image、video のいずれか一つの文字列を指定します。",
"Use one available reset credit for this channel. The reset request is sent only after confirmation.": "このチャンネルで利用可能なリセット回数を1回使用します。確認後にのみリセット要求を送信します。",
"Use one available reset credit to refresh the current Codex usage windows.": "利用可能なリセット回数を1回使用して、現在の Codex 使用量ウィンドウを更新します。",
"Use our unified OpenAI-compatible endpoint in your applications": "アプリケーションでOpenAI互換の統一エンドポイントを使用",
......@@ -5839,9 +5879,11 @@
"Use sidebar shortcut": "サイドバーのショートカットを使用",
"Use the full-width table to scan prices, then select a row to edit it here.": "表で価格を確認し、行を選択してここで編集します。",
"Use the group set on the token. If the token has no group, use the user group. The auto group tries the auto assignment order from top to bottom.": "トークンに設定されたグループを使います。トークンにグループがなければユーザーグループを使います。auto グループは自動割り当て順を上から順に試します。",
"Use the model name shown here; new-api applies the channel model mapping.": "表示されたモデル名を使用します。new-api がチャネルのモデルマッピングを適用します。",
"Use the pricing group table to manage the ratio and whether the group appears in the token creation dropdown.": "料金グループ表で倍率と、トークン作成ドロップダウンに表示するかどうかを管理します。",
"Use this callback URL pattern when registering a custom OAuth provider.": "カスタム OAuth プロバイダーを登録するときは、このコールバック URL 形式を使用します。",
"Use this token for API authentication": "API認証にはこのトークンを使用してください",
"Use WIDTHxHEIGHT (512–2048 per side); Wan also accepts 1K, 2K and 4K. Native parameters.size uses WIDTH*HEIGHT.": "幅x高さ(各辺 512–2048)を指定します。Wan は 1K、2K、4K にも対応します。ネイティブの parameters.size は 幅*高さ 形式です。",
"Use your Passkey": "パスキーを使用",
"used": "使用済み",
"Used": "使用済み",
......@@ -5977,7 +6019,9 @@
"Vertex AI Key Format": "Vertex AIキー形式",
"Vertex AI service account key must be valid JSON": "Vertex AI サービスアカウントキーは有効な JSON である必要があります",
"Video": "動画",
"Video duration; new-api converts seconds to the upstream duration field.": "動画の長さ。new-api が seconds を上流の duration に変換します。",
"Video length in seconds": "動画の長さ(秒)",
"Video reference": "動画参照",
"Video Remix": "動画 Remix",
"Vidu": "Vidu",
"View": "表示",
......
......@@ -209,6 +209,7 @@
"Add a new user by providing necessary info.": "Добавьте нового пользователя, предоставив необходимую информацию.",
"Add a new vendor to the system": "Добавить нового поставщика в систему",
"Add a vendor or adjust your search.": "Добавьте поставщика или измените поиск.",
"Add a watermark to the generated image": "Добавить водяной знак на изображение",
"Add an extra layer of security to your account": "Добавьте дополнительный уровень безопасности к вашей учетной записи",
"Add an index URL to browse installable plugins.": "Добавьте URL индекса, чтобы просматривать доступные для установки плагины.",
"Add and submit": "Добавить и отправить",
......@@ -317,6 +318,8 @@
"Advanced platform configuration.": "Расширенная настройка платформы.",
"Advanced Settings": "Расширенные настройки",
"Advanced text editing": "Расширенное редактирование текста",
"Advanced video inputs: content contains text, image_url, video_url or audio_url items with optional roles.": "Расширенный ввод видео: content содержит элементы text, image_url, video_url или audio_url с необязательными ролями.",
"Advanced video inputs: input.media contains typed media URLs; do not mix frame and reference inputs.": "Расширенный ввод видео: input.media содержит URL с типами медиа; не смешивайте кадры и референсы.",
"Aesthetic style": "Стиль",
"Affected windows:": "Затронутые окна:",
"After clicking the button, you'll be asked to authorize the bot": "После нажатия кнопки вам будет предложено авторизовать бота",
......@@ -707,6 +710,7 @@
"Base URL of your Uptime Kuma instance": "Базовый URL вашего экземпляра Uptime Kuma",
"Basic Authentication": "Базовая аутентификация",
"Basic Configuration": "Базовая конфигурация",
"Basic example": "Базовый пример",
"Basic Info": "Основная информация",
"Basic Information": "Основная информация",
"Basic Templates": "Базовые шаблоны",
......@@ -1242,6 +1246,7 @@
"Controls visibility in the model square. Channel status and existing API access are unchanged.": "Управляет видимостью в каталоге моделей, не затрагивая состояние каналов и существующий доступ к API.",
"Controls whether user verification (biometrics/PIN) is required during Passkey flows.": "Определяет, требуется ли проверка пользователя (биометрия/PIN) во время процессов Passkey.",
"Conversation cleared": "Диалог очищен",
"Conversation messages": "Сообщения диалога",
"Conversion rate from USD to your custom currency": "Курс конвертации из USD в вашу пользовательскую валюту",
"Convert reasoning_content to <think> tag in content": "Преобразовать reasoning_content в тег <think> в content",
"Convert string to lowercase": "Преобразовать строку в нижний регистр",
......@@ -1384,6 +1389,7 @@
"Creem products must be a JSON array": "Продукты Creem должны быть JSON-массивом",
"Cross-group": "Межгрупповой",
"Cross-group retry": "Повтор между группами",
"CTyun": "CTyun",
"Currency": "Валюта",
"Currency & Display": "Валюта и отображение",
"Current": "Текущий",
......@@ -1688,6 +1694,7 @@
"Docs": "Документы",
"Documentation Link": "Ссылка на документацию",
"Documentation or external knowledge base.": "Документация или внешняя база знаний.",
"Documents to rank: strings or objects containing text": "Документы для ранжирования: строки или объекты с полем text",
"Does not check healthy channels. It only rechecks auto-disabled channels and restores them after they recover.": "Рабочие каналы не проверяются. Повторно проверяются только автоматически отключённые каналы, которые включаются после восстановления.",
"does not exist or might have been removed.": "не существует или, возможно, был удален.",
"Domain": "Домен",
......@@ -2024,7 +2031,10 @@
"Example": "Пример",
"Example (all channels):": "Пример (все каналы):",
"Example (specific channels):": "Пример (указанные каналы):",
"Example channel": "Канал примера",
"Example price": "Пример цены",
"Example scenario": "Сценарий примера",
"Example selection does not change request routing. Use a token group matching the example; channels in the same group may still use different formats.": "Выбор примера не меняет маршрутизацию. Используйте соответствующую группу токена; каналы одной группы могут использовать разные форматы.",
"Example spec": "Пример спецификации",
"Example:": "Пример:",
"example.com&#10;blocked-site.com": "example.com&#10;blocked-site.com",
......@@ -2343,6 +2353,7 @@
"Finish Time": "Время завершения",
"First API request": "Первый API-запрос",
"First token": "Первый токен",
"First-frame image URL. Use images for first and last frames; uploaded files are not supported by this video adapter.": "URL первого кадра. Для первого и последнего кадров используйте images; загрузка файлов этим адаптером не поддерживается.",
"First/Last Frame to Video": "Первый/последний кадр в видео",
"Fix Abilities": "Восстановить согласованность каналов",
"Fix order": "Исправить порядок",
......@@ -2639,6 +2650,7 @@
"Ignore": "Игнорировать",
"Ignored upstream models": "Игнорируемые upstream-модели",
"Image": "Изображение",
"Image count is bounded by new-api; the upstream model may impose a lower limit. Seedream groups are converted automatically.": "new-api ограничивает число изображений; у модели лимит может быть ниже. Группы Seedream преобразуются автоматически.",
"Image Generation": "Генерация изображений",
"Image In": "Вход изображения",
"Image input": "Ввод изображения",
......@@ -2648,6 +2660,7 @@
"Image output price": "Цена выходного изображения",
"Image Preview": "Предпросмотр изображения",
"Image ratio": "Коэффициент изображения",
"Image reference": "Референсное изображение",
"Image to Video": "Изображение в видео",
"Image Tokens": "Токены изображений",
"Imagine the pricing table has three groups: default (ratio 1.0), premium (ratio 0.5), and vip (ratio 0.8). Users whose account is in the vip group get user-level perks, and premium is a cheaper channel pool that users can pick for their tokens.": "Представьте, что в таблице тарифов три группы: default (коэффициент 1,0), premium (коэффициент 0,5) и vip (коэффициент 0,8). Пользователи из группы vip получают привилегии на уровне аккаунта, а premium — более дешёвый пул каналов, который пользователи могут выбирать для своих токенов.",
......@@ -2656,6 +2669,7 @@
"Import from URL": "Импорт по URL",
"Import mappings from an Excel workbook.": "Импорт сопоставлений из книги Excel.",
"Import model mappings": "Импорт сопоставлений моделей",
"Import model metadata and optional pricing from an Excel workbook.": "Импорт метаданных моделей и необязательных цен из Excel.",
"Import model metadata from an Excel workbook.": "Импорт метаданных моделей из книги Excel.",
"Import model prices from an Excel workbook.": "Импорт тарифов моделей из книги Excel.",
"Import model pricing": "Импорт тарифов моделей",
......@@ -2664,6 +2678,7 @@
"Important": "Важно",
"Imported mappings replace existing mappings with the same model name.": "Импортированные сопоставления заменяют существующие сопоставления с тем же именем модели.",
"Importing metadata does not add channels, enable model access, or configure prices.": "Импорт метаданных не добавляет каналы, не открывает доступ к моделям и не задаёт цены.",
"Importing replaces existing pricing for matching models. Expression billing clears fixed prices and ratios; other billing modes clear expressions. Prices and expression coefficients are imported as-is without currency conversion.": "Импорт заменяет цены совпадающих моделей. Расчёт по выражению удаляет фиксированные цены и коэффициенты; другие режимы удаляют выражения. Цены и коэффициенты выражений импортируются без конвертации валют.",
"Importing replaces the billing mode for matching models: per-token billing clears fixed prices, while per-request billing clears ratio settings.": "При импорте режим тарификации совпадающих моделей заменяется: тарификация по токенам удаляет фиксированные цены, а тарификация за запрос — настройки коэффициентов.",
"Importing...": "Импорт...",
"In BotFather, open Login Widget, register this callback URL, and copy the Client ID and Client Secret. Existing Telegram bindings will continue to work after configuration.": "Откройте Login Widget в BotFather, зарегистрируйте этот URL обратного вызова и скопируйте Client ID и Client Secret. После настройки существующие привязки Telegram продолжат работать.",
......@@ -2674,6 +2689,7 @@
"incident": "инцидент",
"incidents": "инцидентов",
"Incidents": "Инциденты",
"Include document text in the ranked results": "Включить текст документов в результаты",
"Include Group": "Включить группу",
"Include Model": "Включить модель",
"Include name": "Включить название",
......@@ -2707,6 +2723,7 @@
"Initializing…": "Инициализация…",
"Inpaint": "Инпейнтинг",
"Input": "Ввод",
"Input image URL or array of image URLs for image editing.": "URL изображения или массив URL для редактирования.",
"Input mode": "Режим ввода",
"Input price": "Цена входа",
"Input price is required before saving dependent prices.": "Перед сохранением зависимых цен укажите входную цену.",
......@@ -2866,6 +2883,7 @@
"Learn more": "Узнать больше",
"Learn more:": "Узнать больше:",
"Leave": "Выйти",
"Leave all price cells blank to keep pricing unchanged. Pricing requires a super administrator and exact model names. Existing models and their prices are skipped unless overwrite is enabled. Prices use USD per million tokens, or USD per request for fixed_price; no currency conversion is applied.": "Оставьте все цены пустыми, чтобы сохранить тарифы. Для импорта цен нужны права суперадминистратора и точные имена моделей. Без перезаписи существующие модели и цены пропускаются. Цены задаются в USD за миллион токенов, fixed_price — в USD за запрос, без конвертации валют.",
"Leave blank to keep the existing credential": "Оставьте пустым, чтобы сохранить существующие учетные данные",
"Leave blank to keep the existing key": "Оставьте пустым, чтобы сохранить существующий ключ",
"Leave blank unless rotating the secret": "Оставьте пустым, если не меняете секрет",
......@@ -3046,8 +3064,10 @@
"Maximum custom groups per token": "Максимум пользовательских групп на токен",
"Maximum input window": "Максимальное окно ввода",
"Maximum number of channels tested at the same time (1-32)": "Максимальное число одновременно проверяемых каналов (1–32)",
"Maximum number of ranked results to return": "Максимальное число результатов",
"Maximum number of tokens each user can create. Default 1000. Setting too large may affect performance.": "Максимальное количество токенов, которое может создать каждый пользователь. По умолчанию 1000. Слишком большое значение может повлиять на производительность.",
"Maximum number of tokens in the response": "Максимальное число токенов в ответе",
"Maximum output tokens; the model context and gateway limits also apply.": "Максимум выходных токенов; также действуют ограничения контекста модели и шлюза.",
"Maximum quota amount awarded for check-in": "Максимальная сумма квоты, присуждаемая за регистрацию",
"Maximum tokens including hidden reasoning tokens": "Максимум токенов с учётом скрытых reasoning-токенов",
"Maximum tokens per response": "Максимум токенов на ответ",
......@@ -3162,6 +3182,7 @@
"Model prices reset successfully": "Цены моделей успешно сброшены",
"Model Pricing": "Тарификация моделей",
"Model pricing import completed: {{updated}} updated, {{perToken}} per-token, {{perRequest}} per-request, {{failed}} failed.": "Импорт тарифов моделей завершён: обновлено {{updated}}, по токенам {{perToken}}, за запрос {{perRequest}}, ошибок {{failed}}.",
"Model pricing import completed: {{updated}} updated, {{perToken}} per-token, {{perRequest}} per-request, {{tieredExpr}} expression, {{failed}} failed.": "Импорт завершён: обновлено {{updated}}, за токен — {{perToken}}, за запрос — {{perRequest}}, по выражению — {{tieredExpr}}, ошибок — {{failed}}.",
"Model pricing import failed": "Не удалось импортировать тарифы моделей",
"Model pricing is managed by a super administrator.": "Цены моделей задаёт суперадминистратор.",
"Model pricing saved": "Цены модели сохранены",
......@@ -3256,6 +3277,7 @@
"Multi-user management with flexible permission allocation": "Многопользовательское управление с гибким распределением разрешений",
"Multilingual translation and localisation": "Многоязычный перевод и локализация",
"Multimodal": "Мультимодальное",
"Multimodal options: dimension, enable_fusion, output_type (dense), instruct and fps (0–1). parameters.dimension overrides dimensions.": "Мультимодальные параметры: dimension, enable_fusion, output_type (dense), instruct и fps (0–1). parameters.dimension имеет приоритет над dimensions.",
"Multiplier": "Множитель",
"Multiplier applied when": "Множитель применяется, когда",
"Multiplier applied when {{userGroup}} uses {{targetGroup}}": "Множитель при использовании {{targetGroup}} группой {{userGroup}}",
......@@ -3290,6 +3312,8 @@
"Native format": "Собственный формат",
"Native forwarding": "Нативная пересылка",
"Native Gemini routes plus OpenAI Chat and Responses compatibility forwarding.": "Нативные маршруты Gemini и совместимая пересылка OpenAI Chat и Responses.",
"Native image parameters override top-level size and watermark; include size, n and seed here when needed.": "Поле parameters заменяет size и watermark верхнего уровня; при необходимости задайте здесь size, n и seed.",
"Native input.messages overrides the prompt/image conversion.": "Поле input.messages заменяет автоматическое преобразование prompt/image.",
"Native OpenAI routes plus optional Claude and Gemini compatibility routes.": "Нативные маршруты OpenAI и дополнительные маршруты совместимости Claude и Gemini.",
"Native routes": "Нативные маршруты",
"Need a redemption code?": "Нужен код активации?",
......@@ -3389,6 +3413,7 @@
"No encryption": "Без шифрования",
"No endpoints configured. Switch to JSON mode or add rows to define endpoints.": "Конечные точки не настроены. Переключитесь в режим JSON или добавьте строки для определения конечных точек.",
"No endpoints inferred from channels": "Конечные точки не определены по каналам",
"No example is available for this endpoint.": "Для этой конечной точки нет примера.",
"No extra domains declared": "Дополнительные домены не объявлены",
"No FAQ entries available": "Нет доступных записей FAQ",
"No FAQ entries yet. Click \"Add FAQ\" to create one.": "Пока нет записей FAQ. Нажмите \"Добавить FAQ\", чтобы создать одну.",
......@@ -3570,6 +3595,7 @@
"Notification Method": "Метод уведомления",
"Notifications": "Уведомления",
"Now a user whose user group is vip creates tokens with different groups and makes one call with each:": "Теперь пользователь с группой vip создаёт токены с разными группами и делает по одному вызову с каждым:",
"Nucleus sampling probability": "Вероятность выборки nucleus",
"Nucleus sampling probability mass": "Накопленная вероятность для nucleus-сэмплинга",
"Number": "Число",
"Number of codes to create": "Количество кодов для создания",
......@@ -3718,6 +3744,7 @@
"Optional note describing this version": "Необязательное примечание к этой версии",
"Optional notes about this channel": "Необязательные заметки об этом канале",
"Optional notes about when to use this group": "Необязательные примечания о том, когда использовать эту группу",
"Optional ranking instruction": "Необязательная инструкция ранжирования",
"Optional ratio used when upstream cache hits occur.": "Необязательное соотношение, используемое при попаданиях в вышестоящий кэш.",
"Optional request-rule multiplier expression. Leave empty when no request rule applies.": "Необязательное выражение множителя для правил запроса. Оставьте пустым, если правила не применяются.",
"Optional rule description": "Необязательное описание правила",
......@@ -3746,8 +3773,11 @@
"Outage": "Простой",
"Output": "Вывод",
"Output aspect ratio": "Соотношение сторон",
"Output aspect ratio. H3 frame inputs force adaptive; text-only input requires an explicit ratio.": "Соотношение сторон. Для кадров H3 используется adaptive; для текстового ввода нужно явное соотношение.",
"Output image format": "Формат выходного изображения",
"Output image size": "Размер выходного изображения",
"Output price": "Цена выхода",
"Output resolution": "Выходное разрешение",
"Output token price for generated tokens.": "Цена выходных токенов для сгенерированного текста.",
"Output tokens": "Выходные токены",
"Output Tokens": "Выходные токены",
......@@ -4126,6 +4156,7 @@
"Pricing group example": "Пример группы тарификации",
"Pricing groups": "Группы тарификации",
"Pricing import requirements": "Требования к импорту тарифов",
"Pricing import: {{updated}} updated, {{failed}} failed.": "Импорт цен: обновлено {{updated}}, ошибок {{failed}}.",
"Pricing mode": "Режим ценообразования",
"Pricing must be a JSON object": "Настройки цен должны быть объектом JSON",
"Pricing Ratios": "Коэффициенты ценообразования",
......@@ -4176,6 +4207,7 @@
"Provider created successfully": "Поставщик успешно создан",
"Provider deleted successfully": "Поставщик успешно удален",
"Provider Name": "Имя поставщика",
"Provider options are model-specific. Top-level seconds and resolution take precedence.": "Параметры провайдера зависят от модели. Поля seconds и resolution верхнего уровня имеют приоритет.",
"Provider type (OpenAI, Anthropic, etc.)": "Тип провайдера (OpenAI, Anthropic и т.д.)",
"Provider updated successfully": "Поставщик успешно обновлен",
"Provider-specific endpoint, account, and compatibility settings.": "Настройки endpoint, аккаунта и совместимости для конкретного провайдера.",
......@@ -4237,6 +4269,7 @@
"Quota update failed": "Не удалось обновить квоту",
"Quota Warning Threshold": "Порог предупреждения о квоте",
"Quota:": "Квота:",
"Qwen/Wan images and Seedream 5.0 pro require stream=false in this adapter.": "Этот адаптер требует stream=false для изображений Qwen/Wan и Seedream 5.0 pro.",
"Radius": "Радиус",
"Random": "Случайный",
"Randomly select a key from the pool for each request": "Случайно выбирать ключ из пула для каждого запроса",
......@@ -4408,6 +4441,7 @@
"Replace": "Заменить",
"Replace all existing keys": "Заменить все существующие ключи",
"Replace channel models": "Замена моделей каналов",
"Replace example media URLs with accessible media supported by the model.": "Замените URL примеров доступными медиафайлами, поддерживаемыми моделью.",
"Replace forwarding routes?": "Заменить маршруты пересылки?",
"Replace mode: Will completely replace all existing keys": "Режим замены: полностью заменит все существующие ключи",
"Replace With": "Заменить на",
......@@ -4625,6 +4659,7 @@
"s": "s",
"Safety Settings": "Настройки безопасности",
"Same as Local": "То же, что и локальный",
"Sampling temperature": "Температура выборки",
"Sampling temperature; lower is more deterministic": "Температура сэмплирования; чем ниже, тем детерминированнее",
"Sandbox": "Песочница",
"Sandbox mode": "Режим песочницы",
......@@ -4709,6 +4744,7 @@
"Search payment type keys...": "Поиск ключей типа оплаты...",
"Search payment types...": "Поиск типов оплаты...",
"Search products...": "Поиск продуктов...",
"Search query used to rank documents": "Поисковый запрос для ранжирования документов",
"Search rules...": "Поиск правил…",
"Search tags...": "Поиск тегов...",
"Search the public web at inference time": "Искать в общедоступной сети во время инференса",
......@@ -5291,6 +5327,7 @@
"The exact model identifier as used in API requests.": "Точный идентификатор модели, используемый в запросах API.",
"The Excel workbook must contain a worksheet.": "Книга Excel должна содержать лист.",
"The Excel worksheet must contain the headers “模型名称” and “映射模型”.": "Лист Excel должен содержать заголовки «模型名称» и «映射模型».",
"The first row must contain headers, and model_name is required. For expression billing, provide billing_expr and leave all price columns blank. Use len for input-length tiers. Otherwise provide fixed_price or input_price; optional columns are completion_price, cache_price, create_cache_price, image_price, audio_input_price, and audio_output_price.": "Первая строка должна содержать заголовки; model_name обязателен. Для расчёта по выражению заполните billing_expr, оставив все столбцы цен пустыми. Используйте len для порогов длины ввода. Иначе укажите fixed_price или input_price. Необязательные столбцы: completion_price, cache_price, create_cache_price, image_price, audio_input_price и audio_output_price.",
"The first row must contain headers, and model_name is required. For per-request billing, provide fixed_price. Otherwise input_price is required; optional columns are completion_price, cache_price, create_cache_price, image_price, audio_input_price, and audio_output_price.": "Первая строка должна содержать заголовки, поле model_name обязательно. Для тарификации за запрос укажите fixed_price. В остальных случаях требуется input_price; необязательные столбцы: completion_price, cache_price, create_cache_price, image_price, audio_input_price и audio_output_price.",
"The first row must contain headers, and model_name is required. Optional columns: description, icon, tags, vendor_name, endpoints, status, sync_official, name_rule.": "Первая строка должна содержать заголовки, поле model_name обязательно. Необязательные столбцы: description, icon, tags, vendor_name, endpoints, status, sync_official, name_rule.",
"The first row must contain the headers “模型名称” and “映射模型”. Both columns are required.": "Первая строка должна содержать заголовки «模型名称» и «映射模型». Оба столбца обязательны.",
......@@ -5337,6 +5374,7 @@
"Theme Settings": "Настройки темы",
"There are both add and remove models pending, but you only selected one type. Confirm submitting only the selected items?": "Есть модели для добавления и удаления, но вы выбрали только один тип. Подтвердить отправку только выбранных элементов?",
"There is a rule for vip billed as premium → use its ratio 0.3": "Есть правило «vip по premium» → используется его коэффициент 0,3",
"These examples call new-api with a new-api token. CTyun AppKeys and regional upstream URLs belong in channel settings. Select a token group routed to CTyun; switching examples does not change routing.": "Примеры вызывают new-api с его токеном. AppKey CTyun и региональный URL задаются в канале. Выберите группу токена с маршрутизацией в CTyun; переключение примеров не меняет маршрутизацию.",
"These models are still in your selection but were not returned by the upstream listing. Entries that are only model_mapping source aliases are omitted. Toggle to adjust before saving.": "Эти имена всё ещё отмечены в выборе, но не возвращены в списке upstream; ключи только как источники model_mapping исключены. Скорректируйте выбор перед сохранением.",
"These toggles affect whether certain request fields are passed through to the upstream provider.": "Эти переключатели влияют на то, передаются ли определенные поля запроса вышестоящему поставщику.",
"These values come from the source index and are shown for review only. The gateway admits the plugin based on the metadata compiled from its source.": "Эти значения взяты из индекса источника и показаны только для проверки. Шлюз допускает плагин по метаданным, скомпилированным из его кода.",
......@@ -5819,6 +5857,7 @@
"Use 8–128 characters.": "Используйте от 8 до 128 символов.",
"Use a compatible browser or device with biometric authentication or a security key to register a Passkey.": "Используйте совместимый браузер или устройство с биометрической аутентификацией или ключ безопасности для регистрации ключа доступа.",
"Use a different stable value for each instance, then restart the service.": "Используйте разные стабильные значения для каждого экземпляра, затем перезапустите сервис.",
"Use a model-supported image size, such as 2K for Seedream.": "Используйте размер, поддерживаемый моделью, например 2K для Seedream.",
"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 authenticator code": "Использовать код аутентификатора",
"Use backup code": "Использовать резервный код",
......@@ -5829,6 +5868,7 @@
"Use exact model names such as gpt-4o, or regex rules prefixed with re: such as re:^gemini-.": "Используйте точные имена моделей, например gpt-4o, или regex-правила с префиксом re:, например re:^gemini-.",
"Use expression pricing when a dependent price is non-zero and its base price is zero.": "Используйте выражение, если зависимая цена ненулевая, а базовая цена равна нулю.",
"Use external tools to extend capabilities": "Использовать внешние инструменты для расширения возможностей",
"Use input.contents; each item contains exactly one nonempty text, image or video string.": "Используйте input.contents; каждый элемент содержит ровно одну непустую строку text, image или video.",
"Use one available reset credit for this channel. The reset request is sent only after confirmation.": "Для этого канала будет использован один доступный сброс. Запрос отправляется только после подтверждения.",
"Use one available reset credit to refresh the current Codex usage windows.": "Использует один доступный сброс, чтобы обновить текущие окна использования Codex.",
"Use our unified OpenAI-compatible endpoint in your applications": "Используйте наш единый OpenAI-совместимый эндпоинт в ваших приложениях",
......@@ -5839,9 +5879,11 @@
"Use sidebar shortcut": "Использовать ярлык боковой панели",
"Use the full-width table to scan prices, then select a row to edit it here.": "Просмотрите цены в таблице, затем выберите строку для редактирования здесь.",
"Use the group set on the token. If the token has no group, use the user group. The auto group tries the auto assignment order from top to bottom.": "Используется группа токена. Если у токена нет группы — группа пользователя. Группа auto перебирает порядок автоназначения сверху вниз.",
"Use the model name shown here; new-api applies the channel model mapping.": "Используйте указанное имя модели; new-api применит сопоставление моделей канала.",
"Use the pricing group table to manage the ratio and whether the group appears in the token creation dropdown.": "Используйте таблицу групп тарификации, чтобы управлять коэффициентом и отображением группы в списке создания токена.",
"Use this callback URL pattern when registering a custom OAuth provider.": "Используйте этот формат URL обратного вызова при регистрации пользовательского провайдера OAuth.",
"Use this token for API authentication": "Используйте этот токен для аутентификации API",
"Use WIDTHxHEIGHT (512–2048 per side); Wan also accepts 1K, 2K and 4K. Native parameters.size uses WIDTH*HEIGHT.": "Используйте ШИРИНАxВЫСОТА (512–2048 на сторону); Wan также принимает 1K, 2K и 4K. Поле parameters.size использует ШИРИНА*ВЫСОТА.",
"Use your Passkey": "Используйте свой ключ доступа",
"used": "использовано",
"Used": "Использовано",
......@@ -5977,7 +6019,9 @@
"Vertex AI Key Format": "Формат ключа Vertex AI",
"Vertex AI service account key must be valid JSON": "Ключ сервисного аккаунта Vertex AI должен быть допустимым JSON",
"Video": "Видео",
"Video duration; new-api converts seconds to the upstream duration field.": "Длительность видео; new-api преобразует seconds в поле duration провайдера.",
"Video length in seconds": "Длительность видео в секундах",
"Video reference": "Референсное видео",
"Video Remix": "Ремикс видео",
"Vidu": "Vidu",
"View": "Просмотр",
......
......@@ -209,6 +209,7 @@
"Add a new user by providing necessary info.": "Thêm người dùng mới bằng cách cung cấp thông tin cần thiết.",
"Add a new vendor to the system": "Thêm một nhà cung cấp mới vào hệ thống",
"Add a vendor or adjust your search.": "Thêm nhà cung cấp hoặc điều chỉnh tìm kiếm.",
"Add a watermark to the generated image": "Thêm hình mờ vào ảnh được tạo",
"Add an extra layer of security to your account": "Thêm một lớp bảo mật bổ sung cho tài khoản của bạn",
"Add an index URL to browse installable plugins.": "Thêm một URL chỉ mục để xem các plugin có thể cài đặt.",
"Add and submit": "Thêm và gửi",
......@@ -317,6 +318,8 @@
"Advanced platform configuration.": "Cấu hình nền tảng nâng cao.",
"Advanced Settings": "Cài đặt nâng cao",
"Advanced text editing": "Chỉnh sửa văn bản nâng cao",
"Advanced video inputs: content contains text, image_url, video_url or audio_url items with optional roles.": "Đầu vào video nâng cao: content chứa text, image_url, video_url hoặc audio_url với role tùy chọn.",
"Advanced video inputs: input.media contains typed media URLs; do not mix frame and reference inputs.": "Đầu vào video nâng cao: input.media chứa URL phương tiện có loại; không trộn khung hình và tư liệu tham chiếu.",
"Aesthetic style": "Phong cách",
"Affected windows:": "Cửa sổ bị ảnh hưởng:",
"After clicking the button, you'll be asked to authorize the bot": "Sau khi nhấp vào nút, bạn sẽ được yêu cầu ủy quyền cho bot",
......@@ -707,6 +710,7 @@
"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",
"Basic example": "Ví dụ cơ bản",
"Basic Info": "Thông tin cơ bản",
"Basic Information": "Thông tin cơ bản",
"Basic Templates": "Mẫu cơ bản",
......@@ -1242,6 +1246,7 @@
"Controls visibility in the model square. Channel status and existing API access are unchanged.": "Kiểm soát hiển thị trong kho mô hình, không ảnh hưởng trạng thái kênh và quyền gọi API hiện có.",
"Controls whether user verification (biometrics/PIN) is required during Passkey flows.": "Kiểm soát xem liệu có yêu cầu xác minh người dùng (sinh trắc học/mã PIN) trong các luồng Passkey hay không.",
"Conversation cleared": "Đã xóa cuộc trò chuyện",
"Conversation messages": "Tin nhắn hội thoại",
"Conversion rate from USD to your custom currency": "Tỷ giá chuyển đổi từ USD sang đơn vị tiền tệ tùy chỉnh của bạn",
"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",
......@@ -1384,6 +1389,7 @@
"Creem products must be a JSON array": "Sản phẩm Creem phải là mảng JSON",
"Cross-group": "Liên nhóm",
"Cross-group retry": "Thử lại liên nhóm",
"CTyun": "CTyun",
"Currency": "Tiền tệ",
"Currency & Display": "Tiền tệ & hiển thị",
"Current": "Hiện tại",
......@@ -1688,6 +1694,7 @@
"Docs": "Tài liệu",
"Documentation Link": "Liên kết tài liệu",
"Documentation or external knowledge base.": "Tài liệu hoặc cơ sở kiến thức bên ngoài.",
"Documents to rank: strings or objects containing text": "Tài liệu cần xếp hạng: chuỗi hoặc đối tượng chứa text",
"Does not check healthy channels. It only rechecks auto-disabled channels and restores them after they recover.": "Không kiểm tra kênh đang hoạt động bình thường. Chỉ kiểm tra lại kênh bị hệ thống tự động vô hiệu hóa và bật lại sau khi khôi phục.",
"does not exist or might have been removed.": "không tồn tại hoặc có thể đã bị xóa.",
"Domain": "Miền",
......@@ -2024,7 +2031,10 @@
"Example": "Ví dụ",
"Example (all channels):": "Ví dụ (tất cả kênh):",
"Example (specific channels):": "Ví dụ (kênh cụ thể):",
"Example channel": "Kênh áp dụng cho ví dụ",
"Example price": "Giá ví dụ",
"Example scenario": "Tình huống sử dụng",
"Example selection does not change request routing. Use a token group matching the example; channels in the same group may still use different formats.": "Chọn ví dụ không thay đổi định tuyến. Dùng nhóm token phù hợp; các kênh trong cùng nhóm vẫn có thể dùng định dạng khác nhau.",
"Example spec": "Thông số ví dụ",
"Example:": "Ví dụ:",
"example.com&#10;blocked-site.com": "example.com\nblocked-site.com",
......@@ -2343,6 +2353,7 @@
"Finish Time": "Thời gian hoàn thành",
"First API request": "Yêu cầu API đầu tiên",
"First token": "Token đầu",
"First-frame image URL. Use images for first and last frames; uploaded files are not supported by this video adapter.": "URL ảnh khung đầu. Dùng images cho khung đầu và cuối; bộ chuyển đổi video này không hỗ trợ tải tệp lên.",
"First/Last Frame to Video": "Khung đầu/cuối sang video",
"Fix Abilities": "Sửa tính nhất quán kênh",
"Fix order": "Sửa thứ tự",
......@@ -2639,6 +2650,7 @@
"Ignore": "Bỏ qua",
"Ignored upstream models": "Mô hình upstream bị bỏ qua",
"Image": "Hình ảnh",
"Image count is bounded by new-api; the upstream model may impose a lower limit. Seedream groups are converted automatically.": "Số ảnh bị giới hạn bởi new-api; mô hình có thể có giới hạn thấp hơn. Tham số nhóm ảnh Seedream được chuyển đổi tự động.",
"Image Generation": "Tạo hình ảnh",
"Image In": "Ảnh vào",
"Image input": "Đầu vào hình ảnh",
......@@ -2648,6 +2660,7 @@
"Image output price": "Giá đầu ra hình ảnh",
"Image Preview": "Xem trước ảnh",
"Image ratio": "Tỷ lệ hình ảnh",
"Image reference": "Tham chiếu ảnh",
"Image to Video": "Ảnh sang video",
"Image Tokens": "Token hình ảnh",
"Imagine the pricing table has three groups: default (ratio 1.0), premium (ratio 0.5), and vip (ratio 0.8). Users whose account is in the vip group get user-level perks, and premium is a cheaper channel pool that users can pick for their tokens.": "Giả sử bảng định giá có ba nhóm: default (hệ số 1.0), premium (hệ số 0.5) và vip (hệ số 0.8). Người dùng có tài khoản thuộc nhóm vip nhận ưu đãi cấp người dùng, còn premium là một nhóm kênh rẻ hơn mà người dùng có thể chọn cho token của mình.",
......@@ -2656,6 +2669,7 @@
"Import from URL": "Nhập từ URL",
"Import mappings from an Excel workbook.": "Nhập ánh xạ từ sổ làm việc Excel.",
"Import model mappings": "Nhập ánh xạ mô hình",
"Import model metadata and optional pricing from an Excel workbook.": "Nhập thông tin mô hình và giá tùy chọn từ Excel.",
"Import model metadata from an Excel workbook.": "Nhập metadata mô hình từ sổ làm việc Excel.",
"Import model prices from an Excel workbook.": "Nhập giá mô hình từ sổ làm việc Excel.",
"Import model pricing": "Nhập giá mô hình",
......@@ -2664,6 +2678,7 @@
"Important": "Quan trọng",
"Imported mappings replace existing mappings with the same model name.": "Ánh xạ đã nhập sẽ thay thế ánh xạ hiện có có cùng tên mô hình.",
"Importing metadata does not add channels, enable model access, or configure prices.": "Nhập siêu dữ liệu không thêm kênh, mở quyền truy cập mô hình hay cấu hình giá.",
"Importing replaces existing pricing for matching models. Expression billing clears fixed prices and ratios; other billing modes clear expressions. Prices and expression coefficients are imported as-is without currency conversion.": "Việc nhập sẽ thay thế giá của các mô hình khớp tên. Tính phí bằng biểu thức sẽ xóa giá cố định và tỷ lệ; các chế độ khác sẽ xóa biểu thức. Giá và hệ số biểu thức được nhập nguyên giá trị, không quy đổi tiền tệ.",
"Importing replaces the billing mode for matching models: per-token billing clears fixed prices, while per-request billing clears ratio settings.": "Việc nhập sẽ thay thế chế độ tính phí của các mô hình trùng khớp: tính phí theo token sẽ xóa giá cố định, còn tính phí theo lượt sẽ xóa cấu hình hệ số.",
"Importing...": "Đang nhập...",
"In BotFather, open Login Widget, register this callback URL, and copy the Client ID and Client Secret. Existing Telegram bindings will continue to work after configuration.": "Trong BotFather, mở Login Widget, đăng ký URL gọi lại này và sao chép Client ID cùng Client Secret. Các liên kết Telegram hiện có sẽ tiếp tục hoạt động sau khi cấu hình.",
......@@ -2674,6 +2689,7 @@
"incident": "sự cố",
"incidents": "sự cố",
"Incidents": "Sự cố",
"Include document text in the ranked results": "Bao gồm văn bản tài liệu trong kết quả xếp hạng",
"Include Group": "Bao gồm nhóm",
"Include Model": "Bao gồm mô hình",
"Include name": "Bao gồm tên",
......@@ -2707,6 +2723,7 @@
"Initializing…": "Đang khởi tạo…",
"Inpaint": "Inpaint",
"Input": "Đầu vào",
"Input image URL or array of image URLs for image editing.": "URL ảnh hoặc mảng URL ảnh đầu vào để chỉnh sửa ảnh.",
"Input mode": "Chế độ nhập",
"Input price": "Giá đầu vào",
"Input price is required before saving dependent prices.": "Cần có giá đầu vào trước khi lưu các giá phụ thuộc.",
......@@ -2866,6 +2883,7 @@
"Learn more": "Tìm hiểu thêm",
"Learn more:": "Tìm hiểu thêm:",
"Leave": "Rời khỏi",
"Leave all price cells blank to keep pricing unchanged. Pricing requires a super administrator and exact model names. Existing models and their prices are skipped unless overwrite is enabled. Prices use USD per million tokens, or USD per request for fixed_price; no currency conversion is applied.": "Để trống tất cả ô giá để giữ nguyên giá. Nhập giá yêu cầu quyền siêu quản trị viên và tên mô hình khớp chính xác. Mô hình hiện có và giá của chúng được bỏ qua nếu không bật ghi đè. Giá tính bằng USD trên một triệu token, hoặc USD mỗi yêu cầu với fixed_price; không quy đổi tiền tệ.",
"Leave blank to keep the existing credential": "Để trống để giữ thông tin xác thực hiện có",
"Leave blank to keep the existing key": "Để trống để giữ khóa hiện có",
"Leave blank unless rotating the secret": "Để trống trừ khi xoay vòng bí mật",
......@@ -3046,8 +3064,10 @@
"Maximum custom groups per token": "Số nhóm tùy chỉnh tối đa cho mỗi token",
"Maximum input window": "Cửa sổ nhập tối đa",
"Maximum number of channels tested at the same time (1-32)": "Số kênh tối đa được kiểm tra cùng lúc (1–32)",
"Maximum number of ranked results to return": "Số kết quả xếp hạng tối đa trả về",
"Maximum number of tokens each user can create. Default 1000. Setting too large may affect performance.": "Số lượng token tối đa mỗi người dùng có thể tạo. Mặc định là 1000. Đặt quá lớn có thể ảnh hưởng đến hiệu suất.",
"Maximum number of tokens in the response": "Số token tối đa trong phản hồi",
"Maximum output tokens; the model context and gateway limits also apply.": "Số token đầu ra tối đa; vẫn áp dụng giới hạn ngữ cảnh mô hình và cổng API.",
"Maximum quota amount awarded for check-in": "Số lượng hạn ngạch tối đa được trao cho điểm danh",
"Maximum tokens including hidden reasoning tokens": "Số token tối đa bao gồm token suy luận ẩn",
"Maximum tokens per response": "Số token tối đa mỗi phản hồi",
......@@ -3162,6 +3182,7 @@
"Model prices reset successfully": "Đã đặt lại giá mô hình thành công",
"Model Pricing": "Định giá mô hình",
"Model pricing import completed: {{updated}} updated, {{perToken}} per-token, {{perRequest}} per-request, {{failed}} failed.": "Đã nhập giá mô hình: cập nhật {{updated}}, theo token {{perToken}}, theo lượt {{perRequest}}, lỗi {{failed}}.",
"Model pricing import completed: {{updated}} updated, {{perToken}} per-token, {{perRequest}} per-request, {{tieredExpr}} expression, {{failed}} failed.": "Đã nhập giá: {{updated}} cập nhật, {{perToken}} theo token, {{perRequest}} theo yêu cầu, {{tieredExpr}} theo biểu thức, {{failed}} thất bại.",
"Model pricing import failed": "Nhập giá mô hình thất bại",
"Model pricing is managed by a super administrator.": "Giá mô hình do siêu quản trị viên quản lý.",
"Model pricing saved": "Đã lưu giá mô hình",
......@@ -3256,6 +3277,7 @@
"Multi-user management with flexible permission allocation": "Quản lý nhiều người dùng với phân bổ quyền linh hoạt",
"Multilingual translation and localisation": "Dịch và bản địa hoá đa ngôn ngữ",
"Multimodal": "Đa phương thức",
"Multimodal options: dimension, enable_fusion, output_type (dense), instruct and fps (0–1). parameters.dimension overrides dimensions.": "Tùy chọn đa phương thức: dimension, enable_fusion, output_type (dense), instruct và fps (0–1). parameters.dimension ưu tiên hơn dimensions.",
"Multiplier": "Hệ số nhân",
"Multiplier applied when": "Hệ số nhân áp dụng khi",
"Multiplier applied when {{userGroup}} uses {{targetGroup}}": "Hệ số áp dụng khi {{userGroup}} sử dụng {{targetGroup}}",
......@@ -3290,6 +3312,8 @@
"Native format": "Định dạng gốc",
"Native forwarding": "Chuyển tiếp nguyên bản",
"Native Gemini routes plus OpenAI Chat and Responses compatibility forwarding.": "Route Gemini nguyên bản cùng chuyển tiếp tương thích OpenAI Chat và Responses.",
"Native image parameters override top-level size and watermark; include size, n and seed here when needed.": "parameters ảnh gốc ghi đè size và watermark cấp cao nhất; đặt size, n và seed tại đây khi cần.",
"Native input.messages overrides the prompt/image conversion.": "input.messages gốc ghi đè việc chuyển đổi prompt/image.",
"Native OpenAI routes plus optional Claude and Gemini compatibility routes.": "Route OpenAI nguyên bản cùng các route tương thích Claude và Gemini tùy chọn.",
"Native routes": "Tuyến nguyên bản",
"Need a redemption code?": "Cần mã đổi thưởng?",
......@@ -3389,6 +3413,7 @@
"No encryption": "Không mã hóa",
"No endpoints configured. Switch to JSON mode or add rows to define endpoints.": "Chưa cấu hình endpoint nào. Chuyển sang chế độ JSON hoặc thêm hàng để định nghĩa endpoint.",
"No endpoints inferred from channels": "Không xác định được điểm cuối từ kênh",
"No example is available for this endpoint.": "Chưa có ví dụ cho điểm cuối này.",
"No extra domains declared": "Không khai báo miền bổ sung",
"No FAQ entries available": "Không có mục FAQ nào.",
"No FAQ entries yet. Click \"Add FAQ\" to create one.": "Chưa có mục FAQ nào. Nhấp vào \"Thêm FAQ\" để tạo một mục.",
......@@ -3570,6 +3595,7 @@
"Notification Method": "Phương thức thông báo",
"Notifications": "Thông báo",
"Now a user whose user group is vip creates tokens with different groups and makes one call with each:": "Bây giờ, một người dùng có nhóm người dùng là vip tạo các token với nhóm khác nhau và gọi mỗi token một lần:",
"Nucleus sampling probability": "Xác suất lấy mẫu nucleus",
"Nucleus sampling probability mass": "Tổng xác suất cho nucleus sampling",
"Number": "Số",
"Number of codes to create": "Số mã cần tạo",
......@@ -3718,6 +3744,7 @@
"Optional note describing this version": "Ghi chú tùy chọn mô tả phiên bản này",
"Optional notes about this channel": "Ghi chú tùy chọn về kênh này",
"Optional notes about when to use this group": "Các ghi chú tùy chọn về thời điểm sử dụng nhóm này",
"Optional ranking instruction": "Hướng dẫn xếp hạng tùy chọn",
"Optional ratio used when upstream cache hits occur.": "Tỷ lệ tùy chọn được sử dụng khi xảy ra các lượt truy cập bộ nhớ đệm ngược dòng.",
"Optional request-rule multiplier expression. Leave empty when no request rule applies.": "Biểu thức hệ số tùy chọn cho quy tắc yêu cầu. Để trống khi không áp dụng quy tắc.",
"Optional rule description": "Mô tả quy tắc tùy chọn",
......@@ -3746,8 +3773,11 @@
"Outage": "Gián đoạn",
"Output": "Đầu ra",
"Output aspect ratio": "Tỉ lệ khung hình",
"Output aspect ratio. H3 frame inputs force adaptive; text-only input requires an explicit ratio.": "Tỷ lệ khung hình đầu ra. H3 dùng adaptive khi có khung hình; đầu vào chỉ có văn bản cần tỷ lệ cụ thể.",
"Output image format": "Định dạng ảnh đầu ra",
"Output image size": "Kích thước ảnh đầu ra",
"Output price": "Giá đầu ra",
"Output resolution": "Độ phân giải đầu ra",
"Output token price for generated tokens.": "Giá token đầu ra cho nội dung được tạo.",
"Output tokens": "Token đầu ra",
"Output Tokens": "Token đầu ra",
......@@ -4126,6 +4156,7 @@
"Pricing group example": "Ví dụ nhóm định giá",
"Pricing groups": "Nhóm định giá",
"Pricing import requirements": "Yêu cầu nhập giá",
"Pricing import: {{updated}} updated, {{failed}} failed.": "Nhập giá: cập nhật {{updated}}, thất bại {{failed}}.",
"Pricing mode": "Chế độ định giá",
"Pricing must be a JSON object": "Cấu hình giá phải là đối tượng JSON",
"Pricing Ratios": "Tỷ lệ định giá",
......@@ -4176,6 +4207,7 @@
"Provider created successfully": "Đã tạo nhà cung cấp thành công",
"Provider deleted successfully": "Đã xóa nhà cung cấp thành công",
"Provider Name": "Tên Nhà cung cấp",
"Provider options are model-specific. Top-level seconds and resolution take precedence.": "Tùy chọn nhà cung cấp phụ thuộc mô hình. seconds và resolution ở cấp cao nhất được ưu tiên.",
"Provider type (OpenAI, Anthropic, etc.)": "Loại nhà cung cấp (OpenAI, Anthropic, v.v.)",
"Provider updated successfully": "Nhà cung cấp đã được cập nhật thành công",
"Provider-specific endpoint, account, and compatibility settings.": "Thiết lập endpoint, tài khoản và tương thích riêng cho nhà cung cấp.",
......@@ -4237,6 +4269,7 @@
"Quota update failed": "Cập nhật hạn mức thất bại",
"Quota Warning Threshold": "Ngưỡng cảnh báo hạn mức",
"Quota:": "Hạn ngạch:",
"Qwen/Wan images and Seedream 5.0 pro require stream=false in this adapter.": "Bộ chuyển đổi này yêu cầu stream=false cho ảnh Qwen/Wan và Seedream 5.0 pro.",
"Radius": "Bo góc",
"Random": "Ngẫu nhiên",
"Randomly select a key from the pool for each request": "Chọn ngẫu nhiên một khóa từ kho cho mỗi yêu cầu",
......@@ -4408,6 +4441,7 @@
"Replace": "Thay thế",
"Replace all existing keys": "Thay thế tất cả các khóa hiện có",
"Replace channel models": "Thay thế mô hình kênh",
"Replace example media URLs with accessible media supported by the model.": "Thay URL mẫu bằng URL phương tiện có thể truy cập và được mô hình hỗ trợ.",
"Replace forwarding routes?": "Thay thế các route chuyển tiếp?",
"Replace mode: Will completely replace all existing keys": "Chế độ Thay thế: Sẽ thay thế hoàn toàn tất cả các khóa hiện có",
"Replace With": "Thay bằng",
......@@ -4625,6 +4659,7 @@
"s": "s",
"Safety Settings": "Cài đặt an toàn",
"Same as Local": "Giống như địa phương",
"Sampling temperature": "Nhiệt độ lấy mẫu",
"Sampling temperature; lower is more deterministic": "Nhiệt độ lấy mẫu; càng thấp càng ổn định",
"Sandbox": "Hộp cát",
"Sandbox mode": "Chế độ sandbox",
......@@ -4709,6 +4744,7 @@
"Search payment type keys...": "Tìm khóa loại thanh toán...",
"Search payment types...": "Tìm kiếm loại thanh toán...",
"Search products...": "Tìm kiếm sản phẩm...",
"Search query used to rank documents": "Truy vấn dùng để xếp hạng tài liệu",
"Search rules...": "Tìm kiếm quy tắc…",
"Search tags...": "Tìm thẻ...",
"Search the public web at inference time": "Tìm kiếm web công khai trong khi suy luận",
......@@ -5291,6 +5327,7 @@
"The exact model identifier as used in API requests.": "Mã định danh mô hình chính xác như được sử dụng trong các yêu cầu API.",
"The Excel workbook must contain a worksheet.": "Sổ làm việc Excel phải có một trang tính.",
"The Excel worksheet must contain the headers “模型名称” and “映射模型”.": "Trang tính Excel phải có tiêu đề “模型名称” và “映射模型”.",
"The first row must contain headers, and model_name is required. For expression billing, provide billing_expr and leave all price columns blank. Use len for input-length tiers. Otherwise provide fixed_price or input_price; optional columns are completion_price, cache_price, create_cache_price, image_price, audio_input_price, and audio_output_price.": "Hàng đầu tiên phải chứa tiêu đề và có model_name. Để tính phí bằng biểu thức, điền billing_expr và để trống mọi cột giá. Dùng len để chia bậc theo độ dài đầu vào. Với cách tính khác, điền fixed_price hoặc input_price; các cột tùy chọn gồm completion_price, cache_price, create_cache_price, image_price, audio_input_price và audio_output_price.",
"The first row must contain headers, and model_name is required. For per-request billing, provide fixed_price. Otherwise input_price is required; optional columns are completion_price, cache_price, create_cache_price, image_price, audio_input_price, and audio_output_price.": "Hàng đầu tiên phải chứa tiêu đề và bắt buộc có model_name. Với tính phí theo lượt, hãy nhập fixed_price. Nếu không, bắt buộc có input_price; các cột tùy chọn gồm completion_price, cache_price, create_cache_price, image_price, audio_input_price và audio_output_price.",
"The first row must contain headers, and model_name is required. Optional columns: description, icon, tags, vendor_name, endpoints, status, sync_official, name_rule.": "Hàng đầu tiên phải chứa tiêu đề và bắt buộc có model_name. Các cột tùy chọn: description, icon, tags, vendor_name, endpoints, status, sync_official, name_rule.",
"The first row must contain the headers “模型名称” and “映射模型”. Both columns are required.": "Hàng đầu tiên phải có tiêu đề “模型名称” và “映射模型”. Cả hai cột đều bắt buộc.",
......@@ -5337,6 +5374,7 @@
"Theme Settings": "Cài đặt chủ đề",
"There are both add and remove models pending, but you only selected one type. Confirm submitting only the selected items?": "Có cả mô hình cần thêm và xóa đang chờ, nhưng bạn chỉ chọn một loại. Xác nhận chỉ gửi các mục đã chọn?",
"There is a rule for vip billed as premium → use its ratio 0.3": "Có quy tắc «vip theo premium» → dùng hệ số 0.3 của quy tắc",
"These examples call new-api with a new-api token. CTyun AppKeys and regional upstream URLs belong in channel settings. Select a token group routed to CTyun; switching examples does not change routing.": "Ví dụ gọi new-api bằng token new-api. Đặt AppKey CTyun và URL khu vực trong cấu hình kênh. Chọn nhóm token định tuyến tới CTyun; đổi ví dụ không thay đổi định tuyến.",
"These models are still in your selection but were not returned by the upstream listing. Entries that are only model_mapping source aliases are omitted. Toggle to adjust before saving.": "Các model này vẫn được chọn nhưng không còn xuất hiện trong danh sách upstream; tên chỉ là khóa nguồn trong model_mapping đã được loại. Điều chỉnh trước khi lưu.",
"These toggles affect whether certain request fields are passed through to the upstream provider.": "Các chuyển đổi này ảnh hưởng đến việc các trường yêu cầu nhất định có được chuyển đến nhà cung cấp dịch vụ đầu vào hay không.",
"These values come from the source index and are shown for review only. The gateway admits the plugin based on the metadata compiled from its source.": "Các giá trị này lấy từ chỉ mục của nguồn và chỉ để bạn xem xét. Cổng quyết định chấp nhận plugin dựa trên metadata biên dịch từ mã nguồn của nó.",
......@@ -5819,6 +5857,7 @@
"Use 8–128 characters.": "Sử dụng 8–128 ký tự.",
"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 a different stable value for each instance, then restart the service.": "Dùng một giá trị ổn định khác nhau cho mỗi phiên bản, sau đó khởi động lại dịch vụ.",
"Use a model-supported image size, such as 2K for Seedream.": "Dùng kích thước ảnh được mô hình hỗ trợ, ví dụ 2K cho Seedream.",
"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 authenticator code": "Sử dụng mã xác thực",
"Use backup code": "Sử dụng mã dự phòng",
......@@ -5829,6 +5868,7 @@
"Use exact model names such as gpt-4o, or regex rules prefixed with re: such as re:^gemini-.": "Dùng tên model chính xác như gpt-4o, hoặc quy tắc regex có tiền tố re: như re:^gemini-.",
"Use expression pricing when a dependent price is non-zero and its base price is zero.": "Dùng biểu thức khi giá phụ thuộc khác không nhưng giá cơ sở bằng không.",
"Use external tools to extend capabilities": "Sử dụng công cụ ngoài để mở rộng khả năng",
"Use input.contents; each item contains exactly one nonempty text, image or video string.": "Dùng input.contents; mỗi mục chứa đúng một chuỗi text, image hoặc video không rỗng.",
"Use one available reset credit for this channel. The reset request is sent only after confirmation.": "Sử dụng một lượt đặt lại khả dụng cho kênh này. Yêu cầu chỉ được gửi sau khi xác nhận.",
"Use one available reset credit to refresh the current Codex usage windows.": "Sử dụng một lượt đặt lại khả dụng để làm mới các cửa sổ mức dùng Codex hiện tại.",
"Use our unified OpenAI-compatible endpoint in your applications": "Sử dụng endpoint thống nhất tương thích OpenAI trong ứng dụng của bạn",
......@@ -5839,9 +5879,11 @@
"Use sidebar shortcut": "Sử dụng phím tắt thanh bên",
"Use the full-width table to scan prices, then select a row to edit it here.": "Duyệt giá trong bảng, rồi chọn một hàng để chỉnh sửa tại đây.",
"Use the group set on the token. If the token has no group, use the user group. The auto group tries the auto assignment order from top to bottom.": "Dùng nhóm đặt trên token. Nếu token không có nhóm, dùng nhóm người dùng. Nhóm auto thử theo thứ tự gán tự động từ trên xuống dưới.",
"Use the model name shown here; new-api applies the channel model mapping.": "Dùng tên mô hình hiển thị; new-api sẽ áp dụng ánh xạ mô hình của kênh.",
"Use the pricing group table to manage the ratio and whether the group appears in the token creation dropdown.": "Dùng bảng nhóm định giá để quản lý tỷ lệ và việc nhóm có xuất hiện trong danh sách tạo token hay không.",
"Use this callback URL pattern when registering a custom OAuth provider.": "Dùng mẫu URL callback này khi đăng ký nhà cung cấp OAuth tùy chỉnh.",
"Use this token for API authentication": "Sử dụng token này để xác thực API",
"Use WIDTHxHEIGHT (512–2048 per side); Wan also accepts 1K, 2K and 4K. Native parameters.size uses WIDTH*HEIGHT.": "Dùng RỘNGxCAO (512–2048 mỗi cạnh); Wan còn hỗ trợ 1K, 2K và 4K. parameters.size gốc dùng RỘNG*CAO.",
"Use your Passkey": "Sử dụng Passkey của bạn",
"used": "đã sử dụng, cũ",
"Used": "Đã sử dụng",
......@@ -5977,7 +6019,9 @@
"Vertex AI Key Format": "Định dạng khóa Vertex AI",
"Vertex AI service account key must be valid JSON": "Khóa tài khoản dịch vụ Vertex AI phải là JSON hợp lệ",
"Video": "Video",
"Video duration; new-api converts seconds to the upstream duration field.": "Thời lượng video; new-api chuyển seconds thành trường duration của nhà cung cấp.",
"Video length in seconds": "Độ dài video (giây)",
"Video reference": "Tham chiếu video",
"Video Remix": "Remix video",
"Vidu": "Vidu",
"View": "Xem",
......
......@@ -209,6 +209,7 @@
"Add a new user by providing necessary info.": "透過提供必要資訊新增用戶。",
"Add a new vendor to the system": "向系統新增供應商",
"Add a vendor or adjust your search.": "新增供應商或調整搜尋條件。",
"Add a watermark to the generated image": "為生成圖片加入浮水印",
"Add an extra layer of security to your account": "為您的用戶添加額外的安全層",
"Add an index URL to browse installable plugins.": "新增一個索引 URL 即可瀏覽可安裝的外掛。",
"Add and submit": "新增並提交",
......@@ -317,6 +318,8 @@
"Advanced platform configuration.": "進階平台設定。",
"Advanced Settings": "進階設定",
"Advanced text editing": "進階文字編輯",
"Advanced video inputs: content contains text, image_url, video_url or audio_url items with optional roles.": "進階影片輸入:content 包含 text、image_url、video_url 或 audio_url 項,可指定 role。",
"Advanced video inputs: input.media contains typed media URLs; do not mix frame and reference inputs.": "進階影片輸入:input.media 包含帶類型的媒體 URL;首尾幀與參考素材不能混用。",
"Aesthetic style": "畫風",
"Affected windows:": "影響窗口:",
"After clicking the button, you'll be asked to authorize the bot": "點擊按鈕後,您將被要求授權機器人",
......@@ -707,6 +710,7 @@
"Base URL of your Uptime Kuma instance": "您的 Uptime Kuma 實例的基礎 URL",
"Basic Authentication": "基本身份驗證",
"Basic Configuration": "基本設定",
"Basic example": "基本範例",
"Basic Info": "基本資訊",
"Basic Information": "基本資訊",
"Basic Templates": "基礎模板",
......@@ -1242,6 +1246,7 @@
"Controls visibility in the model square. Channel status and existing API access are unchanged.": "控制模型在模型廣場中的顯示,不影響管道啟用狀態與既有呼叫權限。",
"Controls whether user verification (biometrics/PIN) is required during Passkey flows.": "控制在通行金鑰流程中是否需要用戶驗證(生物識別/PIN)。",
"Conversation cleared": "對話已清空",
"Conversation messages": "對話訊息",
"Conversion rate from USD to your custom currency": "從美元到您的自訂貨幣的轉換率",
"Convert reasoning_content to <think> tag in content": "將 reasoning_content 轉換為 content 中的 <think> 標籤",
"Convert string to lowercase": "把字串轉成小寫",
......@@ -1384,6 +1389,7 @@
"Creem products must be a JSON array": "Creem 產品必須是 JSON 陣列",
"Cross-group": "跨分組",
"Cross-group retry": "跨分組重試",
"CTyun": "天翼雲",
"Currency": "貨幣",
"Currency & Display": "貨幣與展示",
"Current": "目前",
......@@ -1688,6 +1694,7 @@
"Docs": "文件",
"Documentation Link": "文件連結",
"Documentation or external knowledge base.": "文件或外部知識庫。",
"Documents to rank: strings or objects containing text": "待排序文件:字串或包含 text 的物件",
"Does not check healthy channels. It only rechecks auto-disabled channels and restores them after they recover.": "不檢查正常渠道,只複查被系統自動停用的渠道,並在恢復後重新啟用。",
"does not exist or might have been removed.": "不存在或可能已被移除。",
"Domain": "域名",
......@@ -2024,7 +2031,10 @@
"Example": "示例",
"Example (all channels):": "示例(全部渠道):",
"Example (specific channels):": "示例(指定渠道):",
"Example channel": "範例適用渠道",
"Example price": "示例價格",
"Example scenario": "呼叫情境",
"Example selection does not change request routing. Use a token group matching the example; channels in the same group may still use different formats.": "選擇範例不會改變請求路由。請使用與範例相符的權杖群組;同一群組中的渠道仍可能採用不同格式。",
"Example spec": "示例規格",
"Example:": "示例:",
"example.com&#10;blocked-site.com": "example.com&#10;blocked-site.com",
......@@ -2343,6 +2353,7 @@
"Finish Time": "完成時間",
"First API request": "首個 API 請求",
"First token": "首字",
"First-frame image URL. Use images for first and last frames; uploaded files are not supported by this video adapter.": "首幀圖片 URL。首尾幀使用 images;此影片適配器不支援上傳檔案。",
"First/Last Frame to Video": "首尾生影片",
"Fix Abilities": "修復渠道一致性",
"Fix order": "修復順序",
......@@ -2639,6 +2650,7 @@
"Ignore": "忽略",
"Ignored upstream models": "已忽略上游模型",
"Image": "圖片",
"Image count is bounded by new-api; the upstream model may impose a lower limit. Seedream groups are converted automatically.": "圖片數量受 new-api 上限約束,上游模型可能有更低限制。Seedream 組圖參數會自動轉換。",
"Image Generation": "圖片生成",
"Image In": "圖像輸入",
"Image input": "圖片輸入",
......@@ -2648,6 +2660,7 @@
"Image output price": "圖像輸出價格",
"Image Preview": "圖片預覽",
"Image ratio": "圖片倍率",
"Image reference": "圖片參考",
"Image to Video": "圖生影片",
"Image Tokens": "圖像 Token",
"Imagine the pricing table has three groups: default (ratio 1.0), premium (ratio 0.5), and vip (ratio 0.8). Users whose account is in the vip group get user-level perks, and premium is a cheaper channel pool that users can pick for their tokens.": "假設定價分組表裡有三個分組:default(倍率 1.0)、premium(倍率 0.5)、vip(倍率 0.8)。賬號在 vip 分組的用戶享受用戶級待遇,premium 則是一個更便宜的渠道池,用戶建令牌時可以選它。",
......@@ -2656,6 +2669,7 @@
"Import from URL": "從 URL 匯入",
"Import mappings from an Excel workbook.": "Import mappings from an Excel workbook.",
"Import model mappings": "Import model mappings",
"Import model metadata and optional pricing from an Excel workbook.": "從 Excel 活頁簿匯入模型資訊及選填定價。",
"Import model metadata from an Excel workbook.": "從 Excel 活頁簿匯入模型中繼資料。",
"Import model prices from an Excel workbook.": "從 Excel 活頁簿匯入模型定價。",
"Import model pricing": "匯入模型定價",
......@@ -2664,6 +2678,7 @@
"Important": "重要",
"Imported mappings replace existing mappings with the same model name.": "Imported mappings replace existing mappings with the same model name.",
"Importing metadata does not add channels, enable model access, or configure prices.": "匯入中繼資料不會新增渠道、開放模型存取或設定價格。",
"Importing replaces existing pricing for matching models. Expression billing clears fixed prices and ratios; other billing modes clear expressions. Prices and expression coefficients are imported as-is without currency conversion.": "匯入會取代相符模型的現有價格。運算式計費會清除固定價格與倍率,其他計費方式會清除運算式。價格與運算式係數按原值匯入,不進行匯率換算。",
"Importing replaces the billing mode for matching models: per-token billing clears fixed prices, while per-request billing clears ratio settings.": "匯入會覆寫相符模型的計費方式:按量計費會清除固定價格,按次計費會清除倍率設定。",
"Importing...": "正在匯入...",
"In BotFather, open Login Widget, register this callback URL, and copy the Client ID and Client Secret. Existing Telegram bindings will continue to work after configuration.": "在 BotFather 中開啟 Login Widget,登記此回呼網址並複製 Client ID 和 Client Secret。完成設定後,現有 Telegram 綁定可繼續使用。",
......@@ -2674,6 +2689,7 @@
"incident": "次故障",
"incidents": "次故障",
"Incidents": "事件",
"Include document text in the ranked results": "在排序結果中包含文件文字",
"Include Group": "包含分組",
"Include Model": "包含模型",
"Include name": "包含名稱",
......@@ -2707,6 +2723,7 @@
"Initializing…": "正在初始化…",
"Inpaint": "局部重繪",
"Input": "輸入",
"Input image URL or array of image URLs for image editing.": "用於圖片編輯的輸入圖片 URL 或 URL 陣列。",
"Input mode": "輸入模式",
"Input price": "輸入價格",
"Input price is required before saving dependent prices.": "儲存依賴價格前必須先填寫輸入價格。",
......@@ -2866,6 +2883,7 @@
"Learn more": "了解更多",
"Learn more:": "了解更多:",
"Leave": "離開",
"Leave all price cells blank to keep pricing unchanged. Pricing requires a super administrator and exact model names. Existing models and their prices are skipped unless overwrite is enabled. Prices use USD per million tokens, or USD per request for fixed_price; no currency conversion is applied.": "價格儲存格全部留空則保留原定價。匯入定價需要超級管理員權限,且僅支援精確模型名稱。未啟用覆寫時,略過既有模型及其價格。價格單位為美元/百萬 token,fixed_price 為美元/次,不進行匯率換算。",
"Leave blank to keep the existing credential": "留空以保留現有憑證",
"Leave blank to keep the existing key": "留空以保留現有金鑰",
"Leave blank unless rotating the secret": "除非正在輪換金鑰,否則留空",
......@@ -3046,8 +3064,10 @@
"Maximum custom groups per token": "每個令牌的最大自訂分組數",
"Maximum input window": "最大輸入窗口",
"Maximum number of channels tested at the same time (1-32)": "同時測試的最大渠道數(1-32)",
"Maximum number of ranked results to return": "最多傳回的排序結果數",
"Maximum number of tokens each user can create. Default 1000. Setting too large may affect performance.": "每個用戶可建立的最大令牌數量。預設 1000。設定過大可能會影響效能。",
"Maximum number of tokens in the response": "回應中最大 token 數",
"Maximum output tokens; the model context and gateway limits also apply.": "最大輸出 Token 數;同時受模型上下文和閘道限制約束。",
"Maximum quota amount awarded for check-in": "簽到獎勵的最大額度",
"Maximum tokens including hidden reasoning tokens": "最大 token 數(含隱藏的推理 token)",
"Maximum tokens per response": "單次回應最大 token 數",
......@@ -3162,6 +3182,7 @@
"Model prices reset successfully": "模型價格重置成功",
"Model Pricing": "模型定價",
"Model pricing import completed: {{updated}} updated, {{perToken}} per-token, {{perRequest}} per-request, {{failed}} failed.": "模型定價匯入完成:更新 {{updated}} 個、按量 {{perToken}} 個、按次 {{perRequest}} 個、失敗 {{failed}} 個。",
"Model pricing import completed: {{updated}} updated, {{perToken}} per-token, {{perRequest}} per-request, {{tieredExpr}} expression, {{failed}} failed.": "模型價格匯入完成:更新 {{updated}} 個,按量 {{perToken}} 個,按次 {{perRequest}} 個,運算式 {{tieredExpr}} 個,失敗 {{failed}} 個。",
"Model pricing import failed": "模型定價匯入失敗",
"Model pricing is managed by a super administrator.": "模型定價由超級管理員管理。",
"Model pricing saved": "模型定價已儲存",
......@@ -3256,6 +3277,7 @@
"Multi-user management with flexible permission allocation": "多用戶管理,靈活分配權限",
"Multilingual translation and localisation": "多語種翻譯與本地化",
"Multimodal": "多模態",
"Multimodal options: dimension, enable_fusion, output_type (dense), instruct and fps (0–1). parameters.dimension overrides dimensions.": "多模態選項:dimension、enable_fusion、output_type(dense)、instruct 和 fps(0–1)。parameters.dimension 優先於 dimensions。",
"Multiplier": "倍率",
"Multiplier applied when": "倍率套用於當",
"Multiplier applied when {{userGroup}} uses {{targetGroup}}": "當{{userGroup}}使用{{targetGroup}}時套用的倍率",
......@@ -3290,6 +3312,8 @@
"Native format": "原生格式",
"Native forwarding": "原生轉發",
"Native Gemini routes plus OpenAI Chat and Responses compatibility forwarding.": "Gemini 原生轉發,並相容 OpenAI Chat 和 Responses 轉換。",
"Native image parameters override top-level size and watermark; include size, n and seed here when needed.": "原生圖像 parameters 會覆蓋頂層 size 和 watermark;需要時在此設定 size、n 和 seed。",
"Native input.messages overrides the prompt/image conversion.": "原生 input.messages 會覆蓋 prompt/image 的自動轉換。",
"Native OpenAI routes plus optional Claude and Gemini compatibility routes.": "OpenAI 原生轉發,並提供可選的 Claude 和 Gemini 相容轉換。",
"Native routes": "原生路由",
"Need a redemption code?": "需要兌換碼?",
......@@ -3389,6 +3413,7 @@
"No encryption": "無加密",
"No endpoints configured. Switch to JSON mode or add rows to define endpoints.": "未設定端點。切換到 JSON 模式或新增列來定義端點。",
"No endpoints inferred from channels": "未從渠道推導出端點",
"No example is available for this endpoint.": "此端點暫無呼叫範例。",
"No extra domains declared": "未宣告額外網域",
"No FAQ entries available": "暫無 FAQ 條目",
"No FAQ entries yet. Click \"Add FAQ\" to create one.": "暫無常見問題條目。點擊「新增常見問題」來建立一個。",
......@@ -3570,6 +3595,7 @@
"Notification Method": "通知方式",
"Notifications": "通知",
"Now a user whose user group is vip creates tokens with different groups and makes one call with each:": "現在,一個用戶分組為 vip 的用戶建立了不同分組的令牌,各呼叫一次:",
"Nucleus sampling probability": "核心取樣機率",
"Nucleus sampling probability mass": "核採樣累積概率",
"Number": "數值",
"Number of codes to create": "要建立的代碼數量",
......@@ -3718,6 +3744,7 @@
"Optional note describing this version": "選填,用於描述該版本的備註",
"Optional notes about this channel": "關於此渠道的可選備註",
"Optional notes about when to use this group": "關於何時使用此分組的可選說明",
"Optional ranking instruction": "可選的排序指令",
"Optional ratio used when upstream cache hits occur.": "上游緩存命中時使用的可選比率。",
"Optional request-rule multiplier expression. Leave empty when no request rule applies.": "可選的請求規則倍率運算式。沒有請求規則時留空。",
"Optional rule description": "可選規則說明",
......@@ -3746,8 +3773,11 @@
"Outage": "中斷",
"Output": "輸出",
"Output aspect ratio": "輸出寬高比",
"Output aspect ratio. H3 frame inputs force adaptive; text-only input requires an explicit ratio.": "輸出長寬比。H3 首尾幀輸入強制使用 adaptive;純文字輸入需明確比例。",
"Output image format": "輸出圖片格式",
"Output image size": "輸出圖像尺寸",
"Output price": "輸出價格",
"Output resolution": "輸出解析度",
"Output token price for generated tokens.": "生成內容的輸出 token 價格。",
"Output tokens": "輸出 token",
"Output Tokens": "輸出 Token",
......@@ -4126,6 +4156,7 @@
"Pricing group example": "定價分組示例",
"Pricing groups": "定價分組",
"Pricing import requirements": "定價匯入要求",
"Pricing import: {{updated}} updated, {{failed}} failed.": "定價匯入:更新 {{updated}} 項,失敗 {{failed}} 項。",
"Pricing mode": "定價模式",
"Pricing must be a JSON object": "定價設定必須為 JSON 物件",
"Pricing Ratios": "定價比例",
......@@ -4176,6 +4207,7 @@
"Provider created successfully": "供應商建立成功",
"Provider deleted successfully": "供應商刪除成功",
"Provider Name": "供應商名稱",
"Provider options are model-specific. Top-level seconds and resolution take precedence.": "上游選項因模型而異。頂層 seconds 和 resolution 優先。",
"Provider type (OpenAI, Anthropic, etc.)": "供應商類型 (OpenAI、Anthropic 等)",
"Provider updated successfully": "供應商更新成功",
"Provider-specific endpoint, account, and compatibility settings.": "設定供應商專屬的端點、用戶和兼容性選項。",
......@@ -4237,6 +4269,7 @@
"Quota update failed": "額度更新失敗",
"Quota Warning Threshold": "配額警告閾值",
"Quota:": "Quota:",
"Qwen/Wan images and Seedream 5.0 pro require stream=false in this adapter.": "此適配器中 Qwen/萬相圖像和 Seedream 5.0 pro 必須使用 stream=false。",
"Radius": "圓角",
"Random": "隨機",
"Randomly select a key from the pool for each request": "每次請求從池中隨機選擇一個金鑰",
......@@ -4408,6 +4441,7 @@
"Replace": "替換",
"Replace all existing keys": "替換所有現有金鑰",
"Replace channel models": "覆蓋渠道模型",
"Replace example media URLs with accessible media supported by the model.": "請將範例素材 URL 替換為可存取且符合模型要求的媒體位址。",
"Replace forwarding routes?": "取代轉發路由?",
"Replace mode: Will completely replace all existing keys": "替換模式:將完全替換所有現有鍵",
"Replace With": "替換為",
......@@ -4625,6 +4659,7 @@
"s": "秒",
"Safety Settings": "安全設定",
"Same as Local": "與本地相同",
"Sampling temperature": "取樣溫度",
"Sampling temperature; lower is more deterministic": "採樣溫度;越低越穩定",
"Sandbox": "沙盤",
"Sandbox mode": "沙盒模式",
......@@ -4709,6 +4744,7 @@
"Search payment type keys...": "搜尋支付處理標識...",
"Search payment types...": "搜尋支付類型...",
"Search products...": "搜尋產品...",
"Search query used to rank documents": "用於文件排序的查詢文字",
"Search rules...": "搜尋規則…",
"Search tags...": "搜尋標籤...",
"Search the public web at inference time": "推理時檢索公開互聯網",
......@@ -5291,6 +5327,7 @@
"The exact model identifier as used in API requests.": "API 請求中使用的確切模型標識符。",
"The Excel workbook must contain a worksheet.": "Excel 活頁簿必須包含一個工作表。",
"The Excel worksheet must contain the headers “模型名称” and “映射模型”.": "Excel 工作表必須包含「模型名称」和「映射模型」欄位名稱。",
"The first row must contain headers, and model_name is required. For expression billing, provide billing_expr and leave all price columns blank. Use len for input-length tiers. Otherwise provide fixed_price or input_price; optional columns are completion_price, cache_price, create_cache_price, image_price, audio_input_price, and audio_output_price.": "首列必須為欄位名稱,model_name 為必填欄。運算式計費請填寫 billing_expr,並將所有價格欄留空;依輸入長度分級使用 len。其他計費方式填寫 fixed_price 或 input_price;選填欄包括 completion_price、cache_price、create_cache_price、image_price、audio_input_price 和 audio_output_price。",
"The first row must contain headers, and model_name is required. For per-request billing, provide fixed_price. Otherwise input_price is required; optional columns are completion_price, cache_price, create_cache_price, image_price, audio_input_price, and audio_output_price.": "第一列必須是表頭,且 model_name 為必填欄位。按次計費請填寫 fixed_price;否則必須填寫 input_price。可選欄位:completion_price、cache_price、create_cache_price、image_price、audio_input_price、audio_output_price。",
"The first row must contain headers, and model_name is required. Optional columns: description, icon, tags, vendor_name, endpoints, status, sync_official, name_rule.": "第一列必須是表頭,且 model_name 為必填欄位。可選欄位:description、icon、tags、vendor_name、endpoints、status、sync_official、name_rule。",
"The first row must contain the headers “模型名称” and “映射模型”. Both columns are required.": "The first row must contain the headers “模型名称” and “映射模型”. Both columns are required.",
......@@ -5337,6 +5374,7 @@
"Theme Settings": "主題設定",
"There are both add and remove models pending, but you only selected one type. Confirm submitting only the selected items?": "目前有新增和刪除兩類待處理模型,但您只勾選了其中一類。確認僅提交已勾選的部分嗎?",
"There is a rule for vip billed as premium → use its ratio 0.3": "存在「vip 按 premium 收費」的規則 → 用規則裡的 0.3",
"These examples call new-api with a new-api token. CTyun AppKeys and regional upstream URLs belong in channel settings. Select a token group routed to CTyun; switching examples does not change routing.": "範例使用 new-api 權杖呼叫本站。天翼雲 AppKey 和區域上游位址應設定在渠道中。請選擇路由至天翼雲的權杖群組;切換範例不會改變實際路由。",
"These models are still in your selection but were not returned by the upstream listing. Entries that are only model_mapping source aliases are omitted. Toggle to adjust before saving.": "這些模型仍然在您的勾選列表中,但上游已不再返回該名稱;僅作為 model_mapping 來源鍵而不會出現在 upstream 列表的別名已從本視圖排除,請在儲存前調整勾選。",
"These toggles affect whether certain request fields are passed through to the upstream provider.": "這些開關控制某些請求欄位是否透傳到上游服務。",
"These values come from the source index and are shown for review only. The gateway admits the plugin based on the metadata compiled from its source.": "這些值來自來源索引,僅供審查參考。閘道的准入判定只依據從原始碼編譯出的真實中介資料。",
......@@ -5819,6 +5857,7 @@
"Use 8–128 characters.": "使用 8–128 個字元。",
"Use a compatible browser or device with biometric authentication or a security key to register a Passkey.": "請使用支援生物識別認證或安全金鑰的兼容瀏覽器或設備來註冊通行金鑰。",
"Use a different stable value for each instance, then restart the service.": "每個實例使用不同且穩定的值,然後重啟服務。",
"Use a model-supported image size, such as 2K for Seedream.": "使用模型支援的圖片尺寸,例如 Seedream 的 2K。",
"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 authenticator code": "使用驗證器代碼",
"Use backup code": "使用備用代碼",
......@@ -5829,6 +5868,7 @@
"Use exact model names such as gpt-4o, or regex rules prefixed with re: such as re:^gemini-.": "可以填寫 gpt-4o 這類精確模型名,也可以填寫 re:^gemini- 這類以 re: 開頭的正則規則。",
"Use expression pricing when a dependent price is non-zero and its base price is zero.": "基礎價格為零而關聯價格非零時,請使用運算式定價。",
"Use external tools to extend capabilities": "透過外部工具擴展能力",
"Use input.contents; each item contains exactly one nonempty text, image or video string.": "使用 input.contents;每項只能包含一個非空的 text、image 或 video 字串。",
"Use one available reset credit for this channel. The reset request is sent only after confirmation.": "將為目前渠道使用 1 次可用重置次數。只有確認後才會發送重置請求。",
"Use one available reset credit to refresh the current Codex usage windows.": "使用 1 次可用重置次數,重新整理目前 Codex 用量窗口。",
"Use our unified OpenAI-compatible endpoint in your applications": "在套用中使用我們兼容 OpenAI 的統一接口",
......@@ -5839,9 +5879,11 @@
"Use sidebar shortcut": "使用側邊欄快捷方式",
"Use the full-width table to scan prices, then select a row to edit it here.": "先在表格中快速瀏覽價格,然後選擇一行在這裡編輯。",
"Use the group set on the token. If the token has no group, use the user group. The auto group tries the auto assignment order from top to bottom.": "使用令牌上設定的分組;令牌未設定分組時,使用用戶分組。auto 分組會按自動分組順序從上到下嘗試。",
"Use the model name shown here; new-api applies the channel model mapping.": "使用此處顯示的模型名稱;new-api 會套用渠道模型映射。",
"Use the pricing group table to manage the ratio and whether the group appears in the token creation dropdown.": "使用定價分組表管理倍率,以及該分組是否出現在建立令牌的下拉框中。",
"Use this callback URL pattern when registering a custom OAuth provider.": "註冊自訂 OAuth 提供商時使用此回呼 URL 格式。",
"Use this token for API authentication": "使用此令牌進行 API 身份驗證",
"Use WIDTHxHEIGHT (512–2048 per side); Wan also accepts 1K, 2K and 4K. Native parameters.size uses WIDTH*HEIGHT.": "使用 寬x高(每邊 512–2048);萬相也支援 1K、2K、4K。原生 parameters.size 使用 寬*高。",
"Use your Passkey": "使用您的通行金鑰",
"used": "已使用",
"Used": "已使用",
......@@ -5977,7 +6019,9 @@
"Vertex AI Key Format": "Vertex AI 金鑰格式",
"Vertex AI service account key must be valid JSON": "Vertex AI 服務賬號金鑰必須是有效 JSON",
"Video": "影片",
"Video duration; new-api converts seconds to the upstream duration field.": "影片時長;new-api 將 seconds 轉換為上游 duration 欄位。",
"Video length in seconds": "影片時長(秒)",
"Video reference": "影片參考",
"Video Remix": "影片 Remix",
"Vidu": "Vidu",
"View": "查看",
......
......@@ -209,6 +209,7 @@
"Add a new user by providing necessary info.": "通过提供必要信息来添加新用户。",
"Add a new vendor to the system": "向系统添加新供应商",
"Add a vendor or adjust your search.": "新增供应商或调整搜索条件。",
"Add a watermark to the generated image": "为生成图片添加水印",
"Add an extra layer of security to your account": "为您的账户添加额外的安全层",
"Add an index URL to browse installable plugins.": "添加一个索引 URL 即可浏览可安装的插件。",
"Add and submit": "添加后提交",
......@@ -317,6 +318,8 @@
"Advanced platform configuration.": "高级平台配置。",
"Advanced Settings": "高级设置",
"Advanced text editing": "高级文本编辑",
"Advanced video inputs: content contains text, image_url, video_url or audio_url items with optional roles.": "高级视频输入:content 包含 text、image_url、video_url 或 audio_url 项,可指定 role。",
"Advanced video inputs: input.media contains typed media URLs; do not mix frame and reference inputs.": "高级视频输入:input.media 包含带类型的媒体 URL;首尾帧与参考素材不能混用。",
"Aesthetic style": "画风",
"Affected windows:": "影响窗口:",
"After clicking the button, you'll be asked to authorize the bot": "点击按钮后,您将被要求授权机器人",
......@@ -707,6 +710,7 @@
"Base URL of your Uptime Kuma instance": "您的 Uptime Kuma 实例的基础 URL",
"Basic Authentication": "基本身份验证",
"Basic Configuration": "基本配置",
"Basic example": "基础示例",
"Basic Info": "基本信息",
"Basic Information": "基本信息",
"Basic Templates": "基础模板",
......@@ -1242,6 +1246,7 @@
"Controls visibility in the model square. Channel status and existing API access are unchanged.": "控制模型在模型广场中的展示,不影响渠道启用状态和已有调用权限。",
"Controls whether user verification (biometrics/PIN) is required during Passkey flows.": "控制在通行密钥流程中是否需要用户验证(生物识别/PIN)。",
"Conversation cleared": "对话已清空",
"Conversation messages": "对话消息",
"Conversion rate from USD to your custom currency": "从美元到您的自定义货币的转换率",
"Convert reasoning_content to <think> tag in content": "将 reasoning_content 转换为 content 中的 <think> 标签",
"Convert string to lowercase": "把字符串转成小写",
......@@ -1384,6 +1389,7 @@
"Creem products must be a JSON array": "Creem 产品必须是 JSON 数组",
"Cross-group": "跨分组",
"Cross-group retry": "跨分组重试",
"CTyun": "天翼云",
"Currency": "货币",
"Currency & Display": "货币与展示",
"Current": "当前",
......@@ -1688,6 +1694,7 @@
"Docs": "文档",
"Documentation Link": "文档链接",
"Documentation or external knowledge base.": "文档或外部知识库。",
"Documents to rank: strings or objects containing text": "待排序文档:字符串或包含 text 的对象",
"Does not check healthy channels. It only rechecks auto-disabled channels and restores them after they recover.": "不检查正常渠道,只复查被系统自动禁用的渠道,并在恢复后重新启用。",
"does not exist or might have been removed.": "不存在或可能已被移除。",
"Domain": "域名",
......@@ -2024,7 +2031,10 @@
"Example": "示例",
"Example (all channels):": "示例(全部渠道):",
"Example (specific channels):": "示例(指定渠道):",
"Example channel": "示例适用渠道",
"Example price": "示例价格",
"Example scenario": "调用场景",
"Example selection does not change request routing. Use a token group matching the example; channels in the same group may still use different formats.": "选择示例不会改变请求路由。请使用与示例匹配的令牌分组;同一分组中的渠道仍可能采用不同格式。",
"Example spec": "示例规格",
"Example:": "示例:",
"example.com&#10;blocked-site.com": "example.com&#10;blocked-site.com",
......@@ -2343,6 +2353,7 @@
"Finish Time": "完成时间",
"First API request": "首个 API 请求",
"First token": "首字",
"First-frame image URL. Use images for first and last frames; uploaded files are not supported by this video adapter.": "首帧图片 URL。首尾帧使用 images;此视频适配器不支持上传文件。",
"First/Last Frame to Video": "首尾生视频",
"Fix Abilities": "修复渠道一致性",
"Fix order": "修复顺序",
......@@ -2639,6 +2650,7 @@
"Ignore": "忽略",
"Ignored upstream models": "已忽略上游模型",
"Image": "图片",
"Image count is bounded by new-api; the upstream model may impose a lower limit. Seedream groups are converted automatically.": "图片数量受 new-api 上限约束,上游模型可能有更低限制。Seedream 组图参数会自动转换。",
"Image Generation": "图片生成",
"Image In": "图像输入",
"Image input": "图片输入",
......@@ -2648,6 +2660,7 @@
"Image output price": "图像输出价格",
"Image Preview": "图片预览",
"Image ratio": "图片倍率",
"Image reference": "图片参考",
"Image to Video": "图生视频",
"Image Tokens": "图像 Token",
"Imagine the pricing table has three groups: default (ratio 1.0), premium (ratio 0.5), and vip (ratio 0.8). Users whose account is in the vip group get user-level perks, and premium is a cheaper channel pool that users can pick for their tokens.": "假设定价分组表里有三个分组:default(倍率 1.0)、premium(倍率 0.5)、vip(倍率 0.8)。账号在 vip 分组的用户享受用户级待遇,premium 则是一个更便宜的渠道池,用户建令牌时可以选它。",
......@@ -2656,6 +2669,7 @@
"Import from URL": "从 URL 导入",
"Import mappings from an Excel workbook.": "从 Excel 工作簿导入模型映射。",
"Import model mappings": "导入模型映射",
"Import model metadata and optional pricing from an Excel workbook.": "从 Excel 工作簿导入模型信息及可选定价。",
"Import model metadata from an Excel workbook.": "从 Excel 工作簿导入模型元数据。",
"Import model prices from an Excel workbook.": "从 Excel 工作簿导入模型定价。",
"Import model pricing": "导入模型定价",
......@@ -2664,6 +2678,7 @@
"Important": "重要",
"Imported mappings replace existing mappings with the same model name.": "导入的映射会替换同名模型的现有映射。",
"Importing metadata does not add channels, enable model access, or configure prices.": "导入元信息不会添加渠道、开放模型访问或配置价格。",
"Importing replaces existing pricing for matching models. Expression billing clears fixed prices and ratios; other billing modes clear expressions. Prices and expression coefficients are imported as-is without currency conversion.": "导入会替换匹配模型的现有价格。表达式计费会清除固定价格和倍率,其他计费方式会清除表达式。价格及表达式系数按原值导入,不进行汇率换算。",
"Importing replaces the billing mode for matching models: per-token billing clears fixed prices, while per-request billing clears ratio settings.": "导入会覆盖匹配模型的计费方式:按量计费会清除固定价格,按次计费会清除倍率设置。",
"Importing...": "正在导入...",
"In BotFather, open Login Widget, register this callback URL, and copy the Client ID and Client Secret. Existing Telegram bindings will continue to work after configuration.": "在 BotFather 中打开 Login Widget,登记此回调地址并复制 Client ID 和 Client Secret。完成配置后,现有 Telegram 绑定可继续使用。",
......@@ -2674,6 +2689,7 @@
"incident": "次故障",
"incidents": "次故障",
"Incidents": "事件",
"Include document text in the ranked results": "在排序结果中包含文档文本",
"Include Group": "包含分组",
"Include Model": "包含模型",
"Include name": "包含名称",
......@@ -2707,6 +2723,7 @@
"Initializing…": "正在初始化…",
"Inpaint": "局部重绘",
"Input": "输入",
"Input image URL or array of image URLs for image editing.": "用于图片编辑的输入图片 URL 或 URL 数组。",
"Input mode": "输入模式",
"Input price": "输入价格",
"Input price is required before saving dependent prices.": "保存依赖价格前必须先填写输入价格。",
......@@ -2866,6 +2883,7 @@
"Learn more": "了解更多",
"Learn more:": "了解更多:",
"Leave": "离开",
"Leave all price cells blank to keep pricing unchanged. Pricing requires a super administrator and exact model names. Existing models and their prices are skipped unless overwrite is enabled. Prices use USD per million tokens, or USD per request for fixed_price; no currency conversion is applied.": "价格单元格全部留空则保留原定价。导入定价需要超级管理员权限,且仅支持精确模型名称。未启用覆盖时,跳过已有模型及其价格。价格单位为美元/百万 token,fixed_price 为美元/次,不进行汇率换算。",
"Leave blank to keep the existing credential": "留空以保留现有凭证",
"Leave blank to keep the existing key": "留空以保留现有密钥",
"Leave blank unless rotating the secret": "除非正在轮换密钥,否则留空",
......@@ -3046,8 +3064,10 @@
"Maximum custom groups per token": "每个令牌的最大自定义分组数",
"Maximum input window": "最大输入窗口",
"Maximum number of channels tested at the same time (1-32)": "同时测试的最大渠道数(1-32)",
"Maximum number of ranked results to return": "最多返回的排序结果数",
"Maximum number of tokens each user can create. Default 1000. Setting too large may affect performance.": "每个用户可创建的最大令牌数量。默认 1000。设置过大可能会影响性能。",
"Maximum number of tokens in the response": "响应中最大 token 数",
"Maximum output tokens; the model context and gateway limits also apply.": "最大输出 Token 数;同时受模型上下文和网关限制约束。",
"Maximum quota amount awarded for check-in": "签到奖励的最大额度",
"Maximum tokens including hidden reasoning tokens": "最大 token 数(含隐藏的推理 token)",
"Maximum tokens per response": "单次响应最大 token 数",
......@@ -3162,6 +3182,7 @@
"Model prices reset successfully": "模型价格重置成功",
"Model Pricing": "模型定价",
"Model pricing import completed: {{updated}} updated, {{perToken}} per-token, {{perRequest}} per-request, {{failed}} failed.": "模型定价导入完成:更新 {{updated}} 个,按量 {{perToken}} 个,按次 {{perRequest}} 个,失败 {{failed}} 个。",
"Model pricing import completed: {{updated}} updated, {{perToken}} per-token, {{perRequest}} per-request, {{tieredExpr}} expression, {{failed}} failed.": "模型价格导入完成:更新 {{updated}} 个,按量 {{perToken}} 个,按次 {{perRequest}} 个,表达式 {{tieredExpr}} 个,失败 {{failed}} 个。",
"Model pricing import failed": "模型定价导入失败",
"Model pricing is managed by a super administrator.": "模型定价由超级管理员管理。",
"Model pricing saved": "模型定价已保存",
......@@ -3256,6 +3277,7 @@
"Multi-user management with flexible permission allocation": "多用户管理,灵活分配权限",
"Multilingual translation and localisation": "多语种翻译与本地化",
"Multimodal": "多模态",
"Multimodal options: dimension, enable_fusion, output_type (dense), instruct and fps (0–1). parameters.dimension overrides dimensions.": "多模态选项:dimension、enable_fusion、output_type(dense)、instruct 和 fps(0–1)。parameters.dimension 优先于 dimensions。",
"Multiplier": "倍率",
"Multiplier applied when": "倍率应用于当",
"Multiplier applied when {{userGroup}} uses {{targetGroup}}": "当{{userGroup}}使用{{targetGroup}}时应用的倍率",
......@@ -3290,6 +3312,8 @@
"Native format": "原生格式",
"Native forwarding": "原生转发",
"Native Gemini routes plus OpenAI Chat and Responses compatibility forwarding.": "Gemini 原生转发,并兼容 OpenAI Chat 和 Responses 转换。",
"Native image parameters override top-level size and watermark; include size, n and seed here when needed.": "原生图像 parameters 会覆盖顶层 size 和 watermark;需要时在此设置 size、n 和 seed。",
"Native input.messages overrides the prompt/image conversion.": "原生 input.messages 会覆盖 prompt/image 的自动转换。",
"Native OpenAI routes plus optional Claude and Gemini compatibility routes.": "OpenAI 原生转发,并提供可选的 Claude 和 Gemini 兼容转换。",
"Native routes": "原生路由",
"Need a redemption code?": "需要兑换码?",
......@@ -3389,6 +3413,7 @@
"No encryption": "无加密",
"No endpoints configured. Switch to JSON mode or add rows to define endpoints.": "未配置端点。切换到 JSON 模式或添加行来定义端点。",
"No endpoints inferred from channels": "未从渠道推导出端点",
"No example is available for this endpoint.": "此端点暂无调用示例。",
"No extra domains declared": "未声明额外域名",
"No FAQ entries available": "暂无 FAQ 条目",
"No FAQ entries yet. Click \"Add FAQ\" to create one.": "暂无常见问题条目。点击“添加常见问题”来创建一个。",
......@@ -3570,6 +3595,7 @@
"Notification Method": "通知方式",
"Notifications": "通知",
"Now a user whose user group is vip creates tokens with different groups and makes one call with each:": "现在,一个用户分组为 vip 的用户创建了不同分组的令牌,各调用一次:",
"Nucleus sampling probability": "核采样概率",
"Nucleus sampling probability mass": "核采样累计概率",
"Number": "数值",
"Number of codes to create": "要创建的代码数量",
......@@ -3718,6 +3744,7 @@
"Optional note describing this version": "可选,用于描述该版本的备注",
"Optional notes about this channel": "关于此渠道的可选备注",
"Optional notes about when to use this group": "关于何时使用此分组的可选说明",
"Optional ranking instruction": "可选的排序指令",
"Optional ratio used when upstream cache hits occur.": "上游缓存命中时使用的可选比率。",
"Optional request-rule multiplier expression. Leave empty when no request rule applies.": "可选的请求规则倍率表达式。无请求规则时留空。",
"Optional rule description": "可选规则说明",
......@@ -3746,8 +3773,11 @@
"Outage": "中断",
"Output": "输出",
"Output aspect ratio": "输出宽高比",
"Output aspect ratio. H3 frame inputs force adaptive; text-only input requires an explicit ratio.": "输出宽高比。H3 首尾帧输入强制使用 adaptive;纯文本输入需明确比例。",
"Output image format": "输出图片格式",
"Output image size": "输出图像尺寸",
"Output price": "输出价格",
"Output resolution": "输出分辨率",
"Output token price for generated tokens.": "生成内容的输出 token 价格。",
"Output tokens": "输出 token",
"Output Tokens": "输出 Token",
......@@ -4126,6 +4156,7 @@
"Pricing group example": "定价分组示例",
"Pricing groups": "定价分组",
"Pricing import requirements": "定价导入要求",
"Pricing import: {{updated}} updated, {{failed}} failed.": "定价导入:更新 {{updated}} 项,失败 {{failed}} 项。",
"Pricing mode": "定价模式",
"Pricing must be a JSON object": "定价配置必须是 JSON 对象",
"Pricing Ratios": "定价比例",
......@@ -4176,6 +4207,7 @@
"Provider created successfully": "提供商创建成功",
"Provider deleted successfully": "提供商删除成功",
"Provider Name": "提供商名称",
"Provider options are model-specific. Top-level seconds and resolution take precedence.": "上游选项因模型而异。顶层 seconds 和 resolution 优先。",
"Provider type (OpenAI, Anthropic, etc.)": "提供商类型 (OpenAI、Anthropic 等)",
"Provider updated successfully": "提供商更新成功",
"Provider-specific endpoint, account, and compatibility settings.": "配置供应商专属的端点、账户和兼容性选项。",
......@@ -4237,6 +4269,7 @@
"Quota update failed": "额度更新失败",
"Quota Warning Threshold": "配额警告阈值",
"Quota:": "Quota:",
"Qwen/Wan images and Seedream 5.0 pro require stream=false in this adapter.": "此适配器中 Qwen/万相图像和 Seedream 5.0 pro 必须使用 stream=false。",
"Radius": "圆角",
"Random": "随机",
"Randomly select a key from the pool for each request": "每次请求从池中随机选择一个密钥",
......@@ -4408,6 +4441,7 @@
"Replace": "替换",
"Replace all existing keys": "替换所有现有密钥",
"Replace channel models": "覆盖渠道模型",
"Replace example media URLs with accessible media supported by the model.": "请将示例素材 URL 替换为可访问且符合模型要求的媒体地址。",
"Replace forwarding routes?": "替换转发路由?",
"Replace mode: Will completely replace all existing keys": "替换模式:将完全替换所有现有键",
"Replace With": "替换为",
......@@ -4625,6 +4659,7 @@
"s": "秒",
"Safety Settings": "安全设置",
"Same as Local": "与本地相同",
"Sampling temperature": "采样温度",
"Sampling temperature; lower is more deterministic": "采样温度;越低越稳定",
"Sandbox": "沙盘",
"Sandbox mode": "沙盒模式",
......@@ -4709,6 +4744,7 @@
"Search payment type keys...": "搜索支付处理标识...",
"Search payment types...": "搜索支付类型...",
"Search products...": "搜索产品...",
"Search query used to rank documents": "用于文档排序的查询文本",
"Search rules...": "搜索规则…",
"Search tags...": "搜索标签...",
"Search the public web at inference time": "推理时检索公开互联网",
......@@ -5291,6 +5327,7 @@
"The exact model identifier as used in API requests.": "API 请求中使用的确切模型标识符。",
"The Excel workbook must contain a worksheet.": "Excel 工作簿必须包含一个工作表。",
"The Excel worksheet must contain the headers “模型名称” and “映射模型”.": "Excel 工作表必须包含“模型名称”和“映射模型”表头。",
"The first row must contain headers, and model_name is required. For expression billing, provide billing_expr and leave all price columns blank. Use len for input-length tiers. Otherwise provide fixed_price or input_price; optional columns are completion_price, cache_price, create_cache_price, image_price, audio_input_price, and audio_output_price.": "首行必须为表头,model_name 为必填列。表达式计费请填写 billing_expr,并将所有价格列留空;按输入长度分档使用 len。其他计费方式填写 fixed_price 或 input_price;可选列包括 completion_price、cache_price、create_cache_price、image_price、audio_input_price 和 audio_output_price。",
"The first row must contain headers, and model_name is required. For per-request billing, provide fixed_price. Otherwise input_price is required; optional columns are completion_price, cache_price, create_cache_price, image_price, audio_input_price, and audio_output_price.": "第一行必须是表头,且 model_name 为必填列。按次计费请填写 fixed_price;否则必须填写 input_price。可选列:completion_price、cache_price、create_cache_price、image_price、audio_input_price、audio_output_price。",
"The first row must contain headers, and model_name is required. Optional columns: description, icon, tags, vendor_name, endpoints, status, sync_official, name_rule.": "第一行必须是表头,且 model_name 为必填列。可选列:description、icon、tags、vendor_name、endpoints、status、sync_official、name_rule。",
"The first row must contain the headers “模型名称” and “映射模型”. Both columns are required.": "第一行必须包含“模型名称”和“映射模型”表头,两列均为必填。",
......@@ -5337,6 +5374,7 @@
"Theme Settings": "主题设置",
"There are both add and remove models pending, but you only selected one type. Confirm submitting only the selected items?": "当前有新增和删除两类待处理模型,但您只勾选了其中一类。确认仅提交已勾选的部分吗?",
"There is a rule for vip billed as premium → use its ratio 0.3": "存在「vip 按 premium 计费」的规则 → 用规则里的 0.3",
"These examples call new-api with a new-api token. CTyun AppKeys and regional upstream URLs belong in channel settings. Select a token group routed to CTyun; switching examples does not change routing.": "示例使用 new-api 令牌调用本站。天翼云 AppKey 和区域上游地址应配置在渠道中。请选择路由到天翼云的令牌分组;切换示例不会改变实际路由。",
"These models are still in your selection but were not returned by the upstream listing. Entries that are only model_mapping source aliases are omitted. Toggle to adjust before saving.": "这些模型仍然在您的勾选列表中,但上游已不再返回该名称;仅作为 model_mapping 来源键而不会出现在 upstream 列表的别名已从本视图排除,请在保存前调整勾选。",
"These toggles affect whether certain request fields are passed through to the upstream provider.": "这些开关控制某些请求字段是否透传到上游服务。",
"These values come from the source index and are shown for review only. The gateway admits the plugin based on the metadata compiled from its source.": "这些值来自源索引,仅供审查参考。网关的准入判定只依据从源码编译出的真实元数据。",
......@@ -5819,6 +5857,7 @@
"Use 8–128 characters.": "使用 8–128 个字符。",
"Use a compatible browser or device with biometric authentication or a security key to register a Passkey.": "请使用支持生物识别认证或安全密钥的兼容浏览器或设备来注册通行密钥。",
"Use a different stable value for each instance, then restart the service.": "每个实例使用不同且稳定的值,然后重启服务。",
"Use a model-supported image size, such as 2K for Seedream.": "使用模型支持的图片尺寸,例如 Seedream 的 2K。",
"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 authenticator code": "使用验证器代码",
"Use backup code": "使用备用代码",
......@@ -5829,6 +5868,7 @@
"Use exact model names such as gpt-4o, or regex rules prefixed with re: such as re:^gemini-.": "可以填写 gpt-4o 这类精确模型名,也可以填写 re:^gemini- 这类以 re: 开头的正则规则。",
"Use expression pricing when a dependent price is non-zero and its base price is zero.": "基础价格为零而关联价格非零时,请使用表达式定价。",
"Use external tools to extend capabilities": "通过外部工具扩展能力",
"Use input.contents; each item contains exactly one nonempty text, image or video string.": "使用 input.contents;每项只能包含一个非空的 text、image 或 video 字符串。",
"Use one available reset credit for this channel. The reset request is sent only after confirmation.": "将为当前渠道使用 1 次可用重置次数。只有确认后才会发送重置请求。",
"Use one available reset credit to refresh the current Codex usage windows.": "使用 1 次可用重置次数,刷新当前 Codex 用量窗口。",
"Use our unified OpenAI-compatible endpoint in your applications": "在应用中使用我们兼容 OpenAI 的统一接口",
......@@ -5839,9 +5879,11 @@
"Use sidebar shortcut": "使用侧边栏快捷方式",
"Use the full-width table to scan prices, then select a row to edit it here.": "先在表格中快速浏览价格,然后选择一行在这里编辑。",
"Use the group set on the token. If the token has no group, use the user group. The auto group tries the auto assignment order from top to bottom.": "使用令牌上设置的分组;令牌未设置分组时,使用用户分组。auto 分组会按自动分组顺序从上到下尝试。",
"Use the model name shown here; new-api applies the channel model mapping.": "使用此处显示的模型名;new-api 会应用渠道模型映射。",
"Use the pricing group table to manage the ratio and whether the group appears in the token creation dropdown.": "使用定价分组表管理倍率,以及该分组是否出现在创建令牌的下拉框中。",
"Use this callback URL pattern when registering a custom OAuth provider.": "注册自定义 OAuth 提供商时使用此回调 URL 格式。",
"Use this token for API authentication": "使用此令牌进行 API 身份验证",
"Use WIDTHxHEIGHT (512–2048 per side); Wan also accepts 1K, 2K and 4K. Native parameters.size uses WIDTH*HEIGHT.": "使用 宽x高(每边 512–2048);万相也支持 1K、2K、4K。原生 parameters.size 使用 宽*高。",
"Use your Passkey": "使用您的通行密钥",
"used": "已使用",
"Used": "已使用",
......@@ -5977,7 +6019,9 @@
"Vertex AI Key Format": "Vertex AI 密钥格式",
"Vertex AI service account key must be valid JSON": "Vertex AI 服务账号密钥必须是有效 JSON",
"Video": "视频",
"Video duration; new-api converts seconds to the upstream duration field.": "视频时长;new-api 将 seconds 转换为上游 duration 字段。",
"Video length in seconds": "视频时长(秒)",
"Video reference": "视频参考",
"Video Remix": "视频 Remix",
"Vidu": "Vidu",
"View": "查看",
......
......@@ -19,6 +19,7 @@ For commercial licensing, please contact support@quantumnous.com
// Static translation keys that don't get picked up by the t('...') regex.
// These cover dynamic labels (e.g. constants, configs) that are passed into t at runtime.
export const STATIC_I18N_KEYS = [
'CTyun',
'Account deletion',
// Model management and metadata synchronization
'No matching channels',
......
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