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

新增 effective-pricing-v2、9 位十进制定点计算、显式免费校验和结构化缺价错误。估价与生产预处理覆盖全部可用候选,并按最大候选费用冻结;规则优先级为平台模型、平台、基准模型。\n\n同步计价契约和 OpenAPI,补充 Token 参数别名、视频五秒向上取整及缺价回归测试。\n\n验证:go test ./...、pnpm openapi、Web 测试与构建通过。
This commit is contained in:
2026-07-20 23:22:45 +08:00
parent 01a013c809
commit 5114686c35
14 changed files with 1192 additions and 28 deletions
+13
View File
@@ -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}
}
+8 -2
View File
@@ -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 {
@@ -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"
"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
}
@@ -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")
}
}