feat: improve model catalog aggregation
This commit is contained in:
@@ -264,7 +264,7 @@ func (s *Server) createPlatformModel(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusInternalServerError, "create platform model failed")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, model)
|
||||
writeJSON(w, http.StatusCreated, s.platformModelResponse(r.Context(), model))
|
||||
}
|
||||
|
||||
func (s *Server) replacePlatformModels(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -292,7 +292,7 @@ func (s *Server) replacePlatformModels(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusInternalServerError, "replace platform models failed")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"items": models})
|
||||
writeJSON(w, http.StatusOK, map[string]any{"items": s.platformModelResponses(r.Context(), models)})
|
||||
}
|
||||
|
||||
func (s *Server) deletePlatformModel(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -315,7 +315,7 @@ func (s *Server) listModels(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusInternalServerError, "list models failed")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"items": models})
|
||||
writeJSON(w, http.StatusOK, map[string]any{"items": s.platformModelResponses(r.Context(), models)})
|
||||
}
|
||||
|
||||
func (s *Server) listPlayableModels(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -326,7 +326,7 @@ func (s *Server) listPlayableModels(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusInternalServerError, "list playable models failed")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"items": models})
|
||||
writeJSON(w, http.StatusOK, map[string]any{"items": s.platformModelResponses(r.Context(), models)})
|
||||
}
|
||||
|
||||
func (s *Server) listPricingRules(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -623,22 +623,88 @@ func (s *Server) listTasks(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
limit := 50
|
||||
if raw := strings.TrimSpace(r.URL.Query().Get("limit")); raw != "" {
|
||||
parsed, err := strconv.Atoi(raw)
|
||||
if err != nil || parsed <= 0 {
|
||||
writeError(w, http.StatusBadRequest, "invalid limit")
|
||||
return
|
||||
}
|
||||
limit = parsed
|
||||
query := r.URL.Query()
|
||||
page, err := positiveQueryInt(query.Get("page"), 1)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid page")
|
||||
return
|
||||
}
|
||||
tasks, err := s.store.ListTasks(r.Context(), user, limit)
|
||||
pageSizeRaw := query.Get("pageSize")
|
||||
if pageSizeRaw == "" {
|
||||
pageSizeRaw = query.Get("limit")
|
||||
}
|
||||
pageSize, err := positiveQueryInt(pageSizeRaw, 50)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid pageSize")
|
||||
return
|
||||
}
|
||||
createdFrom, err := parseTaskListTime(query.Get("createdFrom"), query.Get("from"))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid createdFrom")
|
||||
return
|
||||
}
|
||||
createdTo, err := parseTaskListTime(query.Get("createdTo"), query.Get("to"))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid createdTo")
|
||||
return
|
||||
}
|
||||
result, err := s.store.ListTasks(r.Context(), user, store.TaskListFilter{
|
||||
Query: firstNonEmpty(query.Get("q"), query.Get("query")),
|
||||
ModelType: firstNonEmpty(query.Get("modelType"), query.Get("type")),
|
||||
CreatedFrom: createdFrom,
|
||||
CreatedTo: createdTo,
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
})
|
||||
if err != nil {
|
||||
s.logger.Error("list tasks failed", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "list tasks failed")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"items": tasks})
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"items": result.Items,
|
||||
"total": result.Total,
|
||||
"page": result.Page,
|
||||
"pageSize": result.PageSize,
|
||||
})
|
||||
}
|
||||
|
||||
func positiveQueryInt(raw string, fallback int) (int, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return fallback, nil
|
||||
}
|
||||
value, err := strconv.Atoi(raw)
|
||||
if err != nil || value <= 0 {
|
||||
return 0, fmt.Errorf("invalid positive integer")
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func parseTaskListTime(values ...string) (*time.Time, error) {
|
||||
raw := strings.TrimSpace(firstNonEmpty(values...))
|
||||
if raw == "" {
|
||||
return nil, nil
|
||||
}
|
||||
layouts := []string{time.RFC3339Nano, time.RFC3339, "2006-01-02T15:04", "2006-01-02 15:04:05", "2006-01-02"}
|
||||
var lastErr error
|
||||
for _, layout := range layouts {
|
||||
parsed, err := time.ParseInLocation(layout, raw, time.Local)
|
||||
if err == nil {
|
||||
return &parsed, nil
|
||||
}
|
||||
lastErr = err
|
||||
}
|
||||
return nil, lastErr
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if trimmed := strings.TrimSpace(value); trimmed != "" {
|
||||
return trimmed
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func boolValue(body map[string]any, key string) bool {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,221 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
||||
)
|
||||
|
||||
func TestBuildModelCatalogAggregatesSources(t *testing.T) {
|
||||
models := []store.PlatformModel{
|
||||
{
|
||||
ID: "model-a",
|
||||
PlatformID: "platform-a",
|
||||
ModelName: "seedance",
|
||||
ModelAlias: "Seedance-2.0",
|
||||
ModelType: store.StringList{"image_generate"},
|
||||
DisplayName: "Seedance Source A",
|
||||
BillingConfig: map[string]any{
|
||||
"image": map[string]any{"basePrice": float64(10), "dynamicWeight": map[string]any{"1K": float64(1), "2K": float64(2)}},
|
||||
},
|
||||
RateLimitPolicy: map[string]any{
|
||||
"platformLimits": map[string]any{
|
||||
"max_request_per_minute": 60,
|
||||
"max_token_per_minute": 1000,
|
||||
"max_concurrent_requests": 2,
|
||||
},
|
||||
},
|
||||
PricingMode: "inherit_discount",
|
||||
Enabled: true,
|
||||
},
|
||||
{
|
||||
ID: "model-b",
|
||||
PlatformID: "platform-b",
|
||||
ModelName: "seedance",
|
||||
ModelAlias: "Seedance-2.0",
|
||||
ModelType: store.StringList{"image_generate"},
|
||||
DisplayName: "Seedance Source B",
|
||||
BillingConfig: map[string]any{
|
||||
"image": map[string]any{"basePrice": float64(10), "dynamicWeight": map[string]any{"1K": float64(1), "2K": float64(2)}},
|
||||
},
|
||||
RateLimitPolicy: map[string]any{
|
||||
"rpm": 40,
|
||||
"tpm": 2000,
|
||||
"concurrent": 3,
|
||||
},
|
||||
DiscountFactor: 0.8,
|
||||
PricingMode: "custom",
|
||||
Enabled: true,
|
||||
},
|
||||
}
|
||||
platforms := []store.Platform{
|
||||
{ID: "platform-a", Provider: "volces", Name: "火山引擎", Status: "enabled", Priority: 20, DefaultDiscountFactor: 1},
|
||||
{ID: "platform-b", Provider: "gemini", Name: "Gemini", Status: "enabled", Priority: 10, DefaultDiscountFactor: 1},
|
||||
}
|
||||
providers := []store.CatalogProvider{
|
||||
{ProviderKey: "volces", DisplayName: "火山引擎", IconPath: "volces.png"},
|
||||
{ProviderKey: "gemini", DisplayName: "Google Gemini", IconPath: "gemini.png"},
|
||||
}
|
||||
accessRules := []store.AccessRule{
|
||||
{SubjectType: "user_group", SubjectID: "group-vip", ResourceType: "platform", ResourceID: "platform-b", Effect: "allow", Status: "active"},
|
||||
{SubjectType: "user_group", SubjectID: "group-blocked", ResourceType: "platform", ResourceID: "platform-a", Effect: "deny", Status: "active"},
|
||||
}
|
||||
userGroups := []store.UserGroup{
|
||||
{ID: "group-vip", GroupKey: "vip", Name: "VIP 用户组"},
|
||||
{ID: "group-blocked", GroupKey: "blocked", Name: "Blocked 用户组"},
|
||||
}
|
||||
baseModels := []store.BaseModel{
|
||||
{ID: "", Metadata: map[string]any{"description": "高质量图像生成模型"}},
|
||||
}
|
||||
|
||||
response := buildModelCatalog(models, platforms, providers, nil, accessRules, userGroups, baseModels)
|
||||
if response.Summary.ModelCount != 1 || response.Summary.SourceCount != 2 {
|
||||
t.Fatalf("unexpected summary: %+v", response.Summary)
|
||||
}
|
||||
item := response.Items[0]
|
||||
if item.SourceCount != 2 {
|
||||
t.Fatalf("expected merged source count, got %d", item.SourceCount)
|
||||
}
|
||||
if item.Source.Label != "2 个源" {
|
||||
t.Fatalf("expected source label to only show count, got %q", item.Source.Label)
|
||||
}
|
||||
if item.RateLimits.RPM == nil || *item.RateLimits.RPM != 100 {
|
||||
t.Fatalf("expected summed rpm 100, got %+v", item.RateLimits.RPM)
|
||||
}
|
||||
if item.RateLimits.TPM == nil || *item.RateLimits.TPM != 3000 {
|
||||
t.Fatalf("expected summed tpm 3000, got %+v", item.RateLimits.TPM)
|
||||
}
|
||||
if item.RateLimits.Concurrent == nil || *item.RateLimits.Concurrent != 5 {
|
||||
t.Fatalf("expected summed concurrency 5, got %+v", item.RateLimits.Concurrent)
|
||||
}
|
||||
if item.Permission.Label != "用户组 VIP 用户组;拒绝 Blocked 用户组" {
|
||||
t.Fatalf("expected permission label from access rules, got %q", item.Permission.Label)
|
||||
}
|
||||
if len(item.Permission.AllowGroups) != 1 || item.Permission.AllowGroups[0] != "VIP 用户组" {
|
||||
t.Fatalf("expected allow permission groups, got %+v", item.Permission.AllowGroups)
|
||||
}
|
||||
if len(item.Permission.DenyGroups) != 1 || item.Permission.DenyGroups[0] != "Blocked 用户组" {
|
||||
t.Fatalf("expected deny permission groups, got %+v", item.Permission.DenyGroups)
|
||||
}
|
||||
if item.Discount.Label != "80% - 无折扣" {
|
||||
t.Fatalf("expected friendly discount label, got %q", item.Discount.Label)
|
||||
}
|
||||
if len(item.ProviderKeys) != 2 {
|
||||
t.Fatalf("expected both providers on merged item, got %+v", item.ProviderKeys)
|
||||
}
|
||||
if !hasFilterCount(response.Filters.Providers, "volces", 1) || !hasFilterCount(response.Filters.Providers, "gemini", 1) {
|
||||
t.Fatalf("expected provider filters to count merged model for each provider: %+v", response.Filters.Providers)
|
||||
}
|
||||
if !hasFilterCount(response.Filters.Capabilities, "image", 1) {
|
||||
t.Fatalf("expected image capability filter: %+v", response.Filters.Capabilities)
|
||||
}
|
||||
if got := item.Pricing.Lines[0]; got != "图像:1K 10 / 2K 20" {
|
||||
t.Fatalf("unexpected pricing line %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildModelCatalogUsesBaseModelProviderForProviderFilters(t *testing.T) {
|
||||
models := []store.PlatformModel{
|
||||
{
|
||||
ID: "glm-volces",
|
||||
PlatformID: "platform-volces",
|
||||
BaseModelID: "base-glm",
|
||||
ModelName: "glm-4.7",
|
||||
ModelAlias: "GLM-4.7",
|
||||
ModelType: store.StringList{"text_generate"},
|
||||
DisplayName: "GLM-4.7",
|
||||
Enabled: true,
|
||||
},
|
||||
{
|
||||
ID: "glm-zhipu",
|
||||
PlatformID: "platform-zhipu",
|
||||
BaseModelID: "base-glm",
|
||||
ModelName: "glm-4.7",
|
||||
ModelAlias: "GLM-4.7",
|
||||
ModelType: store.StringList{"text_generate"},
|
||||
DisplayName: "GLM-4.7",
|
||||
Enabled: true,
|
||||
},
|
||||
}
|
||||
platforms := []store.Platform{
|
||||
{ID: "platform-volces", Provider: "volces-openai", Name: "火山引擎(OpenAI兼容)", Status: "enabled"},
|
||||
{ID: "platform-zhipu", Provider: "zhipu-openai", Name: "智谱官方", Status: "enabled"},
|
||||
}
|
||||
providers := []store.CatalogProvider{
|
||||
{ProviderKey: "volces-openai", DisplayName: "火山引擎(OpenAI兼容)", IconPath: "volces.png"},
|
||||
{ProviderKey: "zhipu-openai", DisplayName: "智谱AI", IconPath: "zhipu.png"},
|
||||
}
|
||||
baseModels := []store.BaseModel{
|
||||
{ID: "base-glm", ProviderKey: "zhipu-openai", ProviderModelName: "glm-4.7", ModelAlias: "GLM-4.7"},
|
||||
}
|
||||
|
||||
response := buildModelCatalog(models, platforms, providers, nil, nil, nil, baseModels)
|
||||
if response.Summary.ModelCount != 1 || response.Summary.SourceCount != 2 {
|
||||
t.Fatalf("unexpected summary: %+v", response.Summary)
|
||||
}
|
||||
item := response.Items[0]
|
||||
if len(item.ProviderKeys) != 1 || item.ProviderKeys[0] != "zhipu-openai" {
|
||||
t.Fatalf("expected model provider zhipu-openai only, got %+v", item.ProviderKeys)
|
||||
}
|
||||
if len(item.Providers) != 1 || item.Providers[0].Name != "智谱AI" || item.Providers[0].SourceCount != 2 {
|
||||
t.Fatalf("expected provider summary to aggregate both sources under model provider, got %+v", item.Providers)
|
||||
}
|
||||
if !hasFilterCount(response.Filters.Providers, "zhipu-openai", 1) {
|
||||
t.Fatalf("expected zhipu provider filter count 1, got %+v", response.Filters.Providers)
|
||||
}
|
||||
if hasFilterCount(response.Filters.Providers, "volces-openai", 1) {
|
||||
t.Fatalf("did not expect platform provider in model provider filters: %+v", response.Filters.Providers)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBillingConfigLinesShowsTextInputAndOutputPricing(t *testing.T) {
|
||||
lines := billingConfigLines(map[string]any{
|
||||
"text_total": map[string]any{
|
||||
"basePrice": 0.01,
|
||||
"formulaConfig": map[string]any{
|
||||
"inputTokenPrice": 0.01,
|
||||
"outputTokenPrice": 0.03,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if len(lines) != 2 {
|
||||
t.Fatalf("expected input and output pricing lines, got %+v", lines)
|
||||
}
|
||||
if lines[0] != "输入 0.01/k tokens" {
|
||||
t.Fatalf("unexpected input pricing line %q", lines[0])
|
||||
}
|
||||
if lines[1] != "输出 0.03/k tokens" {
|
||||
t.Fatalf("unexpected output pricing line %q", lines[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBillingConfigLinesShowsVideoFiveSecondBasis(t *testing.T) {
|
||||
lines := billingConfigLines(map[string]any{
|
||||
"video": map[string]any{
|
||||
"basePrice": float64(75),
|
||||
"dynamicWeight": map[string]any{"480p": float64(1), "720p": float64(2)},
|
||||
},
|
||||
})
|
||||
|
||||
if len(lines) != 1 {
|
||||
t.Fatalf("expected one video pricing line, got %+v", lines)
|
||||
}
|
||||
if lines[0] != "视频:480p 75 / 720p 150(5秒基准)" {
|
||||
t.Fatalf("unexpected video pricing line %q", lines[0])
|
||||
}
|
||||
|
||||
flatLines := billingConfigLines(map[string]any{"videoBase": float64(100)})
|
||||
if len(flatLines) != 1 || flatLines[0] != "视频:100 / 5秒基准" {
|
||||
t.Fatalf("unexpected flat video pricing line %+v", flatLines)
|
||||
}
|
||||
}
|
||||
|
||||
func hasFilterCount(options []ModelCatalogFilterOption, value string, count int) bool {
|
||||
for _, option := range options {
|
||||
if option.Value == value && option.Count == count {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
||||
)
|
||||
|
||||
func (s *Server) platformModelResponse(ctx context.Context, model store.PlatformModel) store.PlatformModel {
|
||||
model = s.withEffectiveResponseBillingConfig(ctx, model)
|
||||
return store.FilterPlatformModelBillingConfig(model)
|
||||
}
|
||||
|
||||
func (s *Server) platformModelResponses(ctx context.Context, models []store.PlatformModel) []store.PlatformModel {
|
||||
items := make([]store.PlatformModel, len(models))
|
||||
for i, model := range models {
|
||||
items[i] = s.platformModelResponse(ctx, model)
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func (s *Server) withEffectiveResponseBillingConfig(ctx context.Context, model store.PlatformModel) store.PlatformModel {
|
||||
config := model.BillingConfig
|
||||
if model.PricingRuleSetID != "" {
|
||||
if ruleSetConfig, err := s.store.PricingRuleSetBillingConfig(ctx, model.PricingRuleSetID); err == nil && len(ruleSetConfig) > 0 {
|
||||
config = ruleSetConfig
|
||||
}
|
||||
}
|
||||
if len(model.BillingConfigOverride) > 0 {
|
||||
config = mergeResponseBillingConfig(config, model.BillingConfigOverride)
|
||||
}
|
||||
model.BillingConfig = config
|
||||
return model
|
||||
}
|
||||
|
||||
func mergeResponseBillingConfig(base map[string]any, override map[string]any) map[string]any {
|
||||
if len(base) == 0 && len(override) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]any, len(base)+len(override))
|
||||
for key, value := range base {
|
||||
out[key] = value
|
||||
}
|
||||
for key, value := range override {
|
||||
out[key] = value
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -92,6 +92,7 @@ func NewServer(cfg config.Config, db *store.Store, logger *slog.Logger) http.Han
|
||||
mux.Handle("POST /api/admin/platform-models", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.createPlatformModel)))
|
||||
mux.Handle("DELETE /api/admin/platform-models/{modelID}", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.deletePlatformModel)))
|
||||
mux.Handle("GET /api/admin/models", server.requireAdmin(auth.PermissionPower, http.HandlerFunc(server.listModels)))
|
||||
mux.Handle("GET /api/v1/model-catalog", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.listModelCatalog)))
|
||||
mux.Handle("GET /api/v1/platforms", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.listPlayablePlatforms)))
|
||||
mux.Handle("GET /api/v1/models", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.listPlayableModels)))
|
||||
mux.Handle("GET /api/v1/playground/models", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.listPlayableModels)))
|
||||
|
||||
Reference in New Issue
Block a user