feat(billing): 统一计价与候选冻结估算
新增 effective-pricing-v2、9 位十进制定点计算、显式免费校验和结构化缺价错误。估价与生产预处理覆盖全部可用候选,并按最大候选费用冻结;规则优先级为平台模型、平台、基准模型。\n\n同步计价契约和 OpenAPI,补充 Token 参数别名、视频五秒向上取整及缺价回归测试。\n\n验证:go test ./...、pnpm openapi、Web 测试与构建通过。
This commit is contained in:
@@ -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}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user