feat(billing): 火山方舟视频计费 - 支持真实 Token 预授权与结算
Refs #228 Co-authored-by: MasterChiefCN <49179444+MasterChiefCN@users.noreply.github.com>
This commit is contained in:
@@ -387,7 +387,9 @@ func (r *Repository) BillingUsage(orderID string) (*BillingUsage, error) {
|
||||
|
||||
func billingUsage(db *gorm.DB, orderID string) (*BillingUsage, error) {
|
||||
var log model.ApiCallLog
|
||||
err := db.Where("billing_order_id = ? AND billable = ? AND status = ? AND usage_available = ?", orderID, true, model.ApiCallStatusSucceeded, true).
|
||||
// 异步视频的真实 usage 由非计费的轮询请求返回;订单本身已经限定了归属,
|
||||
// 结算应读取同订单最新的成功 usage,而不是只看创建请求。
|
||||
err := db.Where("billing_order_id = ? AND status = ? AND usage_available = ?", orderID, model.ApiCallStatusSucceeded, true).
|
||||
Order("created_at desc").First(&log).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, ErrBillingUsageUnavailable
|
||||
@@ -615,6 +617,9 @@ func tokenUsageAmount(order model.BillingOrder, usage *BillingUsage) (int64, err
|
||||
if usage == nil {
|
||||
return 0, ErrBillingUsageUnavailable
|
||||
}
|
||||
if order.Capability == "video" && usage.OutputTokens <= 0 {
|
||||
return 0, ErrBillingUsageUnavailable
|
||||
}
|
||||
input := usage.InputTokens - usage.CachedTokens
|
||||
if input < 0 {
|
||||
input = 0
|
||||
|
||||
101
backend/internal/repository/finance_token_test.go
Normal file
101
backend/internal/repository/finance_token_test.go
Normal file
@@ -0,0 +1,101 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"infinite-canvas/backend/internal/model"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestTokenUsageAmountSettlesArkVideoCompletionTokens(t *testing.T) {
|
||||
amount, err := tokenUsageAmount(model.BillingOrder{
|
||||
Capability: "video",
|
||||
OutputTokenPriceMicrocredits: 16_000_000,
|
||||
MultiplierBasisPoints: 10_000,
|
||||
}, &BillingUsage{OutputTokens: 108900})
|
||||
if err != nil {
|
||||
t.Fatalf("tokenUsageAmount() error = %v", err)
|
||||
}
|
||||
if amount != 1_742_400 {
|
||||
t.Fatalf("tokenUsageAmount() = %d", amount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBillingUsageReadsAsyncVideoPollUsage(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open("file:finance-token-poll?mode=memory&cache=shared"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.ApiCallLog{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(&model.ApiCallLog{
|
||||
ID: "poll-log-1", BillingOrderID: "order-1", RequestKind: "poll", Billable: false,
|
||||
Status: model.ApiCallStatusSucceeded, UsageAvailable: true, OutputTokens: 108900,
|
||||
}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
usage, err := billingUsage(db, "order-1")
|
||||
if err != nil {
|
||||
t.Fatalf("billingUsage() error = %v", err)
|
||||
}
|
||||
if usage.OutputTokens != 108900 {
|
||||
t.Fatalf("billingUsage() = %#v", usage)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenUsageAmountRejectsVideoWithoutOutputUsage(t *testing.T) {
|
||||
_, err := tokenUsageAmount(model.BillingOrder{Capability: "video", OutputTokenPriceMicrocredits: 16_000_000, MultiplierBasisPoints: 10_000}, &BillingUsage{})
|
||||
if !errors.Is(err, ErrBillingUsageUnavailable) {
|
||||
t.Fatalf("tokenUsageAmount() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSettleArkVideoTokenOrderFromPollUsage(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open("file:finance-token-settle?mode=memory&cache=shared"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.CreditAccount{}, &model.BillingOrder{}, &model.ApiCallLog{}, &model.CreditLedgerEntry{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const reserved = int64(1_916_640)
|
||||
if err := db.Create(&model.CreditAccount{UserID: "user-1", ReservedMicrocredits: reserved}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(&model.BillingOrder{
|
||||
ID: "order-1", UserID: "user-1", IdempotencyKey: "task:task-1", Capability: "video", BillingMode: "token",
|
||||
AmountMicrocredits: reserved, ReservedAmountMicrocredits: reserved, OutputTokenPriceMicrocredits: 16_000_000,
|
||||
MultiplierBasisPoints: 10_000, Status: model.BillingStatusRunning,
|
||||
}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(&model.ApiCallLog{
|
||||
ID: "poll-log-1", BillingOrderID: "order-1", RequestKind: "poll", Billable: false,
|
||||
Status: model.ApiCallStatusSucceeded, UsageAvailable: true, OutputTokens: 108900,
|
||||
}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
repo := &Repository{db: db}
|
||||
if err := repo.SettleBillingOrder("order-1", "ark-task-1"); err != nil {
|
||||
t.Fatalf("SettleBillingOrder() error = %v", err)
|
||||
}
|
||||
var order model.BillingOrder
|
||||
if err := db.First(&order, "id = ?", "order-1").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if order.Status != model.BillingStatusSettled || order.ActualAmountMicrocredits != 1_742_400 || order.RefundedAmountMicrocredits != 174_240 {
|
||||
t.Fatalf("settled order = %#v", order)
|
||||
}
|
||||
var account model.CreditAccount
|
||||
if err := db.First(&account, "user_id = ?", "user-1").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if account.AvailableMicrocredits != 174_240 || account.ReservedMicrocredits != 0 {
|
||||
t.Fatalf("settled account = %#v", account)
|
||||
}
|
||||
}
|
||||
@@ -940,6 +940,14 @@ func (s *Service) EnrichAPICallLog(log *model.ApiCallLog, responseBody []byte) {
|
||||
log.UsageAvailable = true
|
||||
log.InputTokens = firstInt64(usage, "input_tokens", "prompt_tokens")
|
||||
log.OutputTokens = firstInt64(usage, "output_tokens", "completion_tokens")
|
||||
// 火山方舟视频的输入 Token 恒为 0;查询任务以 completion_tokens 为实际用量,
|
||||
// 兼容只返回 total_tokens 的同协议中转实现。
|
||||
if log.Capability == "video" && strings.Contains(log.Path, "/contents/generations/tasks") && log.OutputTokens == 0 {
|
||||
log.OutputTokens = firstInt64(usage, "total_tokens")
|
||||
}
|
||||
if log.Capability == "video" && strings.Contains(log.Path, "/contents/generations/tasks") && log.OutputTokens <= 0 {
|
||||
log.UsageAvailable = false
|
||||
}
|
||||
if details, ok := usage["input_tokens_details"].(map[string]any); ok {
|
||||
log.CachedTokens = firstInt64(details, "cached_tokens")
|
||||
}
|
||||
|
||||
@@ -152,8 +152,8 @@ func (s *Service) SaveAdminChannelModel(actor *model.User, channelID string, id
|
||||
if billingMode == "per_second" && capability != "video" {
|
||||
return nil, BadAuthRequest("只有视频模型可以按秒计费")
|
||||
}
|
||||
if billingMode == "token" && capability != "text" {
|
||||
return nil, BadAuthRequest("只有文本模型可以按 Token 计费")
|
||||
if billingMode == "token" && !supportsTokenBilling(capability, protocol) {
|
||||
return nil, BadAuthRequest("Token 计费仅支持文本模型和火山方舟视频协议")
|
||||
}
|
||||
if req.UnitPriceMicrocredits < 0 || req.InputTokenPriceMicrocredits < 0 || req.OutputTokenPriceMicrocredits < 0 || req.CachedTokenPriceMicrocredits < 0 {
|
||||
return nil, BadAuthRequest("模型积分价格不能小于 0")
|
||||
@@ -161,6 +161,9 @@ func (s *Service) SaveAdminChannelModel(actor *model.User, channelID string, id
|
||||
if billingMode == "token" && req.InputTokenPriceMicrocredits == 0 && req.OutputTokenPriceMicrocredits == 0 && req.CachedTokenPriceMicrocredits == 0 {
|
||||
return nil, BadAuthRequest("Token 计费至少需要配置一项价格")
|
||||
}
|
||||
if billingMode == "token" && capability == "video" && req.OutputTokenPriceMicrocredits == 0 {
|
||||
return nil, BadAuthRequest("火山方舟视频 Token 计费需要配置每百万视频 Token 价格")
|
||||
}
|
||||
const maxTokenPriceMicrocredits = int64(1_000_000) * CreditScale
|
||||
if req.InputTokenPriceMicrocredits > maxTokenPriceMicrocredits || req.OutputTokenPriceMicrocredits > maxTokenPriceMicrocredits || req.CachedTokenPriceMicrocredits > maxTokenPriceMicrocredits {
|
||||
return nil, BadAuthRequest("Token 每百万用量价格不能超过 1,000,000 积分")
|
||||
@@ -222,6 +225,10 @@ func (s *Service) SaveAdminChannelModel(actor *model.User, channelID string, id
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func supportsTokenBilling(capability string, protocol model.ChannelInterfaceType) bool {
|
||||
return capability == "text" || (capability == "video" && protocol == model.ChannelInterfaceVolcengineArkVideo)
|
||||
}
|
||||
|
||||
func (s *Service) TestAdminChannelModel(ctx context.Context, actor *model.User, channelID string, req ChannelModelRequest) (*AdminChannelModelTestResult, error) {
|
||||
if err := s.RequireAdmin(actor); err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -449,7 +449,7 @@ func (s *Service) taskBillingOrder(userID string, task *model.Task, input map[st
|
||||
capability = capabilityFromTaskType(task.Type)
|
||||
}
|
||||
scene := firstNonEmpty(strings.TrimSpace(task.Operation), task.Type)
|
||||
return s.newBillingOrder(userID, task.ID, "task:"+task.ID+":"+newID(), channelID, modelKey, capability, scene, billingQuantity(capability, config["videoSeconds"]), estimateTaskTokens(input))
|
||||
return s.newBillingOrder(userID, task.ID, "task:"+task.ID+":"+newID(), channelID, modelKey, capability, scene, billingQuantity(capability, config["videoSeconds"]), estimateTaskBillingTokens(input, capability))
|
||||
}
|
||||
|
||||
func (s *Service) ReserveProxyBilling(userID string, channelID string, modelKey string, capability string, scene string, idempotencyKey string, quantity int64) (*model.BillingOrder, error) {
|
||||
@@ -507,12 +507,15 @@ func (s *Service) newBillingOrder(userID string, taskID string, idempotencyKey s
|
||||
}
|
||||
quantity = requestedQuantity
|
||||
case "token":
|
||||
if item.Capability != "text" || capability != "text" {
|
||||
return nil, BadAuthRequest("Token 计费仅适用于文本生成")
|
||||
if !supportsTokenBilling(item.Capability, item.Protocol) || item.Capability != capability {
|
||||
return nil, BadAuthRequest("Token 计费仅支持文本生成和火山方舟视频生成")
|
||||
}
|
||||
if tokenEstimate.InputTokens <= 0 || tokenEstimate.OutputTokens <= 0 {
|
||||
if capability == "text" && (tokenEstimate.InputTokens <= 0 || tokenEstimate.OutputTokens <= 0) {
|
||||
return nil, BadAuthRequest("无法估算文本 Token 用量")
|
||||
}
|
||||
if capability == "video" && tokenEstimate.OutputTokens <= 0 {
|
||||
return nil, BadAuthRequest("无法估算火山方舟视频 Token 用量")
|
||||
}
|
||||
quantity = tokenEstimate.InputTokens + tokenEstimate.OutputTokens
|
||||
default:
|
||||
return nil, BadAuthRequest("当前模型计费方式暂不支持")
|
||||
@@ -549,6 +552,101 @@ func estimateTaskTokens(input map[string]any) tokenBillingEstimate {
|
||||
return tokenBillingEstimate{InputTokens: estimatedTokens(encoded), OutputTokens: maxOutputTokens(input)}
|
||||
}
|
||||
|
||||
func estimateTaskBillingTokens(input map[string]any, capability string) tokenBillingEstimate {
|
||||
if capability == "video" {
|
||||
return estimateArkVideoTokens(input)
|
||||
}
|
||||
return estimateTaskTokens(input)
|
||||
}
|
||||
|
||||
// 方舟视频成功后才返回真实 completion_tokens;创建任务前按官方像素帧公式预授权,
|
||||
// 并保留少量帧率/取整余量,实际结算时会按 usage 自动退回差额。
|
||||
func estimateArkVideoTokens(input map[string]any) tokenBillingEstimate {
|
||||
config, _ := input["config"].(map[string]any)
|
||||
if config == nil {
|
||||
return tokenBillingEstimate{}
|
||||
}
|
||||
durationSeconds, err := strconv.ParseInt(strings.TrimSpace(fmt.Sprint(config["videoSeconds"])), 10, 64)
|
||||
if err != nil || durationSeconds <= 0 {
|
||||
return tokenBillingEstimate{}
|
||||
}
|
||||
pixels := arkVideoOutputPixels(fmt.Sprint(config["vquality"]), fmt.Sprint(config["size"]), fmt.Sprint(config["model"]))
|
||||
if pixels <= 0 {
|
||||
return tokenBillingEstimate{}
|
||||
}
|
||||
|
||||
if durationSeconds > (1<<63-1)/1000 {
|
||||
return tokenBillingEstimate{}
|
||||
}
|
||||
totalDurationMillis := durationSeconds * 1000
|
||||
referenceCount := int64(0)
|
||||
if references, ok := input["referenceVideos"].([]any); ok && len(references) > 0 {
|
||||
referenceCount = int64(len(references))
|
||||
knownDurationMillis := int64(0)
|
||||
unknownDuration := false
|
||||
for _, raw := range references {
|
||||
media, _ := raw.(map[string]any)
|
||||
durationMillis := firstInt64(media, "durationMs")
|
||||
if durationMillis <= 0 {
|
||||
unknownDuration = true
|
||||
continue
|
||||
}
|
||||
knownDurationMillis = min(15_000, knownDurationMillis+min(durationMillis, int64(15_000)))
|
||||
}
|
||||
// 方舟参考视频总时长上限为 15 秒;缺少媒体元数据时按上限预留。
|
||||
if unknownDuration {
|
||||
knownDurationMillis = 15_000
|
||||
}
|
||||
totalDurationMillis += knownDurationMillis
|
||||
}
|
||||
frames := (totalDurationMillis*24+999)/1000 + 1 + referenceCount
|
||||
if pixels > (1<<63-1-1023)/frames {
|
||||
return tokenBillingEstimate{}
|
||||
}
|
||||
tokens := (pixels*frames + 1023) / 1024
|
||||
if tokens > (1<<63-1-99)/110 {
|
||||
return tokenBillingEstimate{}
|
||||
}
|
||||
return tokenBillingEstimate{OutputTokens: (tokens*110 + 99) / 100}
|
||||
}
|
||||
|
||||
func arkVideoOutputPixels(resolution string, ratio string, modelName string) int64 {
|
||||
resolution = normalizeSeedanceResolution(resolution, modelName)
|
||||
ratio = normalizeSeedanceRatio(ratio)
|
||||
pixelsByRatio := map[string]map[string]int64{
|
||||
"480p": {
|
||||
"16:9": 864 * 496, "4:3": 752 * 560, "1:1": 640 * 640,
|
||||
"3:4": 560 * 752, "9:16": 496 * 864, "21:9": 992 * 432,
|
||||
},
|
||||
"720p": {
|
||||
"16:9": 1280 * 720, "4:3": 1112 * 834, "1:1": 960 * 960,
|
||||
"3:4": 834 * 1112, "9:16": 720 * 1280, "21:9": 1470 * 630,
|
||||
},
|
||||
"1080p": {
|
||||
"16:9": 1920 * 1080, "4:3": 1664 * 1248, "1:1": 1440 * 1440,
|
||||
"3:4": 1248 * 1664, "9:16": 1080 * 1920, "21:9": 2206 * 946,
|
||||
},
|
||||
}
|
||||
values := pixelsByRatio[resolution]
|
||||
if resolution == "2160p" {
|
||||
values = make(map[string]int64, len(pixelsByRatio["1080p"]))
|
||||
for key, value := range pixelsByRatio["1080p"] {
|
||||
values[key] = value * 4
|
||||
}
|
||||
}
|
||||
if len(values) == 0 {
|
||||
return 0
|
||||
}
|
||||
if ratio != "adaptive" {
|
||||
return values[ratio]
|
||||
}
|
||||
var largest int64
|
||||
for _, value := range values {
|
||||
largest = max(largest, value)
|
||||
}
|
||||
return largest
|
||||
}
|
||||
|
||||
func estimateProxyTokens(body []byte) tokenBillingEstimate {
|
||||
var payload map[string]any
|
||||
_ = json.Unmarshal(body, &payload)
|
||||
@@ -581,7 +679,7 @@ func maxOutputTokens(payload map[string]any) int64 {
|
||||
|
||||
// Token 单价按每百万 Token 配置;预授权使用输入价估算缓存 Token,真实结算再按 usage 拆分。
|
||||
func tokenEstimateAmount(item *model.ChannelModel, estimate tokenBillingEstimate, multiplierBPS int64) (int64, error) {
|
||||
if item == nil || estimate.InputTokens <= 0 || estimate.OutputTokens <= 0 || multiplierBPS <= 0 {
|
||||
if item == nil || estimate.InputTokens < 0 || estimate.OutputTokens <= 0 || multiplierBPS <= 0 {
|
||||
return 0, errors.New("Token 计费参数无效")
|
||||
}
|
||||
inputAmount, ok := safeTokenProduct(estimate.InputTokens, item.InputTokenPriceMicrocredits)
|
||||
|
||||
85
backend/internal/service/finance_token_test.go
Normal file
85
backend/internal/service/finance_token_test.go
Normal file
@@ -0,0 +1,85 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"infinite-canvas/backend/internal/model"
|
||||
)
|
||||
|
||||
func TestSupportsTokenBillingForVolcengineArkVideo(t *testing.T) {
|
||||
if !supportsTokenBilling("text", model.ChannelInterfaceChatCompletion) {
|
||||
t.Fatal("text protocol should support Token billing")
|
||||
}
|
||||
if !supportsTokenBilling("video", model.ChannelInterfaceVolcengineArkVideo) {
|
||||
t.Fatal("Volcengine Ark video should support Token billing")
|
||||
}
|
||||
if supportsTokenBilling("video", model.ChannelInterfaceNewAPIVideo) {
|
||||
t.Fatal("video protocols without a final usage contract must not support Token billing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEstimateArkVideoTokensUsesPixelFrameEstimate(t *testing.T) {
|
||||
estimate := estimateArkVideoTokens(map[string]any{
|
||||
"config": map[string]any{
|
||||
"model": "doubao-seedance-1-5-pro", "videoSeconds": "5", "vquality": "720", "size": "16:9",
|
||||
},
|
||||
})
|
||||
// 1280*720*(5*24+1)/1024 = 108900,预授权再保留 10% 余量。
|
||||
if estimate.InputTokens != 0 || estimate.OutputTokens != 119790 {
|
||||
t.Fatalf("estimateArkVideoTokens() = %#v", estimate)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEstimateArkVideoTokensIncludesUnknownReferenceVideo(t *testing.T) {
|
||||
estimate := estimateArkVideoTokens(map[string]any{
|
||||
"config": map[string]any{
|
||||
"model": "doubao-seedance-2-0", "videoSeconds": "5", "vquality": "720p", "size": "16:9",
|
||||
},
|
||||
"referenceVideos": []any{map[string]any{"id": "video-1"}},
|
||||
})
|
||||
if estimate.OutputTokens != 477180 {
|
||||
t.Fatalf("estimateArkVideoTokens() = %#v", estimate)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEstimateArkVideoTokensCapsReferenceDuration(t *testing.T) {
|
||||
estimate := estimateArkVideoTokens(map[string]any{
|
||||
"config": map[string]any{
|
||||
"model": "doubao-seedance-2-0", "videoSeconds": "5", "vquality": "720p", "size": "16:9",
|
||||
},
|
||||
"referenceVideos": []any{map[string]any{"id": "video-1", "durationMs": int64(60_000)}},
|
||||
})
|
||||
if estimate.OutputTokens != 477180 {
|
||||
t.Fatalf("estimateArkVideoTokens() = %#v", estimate)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenEstimateAmountAllowsVideoOutputOnly(t *testing.T) {
|
||||
amount, err := tokenEstimateAmount(&model.ChannelModel{OutputTokenPriceMicrocredits: 16_000_000}, tokenBillingEstimate{OutputTokens: 119790}, 10_000)
|
||||
if err != nil {
|
||||
t.Fatalf("tokenEstimateAmount() error = %v", err)
|
||||
}
|
||||
if amount != 1_916_640 {
|
||||
t.Fatalf("tokenEstimateAmount() = %d", amount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrichAPICallLogReadsArkVideoUsage(t *testing.T) {
|
||||
log := &model.ApiCallLog{Capability: "video", Path: "/api/v3/contents/generations/tasks/cgt-test"}
|
||||
(&Service{}).EnrichAPICallLog(log, []byte(`{"id":"cgt-test","status":"succeeded","usage":{"completion_tokens":108900,"total_tokens":108900}}`))
|
||||
if !log.UsageAvailable || log.InputTokens != 0 || log.OutputTokens != 108900 {
|
||||
t.Fatalf("EnrichAPICallLog() = %#v", log)
|
||||
}
|
||||
|
||||
fallback := &model.ApiCallLog{Capability: "video", Path: "/api/v3/contents/generations/tasks/cgt-test"}
|
||||
(&Service{}).EnrichAPICallLog(fallback, []byte(`{"usage":{"total_tokens":35800}}`))
|
||||
if !fallback.UsageAvailable || fallback.OutputTokens != 35800 {
|
||||
t.Fatalf("EnrichAPICallLog() total_tokens fallback = %#v", fallback)
|
||||
}
|
||||
|
||||
missing := &model.ApiCallLog{Capability: "video", Path: "/api/v3/contents/generations/tasks/cgt-test"}
|
||||
(&Service{}).EnrichAPICallLog(missing, []byte(`{"usage":{}}`))
|
||||
if missing.UsageAvailable {
|
||||
t.Fatalf("EnrichAPICallLog() accepted empty Ark usage: %#v", missing)
|
||||
}
|
||||
}
|
||||
@@ -276,11 +276,16 @@ function formatDurationSummary(profile: NonNullable<ReturnType<typeof modelCapab
|
||||
return `${profile.duration.min || values[0]}-${profile.duration.max || values[values.length - 1]}s`;
|
||||
}
|
||||
|
||||
function modelMenuPrice(config: AiConfig, model: string): { value: number; unit: "次" | "秒" } | null | undefined {
|
||||
type ModelMenuPrice = { value: number; unit: "次" | "秒" | "百万 Token" };
|
||||
|
||||
function modelMenuPrice(config: AiConfig, model: string): ModelMenuPrice | null | undefined {
|
||||
if (!model) return undefined;
|
||||
const channel = resolveModelChannel(config, model);
|
||||
const cost = channel.modelCosts?.find((item) => item.model === modelOptionName(model));
|
||||
if (!cost) return channel.scope === "system" ? null : undefined;
|
||||
if (cost.billingMode === "token") {
|
||||
return { value: (cost.outputTokenPriceMicrocredits || 0) / 1_000_000, unit: "百万 Token" };
|
||||
}
|
||||
return { value: cost.unitPriceMicrocredits / 1_000_000, unit: cost.billingMode === "per_second" ? "秒" : "次" };
|
||||
}
|
||||
|
||||
@@ -292,7 +297,7 @@ function pickerModelOptionLabel(config: AiConfig, model: string, showConfiguredM
|
||||
return showConfiguredModelName ? `${configuredModelDisplayName(config, model)}(${resolveModelChannel(config, model).name})` : modelOptionLabel(config, model);
|
||||
}
|
||||
|
||||
function ModelPrice({ price, compact = false }: { price: { value: number; unit: "次" | "秒" } | null | undefined; compact?: boolean }) {
|
||||
function ModelPrice({ price, compact = false }: { price: ModelMenuPrice | null | undefined; compact?: boolean }) {
|
||||
if (price === undefined) return null;
|
||||
if (price === null) return compact ? null : <span className="shrink-0 text-[var(--fs-tiny)] text-foreground/40">未配置</span>;
|
||||
return (
|
||||
|
||||
@@ -88,6 +88,10 @@ export function modelProtocolCapability(value?: string) {
|
||||
return modelProtocolDefinition(value)?.capability;
|
||||
}
|
||||
|
||||
export function modelProtocolSupportsTokenBilling(capability?: string, protocol?: string) {
|
||||
return capability === "text" || (capability === "video" && protocol === "volcengine-ark-video");
|
||||
}
|
||||
|
||||
export function protocolForModelCatalog(endpointTypes: string[] = []): ModelProtocol | undefined {
|
||||
const normalized = new Set(endpointTypes.map((value) => value.trim().toLowerCase()));
|
||||
if (normalized.has("openai-chat") || normalized.has("chat-completion") || normalized.has("chat")) return "chat-completion";
|
||||
|
||||
@@ -8,7 +8,7 @@ import { ModelIcon } from "@/components/model-picker";
|
||||
import { ModelCapabilityEditor } from "@/components/model-capability-editor";
|
||||
import { CapabilityCardPicker, ProtocolCardPicker, type ModelCapabilityChoice } from "@/components/model-protocol-picker";
|
||||
import { defaultModelCapabilityConfig, type ModelCapabilityConfig } from "@/lib/model-capabilities";
|
||||
import { MODEL_PROTOCOLS, modelProtocolCapability, modelProtocolDefinition, modelProtocolLabel, type ModelProtocol } from "@/lib/model-protocols";
|
||||
import { MODEL_PROTOCOLS, modelProtocolCapability, modelProtocolDefinition, modelProtocolLabel, modelProtocolSupportsTokenBilling, type ModelProtocol } from "@/lib/model-protocols";
|
||||
import { createAdminChannelModel, deleteAdminChannelModel, fetchAdminChannelModels, listAdminChannelModels, testAdminChannelModel, updateAdminChannelModel, type ChannelModel } from "@/services/api/wallet";
|
||||
import type { ModelChannel } from "@/stores/use-config-store";
|
||||
import { AdminPageFrame } from "./admin-shell";
|
||||
@@ -46,6 +46,7 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
|
||||
const [form] = Form.useForm<FormValues>();
|
||||
const billingMode = Form.useWatch("billingMode", form) || "fixed_request";
|
||||
const modelCapability = Form.useWatch("capability", form);
|
||||
const modelProtocol = Form.useWatch("protocol", form);
|
||||
const modelKey = Form.useWatch("modelKey", form) || "";
|
||||
|
||||
const reload = async () => {
|
||||
@@ -164,11 +165,13 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
|
||||
if (changed.protocol && (modelCapability === "image" || modelCapability === "video")) {
|
||||
form.setFieldValue("capabilityConfig", defaultModelCapabilityConfig(changed.protocol, form.getFieldValue("modelKey")));
|
||||
}
|
||||
if (!changed.capability) return;
|
||||
const currentBillingMode = form.getFieldValue("billingMode") as ChannelModel["billingMode"] | undefined;
|
||||
if ((currentBillingMode === "per_second" && changed.capability !== "video") || (currentBillingMode === "token" && changed.capability !== "text")) {
|
||||
const currentCapability = form.getFieldValue("capability") as EditableCapability | undefined;
|
||||
const currentProtocol = form.getFieldValue("protocol") as ModelProtocol | undefined;
|
||||
if ((currentBillingMode === "per_second" && currentCapability !== "video") || (currentBillingMode === "token" && !modelProtocolSupportsTokenBilling(currentCapability, currentProtocol))) {
|
||||
form.setFieldValue("billingMode", "fixed_request");
|
||||
}
|
||||
if (!changed.capability) return;
|
||||
const current = form.getFieldValue("protocol") as ModelProtocol | undefined;
|
||||
if (modelProtocolCapability(current) !== changed.capability) {
|
||||
const nextProtocol = MODEL_PROTOCOLS.find((item) => item.capability === changed.capability)?.value;
|
||||
@@ -258,20 +261,29 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
|
||||
</Form.Item>
|
||||
{modelCapability === "image" || modelCapability === "video" ? <Form.Item name="capabilityConfig" rules={[{ required: true, message: `请配置${modelCapability === "image" ? "图片" : "视频"}能力参数` }]}><ModelCapabilityEditor capability={modelCapability} model={modelKey} protocol={form.getFieldValue("protocol")} /></Form.Item> : null}
|
||||
<Form.Item name="billingMode" label="计费方式" rules={[{ required: true }]}>
|
||||
<Segmented block options={[{ label: "按次计费", value: "fixed_request" }, { label: "按秒计费", value: "per_second", disabled: modelCapability !== "video" }, { label: "Token 计费", value: "token", disabled: modelCapability !== "text" }]} />
|
||||
<Segmented block options={[{ label: "按次计费", value: "fixed_request" }, { label: "按秒计费", value: "per_second", disabled: modelCapability !== "video" }, { label: "Token 计费", value: "token", disabled: !modelProtocolSupportsTokenBilling(modelCapability, modelProtocol) }]} />
|
||||
</Form.Item>
|
||||
{billingMode === "token" ? (
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-3">
|
||||
<Form.Item name="inputTokenPrice" label="输入 / 百万 Token" rules={[{ required: true, message: "请输入输入价格" }]}>
|
||||
<InputNumber style={{ width: "100%" }} min={0} max={1_000_000} precision={6} step={0.1} />
|
||||
</Form.Item>
|
||||
<Form.Item name="outputTokenPrice" label="输出 / 百万 Token" rules={[{ required: true, message: "请输入输出价格" }]}>
|
||||
<InputNumber style={{ width: "100%" }} min={0} max={1_000_000} precision={6} step={0.1} />
|
||||
</Form.Item>
|
||||
<Form.Item name="cachedTokenPrice" label="缓存 / 百万 Token" rules={[{ required: true, message: "请输入缓存价格" }]}>
|
||||
<InputNumber style={{ width: "100%" }} min={0} max={1_000_000} precision={6} step={0.1} />
|
||||
</Form.Item>
|
||||
</div>
|
||||
modelCapability === "video" ? (
|
||||
<div>
|
||||
<Form.Item name="outputTokenPrice" label="视频 / 百万 Token" rules={[{ required: true, message: "请输入视频 Token 价格" }]}>
|
||||
<InputNumber style={{ width: "100%" }} min={0.000001} max={1_000_000} precision={6} step={0.1} />
|
||||
</Form.Item>
|
||||
<div className="mb-4 text-xs text-foreground/45">仅火山方舟视频协议可用;成功后按任务查询响应的 usage.completion_tokens 结算。</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-3">
|
||||
<Form.Item name="inputTokenPrice" label="输入 / 百万 Token" rules={[{ required: true, message: "请输入输入价格" }]}>
|
||||
<InputNumber style={{ width: "100%" }} min={0} max={1_000_000} precision={6} step={0.1} />
|
||||
</Form.Item>
|
||||
<Form.Item name="outputTokenPrice" label="输出 / 百万 Token" rules={[{ required: true, message: "请输入输出价格" }]}>
|
||||
<InputNumber style={{ width: "100%" }} min={0} max={1_000_000} precision={6} step={0.1} />
|
||||
</Form.Item>
|
||||
<Form.Item name="cachedTokenPrice" label="缓存 / 百万 Token" rules={[{ required: true, message: "请输入缓存价格" }]}>
|
||||
<InputNumber style={{ width: "100%" }} min={0} max={1_000_000} precision={6} step={0.1} />
|
||||
</Form.Item>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<Form.Item name="unitPrice" label={billingMode === "per_second" ? "每秒消耗积分" : "每次消耗积分"} rules={[{ required: true, message: "请输入积分价格" }]}>
|
||||
<InputNumber style={{ width: "100%" }} min={0} max={1_000_000} precision={6} step={0.1} />
|
||||
@@ -303,9 +315,7 @@ function billingSummary(item: ChannelModel) {
|
||||
}
|
||||
return (
|
||||
<div className="text-xs leading-5">
|
||||
<div>输入 {formatCredits(item.inputTokenPriceMicrocredits)} / 百万</div>
|
||||
<div>输出 {formatCredits(item.outputTokenPriceMicrocredits)} / 百万</div>
|
||||
<div>缓存 {formatCredits(item.cachedTokenPriceMicrocredits)} / 百万</div>
|
||||
{item.capability === "video" ? <div>视频 {formatCredits(item.outputTokenPriceMicrocredits)} / 百万</div> : <><div>输入 {formatCredits(item.inputTokenPriceMicrocredits)} / 百万</div><div>输出 {formatCredits(item.outputTokenPriceMicrocredits)} / 百万</div><div>缓存 {formatCredits(item.cachedTokenPriceMicrocredits)} / 百万</div></>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { testChannelModelConnection } from "@/lib/model-connection-test";
|
||||
import { ModelCapabilityEditor } from "@/components/model-capability-editor";
|
||||
import { CapabilityCardPicker, ProtocolCardPicker } from "@/components/model-protocol-picker";
|
||||
import { defaultModelCapabilityConfig } from "@/lib/model-capabilities";
|
||||
import { MODEL_PROTOCOLS, modelProtocolCapability, modelProtocolDefinition, type ModelProtocol } from "@/lib/model-protocols";
|
||||
import { MODEL_PROTOCOLS, modelProtocolCapability, modelProtocolDefinition, modelProtocolSupportsTokenBilling, type ModelProtocol } from "@/lib/model-protocols";
|
||||
import { modelMatchesCapability, modelOptionName, type ModelChannel } from "@/stores/use-config-store";
|
||||
|
||||
type ModelCost = NonNullable<ModelChannel["modelCosts"]>[number];
|
||||
@@ -46,6 +46,7 @@ export function ChannelModelSettings({ channel, onChange }: { channel: ModelChan
|
||||
const activeProtocol = activeModel ? activeModelCost?.protocol || defaultProtocolForModel(channel, activeModel) : undefined;
|
||||
const activeCapability = activeModel ? activeModelCost?.capability || modelProtocolCapability(activeProtocol) || "text" : undefined;
|
||||
const activeBillingMode = activeModelCost?.billingMode || "fixed_request";
|
||||
const activeTokenBillingSupported = modelProtocolSupportsTokenBilling(activeCapability, activeProtocol);
|
||||
|
||||
return (
|
||||
<div className="mt-4">
|
||||
@@ -124,7 +125,7 @@ export function ChannelModelSettings({ channel, onChange }: { channel: ModelChan
|
||||
updateCost(activeModel, {
|
||||
protocol: nextProtocol,
|
||||
capability: nextCapability,
|
||||
billingMode: nextCapability === "video" ? activeBillingMode : "fixed_request",
|
||||
billingMode: activeBillingMode === "per_second" && nextCapability === "video" ? "per_second" : activeBillingMode === "token" && modelProtocolSupportsTokenBilling(nextCapability, nextProtocol) ? "token" : "fixed_request",
|
||||
capabilityConfig: nextCapability === "image" || nextCapability === "video" ? defaultModelCapabilityConfig(nextProtocol, activeModel) : undefined,
|
||||
});
|
||||
}}
|
||||
@@ -135,7 +136,7 @@ export function ChannelModelSettings({ channel, onChange }: { channel: ModelChan
|
||||
<ProtocolCardPicker
|
||||
capability={activeCapability}
|
||||
value={activeProtocol}
|
||||
onChange={(nextProtocol) => updateCost(activeModel, { protocol: nextProtocol, capabilityConfig: activeCapability === "image" || activeCapability === "video" ? defaultModelCapabilityConfig(nextProtocol, activeModel) : undefined })}
|
||||
onChange={(nextProtocol) => updateCost(activeModel, { protocol: nextProtocol, billingMode: activeBillingMode === "token" && !modelProtocolSupportsTokenBilling(activeCapability, nextProtocol) ? "fixed_request" : activeBillingMode, capabilityConfig: activeCapability === "image" || activeCapability === "video" ? defaultModelCapabilityConfig(nextProtocol, activeModel) : undefined })}
|
||||
/>
|
||||
</section>
|
||||
{activeCapability === "video" ? (
|
||||
@@ -149,6 +150,7 @@ export function ChannelModelSettings({ channel, onChange }: { channel: ModelChan
|
||||
options={[
|
||||
{ label: "按次", value: "fixed_request" },
|
||||
{ label: "按秒", value: "per_second" },
|
||||
{ label: "Token", value: "token", disabled: !activeTokenBillingSupported },
|
||||
]}
|
||||
onChange={(value) => updateCost(activeModel, { billingMode: value as ModelCost["billingMode"] })}
|
||||
/>
|
||||
@@ -159,12 +161,13 @@ export function ChannelModelSettings({ channel, onChange }: { channel: ModelChan
|
||||
precision={6}
|
||||
step={0.1}
|
||||
className="w-full"
|
||||
placeholder={activeBillingMode === "per_second" ? "每秒价格" : "每次价格"}
|
||||
addonAfter={`积分/${activeBillingMode === "per_second" ? "秒" : "次"}`}
|
||||
value={activeModelCost ? activeModelCost.unitPriceMicrocredits / 1_000_000 : null}
|
||||
onChange={(value) => updateCost(activeModel, { unitPriceMicrocredits: Math.round(Number(value || 0) * 1_000_000) })}
|
||||
placeholder={activeBillingMode === "token" ? "每百万视频 Token 价格" : activeBillingMode === "per_second" ? "每秒价格" : "每次价格"}
|
||||
addonAfter={`积分/${activeBillingMode === "token" ? "百万 Token" : activeBillingMode === "per_second" ? "秒" : "次"}`}
|
||||
value={activeModelCost ? (activeBillingMode === "token" ? (activeModelCost.outputTokenPriceMicrocredits || 0) : activeModelCost.unitPriceMicrocredits) / 1_000_000 : null}
|
||||
onChange={(value) => updateCost(activeModel, activeBillingMode === "token" ? { outputTokenPriceMicrocredits: Math.round(Number(value || 0) * 1_000_000) } : { unitPriceMicrocredits: Math.round(Number(value || 0) * 1_000_000) })}
|
||||
/>
|
||||
</div>
|
||||
{activeBillingMode === "token" ? <div className="text-[var(--fs-tiny)] text-foreground/45">按火山方舟任务查询响应的 usage.completion_tokens 结算。</div> : null}
|
||||
</div>
|
||||
) : null}
|
||||
{activeCapability === "image" || activeCapability === "video" ? (
|
||||
|
||||
@@ -71,7 +71,7 @@ export function ModelDefaultGrid({ config, onChange }: { config: AiConfig; onCha
|
||||
<span className="mt-1 flex min-w-0 flex-wrap items-center gap-x-1.5 gap-y-1 text-[var(--fs-tiny)] text-foreground/45">
|
||||
<span className="max-w-full truncate">{channel.name || "未命名渠道"}</span>
|
||||
<span className="model-default-option-scope">{channel.scope === "system" ? "系统" : "自定义"}</span>
|
||||
{creditsEnabled && cost ? <span className="model-default-price">{formatPrice(cost.unitPriceMicrocredits)} /{cost.billingMode === "per_second" ? "秒" : "次"}</span> : null}
|
||||
{creditsEnabled && cost ? <span className="model-default-price">{formatPrice(cost.billingMode === "token" ? (cost.outputTokenPriceMicrocredits || 0) : cost.unitPriceMicrocredits)} /{cost.billingMode === "token" ? "百万 Token" : cost.billingMode === "per_second" ? "秒" : "次"}</span> : null}
|
||||
</span>
|
||||
</span>
|
||||
<span className={cn("model-default-option-check grid size-5 shrink-0 place-items-center rounded-full", selected ? "is-selected" : "text-transparent")}>
|
||||
|
||||
Reference in New Issue
Block a user