feat(billing): 统一计价与候选冻结估算
新增 effective-pricing-v2、9 位十进制定点计算、显式免费校验和结构化缺价错误。估价与生产预处理覆盖全部可用候选,并按最大候选费用冻结;规则优先级为平台模型、平台、基准模型。\n\n同步计价契约和 OpenAPI,补充 Token 参数别名、视频五秒向上取整及缺价回归测试。\n\n验证:go test ./...、pnpm openapi、Web 测试与构建通过。
This commit is contained in:
@@ -6017,6 +6017,12 @@
|
|||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "#/definitions/httpapi.ErrorEnvelope"
|
"$ref": "#/definitions/httpapi.ErrorEnvelope"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"503": {
|
||||||
|
"description": "Service Unavailable",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/httpapi.ErrorEnvelope"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -11227,6 +11233,14 @@
|
|||||||
"httpapi.PricingEstimateResponse": {
|
"httpapi.PricingEstimateResponse": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
"candidateCount": {
|
||||||
|
"type": "integer",
|
||||||
|
"example": 2
|
||||||
|
},
|
||||||
|
"currency": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "resource"
|
||||||
|
},
|
||||||
"items": {
|
"items": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"items": {
|
"items": {
|
||||||
@@ -11234,9 +11248,25 @@
|
|||||||
"additionalProperties": true
|
"additionalProperties": true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"pricingVersion": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "effective-pricing-v2"
|
||||||
|
},
|
||||||
|
"requestFingerprint": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "76ef6a537de8e71bd1ca93acadc078dbdbfa9f17e45224e4f9df59f535d2886f"
|
||||||
|
},
|
||||||
|
"reservationAmount": {
|
||||||
|
"type": "number",
|
||||||
|
"example": 2.75
|
||||||
|
},
|
||||||
"resolver": {
|
"resolver": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"example": "effective-pricing-v1"
|
"example": "effective-pricing-v2"
|
||||||
|
},
|
||||||
|
"totalAmount": {
|
||||||
|
"type": "number",
|
||||||
|
"example": 1.25
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -13966,6 +13996,12 @@
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": {}
|
"additionalProperties": {}
|
||||||
},
|
},
|
||||||
|
"effectiveFrom": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"effectiveTo": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"formulaConfig": {
|
"formulaConfig": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": {}
|
"additionalProperties": {}
|
||||||
@@ -13973,6 +14009,9 @@
|
|||||||
"id": {
|
"id": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"isFree": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": {}
|
"additionalProperties": {}
|
||||||
@@ -14033,10 +14072,19 @@
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": {}
|
"additionalProperties": {}
|
||||||
},
|
},
|
||||||
|
"effectiveFrom": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"effectiveTo": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"formulaConfig": {
|
"formulaConfig": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": {}
|
"additionalProperties": {}
|
||||||
},
|
},
|
||||||
|
"isFree": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": {}
|
"additionalProperties": {}
|
||||||
|
|||||||
@@ -610,14 +610,32 @@ definitions:
|
|||||||
type: object
|
type: object
|
||||||
httpapi.PricingEstimateResponse:
|
httpapi.PricingEstimateResponse:
|
||||||
properties:
|
properties:
|
||||||
|
candidateCount:
|
||||||
|
example: 2
|
||||||
|
type: integer
|
||||||
|
currency:
|
||||||
|
example: resource
|
||||||
|
type: string
|
||||||
items:
|
items:
|
||||||
items:
|
items:
|
||||||
additionalProperties: true
|
additionalProperties: true
|
||||||
type: object
|
type: object
|
||||||
type: array
|
type: array
|
||||||
resolver:
|
pricingVersion:
|
||||||
example: effective-pricing-v1
|
example: effective-pricing-v2
|
||||||
type: string
|
type: string
|
||||||
|
requestFingerprint:
|
||||||
|
example: 76ef6a537de8e71bd1ca93acadc078dbdbfa9f17e45224e4f9df59f535d2886f
|
||||||
|
type: string
|
||||||
|
reservationAmount:
|
||||||
|
example: 2.75
|
||||||
|
type: number
|
||||||
|
resolver:
|
||||||
|
example: effective-pricing-v2
|
||||||
|
type: string
|
||||||
|
totalAmount:
|
||||||
|
example: 1.25
|
||||||
|
type: number
|
||||||
type: object
|
type: object
|
||||||
httpapi.PricingRuleListResponse:
|
httpapi.PricingRuleListResponse:
|
||||||
properties:
|
properties:
|
||||||
@@ -2485,11 +2503,17 @@ definitions:
|
|||||||
dynamicWeight:
|
dynamicWeight:
|
||||||
additionalProperties: {}
|
additionalProperties: {}
|
||||||
type: object
|
type: object
|
||||||
|
effectiveFrom:
|
||||||
|
type: string
|
||||||
|
effectiveTo:
|
||||||
|
type: string
|
||||||
formulaConfig:
|
formulaConfig:
|
||||||
additionalProperties: {}
|
additionalProperties: {}
|
||||||
type: object
|
type: object
|
||||||
id:
|
id:
|
||||||
type: string
|
type: string
|
||||||
|
isFree:
|
||||||
|
type: boolean
|
||||||
metadata:
|
metadata:
|
||||||
additionalProperties: {}
|
additionalProperties: {}
|
||||||
type: object
|
type: object
|
||||||
@@ -2531,9 +2555,15 @@ definitions:
|
|||||||
dynamicWeight:
|
dynamicWeight:
|
||||||
additionalProperties: {}
|
additionalProperties: {}
|
||||||
type: object
|
type: object
|
||||||
|
effectiveFrom:
|
||||||
|
type: string
|
||||||
|
effectiveTo:
|
||||||
|
type: string
|
||||||
formulaConfig:
|
formulaConfig:
|
||||||
additionalProperties: {}
|
additionalProperties: {}
|
||||||
type: object
|
type: object
|
||||||
|
isFree:
|
||||||
|
type: boolean
|
||||||
metadata:
|
metadata:
|
||||||
additionalProperties: {}
|
additionalProperties: {}
|
||||||
type: object
|
type: object
|
||||||
@@ -6841,6 +6871,10 @@ paths:
|
|||||||
description: Internal Server Error
|
description: Internal Server Error
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/definitions/httpapi.ErrorEnvelope'
|
$ref: '#/definitions/httpapi.ErrorEnvelope'
|
||||||
|
"503":
|
||||||
|
description: Service Unavailable
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/httpapi.ErrorEnvelope'
|
||||||
security:
|
security:
|
||||||
- BearerAuth: []
|
- BearerAuth: []
|
||||||
summary: 估算请求价格
|
summary: 估算请求价格
|
||||||
|
|||||||
@@ -892,6 +892,7 @@ func (s *Server) deleteAPIKey(w http.ResponseWriter, r *http.Request) {
|
|||||||
// @Failure 403 {object} ErrorEnvelope
|
// @Failure 403 {object} ErrorEnvelope
|
||||||
// @Failure 404 {object} ErrorEnvelope
|
// @Failure 404 {object} ErrorEnvelope
|
||||||
// @Failure 429 {object} ErrorEnvelope
|
// @Failure 429 {object} ErrorEnvelope
|
||||||
|
// @Failure 503 {object} ErrorEnvelope
|
||||||
// @Failure 500 {object} ErrorEnvelope
|
// @Failure 500 {object} ErrorEnvelope
|
||||||
// @Router /api/v1/pricing/estimate [post]
|
// @Router /api/v1/pricing/estimate [post]
|
||||||
func (s *Server) estimatePricing(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) estimatePricing(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -916,6 +917,10 @@ func (s *Server) estimatePricing(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
estimate, err := s.runner.Estimate(r.Context(), kind, model, body, user)
|
estimate, err := s.runner.Estimate(r.Context(), kind, model, body, user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if runner.IsPricingUnavailable(err) {
|
||||||
|
writeErrorWithDetails(w, http.StatusServiceUnavailable, runErrorMessage(err), runErrorDetails(err), "pricing_unavailable")
|
||||||
|
return
|
||||||
|
}
|
||||||
if errors.Is(err, store.ErrNoModelCandidate) {
|
if errors.Is(err, store.ErrNoModelCandidate) {
|
||||||
writeErrorWithDetails(w, statusFromRunError(err), runErrorMessage(err), runErrorDetails(err), store.ModelCandidateErrorCode(err))
|
writeErrorWithDetails(w, statusFromRunError(err), runErrorMessage(err), runErrorDetails(err), store.ModelCandidateErrorCode(err))
|
||||||
return
|
return
|
||||||
@@ -1383,6 +1388,8 @@ func scopeForTaskKind(kind string) string {
|
|||||||
|
|
||||||
func statusFromRunError(err error) int {
|
func statusFromRunError(err error) int {
|
||||||
switch {
|
switch {
|
||||||
|
case runner.IsPricingUnavailable(err):
|
||||||
|
return http.StatusServiceUnavailable
|
||||||
case clients.ErrorCode(err) == "invalid_previous_response_id" || clients.ErrorCode(err) == "response_chain_too_deep" || clients.ErrorCode(err) == "unsupported_model_protocol" || clients.ErrorCode(err) == "unsupported_response_tool" || clients.ErrorCode(err) == "unsupported_response_parameter":
|
case clients.ErrorCode(err) == "invalid_previous_response_id" || clients.ErrorCode(err) == "response_chain_too_deep" || clients.ErrorCode(err) == "unsupported_model_protocol" || clients.ErrorCode(err) == "unsupported_response_tool" || clients.ErrorCode(err) == "unsupported_response_parameter":
|
||||||
return http.StatusBadRequest
|
return http.StatusBadRequest
|
||||||
case clients.ErrorCode(err) == "response_chain_unavailable":
|
case clients.ErrorCode(err) == "response_chain_unavailable":
|
||||||
@@ -1411,6 +1418,9 @@ func statusFromRunError(err error) int {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func runErrorCode(err error) string {
|
func runErrorCode(err error) string {
|
||||||
|
if runner.IsPricingUnavailable(err) {
|
||||||
|
return "pricing_unavailable"
|
||||||
|
}
|
||||||
if errors.Is(err, store.ErrNoModelCandidate) {
|
if errors.Is(err, store.ErrNoModelCandidate) {
|
||||||
return store.ModelCandidateErrorCode(err)
|
return store.ModelCandidateErrorCode(err)
|
||||||
}
|
}
|
||||||
@@ -1428,6 +1438,9 @@ func runErrorMessage(err error) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func runErrorDetails(err error) map[string]any {
|
func runErrorDetails(err error) map[string]any {
|
||||||
|
if detail := runner.PricingUnavailableDetails(err); len(detail) > 0 {
|
||||||
|
return map[string]any{"pricing": detail}
|
||||||
|
}
|
||||||
if detail := rateLimitErrorDetail(err); len(detail) > 0 {
|
if detail := rateLimitErrorDetail(err); len(detail) > 0 {
|
||||||
return map[string]any{"rateLimit": detail}
|
return map[string]any{"rateLimit": detail}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -188,8 +188,14 @@ type PricingEstimateRequest struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type PricingEstimateResponse struct {
|
type PricingEstimateResponse struct {
|
||||||
Items []map[string]interface{} `json:"items"`
|
Items []map[string]interface{} `json:"items"`
|
||||||
Resolver string `json:"resolver" example:"effective-pricing-v1"`
|
Resolver string `json:"resolver" example:"effective-pricing-v2"`
|
||||||
|
TotalAmount float64 `json:"totalAmount" example:"1.25"`
|
||||||
|
ReservationAmount float64 `json:"reservationAmount" example:"2.75"`
|
||||||
|
Currency string `json:"currency" example:"resource"`
|
||||||
|
CandidateCount int `json:"candidateCount" example:"2"`
|
||||||
|
PricingVersion string `json:"pricingVersion" example:"effective-pricing-v2"`
|
||||||
|
RequestFingerprint string `json:"requestFingerprint" example:"76ef6a537de8e71bd1ca93acadc078dbdbfa9f17e45224e4f9df59f535d2886f"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type TaskRequest struct {
|
type TaskRequest struct {
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/easyai/easyai-ai-gateway/apps/api/internal/runner"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPricingUnavailableUsesStructuredServiceUnavailableError(t *testing.T) {
|
||||||
|
err := &runner.PricingUnavailableError{
|
||||||
|
Reason: "missing, invalid, or not explicitly free",
|
||||||
|
ResourceType: "image",
|
||||||
|
RuleSetID: "rule-set-id",
|
||||||
|
}
|
||||||
|
if got := statusFromRunError(err); got != http.StatusServiceUnavailable {
|
||||||
|
t.Fatalf("statusFromRunError()=%d, want %d", got, http.StatusServiceUnavailable)
|
||||||
|
}
|
||||||
|
if got := runErrorCode(err); got != "pricing_unavailable" {
|
||||||
|
t.Fatalf("runErrorCode()=%q, want pricing_unavailable", got)
|
||||||
|
}
|
||||||
|
details := runErrorDetails(err)
|
||||||
|
pricing, _ := details["pricing"].(map[string]any)
|
||||||
|
if pricing["resourceType"] != "image" || pricing["ruleSetId"] != "rule-set-id" {
|
||||||
|
t.Fatalf("unexpected pricing details: %+v", details)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
||||||
)
|
)
|
||||||
@@ -146,10 +147,38 @@ func validPricingRuleSetInput(input store.PricingRuleSetInput) bool {
|
|||||||
if strings.TrimSpace(input.RuleSetKey) == "" || strings.TrimSpace(input.Name) == "" || len(input.Rules) == 0 {
|
if strings.TrimSpace(input.RuleSetKey) == "" || strings.TrimSpace(input.Name) == "" || len(input.Rules) == 0 {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
if currency := strings.TrimSpace(input.Currency); currency != "" && currency != "resource" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
for _, rule := range input.Rules {
|
for _, rule := range input.Rules {
|
||||||
if strings.TrimSpace(rule.ResourceType) == "" || strings.TrimSpace(rule.Unit) == "" {
|
if strings.TrimSpace(rule.ResourceType) == "" || strings.TrimSpace(rule.Unit) == "" {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
if rule.BasePrice < 0 || (rule.BasePrice == 0 && !rule.IsFree) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if currency := strings.TrimSpace(rule.Currency); currency != "" && currency != "resource" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
switch calculator := strings.TrimSpace(rule.CalculatorType); calculator {
|
||||||
|
case "", "token_usage", "unit_weight", "duration_weight":
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
effectiveFrom, fromOK := pricingEffectiveTime(rule.EffectiveFrom)
|
||||||
|
effectiveTo, toOK := pricingEffectiveTime(rule.EffectiveTo)
|
||||||
|
if !fromOK || !toOK || (!effectiveFrom.IsZero() && !effectiveTo.IsZero() && !effectiveFrom.Before(effectiveTo)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func pricingEffectiveTime(value string) (time.Time, bool) {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if value == "" {
|
||||||
|
return time.Time{}, true
|
||||||
|
}
|
||||||
|
parsed, err := time.Parse(time.RFC3339, value)
|
||||||
|
return parsed, err == nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPricingRuleSetRejectsImplicitZeroAndUnsupportedCalculator(t *testing.T) {
|
||||||
|
base := store.PricingRuleSetInput{
|
||||||
|
RuleSetKey: "test", Name: "test",
|
||||||
|
Rules: []store.PricingRuleInput{{
|
||||||
|
ResourceType: "image", Unit: "image", BasePrice: 0,
|
||||||
|
Currency: "resource", CalculatorType: "unit_weight",
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
if validPricingRuleSetInput(base) {
|
||||||
|
t.Fatal("implicit zero price must be rejected")
|
||||||
|
}
|
||||||
|
base.Rules[0].IsFree = true
|
||||||
|
if !validPricingRuleSetInput(base) {
|
||||||
|
t.Fatal("explicit free price should be accepted")
|
||||||
|
}
|
||||||
|
base.Rules[0].CalculatorType = "formula"
|
||||||
|
if validPricingRuleSetInput(base) {
|
||||||
|
t.Fatal("arbitrary formula calculator must be rejected")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,10 +11,14 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type EstimateResult struct {
|
type EstimateResult struct {
|
||||||
Items []any `json:"items"`
|
Items []any `json:"items"`
|
||||||
Resolver string `json:"resolver"`
|
Resolver string `json:"resolver"`
|
||||||
TotalAmount float64 `json:"totalAmount"`
|
TotalAmount float64 `json:"totalAmount"`
|
||||||
Currency string `json:"currency"`
|
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) {
|
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 {
|
if err != nil {
|
||||||
return EstimateResult{}, err
|
return EstimateResult{}, err
|
||||||
}
|
}
|
||||||
candidate := candidates[0]
|
estimates := make([]candidateEstimate, 0, len(candidates))
|
||||||
body = preprocessRequest(kind, body, candidate)
|
var pricingErr error
|
||||||
items := s.estimatedBillings(ctx, user, kind, body, candidate)
|
for _, candidate := range candidates {
|
||||||
return EstimateResult{
|
candidateBody := preprocessRequest(kind, cloneMap(body), candidate)
|
||||||
Items: items,
|
estimate, candidateErr := s.estimateCandidateV2(ctx, user, kind, candidateBody, candidate)
|
||||||
Resolver: "effective-pricing-v1",
|
if candidateErr != nil {
|
||||||
TotalAmount: totalBillingAmount(items),
|
pricingErr = candidateErr
|
||||||
Currency: billingCurrency(items),
|
continue
|
||||||
}, nil
|
}
|
||||||
|
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 {
|
func (s *Service) estimatedBillings(ctx context.Context, user *auth.User, kind string, body map[string]any, candidate store.RuntimeModelCandidate) []any {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -258,6 +258,44 @@ func (s *Service) execute(ctx context.Context, task store.GatewayTask, user *aut
|
|||||||
return Result{Task: failed, Output: failed.Result}, err
|
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
|
firstCandidateBody := body
|
||||||
normalizedModelType := modelType
|
normalizedModelType := modelType
|
||||||
attemptNo := task.AttemptCount
|
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 {
|
if err := s.store.MarkTaskRunning(ctx, task.ID, candidates[0].ModelType, s.slimTaskRequestSnapshot(task, firstCandidateBody)); err != nil {
|
||||||
return Result{}, err
|
return Result{}, err
|
||||||
}
|
}
|
||||||
estimatedBillings := s.estimatedBillings(ctx, user, task.Kind, firstCandidateBody, candidates[0])
|
|
||||||
var reserveErr error
|
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 reserveErr != nil {
|
||||||
if errors.Is(reserveErr, store.ErrInsufficientWalletBalance) {
|
if errors.Is(reserveErr, store.ErrInsufficientWalletBalance) {
|
||||||
attemptNo = s.recordFailedAttempt(ctx, failedAttemptRecord{
|
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)
|
response, err := s.runCandidate(ctx, task, user, candidateBody, preprocessing.Log, candidate, nextAttemptNo, onDelta, responseExecution, singleSourceProtected, runnerPolicy.CacheAffinityPolicy, cacheAffinityKeys.Record)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
attemptNo = nextAttemptNo
|
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 := buildSuccessRecord(task, user, candidateBody, candidate, response, billings, isSimulation(task, candidate))
|
||||||
record.Metrics = mergeMetrics(record.Metrics, candidateCapabilityFilterMetrics(candidateFilterSummary))
|
record.Metrics = mergeMetrics(record.Metrics, candidateCapabilityFilterMetrics(candidateFilterSummary))
|
||||||
record.Metrics = mergeMetrics(record.Metrics, parameterPreprocessingMetrics(preprocessing.Log))
|
record.Metrics = mergeMetrics(record.Metrics, parameterPreprocessingMetrics(preprocessing.Log))
|
||||||
@@ -580,6 +629,10 @@ candidatesLoop:
|
|||||||
return Result{Task: failed, Output: failed.Result}, lastErr
|
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) {
|
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)
|
simulated := isSimulation(task, candidate)
|
||||||
baseAttemptMetrics := mergeMetrics(attemptMetrics(candidate, attemptNo, simulated), parameterPreprocessingMetrics(preprocessing))
|
baseAttemptMetrics := mergeMetrics(attemptMetrics(candidate, attemptNo, simulated), parameterPreprocessingMetrics(preprocessing))
|
||||||
|
|||||||
@@ -295,6 +295,7 @@ type PricingRule struct {
|
|||||||
ResourceType string `json:"resourceType"`
|
ResourceType string `json:"resourceType"`
|
||||||
Unit string `json:"unit"`
|
Unit string `json:"unit"`
|
||||||
BasePrice float64 `json:"basePrice"`
|
BasePrice float64 `json:"basePrice"`
|
||||||
|
IsFree bool `json:"isFree"`
|
||||||
Currency string `json:"currency"`
|
Currency string `json:"currency"`
|
||||||
BaseWeight map[string]any `json:"baseWeight,omitempty"`
|
BaseWeight map[string]any `json:"baseWeight,omitempty"`
|
||||||
DynamicWeight map[string]any `json:"dynamicWeight,omitempty"`
|
DynamicWeight map[string]any `json:"dynamicWeight,omitempty"`
|
||||||
@@ -304,6 +305,8 @@ type PricingRule struct {
|
|||||||
Priority int `json:"priority"`
|
Priority int `json:"priority"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
Metadata map[string]any `json:"metadata,omitempty"`
|
Metadata map[string]any `json:"metadata,omitempty"`
|
||||||
|
EffectiveFrom string `json:"effectiveFrom,omitempty"`
|
||||||
|
EffectiveTo string `json:"effectiveTo,omitempty"`
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
UpdatedAt time.Time `json:"updatedAt"`
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,10 @@ package store
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
)
|
)
|
||||||
@@ -15,9 +17,10 @@ status, metadata, created_at, updated_at`
|
|||||||
|
|
||||||
const pricingRuleColumns = `
|
const pricingRuleColumns = `
|
||||||
id::text, COALESCE(rule_set_id::text, ''), rule_key, display_name, scope_type,
|
id::text, COALESCE(rule_set_id::text, ''), rule_key, display_name, scope_type,
|
||||||
COALESCE(scope_id::text, ''), resource_type, unit, base_price::float8, currency,
|
COALESCE(scope_id::text, ''), resource_type, unit, base_price::float8, is_free, currency,
|
||||||
base_weight, dynamic_weight, calculator_type, dimension_schema, formula_config,
|
base_weight, dynamic_weight, calculator_type, dimension_schema, formula_config,
|
||||||
priority, status, metadata, created_at, updated_at`
|
priority, status, metadata, COALESCE(effective_from::text, ''), COALESCE(effective_to::text, ''),
|
||||||
|
created_at, updated_at`
|
||||||
|
|
||||||
type PricingRuleInput struct {
|
type PricingRuleInput struct {
|
||||||
RuleKey string `json:"ruleKey"`
|
RuleKey string `json:"ruleKey"`
|
||||||
@@ -25,6 +28,7 @@ type PricingRuleInput struct {
|
|||||||
ResourceType string `json:"resourceType"`
|
ResourceType string `json:"resourceType"`
|
||||||
Unit string `json:"unit"`
|
Unit string `json:"unit"`
|
||||||
BasePrice float64 `json:"basePrice"`
|
BasePrice float64 `json:"basePrice"`
|
||||||
|
IsFree bool `json:"isFree"`
|
||||||
Currency string `json:"currency"`
|
Currency string `json:"currency"`
|
||||||
BaseWeight map[string]any `json:"baseWeight"`
|
BaseWeight map[string]any `json:"baseWeight"`
|
||||||
DynamicWeight map[string]any `json:"dynamicWeight"`
|
DynamicWeight map[string]any `json:"dynamicWeight"`
|
||||||
@@ -34,6 +38,17 @@ type PricingRuleInput struct {
|
|||||||
Priority int `json:"priority"`
|
Priority int `json:"priority"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
Metadata map[string]any `json:"metadata"`
|
Metadata map[string]any `json:"metadata"`
|
||||||
|
EffectiveFrom string `json:"effectiveFrom"`
|
||||||
|
EffectiveTo string `json:"effectiveTo"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type EffectivePricingConfig struct {
|
||||||
|
RuleSetID string
|
||||||
|
RuleSetKey string
|
||||||
|
Currency string
|
||||||
|
Config map[string]any
|
||||||
|
FreeResource map[string]bool
|
||||||
|
Snapshot map[string]any
|
||||||
}
|
}
|
||||||
|
|
||||||
type PricingRuleSetInput struct {
|
type PricingRuleSetInput struct {
|
||||||
@@ -247,6 +262,173 @@ ORDER BY priority ASC, resource_type ASC`, id)
|
|||||||
return config, nil
|
return config, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Store) PricingRuleSetBillingConfigV2(ctx context.Context, id string) (EffectivePricingConfig, error) {
|
||||||
|
id = strings.TrimSpace(id)
|
||||||
|
if id == "" {
|
||||||
|
return EffectivePricingConfig{}, fmt.Errorf("pricing rule set id is required")
|
||||||
|
}
|
||||||
|
var ruleSetKey string
|
||||||
|
var currency string
|
||||||
|
var status string
|
||||||
|
if err := s.pool.QueryRow(ctx, `
|
||||||
|
SELECT rule_set_key, currency, status
|
||||||
|
FROM model_pricing_rule_sets
|
||||||
|
WHERE id = $1::uuid`, id).Scan(&ruleSetKey, ¤cy, &status); err != nil {
|
||||||
|
return EffectivePricingConfig{}, err
|
||||||
|
}
|
||||||
|
if status != "active" {
|
||||||
|
return EffectivePricingConfig{}, fmt.Errorf("pricing rule set %s is not active", id)
|
||||||
|
}
|
||||||
|
if currency != "resource" {
|
||||||
|
return EffectivePricingConfig{}, fmt.Errorf("pricing rule set %s uses unsupported currency %s", id, currency)
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := s.pool.Query(ctx, `
|
||||||
|
SELECT rule_key, resource_type, unit, base_price::text, currency, base_weight,
|
||||||
|
dynamic_weight, calculator_type, dimension_schema, formula_config,
|
||||||
|
priority, is_free, COALESCE(effective_from::text, ''), COALESCE(effective_to::text, '')
|
||||||
|
FROM model_pricing_rules
|
||||||
|
WHERE rule_set_id = $1::uuid
|
||||||
|
AND status = 'active'
|
||||||
|
AND (effective_from IS NULL OR effective_from <= now())
|
||||||
|
AND (effective_to IS NULL OR effective_to > now())
|
||||||
|
ORDER BY priority ASC, resource_type ASC, rule_key ASC`, id)
|
||||||
|
if err != nil {
|
||||||
|
return EffectivePricingConfig{}, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
config := map[string]any{}
|
||||||
|
freeResource := map[string]bool{}
|
||||||
|
rules := make([]any, 0)
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for rows.Next() {
|
||||||
|
var ruleKey string
|
||||||
|
var resourceType string
|
||||||
|
var unit string
|
||||||
|
var basePrice string
|
||||||
|
var ruleCurrency string
|
||||||
|
var baseWeightBytes []byte
|
||||||
|
var dynamicWeightBytes []byte
|
||||||
|
var calculatorType string
|
||||||
|
var dimensionSchemaBytes []byte
|
||||||
|
var formulaConfigBytes []byte
|
||||||
|
var priority int
|
||||||
|
var isFree bool
|
||||||
|
var effectiveFrom string
|
||||||
|
var effectiveTo string
|
||||||
|
if err := rows.Scan(
|
||||||
|
&ruleKey, &resourceType, &unit, &basePrice, &ruleCurrency, &baseWeightBytes,
|
||||||
|
&dynamicWeightBytes, &calculatorType, &dimensionSchemaBytes, &formulaConfigBytes,
|
||||||
|
&priority, &isFree, &effectiveFrom, &effectiveTo,
|
||||||
|
); err != nil {
|
||||||
|
return EffectivePricingConfig{}, err
|
||||||
|
}
|
||||||
|
if seen[resourceType] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if ruleCurrency != currency {
|
||||||
|
return EffectivePricingConfig{}, fmt.Errorf("pricing rule %s currency does not match its rule set", ruleKey)
|
||||||
|
}
|
||||||
|
switch calculatorType {
|
||||||
|
case "token_usage", "unit_weight", "duration_weight":
|
||||||
|
default:
|
||||||
|
return EffectivePricingConfig{}, fmt.Errorf("pricing rule %s uses unsupported calculator %s", ruleKey, calculatorType)
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(strings.TrimSpace(basePrice), "-") {
|
||||||
|
return EffectivePricingConfig{}, fmt.Errorf("pricing rule %s has a negative base price", ruleKey)
|
||||||
|
}
|
||||||
|
dynamicWeight := decodeObject(dynamicWeightBytes)
|
||||||
|
formulaConfig := decodeObject(formulaConfigBytes)
|
||||||
|
addPricingRuleToConfig(config, resourceType, basePrice, dynamicWeight, formulaConfig)
|
||||||
|
freeResource[resourceType] = isFree
|
||||||
|
seen[resourceType] = true
|
||||||
|
rules = append(rules, map[string]any{
|
||||||
|
"ruleKey": ruleKey, "resourceType": resourceType, "unit": unit,
|
||||||
|
"basePrice": basePrice, "currency": ruleCurrency, "baseWeight": decodeObject(baseWeightBytes),
|
||||||
|
"dynamicWeight": dynamicWeight, "calculatorType": calculatorType,
|
||||||
|
"dimensionSchema": decodeObject(dimensionSchemaBytes), "formulaConfig": formulaConfig,
|
||||||
|
"priority": priority, "isFree": isFree,
|
||||||
|
"effectiveFrom": effectiveFrom, "effectiveTo": effectiveTo,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return EffectivePricingConfig{}, err
|
||||||
|
}
|
||||||
|
if len(rules) == 0 {
|
||||||
|
return EffectivePricingConfig{}, fmt.Errorf("pricing rule set %s has no effective rules", id)
|
||||||
|
}
|
||||||
|
return EffectivePricingConfig{
|
||||||
|
RuleSetID: id, RuleSetKey: ruleSetKey, Currency: currency, Config: config,
|
||||||
|
FreeResource: freeResource,
|
||||||
|
Snapshot: map[string]any{
|
||||||
|
"pricingVersion": "effective-pricing-v2",
|
||||||
|
"ruleSetId": id,
|
||||||
|
"ruleSetKey": ruleSetKey,
|
||||||
|
"currency": currency,
|
||||||
|
"resolvedAt": time.Now().UTC().Format(time.RFC3339Nano),
|
||||||
|
"rules": rules,
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func addPricingRuleToConfig(config map[string]any, resourceType string, basePrice string, dynamicWeight map[string]any, formulaConfig map[string]any) {
|
||||||
|
resourceConfig := map[string]any{"basePrice": basePrice}
|
||||||
|
if len(dynamicWeight) > 0 {
|
||||||
|
resourceConfig["dynamicWeight"] = dynamicWeight
|
||||||
|
}
|
||||||
|
if len(formulaConfig) > 0 {
|
||||||
|
resourceConfig["formulaConfig"] = formulaConfig
|
||||||
|
}
|
||||||
|
switch resourceType {
|
||||||
|
case "text_input":
|
||||||
|
config["textInputPer1k"] = basePrice
|
||||||
|
case "text_cached_input":
|
||||||
|
config["textCachedInputPer1k"] = basePrice
|
||||||
|
case "text_output":
|
||||||
|
config["textOutputPer1k"] = basePrice
|
||||||
|
case "text_total":
|
||||||
|
inputPrice := any(basePrice)
|
||||||
|
if value, ok := pricingRuleValueFromKeys(formulaConfig, "inputTokenPrice", "input_token_price", "textInputPer1k", "text_input"); ok {
|
||||||
|
inputPrice = value
|
||||||
|
}
|
||||||
|
config["textInputPer1k"] = inputPrice
|
||||||
|
if value, ok := pricingRuleValueFromKeys(formulaConfig, "cachedInputTokenPrice", "cached_input_token_price", "textCachedInputPer1k", "text_cached_input", "inputCacheHitTokenPrice", "input_cache_hit_token_price"); ok {
|
||||||
|
config["textCachedInputPer1k"] = value
|
||||||
|
}
|
||||||
|
if value, ok := pricingRuleValueFromKeys(formulaConfig, "outputTokenPrice", "output_token_price", "textOutputPer1k", "text_output"); ok {
|
||||||
|
config["textOutputPer1k"] = value
|
||||||
|
}
|
||||||
|
config["text_total"] = resourceConfig
|
||||||
|
case "image":
|
||||||
|
config["imageBase"] = basePrice
|
||||||
|
config["image"] = resourceConfig
|
||||||
|
case "image_edit":
|
||||||
|
config["editBase"] = basePrice
|
||||||
|
config["image_edit"] = resourceConfig
|
||||||
|
case "video":
|
||||||
|
config["videoBase"] = basePrice
|
||||||
|
config["video"] = resourceConfig
|
||||||
|
case "music":
|
||||||
|
config["musicBase"] = basePrice
|
||||||
|
config["music"] = resourceConfig
|
||||||
|
case "audio":
|
||||||
|
config["audioBase"] = basePrice
|
||||||
|
config["audio"] = resourceConfig
|
||||||
|
default:
|
||||||
|
config[resourceType] = resourceConfig
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func pricingRuleValueFromKeys(config map[string]any, keys ...string) (any, bool) {
|
||||||
|
for _, key := range keys {
|
||||||
|
if value, ok := config[key]; ok {
|
||||||
|
return value, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
func pricingRuleNumberFromKeys(config map[string]any, keys ...string) (float64, bool) {
|
func pricingRuleNumberFromKeys(config map[string]any, keys ...string) (float64, bool) {
|
||||||
if len(config) == 0 {
|
if len(config) == 0 {
|
||||||
return 0, false
|
return 0, false
|
||||||
@@ -305,13 +487,16 @@ func insertPricingRules(ctx context.Context, tx pgx.Tx, ruleSetID string, defaul
|
|||||||
if _, err := tx.Exec(ctx, `
|
if _, err := tx.Exec(ctx, `
|
||||||
INSERT INTO model_pricing_rules (
|
INSERT INTO model_pricing_rules (
|
||||||
rule_set_id, rule_key, display_name, scope_type, scope_id, resource_type,
|
rule_set_id, rule_key, display_name, scope_type, scope_id, resource_type,
|
||||||
unit, base_price, currency, base_weight, dynamic_weight, calculator_type,
|
unit, base_price, is_free, currency, base_weight, dynamic_weight, calculator_type,
|
||||||
dimension_schema, formula_config, priority, status, metadata
|
dimension_schema, formula_config, priority, status, metadata, effective_from, effective_to
|
||||||
)
|
)
|
||||||
VALUES ($1::uuid, $2, $3, 'rule_set', $1::uuid, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)`,
|
VALUES (
|
||||||
|
$1::uuid, $2, $3, 'rule_set', $1::uuid, $4, $5, $6, $7, $8, $9, $10,
|
||||||
|
$11, $12, $13, $14, $15, $16, NULLIF($17, '')::timestamptz, NULLIF($18, '')::timestamptz
|
||||||
|
)`,
|
||||||
ruleSetID, rule.RuleKey, rule.DisplayName, rule.ResourceType, rule.Unit,
|
ruleSetID, rule.RuleKey, rule.DisplayName, rule.ResourceType, rule.Unit,
|
||||||
rule.BasePrice, rule.Currency, baseWeight, dynamicWeight, rule.CalculatorType,
|
rule.BasePrice, rule.IsFree, rule.Currency, baseWeight, dynamicWeight, rule.CalculatorType,
|
||||||
dimensionSchema, formulaConfig, rule.Priority, rule.Status, metadata,
|
dimensionSchema, formulaConfig, rule.Priority, rule.Status, metadata, rule.EffectiveFrom, rule.EffectiveTo,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -358,6 +543,7 @@ func scanPricingRule(scanner pricingScanner) (PricingRule, error) {
|
|||||||
&item.ResourceType,
|
&item.ResourceType,
|
||||||
&item.Unit,
|
&item.Unit,
|
||||||
&item.BasePrice,
|
&item.BasePrice,
|
||||||
|
&item.IsFree,
|
||||||
&item.Currency,
|
&item.Currency,
|
||||||
&baseWeight,
|
&baseWeight,
|
||||||
&dynamicWeight,
|
&dynamicWeight,
|
||||||
@@ -367,6 +553,8 @@ func scanPricingRule(scanner pricingScanner) (PricingRule, error) {
|
|||||||
&item.Priority,
|
&item.Priority,
|
||||||
&item.Status,
|
&item.Status,
|
||||||
&metadata,
|
&metadata,
|
||||||
|
&item.EffectiveFrom,
|
||||||
|
&item.EffectiveTo,
|
||||||
&item.CreatedAt,
|
&item.CreatedAt,
|
||||||
&item.UpdatedAt,
|
&item.UpdatedAt,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
@@ -444,6 +632,7 @@ func pricingInputsToRules(ruleSetID string, defaultCurrency string, rules []Pric
|
|||||||
ResourceType: input.ResourceType,
|
ResourceType: input.ResourceType,
|
||||||
Unit: input.Unit,
|
Unit: input.Unit,
|
||||||
BasePrice: input.BasePrice,
|
BasePrice: input.BasePrice,
|
||||||
|
IsFree: input.IsFree,
|
||||||
Currency: input.Currency,
|
Currency: input.Currency,
|
||||||
BaseWeight: emptyObjectIfNil(input.BaseWeight),
|
BaseWeight: emptyObjectIfNil(input.BaseWeight),
|
||||||
DynamicWeight: emptyObjectIfNil(input.DynamicWeight),
|
DynamicWeight: emptyObjectIfNil(input.DynamicWeight),
|
||||||
@@ -453,6 +642,8 @@ func pricingInputsToRules(ruleSetID string, defaultCurrency string, rules []Pric
|
|||||||
Priority: input.Priority,
|
Priority: input.Priority,
|
||||||
Status: input.Status,
|
Status: input.Status,
|
||||||
Metadata: emptyObjectIfNil(input.Metadata),
|
Metadata: emptyObjectIfNil(input.Metadata),
|
||||||
|
EffectiveFrom: input.EffectiveFrom,
|
||||||
|
EffectiveTo: input.EffectiveTo,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return items
|
return items
|
||||||
|
|||||||
@@ -184,6 +184,7 @@ export interface PricingRule {
|
|||||||
| string;
|
| string;
|
||||||
unit: '1k_tokens' | 'image' | '5s' | 'second' | 'character_1k' | 'item' | string;
|
unit: '1k_tokens' | 'image' | '5s' | 'second' | 'character_1k' | 'item' | string;
|
||||||
basePrice: number;
|
basePrice: number;
|
||||||
|
isFree: boolean;
|
||||||
currency: 'resource' | 'credit' | 'cny' | 'usd' | string;
|
currency: 'resource' | 'credit' | 'cny' | 'usd' | string;
|
||||||
baseWeight?: Record<string, unknown>;
|
baseWeight?: Record<string, unknown>;
|
||||||
dynamicWeight?: Record<string, unknown>;
|
dynamicWeight?: Record<string, unknown>;
|
||||||
@@ -193,6 +194,8 @@ export interface PricingRule {
|
|||||||
priority: number;
|
priority: number;
|
||||||
status: 'active' | 'deprecated' | 'hidden' | string;
|
status: 'active' | 'deprecated' | 'hidden' | string;
|
||||||
metadata?: Record<string, unknown>;
|
metadata?: Record<string, unknown>;
|
||||||
|
effectiveFrom?: string;
|
||||||
|
effectiveTo?: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
@@ -203,6 +206,7 @@ export interface PricingRuleInput {
|
|||||||
resourceType: string;
|
resourceType: string;
|
||||||
unit: string;
|
unit: string;
|
||||||
basePrice: number;
|
basePrice: number;
|
||||||
|
isFree?: boolean;
|
||||||
currency?: string;
|
currency?: string;
|
||||||
baseWeight?: Record<string, unknown>;
|
baseWeight?: Record<string, unknown>;
|
||||||
dynamicWeight?: Record<string, unknown>;
|
dynamicWeight?: Record<string, unknown>;
|
||||||
@@ -212,6 +216,8 @@ export interface PricingRuleInput {
|
|||||||
priority?: number;
|
priority?: number;
|
||||||
status?: 'active' | 'deprecated' | 'hidden' | string;
|
status?: 'active' | 'deprecated' | 'hidden' | string;
|
||||||
metadata?: Record<string, unknown>;
|
metadata?: Record<string, unknown>;
|
||||||
|
effectiveFrom?: string;
|
||||||
|
effectiveTo?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PricingRuleSet {
|
export interface PricingRuleSet {
|
||||||
@@ -548,7 +554,11 @@ export interface GatewayPricingEstimate {
|
|||||||
items: GatewayPricingEstimateItem[];
|
items: GatewayPricingEstimateItem[];
|
||||||
resolver: string;
|
resolver: string;
|
||||||
totalAmount?: number;
|
totalAmount?: number;
|
||||||
|
reservationAmount?: number;
|
||||||
currency?: string;
|
currency?: string;
|
||||||
|
candidateCount: number;
|
||||||
|
pricingVersion: string;
|
||||||
|
requestFingerprint: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GatewayWalletAccount {
|
export interface GatewayWalletAccount {
|
||||||
|
|||||||
Reference in New Issue
Block a user