Files
cpa-plugin/internal/plugin/pricing_management.go
T
2026-08-15 22:31:12 +08:00

296 lines
11 KiB
Go

package plugin
import (
"context"
"encoding/json"
"errors"
"math/big"
"net/http"
"strconv"
"strings"
"time"
"billing/internal/modelcatalog"
"billing/internal/pricing"
"billing/internal/repository"
)
type priceRatesDTO struct {
InputPer1M string `json:"input_per_1m"`
CacheReadPer1M string `json:"cache_read_per_1m"`
CacheWritePer1M string `json:"cache_write_per_1m"`
OutputPer1M string `json:"output_per_1m"`
}
type longContextDTO struct {
ThresholdInputTokens int64 `json:"threshold_input_tokens"`
Comparison string `json:"comparison"`
priceRatesDTO
}
type priceDTO struct {
Model string `json:"model"`
Base priceRatesDTO `json:"base"`
LongContext *longContextDTO `json:"long_context,omitempty"`
FastPricingEnabled bool `json:"fast_pricing_enabled"`
FastMultiplier string `json:"fast_multiplier"`
Source priceSourceDTO `json:"source"`
}
type priceSourceDTO struct {
Kind string `json:"kind"`
CatalogID string `json:"catalog_id,omitempty"`
Provider string `json:"provider,omitempty"`
Model string `json:"model,omitempty"`
Revision string `json:"revision,omitempty"`
FetchedAt time.Time `json:"fetched_at,omitzero"`
}
type priceDeleteRequest struct {
Model string `json:"model"`
}
func (a *App) listPrices() ManagementResponse {
a.mu.RLock()
defer a.mu.RUnlock()
if a.store == nil {
return jsonManagementResponse(http.StatusServiceUnavailable, map[string]any{"error": map[string]string{"code": "database_unavailable", "message": "价格数据库尚未初始化"}})
}
records, err := a.store.ListPriceRecords(context.Background())
if err != nil {
return jsonManagementResponse(http.StatusInternalServerError, map[string]any{"error": map[string]string{"code": "database_error", "message": err.Error()}})
}
prices := make([]priceDTO, 0, len(records))
for _, record := range records {
prices = append(prices, priceRecordToDTO(record))
}
return jsonManagementResponse(http.StatusOK, map[string]any{"prices": prices})
}
func (a *App) putPrice(body []byte) ManagementResponse {
var dto priceDTO
if err := decodeJSONBody(body, &dto); err != nil {
return jsonManagementResponse(http.StatusBadRequest, map[string]any{"error": map[string]string{"code": "invalid_price", "message": err.Error()}})
}
policy, err := dtoToPolicy(dto)
if err != nil {
return jsonManagementResponse(http.StatusBadRequest, map[string]any{"error": map[string]string{"code": "invalid_price", "message": err.Error()}})
}
return a.persistPriceRecord(repository.PriceRecord{Policy: policy, Source: repository.PriceSource{Kind: repository.PriceSourceManual}})
}
func (a *App) persistPriceRecord(record repository.PriceRecord) ManagementResponse {
return a.persistPriceRecordFromCatalog(record, nil)
}
func (a *App) persistPriceRecordFromCatalog(record repository.PriceRecord, expectedCatalog *modelcatalog.Manager) ManagementResponse {
a.priceMu.Lock()
defer a.priceMu.Unlock()
return a.persistPriceRecordLocked(record, expectedCatalog)
}
func (a *App) persistPriceRecordLocked(record repository.PriceRecord, expectedCatalog *modelcatalog.Manager) ManagementResponse {
a.mu.Lock()
store := a.store
if store == nil {
a.mu.Unlock()
return jsonManagementResponse(http.StatusServiceUnavailable, map[string]any{"error": map[string]string{"code": "database_unavailable", "message": "价格数据库尚未初始化"}})
}
if expectedCatalog != nil && a.catalog != expectedCatalog {
a.mu.Unlock()
return jsonManagementResponse(http.StatusServiceUnavailable, map[string]any{"error": map[string]string{"code": "catalog_reconfigured", "message": "价格目录配置已经变化,请重新搜索"}})
}
if err := store.UpsertPriceRecord(context.Background(), record); err != nil {
a.mu.Unlock()
return jsonManagementResponse(http.StatusInternalServerError, map[string]any{"error": map[string]string{"code": "database_error", "message": err.Error()}})
}
a.prices[normalizeModelName(record.Policy.Model)] = record.Policy
a.mu.Unlock()
a.mu.RLock()
currentStore := a.store == store
a.mu.RUnlock()
if !currentStore {
return jsonManagementResponse(http.StatusServiceUnavailable, map[string]any{"error": map[string]string{"code": "database_reconfigured", "message": "价格已经保存,请在重新配置后重试历史补算"}})
}
if _, err := store.BackfillMissingCosts(context.Background(), record.Policy); err != nil {
a.mu.RLock()
reconfigured := a.store != store
a.mu.RUnlock()
if reconfigured {
return jsonManagementResponse(http.StatusServiceUnavailable, map[string]any{"error": map[string]string{"code": "database_reconfigured", "message": "价格已经保存,重新配置中止了历史补算"}})
}
return jsonManagementResponse(http.StatusInternalServerError, map[string]any{"error": map[string]string{"code": "database_error", "message": err.Error()}})
}
return jsonManagementResponse(http.StatusOK, priceRecordToDTO(record))
}
func (a *App) deletePrice(body []byte) ManagementResponse {
var request priceDeleteRequest
if err := decodeJSONBody(body, &request); err != nil {
return jsonManagementResponse(http.StatusBadRequest, map[string]any{"error": map[string]string{"code": "invalid_price", "message": err.Error()}})
}
request.Model = strings.TrimSpace(request.Model)
if request.Model == "" {
return jsonManagementResponse(http.StatusBadRequest, map[string]any{"error": map[string]string{"code": "invalid_price", "message": "model 不能为空"}})
}
a.priceMu.Lock()
defer a.priceMu.Unlock()
a.mu.Lock()
store := a.store
if store == nil {
a.mu.Unlock()
return jsonManagementResponse(http.StatusServiceUnavailable, map[string]any{"error": map[string]string{"code": "database_unavailable", "message": "价格数据库尚未初始化"}})
}
if err := store.DeletePrice(context.Background(), request.Model); err != nil {
a.mu.Unlock()
return jsonManagementResponse(http.StatusInternalServerError, map[string]any{"error": map[string]string{"code": "database_error", "message": err.Error()}})
}
defer a.mu.Unlock()
delete(a.prices, normalizeModelName(request.Model))
return jsonManagementResponse(http.StatusOK, map[string]any{"deleted": request.Model})
}
func dtoToPolicy(dto priceDTO) (pricing.Policy, error) {
base, err := dtoRates(dto.Base)
if err != nil {
return pricing.Policy{}, err
}
multiplierText := strings.TrimSpace(dto.FastMultiplier)
if multiplierText == "" {
multiplierText = "2.5"
}
multiplier, err := parseRatio(multiplierText)
if err != nil {
return pricing.Policy{}, errors.New("fast_multiplier 必须是正数,最多保留六位小数")
}
policy := pricing.Policy{Model: strings.TrimSpace(dto.Model), Base: base, FastPricingEnabled: dto.FastPricingEnabled, FastMultiplier: multiplier}
if dto.LongContext != nil {
rates, ratesErr := dtoRates(dto.LongContext.priceRatesDTO)
if ratesErr != nil {
return pricing.Policy{}, ratesErr
}
policy.LongContext = &pricing.LongContext{ThresholdInputTokens: dto.LongContext.ThresholdInputTokens, Comparison: dto.LongContext.Comparison, Rates: rates}
}
if err := policy.Validate(); err != nil {
return pricing.Policy{}, err
}
return policy, nil
}
func dtoRates(dto priceRatesDTO) (pricing.Rates, error) {
values := []*int64{new(int64), new(int64), new(int64), new(int64)}
texts := []string{dto.InputPer1M, dto.CacheReadPer1M, dto.CacheWritePer1M, dto.OutputPer1M}
for index, text := range texts {
value, err := parseDecimalMicros(text)
if err != nil {
return pricing.Rates{}, errors.New("价格必须是非负数字,最多保留六位小数")
}
*values[index] = value
}
return pricing.Rates{InputMicrosPer1M: *values[0], CacheReadMicrosPer1M: *values[1], CacheWriteMicrosPer1M: *values[2], OutputMicrosPer1M: *values[3]}, nil
}
func policyToDTO(policy pricing.Policy) priceDTO {
dto := priceDTO{Model: policy.Model, Base: ratesToDTO(policy.Base), FastPricingEnabled: policy.FastPricingEnabled, FastMultiplier: formatRatio(policy.FastMultiplier), Source: priceSourceDTO{Kind: repository.PriceSourceManual}}
if policy.LongContext != nil {
dto.LongContext = &longContextDTO{ThresholdInputTokens: policy.LongContext.ThresholdInputTokens, Comparison: policy.LongContext.Comparison, priceRatesDTO: ratesToDTO(policy.LongContext.Rates)}
}
return dto
}
func priceRecordToDTO(record repository.PriceRecord) priceDTO {
dto := policyToDTO(record.Policy)
source := record.Source
if source.Kind == "" {
source.Kind = repository.PriceSourceManual
}
dto.Source = priceSourceDTO{Kind: source.Kind, CatalogID: source.CatalogID, Revision: source.Revision, FetchedAt: source.FetchedAt}
if source.CatalogID != "" {
parts := strings.SplitN(source.CatalogID, "/", 2)
dto.Source.Provider = parts[0]
if len(parts) == 2 {
dto.Source.Model = parts[1]
}
}
return dto
}
func ratesToDTO(rates pricing.Rates) priceRatesDTO {
return priceRatesDTO{InputPer1M: formatMicros(rates.InputMicrosPer1M), CacheReadPer1M: formatMicros(rates.CacheReadMicrosPer1M), CacheWritePer1M: formatMicros(rates.CacheWriteMicrosPer1M), OutputPer1M: formatMicros(rates.OutputMicrosPer1M)}
}
func parseDecimalMicros(text string) (int64, error) {
text = strings.TrimSpace(text)
if text == "" || strings.HasPrefix(text, "-") || strings.Count(text, ".") > 1 {
return 0, errors.New("invalid decimal")
}
parts := strings.SplitN(text, ".", 2)
if parts[0] == "" {
parts[0] = "0"
}
fraction := ""
if len(parts) == 2 {
fraction = parts[1]
}
if len(fraction) > 6 {
return 0, errors.New("too many decimal places")
}
for len(fraction) < 6 {
fraction += "0"
}
whole := new(big.Int)
if _, ok := whole.SetString(parts[0]+fraction, 10); !ok || !whole.IsInt64() {
return 0, errors.New("invalid decimal")
}
return whole.Int64(), nil
}
func parseRatio(text string) (pricing.Ratio, error) {
numerator, err := parseDecimalMicros(text)
if err != nil || numerator <= 0 {
return pricing.Ratio{}, errors.New("invalid ratio")
}
denominator := int64(1_000_000)
divisor := gcd(numerator, denominator)
return pricing.Ratio{Numerator: numerator / divisor, Denominator: denominator / divisor}, nil
}
func gcd(a, b int64) int64 {
for b != 0 {
a, b = b, a%b
}
return a
}
func formatMicros(value int64) string {
prefix := ""
if value < 0 {
prefix = "-"
value = -value
}
whole := value / 1_000_000
fraction := strconv.FormatInt(value%1_000_000+1_000_000, 10)[1:]
fraction = strings.TrimRight(fraction, "0")
if fraction == "" {
return prefix + strconv.FormatInt(whole, 10)
}
return prefix + strconv.FormatInt(whole, 10) + "." + fraction
}
func formatRatio(value pricing.Ratio) string {
if value.Denominator == 0 {
return ""
}
rational := new(big.Rat).SetFrac(big.NewInt(value.Numerator), big.NewInt(value.Denominator))
return strings.TrimRight(strings.TrimRight(rational.FloatString(6), "0"), ".")
}
func decodeJSONBody(body []byte, target any) error {
decoder := json.NewDecoder(strings.NewReader(string(body)))
decoder.DisallowUnknownFields()
if err := decoder.Decode(target); err != nil {
return err
}
return nil
}