feat(billing): 统一计价与候选冻结估算

新增 effective-pricing-v2、9 位十进制定点计算、显式免费校验和结构化缺价错误。估价与生产预处理覆盖全部可用候选,并按最大候选费用冻结;规则优先级为平台模型、平台、基准模型。\n\n同步计价契约和 OpenAPI,补充 Token 参数别名、视频五秒向上取整及缺价回归测试。\n\n验证:go test ./...、pnpm openapi、Web 测试与构建通过。
This commit is contained in:
2026-07-20 23:22:45 +08:00
parent 01a013c809
commit 5114686c35
14 changed files with 1192 additions and 28 deletions
+23 -13
View File
@@ -11,10 +11,14 @@ import (
)
type EstimateResult struct {
Items []any `json:"items"`
Resolver string `json:"resolver"`
TotalAmount float64 `json:"totalAmount"`
Currency string `json:"currency"`
Items []any `json:"items"`
Resolver string `json:"resolver"`
TotalAmount float64 `json:"totalAmount"`
ReservationAmount float64 `json:"reservationAmount"`
Currency string `json:"currency"`
CandidateCount int `json:"candidateCount"`
PricingVersion string `json:"pricingVersion"`
RequestFingerprint string `json:"requestFingerprint"`
}
func (s *Service) Estimate(ctx context.Context, kind string, model string, body map[string]any, user *auth.User) (EstimateResult, error) {
@@ -32,15 +36,21 @@ func (s *Service) Estimate(ctx context.Context, kind string, model string, body
if err != nil {
return EstimateResult{}, err
}
candidate := candidates[0]
body = preprocessRequest(kind, body, candidate)
items := s.estimatedBillings(ctx, user, kind, body, candidate)
return EstimateResult{
Items: items,
Resolver: "effective-pricing-v1",
TotalAmount: totalBillingAmount(items),
Currency: billingCurrency(items),
}, nil
estimates := make([]candidateEstimate, 0, len(candidates))
var pricingErr error
for _, candidate := range candidates {
candidateBody := preprocessRequest(kind, cloneMap(body), candidate)
estimate, candidateErr := s.estimateCandidateV2(ctx, user, kind, candidateBody, candidate)
if candidateErr != nil {
pricingErr = candidateErr
continue
}
estimates = append(estimates, estimate)
}
if len(estimates) == 0 && pricingErr != nil {
return EstimateResult{}, pricingErr
}
return buildEstimateResult(estimates, pricingRequestFingerprint(kind, model, body))
}
func (s *Service) estimatedBillings(ctx context.Context, user *auth.User, kind string, body map[string]any, candidate store.RuntimeModelCandidate) []any {
+605
View File
@@ -0,0 +1,605 @@
package runner
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"math"
"math/big"
"strconv"
"strings"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/clients"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
)
const (
pricingVersionV2 = "effective-pricing-v2"
fixedScale = int64(1_000_000_000)
)
var ErrPricingUnavailable = errors.New("pricing unavailable")
type PricingUnavailableError struct {
Reason string
ResourceType string
RuleSetID string
}
func (e *PricingUnavailableError) Error() string {
message := "pricing unavailable"
if e.ResourceType != "" {
message += " for " + e.ResourceType
}
if e.Reason != "" {
message += ": " + e.Reason
}
return message
}
func (e *PricingUnavailableError) Unwrap() error { return ErrPricingUnavailable }
func isPricingUnavailable(err error) bool { return errors.Is(err, ErrPricingUnavailable) }
func IsPricingUnavailable(err error) bool { return errors.Is(err, ErrPricingUnavailable) }
func PricingUnavailableDetails(err error) map[string]any {
var pricingErr *PricingUnavailableError
if !errors.As(err, &pricingErr) {
return nil
}
details := map[string]any{"reason": pricingErr.Reason}
if pricingErr.ResourceType != "" {
details["resourceType"] = pricingErr.ResourceType
}
if pricingErr.RuleSetID != "" {
details["ruleSetId"] = pricingErr.RuleSetID
}
return details
}
// fixedAmount is a signed decimal with exactly nine fractional digits.
// Monetary decisions use this type; float64 is only produced at JSON-compatible boundaries.
type fixedAmount int64
func parseFixedAmount(value string) (fixedAmount, error) {
value = strings.TrimSpace(value)
if value == "" {
return 0, fmt.Errorf("empty decimal")
}
sign := int64(1)
if value[0] == '-' || value[0] == '+' {
if value[0] == '-' {
sign = -1
}
value = value[1:]
}
parts := strings.Split(value, ".")
if len(parts) > 2 || value == "" {
return 0, fmt.Errorf("invalid decimal %q", value)
}
whole := parts[0]
if whole == "" {
whole = "0"
}
fraction := ""
if len(parts) == 2 {
fraction = parts[1]
}
for _, digits := range []string{whole, fraction} {
for _, char := range digits {
if char < '0' || char > '9' {
return 0, fmt.Errorf("invalid decimal %q", value)
}
}
}
roundUp := false
if len(fraction) > 9 {
roundUp = fraction[9] >= '5'
fraction = fraction[:9]
}
fraction += strings.Repeat("0", 9-len(fraction))
combined := strings.TrimLeft(whole+fraction, "0")
if combined == "" {
combined = "0"
}
number := new(big.Int)
if _, ok := number.SetString(combined, 10); !ok {
return 0, fmt.Errorf("invalid decimal %q", value)
}
if roundUp {
number.Add(number, big.NewInt(1))
}
if !number.IsInt64() {
return 0, fmt.Errorf("decimal %q exceeds fixed amount range", value)
}
return fixedAmount(sign * number.Int64()), nil
}
func fixedAmountFromAny(value any) (fixedAmount, error) {
switch typed := value.(type) {
case fixedAmount:
return typed, nil
case string:
return parseFixedAmount(typed)
case json.Number:
return parseFixedAmount(typed.String())
case int:
return fixedAmount(int64(typed) * fixedScale), nil
case int64:
return fixedAmount(typed * fixedScale), nil
case int32:
return fixedAmount(int64(typed) * fixedScale), nil
case float64:
if math.IsNaN(typed) || math.IsInf(typed, 0) {
return 0, fmt.Errorf("non-finite decimal")
}
return parseFixedAmount(strconv.FormatFloat(typed, 'f', 9, 64))
case float32:
return fixedAmountFromAny(float64(typed))
default:
return 0, fmt.Errorf("unsupported decimal type %T", value)
}
}
func (a fixedAmount) String() string {
value := int64(a)
sign := ""
if value < 0 {
sign = "-"
value = -value
}
return fmt.Sprintf("%s%d.%09d", sign, value/fixedScale, value%fixedScale)
}
func (a fixedAmount) Float64() float64 { return float64(a) / float64(fixedScale) }
func (a fixedAmount) IsZero() bool { return a == 0 }
func (a fixedAmount) Add(other fixedAmount) fixedAmount { return a + other }
func (a fixedAmount) MulInt(multiplier int) fixedAmount {
return fixedAmount(int64(a) * int64(multiplier))
}
func (a fixedAmount) Mul(other fixedAmount) fixedAmount {
product := new(big.Int).Mul(big.NewInt(int64(a)), big.NewInt(int64(other)))
return fixedAmount(roundBigIntRatio(product, big.NewInt(fixedScale)))
}
func (a fixedAmount) MulRatio(numerator int, denominator int) fixedAmount {
if denominator == 0 {
return 0
}
product := new(big.Int).Mul(big.NewInt(int64(a)), big.NewInt(int64(numerator)))
return fixedAmount(roundBigIntRatio(product, big.NewInt(int64(denominator))))
}
func roundBigIntRatio(numerator *big.Int, denominator *big.Int) int64 {
negative := numerator.Sign() < 0
absolute := new(big.Int).Abs(new(big.Int).Set(numerator))
quotient, remainder := new(big.Int), new(big.Int)
quotient.QuoRem(absolute, denominator, remainder)
if new(big.Int).Mul(remainder, big.NewInt(2)).Cmp(denominator) >= 0 {
quotient.Add(quotient, big.NewInt(1))
}
if negative {
quotient.Neg(quotient)
}
return quotient.Int64()
}
type resolvedPricing struct {
Config map[string]any
Currency string
RuleSetID string
RuleSetKey string
Source string
FreeResource map[string]bool
Snapshot map[string]any
}
func (pricing resolvedPricing) requiredPrice(resource string, keys ...string) (fixedAmount, error) {
if price, found, err := pricing.price(resource, keys...); err != nil {
return 0, &PricingUnavailableError{Reason: err.Error(), ResourceType: resource, RuleSetID: pricing.RuleSetID}
} else if found && price > 0 {
return price, nil
} else if found && price == 0 && pricing.FreeResource[resource] {
return 0, nil
}
return 0, &PricingUnavailableError{Reason: "missing, invalid, or not explicitly free", ResourceType: resource, RuleSetID: pricing.RuleSetID}
}
func (pricing resolvedPricing) price(resource string, keys ...string) (fixedAmount, bool, error) {
for _, key := range keys {
if value, ok := pricing.Config[key]; ok {
amount, err := fixedAmountFromAny(value)
return amount, true, err
}
}
resourceConfig, _ := pricing.Config[resource].(map[string]any)
if len(resourceConfig) == 0 && resource == "image_edit" {
resourceConfig, _ = pricing.Config["image"].(map[string]any)
}
for _, key := range keys {
if value, ok := resourceConfig[key]; ok {
amount, err := fixedAmountFromAny(value)
return amount, true, err
}
}
if formula, ok := resourceConfig["formulaConfig"].(map[string]any); ok {
for _, key := range keys {
if value, exists := formula[key]; exists {
amount, err := fixedAmountFromAny(value)
return amount, true, err
}
}
}
if value, ok := resourceConfig["basePrice"]; ok {
amount, err := fixedAmountFromAny(value)
return amount, true, err
}
if resource == "text" {
return resolvedPricing{Config: pricing.Config}.price("text_total", keys...)
}
return 0, false, nil
}
type candidateEstimate struct {
Items []any
Amount fixedAmount
Currency string
Snapshot map[string]any
Pricing resolvedPricing
}
func buildEstimateResult(estimates []candidateEstimate, fingerprint string) (EstimateResult, error) {
if len(estimates) == 0 {
return EstimateResult{}, &PricingUnavailableError{Reason: "no candidate has effective pricing"}
}
preferred := estimates[0]
reservation := preferred.Amount
currency := preferred.Currency
if currency == "" {
currency = "resource"
}
for _, estimate := range estimates[1:] {
candidateCurrency := estimate.Currency
if candidateCurrency == "" {
candidateCurrency = "resource"
}
if candidateCurrency != currency {
return EstimateResult{}, &PricingUnavailableError{Reason: "candidate currencies do not match"}
}
if estimate.Amount > reservation {
reservation = estimate.Amount
}
}
return EstimateResult{
Items: preferred.Items,
Resolver: pricingVersionV2,
TotalAmount: preferred.Amount.Float64(),
ReservationAmount: reservation.Float64(),
Currency: currency,
CandidateCount: len(estimates),
PricingVersion: pricingVersionV2,
RequestFingerprint: fingerprint,
}, nil
}
func estimatedOutputTokens(body map[string]any, candidate store.RuntimeModelCandidate) int {
for _, key := range []string{"max_completion_tokens", "max_output_tokens", "max_tokens"} {
if value, ok := positiveInteger(body[key]); ok {
return value
}
}
if limit, _, _, ok := candidateMaxOutputTokens(candidate, candidate.ModelType); ok {
return limit
}
return 4096
}
func pricingRequestFingerprint(kind string, model string, body map[string]any) string {
payload, _ := json.Marshal(map[string]any{"kind": kind, "model": model, "request": body})
digest := sha256.Sum256(payload)
return hex.EncodeToString(digest[:])
}
func (s *Service) resolveEffectivePricing(ctx context.Context, candidate store.RuntimeModelCandidate) (resolvedPricing, error) {
ruleSetID := firstNonEmptyString(
candidate.ModelPricingRuleSetID,
candidate.PlatformPricingRuleSetID,
candidate.BasePricingRuleSetID,
)
if ruleSetID != "" {
if s.store == nil {
return resolvedPricing{}, &PricingUnavailableError{Reason: "pricing store is unavailable", RuleSetID: ruleSetID}
}
config, err := s.store.PricingRuleSetBillingConfigV2(ctx, ruleSetID)
if err != nil {
return resolvedPricing{}, &PricingUnavailableError{Reason: err.Error(), RuleSetID: ruleSetID}
}
if len(candidate.BillingConfigOverride) > 0 {
config.Config = mergeMap(config.Config, candidate.BillingConfigOverride)
}
return resolvedPricing{
Config: config.Config, Currency: config.Currency, RuleSetID: config.RuleSetID,
RuleSetKey: config.RuleSetKey, Source: pricingRuleSource(candidate, ruleSetID),
FreeResource: config.FreeResource, Snapshot: config.Snapshot,
}, nil
}
config := candidate.BaseBillingConfig
source := "base_model_config"
if len(candidate.BillingConfig) > 0 {
config = candidate.BillingConfig
source = "platform_model_config"
}
if len(candidate.BillingConfigOverride) > 0 {
config = mergeMap(config, candidate.BillingConfigOverride)
source += "_override"
}
if len(config) == 0 {
return resolvedPricing{}, &PricingUnavailableError{Reason: "candidate has no pricing configuration"}
}
freeResource := explicitFreeResources(config)
snapshot := map[string]any{
"pricingVersion": pricingVersionV2,
"source": source,
"currency": "resource",
"config": config,
}
return resolvedPricing{
Config: config, Currency: "resource", Source: source,
FreeResource: freeResource, Snapshot: snapshot,
}, nil
}
func pricingRuleSource(candidate store.RuntimeModelCandidate, ruleSetID string) string {
switch ruleSetID {
case candidate.ModelPricingRuleSetID:
return "platform_model_rule_set"
case candidate.PlatformPricingRuleSetID:
return "platform_rule_set"
default:
return "base_model_rule_set"
}
}
func explicitFreeResources(config map[string]any) map[string]bool {
resources := map[string]bool{}
if isFree, _ := config["isFree"].(bool); isFree {
for _, resource := range []string{"text_input", "text_cached_input", "text_output", "text_total", "image", "image_edit", "video", "music", "audio"} {
resources[resource] = true
}
}
for _, resource := range []string{"text", "text_total", "image", "image_edit", "video", "music", "audio"} {
resourceConfig, _ := config[resource].(map[string]any)
if isFree, _ := resourceConfig["isFree"].(bool); isFree {
resources[resource] = true
}
}
return resources
}
func (s *Service) estimateCandidateV2(ctx context.Context, user *auth.User, kind string, body map[string]any, candidate store.RuntimeModelCandidate) (candidateEstimate, error) {
usage := clients.Usage{InputTokens: estimateRequestTokens(body)}
if isTextGenerationKind(kind) {
usage.OutputTokens = estimatedOutputTokens(body, candidate)
}
usage.TotalTokens = usage.InputTokens + usage.OutputTokens
response := clients.Response{Usage: usage}
items, total, pricing, err := s.billingsV2(ctx, user, kind, body, candidate, response, true)
if err != nil {
return candidateEstimate{}, err
}
if isTextBillingKind(kind) {
if _, err := pricing.requiredTextPrice("text_cached_input", "textCachedInputPer1k", "cachedInputTokenPrice", "cacheInputTokenPrice", "textInputCacheHitPer1k", "inputCacheHitTokenPrice"); err != nil {
return candidateEstimate{}, err
}
}
return candidateEstimate{
Items: items, Amount: total, Currency: pricing.Currency, Snapshot: pricing.Snapshot, Pricing: pricing,
}, nil
}
func (s *Service) billingsV2(
ctx context.Context,
user *auth.User,
kind string,
body map[string]any,
candidate store.RuntimeModelCandidate,
response clients.Response,
simulated bool,
) ([]any, fixedAmount, resolvedPricing, error) {
pricing, err := s.resolveEffectivePricing(ctx, candidate)
if err != nil {
return nil, 0, resolvedPricing{}, err
}
return s.billingsWithResolvedPricingV2(ctx, user, kind, body, candidate, response, simulated, pricing)
}
func (s *Service) billingsWithResolvedPricingV2(
ctx context.Context,
user *auth.User,
kind string,
body map[string]any,
candidate store.RuntimeModelCandidate,
response clients.Response,
simulated bool,
pricing resolvedPricing,
) ([]any, fixedAmount, resolvedPricing, error) {
discount, err := fixedAmountFromAny(effectiveDiscount(ctx, s.store, user, candidate))
if err != nil || discount <= 0 {
return nil, 0, resolvedPricing{}, &PricingUnavailableError{Reason: "invalid discount factor", RuleSetID: pricing.RuleSetID}
}
buildLine := func(resourceType string, unit string, quantity any, amount fixedAmount, details map[string]any) map[string]any {
line := billingLineWithDetails(candidate, resourceType, unit, quantity, amount.Float64(), discount.Float64(), simulated, details)
line["pricingVersion"] = pricingVersionV2
line["pricingSource"] = pricing.Source
if pricing.RuleSetID != "" {
line["pricingRuleSetId"] = pricing.RuleSetID
}
line["isFree"] = amount.IsZero()
return line
}
if isTextBillingKind(kind) {
inputTokens := response.Usage.InputTokens
outputTokens := response.Usage.OutputTokens
cachedInputTokens := response.Usage.CachedInputTokens
if isTextInputOnlyKind(kind) && inputTokens == 0 && response.Usage.TotalTokens > 0 {
inputTokens = response.Usage.TotalTokens
}
if inputTokens == 0 && outputTokens == 0 {
inputTokens = estimateRequestTokens(body)
if isTextGenerationKind(kind) {
outputTokens = 1
}
}
if cachedInputTokens > inputTokens && inputTokens > 0 {
cachedInputTokens = inputTokens
}
uncachedInputTokens := inputTokens - cachedInputTokens
if uncachedInputTokens < 0 {
uncachedInputTokens = 0
}
inputPrice, err := pricing.requiredTextPrice("text_input", "textInputPer1k", "inputTokenPrice", "basePrice")
if err != nil {
return nil, 0, resolvedPricing{}, err
}
items := make([]any, 0, 3)
total := fixedAmount(0)
if uncachedInputTokens > 0 || cachedInputTokens == 0 {
amount := inputPrice.MulRatio(uncachedInputTokens, 1000).Mul(discount)
total = total.Add(amount)
items = append(items, buildLine("text_input", "1k_tokens", uncachedInputTokens, amount, map[string]any{
"inputTokens": inputTokens, "uncachedInputTokens": uncachedInputTokens,
"cachedInputTokens": cachedInputTokens, "pricePer1k": inputPrice.Float64(),
}))
}
if cachedInputTokens > 0 {
cachedPrice, priceErr := pricing.requiredTextPrice("text_cached_input", "textCachedInputPer1k", "cachedInputTokenPrice", "cacheInputTokenPrice", "textInputCacheHitPer1k", "inputCacheHitTokenPrice")
if priceErr != nil {
return nil, 0, resolvedPricing{}, priceErr
}
amount := cachedPrice.MulRatio(cachedInputTokens, 1000).Mul(discount)
total = total.Add(amount)
items = append(items, buildLine("text_cached_input", "1k_tokens", cachedInputTokens, amount, map[string]any{
"inputTokens": inputTokens, "uncachedInputTokens": uncachedInputTokens,
"cachedInputTokens": cachedInputTokens, "pricePer1k": cachedPrice.Float64(),
}))
}
if isTextGenerationKind(kind) {
outputPrice, priceErr := pricing.requiredTextPrice("text_output", "textOutputPer1k", "outputTokenPrice", "basePrice")
if priceErr != nil {
return nil, 0, resolvedPricing{}, priceErr
}
amount := outputPrice.MulRatio(outputTokens, 1000).Mul(discount)
total = total.Add(amount)
items = append(items, buildLine("text_output", "1k_tokens", outputTokens, amount, map[string]any{"pricePer1k": outputPrice.Float64()}))
}
return items, total, pricing, nil
}
count := requestOutputCount(body)
resource := "image"
unit := "image"
baseKey := "imageBase"
if kind == "images.edits" {
resource = "image_edit"
baseKey = "editBase"
}
if kind == "videos.generations" {
resource = "video"
unit = "5s_video"
baseKey = "videoBase"
duration, durationSource := billingDurationSeconds(body, response)
audioEnabled, audioSource := billingAudioEnabled(body, response)
durationUnits := int(math.Max(1, math.Ceil(duration/5)))
price, priceErr := pricing.requiredPrice(resource, baseKey, "basePrice")
if priceErr != nil {
return nil, 0, resolvedPricing{}, priceErr
}
amount := price.MulInt(count).MulInt(durationUnits).
Mul(fixedWeight(resourceWeight(pricing.Config, resource, "resolutionWeights", firstNonEmptyString(stringFromMap(body, "resolution"), stringFromMap(body, "size"))))).
Mul(fixedWeight(resourceWeight(pricing.Config, resource, "audioWeights", boolWeightKey(audioEnabled)))).
Mul(fixedWeight(resourceWeight(pricing.Config, resource, "referenceVideoWeights", boolWeightKey(requestHasReferenceVideo(body))))).
Mul(fixedWeight(resourceWeight(pricing.Config, resource, "voiceSpecifiedWeights", boolWeightKey(requestHasVoiceID(body, audioEnabled))))).
Mul(discount)
item := buildLine(resource, unit, count*durationUnits, amount, map[string]any{
"count": count, "audio": audioEnabled, "audioSource": audioSource,
"durationSeconds": duration, "durationSource": durationSource,
"durationUnit": "5s", "durationUnitCount": durationUnits,
})
return []any{item}, amount, pricing, nil
}
if kind == "song.generations" || kind == "music.generations" {
resource = "music"
unit = "song"
baseKey = "musicBase"
}
if kind == "speech.generations" || kind == "voice.clone" {
resource = "audio"
unit = "character"
baseKey = "audioBase"
count = len([]rune(stringFromMap(body, "text")))
if count <= 0 {
count = 1
}
}
price, err := pricing.requiredPrice(resource, baseKey, "basePrice")
if err != nil {
return nil, 0, resolvedPricing{}, err
}
amount := price.MulInt(count)
if resource == "image" || resource == "image_edit" {
amount = amount.
Mul(fixedWeight(resourceWeight(pricing.Config, resource, "qualityWeights", stringFromMap(body, "quality")))).
Mul(fixedWeight(resourceWeight(pricing.Config, resource, "sizeWeights", stringFromMap(body, "size")))).
Mul(fixedWeight(resourceWeight(pricing.Config, resource, "resolutionWeights", firstNonEmptyString(stringFromMap(body, "resolution"), stringFromMap(body, "size")))))
}
amount = amount.Mul(discount)
return []any{buildLine(resource, unit, count, amount, nil)}, amount, pricing, nil
}
func (pricing resolvedPricing) requiredTextPrice(resource string, keys ...string) (fixedAmount, error) {
price, found, err := pricing.price("text", keys...)
if err != nil {
return 0, &PricingUnavailableError{Reason: err.Error(), ResourceType: resource, RuleSetID: pricing.RuleSetID}
}
if found && price > 0 {
return price, nil
}
if found && price == 0 && (pricing.FreeResource[resource] || pricing.FreeResource["text_total"] || pricing.FreeResource["text"]) {
return 0, nil
}
return 0, &PricingUnavailableError{Reason: "missing, invalid, or not explicitly free", ResourceType: resource, RuleSetID: pricing.RuleSetID}
}
func fixedWeight(value float64) fixedAmount {
weight, err := fixedAmountFromAny(value)
if err != nil || weight <= 0 {
return fixedAmount(fixedScale)
}
return weight
}
func maximumCandidateEstimate(estimates []candidateEstimate) (candidateEstimate, error) {
if len(estimates) == 0 {
return candidateEstimate{}, &PricingUnavailableError{Reason: "no candidate has effective pricing"}
}
maximum := estimates[0]
for _, estimate := range estimates[1:] {
if estimate.Currency != maximum.Currency {
return candidateEstimate{}, &PricingUnavailableError{Reason: "candidate currencies do not match"}
}
if estimate.Amount > maximum.Amount {
maximum = estimate
}
}
return maximum, nil
}
+107
View File
@@ -0,0 +1,107 @@
package runner
import (
"testing"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
)
func TestFixedAmountPreservesNineDecimalPlaces(t *testing.T) {
amount, err := parseFixedAmount("0.123456789")
if err != nil {
t.Fatalf("parse fixed amount: %v", err)
}
if got, want := amount.String(), "0.123456789"; got != want {
t.Fatalf("amount.String()=%q, want %q", got, want)
}
price := mustFixedAmount(t, "0.000000001")
if got, want := price.MulInt(3).String(), "0.000000003"; got != want {
t.Fatalf("nano amount multiplication=%q, want %q", got, want)
}
}
func TestEstimatedOutputTokensUsesAliasesAndCapabilityFallback(t *testing.T) {
candidate := store.RuntimeModelCandidate{
ModelType: "text_generate",
Capabilities: map[string]any{
"text_generate": map[string]any{"max_output_tokens": 8192},
},
}
tests := []struct {
name string
body map[string]any
want int
}{
{name: "max completion has highest priority", body: map[string]any{"max_completion_tokens": 700, "max_output_tokens": 600, "max_tokens": 500}, want: 700},
{name: "max output precedes legacy max tokens", body: map[string]any{"max_output_tokens": 600, "max_tokens": 500}, want: 600},
{name: "legacy max tokens", body: map[string]any{"max_tokens": 500}, want: 500},
{name: "model capability fallback", body: map[string]any{}, want: 8192},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if got := estimatedOutputTokens(test.body, candidate); got != test.want {
t.Fatalf("estimatedOutputTokens()=%d, want %d", got, test.want)
}
})
}
if got := estimatedOutputTokens(nil, store.RuntimeModelCandidate{}); got != 4096 {
t.Fatalf("missing capability fallback=%d, want 4096", got)
}
}
func TestBuildEstimateResultUsesPreferredTotalAndMaximumReservation(t *testing.T) {
result, err := buildEstimateResult([]candidateEstimate{
{Items: []any{map[string]any{"amount": 1.25, "currency": "resource"}}, Amount: mustFixedAmount(t, "1.25")},
{Items: []any{map[string]any{"amount": 2.75, "currency": "resource"}}, Amount: mustFixedAmount(t, "2.75")},
}, "fingerprint")
if err != nil {
t.Fatalf("build estimate result: %v", err)
}
if result.TotalAmount != 1.25 {
t.Fatalf("preferred total=%v, want 1.25", result.TotalAmount)
}
if result.ReservationAmount != 2.75 {
t.Fatalf("reservation=%v, want 2.75", result.ReservationAmount)
}
if result.CandidateCount != 2 || result.PricingVersion != pricingVersionV2 || result.RequestFingerprint != "fingerprint" {
t.Fatalf("unexpected estimate metadata: %+v", result)
}
}
func TestPricingAvailabilityRequiresExplicitFree(t *testing.T) {
paid := resolvedPricing{Config: map[string]any{"imageBase": 1.5}, Currency: "resource"}
if _, err := paid.requiredPrice("image", "imageBase", "basePrice"); err != nil {
t.Fatalf("positive price should be available: %v", err)
}
missing := resolvedPricing{Config: map[string]any{}, Currency: "resource"}
if _, err := missing.requiredPrice("image", "imageBase", "basePrice"); !isPricingUnavailable(err) {
t.Fatalf("missing price should be unavailable, got %v", err)
}
ordinaryZero := resolvedPricing{Config: map[string]any{"imageBase": 0}, Currency: "resource"}
if _, err := ordinaryZero.requiredPrice("image", "imageBase", "basePrice"); !isPricingUnavailable(err) {
t.Fatalf("ordinary zero should be unavailable, got %v", err)
}
explicitFree := resolvedPricing{
Config: map[string]any{"imageBase": 0},
Currency: "resource",
FreeResource: map[string]bool{"image": true},
}
price, err := explicitFree.requiredPrice("image", "imageBase", "basePrice")
if err != nil || !price.IsZero() {
t.Fatalf("explicit free should resolve to zero: price=%s err=%v", price.String(), err)
}
}
func mustFixedAmount(t *testing.T, value string) fixedAmount {
t.Helper()
amount, err := parseFixedAmount(value)
if err != nil {
t.Fatalf("parse %q: %v", value, err)
}
return amount
}
+56 -3
View File
@@ -258,6 +258,44 @@ func (s *Service) execute(ctx context.Context, task store.GatewayTask, user *aut
return Result{Task: failed, Output: failed.Result}, err
}
}
pricingByCandidate := map[string]resolvedPricing{}
reservationBillings := []any(nil)
if task.RunMode == "production" {
pricedCandidates := make([]store.RuntimeModelCandidate, 0, len(candidates))
estimates := make([]candidateEstimate, 0, len(candidates))
var pricingErr error
for _, candidate := range candidates {
pricingBody := preprocessRequest(task.Kind, cloneMap(body), candidate)
estimate, estimateErr := s.estimateCandidateV2(ctx, user, task.Kind, pricingBody, candidate)
if estimateErr != nil {
pricingErr = estimateErr
continue
}
pricedCandidates = append(pricedCandidates, candidate)
estimates = append(estimates, estimate)
pricingByCandidate[pricingCandidateKey(candidate)] = estimate.Pricing
}
if len(pricedCandidates) == 0 {
if pricingErr == nil {
pricingErr = &PricingUnavailableError{Reason: "no candidate has effective pricing"}
}
failed, finishErr := s.failTask(ctx, task.ID, "pricing_unavailable", pricingErr.Error(), false, pricingErr)
if finishErr != nil {
return Result{}, finishErr
}
return Result{Task: failed, Output: failed.Result}, pricingErr
}
maximumEstimate, estimateErr := maximumCandidateEstimate(estimates)
if estimateErr != nil {
failed, finishErr := s.failTask(ctx, task.ID, "pricing_unavailable", estimateErr.Error(), false, estimateErr)
if finishErr != nil {
return Result{}, finishErr
}
return Result{Task: failed, Output: failed.Result}, estimateErr
}
candidates = pricedCandidates
reservationBillings = maximumEstimate.Items
}
firstCandidateBody := body
normalizedModelType := modelType
attemptNo := task.AttemptCount
@@ -299,9 +337,8 @@ func (s *Service) execute(ctx context.Context, task store.GatewayTask, user *aut
if err := s.store.MarkTaskRunning(ctx, task.ID, candidates[0].ModelType, s.slimTaskRequestSnapshot(task, firstCandidateBody)); err != nil {
return Result{}, err
}
estimatedBillings := s.estimatedBillings(ctx, user, task.Kind, firstCandidateBody, candidates[0])
var reserveErr error
walletReservations, reserveErr = s.store.ReserveTaskBilling(ctx, task, user, estimatedBillings)
walletReservations, reserveErr = s.store.ReserveTaskBilling(ctx, task, user, reservationBillings)
if reserveErr != nil {
if errors.Is(reserveErr, store.ErrInsufficientWalletBalance) {
attemptNo = s.recordFailedAttempt(ctx, failedAttemptRecord{
@@ -372,7 +409,19 @@ candidatesLoop:
response, err := s.runCandidate(ctx, task, user, candidateBody, preprocessing.Log, candidate, nextAttemptNo, onDelta, responseExecution, singleSourceProtected, runnerPolicy.CacheAffinityPolicy, cacheAffinityKeys.Record)
if err == nil {
attemptNo = nextAttemptNo
billings := s.billings(ctx, user, task.Kind, candidateBody, candidate, response, isSimulation(task, candidate))
var billings []any
if task.RunMode == "production" {
pricing := pricingByCandidate[pricingCandidateKey(candidate)]
var billingErr error
billings, _, _, billingErr = s.billingsWithResolvedPricingV2(ctx, user, task.Kind, candidateBody, candidate, response, false, pricing)
if billingErr != nil {
// The upstream result may already exist. Preserve the reservation for manual review.
walletReservationFinalized = true
return Result{}, billingErr
}
} else {
billings = s.billings(ctx, user, task.Kind, candidateBody, candidate, response, true)
}
record := buildSuccessRecord(task, user, candidateBody, candidate, response, billings, isSimulation(task, candidate))
record.Metrics = mergeMetrics(record.Metrics, candidateCapabilityFilterMetrics(candidateFilterSummary))
record.Metrics = mergeMetrics(record.Metrics, parameterPreprocessingMetrics(preprocessing.Log))
@@ -580,6 +629,10 @@ candidatesLoop:
return Result{Task: failed, Output: failed.Result}, lastErr
}
func pricingCandidateKey(candidate store.RuntimeModelCandidate) string {
return firstNonEmptyString(candidate.PlatformModelID, candidate.PlatformID+":"+candidate.ModelName)
}
func (s *Service) runCandidate(ctx context.Context, task store.GatewayTask, user *auth.User, body map[string]any, preprocessing parameterPreprocessingLog, candidate store.RuntimeModelCandidate, attemptNo int, onDelta clients.StreamDelta, responseExecution responseExecutionContext, singleSourceProtected bool, cacheAffinityPolicy map[string]any, cacheAffinityRecordKeys []string) (clients.Response, error) {
simulated := isSimulation(task, candidate)
baseAttemptMetrics := mergeMetrics(attemptMetrics(candidate, attemptNo, simulated), parameterPreprocessingMetrics(preprocessing))