From 5114686c35ffdfdc97e697f74fa6133989c1dfbf Mon Sep 17 00:00:00 2001 From: chengcheng Date: Mon, 20 Jul 2026 23:22:45 +0800 Subject: [PATCH] =?UTF-8?q?feat(billing):=20=E7=BB=9F=E4=B8=80=E8=AE=A1?= =?UTF-8?q?=E4=BB=B7=E4=B8=8E=E5=80=99=E9=80=89=E5=86=BB=E7=BB=93=E4=BC=B0?= =?UTF-8?q?=E7=AE=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 effective-pricing-v2、9 位十进制定点计算、显式免费校验和结构化缺价错误。估价与生产预处理覆盖全部可用候选,并按最大候选费用冻结;规则优先级为平台模型、平台、基准模型。\n\n同步计价契约和 OpenAPI,补充 Token 参数别名、视频五秒向上取整及缺价回归测试。\n\n验证:go test ./...、pnpm openapi、Web 测试与构建通过。 --- apps/api/docs/swagger.json | 50 +- apps/api/docs/swagger.yaml | 38 +- apps/api/internal/httpapi/handlers.go | 13 + apps/api/internal/httpapi/openapi_models.go | 10 +- .../internal/httpapi/pricing_error_test.go | 27 + apps/api/internal/httpapi/pricing_handlers.go | 29 + .../internal/httpapi/pricing_handlers_test.go | 28 + apps/api/internal/runner/pricing.go | 36 +- apps/api/internal/runner/pricing_v2.go | 605 ++++++++++++++++++ apps/api/internal/runner/pricing_v2_test.go | 107 ++++ apps/api/internal/runner/service.go | 59 +- apps/api/internal/store/postgres.go | 3 + apps/api/internal/store/pricing_rules.go | 205 +++++- packages/contracts/src/index.ts | 10 + 14 files changed, 1192 insertions(+), 28 deletions(-) create mode 100644 apps/api/internal/httpapi/pricing_error_test.go create mode 100644 apps/api/internal/httpapi/pricing_handlers_test.go create mode 100644 apps/api/internal/runner/pricing_v2.go create mode 100644 apps/api/internal/runner/pricing_v2_test.go diff --git a/apps/api/docs/swagger.json b/apps/api/docs/swagger.json index 0c4f779..a4ea972 100644 --- a/apps/api/docs/swagger.json +++ b/apps/api/docs/swagger.json @@ -6017,6 +6017,12 @@ "schema": { "$ref": "#/definitions/httpapi.ErrorEnvelope" } + }, + "503": { + "description": "Service Unavailable", + "schema": { + "$ref": "#/definitions/httpapi.ErrorEnvelope" + } } } } @@ -11227,6 +11233,14 @@ "httpapi.PricingEstimateResponse": { "type": "object", "properties": { + "candidateCount": { + "type": "integer", + "example": 2 + }, + "currency": { + "type": "string", + "example": "resource" + }, "items": { "type": "array", "items": { @@ -11234,9 +11248,25 @@ "additionalProperties": true } }, + "pricingVersion": { + "type": "string", + "example": "effective-pricing-v2" + }, + "requestFingerprint": { + "type": "string", + "example": "76ef6a537de8e71bd1ca93acadc078dbdbfa9f17e45224e4f9df59f535d2886f" + }, + "reservationAmount": { + "type": "number", + "example": 2.75 + }, "resolver": { "type": "string", - "example": "effective-pricing-v1" + "example": "effective-pricing-v2" + }, + "totalAmount": { + "type": "number", + "example": 1.25 } } }, @@ -13966,6 +13996,12 @@ "type": "object", "additionalProperties": {} }, + "effectiveFrom": { + "type": "string" + }, + "effectiveTo": { + "type": "string" + }, "formulaConfig": { "type": "object", "additionalProperties": {} @@ -13973,6 +14009,9 @@ "id": { "type": "string" }, + "isFree": { + "type": "boolean" + }, "metadata": { "type": "object", "additionalProperties": {} @@ -14033,10 +14072,19 @@ "type": "object", "additionalProperties": {} }, + "effectiveFrom": { + "type": "string" + }, + "effectiveTo": { + "type": "string" + }, "formulaConfig": { "type": "object", "additionalProperties": {} }, + "isFree": { + "type": "boolean" + }, "metadata": { "type": "object", "additionalProperties": {} diff --git a/apps/api/docs/swagger.yaml b/apps/api/docs/swagger.yaml index 93e7f6f..c6fc9fc 100644 --- a/apps/api/docs/swagger.yaml +++ b/apps/api/docs/swagger.yaml @@ -610,14 +610,32 @@ definitions: type: object httpapi.PricingEstimateResponse: properties: + candidateCount: + example: 2 + type: integer + currency: + example: resource + type: string items: items: additionalProperties: true type: object type: array - resolver: - example: effective-pricing-v1 + pricingVersion: + example: effective-pricing-v2 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 httpapi.PricingRuleListResponse: properties: @@ -2485,11 +2503,17 @@ definitions: dynamicWeight: additionalProperties: {} type: object + effectiveFrom: + type: string + effectiveTo: + type: string formulaConfig: additionalProperties: {} type: object id: type: string + isFree: + type: boolean metadata: additionalProperties: {} type: object @@ -2531,9 +2555,15 @@ definitions: dynamicWeight: additionalProperties: {} type: object + effectiveFrom: + type: string + effectiveTo: + type: string formulaConfig: additionalProperties: {} type: object + isFree: + type: boolean metadata: additionalProperties: {} type: object @@ -6841,6 +6871,10 @@ paths: description: Internal Server Error schema: $ref: '#/definitions/httpapi.ErrorEnvelope' + "503": + description: Service Unavailable + schema: + $ref: '#/definitions/httpapi.ErrorEnvelope' security: - BearerAuth: [] summary: 估算请求价格 diff --git a/apps/api/internal/httpapi/handlers.go b/apps/api/internal/httpapi/handlers.go index 82bb8cf..2a45dad 100644 --- a/apps/api/internal/httpapi/handlers.go +++ b/apps/api/internal/httpapi/handlers.go @@ -892,6 +892,7 @@ func (s *Server) deleteAPIKey(w http.ResponseWriter, r *http.Request) { // @Failure 403 {object} ErrorEnvelope // @Failure 404 {object} ErrorEnvelope // @Failure 429 {object} ErrorEnvelope +// @Failure 503 {object} ErrorEnvelope // @Failure 500 {object} ErrorEnvelope // @Router /api/v1/pricing/estimate [post] 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) if err != nil { + if runner.IsPricingUnavailable(err) { + writeErrorWithDetails(w, http.StatusServiceUnavailable, runErrorMessage(err), runErrorDetails(err), "pricing_unavailable") + return + } if errors.Is(err, store.ErrNoModelCandidate) { writeErrorWithDetails(w, statusFromRunError(err), runErrorMessage(err), runErrorDetails(err), store.ModelCandidateErrorCode(err)) return @@ -1383,6 +1388,8 @@ func scopeForTaskKind(kind string) string { func statusFromRunError(err error) int { 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": return http.StatusBadRequest case clients.ErrorCode(err) == "response_chain_unavailable": @@ -1411,6 +1418,9 @@ func statusFromRunError(err error) int { } func runErrorCode(err error) string { + if runner.IsPricingUnavailable(err) { + return "pricing_unavailable" + } if errors.Is(err, store.ErrNoModelCandidate) { return store.ModelCandidateErrorCode(err) } @@ -1428,6 +1438,9 @@ func runErrorMessage(err error) string { } 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 { return map[string]any{"rateLimit": detail} } diff --git a/apps/api/internal/httpapi/openapi_models.go b/apps/api/internal/httpapi/openapi_models.go index 636b91f..59bad83 100644 --- a/apps/api/internal/httpapi/openapi_models.go +++ b/apps/api/internal/httpapi/openapi_models.go @@ -188,8 +188,14 @@ type PricingEstimateRequest struct { } type PricingEstimateResponse struct { - Items []map[string]interface{} `json:"items"` - Resolver string `json:"resolver" example:"effective-pricing-v1"` + Items []map[string]interface{} `json:"items"` + 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 { diff --git a/apps/api/internal/httpapi/pricing_error_test.go b/apps/api/internal/httpapi/pricing_error_test.go new file mode 100644 index 0000000..9786771 --- /dev/null +++ b/apps/api/internal/httpapi/pricing_error_test.go @@ -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) + } +} diff --git a/apps/api/internal/httpapi/pricing_handlers.go b/apps/api/internal/httpapi/pricing_handlers.go index 3dad415..a0b8572 100644 --- a/apps/api/internal/httpapi/pricing_handlers.go +++ b/apps/api/internal/httpapi/pricing_handlers.go @@ -5,6 +5,7 @@ import ( "errors" "net/http" "strings" + "time" "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 { return false } + if currency := strings.TrimSpace(input.Currency); currency != "" && currency != "resource" { + return false + } for _, rule := range input.Rules { if strings.TrimSpace(rule.ResourceType) == "" || strings.TrimSpace(rule.Unit) == "" { 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 } + +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 +} diff --git a/apps/api/internal/httpapi/pricing_handlers_test.go b/apps/api/internal/httpapi/pricing_handlers_test.go new file mode 100644 index 0000000..4a643d3 --- /dev/null +++ b/apps/api/internal/httpapi/pricing_handlers_test.go @@ -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") + } +} diff --git a/apps/api/internal/runner/pricing.go b/apps/api/internal/runner/pricing.go index abda6fb..901a866 100644 --- a/apps/api/internal/runner/pricing.go +++ b/apps/api/internal/runner/pricing.go @@ -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 { diff --git a/apps/api/internal/runner/pricing_v2.go b/apps/api/internal/runner/pricing_v2.go new file mode 100644 index 0000000..95c42db --- /dev/null +++ b/apps/api/internal/runner/pricing_v2.go @@ -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 +} diff --git a/apps/api/internal/runner/pricing_v2_test.go b/apps/api/internal/runner/pricing_v2_test.go new file mode 100644 index 0000000..ea16827 --- /dev/null +++ b/apps/api/internal/runner/pricing_v2_test.go @@ -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 +} diff --git a/apps/api/internal/runner/service.go b/apps/api/internal/runner/service.go index 6461363..3ab59e1 100644 --- a/apps/api/internal/runner/service.go +++ b/apps/api/internal/runner/service.go @@ -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)) diff --git a/apps/api/internal/store/postgres.go b/apps/api/internal/store/postgres.go index 2d32011..c364834 100644 --- a/apps/api/internal/store/postgres.go +++ b/apps/api/internal/store/postgres.go @@ -295,6 +295,7 @@ type PricingRule struct { ResourceType string `json:"resourceType"` Unit string `json:"unit"` BasePrice float64 `json:"basePrice"` + IsFree bool `json:"isFree"` Currency string `json:"currency"` BaseWeight map[string]any `json:"baseWeight,omitempty"` DynamicWeight map[string]any `json:"dynamicWeight,omitempty"` @@ -304,6 +305,8 @@ type PricingRule struct { Priority int `json:"priority"` Status string `json:"status"` Metadata map[string]any `json:"metadata,omitempty"` + EffectiveFrom string `json:"effectiveFrom,omitempty"` + EffectiveTo string `json:"effectiveTo,omitempty"` CreatedAt time.Time `json:"createdAt"` UpdatedAt time.Time `json:"updatedAt"` } diff --git a/apps/api/internal/store/pricing_rules.go b/apps/api/internal/store/pricing_rules.go index 8e4efbc..a3136cc 100644 --- a/apps/api/internal/store/pricing_rules.go +++ b/apps/api/internal/store/pricing_rules.go @@ -3,8 +3,10 @@ package store import ( "context" "encoding/json" + "fmt" "strconv" "strings" + "time" "github.com/jackc/pgx/v5" ) @@ -15,9 +17,10 @@ status, metadata, created_at, updated_at` const pricingRuleColumns = ` 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, -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 { RuleKey string `json:"ruleKey"` @@ -25,6 +28,7 @@ type PricingRuleInput struct { ResourceType string `json:"resourceType"` Unit string `json:"unit"` BasePrice float64 `json:"basePrice"` + IsFree bool `json:"isFree"` Currency string `json:"currency"` BaseWeight map[string]any `json:"baseWeight"` DynamicWeight map[string]any `json:"dynamicWeight"` @@ -34,6 +38,17 @@ type PricingRuleInput struct { Priority int `json:"priority"` Status string `json:"status"` 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 { @@ -247,6 +262,173 @@ ORDER BY priority ASC, resource_type ASC`, id) 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) { if len(config) == 0 { return 0, false @@ -305,13 +487,16 @@ func insertPricingRules(ctx context.Context, tx pgx.Tx, ruleSetID string, defaul if _, err := tx.Exec(ctx, ` INSERT INTO model_pricing_rules ( rule_set_id, rule_key, display_name, scope_type, scope_id, resource_type, - unit, base_price, currency, base_weight, dynamic_weight, calculator_type, - dimension_schema, formula_config, priority, status, metadata + unit, base_price, is_free, currency, base_weight, dynamic_weight, calculator_type, + 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, - rule.BasePrice, rule.Currency, baseWeight, dynamicWeight, rule.CalculatorType, - dimensionSchema, formulaConfig, rule.Priority, rule.Status, metadata, + rule.BasePrice, rule.IsFree, rule.Currency, baseWeight, dynamicWeight, rule.CalculatorType, + dimensionSchema, formulaConfig, rule.Priority, rule.Status, metadata, rule.EffectiveFrom, rule.EffectiveTo, ); err != nil { return err } @@ -358,6 +543,7 @@ func scanPricingRule(scanner pricingScanner) (PricingRule, error) { &item.ResourceType, &item.Unit, &item.BasePrice, + &item.IsFree, &item.Currency, &baseWeight, &dynamicWeight, @@ -367,6 +553,8 @@ func scanPricingRule(scanner pricingScanner) (PricingRule, error) { &item.Priority, &item.Status, &metadata, + &item.EffectiveFrom, + &item.EffectiveTo, &item.CreatedAt, &item.UpdatedAt, ); err != nil { @@ -444,6 +632,7 @@ func pricingInputsToRules(ruleSetID string, defaultCurrency string, rules []Pric ResourceType: input.ResourceType, Unit: input.Unit, BasePrice: input.BasePrice, + IsFree: input.IsFree, Currency: input.Currency, BaseWeight: emptyObjectIfNil(input.BaseWeight), DynamicWeight: emptyObjectIfNil(input.DynamicWeight), @@ -453,6 +642,8 @@ func pricingInputsToRules(ruleSetID string, defaultCurrency string, rules []Pric Priority: input.Priority, Status: input.Status, Metadata: emptyObjectIfNil(input.Metadata), + EffectiveFrom: input.EffectiveFrom, + EffectiveTo: input.EffectiveTo, }) } return items diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 5f0472f..ea9504d 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -184,6 +184,7 @@ export interface PricingRule { | string; unit: '1k_tokens' | 'image' | '5s' | 'second' | 'character_1k' | 'item' | string; basePrice: number; + isFree: boolean; currency: 'resource' | 'credit' | 'cny' | 'usd' | string; baseWeight?: Record; dynamicWeight?: Record; @@ -193,6 +194,8 @@ export interface PricingRule { priority: number; status: 'active' | 'deprecated' | 'hidden' | string; metadata?: Record; + effectiveFrom?: string; + effectiveTo?: string; createdAt: string; updatedAt: string; } @@ -203,6 +206,7 @@ export interface PricingRuleInput { resourceType: string; unit: string; basePrice: number; + isFree?: boolean; currency?: string; baseWeight?: Record; dynamicWeight?: Record; @@ -212,6 +216,8 @@ export interface PricingRuleInput { priority?: number; status?: 'active' | 'deprecated' | 'hidden' | string; metadata?: Record; + effectiveFrom?: string; + effectiveTo?: string; } export interface PricingRuleSet { @@ -548,7 +554,11 @@ export interface GatewayPricingEstimate { items: GatewayPricingEstimateItem[]; resolver: string; totalAmount?: number; + reservationAmount?: number; currency?: string; + candidateCount: number; + pricingVersion: string; + requestFingerprint: string; } export interface GatewayWalletAccount {