Files
ttstd-infinite-canvas/backend/internal/app/logical_model_quote.go

182 lines
7.2 KiB
Go

package app
import (
"strings"
"yingce/backend/internal/model"
)
// LogicalModelQuote 是创作端当前参数命中的实际供应线路报价。
// Token 视频在上游返回真实 usage 前只能预估,创建任务仍以账务预留逻辑为准。
type LogicalModelQuote struct {
LogicalModelID string `json:"logicalModelId"`
BillingMode string `json:"billingMode"`
Quantity int64 `json:"quantity"`
AmountMicrocredits int64 `json:"amountMicrocredits"`
Estimated bool `json:"estimated"`
VideoTokenEstimate *VideoTokenEstimate `json:"videoTokenEstimate,omitempty"`
}
type ChannelModelQuoteRequest struct {
ChannelID string `json:"channelId"`
ModelKey string `json:"modelKey"`
Intent ModelRequestIntent `json:"intent"`
}
// QuoteChannelModel uses the same admission/defaults/SKU selection as task
// creation, without reserving credits or submitting a provider request.
func (s *Service) QuoteChannelModel(req ChannelModelQuoteRequest) (*LogicalModelQuote, error) {
if strings.TrimSpace(req.ChannelID) == "" || strings.TrimSpace(req.ModelKey) == "" {
return nil, InvalidModelSelection("报价必须指定系统渠道和模型")
}
if err := validateQuoteIntent(req.Intent); err != nil {
return nil, err
}
input := quoteInput(req.Intent, req.ModelKey)
config := input["config"].(map[string]any)
config["channelId"] = req.ChannelID
input, err := s.resolveSystemChannelModelSelection(input, "canvas_"+req.Intent.Capability, req.Intent.Operation)
if err != nil {
return nil, err
}
config = input["config"].(map[string]any)
estimate := estimateTaskBillingTokens(input, req.Intent.Capability)
intent := ModelRequestIntentFromTaskInput(input, "canvas_"+req.Intent.Capability, req.Intent.Operation)
order, err := s.newBillingOrderWithPriceTier("", "", "quote", req.ChannelID, req.ModelKey, req.Intent.Capability, "model_quote", requestedBillingQuantity(req.Intent.Capability, config), estimate, stringValue(config["priceTierId"]), intent)
if err != nil {
return nil, err
}
return quoteFromOrder("", order, estimate), nil
}
func validateQuoteIntent(intent ModelRequestIntent) error {
if intent.Capability != "text" && intent.Capability != "image" && intent.Capability != "video" && intent.Capability != "audio" {
return BadAuthRequest("报价请求缺少有效的模型能力类型")
}
for _, count := range intent.Inputs {
if count < 0 || count > 128 {
return BadAuthRequest("报价参考素材数量必须为 0 到 128 的整数")
}
}
return nil
}
func quoteFromOrder(logicalModelID string, order *model.BillingOrder, estimate tokenBillingEstimate) *LogicalModelQuote {
quote := &LogicalModelQuote{LogicalModelID: logicalModelID, BillingMode: order.BillingMode, Quantity: order.Quantity, AmountMicrocredits: order.AmountMicrocredits, Estimated: order.BillingMode == "token"}
if order.BillingMode == "token" && order.Capability == "video" {
quote.VideoTokenEstimate = estimate.Video
}
return quote
}
func (s *Service) QuoteLogicalModel(logicalModelID string, intent ModelRequestIntent) (*LogicalModelQuote, error) {
if err := validateQuoteIntent(intent); err != nil {
return nil, err
}
routed, err := s.ResolveLogicalModel(logicalModelID, intent)
if err != nil {
return nil, err
}
capability := normalizeCapability(intent.Capability)
if capability == "" {
return nil, BadAuthRequest("报价请求缺少模型能力类型")
}
resolvedIntent := intent
resolvedIntent.Options = mergeIntentDefaults(intent.Options, routed.Defaults)
input := quoteInput(resolvedIntent, routed.ChannelModel.ModelKey)
providerModelKey := routed.ChannelModel.ProviderModelKey
if routed.PriceTier != nil {
providerModelKey = firstNonEmpty(routed.PriceTier.ProviderModelKey, providerModelKey)
}
input["config"].(map[string]any)["providerModelKey"] = providerModelKey
config, _ := input["config"].(map[string]any)
quantity := requestedBillingQuantity(capability, config)
if capability != "video" && capability != "audio" {
quantity = 1
}
tokenEstimate := estimateTaskBillingTokens(input, capability)
if routed.LogicalModel.PricePolicy == "channel" {
priceTierID := ""
if routed.PriceTier != nil {
priceTierID = routed.PriceTier.ID
}
order, billingErr := s.newBillingOrderWithPriceTier("", "", "quote", routed.ChannelModel.ChannelID, routed.ChannelModel.ModelKey, capability, "model_quote", quantity, tokenEstimate, priceTierID)
if billingErr != nil {
return nil, billingErr
}
return quoteFromOrder(routed.LogicalModel.ID, order, tokenEstimate), nil
}
if routed.LogicalModel.PricePolicy != "unified" {
return nil, BadAuthRequest("当前模型价格策略无效")
}
amount := int64(0)
switch routed.LogicalModel.BillingMode {
case "fixed_request":
quantity = 1
amount = routed.LogicalModel.UnitPriceMicrocredits
case "per_second":
if (capability != "video" && capability != "audio") || quantity <= 0 {
return nil, BadAuthRequest("当前模型按时长计费,但请求未提供有效时长")
}
amount, err = creditAmount(routed.LogicalModel.UnitPriceMicrocredits, quantity, 10_000)
case "token":
if routed.ChannelModel.Capability != capability || !supportsTokenBilling(capability, routed.ChannelModel.Protocol) {
return nil, BadAuthRequest("当前供应线路不支持前台模型的 Token 计费方式")
}
if capability == "video" && (routed.LogicalModel.InputPriceMicrocredits != 0 || routed.LogicalModel.CachedPriceMicrocredits != 0) {
return nil, BadAuthRequest("视频 Token 仅按视频用量定价,请将输入与缓存价格设为 0")
}
pricing := &model.ChannelModel{
InputTokenPriceMicrocredits: routed.LogicalModel.InputPriceMicrocredits,
OutputTokenPriceMicrocredits: routed.LogicalModel.OutputPriceMicrocredits,
CachedTokenPriceMicrocredits: routed.LogicalModel.CachedPriceMicrocredits,
}
amount, err = tokenEstimateAmount(pricing, tokenEstimate, 10_000)
quantity = tokenEstimate.InputTokens + tokenEstimate.OutputTokens
default:
return nil, BadAuthRequest("当前模型计费方式暂不支持")
}
if err != nil {
return nil, err
}
if amount < 0 {
return nil, BadAuthRequest("当前模型尚未配置有效的用户价格")
}
quote := &LogicalModelQuote{
LogicalModelID: routed.LogicalModel.ID,
BillingMode: routed.LogicalModel.BillingMode,
Quantity: quantity,
AmountMicrocredits: amount,
Estimated: routed.LogicalModel.BillingMode == "token",
}
if quote.Estimated && capability == "video" {
quote.VideoTokenEstimate = tokenEstimate.Video
}
return quote, nil
}
func quoteInput(intent ModelRequestIntent, modelKey string) map[string]any {
config := make(map[string]any, len(intent.Options)+1)
for key, value := range intent.Options {
config[key] = value
}
config["model"] = modelKey
input := map[string]any{"mode": intent.Capability, "config": config}
for kind, key := range map[string]string{"image": "referenceImages", "video": "referenceVideos", "audio": "referenceAudios"} {
if count := intent.Inputs[kind]; count > 0 {
input[key] = make([]any, count)
}
}
return input
}
func inputConfigValue(input map[string]any, key string) any {
config, _ := input["config"].(map[string]any)
if config == nil {
return nil
}
return config[key]
}