mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-08-06 10:21:11 +00:00
363 lines
12 KiB
Go
363 lines
12 KiB
Go
package pg
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"math/big"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/nextlevelbuilder/goclaw/internal/store"
|
|
)
|
|
|
|
type PGUsageCapStore struct {
|
|
db *sql.DB
|
|
}
|
|
|
|
func NewPGUsageCapStore(db *sql.DB) *PGUsageCapStore {
|
|
return &PGUsageCapStore{db: db}
|
|
}
|
|
|
|
func (s *PGUsageCapStore) UpsertPricingCatalog(ctx context.Context, entries []store.UsagePricingCatalogEntry) (int, error) {
|
|
const q = `
|
|
INSERT INTO usage_pricing_catalog (
|
|
model_id, canonical_model_id, raw_pricing, raw_model,
|
|
input_price, output_price, cache_read_price, cache_write_price,
|
|
reasoning_price, request_price, image_price, web_search_price, synced_at
|
|
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)
|
|
ON CONFLICT (model_id) DO UPDATE SET
|
|
canonical_model_id = EXCLUDED.canonical_model_id,
|
|
raw_pricing = EXCLUDED.raw_pricing,
|
|
raw_model = EXCLUDED.raw_model,
|
|
input_price = EXCLUDED.input_price,
|
|
output_price = EXCLUDED.output_price,
|
|
cache_read_price = EXCLUDED.cache_read_price,
|
|
cache_write_price = EXCLUDED.cache_write_price,
|
|
reasoning_price = EXCLUDED.reasoning_price,
|
|
request_price = EXCLUDED.request_price,
|
|
image_price = EXCLUDED.image_price,
|
|
web_search_price = EXCLUDED.web_search_price,
|
|
synced_at = EXCLUDED.synced_at,
|
|
updated_at = now()`
|
|
for _, e := range entries {
|
|
if strings.TrimSpace(e.ModelID) == "" {
|
|
continue
|
|
}
|
|
if err := validateUsagePricingFields(e.Pricing); err != nil {
|
|
return 0, err
|
|
}
|
|
if len(e.RawPricing) == 0 {
|
|
e.RawPricing = json.RawMessage(`{}`)
|
|
}
|
|
if len(e.RawModel) == 0 {
|
|
e.RawModel = json.RawMessage(`{}`)
|
|
}
|
|
if _, err := s.db.ExecContext(ctx, q,
|
|
e.ModelID, nullEmpty(e.CanonicalModelID), e.RawPricing, e.RawModel,
|
|
priceVal(e.Pricing.Input), priceVal(e.Pricing.Output),
|
|
priceVal(e.Pricing.CacheRead), priceVal(e.Pricing.CacheWrite),
|
|
priceVal(e.Pricing.Reasoning), priceVal(e.Pricing.Request),
|
|
priceVal(e.Pricing.Image), priceVal(e.Pricing.WebSearch), e.SyncedAt,
|
|
); err != nil {
|
|
return 0, err
|
|
}
|
|
}
|
|
return len(entries), nil
|
|
}
|
|
|
|
func (s *PGUsageCapStore) ListPricingCatalog(ctx context.Context, q store.UsagePricingQuery) ([]store.UsagePricingCatalogEntry, error) {
|
|
limit := q.Limit
|
|
if limit <= 0 || limit > 500 {
|
|
limit = 100
|
|
}
|
|
args := []any{}
|
|
where := "TRUE"
|
|
if q.ModelID != "" {
|
|
args = append(args, "%"+q.ModelID+"%")
|
|
where = "model_id ILIKE $1"
|
|
}
|
|
args = append(args, limit)
|
|
rows, err := s.db.QueryContext(ctx, `
|
|
SELECT id, model_id, COALESCE(canonical_model_id,''), raw_pricing, raw_model,
|
|
input_price::text, output_price::text, cache_read_price::text, cache_write_price::text,
|
|
reasoning_price::text, request_price::text, image_price::text, web_search_price::text,
|
|
synced_at, created_at, updated_at
|
|
FROM usage_pricing_catalog
|
|
WHERE `+where+`
|
|
ORDER BY model_id
|
|
LIMIT $`+strconv.Itoa(len(args))+``, args...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var out []store.UsagePricingCatalogEntry
|
|
for rows.Next() {
|
|
e, err := scanCatalog(rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, e)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func (s *PGUsageCapStore) PutPricingOverride(ctx context.Context, o *store.UsagePricingOverride) error {
|
|
if o.ID == uuid.Nil {
|
|
o.ID = uuid.New()
|
|
}
|
|
if o.ProviderID == uuid.Nil || o.TenantID == uuid.Nil || o.ModelID == "" {
|
|
return errors.New("tenant_id, provider_id, and model_id are required")
|
|
}
|
|
if err := validateUsagePricingFields(o.Pricing); err != nil {
|
|
return err
|
|
}
|
|
if err := s.validateUsageCapRefs(ctx, o.TenantID, nil, &o.ProviderID); err != nil {
|
|
return err
|
|
}
|
|
const q = `
|
|
INSERT INTO usage_pricing_overrides (
|
|
id, tenant_id, provider_id, provider_type, model_id,
|
|
input_price, output_price, cache_read_price, cache_write_price,
|
|
reasoning_price, request_price, image_price, web_search_price, enabled
|
|
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)
|
|
ON CONFLICT (tenant_id, provider_id, model_id) DO UPDATE SET
|
|
provider_type = EXCLUDED.provider_type,
|
|
input_price = EXCLUDED.input_price,
|
|
output_price = EXCLUDED.output_price,
|
|
cache_read_price = EXCLUDED.cache_read_price,
|
|
cache_write_price = EXCLUDED.cache_write_price,
|
|
reasoning_price = EXCLUDED.reasoning_price,
|
|
request_price = EXCLUDED.request_price,
|
|
image_price = EXCLUDED.image_price,
|
|
web_search_price = EXCLUDED.web_search_price,
|
|
enabled = EXCLUDED.enabled,
|
|
updated_at = now()
|
|
RETURNING id, created_at, updated_at`
|
|
return s.db.QueryRowContext(ctx, q,
|
|
o.ID, o.TenantID, o.ProviderID, o.ProviderType, o.ModelID,
|
|
priceVal(o.Pricing.Input), priceVal(o.Pricing.Output),
|
|
priceVal(o.Pricing.CacheRead), priceVal(o.Pricing.CacheWrite),
|
|
priceVal(o.Pricing.Reasoning), priceVal(o.Pricing.Request),
|
|
priceVal(o.Pricing.Image), priceVal(o.Pricing.WebSearch), o.Enabled,
|
|
).Scan(&o.ID, &o.CreatedAt, &o.UpdatedAt)
|
|
}
|
|
|
|
func (s *PGUsageCapStore) ListPricingOverrides(ctx context.Context, q store.UsagePricingQuery) ([]store.UsagePricingOverride, error) {
|
|
args := []any{q.TenantID}
|
|
where := "tenant_id = $1"
|
|
if q.ProviderID != uuid.Nil {
|
|
args = append(args, q.ProviderID)
|
|
where += " AND provider_id = $" + itoa(len(args))
|
|
}
|
|
rows, err := s.db.QueryContext(ctx, overrideSelectSQL+" WHERE "+where+" ORDER BY updated_at DESC", args...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var out []store.UsagePricingOverride
|
|
for rows.Next() {
|
|
o, err := scanOverride(rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, o)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func (s *PGUsageCapStore) DeletePricingOverride(ctx context.Context, tenantID, id uuid.UUID) error {
|
|
_, err := s.db.ExecContext(ctx, `DELETE FROM usage_pricing_overrides WHERE tenant_id=$1 AND id=$2`, tenantID, id)
|
|
return err
|
|
}
|
|
|
|
func (s *PGUsageCapStore) ResolvePricing(ctx context.Context, tenantID, providerID uuid.UUID, providerName, providerType, modelID string) (*store.ResolvedUsagePricing, error) {
|
|
candidates := usagePricingModelCandidates(providerName, providerType, modelID)
|
|
if tenantID != uuid.Nil && providerID != uuid.Nil {
|
|
for _, candidate := range candidates {
|
|
row := s.db.QueryRowContext(ctx, overrideSelectSQL+` WHERE tenant_id=$1 AND provider_id=$2 AND model_id=$3 AND enabled=true`, tenantID, providerID, candidate)
|
|
if o, err := scanOverride(row); err == nil {
|
|
return &store.ResolvedUsagePricing{ModelID: o.ModelID, ProviderID: providerID, ProviderType: providerType, Source: "override", Pricing: o.Pricing, OverrideID: o.ID}, nil
|
|
} else if !errors.Is(err, sql.ErrNoRows) {
|
|
return nil, err
|
|
}
|
|
}
|
|
}
|
|
for _, candidate := range candidates {
|
|
row := s.db.QueryRowContext(ctx, `
|
|
SELECT id, model_id, COALESCE(canonical_model_id,''), raw_pricing, raw_model,
|
|
input_price::text, output_price::text, cache_read_price::text, cache_write_price::text,
|
|
reasoning_price::text, request_price::text, image_price::text, web_search_price::text,
|
|
synced_at, created_at, updated_at
|
|
FROM usage_pricing_catalog WHERE model_id=$1 OR canonical_model_id=$1 LIMIT 1`, candidate)
|
|
e, err := scanCatalog(row)
|
|
if err == nil {
|
|
return &store.ResolvedUsagePricing{ModelID: e.ModelID, ProviderID: providerID, ProviderType: providerType, Source: "catalog", Pricing: e.Pricing, CatalogSynced: &e.SyncedAt}, nil
|
|
}
|
|
if !errors.Is(err, sql.ErrNoRows) {
|
|
return nil, err
|
|
}
|
|
}
|
|
return nil, sql.ErrNoRows
|
|
}
|
|
|
|
func usagePricingModelCandidates(providerName, providerType, modelID string) []string {
|
|
modelID = strings.TrimSpace(modelID)
|
|
if modelID == "" {
|
|
return nil
|
|
}
|
|
out := []string{modelID}
|
|
if strings.Contains(modelID, "/") {
|
|
return out
|
|
}
|
|
for _, prefix := range openRouterProviderPrefixes(providerName, providerType) {
|
|
out = appendUniqueString(out, prefix+"/"+modelID)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func openRouterProviderPrefixes(providerName, providerType string) []string {
|
|
switch providerType {
|
|
case store.ProviderAnthropicNative:
|
|
return []string{"anthropic"}
|
|
case store.ProviderGeminiNative, store.ProviderVertex:
|
|
return []string{"google"}
|
|
case store.ProviderOpenAICompat:
|
|
switch normalizeProviderAlias(providerName) {
|
|
case "openai", "azure", "azure-openai", "azure_openai":
|
|
return []string{"openai"}
|
|
case "anthropic":
|
|
return []string{"anthropic"}
|
|
case "gemini", "google", "vertex":
|
|
return []string{"google"}
|
|
}
|
|
return nil
|
|
case store.ProviderOpenRouter:
|
|
return nil
|
|
case store.ProviderGroq:
|
|
return []string{"groq"}
|
|
case store.ProviderDeepSeek:
|
|
return []string{"deepseek"}
|
|
case store.ProviderMistral:
|
|
return []string{"mistralai"}
|
|
case store.ProviderXAI:
|
|
return []string{"x-ai"}
|
|
case store.ProviderMiniMax:
|
|
return []string{"minimax"}
|
|
case store.ProviderCohere:
|
|
return []string{"cohere"}
|
|
case store.ProviderPerplexity:
|
|
return []string{"perplexity"}
|
|
case store.ProviderDashScope:
|
|
return []string{"qwen"}
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func normalizeProviderAlias(s string) string {
|
|
s = strings.ToLower(strings.TrimSpace(s))
|
|
s = strings.ReplaceAll(s, " ", "-")
|
|
return s
|
|
}
|
|
|
|
func appendUniqueString(values []string, next string) []string {
|
|
next = strings.TrimSpace(next)
|
|
if next == "" {
|
|
return values
|
|
}
|
|
for _, existing := range values {
|
|
if existing == next {
|
|
return values
|
|
}
|
|
}
|
|
return append(values, next)
|
|
}
|
|
|
|
const overrideSelectSQL = `SELECT id, tenant_id, provider_id, provider_type, model_id,
|
|
input_price::text, output_price::text, cache_read_price::text, cache_write_price::text,
|
|
reasoning_price::text, request_price::text, image_price::text, web_search_price::text,
|
|
enabled, created_at, updated_at FROM usage_pricing_overrides`
|
|
|
|
type scanner interface{ Scan(dest ...any) error }
|
|
|
|
func scanOverride(row scanner) (store.UsagePricingOverride, error) {
|
|
var o store.UsagePricingOverride
|
|
var prices [8]sql.NullString
|
|
err := row.Scan(&o.ID, &o.TenantID, &o.ProviderID, &o.ProviderType, &o.ModelID,
|
|
&prices[0], &prices[1], &prices[2], &prices[3], &prices[4], &prices[5], &prices[6], &prices[7],
|
|
&o.Enabled, &o.CreatedAt, &o.UpdatedAt)
|
|
o.Pricing = pricingFromNulls(prices)
|
|
return o, err
|
|
}
|
|
|
|
func scanCatalog(row scanner) (store.UsagePricingCatalogEntry, error) {
|
|
var e store.UsagePricingCatalogEntry
|
|
var prices [8]sql.NullString
|
|
err := row.Scan(&e.ID, &e.ModelID, &e.CanonicalModelID, &e.RawPricing, &e.RawModel,
|
|
&prices[0], &prices[1], &prices[2], &prices[3], &prices[4], &prices[5], &prices[6], &prices[7],
|
|
&e.SyncedAt, &e.CreatedAt, &e.UpdatedAt)
|
|
e.Pricing = pricingFromNulls(prices)
|
|
return e, err
|
|
}
|
|
|
|
func pricingFromNulls(p [8]sql.NullString) store.UsagePricingFields {
|
|
return store.UsagePricingFields{
|
|
Input: pricePtr(p[0]), Output: pricePtr(p[1]),
|
|
CacheRead: pricePtr(p[2]), CacheWrite: pricePtr(p[3]),
|
|
Reasoning: pricePtr(p[4]), Request: pricePtr(p[5]),
|
|
Image: pricePtr(p[6]), WebSearch: pricePtr(p[7]),
|
|
}
|
|
}
|
|
|
|
func pricePtr(v sql.NullString) *string {
|
|
if !v.Valid {
|
|
return nil
|
|
}
|
|
s := v.String
|
|
return &s
|
|
}
|
|
|
|
func priceVal(v *string) any {
|
|
if v == nil || strings.TrimSpace(*v) == "" {
|
|
return nil
|
|
}
|
|
return strings.TrimSpace(*v)
|
|
}
|
|
|
|
func validateUsagePricingFields(fields store.UsagePricingFields) error {
|
|
values := map[string]*string{
|
|
"input": fields.Input,
|
|
"output": fields.Output,
|
|
"cache_read": fields.CacheRead,
|
|
"cache_write": fields.CacheWrite,
|
|
"reasoning": fields.Reasoning,
|
|
"request": fields.Request,
|
|
"image": fields.Image,
|
|
"web_search": fields.WebSearch,
|
|
}
|
|
for name, raw := range values {
|
|
if raw == nil || strings.TrimSpace(*raw) == "" {
|
|
continue
|
|
}
|
|
rat, ok := new(big.Rat).SetString(strings.TrimSpace(*raw))
|
|
if !ok {
|
|
return fmt.Errorf("invalid %s price", name)
|
|
}
|
|
if rat.Sign() < 0 {
|
|
return fmt.Errorf("%s price must be non-negative", name)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func nullEmpty(s string) any {
|
|
if strings.TrimSpace(s) == "" {
|
|
return nil
|
|
}
|
|
return s
|
|
}
|