300 lines
11 KiB
Go
300 lines
11 KiB
Go
package plugin
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"net/url"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"billing/internal/modelcatalog"
|
|
"billing/internal/pricing"
|
|
"billing/internal/repository"
|
|
)
|
|
|
|
type catalogEntryDTO struct {
|
|
ID string `json:"id"`
|
|
Provider string `json:"provider"`
|
|
ProviderName string `json:"provider_name"`
|
|
Model string `json:"model"`
|
|
ModelName string `json:"model_name"`
|
|
Base priceRatesDTO `json:"base"`
|
|
LongContext *longContextDTO `json:"long_context,omitempty"`
|
|
}
|
|
|
|
type catalogChangeDTO struct {
|
|
Model string `json:"model"`
|
|
CatalogID string `json:"catalog_id"`
|
|
Status string `json:"status"`
|
|
Before priceDTO `json:"before"`
|
|
After *priceDTO `json:"after,omitempty"`
|
|
FetchedAt *time.Time `json:"fetched_at,omitempty"`
|
|
}
|
|
|
|
type catalogImportRequest struct {
|
|
Model string `json:"model"`
|
|
CatalogID string `json:"catalog_id"`
|
|
}
|
|
|
|
type catalogApplyRequest struct {
|
|
Revision string `json:"revision"`
|
|
Models []string `json:"models"`
|
|
}
|
|
|
|
func (a *App) searchPriceCatalog(query url.Values) ManagementResponse {
|
|
a.mu.RLock()
|
|
catalog := a.catalog
|
|
a.mu.RUnlock()
|
|
if catalog == nil {
|
|
return catalogManagementError(http.StatusServiceUnavailable, "catalog_unavailable", "价格目录尚未初始化")
|
|
}
|
|
limit := 20
|
|
if raw := strings.TrimSpace(query.Get("limit")); raw != "" {
|
|
parsed, err := strconv.Atoi(raw)
|
|
if err != nil || parsed < 1 || parsed > 50 {
|
|
return catalogManagementError(http.StatusBadRequest, "invalid_limit", "limit 必须是 1-50 的整数")
|
|
}
|
|
limit = parsed
|
|
}
|
|
entries, info, loaded := catalog.Search(query.Get("q"), limit)
|
|
_, lastError, _ := catalog.Status()
|
|
results := make([]catalogEntryDTO, 0, len(entries))
|
|
for _, entry := range entries {
|
|
results = append(results, catalogEntryToDTO(entry))
|
|
}
|
|
return jsonManagementResponse(http.StatusOK, map[string]any{
|
|
"loaded": loaded, "catalog": info, "models": results, "last_error": lastError,
|
|
})
|
|
}
|
|
|
|
func (a *App) refreshPriceCatalog() ManagementResponse {
|
|
a.mu.RLock()
|
|
catalog := a.catalog
|
|
a.mu.RUnlock()
|
|
if catalog == nil {
|
|
return catalogManagementError(http.StatusServiceUnavailable, "catalog_unavailable", "价格目录尚未初始化")
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 35*time.Second)
|
|
defer cancel()
|
|
info, err := catalog.Refresh(ctx)
|
|
if err != nil {
|
|
return catalogManagementError(http.StatusBadGateway, "catalog_download_failed", err.Error())
|
|
}
|
|
a.mu.RLock()
|
|
defer a.mu.RUnlock()
|
|
if a.catalog != catalog || a.store == nil {
|
|
return catalogManagementError(http.StatusServiceUnavailable, "catalog_reconfigured", "价格目录配置已经变化,请重新刷新")
|
|
}
|
|
changes, err := previewCatalogChanges(context.Background(), a.store, catalog)
|
|
if err != nil {
|
|
return catalogManagementError(http.StatusInternalServerError, "database_error", err.Error())
|
|
}
|
|
changed, missing := catalogChangeCounts(changes)
|
|
return jsonManagementResponse(http.StatusOK, map[string]any{
|
|
"catalog": info, "changes": changes, "changed": changed, "missing": missing,
|
|
})
|
|
}
|
|
|
|
func (a *App) importCatalogPrice(body []byte) ManagementResponse {
|
|
var request catalogImportRequest
|
|
if err := decodeJSONBody(body, &request); err != nil {
|
|
return catalogManagementError(http.StatusBadRequest, "invalid_catalog_import", err.Error())
|
|
}
|
|
request.Model = strings.TrimSpace(request.Model)
|
|
request.CatalogID = strings.TrimSpace(request.CatalogID)
|
|
if request.Model == "" || request.CatalogID == "" {
|
|
return catalogManagementError(http.StatusBadRequest, "invalid_catalog_import", "model 和 catalog_id 不能为空")
|
|
}
|
|
a.priceMu.Lock()
|
|
defer a.priceMu.Unlock()
|
|
a.mu.RLock()
|
|
catalog := a.catalog
|
|
current, currentExists := a.prices[normalizeModelName(request.Model)]
|
|
a.mu.RUnlock()
|
|
if catalog == nil {
|
|
return catalogManagementError(http.StatusServiceUnavailable, "catalog_unavailable", "价格目录尚未初始化")
|
|
}
|
|
entry, info, found := catalog.Lookup(request.CatalogID)
|
|
if !found {
|
|
return catalogManagementError(http.StatusNotFound, "catalog_price_not_found", "参考价格不存在或当前目录尚未下载")
|
|
}
|
|
fastEnabled := false
|
|
multiplier := pricing.Ratio{Numerator: 5, Denominator: 2}
|
|
if currentExists {
|
|
fastEnabled = current.FastPricingEnabled
|
|
multiplier = current.FastMultiplier
|
|
}
|
|
policy := entry.Policy(request.Model, fastEnabled, multiplier)
|
|
if err := policy.Validate(); err != nil {
|
|
return catalogManagementError(http.StatusBadRequest, "invalid_catalog_price", err.Error())
|
|
}
|
|
record := repository.PriceRecord{
|
|
Policy: policy,
|
|
Source: repository.PriceSource{Kind: repository.PriceSourceModelsDev, CatalogID: entry.ID, Revision: info.Revision, FetchedAt: info.FetchedAt},
|
|
}
|
|
return a.persistPriceRecordLocked(record, catalog)
|
|
}
|
|
|
|
func (a *App) applyCatalogChanges(body []byte) ManagementResponse {
|
|
var request catalogApplyRequest
|
|
if err := decodeJSONBody(body, &request); err != nil {
|
|
return catalogManagementError(http.StatusBadRequest, "invalid_catalog_apply", err.Error())
|
|
}
|
|
request.Revision = strings.TrimSpace(request.Revision)
|
|
requested := make(map[string]struct{}, len(request.Models))
|
|
for _, model := range request.Models {
|
|
model = normalizeModelName(model)
|
|
if model != "" {
|
|
requested[model] = struct{}{}
|
|
}
|
|
}
|
|
if request.Revision == "" || len(requested) == 0 {
|
|
return catalogManagementError(http.StatusBadRequest, "invalid_catalog_apply", "revision 和 models 不能为空")
|
|
}
|
|
|
|
a.priceMu.Lock()
|
|
defer a.priceMu.Unlock()
|
|
a.mu.Lock()
|
|
catalog, store := a.catalog, a.store
|
|
if catalog == nil || store == nil {
|
|
a.mu.Unlock()
|
|
return catalogManagementError(http.StatusServiceUnavailable, "catalog_unavailable", "价格目录或数据库尚未初始化")
|
|
}
|
|
info, _, loaded := catalog.Status()
|
|
if !loaded || info.Revision != request.Revision {
|
|
a.mu.Unlock()
|
|
return catalogManagementError(http.StatusConflict, "catalog_changed", "价格目录已经变化,请重新查看差异")
|
|
}
|
|
records, err := store.ListPriceRecords(context.Background())
|
|
if err != nil {
|
|
a.mu.Unlock()
|
|
return catalogManagementError(http.StatusInternalServerError, "database_error", err.Error())
|
|
}
|
|
updates := make([]repository.PriceRecord, 0, len(requested))
|
|
seen := make(map[string]struct{}, len(requested))
|
|
for _, record := range records {
|
|
normalized := normalizeModelName(record.Policy.Model)
|
|
if _, wanted := requested[normalized]; !wanted {
|
|
continue
|
|
}
|
|
if record.Source.Kind != repository.PriceSourceModelsDev {
|
|
a.mu.Unlock()
|
|
return catalogManagementError(http.StatusConflict, "price_source_changed", record.Policy.Model+" 已经改为手动价格")
|
|
}
|
|
entry, _, found := catalog.Lookup(record.Source.CatalogID)
|
|
if !found {
|
|
a.mu.Unlock()
|
|
return catalogManagementError(http.StatusConflict, "catalog_price_missing", record.Policy.Model+" 的参考价格已不存在")
|
|
}
|
|
policy := entry.Policy(record.Policy.Model, record.Policy.FastPricingEnabled, record.Policy.FastMultiplier)
|
|
updates = append(updates, repository.PriceRecord{
|
|
Policy: policy,
|
|
Source: repository.PriceSource{Kind: repository.PriceSourceModelsDev, CatalogID: entry.ID, Revision: info.Revision, FetchedAt: info.FetchedAt},
|
|
})
|
|
seen[normalized] = struct{}{}
|
|
}
|
|
if len(seen) != len(requested) {
|
|
a.mu.Unlock()
|
|
return catalogManagementError(http.StatusConflict, "price_source_changed", "部分待更新价格已经被删除或改变")
|
|
}
|
|
if err := store.UpsertPriceRecords(context.Background(), updates); err != nil {
|
|
a.mu.Unlock()
|
|
return catalogManagementError(http.StatusInternalServerError, "database_error", err.Error())
|
|
}
|
|
for _, update := range updates {
|
|
a.prices[normalizeModelName(update.Policy.Model)] = update.Policy
|
|
}
|
|
a.mu.Unlock()
|
|
|
|
for _, update := range updates {
|
|
if _, err := store.BackfillMissingCosts(context.Background(), update.Policy); err != nil {
|
|
a.mu.RLock()
|
|
reconfigured := a.store != store
|
|
a.mu.RUnlock()
|
|
if reconfigured {
|
|
return catalogManagementError(http.StatusServiceUnavailable, "database_reconfigured", "价格已经更新,重新配置中止了历史补算")
|
|
}
|
|
return catalogManagementError(http.StatusInternalServerError, "database_error", err.Error())
|
|
}
|
|
}
|
|
models := make([]string, 0, len(updates))
|
|
for _, update := range updates {
|
|
models = append(models, update.Policy.Model)
|
|
}
|
|
sort.Strings(models)
|
|
return jsonManagementResponse(http.StatusOK, map[string]any{"updated": len(updates), "models": models, "catalog": info})
|
|
}
|
|
|
|
func previewCatalogChanges(ctx context.Context, store *repository.SQLiteUsageRepository, catalog *modelcatalog.Manager) ([]catalogChangeDTO, error) {
|
|
records, err := store.ListPriceRecords(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
changes := make([]catalogChangeDTO, 0)
|
|
for _, record := range records {
|
|
if record.Source.Kind != repository.PriceSourceModelsDev {
|
|
continue
|
|
}
|
|
entry, info, found := catalog.Lookup(record.Source.CatalogID)
|
|
before := priceRecordToDTO(record)
|
|
if !found {
|
|
changes = append(changes, catalogChangeDTO{Model: record.Policy.Model, CatalogID: record.Source.CatalogID, Status: "missing", Before: before})
|
|
continue
|
|
}
|
|
policy := entry.Policy(record.Policy.Model, record.Policy.FastPricingEnabled, record.Policy.FastMultiplier)
|
|
if sameCatalogPolicy(record.Policy, policy) {
|
|
continue
|
|
}
|
|
afterRecord := repository.PriceRecord{
|
|
Policy: policy,
|
|
Source: repository.PriceSource{Kind: repository.PriceSourceModelsDev, CatalogID: entry.ID, Revision: info.Revision, FetchedAt: info.FetchedAt},
|
|
}
|
|
after := priceRecordToDTO(afterRecord)
|
|
fetchedAt := info.FetchedAt
|
|
changes = append(changes, catalogChangeDTO{Model: record.Policy.Model, CatalogID: entry.ID, Status: "changed", Before: before, After: &after, FetchedAt: &fetchedAt})
|
|
}
|
|
sort.Slice(changes, func(i, j int) bool { return changes[i].Model < changes[j].Model })
|
|
return changes, nil
|
|
}
|
|
|
|
func catalogChangeCounts(changes []catalogChangeDTO) (changed, missing int) {
|
|
for _, change := range changes {
|
|
if change.Status == "changed" {
|
|
changed++
|
|
} else if change.Status == "missing" {
|
|
missing++
|
|
}
|
|
}
|
|
return changed, missing
|
|
}
|
|
|
|
func sameCatalogPolicy(left, right pricing.Policy) bool {
|
|
if left.Model != right.Model || left.Base != right.Base || left.FastPricingEnabled != right.FastPricingEnabled || left.FastMultiplier != right.FastMultiplier {
|
|
return false
|
|
}
|
|
if left.LongContext == nil || right.LongContext == nil {
|
|
return left.LongContext == nil && right.LongContext == nil
|
|
}
|
|
return *left.LongContext == *right.LongContext
|
|
}
|
|
|
|
func catalogEntryToDTO(entry modelcatalog.Entry) catalogEntryDTO {
|
|
dto := catalogEntryDTO{
|
|
ID: entry.ID, Provider: entry.Provider, ProviderName: entry.ProviderName,
|
|
Model: entry.Model, ModelName: entry.ModelName, Base: ratesToDTO(entry.Base),
|
|
}
|
|
if entry.LongContext != nil {
|
|
dto.LongContext = &longContextDTO{
|
|
ThresholdInputTokens: entry.LongContext.ThresholdInputTokens,
|
|
Comparison: entry.LongContext.Comparison, priceRatesDTO: ratesToDTO(entry.LongContext.Rates),
|
|
}
|
|
}
|
|
return dto
|
|
}
|
|
|
|
func catalogManagementError(status int, code, message string) ManagementResponse {
|
|
return jsonManagementResponse(status, map[string]any{"error": map[string]string{"code": code, "message": message}})
|
|
}
|