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()
......
......@@ -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 (
"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' },
],
},
})
})
/*
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
}
......@@ -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
......
......@@ -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