feat: improve model catalog aggregation

This commit is contained in:
2026-05-11 17:44:57 +08:00
parent ec87816c95
commit 0431cb8157
41 changed files with 4745 additions and 550 deletions
+13 -7
View File
@@ -17,7 +17,7 @@ SELECT p.id::text, p.platform_key, p.name, p.provider,
p.retry_policy, p.rate_limit_policy,
COALESCE(p.dynamic_priority, p.priority) AS effective_priority,
m.id::text, COALESCE(m.base_model_id::text, ''), COALESCE(b.canonical_model_key, ''),
COALESCE(b.provider_model_name, ''), m.model_name, COALESCE(m.model_alias, ''),
COALESCE(NULLIF(m.provider_model_name, ''), m.model_name), m.model_name, COALESCE(m.model_alias, ''),
$2 AS requested_model_type, m.display_name, m.capabilities, m.capability_override,
COALESCE(b.base_billing_config, '{}'::jsonb), m.billing_config, m.billing_config_override,
m.pricing_mode, COALESCE(m.discount_factor, 0)::float8, COALESCE(m.pricing_rule_set_id::text, ''),
@@ -32,17 +32,22 @@ LEFT JOIN model_catalog_providers cp ON cp.provider_key = p.provider OR cp.provi
LEFT JOIN base_model_catalog b ON b.id = m.base_model_id
LEFT JOIN model_runtime_policy_sets rp ON rp.id = COALESCE(m.runtime_policy_set_id, b.runtime_policy_set_id)
LEFT JOIN runtime_client_states s
ON s.client_id = p.platform_key || ':' || $2 || ':' || m.model_name
ON s.client_id = p.platform_key || ':' || $2 || ':' || COALESCE(NULLIF(m.provider_model_name, ''), m.model_name)
WHERE p.status = 'enabled'
AND p.deleted_at IS NULL
AND m.enabled = true
AND m.model_type @> jsonb_build_array($2)
AND (p.cooldown_until IS NULL OR p.cooldown_until <= now())
AND (
m.model_name = $1
OR m.model_alias = $1
OR b.canonical_model_key = $1
OR b.provider_model_name = $1
(COALESCE(m.model_alias, '') <> '' AND m.model_alias = $1)
OR (
COALESCE(m.model_alias, '') = ''
AND (
m.model_name = $1
OR b.canonical_model_key = $1
OR b.provider_model_name = $1
)
)
)
ORDER BY effective_priority ASC,
COALESCE(s.limiter_ratio, 0) ASC,
@@ -137,7 +142,8 @@ ORDER BY effective_priority ASC,
item.RuntimeRateLimitPolicy = decodeObject(runtimeRateLimitPolicy)
item.AutoDisablePolicy = decodeObject(autoDisablePolicy)
item.DegradePolicy = decodeObject(degradePolicy)
item.ClientID = fmt.Sprintf("%s:%s:%s", item.PlatformKey, item.ModelType, item.ModelName)
upstreamModelName := firstNonEmpty(item.ProviderModelName, item.ModelName)
item.ClientID = fmt.Sprintf("%s:%s:%s", item.PlatformKey, item.ModelType, upstreamModelName)
item.QueueKey = item.ClientID
items = append(items, item)
}
@@ -0,0 +1,147 @@
package store
import "strings"
func FilterPlatformModelBillingConfigs(models []PlatformModel) []PlatformModel {
filtered := make([]PlatformModel, len(models))
for i, model := range models {
filtered[i] = FilterPlatformModelBillingConfig(model)
}
return filtered
}
func FilterPlatformModelBillingConfig(model PlatformModel) PlatformModel {
model.BillingConfig = filterBillingConfigByModelTypes(model.BillingConfig, model.ModelType)
model.BillingConfigOverride = filterBillingConfigByModelTypes(model.BillingConfigOverride, model.ModelType)
return model
}
func filterBillingConfigByModelTypes(config map[string]any, modelTypes []string) map[string]any {
if len(config) == 0 {
return nil
}
resources := billingResourcesForModelTypes(modelTypes)
if len(resources) == 0 {
return cloneBillingConfig(config)
}
filtered := map[string]any{}
for key, value := range config {
if billingConfigKeyAllowed(key, resources) {
filtered[key] = value
}
}
if resources["image_edit"] && !hasAnyKey(filtered, "image_edit", "editBase") && !hasAnyKey(config, "image_edit", "editBase") {
copyBillingConfigKey(filtered, config, "image")
copyBillingConfigKey(filtered, config, "imageBase")
}
if len(filtered) == 0 {
return nil
}
return filtered
}
func billingResourcesForModelTypes(modelTypes []string) map[string]bool {
resources := map[string]bool{}
for _, modelType := range modelTypes {
switch normalizeBillingType(modelType) {
case "chat", "text", "responses", "text_generate", "text_embedding", "embedding",
"image_analysis", "video_understanding", "audio_understanding", "omni", "tools_call":
resources["text"] = true
case "image", "images.generations", "image_generate":
resources["image"] = true
case "images.edits", "image_edit":
resources["image_edit"] = true
case "video", "videos.generations", "video_generate", "image_to_video", "text_to_video",
"video_edit", "omni_video", "video_reference", "video_first_last_frame":
resources["video"] = true
case "audio", "audio_generate", "text_to_speech", "speech":
resources["audio"] = true
case "music", "music_generate":
resources["music"] = true
case "digital_human", "digital_human_generate":
resources["digital_human"] = true
case "model", "model_3d", "text_to_model", "image_to_model", "multiview_to_model", "mesh_edit":
resources["model"] = true
default:
inferBillingResources(modelType, resources)
}
}
return resources
}
func inferBillingResources(modelType string, resources map[string]bool) {
normalized := normalizeBillingType(modelType)
switch {
case strings.Contains(normalized, "digital_human"):
resources["digital_human"] = true
case strings.Contains(normalized, "video"):
resources["video"] = true
case strings.Contains(normalized, "image"):
resources["image"] = true
case strings.Contains(normalized, "audio") || strings.Contains(normalized, "speech"):
resources["audio"] = true
case strings.Contains(normalized, "music"):
resources["music"] = true
case strings.Contains(normalized, "model") || strings.Contains(normalized, "mesh"):
resources["model"] = true
case strings.Contains(normalized, "text") || strings.Contains(normalized, "token"):
resources["text"] = true
}
}
func billingConfigKeyAllowed(key string, resources map[string]bool) bool {
switch normalizeBillingConfigKey(key) {
case "text", "texttotal", "text_total", "textinputper1k", "textoutputper1k", "textinput", "textoutput", "text_input", "text_output", "inputtokenprice", "outputtokenprice":
return resources["text"]
case "image", "imagebase":
return resources["image"]
case "image_edit", "imageedit", "editbase":
return resources["image_edit"]
case "video", "videobase":
return resources["video"]
case "audio", "audiobase":
return resources["audio"]
case "music", "musicbase":
return resources["music"]
case "digital_human", "digitalhuman", "digitalhumanbase":
return resources["digital_human"]
case "model", "modelbase":
return resources["model"]
default:
return false
}
}
func normalizeBillingType(value string) string {
return strings.ToLower(strings.TrimSpace(value))
}
func normalizeBillingConfigKey(value string) string {
return strings.ReplaceAll(strings.ReplaceAll(normalizeBillingType(value), "-", "_"), " ", "")
}
func cloneBillingConfig(config map[string]any) map[string]any {
clone := make(map[string]any, len(config))
for key, value := range config {
clone[key] = value
}
return clone
}
func hasAnyKey(config map[string]any, keys ...string) bool {
for _, key := range keys {
if _, ok := config[key]; ok {
return true
}
}
return false
}
func copyBillingConfigKey(target map[string]any, source map[string]any, key string) {
if value, ok := source[key]; ok {
target[key] = value
}
}
@@ -0,0 +1,99 @@
package store
import "testing"
func TestFilterPlatformModelBillingConfigKeepsOnlyImagePricing(t *testing.T) {
model := PlatformModel{
ModelType: StringList{"image_generate"},
BillingConfig: map[string]any{
"text": map[string]any{"basePrice": 0.01},
"image": map[string]any{"basePrice": 10},
"image_edit": map[string]any{"basePrice": 12},
"video": map[string]any{"basePrice": 100},
},
BillingConfigOverride: map[string]any{
"image": map[string]any{"basePrice": 8},
"video": map[string]any{"basePrice": 80},
},
}
filtered := FilterPlatformModelBillingConfig(model)
assertHasKeys(t, filtered.BillingConfig, "image")
assertMissingKeys(t, filtered.BillingConfig, "text", "image_edit", "video")
assertHasKeys(t, filtered.BillingConfigOverride, "image")
assertMissingKeys(t, filtered.BillingConfigOverride, "video")
}
func TestFilterPlatformModelBillingConfigKeepsImageEditOrImageFallback(t *testing.T) {
model := PlatformModel{
ModelType: StringList{"image_edit"},
BillingConfig: map[string]any{
"image": map[string]any{"basePrice": 10},
"video": map[string]any{"basePrice": 100},
},
}
filtered := FilterPlatformModelBillingConfig(model)
assertHasKeys(t, filtered.BillingConfig, "image")
assertMissingKeys(t, filtered.BillingConfig, "video")
}
func TestFilterPlatformModelBillingConfigKeepsVideoPricing(t *testing.T) {
model := PlatformModel{
ModelType: StringList{"video_generate", "image_to_video"},
BillingConfig: map[string]any{
"image": map[string]any{"basePrice": 10},
"video": map[string]any{"basePrice": 100},
"videoBase": 100,
},
}
filtered := FilterPlatformModelBillingConfig(model)
assertHasKeys(t, filtered.BillingConfig, "video", "videoBase")
assertMissingKeys(t, filtered.BillingConfig, "image")
}
func TestFilterPlatformModelBillingConfigKeepsTextFlatPricing(t *testing.T) {
model := PlatformModel{
ModelType: StringList{"text_generate"},
BillingConfig: map[string]any{
"textInputPer1k": 0.01,
"textOutputPer1k": 0.02,
"text_total": map[string]any{
"basePrice": 0.01,
"formulaConfig": map[string]any{"inputTokenPrice": 0.01, "outputTokenPrice": 0.02},
"dynamicWeight": map[string]any{"cached": 0.5},
"dimensionSchema": map[string]any{"unit": "1k_tokens"},
},
"inputTokenPrice": 0.01,
"outputTokenPrice": 0.02,
"image": map[string]any{"basePrice": 10},
},
}
filtered := FilterPlatformModelBillingConfig(model)
assertHasKeys(t, filtered.BillingConfig, "textInputPer1k", "textOutputPer1k", "text_total", "inputTokenPrice", "outputTokenPrice")
assertMissingKeys(t, filtered.BillingConfig, "image")
}
func assertHasKeys(t *testing.T, value map[string]any, keys ...string) {
t.Helper()
for _, key := range keys {
if _, ok := value[key]; !ok {
t.Fatalf("expected key %q in %#v", key, value)
}
}
}
func assertMissingKeys(t *testing.T, value map[string]any, keys ...string) {
t.Helper()
for _, key := range keys {
if _, ok := value[key]; ok {
t.Fatalf("expected key %q to be filtered from %#v", key, value)
}
}
}
+14 -6
View File
@@ -79,6 +79,8 @@ WHERE resource_type = 'platform_model'
}
func (s *Store) createPlatformModel(ctx context.Context, q platformModelQuerier, input CreatePlatformModelInput) (PlatformModel, error) {
input.ModelName = strings.TrimSpace(input.ModelName)
input.ProviderModelName = strings.TrimSpace(input.ProviderModelName)
base, err := s.lookupBaseModel(ctx, q, input.BaseModelID, input.CanonicalModelKey, input.ModelName)
if err != nil && !IsNotFound(err) {
return PlatformModel{}, err
@@ -93,6 +95,9 @@ func (s *Store) createPlatformModel(ctx context.Context, q platformModelQuerier,
if input.ModelName == "" {
input.ModelName = base.ProviderModelName
}
if input.ProviderModelName == "" {
input.ProviderModelName = input.ModelName
}
if input.DisplayName == "" {
input.DisplayName = firstNonEmpty(base.DisplayName, input.ModelName)
}
@@ -153,19 +158,20 @@ func (s *Store) createPlatformModel(ctx context.Context, q platformModelQuerier,
var modelTypeBytes []byte
err = q.QueryRow(ctx, `
INSERT INTO platform_models (
platform_id, base_model_id, model_name, model_alias, model_type, display_name,
platform_id, base_model_id, model_name, provider_model_name, model_alias, model_type, display_name,
capability_override, capabilities, pricing_mode, discount_factor,
pricing_rule_set_id, billing_config_override, billing_config, permission_config, retry_policy, rate_limit_policy,
runtime_policy_set_id, runtime_policy_override, enabled
)
VALUES (
$1::uuid, $2::uuid, $3, NULLIF($4, ''), $5::jsonb, $6,
$7::jsonb, $8::jsonb, $9, $10::numeric,
NULLIF($11, '')::uuid, $12::jsonb, $13::jsonb, $14::jsonb, $15::jsonb, $16::jsonb,
NULLIF($17, '')::uuid, $18::jsonb, true
$1::uuid, $2::uuid, $3, NULLIF($4, ''), NULLIF($5, ''), $6::jsonb, $7,
$8::jsonb, $9::jsonb, $10, $11::numeric,
NULLIF($12, '')::uuid, $13::jsonb, $14::jsonb, $15::jsonb, $16::jsonb, $17::jsonb,
NULLIF($18, '')::uuid, $19::jsonb, true
)
ON CONFLICT (platform_id, model_name) DO UPDATE
SET base_model_id = EXCLUDED.base_model_id,
provider_model_name = EXCLUDED.provider_model_name,
model_alias = EXCLUDED.model_alias,
display_name = EXCLUDED.display_name,
capability_override = EXCLUDED.capability_override,
@@ -183,7 +189,7 @@ SET base_model_id = EXCLUDED.base_model_id,
enabled = true,
updated_at = now()
RETURNING id::text, platform_id::text, COALESCE(base_model_id::text, ''), model_name,
COALESCE(model_alias, ''), model_type, display_name, capability_override,
COALESCE(NULLIF(provider_model_name, ''), model_name), COALESCE(model_alias, ''), model_type, display_name, capability_override,
capabilities, pricing_mode, COALESCE(discount_factor, 0)::float8,
COALESCE(pricing_rule_set_id::text, ''), billing_config_override, billing_config,
permission_config, retry_policy, rate_limit_policy, COALESCE(runtime_policy_set_id::text, ''), runtime_policy_override,
@@ -191,6 +197,7 @@ RETURNING id::text, platform_id::text, COALESCE(base_model_id::text, ''), model_
input.PlatformID,
baseID,
input.ModelName,
input.ProviderModelName,
input.ModelAlias,
string(modelTypeJSON),
input.DisplayName,
@@ -211,6 +218,7 @@ RETURNING id::text, platform_id::text, COALESCE(base_model_id::text, ''), model_
&model.PlatformID,
&model.BaseModelID,
&model.ModelName,
&model.ProviderModelName,
&model.ModelAlias,
&modelTypeBytes,
&model.DisplayName,
+3 -1
View File
@@ -133,6 +133,7 @@ type PlatformModel struct {
Provider string `json:"provider,omitempty"`
PlatformName string `json:"platformName,omitempty"`
ModelName string `json:"modelName"`
ProviderModelName string `json:"providerModelName,omitempty"`
ModelAlias string `json:"modelAlias,omitempty"`
ModelType StringList `json:"modelType"`
DisplayName string `json:"displayName"`
@@ -691,7 +692,7 @@ func (s *Store) listModels(ctx context.Context, platformID string) ([]PlatformMo
rows, err := s.pool.Query(ctx, `
SELECT m.id::text, m.platform_id::text, COALESCE(m.base_model_id::text, ''), p.provider, p.name,
m.model_name, COALESCE(m.model_alias, ''), m.model_type, m.display_name,
m.model_name, COALESCE(NULLIF(m.provider_model_name, ''), m.model_name), COALESCE(m.model_alias, ''), m.model_type, m.display_name,
m.capability_override, m.capabilities, m.pricing_mode, COALESCE(m.discount_factor, 0)::float8,
COALESCE(m.pricing_rule_set_id::text, ''), m.billing_config_override, m.billing_config,
m.permission_config, m.retry_policy, m.rate_limit_policy, COALESCE(m.runtime_policy_set_id::text, ''), m.runtime_policy_override,
@@ -724,6 +725,7 @@ ORDER BY m.model_type ASC, m.model_name ASC`, args...)
&model.Provider,
&model.PlatformName,
&model.ModelName,
&model.ProviderModelName,
&model.ModelAlias,
&modelTypeBytes,
&model.DisplayName,
+58 -2
View File
@@ -3,6 +3,7 @@ package store
import (
"context"
"encoding/json"
"strconv"
"strings"
"github.com/jackc/pgx/v5"
@@ -182,7 +183,7 @@ func (s *Store) PricingRuleSetBillingConfig(ctx context.Context, id string) (map
return nil, nil
}
rows, err := s.pool.Query(ctx, `
SELECT resource_type, base_price::float8, dynamic_weight
SELECT resource_type, base_price::float8, dynamic_weight, formula_config
FROM model_pricing_rules
WHERE rule_set_id = $1::uuid
AND status = 'active'
@@ -197,15 +198,31 @@ ORDER BY priority ASC, resource_type ASC`, id)
var resourceType string
var basePrice float64
var dynamicWeightBytes []byte
if err := rows.Scan(&resourceType, &basePrice, &dynamicWeightBytes); err != nil {
var formulaConfigBytes []byte
if err := rows.Scan(&resourceType, &basePrice, &dynamicWeightBytes, &formulaConfigBytes); err != nil {
return nil, err
}
dynamicWeight := decodeObject(dynamicWeightBytes)
formulaConfig := decodeObject(formulaConfigBytes)
switch resourceType {
case "text_input":
config["textInputPer1k"] = basePrice
case "text_output":
config["textOutputPer1k"] = basePrice
case "text_total":
inputPrice := basePrice
if value, ok := pricingRuleNumberFromKeys(formulaConfig, "inputTokenPrice", "input_token_price", "textInputPer1k", "text_input"); ok {
inputPrice = value
}
config["textInputPer1k"] = inputPrice
if outputPrice, ok := pricingRuleNumberFromKeys(formulaConfig, "outputTokenPrice", "output_token_price", "textOutputPer1k", "text_output"); ok {
config["textOutputPer1k"] = outputPrice
}
resourceConfig := pricingResourceConfig(basePrice, dynamicWeight)
if len(formulaConfig) > 0 {
resourceConfig["formulaConfig"] = formulaConfig
}
config["text_total"] = resourceConfig
case "image":
config["imageBase"] = basePrice
config["image"] = pricingResourceConfig(basePrice, dynamicWeight)
@@ -225,6 +242,45 @@ ORDER BY priority ASC, resource_type ASC`, id)
return config, nil
}
func pricingRuleNumberFromKeys(config map[string]any, keys ...string) (float64, bool) {
if len(config) == 0 {
return 0, false
}
for _, key := range keys {
if value, ok := pricingRuleNumberValue(config[key]); ok {
return value, true
}
}
return 0, false
}
func pricingRuleNumberValue(value any) (float64, bool) {
switch typed := value.(type) {
case float64:
return typed, true
case float32:
return float64(typed), true
case int:
return float64(typed), true
case int64:
return float64(typed), true
case int32:
return float64(typed), true
case json.Number:
number, err := typed.Float64()
return number, err == nil
case string:
trimmed := strings.TrimSpace(typed)
if trimmed == "" {
return 0, false
}
number, err := strconv.ParseFloat(trimmed, 64)
return number, err == nil
default:
return 0, false
}
}
func pricingResourceConfig(basePrice float64, dynamicWeight map[string]any) map[string]any {
config := map[string]any{"basePrice": basePrice}
if len(dynamicWeight) > 0 {
+1
View File
@@ -15,6 +15,7 @@ type CreatePlatformModelInput struct {
BaseModelID string `json:"baseModelId"`
CanonicalModelKey string `json:"canonicalModelKey"`
ModelName string `json:"modelName"`
ProviderModelName string `json:"providerModelName"`
ModelAlias string `json:"modelAlias"`
ModelType StringList `json:"modelType"`
DisplayName string `json:"displayName"`
+96 -13
View File
@@ -11,13 +11,35 @@ import (
"github.com/jackc/pgx/v5/pgconn"
)
func (s *Store) ListTasks(ctx context.Context, user *auth.User, limit int) ([]GatewayTask, error) {
if limit <= 0 {
limit = 50
type TaskListFilter struct {
Query string
ModelType string
CreatedFrom *time.Time
CreatedTo *time.Time
Page int
PageSize int
}
type TaskListResult struct {
Items []GatewayTask
Total int
Page int
PageSize int
}
func (s *Store) ListTasks(ctx context.Context, user *auth.User, filter TaskListFilter) (TaskListResult, error) {
page := filter.Page
if page <= 0 {
page = 1
}
if limit > 100 {
limit = 100
pageSize := filter.PageSize
if pageSize <= 0 {
pageSize = 50
}
if pageSize > 100 {
pageSize = 100
}
offset := (page - 1) * pageSize
gatewayUserID := localGatewayUserID(user)
apiKeyID := ""
userID := ""
@@ -26,11 +48,22 @@ func (s *Store) ListTasks(ctx context.Context, user *auth.User, limit int) ([]Ga
userID = strings.TrimSpace(user.ID)
}
if gatewayUserID == "" && userID == "" {
return nil, ErrLocalUserRequired
return TaskListResult{}, ErrLocalUserRequired
}
rows, err := s.pool.Query(ctx, `
SELECT `+gatewayTaskColumns+`
FROM gateway_tasks
queryPattern := ""
if query := strings.TrimSpace(filter.Query); query != "" {
queryPattern = "%" + query + "%"
}
args := []any{
gatewayUserID,
userID,
apiKeyID,
queryPattern,
strings.TrimSpace(filter.ModelType),
nullableTaskListTime(filter.CreatedFrom),
nullableTaskListTime(filter.CreatedTo),
}
whereSQL := `
WHERE (
(
NULLIF($1, '')::uuid IS NOT NULL
@@ -46,10 +79,45 @@ WHERE (
NULLIF($3, '') IS NULL
OR api_key_id = $3
)
AND (
NULLIF($4, '') IS NULL
OR id::text ILIKE $4
OR COALESCE(request_id, '') ILIKE $4
OR kind ILIKE $4
OR model ILIKE $4
OR COALESCE(requested_model, '') ILIKE $4
OR COALESCE(resolved_model, '') ILIKE $4
OR COALESCE(api_key_id, '') ILIKE $4
OR COALESCE(api_key_name, '') ILIKE $4
OR COALESCE(api_key_prefix, '') ILIKE $4
OR status ILIKE $4
OR COALESCE(model_type, '') ILIKE $4
)
AND (
NULLIF($5, '') IS NULL
OR model_type = $5
)
AND (
$6::timestamptz IS NULL
OR created_at >= $6::timestamptz
)
AND (
$7::timestamptz IS NULL
OR created_at <= $7::timestamptz
)`
var total int
if err := s.pool.QueryRow(ctx, `SELECT count(*) FROM gateway_tasks `+whereSQL, args...).Scan(&total); err != nil {
return TaskListResult{}, err
}
queryArgs := append(args, pageSize, offset)
rows, err := s.pool.Query(ctx, `
SELECT `+gatewayTaskColumns+`
FROM gateway_tasks
`+whereSQL+`
ORDER BY created_at DESC
LIMIT $4`, gatewayUserID, userID, apiKeyID, limit)
LIMIT $8 OFFSET $9`, queryArgs...)
if err != nil {
return nil, err
return TaskListResult{}, err
}
defer rows.Close()
@@ -57,11 +125,26 @@ LIMIT $4`, gatewayUserID, userID, apiKeyID, limit)
for rows.Next() {
task, err := scanGatewayTask(rows)
if err != nil {
return nil, err
return TaskListResult{}, err
}
items = append(items, task)
}
return items, rows.Err()
if err := rows.Err(); err != nil {
return TaskListResult{}, err
}
return TaskListResult{
Items: items,
Total: total,
Page: page,
PageSize: pageSize,
}, nil
}
func nullableTaskListTime(value *time.Time) any {
if value == nil {
return nil
}
return *value
}
func (s *Store) MarkTaskRunning(ctx context.Context, taskID string, modelType string, normalizedRequest map[string]any) error {