From b12ad1b77916ca9b9328133a635aec3529ae8a47 Mon Sep 17 00:00:00 2001 From: zijiren233 Date: Thu, 2 Jul 2026 10:11:55 +0800 Subject: [PATCH 1/6] feat: group channel --- core/common/consume/consume.go | 70 +- core/common/consume/consume_test.go | 345 +- core/common/consume/record.go | 2 + core/common/reqlimit/main.go | 598 +- core/common/reqlimit/mem_internal_test.go | 126 + core/common/reqlimit/redis.go | 8 + core/controller/channel-billing.go | 4 +- core/controller/channel-test.go | 118 +- core/controller/channel.go | 6 +- core/controller/dashboard.go | 368 +- core/controller/group-channel.go | 2153 +++ core/controller/group_channel_models_test.go | 190 + core/controller/import.go | 133 +- core/controller/log.go | 373 + core/controller/log_export.go | 260 +- core/controller/log_export_test.go | 52 + core/controller/mcp/publicmcp-group.go | 2 +- core/controller/modelconfig.go | 309 + core/controller/relay-channel.go | 921 +- core/controller/relay-channel_test.go | 898 +- core/controller/relay-controller.go | 586 +- core/controller/relay-controller_test.go | 602 +- core/controller/relay-model.go | 77 +- core/controller/relay-model_test.go | 374 + core/controller/relay.go | 556 +- core/controller/token.go | 80 +- core/controller/token_test.go | 32 + core/docs/docs.go | 12782 +++++++++++---- core/docs/swagger.json | 12784 ++++++++++++---- core/docs/swagger.yaml | 5618 ++++++- core/middleware/auth.go | 348 +- core/middleware/auth_test.go | 451 + core/middleware/ctxkey.go | 46 +- core/middleware/distributor.go | 254 +- .../distributor_provider_video_test.go | 6 +- core/middleware/distributor_video_test.go | 12 +- core/model/async_usage.go | 57 +- .../async_usage_postgres_integration_test.go | 13 +- core/model/batch.go | 1145 +- core/model/channel.go | 22 +- core/model/group.go | 261 +- core/model/group_cache.go | 5 +- core/model/group_scope_modelconfig.go | 228 + core/model/group_scope_modelconfig_cache.go | 317 + core/model/groupchannel.go | 898 ++ core/model/groupchannel_cache.go | 197 + core/model/groupchannel_log.go | 264 + core/model/groupchannel_summary.go | 1121 ++ core/model/groupchannel_summary_minute.go | 523 + core/model/groupchannel_test.go | 972 ++ core/model/groupchannel_token_summary.go | 965 ++ .../groupchannel_token_summary_minute.go | 574 + core/model/groupchanneltest.go | 35 + core/model/groupsummary-minute.go | 41 + core/model/groupsummary_minute_test.go | 78 + core/model/local_cache.go | 18 +- core/model/log.go | 1767 ++- core/model/log_test.go | 1115 ++ core/model/main.go | 202 +- core/model/main_test.go | 107 + core/model/retrylog.go | 8 +- core/model/sets.go | 201 + core/model/sets_test.go | 129 + core/model/store.go | 283 +- core/model/store_cache.go | 91 +- core/model/store_cache_redis_test.go | 4 +- core/model/store_postgres_integration_test.go | 2 +- core/model/store_test.go | 232 +- core/model/summary-minute.go | 32 +- core/model/summary.go | 10 + core/model/token.go | 159 +- core/model/token_cache.go | 124 +- core/model/token_update_test.go | 179 + core/monitor/local_cache.go | 122 +- core/monitor/memmodel.go | 158 +- core/monitor/memmodel_test.go | 102 +- core/monitor/model.go | 547 +- core/monitor/model_integration_test.go | 18 +- core/relay/adaptor/ali/adaptor_test.go | 22 +- core/relay/adaptor/ali/ali_video.go | 2 +- core/relay/adaptor/ali/async_usage.go | 7 +- core/relay/adaptor/ali/video.go | 9 +- core/relay/adaptor/doubao/async_usage.go | 7 +- core/relay/adaptor/doubao/main_test.go | 22 +- core/relay/adaptor/doubao/video.go | 6 +- core/relay/adaptor/fake/adaptor_test.go | 35 +- core/relay/adaptor/fake/helpers.go | 17 +- core/relay/adaptor/fake/response.go | 2 +- core/relay/adaptor/gemini/async_usage.go | 7 +- core/relay/adaptor/gemini/video.go | 20 +- core/relay/adaptor/gemini/video_test.go | 24 +- core/relay/adaptor/interface.go | 13 +- core/relay/adaptor/openai/response.go | 4 +- core/relay/adaptor/openai/response_test.go | 18 +- core/relay/adaptor/openai/video.go | 6 +- .../relay/adaptor/siliconflow/adaptor_test.go | 14 +- core/relay/adaptor/siliconflow/async_usage.go | 7 +- core/relay/adaptor/siliconflow/video.go | 6 +- core/relay/adaptor/vertexai/adaptor_test.go | 24 +- core/relay/meta/meta.go | 33 + core/relay/plugin/cachefollow/cachefollow.go | 30 +- .../plugin/cachefollow/cachefollow_test.go | 26 +- core/relay/plugin/monitor/group.go | 22 + core/relay/plugin/monitor/monitor.go | 191 +- core/relay/plugin/monitor/monitor_test.go | 321 + core/relay/utils/config_cache.go | 14 +- core/relay/utils/config_cache_test.go | 84 + core/relay/utils/testreq.go | 228 +- core/relay/utils/testreq_test.go | 26 + core/router/api.go | 126 + core/task/async_usage_test.go | 236 +- core/task/task.go | 96 +- 112 files changed, 46979 insertions(+), 9596 deletions(-) create mode 100644 core/controller/group-channel.go create mode 100644 core/controller/group_channel_models_test.go create mode 100644 core/controller/relay-model_test.go create mode 100644 core/controller/token_test.go create mode 100644 core/middleware/auth_test.go create mode 100644 core/model/group_scope_modelconfig.go create mode 100644 core/model/group_scope_modelconfig_cache.go create mode 100644 core/model/groupchannel.go create mode 100644 core/model/groupchannel_cache.go create mode 100644 core/model/groupchannel_log.go create mode 100644 core/model/groupchannel_summary.go create mode 100644 core/model/groupchannel_summary_minute.go create mode 100644 core/model/groupchannel_test.go create mode 100644 core/model/groupchannel_token_summary.go create mode 100644 core/model/groupchannel_token_summary_minute.go create mode 100644 core/model/groupchanneltest.go create mode 100644 core/model/groupsummary_minute_test.go create mode 100644 core/model/main_test.go create mode 100644 core/model/sets.go create mode 100644 core/model/sets_test.go create mode 100644 core/model/token_update_test.go diff --git a/core/common/consume/consume.go b/core/common/consume/consume.go index 381f0f9d..21e2c247 100644 --- a/core/common/consume/consume.go +++ b/core/common/consume/consume.go @@ -43,33 +43,35 @@ func AsyncConsume( } consumeWaitGroup.Add(1) - defer func() { - consumeWaitGroup.Done() - - if r := recover(); r != nil { - log.Errorf("panic in consume: %v", r) - } + go func() { + defer func() { + consumeWaitGroup.Done() + + if r := recover(); r != nil { + log.Errorf("panic in consume: %v", r) + } + }() + + Consume( + context.Background(), + time.Now(), + postGroupConsumer, + firstByteAt, + code, + meta, + usage, + usageContext, + modelPrice, + content, + ip, + retryTimes, + requestDetail, + downstreamResult, + metadata, + upstreamID, + asyncUsageStatus, + ) }() - - go Consume( - context.Background(), - time.Now(), - postGroupConsumer, - firstByteAt, - code, - meta, - usage, - usageContext, - modelPrice, - content, - ip, - retryTimes, - requestDetail, - downstreamResult, - metadata, - upstreamID, - asyncUsageStatus, - ) } func Consume( @@ -95,6 +97,11 @@ func Consume( return } + groupChannel := meta != nil && meta.Channel.Scope == model.ChannelScopeGroup + if groupChannel { + asyncUsageStatus = model.AsyncUsageStatusNone + } + recordUsage := usage amountDetail := model.Amount{} @@ -111,8 +118,10 @@ func Consume( } if downstreamResult { - // TODO: add record actual consume amount - _ = consumeAmount(ctx, amountDetail.UsedAmount, postGroupConsumer, meta) + if !groupChannel { + // TODO: add record actual consume amount + _ = consumeAmount(ctx, amountDetail.UsedAmount, postGroupConsumer, meta) + } } else if amountDetail.UsedAmount != 0 { log.Warnf( "not downstream result but used amount is not zero, request_id: %s, used_amount: %f", @@ -151,6 +160,11 @@ func Consume( ) if err != nil { log.Error("error batch record consume: " + err.Error()) + + if groupChannel { + return + } + notify.ErrorThrottle("recordConsume", time.Minute*5, "record consume failed", err.Error()) } } diff --git a/core/common/consume/consume_test.go b/core/common/consume/consume_test.go index 7bb54da2..7b96d861 100644 --- a/core/common/consume/consume_test.go +++ b/core/common/consume/consume_test.go @@ -3,18 +3,34 @@ package consume_test import ( "context" "net/http" + "path/filepath" + "sync/atomic" "testing" "time" - "github.com/glebarez/sqlite" + "github.com/labring/aiproxy/core/common/balance" "github.com/labring/aiproxy/core/common/consume" "github.com/labring/aiproxy/core/model" "github.com/labring/aiproxy/core/relay/meta" "github.com/labring/aiproxy/core/relay/mode" "github.com/stretchr/testify/require" - "gorm.io/gorm" ) +type trackingPostGroupConsumer struct { + calls atomic.Int64 +} + +var _ balance.PostGroupConsumer = (*trackingPostGroupConsumer)(nil) + +func (c *trackingPostGroupConsumer) PostGroupConsume( + context.Context, + string, + float64, +) (float64, error) { + c.calls.Add(1) + return 0, nil +} + func TestCalculateAmount(t *testing.T) { tests := []struct { name string @@ -613,14 +629,28 @@ func TestCalculateAmountWithConditionalPricing(t *testing.T) { } func TestConsumePendingAsyncUsageDoesNotRecordPriceUsageOrAmount(t *testing.T) { - db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + db, err := model.OpenSQLite(filepath.Join(t.TempDir(), "pending_async_usage.db")) require.NoError(t, err) - require.NoError(t, db.AutoMigrate(&model.Log{})) + require.NoError(t, db.AutoMigrate( + &model.Log{}, + &model.Group{}, + &model.Token{}, + &model.Channel{}, + &model.Summary{}, + &model.SummaryMinute{}, + &model.GroupSummary{}, + &model.GroupSummaryMinute{}, + )) oldLogDB := model.LogDB + oldDB := model.DB model.LogDB = db + model.DB = db t.Cleanup(func() { + model.ProcessBatchUpdatesSummary() + model.LogDB = oldLogDB + model.DB = oldDB }) requestMeta := &meta.Meta{ @@ -682,3 +712,310 @@ func TestConsumePendingAsyncUsageDoesNotRecordPriceUsageOrAmount(t *testing.T) { require.Zero(t, logEntry.Price.OutputPrice) require.Empty(t, logEntry.Price.ConditionalPrices) } + +func TestConsumeGroupChannelRecordsAmountWithoutPostConsume(t *testing.T) { + db, err := model.OpenSQLite(filepath.Join(t.TempDir(), "group_channel_consume.db")) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate( + &model.Group{}, + &model.Token{}, + &model.GroupChannel{}, + &model.GroupChannelLog{}, + &model.GroupChannelSummary{}, + &model.GroupChannelSummaryMinute{}, + &model.GroupChannelTokenSummary{}, + &model.GroupChannelTokenSummaryMinute{}, + )) + + oldLogDB := model.LogDB + oldDB := model.DB + model.LogDB = db + model.DB = db + t.Cleanup(func() { + model.ProcessBatchUpdatesSummary() + + model.LogDB = oldLogDB + model.DB = oldDB + }) + + require.NoError(t, db.Create(&model.Group{ID: "group"}).Error) + require.NoError(t, db.Create(&model.Token{ID: 1, Name: "token", GroupID: "group"}).Error) + require.NoError(t, db.Create(&model.GroupChannel{ + ID: 2, + GroupID: "group", + Type: model.ChannelTypeOpenAI, + }).Error) + + requestMeta := &meta.Meta{ + RequestID: "group_channel_async_ignored", + RequestAt: time.Now(), + Group: model.GroupCache{ID: "group"}, + Token: model.TokenCache{ID: 1, Name: "token"}, + Channel: meta.ChannelMeta{ID: 2, Scope: model.ChannelScopeGroup, GroupID: "group"}, + OriginModel: "video-model", + Mode: mode.VideoGenerationsJobs, + } + consumer := &trackingPostGroupConsumer{} + + consume.Consume( + context.Background(), + time.Now(), + consumer, + time.Now(), + http.StatusOK, + requestMeta, + model.Usage{OutputTokens: 5, TotalTokens: 5}, + model.UsageContext{}, + model.Price{OutputPrice: 1, OutputPriceUnit: 1}, + "", + "127.0.0.1", + 0, + nil, + true, + nil, + "upstream-id", + model.AsyncUsageStatusPending, + ) + model.ProcessBatchUpdatesSummary() + + var logEntry model.GroupChannelLog + require.NoError(t, db.Where("request_id = ?", requestMeta.RequestID).First(&logEntry).Error) + require.Equal(t, model.AsyncUsageStatusNone, logEntry.AsyncUsageStatus) + require.Equal(t, model.ZeroNullInt64(5), logEntry.Usage.OutputTokens) + require.Equal(t, 5.0, logEntry.Amount.UsedAmount) + require.Equal(t, float64(1), float64(logEntry.Price.OutputPrice)) + require.Empty(t, logEntry.Price.ConditionalPrices) + require.Zero(t, consumer.calls.Load()) +} + +func TestSummaryGroupChannelRecordsScopedSummaryWithAmount(t *testing.T) { + model.ProcessBatchUpdatesSummary() + + db, err := model.OpenSQLite(filepath.Join(t.TempDir(), "group_channel_summary.db")) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate( + &model.Group{}, + &model.Token{}, + &model.GroupChannel{}, + &model.GroupSummary{}, + &model.GroupSummaryMinute{}, + &model.GroupChannelSummary{}, + &model.GroupChannelSummaryMinute{}, + &model.GroupChannelTokenSummary{}, + &model.GroupChannelTokenSummaryMinute{}, + )) + + oldLogDB := model.LogDB + oldDB := model.DB + model.LogDB = db + model.DB = db + t.Cleanup(func() { + model.ProcessBatchUpdatesSummary() + + model.LogDB = oldLogDB + model.DB = oldDB + }) + + require.NoError(t, db.Create(&model.Group{ID: "group"}).Error) + require.NoError(t, db.Create(&model.Token{ID: 11, Name: "token", GroupID: "group"}).Error) + require.NoError(t, db.Create(&model.GroupChannel{ + ID: 2, + GroupID: "group", + Type: model.ChannelTypeOpenAI, + }).Error) + + now := time.Now().Truncate(time.Minute) + requestMeta := &meta.Meta{ + RequestID: "group_channel_summary", + RequestAt: now, + Group: model.GroupCache{ID: "group"}, + Token: model.TokenCache{ID: 11, Name: "token"}, + Channel: meta.ChannelMeta{ID: 2, Scope: model.ChannelScopeGroup, GroupID: "group"}, + OriginModel: "gpt-5", + Mode: mode.ChatCompletions, + ModelConfig: model.ModelConfig{Model: "gpt-5"}, + } + + consume.Summary( + http.StatusOK, + now, + requestMeta, + model.Usage{InputTokens: 5, OutputTokens: 7, TotalTokens: 12}, + model.UsageContext{}, + model.Price{InputPrice: 1, InputPriceUnit: 1, OutputPrice: 1, OutputPriceUnit: 1}, + true, + ) + model.ProcessBatchUpdatesSummary() + + var groupSummaryCount int64 + require.NoError(t, db.Model(&model.GroupSummary{}).Count(&groupSummaryCount).Error) + require.Zero(t, groupSummaryCount) + + var gotChannelSummary model.GroupChannelSummary + require.NoError(t, db.Where( + "group_id = ? AND group_channel_id = ? AND model = ?", + "group", + 2, + "gpt-5", + ).First(&gotChannelSummary).Error) + require.Equal(t, int64(1), gotChannelSummary.Data.RequestCount) + require.Equal(t, model.ZeroNullInt64(12), gotChannelSummary.Data.TotalTokens) + require.Equal(t, 12.0, gotChannelSummary.Data.UsedAmount) + + var gotTokenSummary model.GroupChannelTokenSummary + require.NoError(t, db.Where( + "group_id = ? AND token_name = ? AND model = ?", + "group", + "token", + "gpt-5", + ).First(&gotTokenSummary).Error) + require.Equal(t, int64(1), gotTokenSummary.Data.RequestCount) + require.Equal(t, model.ZeroNullInt64(12), gotTokenSummary.Data.TotalTokens) + require.Equal(t, 12.0, gotTokenSummary.Data.UsedAmount) + + var gotTokenSummaryMinute model.GroupChannelTokenSummaryMinute + require.NoError(t, db.Where( + "group_id = ? AND token_name = ? AND model = ?", + "group", + "token", + "gpt-5", + ).First(&gotTokenSummaryMinute).Error) + require.Equal(t, int64(1), gotTokenSummaryMinute.Data.RequestCount) + require.Equal(t, model.ZeroNullInt64(12), gotTokenSummaryMinute.Data.TotalTokens) + require.Equal(t, 12.0, gotTokenSummaryMinute.Data.UsedAmount) + + var gotGroup model.Group + require.NoError(t, db.First(&gotGroup, "id = ?", "group").Error) + require.Zero(t, gotGroup.UsedAmount) + require.Zero(t, gotGroup.RequestCount) + require.Equal(t, 12.0, gotGroup.GroupChannelUsedAmount) + require.Equal(t, 1, gotGroup.GroupChannelRequestCount) + + var gotToken model.Token + require.NoError(t, db.First(&gotToken, "id = ?", 11).Error) + require.Zero(t, gotToken.UsedAmount) + require.Zero(t, gotToken.RequestCount) + require.Equal(t, 12.0, gotToken.GroupChannelUsedAmount) + require.Equal(t, 1, gotToken.GroupChannelRequestCount) + + var gotGroupChannel model.GroupChannel + require.NoError(t, db.First(&gotGroupChannel, "group_id = ? AND id = ?", "group", 2).Error) + require.Equal(t, 12.0, gotGroupChannel.UsedAmount) + require.Equal(t, 1, gotGroupChannel.RequestCount) +} + +func TestConsumeAdminGroupChannelRecordsLogAndSummaryWithoutTokenSummary(t *testing.T) { + model.ProcessBatchUpdatesSummary() + + db, err := model.OpenSQLite(filepath.Join(t.TempDir(), "group_channel_admin.db")) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate( + &model.Group{}, + &model.Token{}, + &model.GroupChannel{}, + &model.GroupChannelLog{}, + &model.GroupChannelSummary{}, + &model.GroupChannelSummaryMinute{}, + &model.GroupChannelTokenSummary{}, + &model.GroupChannelTokenSummaryMinute{}, + )) + + oldLogDB := model.LogDB + oldDB := model.DB + model.LogDB = db + model.DB = db + t.Cleanup(func() { + model.ProcessBatchUpdatesSummary() + + model.LogDB = oldLogDB + model.DB = oldDB + }) + + require.NoError(t, db.Create(&model.Group{ID: "group"}).Error) + require.NoError(t, db.Create(&model.Token{ID: 11, Name: "token", GroupID: "group"}).Error) + require.NoError(t, db.Create(&model.GroupChannel{ + ID: 2, + GroupID: "group", + Type: model.ChannelTypeOpenAI, + }).Error) + + now := time.Now().Truncate(time.Minute) + requestMeta := &meta.Meta{ + RequestID: "admin_group_channel", + RequestAt: now, + Group: model.GroupCache{ID: "group"}, + Token: model.TokenCache{}, + Channel: meta.ChannelMeta{ID: 2, Scope: model.ChannelScopeGroup, GroupID: "group"}, + OriginModel: "gpt-5", + Mode: mode.ChatCompletions, + ModelConfig: model.ModelConfig{Model: "gpt-5"}, + } + + consume.Consume( + context.Background(), + now, + nil, + now, + http.StatusOK, + requestMeta, + model.Usage{InputTokens: 5, OutputTokens: 7, TotalTokens: 12}, + model.UsageContext{}, + model.Price{InputPrice: 1, InputPriceUnit: 1, OutputPrice: 1, OutputPriceUnit: 1}, + "", + "127.0.0.1", + 0, + nil, + true, + nil, + "upstream-id", + model.AsyncUsageStatusNone, + ) + model.ProcessBatchUpdatesSummary() + + var logEntry model.GroupChannelLog + require.NoError(t, db.Where("request_id = ?", requestMeta.RequestID).First(&logEntry).Error) + require.Zero(t, logEntry.TokenID) + require.Empty(t, logEntry.TokenName) + require.Equal(t, "group", logEntry.GroupID) + require.Equal(t, 2, logEntry.GroupChannelID) + require.Equal(t, 12.0, logEntry.Amount.UsedAmount) + + var gotChannelSummary model.GroupChannelSummary + require.NoError(t, db.Where( + "group_id = ? AND group_channel_id = ? AND model = ?", + "group", + 2, + "gpt-5", + ).First(&gotChannelSummary).Error) + require.Equal(t, int64(1), gotChannelSummary.Data.RequestCount) + require.Equal(t, model.ZeroNullInt64(12), gotChannelSummary.Data.TotalTokens) + require.Equal(t, 12.0, gotChannelSummary.Data.UsedAmount) + + var tokenSummaryCount int64 + require.NoError(t, db.Model(&model.GroupChannelTokenSummary{}).Count(&tokenSummaryCount).Error) + require.Zero(t, tokenSummaryCount) + + var tokenSummaryMinuteCount int64 + require.NoError(t, db.Model(&model.GroupChannelTokenSummaryMinute{}). + Count(&tokenSummaryMinuteCount).Error) + require.Zero(t, tokenSummaryMinuteCount) + + var gotGroup model.Group + require.NoError(t, db.First(&gotGroup, "id = ?", "group").Error) + require.Zero(t, gotGroup.UsedAmount) + require.Zero(t, gotGroup.RequestCount) + require.Zero(t, gotGroup.GroupChannelUsedAmount) + require.Zero(t, gotGroup.GroupChannelRequestCount) + + var gotToken model.Token + require.NoError(t, db.First(&gotToken, "id = ?", 11).Error) + require.Zero(t, gotToken.UsedAmount) + require.Zero(t, gotToken.RequestCount) + require.Zero(t, gotToken.GroupChannelUsedAmount) + require.Zero(t, gotToken.GroupChannelRequestCount) + + var gotGroupChannel model.GroupChannel + require.NoError(t, db.First(&gotGroupChannel, "group_id = ? AND id = ?", "group", 2).Error) + require.Equal(t, 12.0, gotGroupChannel.UsedAmount) + require.Equal(t, 1, gotGroupChannel.RequestCount) +} diff --git a/core/common/consume/record.go b/core/common/consume/record.go index 218e5623..0a6733b7 100644 --- a/core/common/consume/record.go +++ b/core/common/consume/record.go @@ -41,6 +41,7 @@ func recordConsume( firstByteAt, meta.Group.ID, code, + meta.Channel.Scope, meta.Channel.ID, meta.OriginModel, meta.Token.ID, @@ -89,6 +90,7 @@ func recordSummary( firstByteAt, meta.Group.ID, code, + meta.Channel.Scope, meta.Channel.ID, meta.OriginModel, meta.Token.ID, diff --git a/core/common/reqlimit/main.go b/core/common/reqlimit/main.go index b125741f..a041fcf2 100644 --- a/core/common/reqlimit/main.go +++ b/core/common/reqlimit/main.go @@ -3,6 +3,7 @@ package reqlimit import ( "context" "strconv" + "strings" "time" "github.com/labring/aiproxy/core/common" @@ -23,6 +24,76 @@ type RateSnapshot struct { SecondCount int64 `json:"second_count"` } +type ChannelRateScope string + +const ( + ChannelRateScopeGlobal ChannelRateScope = "global" + ChannelRateScopeGroup ChannelRateScope = "group" +) + +func chooseRecordSnapshots( + redisEnabled bool, + redisSnapshots []recordSnapshot, + redisErr error, + memorySnapshots func() []recordSnapshot, + logMessage string, +) []recordSnapshot { + if redisEnabled { + if redisErr != nil { + log.Error(logMessage + redisErr.Error()) + return memorySnapshots() + } + + return redisSnapshots + } + + return memorySnapshots() +} + +func pushRateRecord( + ctx context.Context, + redisRecord *redisRateRecord, + memoryRecord *InMemoryRecord, + overed int64, + n int64, + keys ...string, +) (int64, int64, int64) { + if common.RedisEnabled { + count, overLimitCount, secondCount, err := redisRecord.PushRequest( + ctx, + overed, + time.Minute, + n, + keys..., + ) + if err == nil { + return count, overLimitCount, secondCount + } + + log.Error("redis push request error: " + err.Error()) + } + + return memoryRecord.PushRequest(overed, time.Minute, n, keys...) +} + +func getRateRecord( + ctx context.Context, + redisRecord *redisRateRecord, + memoryRecord *InMemoryRecord, + keys ...string, +) (int64, int64) { + if common.RedisEnabled { + totalCount, secondCount, err := redisRecord.GetRequest(ctx, time.Minute, keys...) + if err == nil { + return totalCount, secondCount + } + + log.Error("redis get request error: " + err.Error()) + } + + return memoryRecord.GetRequest(time.Minute, keys...) +} + //nolint:unparam func snapshotRateRecord( ctx context.Context, @@ -38,14 +109,15 @@ func snapshotRateRecord( if common.RedisEnabled { rawSnapshots, err = redisRecord.SnapshotByPattern(ctx, duration, keys...) - if err != nil { - log.Error("redis snapshot error: " + err.Error()) - } } - if len(rawSnapshots) == 0 { - rawSnapshots = memoryRecord.SnapshotByPattern(duration, keys...) - } + rawSnapshots = chooseRecordSnapshots( + common.RedisEnabled, + rawSnapshots, + err, + func() []recordSnapshot { return memoryRecord.SnapshotByPattern(duration, keys...) }, + "redis snapshot error: ", + ) snapshots := make([]RateSnapshot, 0, len(rawSnapshots)) for _, snapshot := range rawSnapshots { @@ -69,23 +141,15 @@ func PushGroupModelRequest( group, model string, overed int64, ) (int64, int64, int64) { - if common.RedisEnabled { - count, overLimitCount, secondCount, err := redisGroupModelLimiter.PushRequest( - ctx, - overed, - time.Minute, - 1, - group, - model, - ) - if err == nil { - return count, overLimitCount, secondCount - } - - log.Error("redis push request error: " + err.Error()) - } - - return memoryGroupModelLimiter.PushRequest(overed, time.Minute, 1, group, model) + return pushRateRecord( + ctx, + redisGroupModelLimiter, + memoryGroupModelLimiter, + overed, + 1, + group, + model, + ) } func GetGroupModelRequest(ctx context.Context, group, model string) (int64, int64) { @@ -93,21 +157,13 @@ func GetGroupModelRequest(ctx context.Context, group, model string) (int64, int6 model = "*" } - if common.RedisEnabled { - totalCount, secondCount, err := redisGroupModelLimiter.GetRequest( - ctx, - time.Minute, - group, - model, - ) - if err == nil { - return totalCount, secondCount - } - - log.Error("redis get request error: " + err.Error()) - } - - return memoryGroupModelLimiter.GetRequest(time.Minute, group, model) + return getRateRecord( + ctx, + redisGroupModelLimiter, + memoryGroupModelLimiter, + group, + model, + ) } func GetGroupModelRequestSnapshots( @@ -143,24 +199,16 @@ func PushGroupModelTokennameRequest( ctx context.Context, group, model, tokenname string, ) (int64, int64, int64) { - if common.RedisEnabled { - count, overLimitCount, secondCount, err := redisGroupModelTokennameLimiter.PushRequest( - ctx, - 0, - time.Minute, - 1, - group, - model, - tokenname, - ) - if err == nil { - return count, overLimitCount, secondCount - } - - log.Error("redis push request error: " + err.Error()) - } - - return memoryGroupModelTokennameLimiter.PushRequest(0, time.Minute, 1, group, model, tokenname) + return pushRateRecord( + ctx, + redisGroupModelTokennameLimiter, + memoryGroupModelTokennameLimiter, + 0, + 1, + group, + model, + tokenname, + ) } func GetGroupModelTokennameRequest( @@ -175,22 +223,14 @@ func GetGroupModelTokennameRequest( tokenname = "*" } - if common.RedisEnabled { - totalCount, secondCount, err := redisGroupModelTokennameLimiter.GetRequest( - ctx, - time.Minute, - group, - model, - tokenname, - ) - if err == nil { - return totalCount, secondCount - } - - log.Error("redis get request error: " + err.Error()) - } - - return memoryGroupModelTokennameLimiter.GetRequest(time.Minute, group, model, tokenname) + return getRateRecord( + ctx, + redisGroupModelTokennameLimiter, + memoryGroupModelTokennameLimiter, + group, + model, + tokenname, + ) } func GetGroupModelTokennameRequestSnapshots( @@ -225,29 +265,65 @@ var ( redisChannelModelRecord = newRedisChannelModelRecord( func() *redis.Client { return common.RDB }, ) + memoryGroupChannelModelRecord = NewInMemoryRecord() + redisGroupChannelModelRecord = newRedisGroupChannelModelRecord( + func() *redis.Client { return common.RDB }, + ) ) func PushChannelModelRequest(ctx context.Context, channel, model string) (int64, int64, int64) { - if common.RedisEnabled { - count, overLimitCount, secondCount, err := redisChannelModelRecord.PushRequest( - ctx, - 0, - time.Minute, - 1, - channel, - model, - ) - if err == nil { - return count, overLimitCount, secondCount - } + return pushRateRecord( + ctx, + redisChannelModelRecord, + memoryChannelModelRecord, + 0, + 1, + channel, + model, + ) +} - log.Error("redis push request error: " + err.Error()) +func GetChannelModelRequest(ctx context.Context, channel, model string) (int64, int64) { + if channel == "" || channel == "*" { + return getGlobalChannelModelRequest(ctx, model) + } + + if model == "" { + model = "*" } - return memoryChannelModelRecord.PushRequest(0, time.Minute, 1, channel, model) + return getChannelModelRequestByPattern(ctx, channel, model) } -func GetChannelModelRequest(ctx context.Context, channel, model string) (int64, int64) { +func PushGroupChannelModelRequest( + ctx context.Context, + group, channel, model string, +) (int64, int64, int64) { + return pushRateRecord( + ctx, + redisGroupChannelModelRecord, + memoryGroupChannelModelRecord, + 0, + 1, + group, + channel, + model, + ) +} + +func PushScopedChannelModelRequest( + ctx context.Context, + scope ChannelRateScope, + group, channel, model string, +) (int64, int64, int64) { + if scope == ChannelRateScopeGroup { + return PushGroupChannelModelRequest(ctx, group, channel, model) + } + + return PushChannelModelRequest(ctx, channel, model) +} + +func GetGroupChannelModelRequest(ctx context.Context, group, channel, model string) (int64, int64) { if channel == "" { channel = "*" } @@ -256,21 +332,45 @@ func GetChannelModelRequest(ctx context.Context, channel, model string) (int64, model = "*" } - if common.RedisEnabled { - totalCount, secondCount, err := redisChannelModelRecord.GetRequest( - ctx, - time.Minute, - channel, - model, - ) - if err == nil { - return totalCount, secondCount - } + return getRateRecord( + ctx, + redisGroupChannelModelRecord, + memoryGroupChannelModelRecord, + group, + channel, + model, + ) +} - log.Error("redis get request error: " + err.Error()) +func getChannelModelRequestByPattern(ctx context.Context, channel, model string) (int64, int64) { + if channel == "" { + channel = "*" } - return memoryChannelModelRecord.GetRequest(time.Minute, channel, model) + if model == "" { + model = "*" + } + + return getRateRecord( + ctx, + redisChannelModelRecord, + memoryChannelModelRecord, + channel, + model, + ) +} + +func getGlobalChannelModelRequest(ctx context.Context, model string) (int64, int64) { + return aggregateChannelModelSnapshots( + ctx, + model, + redisChannelModelRecord, + memoryChannelModelRecord, + func(channel string) bool { + _, err := strconv.ParseInt(channel, 10, 64) + return err == nil + }, + ) } var ( @@ -285,23 +385,15 @@ func PushGroupModelTokensRequest( group, model string, maxTokens, tokens int64, ) (int64, int64, int64) { - if common.RedisEnabled { - count, overLimitCount, secondCount, err := redisGroupModelTokensLimiter.PushRequest( - ctx, - maxTokens, - time.Minute, - tokens, - group, - model, - ) - if err == nil { - return count, overLimitCount, secondCount - } - - log.Error("redis push request error: " + err.Error()) - } - - return memoryGroupModelTokensLimiter.PushRequest(maxTokens, time.Minute, tokens, group, model) + return pushRateRecord( + ctx, + redisGroupModelTokensLimiter, + memoryGroupModelTokensLimiter, + maxTokens, + tokens, + group, + model, + ) } func GetGroupModelTokensRequest(ctx context.Context, group, model string) (int64, int64) { @@ -309,21 +401,13 @@ func GetGroupModelTokensRequest(ctx context.Context, group, model string) (int64 model = "*" } - if common.RedisEnabled { - totalCount, secondCount, err := redisGroupModelTokensLimiter.GetRequest( - ctx, - time.Minute, - group, - model, - ) - if err == nil { - return totalCount, secondCount - } - - log.Error("redis get request error: " + err.Error()) - } - - return memoryGroupModelTokensLimiter.GetRequest(time.Minute, group, model) + return getRateRecord( + ctx, + redisGroupModelTokensLimiter, + memoryGroupModelTokensLimiter, + group, + model, + ) } func GetGroupModelTokensRequestSnapshots( @@ -360,26 +444,11 @@ func PushGroupModelTokennameTokensRequest( group, model, tokenname string, tokens int64, ) (int64, int64, int64) { - if common.RedisEnabled { - count, overLimitCount, secondCount, err := redisGroupModelTokennameTokensLimiter.PushRequest( - ctx, - 0, - time.Minute, - tokens, - group, - model, - tokenname, - ) - if err == nil { - return count, overLimitCount, secondCount - } - - log.Error("redis push request error: " + err.Error()) - } - - return memoryGroupModelTokennameTokensLimiter.PushRequest( + return pushRateRecord( + ctx, + redisGroupModelTokennameTokensLimiter, + memoryGroupModelTokennameTokensLimiter, 0, - time.Minute, tokens, group, model, @@ -399,22 +468,14 @@ func GetGroupModelTokennameTokensRequest( tokenname = "*" } - if common.RedisEnabled { - totalCount, secondCount, err := redisGroupModelTokennameTokensLimiter.GetRequest( - ctx, - time.Minute, - group, - model, - tokenname, - ) - if err == nil { - return totalCount, secondCount - } - - log.Error("redis get request error: " + err.Error()) - } - - return memoryGroupModelTokennameTokensLimiter.GetRequest(time.Minute, group, model, tokenname) + return getRateRecord( + ctx, + redisGroupModelTokennameTokensLimiter, + memoryGroupModelTokennameTokensLimiter, + group, + model, + tokenname, + ) } func GetGroupModelTokennameTokensRequestSnapshots( @@ -449,6 +510,10 @@ var ( redisChannelModelTokensRecord = newRedisChannelModelTokensRecord( func() *redis.Client { return common.RDB }, ) + memoryGroupChannelModelTokensRecord = NewInMemoryRecord() + redisGroupChannelModelTokensRecord = newRedisGroupChannelModelTokensRecord( + func() *redis.Client { return common.RDB }, + ) ) func PushChannelModelTokensRequest( @@ -456,26 +521,85 @@ func PushChannelModelTokensRequest( channel, model string, tokens int64, ) (int64, int64, int64) { - if common.RedisEnabled { - count, overLimitCount, secondCount, err := redisChannelModelTokensRecord.PushRequest( - ctx, - 0, - time.Minute, - tokens, - channel, - model, - ) - if err == nil { - return count, overLimitCount, secondCount - } + return pushRateRecord( + ctx, + redisChannelModelTokensRecord, + memoryChannelModelTokensRecord, + 0, + tokens, + channel, + model, + ) +} - log.Error("redis push request error: " + err.Error()) +func GetChannelModelTokensRequest(ctx context.Context, channel, model string) (int64, int64) { + if channel == "" || channel == "*" { + return getGlobalChannelModelTokensRequest(ctx, model) } - return memoryChannelModelTokensRecord.PushRequest(0, time.Minute, tokens, channel, model) + if model == "" { + model = "*" + } + + return getChannelModelTokensRequestByPattern(ctx, channel, model) } -func GetChannelModelTokensRequest(ctx context.Context, channel, model string) (int64, int64) { +func PushGroupChannelModelTokensRequest( + ctx context.Context, + group, channel, model string, + tokens int64, +) (int64, int64, int64) { + return pushRateRecord( + ctx, + redisGroupChannelModelTokensRecord, + memoryGroupChannelModelTokensRecord, + 0, + tokens, + group, + channel, + model, + ) +} + +func PushScopedChannelModelTokensRequest( + ctx context.Context, + scope ChannelRateScope, + group, channel, model string, + tokens int64, +) (int64, int64, int64) { + if scope == ChannelRateScopeGroup { + return PushGroupChannelModelTokensRequest(ctx, group, channel, model, tokens) + } + + return PushChannelModelTokensRequest(ctx, channel, model, tokens) +} + +func GetGroupChannelModelTokensRequest( + ctx context.Context, + group, channel, model string, +) (int64, int64) { + if channel == "" { + channel = "*" + } + + if model == "" { + model = "*" + } + + return getRateRecord( + ctx, + redisGroupChannelModelTokensRecord, + memoryGroupChannelModelTokensRecord, + group, + channel, + model, + ) +} + +func getChannelModelTokensRequestByPattern( + ctx context.Context, + channel, model string, +) (int64, int64) { if channel == "" { channel = "*" } @@ -484,21 +608,86 @@ func GetChannelModelTokensRequest(ctx context.Context, channel, model string) (i model = "*" } + return getRateRecord( + ctx, + redisChannelModelTokensRecord, + memoryChannelModelTokensRecord, + channel, + model, + ) +} + +func getGlobalChannelModelTokensRequest(ctx context.Context, model string) (int64, int64) { + return aggregateChannelModelSnapshots( + ctx, + model, + redisChannelModelTokensRecord, + memoryChannelModelTokensRecord, + func(channel string) bool { + _, err := strconv.ParseInt(channel, 10, 64) + return err == nil + }, + ) +} + +func aggregateChannelModelSnapshots( + ctx context.Context, + model string, + redisRecord *redisRateRecord, + memoryRecord *InMemoryRecord, + matchChannel func(string) bool, +) (int64, int64) { + model = normalizeAggregateModel(model) + matchSnapshot := func(snapshot recordSnapshot) bool { + if len(snapshot.Keys) < 2 { + return false + } + + snapshotModel := snapshot.Keys[len(snapshot.Keys)-1] + if model != "" && snapshotModel != model { + return false + } + + channel := strings.Join(snapshot.Keys[:len(snapshot.Keys)-1], ":") + + return matchChannel(channel) + } + + var ( + snapshots []recordSnapshot + err error + ) if common.RedisEnabled { - totalCount, secondCount, err := redisChannelModelTokensRecord.GetRequest( - ctx, - time.Minute, - channel, - model, - ) - if err == nil { - return totalCount, secondCount + snapshots, err = redisRecord.Snapshot(ctx, time.Minute) + } + + snapshots = chooseRecordSnapshots( + common.RedisEnabled, + snapshots, + err, + func() []recordSnapshot { return memoryRecord.Snapshot(time.Minute) }, + "redis channel model snapshot error: ", + ) + + var totalCount, secondCount int64 + for _, snapshot := range snapshots { + if !matchSnapshot(snapshot) { + continue } - log.Error("redis get request error: " + err.Error()) + totalCount += snapshot.TotalCount + secondCount += snapshot.SecondCount + } + + return totalCount, secondCount +} + +func normalizeAggregateModel(model string) string { + if model == "*" { + return "" } - return memoryChannelModelTokensRecord.GetRequest(time.Minute, channel, model) + return model } func GetAllChannelModelRates(ctx context.Context) (map[int64]map[string]ChannelModelRate, error) { @@ -532,21 +721,24 @@ func GetAllChannelModelRates(ctx context.Context) (map[int64]map[string]ChannelM if common.RedisEnabled { requestSnapshots, err = redisChannelModelRecord.Snapshot(ctx, time.Minute) - if err != nil { - log.Error("redis snapshot request error: " + err.Error()) - } + requestSnapshots = chooseRecordSnapshots( + true, + requestSnapshots, + err, + func() []recordSnapshot { return memoryChannelModelRecord.Snapshot(time.Minute) }, + "redis snapshot request error: ", + ) tokenSnapshots, err = redisChannelModelTokensRecord.Snapshot(ctx, time.Minute) - if err != nil { - log.Error("redis snapshot token error: " + err.Error()) - } - } - - if len(requestSnapshots) == 0 { + tokenSnapshots = chooseRecordSnapshots( + true, + tokenSnapshots, + err, + func() []recordSnapshot { return memoryChannelModelTokensRecord.Snapshot(time.Minute) }, + "redis snapshot token error: ", + ) + } else { requestSnapshots = memoryChannelModelRecord.Snapshot(time.Minute) - } - - if len(tokenSnapshots) == 0 { tokenSnapshots = memoryChannelModelTokensRecord.Snapshot(time.Minute) } diff --git a/core/common/reqlimit/mem_internal_test.go b/core/common/reqlimit/mem_internal_test.go index 5677d843..3c5e790f 100644 --- a/core/common/reqlimit/mem_internal_test.go +++ b/core/common/reqlimit/mem_internal_test.go @@ -1,9 +1,12 @@ package reqlimit import ( + "context" + "errors" "testing" "time" + "github.com/labring/aiproxy/core/common" "github.com/stretchr/testify/require" ) @@ -71,3 +74,126 @@ func TestInMemoryRecordCleanupInactiveEntries(t *testing.T) { return !ok }, 300*time.Millisecond, 10*time.Millisecond) } + +func TestChooseRecordSnapshotsKeepsEmptyRedisSnapshot(t *testing.T) { + memoryCalled := false + snapshots := chooseRecordSnapshots(true, nil, nil, func() []recordSnapshot { + memoryCalled = true + return []recordSnapshot{{Keys: []string{"memory"}, TotalCount: 1}} + }, "redis snapshot error: ") + + require.Empty(t, snapshots) + require.False(t, memoryCalled) +} + +func TestChooseRecordSnapshotsFallsBackOnRedisError(t *testing.T) { + snapshots := chooseRecordSnapshots( + true, + nil, + errors.New("boom"), + func() []recordSnapshot { + return []recordSnapshot{{Keys: []string{"memory"}, TotalCount: 1}} + }, + "redis snapshot error: ", + ) + + require.Len(t, snapshots, 1) + require.Equal(t, []string{"memory"}, snapshots[0].Keys) + require.Equal(t, int64(1), snapshots[0].TotalCount) +} + +func TestChannelModelScopedAggregateRequests(t *testing.T) { + oldRedisEnabled := common.RedisEnabled + oldRequestRecord := memoryChannelModelRecord + oldTokensRecord := memoryChannelModelTokensRecord + oldGroupChannelRequestRecord := memoryGroupChannelModelRecord + oldGroupChannelTokensRecord := memoryGroupChannelModelTokensRecord + common.RedisEnabled = false + memoryChannelModelRecord = &InMemoryRecord{} + memoryChannelModelTokensRecord = &InMemoryRecord{} + memoryGroupChannelModelRecord = &InMemoryRecord{} + memoryGroupChannelModelTokensRecord = &InMemoryRecord{} + t.Cleanup(func() { + common.RedisEnabled = oldRedisEnabled + memoryChannelModelRecord = oldRequestRecord + memoryChannelModelTokensRecord = oldTokensRecord + memoryGroupChannelModelRecord = oldGroupChannelRequestRecord + memoryGroupChannelModelTokensRecord = oldGroupChannelTokensRecord + }) + + ctx := context.Background() + PushChannelModelRequest(ctx, "1", "gpt-5") + PushGroupChannelModelRequest(ctx, "group-a", "1", "gpt-5") + PushGroupChannelModelRequest(ctx, "group-a", "2", "gpt-5") + PushGroupChannelModelRequest(ctx, "group-b", "1", "gpt-5") + PushChannelModelTokensRequest(ctx, "1", "gpt-5", 10) + PushGroupChannelModelTokensRequest(ctx, "group-a", "1", "gpt-5", 20) + PushGroupChannelModelTokensRequest(ctx, "group-a", "2", "gpt-5", 30) + PushGroupChannelModelTokensRequest(ctx, "group-b", "1", "gpt-5", 40) + + rpm, _ := GetChannelModelRequest(ctx, "*", "gpt-5") + require.Equal(t, int64(1), rpm) + + tpm, _ := GetChannelModelTokensRequest(ctx, "*", "gpt-5") + require.Equal(t, int64(10), tpm) + + PushChannelModelRequest(ctx, "2", "gpt-5-mini") + PushChannelModelTokensRequest(ctx, "2", "gpt-5-mini", 15) + rpm, _ = GetChannelModelRequest(ctx, "*", "*") + require.Equal(t, int64(2), rpm) + + tpm, _ = GetChannelModelTokensRequest(ctx, "*", "*") + require.Equal(t, int64(25), tpm) + + rpm, _ = GetGroupChannelModelRequest(ctx, "group-a", "", "gpt-5") + require.Equal(t, int64(2), rpm) + + tpm, _ = GetGroupChannelModelTokensRequest(ctx, "group-a", "", "gpt-5") + require.Equal(t, int64(50), tpm) + + PushGroupChannelModelRequest(ctx, "group-a", "3", "gpt-5-mini") + PushGroupChannelModelTokensRequest(ctx, "group-a", "3", "gpt-5-mini", 15) + rpm, _ = GetGroupChannelModelRequest(ctx, "group-a", "", "*") + require.Equal(t, int64(3), rpm) + + tpm, _ = GetGroupChannelModelTokensRequest(ctx, "group-a", "", "*") + require.Equal(t, int64(65), tpm) + + rpm, _ = GetGroupChannelModelRequest(ctx, "group-a", "1", "gpt-5") + require.Equal(t, int64(1), rpm) + + tpm, _ = GetGroupChannelModelTokensRequest(ctx, "group-a", "1", "gpt-5") + require.Equal(t, int64(20), tpm) +} + +func TestGroupChannelScopedAggregateRequestsMatchExactGroup(t *testing.T) { + oldRedisEnabled := common.RedisEnabled + oldGroupChannelRequestRecord := memoryGroupChannelModelRecord + oldGroupChannelTokensRecord := memoryGroupChannelModelTokensRecord + common.RedisEnabled = false + memoryGroupChannelModelRecord = &InMemoryRecord{} + memoryGroupChannelModelTokensRecord = &InMemoryRecord{} + t.Cleanup(func() { + common.RedisEnabled = oldRedisEnabled + memoryGroupChannelModelRecord = oldGroupChannelRequestRecord + memoryGroupChannelModelTokensRecord = oldGroupChannelTokensRecord + }) + + ctx := context.Background() + PushGroupChannelModelRequest(ctx, "group-a", "1", "gpt-5") + PushGroupChannelModelRequest(ctx, "group-a-child", "1", "gpt-5") + PushGroupChannelModelTokensRequest(ctx, "group-a", "1", "gpt-5", 20) + PushGroupChannelModelTokensRequest(ctx, "group-a-child", "1", "gpt-5", 30) + + rpm, _ := GetGroupChannelModelRequest(ctx, "group-a", "", "gpt-5") + require.Equal(t, int64(1), rpm) + + tpm, _ := GetGroupChannelModelTokensRequest(ctx, "group-a", "", "gpt-5") + require.Equal(t, int64(20), tpm) + + rpm, _ = GetGroupChannelModelRequest(ctx, "group-a-child", "", "gpt-5") + require.Equal(t, int64(1), rpm) + + tpm, _ = GetGroupChannelModelTokensRequest(ctx, "group-a-child", "", "gpt-5") + require.Equal(t, int64(30), tpm) +} diff --git a/core/common/reqlimit/redis.go b/core/common/reqlimit/redis.go index 8837fa52..74e4fa73 100644 --- a/core/common/reqlimit/redis.go +++ b/core/common/reqlimit/redis.go @@ -36,6 +36,10 @@ func newRedisChannelModelRecord(getRDB func() *redis.Client) *redisRateRecord { return newRedisRateRecord("channel-model-record", getRDB) } +func newRedisGroupChannelModelRecord(getRDB func() *redis.Client) *redisRateRecord { + return newRedisRateRecord("group-channel-model-record", getRDB) +} + func newRedisGroupModelTokensRecord(getRDB func() *redis.Client) *redisRateRecord { return newRedisRateRecord("group-model-tokens-record", getRDB) } @@ -48,6 +52,10 @@ func newRedisChannelModelTokensRecord(getRDB func() *redis.Client) *redisRateRec return newRedisRateRecord("channel-model-tokens-record", getRDB) } +func newRedisGroupChannelModelTokensRecord(getRDB func() *redis.Client) *redisRateRecord { + return newRedisRateRecord("group-channel-model-tokens-record", getRDB) +} + const pushRequestLuaScript = ` local bucket_key = KEYS[1] local meta_key = KEYS[2] diff --git a/core/controller/channel-billing.go b/core/controller/channel-billing.go index 50a7bff6..7c710175 100644 --- a/core/controller/channel-billing.go +++ b/core/controller/channel-billing.go @@ -75,7 +75,7 @@ func updateChannelBalance(channel *model.Channel) (float64, error) { // @Security ApiKeyAuth // @Param id path int true "Channel ID" // @Success 200 {object} middleware.APIResponse{data=float64} -// @Router /api/channel/{id}/balance [get] +// @Router /api/channel/{id}/update_balance [get] func UpdateChannelBalance(c *gin.Context) { id, err := strconv.Atoi(c.Param("id")) if err != nil { @@ -170,7 +170,7 @@ func updateAllChannelsBalance() error { // @Produce json // @Security ApiKeyAuth // @Success 200 {object} middleware.APIResponse -// @Router /api/channels/balance [get] +// @Router /api/channels/update_balance [get] func UpdateAllChannelsBalance(c *gin.Context) { err := updateAllChannelsBalance() if err != nil { diff --git a/core/controller/channel-test.go b/core/controller/channel-test.go index 14c3b26b..ea3eae6b 100644 --- a/core/controller/channel-test.go +++ b/core/controller/channel-test.go @@ -38,6 +38,12 @@ var ( modelConfigCacheOnce sync.Once ) +type testSingleModelOptions struct { + AllowMissingModelConfig bool + ModelConfig *model.ModelConfig + SaveResult func(*meta.Meta, bool, string, int) (*model.ChannelTest, error) +} + func guessModelConfig(modelName string) model.ModelConfig { modelConfigCacheOnce.Do(func() { for _, c := range adaptors.ChannelAdaptor { @@ -56,6 +62,47 @@ func guessModelConfig(modelName string) model.ModelConfig { return model.ModelConfig{} } +func resolveTestModelConfig( + mc *model.ModelCaches, + modelName string, + opts testSingleModelOptions, +) (model.ModelConfig, bool, error) { + if opts.ModelConfig != nil { + return *opts.ModelConfig, true, nil + } + + if mc != nil && mc.ModelConfig != nil { + modelConfig, ok := mc.ModelConfig.GetModelConfig(modelName) + if ok { + return modelConfig, true, nil + } + } + + if !opts.AllowMissingModelConfig { + return model.ModelConfig{}, false, errors.New(modelName + " model config not found") + } + + return model.NewDefaultModelConfig(modelName), false, nil +} + +func testRequestModelConfig(modelConfig model.ModelConfig) model.ModelConfig { + if modelConfig.Type != mode.Unknown { + return modelConfig + } + + guessedModelConfig := guessModelConfig(modelConfig.Model) + if guessedModelConfig.Type == mode.Unknown { + return modelConfig + } + + modelConfig.Type = guessedModelConfig.Type + if len(modelConfig.Config) == 0 { + modelConfig.Config = guessedModelConfig.Config + } + + return modelConfig +} + // testSingleModel tests a single model in the channel // If saveToDB is true, the test result will be saved to database func testSingleModel( @@ -64,26 +111,50 @@ func testSingleModel( modelName string, saveToDB bool, ) (*model.ChannelTest, error) { - modelConfig, ok := mc.ModelConfig.GetModelConfig(modelName) - if !ok { - return nil, errors.New(modelName + " model config not found") - } + return testSingleModelWithOptions( + mc, + channel, + modelName, + testSingleModelOptions{ + SaveResult: func(testMeta *meta.Meta, success bool, response string, code int) (*model.ChannelTest, error) { + return channel.UpdateModelTest( + testMeta.RequestAt, + testMeta.OriginModel, + testMeta.ActualModel, + testMeta.Mode, + time.Since(testMeta.RequestAt).Seconds(), + success, + response, + code, + ) + }, + }, + saveToDB, + ) +} - if modelConfig.Type == mode.Unknown { - newModelConfig := guessModelConfig(modelName) - if newModelConfig.Type != mode.Unknown { - modelConfig = newModelConfig - } +func testSingleModelWithOptions( + mc *model.ModelCaches, + channel *model.Channel, + modelName string, + opts testSingleModelOptions, + saveToDB bool, +) (*model.ChannelTest, error) { + modelConfig, _, err := resolveTestModelConfig(mc, modelName, opts) + if err != nil { + return nil, err } - if modelConfig.Type != mode.Unknown { + requestModelConfig := testRequestModelConfig(modelConfig) + + if requestModelConfig.Type != mode.Unknown { a, ok := adaptors.GetAdaptor(channel.Type) if !ok { return nil, errors.New("adaptor not found") } - if !a.SupportMode(meta.NewMeta(channel, modelConfig.Type, modelName, modelConfig)) { - return nil, fmt.Errorf("%s not supported by adaptor", modelConfig.Type) + if !a.SupportMode(meta.NewMeta(channel, requestModelConfig.Type, modelName, modelConfig)) { + return nil, fmt.Errorf("%s not supported by adaptor", requestModelConfig.Type) } } @@ -94,14 +165,14 @@ func testSingleModel( ActualModel: modelName, Success: true, Code: http.StatusOK, - Mode: modelConfig.Type, + Mode: requestModelConfig.Type, ChannelName: channel.Name, ChannelType: channel.Type, ChannelID: channel.ID, }, nil } - body, m, err := utils.BuildRequest(modelConfig) + body, m, err := utils.BuildRequest(requestModelConfig) if err != nil { return nil, err } @@ -162,16 +233,9 @@ func testSingleModel( // Only save to database for saved channels (not preview tests) if saveToDB && channel.ID != 0 { - return channel.UpdateModelTest( - testMeta.RequestAt, - testMeta.OriginModel, - testMeta.ActualModel, - testMeta.Mode, - time.Since(testMeta.RequestAt).Seconds(), - success, - respStr, - code, - ) + if opts.SaveResult != nil { + return opts.SaveResult(testMeta, success, respStr, code) + } } return ct, nil @@ -187,7 +251,7 @@ func testSingleModel( // @Param id path int true "Channel ID" // @Param model path string true "Model name" // @Success 200 {object} middleware.APIResponse{data=model.ChannelTest} -// @Router /api/channel/{id}/{model} [get] +// @Router /api/channel/{id}/test/{model} [get] // //nolint:goconst func TestChannel(c *gin.Context) { @@ -797,7 +861,7 @@ func createTempChannel(req *TestChannelRequest) *model.Channel { // @Security ApiKeyAuth // @Param request body TestSingleModelRequest true "Channel test request" // @Success 200 {object} middleware.APIResponse{data=model.ChannelTest} -// @Router /api/channel/test [post] +// @Router /api/channel/test-preview [post] func TestChannelPreview(c *gin.Context) { var req TestSingleModelRequest if err := c.ShouldBindJSON(&req); err != nil { @@ -861,7 +925,7 @@ func TestChannelPreview(c *gin.Context) { // @Param stream query bool false "Stream mode (SSE)" // @Param request body TestChannelRequest true "Channel test request" // @Success 200 {object} middleware.APIResponse{data=[]TestResult} -// @Router /api/channel/test-all [post] +// @Router /api/channel/test-preview-all [post] func TestChannelPreviewAll(c *gin.Context) { var req TestChannelRequest if err := c.ShouldBindJSON(&req); err != nil { diff --git a/core/controller/channel.go b/core/controller/channel.go index 72d4e032..9704840e 100644 --- a/core/controller/channel.go +++ b/core/controller/channel.go @@ -27,6 +27,8 @@ import ( // @Security ApiKeyAuth // @Success 200 {object} middleware.APIResponse{data=map[int]adaptors.AdaptorMeta} // @Router /api/channels/type_metas [get] +// @Router /api/group/{group}/channels/type_metas [get] +// @Router /api/group_channels/type_metas [get] func ChannelTypeMetas(c *gin.Context) { middleware.SuccessResponse(c, adaptors.ChannelMetas) } @@ -164,8 +166,8 @@ func AddChannels(c *gin.Context) { } _channels := make([]*model.Channel, 0, len(channels)) - for _, channel := range channels { - channel, err := channel.ToChannel() + for _, req := range channels { + channel, err := req.ToChannel() if err != nil { middleware.ErrorResponse(c, http.StatusBadRequest, err.Error()) return diff --git a/core/controller/dashboard.go b/core/controller/dashboard.go index 01cf039a..38613aa4 100644 --- a/core/controller/dashboard.go +++ b/core/controller/dashboard.go @@ -423,6 +423,51 @@ func GetGroupDashboardModels(c *gin.Context) { middleware.SuccessResponse(c, newEnabledModelConfigs) } +// GetGroupChannelDashboardModels godoc +// +// @Summary Get group channel dashboard models +// @Description Returns group-channel model configs backed by enabled group channels for the given group +// @Tags dashboard +// @Produce json +// @Security ApiKeyAuth +// @Param group path string true "Group" +// @Success 200 {object} middleware.APIResponse{data=[]GroupModel} +// @Router /api/group/{group}/channel-dashboard/models [get] +func GetGroupChannelDashboardModels(c *gin.Context) { + group := c.Param("group") + if group == "" { + middleware.ErrorResponse(c, http.StatusBadRequest, "invalid group parameter") + return + } + + configsBySet, _, err := loadGroupChannelModelsBySet(group) + if err != nil { + middleware.ErrorResponse( + c, + http.StatusInternalServerError, + fmt.Sprintf("failed to get group channel models: %v", err), + ) + + return + } + + groupModels := make([]GroupModel, 0) + + appended := make(map[string]struct{}) + for _, configs := range configsBySet { + for _, mc := range configs { + if _, ok := appended[mc.Model]; ok { + continue + } + + groupModels = append(groupModels, NewGroupModel(mc)) + appended[mc.Model] = struct{}{} + } + } + + middleware.SuccessResponse(c, groupModels) +} + // GetTimeSeriesModelData godoc // // @Summary Get model usage data for a specific channel @@ -606,20 +651,335 @@ func GetGroupTimeSeriesModelDataV3(c *gin.Context) { return } - rpm, _ := reqlimit.GetGroupModelTokennameRequest( + middleware.SuccessResponse(c, result) +} + +func parseGroupChannelDashboardParams(c *gin.Context) ( + int, + string, + time.Time, + time.Time, + model.TimeSpanType, + *time.Location, + model.SummarySelectFields, +) { + groupChannelID, _ := strconv.Atoi(c.Query("group_channel")) + modelName := c.Query("model") + startTime, endTime := utils.ParseTimeRange(c, -1) + timezoneLocation, _ := time.LoadLocation(c.DefaultQuery("timezone", "Local")) + timespan := c.Query("timespan") + start, end, timeSpan := getDashboardTime( + c.Query("type"), + timespan, + startTime, + endTime, + timezoneLocation, + ) + fields := model.ParseSummaryFields(c.Query("fields")) + + return groupChannelID, modelName, start, end, timeSpan, timezoneLocation, fields +} + +func groupChannelRateQueryKey(groupChannelID int) string { + if groupChannelID == 0 { + return "" + } + return strconv.Itoa(groupChannelID) +} + +// GetGroupChannelDashboard godoc +// +// @Summary Get group channel dashboard data +// @Description Returns group-channel usage statistics for a specific group +// @Tags dashboard +// @Produce json +// @Security ApiKeyAuth +// @Param group path string true "Group" +// @Param group_channel query int false "Group channel ID" +// @Param model query string false "Model name" +// @Param start_timestamp query int64 false "Start second timestamp" +// @Param end_timestamp query int64 false "End second timestamp" +// @Param timezone query string false "Timezone, default is Local" +// @Param timespan query string false "Time span type (minute, hour, day, month)" +// @Param fields query string false "Comma-separated list of fields to select" +// @Success 200 {object} middleware.APIResponse{data=model.DashboardResponse} +// @Router /api/group/{group}/channel-dashboard [get] +func GetGroupChannelDashboard(c *gin.Context) { + group := c.Param("group") + if group == "" { + middleware.ErrorResponse(c, http.StatusBadRequest, "invalid group parameter") + return + } + + groupChannelID, modelName, start, end, timeSpan, timezoneLocation, fields := parseGroupChannelDashboardParams( + c, + ) + + dashboards, err := model.GetGroupChannelDashboardData( + group, + groupChannelID, + modelName, + start, + end, + timeSpan, + timezoneLocation, + fields, + ) + if err != nil { + middleware.ErrorResponse(c, http.StatusInternalServerError, err.Error()) + return + } + + dashboards.ChartData = fillGaps(dashboards.ChartData, start, end, timeSpan) + + channelKey := groupChannelRateQueryKey(groupChannelID) + rpm, _ := reqlimit.GetGroupChannelModelRequest( c.Request.Context(), group, + channelKey, + modelName, + ) + dashboards.RPM = rpm + tpm, _ := reqlimit.GetGroupChannelModelTokensRequest( + c.Request.Context(), + group, + channelKey, + modelName, + ) + dashboards.TPM = tpm + + middleware.SuccessResponse(c, dashboards) +} + +// GetGlobalGroupChannelDashboard godoc +// +// @Summary Get global group channel dashboard data +// @Description Returns group-channel usage statistics across groups, optionally filtered by group +// @Tags dashboard +// @Produce json +// @Security ApiKeyAuth +// @Param group query string false "Filter by group" +// @Param group_channel query int false "Group channel ID" +// @Param model query string false "Model name" +// @Param start_timestamp query int64 false "Start second timestamp" +// @Param end_timestamp query int64 false "End second timestamp" +// @Param timezone query string false "Timezone, default is Local" +// @Param timespan query string false "Time span type (minute, hour, day, month)" +// @Param fields query string false "Comma-separated list of fields to select" +// @Success 200 {object} middleware.APIResponse{data=model.DashboardResponse} +// @Router /api/dashboard/group_channel [get] +func GetGlobalGroupChannelDashboard(c *gin.Context) { + group := c.Query("group") + groupChannelID, modelName, start, end, timeSpan, timezoneLocation, fields := parseGroupChannelDashboardParams( + c, + ) + + dashboards, err := model.GetGlobalGroupChannelDashboardData( + group, + groupChannelID, + modelName, + start, + end, + timeSpan, + timezoneLocation, + fields, + ) + if err != nil { + middleware.ErrorResponse(c, http.StatusInternalServerError, err.Error()) + return + } + + dashboards.ChartData = fillGaps(dashboards.ChartData, start, end, timeSpan) + + middleware.SuccessResponse(c, dashboards) +} + +// GetGroupChannelTimeSeriesModelData godoc +// +// @Summary Get group channel time series data +// @Description Returns group-channel model usage time series for a specific group +// @Tags dashboard +// @Produce json +// @Security ApiKeyAuth +// @Param group path string true "Group" +// @Param group_channel query int false "Group channel ID" +// @Param model query string false "Model name" +// @Param start_timestamp query int64 false "Start timestamp" +// @Param end_timestamp query int64 false "End timestamp" +// @Param timezone query string false "Timezone, default is Local" +// @Param timespan query string false "Time span type (minute, hour, day, month)" +// @Param fields query string false "Comma-separated list of fields to select" +// @Success 200 {object} middleware.APIResponse{data=[]model.TimeSummaryDataV2} +// @Router /api/group/{group}/channel-dashboardv2 [get] +func GetGroupChannelTimeSeriesModelData(c *gin.Context) { + group := c.Param("group") + if group == "" { + middleware.ErrorResponse(c, http.StatusBadRequest, "invalid group parameter") + return + } + + groupChannelID, modelName, start, end, timeSpan, timezoneLocation, fields := parseGroupChannelDashboardParams( + c, + ) + + result, err := model.GetGroupChannelTimeSeriesModelData( + group, + groupChannelID, + modelName, + start, + end, + timeSpan, + timezoneLocation, + fields, + ) + if err != nil { + middleware.ErrorResponse(c, http.StatusInternalServerError, err.Error()) + return + } + + middleware.SuccessResponse(c, result) +} + +// GetGlobalGroupChannelTimeSeriesModelData godoc +// +// @Summary Get global group channel time series data +// @Description Returns group-channel model usage time series across groups, optionally filtered by group +// @Tags dashboard +// @Produce json +// @Security ApiKeyAuth +// @Param group query string false "Filter by group" +// @Param group_channel query int false "Group channel ID" +// @Param model query string false "Model name" +// @Param start_timestamp query int64 false "Start timestamp" +// @Param end_timestamp query int64 false "End timestamp" +// @Param timezone query string false "Timezone, default is Local" +// @Param timespan query string false "Time span type (minute, hour, day, month)" +// @Param fields query string false "Comma-separated list of fields to select" +// @Success 200 {object} middleware.APIResponse{data=[]model.TimeSummaryDataV2} +// @Router /api/dashboardv2/group_channel [get] +func GetGlobalGroupChannelTimeSeriesModelData(c *gin.Context) { + group := c.Query("group") + groupChannelID, modelName, start, end, timeSpan, timezoneLocation, fields := parseGroupChannelDashboardParams( + c, + ) + + result, err := model.GetGlobalGroupChannelTimeSeriesModelData( + group, + groupChannelID, + modelName, + start, + end, + timeSpan, + timezoneLocation, + fields, + ) + if err != nil { + middleware.ErrorResponse(c, http.StatusInternalServerError, err.Error()) + return + } + + middleware.SuccessResponse(c, result) +} + +// GetGroupChannelTimeSeriesModelDataV3 godoc +// +// @Summary Get group channel dashboard V3 data +// @Description Returns group-channel dashboard V3 data for a specific group +// @Tags dashboard +// @Produce json +// @Security ApiKeyAuth +// @Param group path string true "Group" +// @Param group_channel query int false "Group channel ID" +// @Param model query string false "Model name" +// @Param start_timestamp query int64 false "Start timestamp" +// @Param end_timestamp query int64 false "End timestamp" +// @Param timezone query string false "Timezone, default is Local" +// @Param timespan query string false "Time span type (minute, hour, day, month)" +// @Param fields query string false "Comma-separated list of fields to select" +// @Success 200 {object} middleware.APIResponse{data=model.DashboardV3Response} +// @Router /api/group/{group}/channel-dashboardv3 [get] +func GetGroupChannelTimeSeriesModelDataV3(c *gin.Context) { + group := c.Param("group") + if group == "" { + middleware.ErrorResponse(c, http.StatusBadRequest, "invalid group parameter") + return + } + + groupChannelID, modelName, start, end, timeSpan, timezoneLocation, fields := parseGroupChannelDashboardParams( + c, + ) + + result, err := model.GetGroupChannelDashboardV3Data( + group, + groupChannelID, + modelName, + start, + end, + timeSpan, + timezoneLocation, + fields, + ) + if err != nil { + middleware.ErrorResponse(c, http.StatusInternalServerError, err.Error()) + return + } + + channelKey := groupChannelRateQueryKey(groupChannelID) + rpm, _ := reqlimit.GetGroupChannelModelRequest( + c.Request.Context(), + group, + channelKey, modelName, - tokenName, ) result.RPM = rpm - tpm, _ := reqlimit.GetGroupModelTokennameTokensRequest( + tpm, _ := reqlimit.GetGroupChannelModelTokensRequest( c.Request.Context(), group, + channelKey, modelName, - tokenName, ) result.TPM = tpm middleware.SuccessResponse(c, result) } + +// GetGlobalGroupChannelTimeSeriesModelDataV3 godoc +// +// @Summary Get global group channel dashboard V3 data +// @Description Returns group-channel dashboard V3 data across groups, optionally filtered by group +// @Tags dashboard +// @Produce json +// @Security ApiKeyAuth +// @Param group query string false "Filter by group" +// @Param group_channel query int false "Group channel ID" +// @Param model query string false "Model name" +// @Param start_timestamp query int64 false "Start timestamp" +// @Param end_timestamp query int64 false "End timestamp" +// @Param timezone query string false "Timezone, default is Local" +// @Param timespan query string false "Time span type (minute, hour, day, month)" +// @Param fields query string false "Comma-separated list of fields to select" +// @Success 200 {object} middleware.APIResponse{data=model.DashboardV3Response} +// @Router /api/dashboardv3/group_channel [get] +func GetGlobalGroupChannelTimeSeriesModelDataV3(c *gin.Context) { + group := c.Query("group") + groupChannelID, modelName, start, end, timeSpan, timezoneLocation, fields := parseGroupChannelDashboardParams( + c, + ) + + result, err := model.GetGlobalGroupChannelDashboardV3Data( + group, + groupChannelID, + modelName, + start, + end, + timeSpan, + timezoneLocation, + fields, + ) + if err != nil { + middleware.ErrorResponse(c, http.StatusInternalServerError, err.Error()) + return + } + + middleware.SuccessResponse(c, result) +} diff --git a/core/controller/group-channel.go b/core/controller/group-channel.go new file mode 100644 index 00000000..b97bf1e8 --- /dev/null +++ b/core/controller/group-channel.go @@ -0,0 +1,2153 @@ +package controller + +import ( + "errors" + "fmt" + "maps" + "math/rand/v2" + "net/http" + "slices" + "strconv" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/bytedance/sonic" + "github.com/gin-gonic/gin" + "github.com/labring/aiproxy/core/common/config" + "github.com/labring/aiproxy/core/controller/utils" + "github.com/labring/aiproxy/core/middleware" + "github.com/labring/aiproxy/core/model" + "github.com/labring/aiproxy/core/relay/adaptors" + "github.com/labring/aiproxy/core/relay/meta" + "github.com/labring/aiproxy/core/relay/render" + log "github.com/sirupsen/logrus" +) + +type GroupChannelResponse struct { + *model.GroupChannel + AccessedAt time.Time `json:"accessed_at,omitempty"` +} + +func (c *GroupChannelResponse) MarshalJSON() ([]byte, error) { + type Alias model.GroupChannel + + accessedAt := int64(0) + if !c.AccessedAt.IsZero() { + accessedAt = c.AccessedAt.UnixMilli() + } + + return sonic.Marshal(&struct { + *Alias + CreatedAt int64 `json:"created_at"` + LastTestErrorAt int64 `json:"last_test_error_at"` + AccessedAt int64 `json:"accessed_at,omitempty"` + }{ + Alias: (*Alias)(c.GroupChannel), + CreatedAt: c.CreatedAt.UnixMilli(), + LastTestErrorAt: c.LastTestErrorAt.UnixMilli(), + AccessedAt: accessedAt, + }) +} + +func buildGroupChannelResponse(channel *model.GroupChannel) *GroupChannelResponse { + return &GroupChannelResponse{GroupChannel: channel} +} + +func buildGroupChannelResponses(channels []*model.GroupChannel) []*GroupChannelResponse { + lastRequestTimes := getGroupChannelLastRequestTimes(channels) + + responses := make([]*GroupChannelResponse, len(channels)) + for i, channel := range channels { + responses[i] = buildGroupChannelResponse(channel) + responses[i].AccessedAt = lastRequestTimes[channel.ID] + } + + return responses +} + +func getGroupChannelLastRequestTimes(channels []*model.GroupChannel) map[int]time.Time { + if len(channels) == 0 { + return nil + } + + groupID := channels[0].GroupID + + channelIDs := make([]int, 0, len(channels)) + for _, channel := range channels { + if channel.GroupID != groupID { + return getGroupChannelLastRequestTimesByChannel(channels) + } + + channelIDs = append(channelIDs, channel.ID) + } + + lastRequestTimes, err := model.GetGroupChannelLastRequestTimesMinute(groupID, channelIDs) + if err != nil { + return nil + } + + return lastRequestTimes +} + +func getGroupChannelLastRequestTimesByChannel(channels []*model.GroupChannel) map[int]time.Time { + lastRequestTimes := make(map[int]time.Time, len(channels)) + for _, channel := range channels { + lastRequestAt, err := model.GetGroupChannelLastRequestTimeMinute( + channel.GroupID, + channel.ID, + ) + if err == nil { + lastRequestTimes[channel.ID] = lastRequestAt + } + } + + return lastRequestTimes +} + +func groupParam(c *gin.Context) string { + return c.Param("group") +} + +type GroupChannelEnabledModelChannel struct { + ID int `json:"id"` + GroupID string `json:"group_id"` + Type model.ChannelType `json:"type"` + Name string `json:"name"` + Priority int32 `json:"priority"` + Weight float64 `json:"weight"` +} + +func newGroupChannelEnabledModelChannel(ch *model.GroupChannel) GroupChannelEnabledModelChannel { + return GroupChannelEnabledModelChannel{ + ID: ch.ID, + GroupID: ch.GroupID, + Type: ch.Type, + Name: ch.Name, + Priority: ch.GetPriority(), + } +} + +func calculateGroupChannelWeights(channels []GroupChannelEnabledModelChannel) { + if len(channels) == 0 { + return + } + + totalWeight := 0.0 + for _, ch := range channels { + if ch.Priority > 0 { + totalWeight += float64(ch.Priority) + } + } + + if totalWeight <= 0 { + return + } + + for i := range channels { + if channels[i].Priority > 0 { + channels[i].Weight = float64(channels[i].Priority) / totalWeight * 100 + } + } +} + +func loadGroupChannelModelsBySet( + group string, +) ( + map[string][]model.ModelConfig, + map[string]map[string][]GroupChannelEnabledModelChannel, + error, +) { + if group == "" { + return nil, nil, errors.New("group id is required") + } + + groupCache, err := model.CacheGetGroup(group) + if err != nil { + return nil, nil, err + } + + channelsCache, err := model.CacheGetGroupChannels(group) + if err != nil { + return nil, nil, err + } + + scopeConfigs, err := model.CacheGetGroupScopeModelConfigs(group) + if err != nil { + return nil, nil, err + } + + configsBySet := make(map[string][]model.ModelConfig) + channelsByModelSet := make(map[string]map[string][]GroupChannelEnabledModelChannel) + appendedConfigs := make(map[string]map[string]struct{}) + + for _, channel := range channelsCache.Channels { + if channel.Status != model.ChannelStatusEnabled { + continue + } + + for _, modelName := range model.GroupChannelAccessModels(channel) { + mc, ok := scopeConfigs.Configs[modelName] + if !ok { + continue + } + + mc = middleware.GetGroupScopeAdjustedModelConfig(*groupCache, mc) + for _, set := range channel.GetSets() { + if _, ok := appendedConfigs[set]; !ok { + appendedConfigs[set] = make(map[string]struct{}) + } + + if _, ok := appendedConfigs[set][mc.Model]; !ok { + configsBySet[set] = append(configsBySet[set], mc) + appendedConfigs[set][mc.Model] = struct{}{} + } + + if _, ok := channelsByModelSet[mc.Model]; !ok { + channelsByModelSet[mc.Model] = make( + map[string][]GroupChannelEnabledModelChannel, + ) + } + + channelsByModelSet[mc.Model][set] = append( + channelsByModelSet[mc.Model][set], + newGroupChannelEnabledModelChannel(channel), + ) + } + } + } + + for set := range configsBySet { + slices.SortStableFunc(configsBySet[set], model.SortModelConfigsFunc) + } + + for modelName, sets := range channelsByModelSet { + for set, channels := range sets { + calculateGroupChannelWeights(channels) + channelsByModelSet[modelName][set] = channels + } + } + + return configsBySet, channelsByModelSet, nil +} + +// GetGroupChannelEnabledModels godoc +// +// @Summary Get enabled group channel models +// @Description Returns group channel model configs grouped by set for a group +// @Tags group-channel +// @Produce json +// @Security ApiKeyAuth +// @Param group path string true "Group ID" +// @Success 200 {object} middleware.APIResponse{data=map[string][]model.ModelConfig} +// @Router /api/group/{group}/channel-models/enabled [get] +func GetGroupChannelEnabledModels(c *gin.Context) { + configsBySet, _, err := loadGroupChannelModelsBySet(groupParam(c)) + if err != nil { + middleware.ErrorResponse(c, http.StatusInternalServerError, err.Error()) + return + } + + middleware.SuccessResponse(c, configsBySet) +} + +// GetGroupChannelEnabledModelsSet godoc +// +// @Summary Get enabled group channel models by set +// @Description Returns group channel model configs for a specific set +// @Tags group-channel +// @Produce json +// @Security ApiKeyAuth +// @Param group path string true "Group ID" +// @Param set path string true "Models set" +// @Success 200 {object} middleware.APIResponse{data=[]model.ModelConfig} +// @Router /api/group/{group}/channel-models/enabled/{set} [get] +func GetGroupChannelEnabledModelsSet(c *gin.Context) { + set := c.Param("set") + if set == "" { + middleware.ErrorResponse(c, http.StatusBadRequest, "set is required") + return + } + + configsBySet, _, err := loadGroupChannelModelsBySet(groupParam(c)) + if err != nil { + middleware.ErrorResponse(c, http.StatusInternalServerError, err.Error()) + return + } + + middleware.SuccessResponse(c, configsBySet[set]) +} + +// GetGlobalGroupChannels godoc +// +// @Summary Get group channels with pagination +// @Description Returns a paginated list of group channels across groups with optional filters +// @Tags group_channels +// @Produce json +// @Security ApiKeyAuth +// @Param group query string false "Filter by group" +// @Param page query int false "Page number" +// @Param per_page query int false "Items per page" +// @Param id query int false "Filter by id" +// @Param name query string false "Filter by name" +// @Param key query string false "Filter by key" +// @Param channel_type query int false "Filter by channel type" +// @Param base_url query string false "Filter by base URL" +// @Param order query string false "Order by field" +// @Success 200 {object} middleware.APIResponse{data=map[string]any{channels=[]model.GroupChannel,total=int}} +// @Router /api/group_channels/ [get] +func GetGlobalGroupChannels(c *gin.Context) { + page, perPage := utils.ParsePageParams(c) + id, _ := strconv.Atoi(c.Query("id")) + channelType, _ := strconv.Atoi(c.Query("channel_type")) + + channels, total, err := model.GetGlobalGroupChannels( + c.Query("group"), + page, + perPage, + id, + c.Query("name"), + c.Query("key"), + channelType, + c.Query("base_url"), + c.Query("order"), + ) + if err != nil { + middleware.ErrorResponse(c, http.StatusInternalServerError, err.Error()) + return + } + + middleware.SuccessResponse(c, gin.H{ + "channels": buildGroupChannelResponses(channels), + "total": total, + }) +} + +// GetGroupChannels godoc +// +// @Summary Get group channels with pagination +// @Description Returns a paginated list of group channels for a specific group +// @Tags group-channel +// @Produce json +// @Security ApiKeyAuth +// @Param group path string true "Group ID" +// @Param page query int false "Page number" +// @Param per_page query int false "Items per page" +// @Param id query int false "Filter by id" +// @Param name query string false "Filter by name" +// @Param key query string false "Filter by key" +// @Param channel_type query int false "Filter by channel type" +// @Param base_url query string false "Filter by base URL" +// @Param order query string false "Order by field" +// @Success 200 {object} middleware.APIResponse{data=map[string]any{channels=[]model.GroupChannel,total=int}} +// @Router /api/group/{group}/channels/ [get] +func GetGroupChannels(c *gin.Context) { + page, perPage := utils.ParsePageParams(c) + id, _ := strconv.Atoi(c.Query("id")) + channelType, _ := strconv.Atoi(c.Query("channel_type")) + + channels, total, err := model.GetGroupChannels( + groupParam(c), + page, + perPage, + id, + c.Query("name"), + c.Query("key"), + channelType, + c.Query("base_url"), + c.Query("order"), + ) + if err != nil { + middleware.ErrorResponse(c, http.StatusInternalServerError, err.Error()) + return + } + + middleware.SuccessResponse(c, gin.H{ + "channels": buildGroupChannelResponses(channels), + "total": total, + }) +} + +// SearchGlobalGroupChannels godoc +// +// @Summary Search group channels +// @Description Search group channels across groups with keyword and optional filters +// @Tags group_channels +// @Produce json +// @Security ApiKeyAuth +// @Param group query string false "Filter by group" +// @Param keyword query string false "Search keyword" +// @Param page query int false "Page number" +// @Param per_page query int false "Items per page" +// @Param id query int false "Filter by id" +// @Param name query string false "Filter by name" +// @Param key query string false "Filter by key" +// @Param channel_type query int false "Filter by channel type" +// @Param base_url query string false "Filter by base URL" +// @Param order query string false "Order by field" +// @Success 200 {object} middleware.APIResponse{data=map[string]any{channels=[]model.GroupChannel,total=int}} +// @Router /api/group_channels/search [get] +func SearchGlobalGroupChannels(c *gin.Context) { + page, perPage := utils.ParsePageParams(c) + id, _ := strconv.Atoi(c.Query("id")) + channelType, _ := strconv.Atoi(c.Query("channel_type")) + + channels, total, err := model.SearchGlobalGroupChannels( + c.Query("group"), + c.Query("keyword"), + page, + perPage, + id, + c.Query("name"), + c.Query("key"), + channelType, + c.Query("base_url"), + c.Query("order"), + ) + if err != nil { + middleware.ErrorResponse(c, http.StatusInternalServerError, err.Error()) + return + } + + middleware.SuccessResponse(c, gin.H{ + "channels": buildGroupChannelResponses(channels), + "total": total, + }) +} + +// SearchGroupChannels godoc +// +// @Summary Search group channels +// @Description Search group channels for a specific group with keyword and optional filters +// @Tags group-channel +// @Produce json +// @Security ApiKeyAuth +// @Param group path string true "Group ID" +// @Param keyword query string false "Search keyword" +// @Param page query int false "Page number" +// @Param per_page query int false "Items per page" +// @Param id query int false "Filter by id" +// @Param name query string false "Filter by name" +// @Param key query string false "Filter by key" +// @Param channel_type query int false "Filter by channel type" +// @Param base_url query string false "Filter by base URL" +// @Param order query string false "Order by field" +// @Success 200 {object} middleware.APIResponse{data=map[string]any{channels=[]model.GroupChannel,total=int}} +// @Router /api/group/{group}/channels/search [get] +func SearchGroupChannels(c *gin.Context) { + page, perPage := utils.ParsePageParams(c) + id, _ := strconv.Atoi(c.Query("id")) + channelType, _ := strconv.Atoi(c.Query("channel_type")) + + channels, total, err := model.SearchGroupChannels( + groupParam(c), + c.Query("keyword"), + page, + perPage, + id, + c.Query("name"), + c.Query("key"), + channelType, + c.Query("base_url"), + c.Query("order"), + ) + if err != nil { + middleware.ErrorResponse(c, http.StatusInternalServerError, err.Error()) + return + } + + middleware.SuccessResponse(c, gin.H{ + "channels": buildGroupChannelResponses(channels), + "total": total, + }) +} + +func getGroupChannelBatchInfoRequest(c *gin.Context) ([]int, bool) { + ids := []int{} + if err := c.ShouldBindJSON(&ids); err != nil { + middleware.ErrorResponse(c, http.StatusBadRequest, err.Error()) + return nil, false + } + + return ids, true +} + +// GetGlobalGroupChannelBatchInfo godoc +// +// @Summary Get basic info for multiple group channels +// @Description Returns id, group, name, and type for a batch of group channel IDs across groups +// @Tags group_channels +// @Accept json +// @Produce json +// @Security ApiKeyAuth +// @Param ids body []int true "Group channel IDs" +// @Success 200 {object} middleware.APIResponse{data=[]model.GroupChannelBasicInfo} +// @Router /api/group_channels/batch_info [post] +func GetGlobalGroupChannelBatchInfo(c *gin.Context) { + ids, ok := getGroupChannelBatchInfoRequest(c) + if !ok { + return + } + + channels, err := model.GetGlobalGroupChannelsBasicInfoByIDs(ids) + if err != nil { + middleware.ErrorResponse(c, http.StatusInternalServerError, err.Error()) + return + } + + middleware.SuccessResponse(c, channels) +} + +// GetGroupChannelBatchInfo godoc +// +// @Summary Get basic info for multiple group channels in a group +// @Description Returns id, group, name, and type for a batch of group channel IDs filtered by group +// @Tags group-channel +// @Accept json +// @Produce json +// @Security ApiKeyAuth +// @Param group path string true "Group ID" +// @Param ids body []int true "Group channel IDs" +// @Success 200 {object} middleware.APIResponse{data=[]model.GroupChannelBasicInfo} +// @Router /api/group/{group}/channels/batch_info [post] +func GetGroupChannelBatchInfo(c *gin.Context) { + ids, ok := getGroupChannelBatchInfoRequest(c) + if !ok { + return + } + + channels, err := model.GetGroupChannelsBasicInfoByIDs(groupParam(c), ids) + if err != nil { + middleware.ErrorResponse(c, http.StatusInternalServerError, err.Error()) + return + } + + middleware.SuccessResponse(c, channels) +} + +type AddGroupChannelRequest struct { + ModelMapping map[string]string `json:"model_mapping"` + Configs model.ChannelConfigs `json:"configs"` + GroupID string `json:"group_id"` + Name string `json:"name"` + Key string `json:"key"` + BaseURL string `json:"base_url"` + ProxyURL string `json:"proxy_url"` + Models []string `json:"models"` + Sets []string `json:"sets"` + Type model.ChannelType `json:"type"` + Priority int32 `json:"priority"` + Status int `json:"status"` + SkipTLSVerify bool `json:"skip_tls_verify"` + EnabledNoPermissionBan bool `json:"enabled_no_permission_ban"` + MaxErrorRate float64 `json:"max_error_rate"` +} + +func (r *AddGroupChannelRequest) toGroupChannel(group string) (*model.GroupChannel, error) { + a, ok := adaptors.GetAdaptor(r.Type) + if !ok { + return nil, fmt.Errorf("invalid channel type: %d", r.Type) + } + + if validator := adaptors.GetKeyValidator(a); validator != nil { + metadata := a.Metadata() + if err := validator.ValidateKey(r.Key); err != nil { + if metadata.KeyHelp == "" { + return nil, fmt.Errorf( + "%s [%s(%d)] invalid key: %w", + r.Name, + r.Type.String(), + r.Type, + err, + ) + } + + return nil, fmt.Errorf( + "%s [%s(%d)] invalid key: %w, %s", + r.Name, + r.Type.String(), + r.Type, + err, + metadata.KeyHelp, + ) + } + } + + return &model.GroupChannel{ + GroupID: group, + Type: r.Type, + Name: r.Name, + Key: r.Key, + BaseURL: r.BaseURL, + ProxyURL: r.ProxyURL, + Models: slices.Clone(r.Models), + ModelMapping: maps.Clone(r.ModelMapping), + Priority: r.Priority, + Status: r.Status, + Configs: r.Configs, + Sets: slices.Clone(r.Sets), + SkipTLSVerify: r.SkipTLSVerify, + EnabledNoPermissionBan: r.EnabledNoPermissionBan, + MaxErrorRate: r.MaxErrorRate, + }, nil +} + +// AddGroupChannel godoc +// +// @Summary Add a group channel +// @Description Adds a group channel to a specific group +// @Tags group-channel +// @Accept json +// @Produce json +// @Security ApiKeyAuth +// @Param group path string true "Group ID" +// @Param channel body AddGroupChannelRequest true "Group channel information" +// @Success 200 {object} middleware.APIResponse +// @Router /api/group/{group}/channel/ [post] +func AddGroupChannel(c *gin.Context) { + req := AddGroupChannelRequest{} + if err := c.ShouldBindJSON(&req); err != nil { + middleware.ErrorResponse(c, http.StatusBadRequest, err.Error()) + return + } + + channel, err := req.toGroupChannel(groupParam(c)) + if err != nil { + middleware.ErrorResponse(c, http.StatusBadRequest, err.Error()) + return + } + + if err := model.BatchInsertGroupChannels([]*model.GroupChannel{channel}); err != nil { + middleware.ErrorResponse(c, http.StatusInternalServerError, err.Error()) + return + } + + middleware.SuccessResponse(c, nil) +} + +// AddGlobalGroupChannel godoc +// +// @Summary Add a group channel +// @Description Adds a group channel from the global management view. The request body must include group_id. +// @Tags group_channel +// @Accept json +// @Produce json +// @Security ApiKeyAuth +// @Param channel body AddGroupChannelRequest true "Group channel information" +// @Success 200 {object} middleware.APIResponse +// @Router /api/group_channel/ [post] +func AddGlobalGroupChannel(c *gin.Context) { + req := AddGroupChannelRequest{} + if err := c.ShouldBindJSON(&req); err != nil { + middleware.ErrorResponse(c, http.StatusBadRequest, err.Error()) + return + } + + channel, err := req.toGroupChannel(req.GroupID) + if err != nil { + middleware.ErrorResponse(c, http.StatusBadRequest, err.Error()) + return + } + + if channel.GroupID == "" { + middleware.ErrorResponse(c, http.StatusBadRequest, "group id is required") + return + } + + if err := model.BatchInsertGroupChannels([]*model.GroupChannel{channel}); err != nil { + middleware.ErrorResponse(c, http.StatusInternalServerError, err.Error()) + return + } + + middleware.SuccessResponse(c, nil) +} + +// AddGroupChannels godoc +// +// @Summary Add multiple group channels +// @Description Adds group channels to a specific group +// @Tags group-channel +// @Accept json +// @Produce json +// @Security ApiKeyAuth +// @Param group path string true "Group ID" +// @Param channels body []AddGroupChannelRequest true "Group channel information" +// @Success 200 {object} middleware.APIResponse +// @Router /api/group/{group}/channels/ [post] +func AddGroupChannels(c *gin.Context) { + reqs := make([]*AddGroupChannelRequest, 0) + if err := c.ShouldBindJSON(&reqs); err != nil { + middleware.ErrorResponse(c, http.StatusBadRequest, err.Error()) + return + } + + channels := make([]*model.GroupChannel, 0, len(reqs)) + for _, req := range reqs { + channel, err := req.toGroupChannel(groupParam(c)) + if err != nil { + middleware.ErrorResponse(c, http.StatusBadRequest, err.Error()) + return + } + + channels = append(channels, channel) + } + + if err := model.BatchInsertGroupChannels(channels); err != nil { + middleware.ErrorResponse(c, http.StatusInternalServerError, err.Error()) + return + } + + middleware.SuccessResponse(c, nil) +} + +// AddGlobalGroupChannels godoc +// +// @Summary Add multiple group channels +// @Description Adds group channels from the global management view. Each request item must include group_id. +// @Tags group_channels +// @Accept json +// @Produce json +// @Security ApiKeyAuth +// @Param channels body []AddGroupChannelRequest true "Group channel information" +// @Success 200 {object} middleware.APIResponse +// @Router /api/group_channels/ [post] +func AddGlobalGroupChannels(c *gin.Context) { + reqs := make([]*AddGroupChannelRequest, 0) + if err := c.ShouldBindJSON(&reqs); err != nil { + middleware.ErrorResponse(c, http.StatusBadRequest, err.Error()) + return + } + + channels := make([]*model.GroupChannel, 0, len(reqs)) + for _, req := range reqs { + channel, err := req.toGroupChannel(req.GroupID) + if err != nil { + middleware.ErrorResponse(c, http.StatusBadRequest, err.Error()) + return + } + + if channel.GroupID == "" { + middleware.ErrorResponse(c, http.StatusBadRequest, "group id is required") + return + } + + channels = append(channels, channel) + } + + if err := model.BatchInsertGroupChannels(channels); err != nil { + middleware.ErrorResponse(c, http.StatusInternalServerError, err.Error()) + return + } + + middleware.SuccessResponse(c, nil) +} + +// GetGroupChannel godoc +// +// @Summary Get a group channel by ID +// @Description Returns detailed information about a group channel in a specific group +// @Tags group-channel +// @Produce json +// @Security ApiKeyAuth +// @Param group path string true "Group ID" +// @Param id path int true "Group channel ID" +// @Success 200 {object} middleware.APIResponse{data=model.GroupChannel} +// @Router /api/group/{group}/channel/{id} [get] +func GetGroupChannel(c *gin.Context) { + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + middleware.ErrorResponse(c, http.StatusBadRequest, err.Error()) + return + } + + channel, err := model.GetGroupChannelByID(groupParam(c), id) + if err != nil { + middleware.ErrorResponse(c, http.StatusInternalServerError, err.Error()) + return + } + + middleware.SuccessResponse(c, buildGroupChannelResponse(channel)) +} + +// GetGlobalGroupChannel godoc +// +// @Summary Get a group channel by ID +// @Description Returns detailed information about a group channel across groups +// @Tags group_channel +// @Produce json +// @Security ApiKeyAuth +// @Param id path int true "Group channel ID" +// @Success 200 {object} middleware.APIResponse{data=model.GroupChannel} +// @Router /api/group_channel/{id} [get] +func GetGlobalGroupChannel(c *gin.Context) { + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + middleware.ErrorResponse(c, http.StatusBadRequest, err.Error()) + return + } + + channel, err := model.GetGlobalGroupChannelByID(id) + if err != nil { + middleware.ErrorResponse(c, http.StatusInternalServerError, err.Error()) + return + } + + middleware.SuccessResponse(c, buildGroupChannelResponse(channel)) +} + +// UpdateGroupChannel godoc +// +// @Summary Update a group channel +// @Description Updates a group channel by ID in a specific group +// @Tags group-channel +// @Accept json +// @Produce json +// @Security ApiKeyAuth +// @Param group path string true "Group ID" +// @Param id path int true "Group channel ID" +// @Param channel body AddGroupChannelRequest true "Updated group channel information" +// @Success 200 {object} middleware.APIResponse{data=model.GroupChannel} +// @Router /api/group/{group}/channel/{id} [put] +func UpdateGroupChannel(c *gin.Context) { + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + middleware.ErrorResponse(c, http.StatusBadRequest, err.Error()) + return + } + + req := AddGroupChannelRequest{} + if err := c.ShouldBindJSON(&req); err != nil { + middleware.ErrorResponse(c, http.StatusBadRequest, err.Error()) + return + } + + ch, err := req.toGroupChannel(groupParam(c)) + if err != nil { + middleware.ErrorResponse(c, http.StatusBadRequest, err.Error()) + return + } + + ch.ID = id + if err := model.UpdateGroupChannel(ch); err != nil { + middleware.ErrorResponse(c, http.StatusInternalServerError, err.Error()) + return + } + + middleware.SuccessResponse(c, ch) +} + +// UpdateGlobalGroupChannel godoc +// +// @Summary Update a group channel +// @Description Updates a group channel by ID from the global management view +// @Tags group_channel +// @Accept json +// @Produce json +// @Security ApiKeyAuth +// @Param id path int true "Group channel ID" +// @Param channel body AddGroupChannelRequest true "Updated group channel information" +// @Success 200 {object} middleware.APIResponse{data=model.GroupChannel} +// @Router /api/group_channel/{id} [put] +func UpdateGlobalGroupChannel(c *gin.Context) { + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + middleware.ErrorResponse(c, http.StatusBadRequest, err.Error()) + return + } + + req := AddGroupChannelRequest{} + if err := c.ShouldBindJSON(&req); err != nil { + middleware.ErrorResponse(c, http.StatusBadRequest, err.Error()) + return + } + + ch, err := req.toGroupChannel(req.GroupID) + if err != nil { + middleware.ErrorResponse(c, http.StatusBadRequest, err.Error()) + return + } + + ch.ID = id + if err := model.UpdateGlobalGroupChannel(ch); err != nil { + middleware.ErrorResponse(c, http.StatusInternalServerError, err.Error()) + return + } + + middleware.SuccessResponse(c, ch) +} + +// DeleteGroupChannel godoc +// +// @Summary Delete a group channel +// @Description Deletes a group channel by ID in a specific group +// @Tags group-channel +// @Produce json +// @Security ApiKeyAuth +// @Param group path string true "Group ID" +// @Param id path int true "Group channel ID" +// @Success 200 {object} middleware.APIResponse +// @Router /api/group/{group}/channel/{id} [delete] +func DeleteGroupChannel(c *gin.Context) { + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + middleware.ErrorResponse(c, http.StatusBadRequest, err.Error()) + return + } + + if err := model.DeleteGroupChannelByID(groupParam(c), id); err != nil { + middleware.ErrorResponse(c, http.StatusInternalServerError, err.Error()) + return + } + + middleware.SuccessResponse(c, nil) +} + +// DeleteGlobalGroupChannel godoc +// +// @Summary Delete a group channel +// @Description Deletes a group channel by ID from the global management view +// @Tags group_channel +// @Produce json +// @Security ApiKeyAuth +// @Param id path int true "Group channel ID" +// @Success 200 {object} middleware.APIResponse +// @Router /api/group_channel/{id} [delete] +func DeleteGlobalGroupChannel(c *gin.Context) { + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + middleware.ErrorResponse(c, http.StatusBadRequest, err.Error()) + return + } + + if err := model.DeleteGlobalGroupChannelByID(id); err != nil { + middleware.ErrorResponse(c, http.StatusInternalServerError, err.Error()) + return + } + + middleware.SuccessResponse(c, nil) +} + +// DeleteGroupChannels godoc +// +// @Summary Delete multiple group channels +// @Description Deletes group channels by IDs in a specific group +// @Tags group-channel +// @Accept json +// @Produce json +// @Security ApiKeyAuth +// @Param group path string true "Group ID" +// @Param ids body []int true "Group channel IDs" +// @Success 200 {object} middleware.APIResponse +// @Router /api/group/{group}/channels/batch_delete [post] +func DeleteGroupChannels(c *gin.Context) { + ids := []int{} + if err := c.ShouldBindJSON(&ids); err != nil { + middleware.ErrorResponse(c, http.StatusBadRequest, err.Error()) + return + } + + if err := model.DeleteGroupChannelsByIDs(groupParam(c), ids); err != nil { + middleware.ErrorResponse(c, http.StatusInternalServerError, err.Error()) + return + } + + middleware.SuccessResponse(c, nil) +} + +// DeleteGlobalGroupChannels godoc +// +// @Summary Delete multiple group channels +// @Description Deletes group channels by IDs from the global management view +// @Tags group_channels +// @Accept json +// @Produce json +// @Security ApiKeyAuth +// @Param ids body []int true "Group channel IDs" +// @Success 200 {object} middleware.APIResponse +// @Router /api/group_channels/batch_delete [post] +func DeleteGlobalGroupChannels(c *gin.Context) { + ids := []int{} + if err := c.ShouldBindJSON(&ids); err != nil { + middleware.ErrorResponse(c, http.StatusBadRequest, err.Error()) + return + } + + if err := model.DeleteGlobalGroupChannelsByIDs(ids); err != nil { + middleware.ErrorResponse(c, http.StatusInternalServerError, err.Error()) + return + } + + middleware.SuccessResponse(c, nil) +} + +// UpdateGroupChannelStatus godoc +// +// @Summary Update group channel status +// @Description Updates the status of a group channel by ID in a specific group +// @Tags group-channel +// @Accept json +// @Produce json +// @Security ApiKeyAuth +// @Param group path string true "Group ID" +// @Param id path int true "Group channel ID" +// @Param status body UpdateChannelStatusRequest true "Status information" +// @Success 200 {object} middleware.APIResponse +// @Router /api/group/{group}/channel/{id}/status [post] +func UpdateGroupChannelStatus(c *gin.Context) { + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + middleware.ErrorResponse(c, http.StatusBadRequest, err.Error()) + return + } + + status := UpdateChannelStatusRequest{} + if err := c.ShouldBindJSON(&status); err != nil { + middleware.ErrorResponse(c, http.StatusBadRequest, err.Error()) + return + } + + if err := model.UpdateGroupChannelStatusByID(groupParam(c), id, status.Status); err != nil { + middleware.ErrorResponse(c, http.StatusInternalServerError, err.Error()) + return + } + + middleware.SuccessResponse(c, nil) +} + +// UpdateGlobalGroupChannelStatus godoc +// +// @Summary Update group channel status +// @Description Updates the status of a group channel by ID from the global management view +// @Tags group_channel +// @Accept json +// @Produce json +// @Security ApiKeyAuth +// @Param id path int true "Group channel ID" +// @Param status body UpdateChannelStatusRequest true "Status information" +// @Success 200 {object} middleware.APIResponse +// @Router /api/group_channel/{id}/status [post] +func UpdateGlobalGroupChannelStatus(c *gin.Context) { + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + middleware.ErrorResponse(c, http.StatusBadRequest, err.Error()) + return + } + + status := UpdateChannelStatusRequest{} + if err := c.ShouldBindJSON(&status); err != nil { + middleware.ErrorResponse(c, http.StatusBadRequest, err.Error()) + return + } + + if err := model.UpdateGlobalGroupChannelStatusByID(id, status.Status); err != nil { + middleware.ErrorResponse(c, http.StatusInternalServerError, err.Error()) + return + } + + middleware.SuccessResponse(c, nil) +} + +func groupChannelTestSaveFunc( + groupChannel *model.GroupChannel, +) func(*meta.Meta, bool, string, int) (*model.ChannelTest, error) { + return func(testMeta *meta.Meta, success bool, response string, code int) (*model.ChannelTest, error) { + groupTest, err := groupChannel.UpdateModelTest( + testMeta.RequestAt, + testMeta.OriginModel, + testMeta.ActualModel, + testMeta.Mode, + time.Since(testMeta.RequestAt).Seconds(), + success, + response, + code, + ) + if err != nil { + return nil, err + } + + return groupChannelTestToChannelTest(groupTest), nil + } +} + +func groupChannelTestToChannelTest(groupTest *model.GroupChannelTest) *model.ChannelTest { + if groupTest == nil { + return nil + } + + return &model.ChannelTest{ + TestAt: groupTest.TestAt, + Model: groupTest.Model, + ActualModel: groupTest.ActualModel, + Response: groupTest.Response, + ChannelName: groupTest.ChannelName, + ChannelType: groupTest.ChannelType, + ChannelID: groupTest.GroupChannelID, + Took: groupTest.Took, + Success: groupTest.Success, + Mode: groupTest.Mode, + Code: groupTest.Code, + } +} + +func channelTestToGroupChannelTest( + group string, + channel *model.GroupChannel, + test *model.ChannelTest, +) *model.GroupChannelTest { + if test == nil { + return nil + } + + return &model.GroupChannelTest{ + TestAt: test.TestAt, + Model: test.Model, + ActualModel: test.ActualModel, + Response: test.Response, + GroupID: group, + ChannelName: test.ChannelName, + ChannelType: test.ChannelType, + GroupChannelID: channel.ID, + Took: test.Took, + Success: test.Success, + Mode: test.Mode, + Code: test.Code, + } +} + +type TestGroupChannelRequest AddGroupChannelRequest + +func (r *TestGroupChannelRequest) toGroupChannel(group string) *model.GroupChannel { + req := (*AddGroupChannelRequest)(r) + + return &model.GroupChannel{ + GroupID: group, + Type: req.Type, + Name: req.Name, + Key: req.Key, + BaseURL: req.BaseURL, + ProxyURL: req.ProxyURL, + Models: slices.Clone(req.Models), + ModelMapping: maps.Clone(req.ModelMapping), + SkipTLSVerify: req.SkipTLSVerify, + Configs: req.Configs, + Sets: slices.Clone(req.Sets), + EnabledNoPermissionBan: req.EnabledNoPermissionBan, + MaxErrorRate: req.MaxErrorRate, + } +} + +type TestSingleGroupChannelRequest struct { + Type int `json:"type" binding:"required"` + Key string `json:"key" binding:"required"` + BaseURL string `json:"base_url"` + ProxyURL string `json:"proxy_url"` + GroupID string `json:"group_id"` + Name string `json:"name"` + Model string `json:"model" binding:"required"` + ModelMapping map[string]string `json:"model_mapping"` + SkipTLSVerify bool `json:"skip_tls_verify"` + Configs model.ChannelConfigs `json:"configs"` + Sets []string `json:"sets"` +} + +func (r *TestSingleGroupChannelRequest) toGroupChannel(group string) *model.GroupChannel { + return &model.GroupChannel{ + GroupID: group, + Type: model.ChannelType(r.Type), + Name: r.Name, + Key: r.Key, + BaseURL: r.BaseURL, + ProxyURL: r.ProxyURL, + Models: []string{r.Model}, + ModelMapping: maps.Clone(r.ModelMapping), + SkipTLSVerify: r.SkipTLSVerify, + Configs: r.Configs, + Sets: slices.Clone(r.Sets), + } +} + +func testSingleGroupChannelModel( + mc *model.ModelCaches, + groupChannel *model.GroupChannel, + modelName string, + saveToDB bool, +) (*model.GroupChannelTest, error) { + group, err := model.CacheGetGroup(groupChannel.GroupID) + if err != nil { + return nil, err + } + + modelConfig, err := groupChannelTestModelConfig(*group, modelName) + if err != nil { + return nil, err + } + + channel := groupChannel.ToChannel() + + test, err := testSingleModelWithOptions( + mc, + channel, + modelName, + testSingleModelOptions{ + AllowMissingModelConfig: true, + ModelConfig: &modelConfig, + SaveResult: groupChannelTestSaveFunc(groupChannel), + }, + saveToDB, + ) + if err != nil { + return nil, err + } + + return channelTestToGroupChannelTest(groupChannel.GroupID, groupChannel, test), nil +} + +func groupChannelTestModelConfig( + group model.GroupCache, + modelName string, +) (model.ModelConfig, error) { + modelConfig, ok := model.ResolveGroupScopeModelConfig(group.ID, modelName) + if !ok { + return model.ModelConfig{}, fmt.Errorf("%s model config not found", modelName) + } + + return middleware.GetGroupScopeAdjustedModelConfig( + group, + modelConfig, + ), nil +} + +func groupChannelTestableModels(groupID string, channel *model.GroupChannel) ([]string, error) { + if config.DisableModelConfig { + models := model.GroupChannelAccessModels(channel) + slices.Sort(models) + return slices.Compact(models), nil + } + + scopeConfigs, err := model.CacheGetGroupScopeModelConfigs(groupID) + if err != nil { + return nil, err + } + + models := make([]string, 0, len(scopeConfigs.Models)) + for _, modelName := range scopeConfigs.Models { + if _, ok := scopeConfigs.Configs[modelName]; !ok { + continue + } + + if !model.GroupChannelSupportsModel(channel, modelName) { + continue + } + + models = append(models, modelName) + } + + slices.Sort(models) + + return models, nil +} + +type GroupChannelTestResult struct { + Data *model.GroupChannelTest `json:"data,omitempty"` + Message string `json:"message,omitempty"` + Success bool `json:"success"` +} + +func processGroupChannelTestResult( + mc *model.ModelCaches, + channel *model.GroupChannel, + modelName string, + saveToDB bool, + returnSuccess, successResponseBody bool, +) *GroupChannelTestResult { + ct, err := testSingleGroupChannelModel(mc, channel, modelName, saveToDB) + + result := &GroupChannelTestResult{ + Success: err == nil, + } + if err != nil { + result.Message = fmt.Sprintf( + "failed to test group channel %s(%d) model %s: %s", + channel.Name, + channel.ID, + modelName, + err.Error(), + ) + + return result + } + + if !ct.Success { + result.Data = ct + return result + } + + if !returnSuccess { + return nil + } + + if !successResponseBody { + ct.Response = "" + } + + result.Data = ct + + return result +} + +// GetGroupChannelTests godoc +// +// @Summary Get group channel test results +// @Description Returns persisted test results for a group channel +// @Tags group-channel +// @Produce json +// @Security ApiKeyAuth +// @Param group path string true "Group ID" +// @Param id path int true "Group Channel ID" +// @Param success_body query bool false "Success body" +// @Success 200 {object} middleware.APIResponse{data=[]model.GroupChannelTest} +// @Router /api/group/{group}/channel/{id}/tests [get] +func GetGroupChannelTests(c *gin.Context) { + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + c.JSON(http.StatusOK, middleware.APIResponse{ + Success: false, + Message: err.Error(), + }) + + return + } + + if _, err := model.GetGroupChannelByID(groupParam(c), id); err != nil { + c.JSON(http.StatusOK, middleware.APIResponse{ + Success: false, + Message: "group channel not found", + }) + + return + } + + tests, err := model.GetGroupChannelTests(groupParam(c), id) + if err != nil { + c.JSON(http.StatusOK, middleware.APIResponse{ + Success: false, + Message: err.Error(), + }) + + return + } + + if c.Query("success_body") != "true" { + for _, test := range tests { + if test.Success { + test.Response = "" + } + } + } + + c.JSON(http.StatusOK, middleware.APIResponse{ + Success: true, + Data: tests, + }) +} + +// GetGlobalGroupChannelTests godoc +// +// @Summary Get group channel test results +// @Description Returns persisted test results for a group channel from the global management view +// @Tags group_channel +// @Produce json +// @Security ApiKeyAuth +// @Param id path int true "Group Channel ID" +// @Param success_body query bool false "Success body" +// @Success 200 {object} middleware.APIResponse{data=[]model.GroupChannelTest} +// @Router /api/group_channel/{id}/tests [get] +func GetGlobalGroupChannelTests(c *gin.Context) { + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + c.JSON(http.StatusOK, middleware.APIResponse{ + Success: false, + Message: err.Error(), + }) + + return + } + + channel, err := model.GetGlobalGroupChannelByID(id) + if err != nil { + c.JSON(http.StatusOK, middleware.APIResponse{ + Success: false, + Message: "group channel not found", + }) + + return + } + + tests, err := model.GetGroupChannelTests(channel.GroupID, id) + if err != nil { + c.JSON(http.StatusOK, middleware.APIResponse{ + Success: false, + Message: err.Error(), + }) + + return + } + + if c.Query("success_body") != "true" { + for _, test := range tests { + if test.Success { + test.Response = "" + } + } + } + + c.JSON(http.StatusOK, middleware.APIResponse{ + Success: true, + Data: tests, + }) +} + +// TestGroupChannel godoc +// +// @Summary Test group channel model +// @Description Tests a single model in the group channel +// @Tags group-channel +// @Produce json +// @Security ApiKeyAuth +// @Param group path string true "Group ID" +// @Param id path int true "Group Channel ID" +// @Param model path string true "Model name" +// @Param success_body query bool false "Success body" +// @Success 200 {object} middleware.APIResponse{data=model.GroupChannelTest} +// @Router /api/group/{group}/channel/{id}/test/{model} [get] +func TestGroupChannel(c *gin.Context) { + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + c.JSON(http.StatusOK, middleware.APIResponse{ + Success: false, + Message: err.Error(), + }) + + return + } + + modelName := strings.TrimPrefix(c.Param("model"), "/") + if modelName == "" { + c.JSON(http.StatusOK, middleware.APIResponse{ + Success: false, + Message: "model is required", + }) + + return + } + + channel, err := model.LoadGroupChannelByID(groupParam(c), id) + if err != nil { + c.JSON(http.StatusOK, middleware.APIResponse{ + Success: false, + Message: "group channel not found", + }) + + return + } + + if !model.GroupChannelSupportsModel(channel, modelName) { + c.JSON(http.StatusOK, middleware.APIResponse{ + Success: false, + Message: "model not supported by group channel", + }) + + return + } + + if _, ok := model.ResolveGroupScopeModelConfig(channel.GroupID, modelName); !ok { + c.JSON(http.StatusOK, middleware.APIResponse{ + Success: false, + Message: "model config not found", + }) + + return + } + + ct, err := testSingleGroupChannelModel(model.LoadModelCaches(), channel, modelName, true) + if err != nil { + log.Errorf( + "failed to test group channel %s(%d) model %s: %s", + channel.Name, + channel.ID, + modelName, + err.Error(), + ) + c.JSON(http.StatusOK, middleware.APIResponse{ + Success: false, + Message: fmt.Sprintf( + "failed to test group channel %s(%d) model %s: %s", + channel.Name, + channel.ID, + modelName, + err.Error(), + ), + }) + + return + } + + if c.Query("success_body") != "true" && ct.Success { + ct.Response = "" + } + + c.JSON(http.StatusOK, middleware.APIResponse{ + Success: true, + Data: ct, + }) +} + +// TestGlobalGroupChannel godoc +// +// @Summary Test group channel model +// @Description Tests a single model in a group channel from the global management view +// @Tags group_channel +// @Produce json +// @Security ApiKeyAuth +// @Param id path int true "Group Channel ID" +// @Param model path string true "Model name" +// @Param success_body query bool false "Success body" +// @Success 200 {object} middleware.APIResponse{data=model.GroupChannelTest} +// @Router /api/group_channel/{id}/test/{model} [get] +func TestGlobalGroupChannel(c *gin.Context) { + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + c.JSON(http.StatusOK, middleware.APIResponse{ + Success: false, + Message: err.Error(), + }) + + return + } + + modelName := strings.TrimPrefix(c.Param("model"), "/") + if modelName == "" { + c.JSON(http.StatusOK, middleware.APIResponse{ + Success: false, + Message: "model is required", + }) + + return + } + + channel, err := model.LoadGlobalGroupChannelByID(id) + if err != nil { + c.JSON(http.StatusOK, middleware.APIResponse{ + Success: false, + Message: "group channel not found", + }) + + return + } + + if !model.GroupChannelSupportsModel(channel, modelName) { + c.JSON(http.StatusOK, middleware.APIResponse{ + Success: false, + Message: "model not supported by group channel", + }) + + return + } + + if _, ok := model.ResolveGroupScopeModelConfig(channel.GroupID, modelName); !ok { + c.JSON(http.StatusOK, middleware.APIResponse{ + Success: false, + Message: "model config not found", + }) + + return + } + + ct, err := testSingleGroupChannelModel(model.LoadModelCaches(), channel, modelName, true) + if err != nil { + log.Errorf( + "failed to test group channel %s(%d) model %s: %s", + channel.Name, + channel.ID, + modelName, + err.Error(), + ) + c.JSON(http.StatusOK, middleware.APIResponse{ + Success: false, + Message: fmt.Sprintf( + "failed to test group channel %s(%d) model %s: %s", + channel.Name, + channel.ID, + modelName, + err.Error(), + ), + }) + + return + } + + if c.Query("success_body") != "true" && ct.Success { + ct.Response = "" + } + + c.JSON(http.StatusOK, middleware.APIResponse{ + Success: true, + Data: ct, + }) +} + +// TestGroupChannelModels godoc +// +// @Summary Test group channel models +// @Description Tests all models in the group channel +// @Tags group-channel +// @Produce json +// @Security ApiKeyAuth +// @Param group path string true "Group ID" +// @Param id path int true "Group Channel ID" +// @Param return_success query bool false "Return success" +// @Param success_body query bool false "Success body" +// @Param stream query bool false "Stream" +// @Success 200 {object} middleware.APIResponse{data=[]GroupChannelTestResult} +// @Router /api/group/{group}/channel/{id}/test [get] +func TestGroupChannelModels(c *gin.Context) { + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + c.JSON(http.StatusOK, middleware.APIResponse{ + Success: false, + Message: err.Error(), + }) + + return + } + + channel, err := model.LoadGroupChannelByID(groupParam(c), id) + if err != nil { + c.JSON(http.StatusOK, middleware.APIResponse{ + Success: false, + Message: "group channel not found", + }) + + return + } + + returnSuccess := c.Query("return_success") == "true" + successResponseBody := c.Query("success_body") == "true" + isStream := c.Query("stream") == "true" + + results := make([]*GroupChannelTestResult, 0) + resultsMutex := sync.Mutex{} + hasError := atomic.Bool{} + + var wg sync.WaitGroup + + semaphore := make(chan struct{}, 5) + + models, err := groupChannelTestableModels(channel.GroupID, channel) + if err != nil { + c.JSON(http.StatusOK, middleware.APIResponse{ + Success: false, + Message: err.Error(), + }) + + return + } + + rand.Shuffle(len(models), func(i, j int) { + models[i], models[j] = models[j], models[i] + }) + + mc := model.LoadModelCaches() + + for _, modelName := range models { + wg.Add(1) + + semaphore <- struct{}{} + + go func(modelName string) { + defer wg.Done() + defer func() { <-semaphore }() + + result := processGroupChannelTestResult( + mc, + channel, + modelName, + true, + returnSuccess, + successResponseBody, + ) + if result == nil { + return + } + + if !result.Success || (result.Data != nil && !result.Data.Success) { + hasError.Store(true) + } + + resultsMutex.Lock() + defer resultsMutex.Unlock() + + if isStream { + if err := render.OpenaiObjectData(c, result); err != nil { + log.Errorf("failed to render result: %s", err.Error()) + } + return + } + + results = append(results, result) + }(modelName) + } + + wg.Wait() + + if !hasError.Load() { + if err := model.ClearGroupChannelLastTestErrorAt(channel.GroupID, channel.ID); err != nil { + log.Errorf( + "failed to clear last test error at for group channel %s(%d): %s", + channel.Name, + channel.ID, + err.Error(), + ) + } + } + + if !isStream { + c.JSON(http.StatusOK, middleware.APIResponse{ + Success: true, + Data: results, + }) + } +} + +// TestGlobalGroupChannelModels godoc +// +// @Summary Test group channel models +// @Description Tests all models in a group channel from the global management view +// @Tags group_channel +// @Produce json +// @Security ApiKeyAuth +// @Param id path int true "Group Channel ID" +// @Param return_success query bool false "Return success" +// @Param success_body query bool false "Success body" +// @Param stream query bool false "Stream" +// @Success 200 {object} middleware.APIResponse{data=[]GroupChannelTestResult} +// @Router /api/group_channel/{id}/test [get] +func TestGlobalGroupChannelModels(c *gin.Context) { + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + c.JSON(http.StatusOK, middleware.APIResponse{ + Success: false, + Message: err.Error(), + }) + + return + } + + channel, err := model.LoadGlobalGroupChannelByID(id) + if err != nil { + c.JSON(http.StatusOK, middleware.APIResponse{ + Success: false, + Message: "group channel not found", + }) + + return + } + + returnSuccess := c.Query("return_success") == "true" + successResponseBody := c.Query("success_body") == "true" + isStream := c.Query("stream") == "true" + + results := make([]*GroupChannelTestResult, 0) + resultsMutex := sync.Mutex{} + hasError := atomic.Bool{} + + var wg sync.WaitGroup + + semaphore := make(chan struct{}, 5) + + models, err := groupChannelTestableModels(channel.GroupID, channel) + if err != nil { + c.JSON(http.StatusOK, middleware.APIResponse{ + Success: false, + Message: err.Error(), + }) + + return + } + + rand.Shuffle(len(models), func(i, j int) { + models[i], models[j] = models[j], models[i] + }) + + mc := model.LoadModelCaches() + + for _, modelName := range models { + wg.Add(1) + + semaphore <- struct{}{} + + go func(modelName string) { + defer wg.Done() + defer func() { <-semaphore }() + + result := processGroupChannelTestResult( + mc, + channel, + modelName, + true, + returnSuccess, + successResponseBody, + ) + if result == nil { + return + } + + if !result.Success || (result.Data != nil && !result.Data.Success) { + hasError.Store(true) + } + + resultsMutex.Lock() + defer resultsMutex.Unlock() + + if isStream { + if err := render.OpenaiObjectData(c, result); err != nil { + log.Errorf("failed to render result: %s", err.Error()) + } + return + } + + results = append(results, result) + }(modelName) + } + + wg.Wait() + + if !hasError.Load() { + if err := model.ClearGroupChannelLastTestErrorAt(channel.GroupID, channel.ID); err != nil { + log.Errorf( + "failed to clear last test error at for group channel %s(%d): %s", + channel.Name, + channel.ID, + err.Error(), + ) + } + } + + if !isStream { + c.JSON(http.StatusOK, middleware.APIResponse{ + Success: true, + Data: results, + }) + } +} + +// TestGroupChannelPreview godoc +// +// @Summary Test group channel preview +// @Description Test a single model in a group channel without saving to database +// @Tags group-channel +// @Accept json +// @Produce json +// @Security ApiKeyAuth +// @Param group path string true "Group ID" +// @Param success_body query bool false "Success body" +// @Param request body TestSingleGroupChannelRequest true "Group channel test request" +// @Success 200 {object} middleware.APIResponse{data=model.GroupChannelTest} +// @Router /api/group/{group}/channel/test-preview [post] +func TestGroupChannelPreview(c *gin.Context) { + var req TestSingleGroupChannelRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusOK, middleware.APIResponse{ + Success: false, + Message: err.Error(), + }) + + return + } + + channel := req.toGroupChannel(groupParam(c)) + + ct, err := testSingleGroupChannelModel(model.LoadModelCaches(), channel, req.Model, false) + if err != nil { + log.Errorf("failed to test group channel preview: %s", err.Error()) + c.JSON(http.StatusOK, middleware.APIResponse{ + Success: false, + Message: err.Error(), + }) + + return + } + + if c.Query("success_body") != "true" && ct.Success { + ct.Response = "" + } + + c.JSON(http.StatusOK, middleware.APIResponse{ + Success: true, + Data: ct, + }) +} + +// TestGlobalGroupChannelPreview godoc +// +// @Summary Test group channel preview +// @Description Test a single model in a group channel without saving to database from the global management view. The request body must include group_id. +// @Tags group_channel +// @Accept json +// @Produce json +// @Security ApiKeyAuth +// @Param success_body query bool false "Success body" +// @Param request body TestSingleGroupChannelRequest true "Group channel test request" +// @Success 200 {object} middleware.APIResponse{data=model.GroupChannelTest} +// @Router /api/group_channel/test-preview [post] +func TestGlobalGroupChannelPreview(c *gin.Context) { + var req TestSingleGroupChannelRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusOK, middleware.APIResponse{ + Success: false, + Message: err.Error(), + }) + + return + } + + channel := req.toGroupChannel(req.GroupID) + + ct, err := testSingleGroupChannelModel(model.LoadModelCaches(), channel, req.Model, false) + if err != nil { + log.Errorf("failed to test group channel preview: %s", err.Error()) + c.JSON(http.StatusOK, middleware.APIResponse{ + Success: false, + Message: err.Error(), + }) + + return + } + + if c.Query("success_body") != "true" && ct.Success { + ct.Response = "" + } + + c.JSON(http.StatusOK, middleware.APIResponse{ + Success: true, + Data: ct, + }) +} + +// TestGroupChannelPreviewAll godoc +// +// @Summary Test group channel preview models +// @Description Test all models in a group channel without saving to database +// @Tags group-channel +// @Accept json +// @Produce json +// @Security ApiKeyAuth +// @Param group path string true "Group ID" +// @Param return_success query bool false "Return success" +// @Param success_body query bool false "Success body" +// @Param stream query bool false "Stream mode (SSE)" +// @Param request body TestGroupChannelRequest true "Group channel test request" +// @Success 200 {object} middleware.APIResponse{data=[]GroupChannelTestResult} +// @Router /api/group/{group}/channel/test-preview-all [post] +func TestGroupChannelPreviewAll(c *gin.Context) { + var req TestGroupChannelRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusOK, middleware.APIResponse{ + Success: false, + Message: err.Error(), + }) + + return + } + + channel := req.toGroupChannel(groupParam(c)) + + models, err := groupChannelTestableModels(channel.GroupID, channel) + if err != nil { + c.JSON(http.StatusOK, middleware.APIResponse{ + Success: false, + Message: err.Error(), + }) + + return + } + + if len(models) == 0 { + c.JSON(http.StatusOK, middleware.APIResponse{ + Success: false, + Message: "no models to test", + }) + + return + } + + returnSuccess := c.Query("return_success") == "true" + successResponseBody := c.Query("success_body") == "true" + isStream := c.Query("stream") == "true" + + results := make([]*GroupChannelTestResult, 0) + resultsMutex := sync.Mutex{} + + var wg sync.WaitGroup + + semaphore := make(chan struct{}, 5) + + rand.Shuffle(len(models), func(i, j int) { + models[i], models[j] = models[j], models[i] + }) + + mc := model.LoadModelCaches() + + for _, modelName := range models { + wg.Add(1) + + semaphore <- struct{}{} + + go func(modelName string) { + defer wg.Done() + defer func() { <-semaphore }() + + result := processGroupChannelTestResult( + mc, + channel, + modelName, + false, + returnSuccess, + successResponseBody, + ) + if result == nil { + return + } + + resultsMutex.Lock() + defer resultsMutex.Unlock() + + if isStream { + if err := render.OpenaiObjectData(c, result); err != nil { + log.Errorf("failed to render result: %s", err.Error()) + } + return + } + + results = append(results, result) + }(modelName) + } + + wg.Wait() + + if !isStream { + c.JSON(http.StatusOK, middleware.APIResponse{ + Success: true, + Data: results, + }) + } +} + +// TestGlobalGroupChannelPreviewAll godoc +// +// @Summary Test group channel preview models +// @Description Test all models in a group channel without saving to database from the global management view. The request body must include group_id. +// @Tags group_channel +// @Accept json +// @Produce json +// @Security ApiKeyAuth +// @Param return_success query bool false "Return success" +// @Param success_body query bool false "Success body" +// @Param stream query bool false "Stream mode (SSE)" +// @Param request body TestGroupChannelRequest true "Group channel test request" +// @Success 200 {object} middleware.APIResponse{data=[]GroupChannelTestResult} +// @Router /api/group_channel/test-preview-all [post] +func TestGlobalGroupChannelPreviewAll(c *gin.Context) { + var req TestGroupChannelRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusOK, middleware.APIResponse{ + Success: false, + Message: err.Error(), + }) + + return + } + + channel := req.toGroupChannel(req.GroupID) + + models, err := groupChannelTestableModels(channel.GroupID, channel) + if err != nil { + c.JSON(http.StatusOK, middleware.APIResponse{ + Success: false, + Message: err.Error(), + }) + + return + } + + if len(models) == 0 { + c.JSON(http.StatusOK, middleware.APIResponse{ + Success: false, + Message: "no models to test", + }) + + return + } + + returnSuccess := c.Query("return_success") == "true" + successResponseBody := c.Query("success_body") == "true" + isStream := c.Query("stream") == "true" + + results := make([]*GroupChannelTestResult, 0) + resultsMutex := sync.Mutex{} + + var wg sync.WaitGroup + + semaphore := make(chan struct{}, 5) + + rand.Shuffle(len(models), func(i, j int) { + models[i], models[j] = models[j], models[i] + }) + + mc := model.LoadModelCaches() + + for _, modelName := range models { + wg.Add(1) + + semaphore <- struct{}{} + + go func(modelName string) { + defer wg.Done() + defer func() { <-semaphore }() + + result := processGroupChannelTestResult( + mc, + channel, + modelName, + false, + returnSuccess, + successResponseBody, + ) + if result == nil { + return + } + + resultsMutex.Lock() + defer resultsMutex.Unlock() + + if isStream { + if err := render.OpenaiObjectData(c, result); err != nil { + log.Errorf("failed to render result: %s", err.Error()) + } + return + } + + results = append(results, result) + }(modelName) + } + + wg.Wait() + + if !isStream { + c.JSON(http.StatusOK, middleware.APIResponse{ + Success: true, + Data: results, + }) + } +} diff --git a/core/controller/group_channel_models_test.go b/core/controller/group_channel_models_test.go new file mode 100644 index 00000000..9aafca79 --- /dev/null +++ b/core/controller/group_channel_models_test.go @@ -0,0 +1,190 @@ +//nolint:testpackage +package controller + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "path/filepath" + "testing" + + "github.com/gin-gonic/gin" + "github.com/labring/aiproxy/core/common" + "github.com/labring/aiproxy/core/middleware" + "github.com/labring/aiproxy/core/model" + "github.com/stretchr/testify/require" +) + +func TestLoadGroupChannelModelsBySetUsesGroupScopedChannels(t *testing.T) { + oldDB := model.DB + oldRedisEnabled := common.RedisEnabled + + db, err := model.OpenSQLite(filepath.Join(t.TempDir(), "group_channel_models.db")) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate( + &model.Group{}, + &model.GroupModelConfig{}, + &model.GroupChannel{}, + &model.GroupScopeModelConfig{}, + )) + + model.DB = db + common.RedisEnabled = false + t.Cleanup(func() { + model.DB = oldDB + common.RedisEnabled = oldRedisEnabled + + require.NoError(t, model.CacheDeleteGroup("group-1")) + require.NoError(t, model.CacheDeleteGroupChannels("group-1")) + require.NoError(t, model.CacheDeleteGroupScopeModelConfig("group-1")) + + sqlDB, err := db.DB() + require.NoError(t, err) + require.NoError(t, sqlDB.Close()) + }) + + require.NoError(t, db.Create(&model.Group{ + ID: "group-1", + AvailableSets: []string{"global-only"}, + }).Error) + require.NoError(t, db.Create(&[]model.GroupChannel{ + { + ID: 7, + GroupID: "group-1", + Name: "default-channel", + Type: model.ChannelTypeOpenAI, + Status: model.ChannelStatusEnabled, + Models: []string{"gpt-5"}, + Sets: []string{model.ChannelDefaultSet}, + }, + { + ID: 8, + GroupID: "group-1", + Name: "beta-channel", + Type: model.ChannelTypeOpenAI, + Status: model.ChannelStatusEnabled, + Models: []string{"gpt-5"}, + Sets: []string{"beta"}, + }, + { + ID: 9, + GroupID: "group-1", + Name: "missing-config-channel", + Type: model.ChannelTypeOpenAI, + Status: model.ChannelStatusEnabled, + Models: []string{"missing-config"}, + Sets: []string{"beta"}, + }, + { + ID: 10, + GroupID: "group-2", + Name: "other-group-channel", + Type: model.ChannelTypeOpenAI, + Status: model.ChannelStatusEnabled, + Models: []string{"gpt-5"}, + Sets: []string{"beta"}, + }, + }).Error) + require.NoError(t, model.SaveGroupScopeModelConfig(model.GroupScopeModelConfig{ + GroupID: "group-1", + ModelConfig: model.ModelConfig{ + Model: "gpt-5", + }, + })) + + configsBySet, channelsByModelSet, err := loadGroupChannelModelsBySet("group-1") + require.NoError(t, err) + + require.Len(t, configsBySet[model.ChannelDefaultSet], 1) + require.Equal(t, "gpt-5", configsBySet[model.ChannelDefaultSet][0].Model) + require.Len(t, configsBySet["beta"], 1) + require.Equal(t, "gpt-5", configsBySet["beta"][0].Model) + + require.Len(t, channelsByModelSet["gpt-5"][model.ChannelDefaultSet], 1) + require.Equal(t, 7, channelsByModelSet["gpt-5"][model.ChannelDefaultSet][0].ID) + require.Len(t, channelsByModelSet["gpt-5"]["beta"], 1) + require.Equal(t, 8, channelsByModelSet["gpt-5"]["beta"][0].ID) + require.NotContains(t, channelsByModelSet, "missing-config") +} + +func TestGetGroupChannelDashboardModelsUsesEnabledGroupChannels(t *testing.T) { + gin.SetMode(gin.TestMode) + + oldDB := model.DB + oldRedisEnabled := common.RedisEnabled + + db, err := model.OpenSQLite(filepath.Join(t.TempDir(), "group_channel_dashboard_models.db")) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate( + &model.Group{}, + &model.GroupModelConfig{}, + &model.GroupChannel{}, + &model.GroupScopeModelConfig{}, + )) + + model.DB = db + common.RedisEnabled = false + t.Cleanup(func() { + model.DB = oldDB + common.RedisEnabled = oldRedisEnabled + + require.NoError(t, model.CacheDeleteGroup("group-dashboard-models")) + require.NoError(t, model.CacheDeleteGroupChannels("group-dashboard-models")) + require.NoError(t, model.CacheDeleteGroupScopeModelConfig("group-dashboard-models")) + + sqlDB, err := db.DB() + require.NoError(t, err) + require.NoError(t, sqlDB.Close()) + }) + + groupID := "group-dashboard-models" + require.NoError(t, db.Create(&model.Group{ID: groupID}).Error) + require.NoError(t, db.Create(&model.GroupChannel{ + ID: 11, + GroupID: groupID, + Name: "enabled-channel", + Type: model.ChannelTypeOpenAI, + Status: model.ChannelStatusEnabled, + Models: []string{"enabled-model"}, + Sets: []string{model.ChannelDefaultSet}, + }).Error) + require.NoError(t, model.SaveGroupScopeModelConfigs(groupID, []model.GroupScopeModelConfig{ + { + GroupID: groupID, + ModelConfig: model.ModelConfig{ + Model: "enabled-model", + }, + }, + { + GroupID: groupID, + ModelConfig: model.ModelConfig{ + Model: "scope-only-model", + }, + }, + })) + + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Params = gin.Params{{Key: "group", Value: groupID}} + c.Request = httptest.NewRequestWithContext( + t.Context(), + http.MethodGet, + "/api/group/"+groupID+"/channel-dashboard/models", + nil, + ) + + GetGroupChannelDashboardModels(c) + + require.Equal(t, http.StatusOK, recorder.Code) + + var response middleware.APIResponse + require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &response)) + + data, ok := response.Data.([]any) + require.True(t, ok) + require.Len(t, data, 1) + + item, ok := data[0].(map[string]any) + require.True(t, ok) + require.Equal(t, "enabled-model", item["model"]) +} diff --git a/core/controller/import.go b/core/controller/import.go index e63a96e0..68d6f816 100644 --- a/core/controller/import.go +++ b/core/controller/import.go @@ -129,7 +129,8 @@ var OneAPIChannelType2AIProxyMap = map[int]model.ChannelType{ } type ImportChannelFromOneAPIRequest struct { - DSN string `json:"dsn"` + DSN string `json:"dsn"` + GroupID string `json:"group_id"` } func AddOneAPIChannel(ch OneAPIChannel) error { @@ -162,6 +163,42 @@ func AddOneAPIChannel(ch OneAPIChannel) error { return model.BatchInsertChannels([]*model.Channel{channel}) } +func oneAPIChannelToGroupChannelRequest(ch OneAPIChannel) AddGroupChannelRequest { + add := AddGroupChannelRequest{ + Type: model.ChannelType(ch.Type), + Name: ch.Name, + Key: ch.Key, + BaseURL: ch.BaseURL, + ProxyURL: ch.ProxyURL, + Models: strings.Split(ch.Models, ","), + ModelMapping: ch.ModelMapping, + Priority: ch.Priority, + Status: ch.Status, + } + if t, ok := OneAPIChannelType2AIProxyMap[ch.Type]; ok { + add.Type = t + } else { + add.Type = 1 + } + + if add.Type == 1 && add.BaseURL != "" { + add.BaseURL += "/v1" + } + + return add +} + +func AddOneAPIGroupChannel(group string, ch OneAPIChannel) error { + add := oneAPIChannelToGroupChannelRequest(ch) + + channel, err := add.toGroupChannel(group) + if err != nil { + return err + } + + return model.BatchInsertGroupChannels([]*model.GroupChannel{channel}) +} + // ImportChannelFromOneAPI godoc // // @Summary Import channel from OneAPI @@ -179,9 +216,29 @@ func ImportChannelFromOneAPI(c *gin.Context) { return } + allChannels, ok := loadOneAPIChannels(c, req) + if !ok { + return + } + + errs := make([]error, 0) + for _, ch := range allChannels { + err := AddOneAPIChannel(*ch) + if err != nil { + errs = append(errs, err) + } + } + + middleware.SuccessResponse(c, errs) +} + +func loadOneAPIChannels( + c *gin.Context, + req ImportChannelFromOneAPIRequest, +) ([]*OneAPIChannel, bool) { if req.DSN == "" { middleware.ErrorResponse(c, http.StatusBadRequest, "sql dsn is required") - return + return nil, false } var ( @@ -201,36 +258,92 @@ func ImportChannelFromOneAPI(c *gin.Context) { "invalid dsn, only mysql and postgres are supported", ) - return + return nil, false } if err != nil { middleware.ErrorResponse(c, http.StatusBadRequest, err.Error()) - return + return nil, false } sqlDB, err := db.DB() if err != nil { middleware.ErrorResponse(c, http.StatusBadRequest, err.Error()) - return + return nil, false } defer sqlDB.Close() allChannels := make([]*OneAPIChannel, 0) - - err = db.Model(&OneAPIChannel{}).Find(&allChannels).Error - if err != nil { + if err := db.Model(&OneAPIChannel{}).Find(&allChannels).Error; err != nil { middleware.ErrorResponse(c, http.StatusBadRequest, err.Error()) + return nil, false + } + + return allChannels, true +} + +func importGroupChannelsFromOneAPI( + c *gin.Context, + group string, + req ImportChannelFromOneAPIRequest, +) { + if group == "" { + middleware.ErrorResponse(c, http.StatusBadRequest, "group id is required") + return + } + + allChannels, ok := loadOneAPIChannels(c, req) + if !ok { return } errs := make([]error, 0) for _, ch := range allChannels { - err := AddOneAPIChannel(*ch) - if err != nil { + if err := AddOneAPIGroupChannel(group, *ch); err != nil { errs = append(errs, err) } } middleware.SuccessResponse(c, errs) } + +// ImportGlobalGroupChannelFromOneAPI godoc +// +// @Summary Import group channel from OneAPI +// @Description Imports group channels from OneAPI from the global management view. The request body must include group_id. +// @Tags group_channels +// @Produce json +// @Security ApiKeyAuth +// @Param request body ImportChannelFromOneAPIRequest true "Import group channel from OneAPI request" +// @Success 200 {object} middleware.APIResponse{data=[]error} +// @Router /api/group_channels/import/oneapi [post] +func ImportGlobalGroupChannelFromOneAPI(c *gin.Context) { + var req ImportChannelFromOneAPIRequest + if err := c.ShouldBindJSON(&req); err != nil { + middleware.ErrorResponse(c, http.StatusBadRequest, err.Error()) + return + } + + importGroupChannelsFromOneAPI(c, req.GroupID, req) +} + +// ImportGroupChannelFromOneAPI godoc +// +// @Summary Import group channel from OneAPI +// @Description Imports group channels from OneAPI into a group +// @Tags group-channel +// @Produce json +// @Security ApiKeyAuth +// @Param group path string true "Group ID" +// @Param request body ImportChannelFromOneAPIRequest true "Import group channel from OneAPI request" +// @Success 200 {object} middleware.APIResponse{data=[]error} +// @Router /api/group/{group}/channels/import/oneapi [post] +func ImportGroupChannelFromOneAPI(c *gin.Context) { + var req ImportChannelFromOneAPIRequest + if err := c.ShouldBindJSON(&req); err != nil { + middleware.ErrorResponse(c, http.StatusBadRequest, err.Error()) + return + } + + importGroupChannelsFromOneAPI(c, c.Param("group"), req) +} diff --git a/core/controller/log.go b/core/controller/log.go index 02c566b0..28a9e255 100644 --- a/core/controller/log.go +++ b/core/controller/log.go @@ -142,6 +142,7 @@ func GetGroupLogs(c *gin.Context) { params.upstreamID, params.tokenID, params.tokenName, + params.channelID, params.order, model.CodeType(params.codeType), params.code, @@ -269,6 +270,257 @@ func SearchGroupLogs(c *gin.Context) { params.modelName, startTime, endTime, + params.channelID, + params.order, + model.CodeType(params.codeType), + params.code, + params.includeDetail, + params.ip, + params.user, + page, + perPage, + ) + if err != nil { + middleware.ErrorResponse(c, http.StatusInternalServerError, err.Error()) + return + } + + middleware.SuccessResponse(c, result) +} + +// GetGroupChannelLogs godoc +// +// @Summary Get group channel logs +// @Description Get group-channel logs for a specific group +// @Tags log +// @Produce json +// @Security ApiKeyAuth +// @Param group path string true "Group name" +// @Param page query int false "Page number" +// @Param per_page query int false "Items per page" +// @Param start_timestamp query int false "Start timestamp (milliseconds)" +// @Param end_timestamp query int false "End timestamp (milliseconds)" +// @Param token_name query string false "Token name" +// @Param model_name query string false "Model name" +// @Param channel query int false "Group channel ID" +// @Param token_id query int false "Token ID" +// @Param order query string false "Order" +// @Param request_id query string false "Request ID" +// @Param upstream_id query string false "Upstream ID" +// @Param code_type query string false "Status code type" +// @Param code query int false "Status code" +// @Param include_detail query bool false "Include request and response detail" +// @Param ip query string false "IP" +// @Param user query string false "User" +// @Success 200 {object} middleware.APIResponse{data=model.GetGroupChannelLogsResult} +// @Router /api/log/{group}/group_channel [get] +func GetGroupChannelLogs(c *gin.Context) { + group := c.Param("group") + if group == "" { + middleware.ErrorResponse(c, http.StatusBadRequest, "invalid group parameter") + return + } + + page, perPage := utils.ParsePageParams(c) + startTime, endTime := utils.ParseTimeRange(c, 0) + params := parseCommonParams(c) + + result, err := model.GetGroupChannelLogs( + group, + startTime, + endTime, + params.modelName, + params.requestID, + params.upstreamID, + params.tokenID, + params.tokenName, + params.channelID, + params.order, + model.CodeType(params.codeType), + params.code, + params.includeDetail, + params.ip, + params.user, + page, + perPage, + ) + if err != nil { + middleware.ErrorResponse(c, http.StatusInternalServerError, err.Error()) + return + } + + middleware.SuccessResponse(c, result) +} + +// SearchGroupChannelLogs godoc +// +// @Summary Search group channel logs +// @Description Search group-channel logs for a specific group +// @Tags log +// @Produce json +// @Security ApiKeyAuth +// @Param group path string true "Group name" +// @Param keyword query string false "Keyword" +// @Param page query int false "Page number" +// @Param per_page query int false "Items per page" +// @Param start_timestamp query int false "Start timestamp (milliseconds)" +// @Param end_timestamp query int false "End timestamp (milliseconds)" +// @Param token_name query string false "Filter by token name" +// @Param model_name query string false "Filter by model name" +// @Param token_id query int false "Filter by token id" +// @Param channel query int false "Filter by group channel" +// @Param order query string false "Order" +// @Param request_id query string false "Request ID" +// @Param upstream_id query string false "Upstream ID" +// @Param code_type query string false "Status code type" +// @Param code query int false "Status code" +// @Param include_detail query bool false "Include request and response detail" +// @Param ip query string false "IP" +// @Param user query string false "User" +// @Success 200 {object} middleware.APIResponse{data=model.GetGroupChannelLogsResult} +// @Router /api/log/{group}/group_channel/search [get] +func SearchGroupChannelLogs(c *gin.Context) { + group := c.Param("group") + if group == "" { + middleware.ErrorResponse(c, http.StatusBadRequest, "invalid group parameter") + return + } + + page, perPage := utils.ParsePageParams(c) + startTime, endTime := utils.ParseTimeRange(c, 0) + params := parseCommonParams(c) + keyword := c.Query("keyword") + + result, err := model.SearchGroupChannelLogs( + group, + keyword, + params.requestID, + params.upstreamID, + params.tokenID, + params.tokenName, + params.modelName, + startTime, + endTime, + params.channelID, + params.order, + model.CodeType(params.codeType), + params.code, + params.includeDetail, + params.ip, + params.user, + page, + perPage, + ) + if err != nil { + middleware.ErrorResponse(c, http.StatusInternalServerError, err.Error()) + return + } + + middleware.SuccessResponse(c, result) +} + +// GetGlobalGroupChannelLogs godoc +// +// @Summary Get global group channel logs +// @Description Get group-channel logs across groups with optional filters +// @Tags logs +// @Produce json +// @Security ApiKeyAuth +// @Param group query string false "Filter by group" +// @Param page query int false "Page number" +// @Param per_page query int false "Items per page" +// @Param start_timestamp query int false "Start timestamp (milliseconds)" +// @Param end_timestamp query int false "End timestamp (milliseconds)" +// @Param token_name query string false "Token name" +// @Param model_name query string false "Model name" +// @Param channel query int false "Group channel ID" +// @Param token_id query int false "Token ID" +// @Param order query string false "Order" +// @Param request_id query string false "Request ID" +// @Param upstream_id query string false "Upstream ID" +// @Param code_type query string false "Status code type" +// @Param code query int false "Status code" +// @Param include_detail query bool false "Include request and response detail" +// @Param ip query string false "IP" +// @Param user query string false "User" +// @Success 200 {object} middleware.APIResponse{data=model.GetGroupChannelLogsResult} +// @Router /api/logs/group_channel [get] +func GetGlobalGroupChannelLogs(c *gin.Context) { + page, perPage := utils.ParsePageParams(c) + startTime, endTime := utils.ParseTimeRange(c, 0) + params := parseCommonParams(c) + + result, err := model.GetGlobalGroupChannelLogs( + params.group, + startTime, + endTime, + params.modelName, + params.requestID, + params.upstreamID, + params.tokenID, + params.tokenName, + params.channelID, + params.order, + model.CodeType(params.codeType), + params.code, + params.includeDetail, + params.ip, + params.user, + page, + perPage, + ) + if err != nil { + middleware.ErrorResponse(c, http.StatusInternalServerError, err.Error()) + return + } + + middleware.SuccessResponse(c, result) +} + +// SearchGlobalGroupChannelLogs godoc +// +// @Summary Search global group channel logs +// @Description Search group-channel logs across groups with optional filters +// @Tags logs +// @Produce json +// @Security ApiKeyAuth +// @Param group query string false "Filter by group" +// @Param keyword query string false "Keyword" +// @Param page query int false "Page number" +// @Param per_page query int false "Items per page" +// @Param start_timestamp query int false "Start timestamp (milliseconds)" +// @Param end_timestamp query int false "End timestamp (milliseconds)" +// @Param token_name query string false "Filter by token name" +// @Param model_name query string false "Filter by model name" +// @Param token_id query int false "Filter by token id" +// @Param channel query int false "Filter by group channel" +// @Param order query string false "Order" +// @Param request_id query string false "Request ID" +// @Param upstream_id query string false "Upstream ID" +// @Param code_type query string false "Status code type" +// @Param code query int false "Status code" +// @Param include_detail query bool false "Include request and response detail" +// @Param ip query string false "IP" +// @Param user query string false "User" +// @Success 200 {object} middleware.APIResponse{data=model.GetGroupChannelLogsResult} +// @Router /api/logs/group_channel/search [get] +func SearchGlobalGroupChannelLogs(c *gin.Context) { + page, perPage := utils.ParsePageParams(c) + startTime, endTime := utils.ParseTimeRange(c, 0) + params := parseCommonParams(c) + keyword := c.Query("keyword") + + result, err := model.SearchGlobalGroupChannelLogs( + params.group, + keyword, + params.requestID, + params.upstreamID, + params.tokenID, + params.tokenName, + params.modelName, + startTime, + endTime, + params.channelID, params.order, model.CodeType(params.codeType), params.code, @@ -337,6 +589,57 @@ func GetGroupLogDetail(c *gin.Context) { middleware.SuccessResponse(c, log) } +// GetGroupChannelLogDetailForGroup godoc +// +// @Summary Get group channel log detail for a group +// @Description Get detailed information about a group channel log entry in a group +// @Tags log +// @Produce json +// @Security ApiKeyAuth +// @Param group path string true "Group name" +// @Param log_id path string true "Log ID" +// @Success 200 {object} middleware.APIResponse{data=model.RequestDetail} +// @Router /api/log/{group}/group_channel/detail/{log_id} [get] +func GetGroupChannelLogDetailForGroup(c *gin.Context) { + group := c.Param("group") + if group == "" { + middleware.ErrorResponse(c, http.StatusBadRequest, "invalid group parameter") + return + } + + logID, _ := strconv.Atoi(c.Param("log_id")) + + log, err := model.GetGroupChannelLogDetailForGroup(logID, group) + if err != nil { + middleware.ErrorResponse(c, http.StatusInternalServerError, err.Error()) + return + } + + middleware.SuccessResponse(c, log) +} + +// GetGlobalGroupChannelLogDetail godoc +// +// @Summary Get global group channel log detail +// @Description Get detailed information about a group channel log entry across groups +// @Tags logs +// @Produce json +// @Security ApiKeyAuth +// @Param log_id path string true "Log ID" +// @Success 200 {object} middleware.APIResponse{data=model.RequestDetail} +// @Router /api/logs/group_channel/detail/{log_id} [get] +func GetGlobalGroupChannelLogDetail(c *gin.Context) { + logID, _ := strconv.Atoi(c.Param("log_id")) + + log, err := model.GetGroupChannelLogDetail(logID) + if err != nil { + middleware.ErrorResponse(c, http.StatusInternalServerError, err.Error()) + return + } + + middleware.SuccessResponse(c, log) +} + // DeleteHistoryLogs godoc // // @Summary Delete historical logs @@ -363,6 +666,76 @@ func DeleteHistoryLogs(c *gin.Context) { middleware.SuccessResponse(c, count) } +// DeleteGroupChannelHistoryLogs godoc +// +// @Summary Delete historical group channel logs +// @Description Deletes group-channel logs older than the specified retention period +// @Tags log +// @Produce json +// @Security ApiKeyAuth +// @Param timestamp query int true "Timestamp (milliseconds)" +// @Success 200 {object} middleware.APIResponse{data=int} +// @Router /api/log/{group}/group_channel [delete] +func DeleteGroupChannelHistoryLogs(c *gin.Context) { + timestamp, _ := strconv.ParseInt(c.Query("timestamp"), 10, 64) + if timestamp == 0 { + middleware.ErrorResponse(c, http.StatusBadRequest, "timestamp is required") + return + } + + group := c.Param("group") + if group == "" { + middleware.ErrorResponse(c, http.StatusBadRequest, "invalid group parameter") + return + } + + count, err := model.DeleteOldGroupChannelLogForGroup(group, time.UnixMilli(timestamp)) + if err != nil { + middleware.ErrorResponse(c, http.StatusInternalServerError, err.Error()) + return + } + + middleware.SuccessResponse(c, count) +} + +// DeleteGlobalGroupChannelHistoryLogs godoc +// +// @Summary Delete historical group channel logs +// @Description Deletes group-channel logs older than the specified retention period, optionally filtered by group +// @Tags logs +// @Produce json +// @Security ApiKeyAuth +// @Param group query string false "Filter by group" +// @Param timestamp query int true "Timestamp (milliseconds)" +// @Success 200 {object} middleware.APIResponse{data=int} +// @Router /api/logs/group_channel [delete] +func DeleteGlobalGroupChannelHistoryLogs(c *gin.Context) { + timestamp, _ := strconv.ParseInt(c.Query("timestamp"), 10, 64) + if timestamp == 0 { + middleware.ErrorResponse(c, http.StatusBadRequest, "timestamp is required") + return + } + + group := c.Query("group") + + var ( + count int64 + err error + ) + if group != "" { + count, err = model.DeleteOldGroupChannelLogForGroup(group, time.UnixMilli(timestamp)) + } else { + count, err = model.DeleteOldGroupChannelLog(time.UnixMilli(timestamp)) + } + + if err != nil { + middleware.ErrorResponse(c, http.StatusInternalServerError, err.Error()) + return + } + + middleware.SuccessResponse(c, count) +} + // SearchConsumeError godoc // // @Summary Search consumption errors diff --git a/core/controller/log_export.go b/core/controller/log_export.go index bd40e936..8f2162b0 100644 --- a/core/controller/log_export.go +++ b/core/controller/log_export.go @@ -7,6 +7,7 @@ import ( "fmt" "mime" "net/http" + "slices" "strconv" "strings" "time" @@ -229,6 +230,125 @@ func ExportGroupLogs(c *gin.Context) { params.upstreamID, params.tokenID, params.tokenName, + params.channelID, + normalizeLogExportModelOrder(params.order), + model.CodeType(params.codeType), + params.code, + params.includeDetail, + params.ip, + params.user, + limit, + ) + }, + ) +} + +func groupChannelLogToLogForExport(logItem *model.GroupChannelLog) *model.Log { + if logItem == nil { + return nil + } + + detail := (*model.RequestDetail)(nil) + if logItem.RequestDetail != nil { + detail = &model.RequestDetail{ + CreatedAt: logItem.RequestDetail.CreatedAt, + RequestBody: logItem.RequestDetail.RequestBody, + ResponseBody: logItem.RequestDetail.ResponseBody, + RequestBodyTruncated: logItem.RequestDetail.RequestBodyTruncated, + ResponseBodyTruncated: logItem.RequestDetail.ResponseBodyTruncated, + ID: logItem.RequestDetail.ID, + LogID: logItem.RequestDetail.LogID, + } + } + + return &model.Log{ + RequestDetail: detail, + RequestAt: logItem.RequestAt, + RetryAt: logItem.RetryAt, + TTFBMilliseconds: logItem.TTFBMilliseconds, + CreatedAt: logItem.CreatedAt, + TokenName: logItem.TokenName, + Endpoint: logItem.Endpoint, + Content: logItem.Content, + GroupID: logItem.GroupID, + Model: logItem.Model, + RequestID: logItem.RequestID, + UpstreamID: logItem.UpstreamID, + AsyncUsageStatus: logItem.AsyncUsageStatus, + ID: logItem.ID, + TokenID: logItem.TokenID, + ChannelID: logItem.GroupChannelID, + Code: logItem.Code, + Mode: logItem.Mode, + IP: logItem.IP, + RetryTimes: logItem.RetryTimes, + Price: logItem.Price, + Usage: logItem.Usage, + UsageContext: logItem.UsageContext, + Amount: logItem.Amount, + PromptCacheKey: logItem.PromptCacheKey, + User: logItem.User, + Metadata: logItem.Metadata, + } +} + +// ExportGroupChannelLogs godoc +// +// @Summary Export group channel logs +// @Description Streams filtered group-channel logs as a CSV table file +// @Tags log +// @Produce text/csv +// @Security ApiKeyAuth +// @Param group path string true "Group name" +// @Param start_timestamp query int false "Start timestamp, max span 30 days" +// @Param end_timestamp query int false "End timestamp, max span 30 days" +// @Param model_name query string false "Model name" +// @Param channel query int false "Group channel ID" +// @Param token_id query int false "Token ID" +// @Param token_name query string false "Token name" +// @Param order query string false "Sort order for created_at, supports desc or asc" +// @Param request_id query string false "Request ID" +// @Param upstream_id query string false "Upstream ID" +// @Param code_type query string false "Status code type" +// @Param code query int false "Status code" +// @Param include_detail query bool false "Include request and response detail, default false" +// @Param ip query string false "IP" +// @Param user query string false "User" +// @Param timezone query string false "Timezone, default is Local" +// @Param max_entries query int false "Maximum exported rows; zero or negative means unlimited" +// @Param include_retry_at query bool false "Include retry_at column, default false" +// @Param chunk_interval query string false "Chunk interval, default 30m, min 10m, max 4h, e.g. 10m, 30m, 1h" +// @Router /api/log/{group}/group_channel/export [get] +func ExportGroupChannelLogs(c *gin.Context) { + group := c.Param("group") + if group == "" { + middleware.ErrorResponse(c, http.StatusBadRequest, "invalid group parameter") + return + } + + params, err := parseLogExportParams(c) + if err != nil { + middleware.ErrorResponse(c, http.StatusBadRequest, err.Error()) + return + } + + filename := buildLogExportFilename("group_channel_"+group, group, params.location) + streamGroupChannelCSV( + c, + filename, + params, + params.includeRetryAt, + func(start, endExclusive time.Time, limit int) ([]*model.GroupChannelLog, error) { + return model.ExportGroupChannelLogsRange( + group, + start, + endExclusive, + params.modelName, + params.requestID, + params.upstreamID, + params.tokenID, + params.tokenName, + params.channelID, normalizeLogExportModelOrder(params.order), model.CodeType(params.codeType), params.code, @@ -241,6 +361,98 @@ func ExportGroupLogs(c *gin.Context) { ) } +// ExportGlobalGroupChannelLogs godoc +// +// @Summary Export global group channel logs +// @Description Streams filtered group-channel logs across groups as a CSV table file +// @Tags logs +// @Produce text/csv +// @Security ApiKeyAuth +// @Param group query string false "Filter by group" +// @Param start_timestamp query int false "Start timestamp, max span 30 days" +// @Param end_timestamp query int false "End timestamp, max span 30 days" +// @Param model_name query string false "Model name" +// @Param channel query int false "Group channel ID" +// @Param token_id query int false "Token ID" +// @Param token_name query string false "Token name" +// @Param order query string false "Sort order for created_at, supports desc or asc" +// @Param request_id query string false "Request ID" +// @Param upstream_id query string false "Upstream ID" +// @Param code_type query string false "Status code type" +// @Param code query int false "Status code" +// @Param include_detail query bool false "Include request and response detail, default false" +// @Param ip query string false "IP" +// @Param user query string false "User" +// @Param timezone query string false "Timezone, default is Local" +// @Param max_entries query int false "Maximum exported rows; zero or negative means unlimited" +// @Param include_retry_at query bool false "Include retry_at column, default false" +// @Param chunk_interval query string false "Chunk interval, default 30m, min 10m, max 4h, e.g. 10m, 30m, 1h" +// @Router /api/logs/group_channel/export [get] +func ExportGlobalGroupChannelLogs(c *gin.Context) { + params, err := parseLogExportParams(c) + if err != nil { + middleware.ErrorResponse(c, http.StatusBadRequest, err.Error()) + return + } + + filename := buildLogExportFilename("group_channel_global", params.group, params.location) + streamGroupChannelCSV( + c, + filename, + params, + params.includeRetryAt, + func(start, endExclusive time.Time, limit int) ([]*model.GroupChannelLog, error) { + return model.ExportGlobalGroupChannelLogsRange( + params.group, + start, + endExclusive, + params.modelName, + params.requestID, + params.upstreamID, + params.tokenID, + params.tokenName, + params.channelID, + normalizeLogExportModelOrder(params.order), + model.CodeType(params.codeType), + params.code, + params.includeDetail, + params.ip, + params.user, + limit, + ) + }, + ) +} + +func streamGroupChannelCSV( + c *gin.Context, + filename string, + params logExportParams, + includeRetryAt bool, + fetch func(start, endExclusive time.Time, limit int) ([]*model.GroupChannelLog, error), +) { + streamCSVWithHeader( + c, + filename, + params, + buildLogExportHeader("group_channel", includeRetryAt), + includeRetryAt, + func(start, endExclusive time.Time, limit int) ([]*model.Log, error) { + logs, err := fetch(start, endExclusive, limit) + if err != nil { + return nil, err + } + + result := make([]*model.Log, 0, len(logs)) + for _, logItem := range logs { + result = append(result, groupChannelLogToLogForExport(logItem)) + } + + return result, nil + }, + ) +} + func streamCSV( c *gin.Context, filename string, @@ -248,6 +460,29 @@ func streamCSV( includeChannel bool, includeRetryAt bool, fetch func(start, endExclusive time.Time, limit int) ([]*model.Log, error), +) { + channelHeader := "" + if includeChannel { + channelHeader = "channel" + } + + streamCSVWithHeader( + c, + filename, + params, + buildLogExportHeader(channelHeader, includeRetryAt), + includeRetryAt, + fetch, + ) +} + +func streamCSVWithHeader( + c *gin.Context, + filename string, + params logExportParams, + header []string, + includeRetryAt bool, + fetch func(start, endExclusive time.Time, limit int) ([]*model.Log, error), ) { flusher, ok := c.Writer.(http.Flusher) if !ok { @@ -271,7 +506,7 @@ func streamCSV( } writer := csv.NewWriter(c.Writer) - if err := writer.Write(buildLogExportHeader(includeChannel, includeRetryAt)); err != nil { + if err := writer.Write(header); err != nil { return } @@ -314,7 +549,7 @@ func streamCSV( var writeErr error for _, logItem := range logs { if err := writer.Write( - buildLogExportRow(logItem, params.location, includeChannel, includeRetryAt), + buildLogExportRow(logItem, params.location, header, includeRetryAt), ); err != nil { writeErr = err break @@ -399,6 +634,13 @@ func buildLogExportCSV( includeChannel bool, includeRetryAt bool, ) ([]byte, error) { + channelHeader := "" + if includeChannel { + channelHeader = "channel" + } + + header := buildLogExportHeader(channelHeader, includeRetryAt) + if location == nil { location = time.Local } @@ -407,13 +649,13 @@ func buildLogExportCSV( buffer.WriteString("\xEF\xBB\xBF") writer := csv.NewWriter(&buffer) - if err := writer.Write(buildLogExportHeader(includeChannel, includeRetryAt)); err != nil { + if err := writer.Write(header); err != nil { return nil, err } for _, logItem := range logs { if err := writer.Write( - buildLogExportRow(logItem, location, includeChannel, includeRetryAt), + buildLogExportRow(logItem, location, header, includeRetryAt), ); err != nil { return nil, err } @@ -428,7 +670,7 @@ func buildLogExportCSV( return buffer.Bytes(), nil } -func buildLogExportHeader(includeChannel, includeRetryAt bool) []string { +func buildLogExportHeader(channelHeader string, includeRetryAt bool) []string { header := []string{ "id", "end_time", @@ -441,8 +683,8 @@ func buildLogExportHeader(includeChannel, includeRetryAt bool) []string { header = append(header, "retry_at") } - if includeChannel { - header = append(header, "channel") + if channelHeader != "" { + header = append(header, channelHeader) } return append(header, @@ -495,7 +737,7 @@ func buildLogExportHeader(includeChannel, includeRetryAt bool) []string { func buildLogExportRow( logItem *model.Log, location *time.Location, - includeChannel bool, + header []string, includeRetryAt bool, ) []string { requestBody := "" @@ -523,7 +765,7 @@ func buildLogExportRow( row = append(row, formatTimeForExport(logItem.RetryAt, location)) } - if includeChannel { + if slices.Contains(header, "channel") || slices.Contains(header, "group_channel") { row = append(row, strconv.Itoa(logItem.ChannelID)) } diff --git a/core/controller/log_export_test.go b/core/controller/log_export_test.go index f243fc2a..c3702679 100644 --- a/core/controller/log_export_test.go +++ b/core/controller/log_export_test.go @@ -145,6 +145,58 @@ func TestBuildLogExportCSVIncludesChannelWhenRequested(t *testing.T) { } } +func TestBuildGroupChannelLogExportUsesGroupChannelColumn(t *testing.T) { + logItem := &model.GroupChannelLog{ + ID: 1, + CreatedAt: time.Date(2026, time.April, 14, 12, 0, 0, 0, time.UTC), + RequestAt: time.Date(2026, time.April, 14, 12, 0, 1, 0, time.UTC), + GroupID: "demo", + TokenID: 2, + TokenName: "token-a", + GroupChannelID: 9, + Model: "gpt-test", + RequestID: model.EmptyNullString("req-1"), + } + + var buffer strings.Builder + + writer := csv.NewWriter(&buffer) + + header := buildLogExportHeader("group_channel", false) + if err := writer.Write(header); err != nil { + t.Fatalf("write header: %v", err) + } + + if err := writer.Write(buildLogExportRow( + groupChannelLogToLogForExport(logItem), + time.UTC, + header, + false, + )); err != nil { + t.Fatalf("write row: %v", err) + } + + writer.Flush() + + if err := writer.Error(); err != nil { + t.Fatalf("flush csv: %v", err) + } + + records, err := csv.NewReader(strings.NewReader(buffer.String())).ReadAll() + if err != nil { + t.Fatalf("parse csv: %v", err) + } + + values := csvRecordMap(t, records) + if _, ok := values["channel"]; ok { + t.Fatalf("expected group-channel export to omit channel column, got %#v", values) + } + + if values["group_channel"] != "9" { + t.Fatalf("expected group_channel value 9, got %q", values["group_channel"]) + } +} + func TestBuildLogExportCSVExcludesTimezoneModeAndRetryAtByDefault(t *testing.T) { content, err := buildLogExportCSV([]*model.Log{ { diff --git a/core/controller/mcp/publicmcp-group.go b/core/controller/mcp/publicmcp-group.go index 8e2cafe5..c5392423 100644 --- a/core/controller/mcp/publicmcp-group.go +++ b/core/controller/mcp/publicmcp-group.go @@ -177,7 +177,7 @@ func NewGroupPublicMCPDetailResponse( // @Param type query string false "hosted or local" // @Param keyword query string false "Keyword" // @Success 200 {object} middleware.APIResponse{data=[]GroupPublicMCPResponse} -// @Router /api/group/{group}/mcp [get] +// @Router /api/group/{group}/mcp/ [get] func GetGroupPublicMCPs(c *gin.Context) { page, perPage := utils.ParsePageParams(c) id := c.Query("id") diff --git a/core/controller/modelconfig.go b/core/controller/modelconfig.go index 8738cbb8..f186bbbd 100644 --- a/core/controller/modelconfig.go +++ b/core/controller/modelconfig.go @@ -258,3 +258,312 @@ func GetModelConfig(c *gin.Context) { middleware.SuccessResponse(c, config) } + +// GetGroupScopeModelConfigs godoc +// +// @Summary Get group scope model configs +// @Description Returns group-channel scope model configs with pagination for a specific group +// @Tags group-scope-modelconfig +// @Produce json +// @Security ApiKeyAuth +// @Param group path string true "Group name" +// @Param model query string false "Model name" +// @Param page query int false "Page" +// @Param per_page query int false "Per page" +// @Success 200 {object} middleware.APIResponse{data=map[string]any{configs=[]model.GroupScopeModelConfig,total=int}} +// @Router /api/group/{group}/scope_model_configs/ [get] +func GetGroupScopeModelConfigs(c *gin.Context) { + group := c.Param("group") + if group == "" { + middleware.ErrorResponse(c, http.StatusBadRequest, "group is required") + return + } + + page, perPage := utils.ParsePageParams(c) + + configs, total, err := model.GetGroupScopeModelConfigs( + group, + page, + perPage, + c.Query("model"), + ) + if err != nil { + middleware.ErrorResponse(c, http.StatusInternalServerError, err.Error()) + return + } + + middleware.SuccessResponse(c, gin.H{ + "configs": configs, + "total": total, + }) +} + +// GetAllGroupScopeModelConfigs godoc +// +// @Summary Get all group scope model configs +// @Description Returns all group-channel scope model configs for a specific group +// @Tags group-scope-modelconfig +// @Produce json +// @Security ApiKeyAuth +// @Param group path string true "Group name" +// @Success 200 {object} middleware.APIResponse{data=[]model.GroupScopeModelConfig} +// @Router /api/group/{group}/scope_model_configs/all [get] +func GetAllGroupScopeModelConfigs(c *gin.Context) { + group := c.Param("group") + if group == "" { + middleware.ErrorResponse(c, http.StatusBadRequest, "group is required") + return + } + + configs, err := model.GetAllGroupScopeModelConfigs(group) + if err != nil { + middleware.ErrorResponse(c, http.StatusInternalServerError, err.Error()) + return + } + + middleware.SuccessResponse(c, configs) +} + +// GetGroupScopeModelConfigsByModelsContains godoc +// +// @Summary Get group scope model configs by models +// @Description Returns group-channel scope model configs for the requested models in a specific group +// @Tags group-scope-modelconfig +// @Accept json +// @Produce json +// @Security ApiKeyAuth +// @Param group path string true "Group name" +// @Param models body GetModelConfigsByModelsContainsRequest true "Models" +// @Success 200 {object} middleware.APIResponse{data=[]model.GroupScopeModelConfig} +// @Router /api/group/{group}/scope_model_configs/contains [post] +func GetGroupScopeModelConfigsByModelsContains(c *gin.Context) { + group := c.Param("group") + if group == "" { + middleware.ErrorResponse(c, http.StatusBadRequest, "group is required") + return + } + + request := GetModelConfigsByModelsContainsRequest{} + if err := c.ShouldBindJSON(&request); err != nil { + middleware.ErrorResponse(c, http.StatusBadRequest, err.Error()) + return + } + + configs, err := model.GetGroupScopeModelConfigsByModels(group, request.Models) + if err != nil { + middleware.ErrorResponse(c, http.StatusInternalServerError, err.Error()) + return + } + + middleware.SuccessResponse(c, configs) +} + +// SearchGroupScopeModelConfigs godoc +// +// @Summary Search group scope model configs +// @Description Returns group-channel scope model configs by keyword for a specific group +// @Tags group-scope-modelconfig +// @Produce json +// @Security ApiKeyAuth +// @Param group path string true "Group name" +// @Param keyword query string false "Keyword" +// @Param model query string false "Model name" +// @Param owner query string false "Owner" +// @Param page query int false "Page" +// @Param per_page query int false "Per page" +// @Success 200 {object} middleware.APIResponse{data=map[string]any{configs=[]model.GroupScopeModelConfig,total=int}} +// @Router /api/group/{group}/scope_model_configs/search [get] +func SearchGroupScopeModelConfigs(c *gin.Context) { + group := c.Param("group") + if group == "" { + middleware.ErrorResponse(c, http.StatusBadRequest, "group is required") + return + } + + page, perPage := utils.ParsePageParams(c) + + configs, total, err := model.SearchGroupScopeModelConfigs( + group, + c.Query("keyword"), + c.Query("model"), + page, + perPage, + model.ModelOwner(c.Query("owner")), + ) + if err != nil { + middleware.ErrorResponse(c, http.StatusInternalServerError, err.Error()) + return + } + + middleware.SuccessResponse(c, gin.H{ + "configs": configs, + "total": total, + }) +} + +type SaveGroupScopeModelConfigsRequest = model.GroupScopeModelConfig + +// SaveGroupScopeModelConfigs godoc +// +// @Summary Save group scope model configs +// @Description Saves group-channel scope model configs for a specific group +// @Tags group-scope-modelconfig +// @Accept json +// @Produce json +// @Security ApiKeyAuth +// @Param group path string true "Group name" +// @Param configs body []SaveGroupScopeModelConfigsRequest true "Model configs" +// @Success 200 {object} middleware.APIResponse +// @Router /api/group/{group}/scope_model_configs/ [post] +func SaveGroupScopeModelConfigs(c *gin.Context) { + group := c.Param("group") + if group == "" { + middleware.ErrorResponse(c, http.StatusBadRequest, "group is required") + return + } + + var configs []SaveGroupScopeModelConfigsRequest + if err := c.ShouldBindJSON(&configs); err != nil { + middleware.ErrorResponse(c, http.StatusBadRequest, err.Error()) + return + } + + if err := model.SaveGroupScopeModelConfigs(group, configs); err != nil { + middleware.ErrorResponse(c, http.StatusInternalServerError, err.Error()) + return + } + + middleware.SuccessResponse(c, nil) +} + +// SaveGroupScopeModelConfig godoc +// +// @Summary Save group scope model config +// @Description Saves a group-channel scope model config for a specific group +// @Tags group-scope-modelconfig +// @Accept json +// @Produce json +// @Security ApiKeyAuth +// @Param group path string true "Group name" +// @Param model path string true "Model name" +// @Param config body SaveGroupScopeModelConfigsRequest true "Model config" +// @Success 200 {object} middleware.APIResponse +// @Router /api/group/{group}/scope_model_config/{model} [post] +func SaveGroupScopeModelConfig(c *gin.Context) { + group := c.Param("group") + if group == "" { + middleware.ErrorResponse(c, http.StatusBadRequest, "group is required") + return + } + + var config SaveGroupScopeModelConfigsRequest + if err := c.ShouldBindJSON(&config); err != nil { + middleware.ErrorResponse(c, http.StatusBadRequest, err.Error()) + return + } + + modelName := strings.TrimPrefix(c.Param("model"), "/") + if modelName == "" { + middleware.ErrorResponse(c, http.StatusBadRequest, "invalid parameter") + return + } + + config.GroupID = group + + config.Model = modelName + if err := model.SaveGroupScopeModelConfig(config); err != nil { + middleware.ErrorResponse(c, http.StatusInternalServerError, err.Error()) + return + } + + middleware.SuccessResponse(c, nil) +} + +// DeleteGroupScopeModelConfig godoc +// +// @Summary Delete group scope model config +// @Description Deletes a group-channel scope model config for a specific group +// @Tags group-scope-modelconfig +// @Produce json +// @Security ApiKeyAuth +// @Param group path string true "Group name" +// @Param model path string true "Model name" +// @Success 200 {object} middleware.APIResponse +// @Router /api/group/{group}/scope_model_config/{model} [delete] +func DeleteGroupScopeModelConfig(c *gin.Context) { + group := c.Param("group") + if group == "" { + middleware.ErrorResponse(c, http.StatusBadRequest, "group is required") + return + } + + modelName := strings.TrimPrefix(c.Param("model"), "/") + if err := model.DeleteGroupScopeModelConfig(group, modelName); err != nil { + middleware.ErrorResponse(c, http.StatusInternalServerError, err.Error()) + return + } + + middleware.SuccessResponse(c, nil) +} + +// DeleteGroupScopeModelConfigs godoc +// +// @Summary Delete group scope model configs +// @Description Deletes group-channel scope model configs by model names for a specific group +// @Tags group-scope-modelconfig +// @Accept json +// @Produce json +// @Security ApiKeyAuth +// @Param group path string true "Group name" +// @Param models body []string true "Model names" +// @Success 200 {object} middleware.APIResponse +// @Router /api/group/{group}/scope_model_configs/batch_delete [post] +func DeleteGroupScopeModelConfigs(c *gin.Context) { + group := c.Param("group") + if group == "" { + middleware.ErrorResponse(c, http.StatusBadRequest, "group is required") + return + } + + models := []string{} + if err := c.ShouldBindJSON(&models); err != nil { + middleware.ErrorResponse(c, http.StatusBadRequest, err.Error()) + return + } + + if err := model.DeleteGroupScopeModelConfigsByModels(group, models); err != nil { + middleware.ErrorResponse(c, http.StatusInternalServerError, err.Error()) + return + } + + middleware.SuccessResponse(c, nil) +} + +// GetGroupScopeModelConfig godoc +// +// @Summary Get group scope model config +// @Description Returns a group-channel scope model config for a specific group +// @Tags group-scope-modelconfig +// @Produce json +// @Security ApiKeyAuth +// @Param group path string true "Group name" +// @Param model path string true "Model name" +// @Success 200 {object} middleware.APIResponse{data=model.GroupScopeModelConfig} +// @Router /api/group/{group}/scope_model_config/{model} [get] +func GetGroupScopeModelConfig(c *gin.Context) { + group := c.Param("group") + if group == "" { + middleware.ErrorResponse(c, http.StatusBadRequest, "group is required") + return + } + + modelName := strings.TrimPrefix(c.Param("model"), "/") + + config, err := model.GetGroupScopeModelConfig(group, modelName) + if err != nil { + middleware.ErrorResponse(c, http.StatusInternalServerError, err.Error()) + return + } + + middleware.SuccessResponse(c, config) +} diff --git a/core/controller/relay-channel.go b/core/controller/relay-channel.go index f5707725..e10fea1f 100644 --- a/core/controller/relay-channel.go +++ b/core/controller/relay-channel.go @@ -6,6 +6,7 @@ import ( "fmt" "math" "math/rand/v2" + "slices" "strconv" "github.com/gin-gonic/gin" @@ -20,6 +21,36 @@ import ( "github.com/labring/aiproxy/core/relay/plugin/cachefollow" ) +type scopedChannel struct { + channel *model.Channel + scope model.ChannelScope + groupID string +} + +func newGlobalScopedChannel(channel *model.Channel) *scopedChannel { + return &scopedChannel{channel: channel, scope: model.ChannelScopeGlobal} +} + +func newGroupScopedChannel(groupID string, channel *model.Channel) *scopedChannel { + return &scopedChannel{channel: channel, scope: model.ChannelScopeGroup, groupID: groupID} +} + +func (c *scopedChannel) monitorKey() string { + if c == nil || c.channel == nil { + return "0" + } + + if c.scope == model.ChannelScopeGroup { + return model.GroupChannelMonitorKey(c.groupID, c.channel.ID) + } + + return strconv.Itoa(c.channel.ID) +} + +func (c *scopedChannel) isGroupChannel() bool { + return c != nil && c.scope == model.ChannelScopeGroup +} + const ( AIProxyChannelHeader = "Aiproxy-Channel" // maxRetryErrorRate is the maximum error rate threshold for channel retry selection @@ -35,17 +66,25 @@ const ( ) func supportModeMeta( - mc *model.ModelCaches, channel *model.Channel, modelName string, m mode.Mode, + modelConfig model.ModelConfig, ) *relaymeta.Meta { + return relaymeta.NewMeta(channel, m, modelName, modelConfig) +} + +func supportModeModelConfig(mc *model.ModelCaches, modelName string) model.ModelConfig { modelConfig := model.ModelConfig{} if mc != nil && mc.ModelConfig != nil { modelConfig, _ = mc.ModelConfig.GetModelConfig(modelName) } - return relaymeta.NewMeta(channel, m, modelName, modelConfig) + return modelConfig +} + +func groupChannelSupportModeModelConfig(groupID, modelName string) (model.ModelConfig, bool) { + return model.ResolveGroupScopeModelConfig(groupID, modelName) } func adaptorSupportsMode( @@ -55,60 +94,233 @@ func adaptorSupportsMode( modelName string, m mode.Mode, ) bool { - return a.SupportMode(supportModeMeta(mc, channel, modelName, m)) + return adaptorSupportsModeWithConfig( + a, + channel, + modelName, + m, + supportModeModelConfig(mc, modelName), + ) +} + +func adaptorSupportsModeWithConfig( + a adaptor.Adaptor, + channel *model.Channel, + modelName string, + m mode.Mode, + modelConfig model.ModelConfig, +) bool { + return a.SupportMode(supportModeMeta(channel, modelName, m, modelConfig)) } func GetChannelFromHeader( header string, mc *model.ModelCaches, - availableSet []string, - model string, + modelName string, m mode.Mode, ) (*model.Channel, error) { - channelIDInt, err := strconv.ParseInt(header, 10, 64) + channelID, err := strconv.Atoi(header) if err != nil { return nil, err } - for _, set := range availableSet { - enabledChannels := mc.EnabledModel2ChannelsBySet[set][model] - if len(enabledChannels) > 0 { - for _, channel := range enabledChannels { - if int64(channel.ID) == channelIDInt { - a, ok := adaptors.GetAdaptor(channel.Type) - if !ok { - return nil, fmt.Errorf("adaptor not found for channel %d", channel.ID) - } - - if !adaptorSupportsMode(a, mc, channel, model, m) { - return nil, fmt.Errorf("channel %d not supported by adaptor", channel.ID) - } - - return channel, nil - } + channel := findGlobalHeaderChannelByID(mc, modelName, channelID) + if channel == nil { + return nil, fmt.Errorf("channel %d not found for model `%s`", channelID, modelName) + } + + if !globalHeaderChannelSupportsRequest(channel, mc, modelName, m) { + return nil, fmt.Errorf("channel %d not supported for model `%s`", channelID, modelName) + } + + return channel, nil +} + +func findEnabledGlobalChannelByID( + mc *model.ModelCaches, + availableSet []string, + modelName string, + channelID int, +) *model.Channel { + if mc == nil || channelID == 0 { + return nil + } + + return findGlobalChannelByIDInSets( + mc.EnabledModel2ChannelsBySet, + availableSet, + modelName, + channelID, + ) +} + +func findGlobalHeaderChannelByID( + mc *model.ModelCaches, + modelName string, + channelID int, +) *model.Channel { + if mc == nil || channelID == 0 { + return nil + } + + if channel := findGlobalChannelByIDInSets( + mc.EnabledModel2ChannelsBySet, + nil, + modelName, + channelID, + ); channel != nil { + return channel + } + + return findGlobalChannelByIDInSets( + mc.DisabledModel2ChannelsBySet, + nil, + modelName, + channelID, + ) +} + +func findGlobalChannelByIDInSets( + channelsBySet map[string]map[string][]*model.Channel, + availableSet []string, + modelName string, + channelID int, +) *model.Channel { + if availableSet != nil && len(availableSet) == 0 { + return nil + } + + findInSet := func(set string) *model.Channel { + for _, channel := range channelsBySet[set][modelName] { + if channel.ID == channelID { + return channel } } - disabledChannels := mc.DisabledModel2ChannelsBySet[set][model] - if len(disabledChannels) > 0 { - for _, channel := range disabledChannels { - if int64(channel.ID) == channelIDInt { - a, ok := adaptors.GetAdaptor(channel.Type) - if !ok { - return nil, fmt.Errorf("adaptor not found for channel %d", channel.ID) - } + return nil + } - if !adaptorSupportsMode(a, mc, channel, model, m) { - return nil, fmt.Errorf("channel %d not supported by adaptor", channel.ID) - } + for _, set := range availableSet { + if channel := findInSet(set); channel != nil { + return channel + } + } - return channel, nil - } - } + if len(availableSet) > 0 { + return nil + } + + for set := range channelsBySet { + if channel := findInSet(set); channel != nil { + return channel } } - return nil, fmt.Errorf("channel %d not found for model `%s`", channelIDInt, model) + return nil +} + +func globalHeaderChannelSupportsRequest( + channel *model.Channel, + mc *model.ModelCaches, + modelName string, + m mode.Mode, +) bool { + if channel == nil { + return false + } + + if !model.ChannelSupportsModel(channel, modelName) { + return false + } + + a, ok := adaptors.GetAdaptor(channel.Type) + if !ok { + return false + } + + return adaptorSupportsMode(a, mc, channel, modelName, m) +} + +func GetGroupChannelFromHeader( + header string, + group model.GroupCache, + availableSet []string, + ignoreSetLimit bool, + modelName string, + m mode.Mode, +) (*scopedChannel, error) { + channelID, err := strconv.Atoi(header) + if err != nil { + return nil, err + } + + groupChannel, err := model.LoadGroupChannelByID(group.ID, channelID) + if err != nil { + return nil, fmt.Errorf("group channel %d not found for model `%s`", channelID, modelName) + } + + if !groupChannelSupportsRequest( + groupChannel, + availableSet, + ignoreSetLimit, + modelName, + m, + ) { + return nil, fmt.Errorf( + "group channel %d not supported for model `%s`", + channelID, + modelName, + ) + } + + return newGroupScopedChannel(group.ID, groupChannel.ToChannel()), nil +} + +func groupChannelSupportsRequest( + groupChannel *model.GroupChannel, + availableSet []string, + ignoreSetLimit bool, + modelName string, + m mode.Mode, +) bool { + if groupChannel == nil || groupChannel.Status != model.ChannelStatusEnabled { + return false + } + + if !model.GroupChannelSupportsModel(groupChannel, modelName) { + return false + } + + if !ignoreSetLimit && len(availableSet) > 0 && + !slices.ContainsFunc(groupChannel.GetSets(), func(set string) bool { + return slices.Contains(availableSet, set) + }) { + return false + } + + if !ignoreSetLimit && availableSet != nil && len(availableSet) == 0 { + return false + } + + channel := groupChannel.ToChannel() + + a, ok := adaptors.GetAdaptor(channel.Type) + if !ok { + return false + } + + modelConfig, ok := groupChannelSupportModeModelConfig(groupChannel.GroupID, modelName) + if !ok { + return false + } + + return adaptorSupportsModeWithConfig( + a, + channel, + modelName, + m, + modelConfig, + ) } func needPinChannel(m mode.Mode) bool { @@ -143,35 +355,87 @@ func GetChannelFromRequest( return nil, nil } - for _, set := range availableSet { - enabledChannels := mc.EnabledModel2ChannelsBySet[set][modelName] - if len(enabledChannels) > 0 { - for _, channel := range enabledChannels { - if channel.ID == channelID { - a, ok := adaptors.GetAdaptor(channel.Type) - if !ok { - return nil, fmt.Errorf( - "adaptor not found for pinned channel %d", - channel.ID, - ) - } - - if !adaptorSupportsMode(a, mc, channel, modelName, m) { - return nil, fmt.Errorf( - "pinned channel %d not supported by adaptor", - channel.ID, - ) - } - - return channel, nil - } - } + channel := findEnabledGlobalChannelByID(mc, availableSet, modelName, channelID) + if channel != nil { + a, ok := adaptors.GetAdaptor(channel.Type) + if !ok { + return nil, fmt.Errorf( + "adaptor not found for pinned channel %d", + channel.ID, + ) } + + if !adaptorSupportsMode(a, mc, channel, modelName, m) { + return nil, fmt.Errorf( + "pinned channel %d not supported by adaptor", + channel.ID, + ) + } + + return channel, nil } return nil, fmt.Errorf("pinned channel %d not found for model `%s`", channelID, modelName) } +func GetScopedChannelFromRequest( + c *gin.Context, + mc *model.ModelCaches, + availableSet []string, + ignoreGroupChannelSetLimit bool, + modelName string, + m mode.Mode, +) (*scopedChannel, error) { + channelID := middleware.GetChannelID(c) + if channelID == 0 { + if needPinChannel(m) { + return nil, fmt.Errorf("%s need pinned channel", m) + } + return nil, nil + } + + switch middleware.GetChannelScope(c) { + case model.ChannelScopeGroup: + group := middleware.GetGroup(c) + + groupChannel, err := model.LoadGroupChannelByID(group.ID, channelID) + if err != nil { + return nil, fmt.Errorf( + "pinned group channel %d not found for model `%s`", + channelID, + modelName, + ) + } + + if groupChannel.Status != model.ChannelStatusEnabled { + return nil, fmt.Errorf("pinned group channel %d is disabled", channelID) + } + + if !groupChannelSupportsRequest( + groupChannel, + availableSet, + ignoreGroupChannelSetLimit, + modelName, + m, + ) { + return nil, fmt.Errorf( + "pinned group channel %d not supported for model `%s`", + channelID, + modelName, + ) + } + + return newGroupScopedChannel(group.ID, groupChannel.ToChannel()), nil + default: + channel, err := GetChannelFromRequest(c, mc, availableSet, modelName, m) + if err != nil || channel == nil { + return nil, err + } + + return newGlobalScopedChannel(channel), nil + } +} + var ( ErrChannelsNotFound = errors.New("channels not found") ErrChannelsExhausted = errors.New("channels exhausted") @@ -182,8 +446,13 @@ func getAvailableChannels( availableSet []string, modelName string, mode mode.Mode, -) ([]*model.Channel, error) { +) ([]*scopedChannel, error) { channelMap := make(map[int]*model.Channel) + + if availableSet != nil && len(availableSet) == 0 { + return nil, ErrChannelsNotFound + } + if len(availableSet) != 0 { for _, set := range availableSet { channels := mc.EnabledModel2ChannelsBySet[set][modelName] @@ -221,16 +490,81 @@ func getAvailableChannels( return nil, ErrChannelsNotFound } - migratedChannels := make([]*model.Channel, 0, len(channelMap)) + migratedChannels := make([]*scopedChannel, 0, len(channelMap)) for _, channel := range channelMap { - migratedChannels = append(migratedChannels, channel) + migratedChannels = append(migratedChannels, newGlobalScopedChannel(channel)) } return migratedChannels, nil } -func getPriorityWeight(channel *model.Channel, errorRate float64) float64 { - priority := float64(channel.GetPriority()) +func mergeGroupChannels( + channels []*scopedChannel, + groupChannels []*model.GroupChannel, + availableSet []string, + modelName string, + m mode.Mode, +) []*scopedChannel { + if len(groupChannels) == 0 { + return channels + } + + if availableSet != nil && len(availableSet) == 0 { + return channels + } + + setAllowed := make(map[string]struct{}, len(availableSet)) + for _, set := range availableSet { + setAllowed[set] = struct{}{} + } + + for _, groupChannel := range groupChannels { + if groupChannel.Status != model.ChannelStatusEnabled { + continue + } + + if !model.GroupChannelSupportsModel(groupChannel, modelName) { + continue + } + + if len(setAllowed) > 0 && + !slices.ContainsFunc(groupChannel.GetSets(), func(set string) bool { + _, ok := setAllowed[set] + return ok + }) { + continue + } + + channel := groupChannel.ToChannel() + + a, ok := adaptors.GetAdaptor(channel.Type) + if !ok { + continue + } + + modelConfig, ok := groupChannelSupportModeModelConfig(groupChannel.GroupID, modelName) + if !ok { + continue + } + + if !adaptorSupportsModeWithConfig( + a, + channel, + modelName, + m, + modelConfig, + ) { + continue + } + + channels = append(channels, newGroupScopedChannel(groupChannel.GroupID, channel)) + } + + return channels +} + +func getPriorityWeight(channel *scopedChannel, errorRate float64) float64 { + priority := float64(channel.channel.GetPriority()) if priority <= 0 { return 0 } @@ -247,7 +581,7 @@ func getPriorityWeight(channel *model.Channel, errorRate float64) float64 { return priority / math.Pow(errorRate+errorRatePenaltyBase, errorRatePenalty) } -func getChannelErrorRate(errorRates map[int64]float64, channelID int64) float64 { +func getChannelErrorRate(errorRates map[string]float64, channelID string) float64 { if errorRates == nil { return 0 } @@ -255,12 +589,25 @@ func getChannelErrorRate(errorRates map[int64]float64, channelID int64) float64 return errorRates[channelID] } +func int64SetToStringSet(values map[int64]struct{}) map[string]struct{} { + if len(values) == 0 { + return nil + } + + result := make(map[string]struct{}, len(values)) + for value := range values { + result[strconv.FormatInt(value, 10)] = struct{}{} + } + + return result +} + func pickMinErrorRateHasPermissionChannel( - current *model.Channel, + current *scopedChannel, currentErrorRate float64, - candidate *model.Channel, + candidate *scopedChannel, candidateErrorRate float64, -) *model.Channel { +) *scopedChannel { if candidate == nil { return current } @@ -277,9 +624,9 @@ func pickMinErrorRateHasPermissionChannel( } func pickChannel( - channels []*model.Channel, - errorRates map[int64]float64, -) (*model.Channel, error) { + channels []*scopedChannel, + errorRates map[string]float64, +) (*scopedChannel, error) { if len(channels) == 0 { return nil, ErrChannelsExhausted } @@ -292,7 +639,7 @@ func pickChannel( cachedWeights := make([]float64, len(channels)) for i, ch := range channels { - weight := getPriorityWeight(ch, getChannelErrorRate(errorRates, int64(ch.ID))) + weight := getPriorityWeight(ch, getChannelErrorRate(errorRates, ch.monitorKey())) totalWeight += weight cachedWeights[i] = weight } @@ -312,24 +659,84 @@ func pickChannel( return channels[rand.IntN(len(channels))], nil } -func getChannelWithFallback( +func getInitialChannelScope(groupMode string) model.ChannelScope { + if groupMode == middleware.GroupChannelModeOwn { + return model.ChannelScopeGroup + } + + return model.ChannelScopeGlobal +} + +func getScopedBannedChannelKeysMap( + ctx context.Context, + scope model.ChannelScope, + modelName string, +) (map[string]struct{}, error) { + if scope == model.ChannelScopeGroup { + return monitor.GetGroupChannelBannedChannelKeysMapWithModel(ctx, modelName) + } + + return monitor.GetBannedChannelKeysMapWithModel(ctx, modelName) +} + +func getScopedModelChannelErrorRateByKey( + ctx context.Context, + scope model.ChannelScope, + modelName string, +) (map[string]float64, error) { + if scope == model.ChannelScopeGroup { + return monitor.GetGroupChannelModelErrorRateByKey(ctx, modelName) + } + + return monitor.GetModelChannelErrorRateByKey(ctx, modelName) +} + +func getScopedChannelModelErrorRateByKey( + ctx context.Context, + scope model.ChannelScope, + modelName string, + channelKey string, +) (float64, error) { + if scope == model.ChannelScopeGroup { + return monitor.GetGroupChannelChannelModelErrorRateByKey(ctx, modelName, channelKey) + } + + return monitor.GetChannelModelErrorRateByKey(ctx, modelName, channelKey) +} + +func getScopedChannelWithFallback( cache *model.ModelCaches, availableSet []string, + groupChannels []*model.GroupChannel, + groupOnly bool, modelName string, mode mode.Mode, - preferChannelIDs []int, - errorRates map[int64]float64, - ignoreChannelIDs map[int64]struct{}, -) (*model.Channel, []*model.Channel, error) { - migratedChannels, err := getAvailableChannels( - cache, + preferChannelKeys []string, + errorRates map[string]float64, + ignoreChannelIDs map[string]struct{}, +) (*scopedChannel, []*scopedChannel, error) { + var migratedChannels []*scopedChannel + if !groupOnly { + var err error + + migratedChannels, err = getAvailableChannels( + cache, + availableSet, + modelName, + mode, + ) + if err != nil && len(groupChannels) == 0 { + return nil, nil, err + } + } + + migratedChannels = mergeGroupChannels( + migratedChannels, + groupChannels, availableSet, modelName, mode, ) - if err != nil { - return nil, nil, err - } filteredChannels := filterChannels( migratedChannels, @@ -338,21 +745,21 @@ func getChannelWithFallback( ignoreChannelIDs, ) - if len(preferChannelIDs) > 0 { + if len(preferChannelKeys) > 0 { channel := pickPreferredChannel( filteredChannels, - preferChannelIDs, + preferChannelKeys, ) if channel != nil { return channel, migratedChannels, nil } } - pipeline := []func() []*model.Channel{ - func() []*model.Channel { + pipeline := []func() []*scopedChannel{ + func() []*scopedChannel { return filteredChannels }, - func() []*model.Channel { + func() []*scopedChannel { return filterChannels( migratedChannels, errorRates, @@ -360,7 +767,7 @@ func getChannelWithFallback( ignoreChannelIDs, ) }, - func() []*model.Channel { + func() []*scopedChannel { return filterChannels( migratedChannels, errorRates, @@ -380,26 +787,26 @@ func getChannelWithFallback( } func pickPreferredChannel( - channels []*model.Channel, - preferChannelIDs []int, -) *model.Channel { - if len(channels) == 0 || len(preferChannelIDs) == 0 { + channels []*scopedChannel, + preferChannelKeys []string, +) *scopedChannel { + if len(channels) == 0 || len(preferChannelKeys) == 0 { return nil } - channelMap := make(map[int]*model.Channel, len(channels)) + channelMap := make(map[string]*scopedChannel, len(channels)) for _, channel := range channels { - channelMap[channel.ID] = channel + channelMap[channel.monitorKey()] = channel } - seen := make(map[int]struct{}, len(preferChannelIDs)) - for _, channelID := range preferChannelIDs { - if _, ok := seen[channelID]; ok { + seen := make(map[string]struct{}, len(preferChannelKeys)) + for _, channelKey := range preferChannelKeys { + if _, ok := seen[channelKey]; ok { continue } - seen[channelID] = struct{}{} - if channel, ok := channelMap[channelID]; ok { + seen[channelKey] = struct{}{} + if channel, ok := channelMap[channelKey]; ok { return channel } } @@ -407,29 +814,69 @@ func pickPreferredChannel( return nil } +func channelIDsToKeys(ids []int) []string { + if len(ids) == 0 { + return nil + } + + keys := make([]string, 0, len(ids)) + for _, id := range ids { + if id == 0 { + continue + } + + keys = append(keys, strconv.Itoa(id)) + } + + return keys +} + type initialChannel struct { - channel *model.Channel + channel *scopedChannel designatedChannel bool - preferChannelIDs []int - ignoreChannelIDs map[int64]struct{} - migratedChannels []*model.Channel + groupRetryOnly bool + preferChannelKeys []string + ignoreChannelIDs map[string]struct{} + migratedChannels []*scopedChannel } func getInitialChannel(c *gin.Context, modelName string, m mode.Mode) (*initialChannel, error) { log := common.GetLogger(c) group := middleware.GetGroup(c) - availableSet := group.GetAvailableSets() + groupMode := middleware.GetGroupChannelMode(c) + availableSet := middleware.GetActiveAvailableSets(c) if channelHeader := c.Request.Header.Get(AIProxyChannelHeader); channelHeader != "" { + if groupMode == middleware.GroupChannelModeOwn { + channel, err := GetGroupChannelFromHeader( + channelHeader, + group, + availableSet, + group.Status == model.GroupStatusInternal, + modelName, + m, + ) + if err != nil { + return nil, err + } + + log.Data["designated_channel"] = "true" + + return &initialChannel{ + channel: channel, + designatedChannel: true, + groupRetryOnly: true, + }, nil + } + if group.Status != model.GroupStatusInternal { - return nil, errors.New("channel header is not allowed in non-internal group") + return nil, errors.New("global channel header is not allowed in non-internal group") } channel, err := GetChannelFromHeader( channelHeader, middleware.GetModelCaches(c), - availableSet, modelName, m, ) @@ -439,13 +886,17 @@ func getInitialChannel(c *gin.Context, modelName string, m mode.Mode) (*initialC log.Data["designated_channel"] = "true" - return &initialChannel{channel: channel, designatedChannel: true}, nil + return &initialChannel{ + channel: newGlobalScopedChannel(channel), + designatedChannel: true, + }, nil } - channel, err := GetChannelFromRequest( + channel, err := GetScopedChannelFromRequest( c, middleware.GetModelCaches(c), availableSet, + group.Status == model.GroupStatusInternal, modelName, m, ) @@ -454,12 +905,32 @@ func getInitialChannel(c *gin.Context, modelName string, m mode.Mode) (*initialC } if channel != nil { - return &initialChannel{channel: channel, designatedChannel: true}, nil + return &initialChannel{ + channel: channel, + designatedChannel: true, + groupRetryOnly: channel.isGroupChannel(), + }, nil } mc := middleware.GetModelCaches(c) - ignoreChannelIDs, err := monitor.GetBannedChannelsMapWithModel(c.Request.Context(), modelName) + var groupChannels []*model.GroupChannel + if groupMode == middleware.GroupChannelModeOwn && group.ID != "" { + groupChannelsCache, err := model.CacheGetGroupChannels(group.ID) + if err != nil { + log.Errorf("get group channels failed: %+v", err) + } else { + groupChannels = groupChannelsCache.Channels + } + } + + selectionScope := getInitialChannelScope(groupMode) + + ignoreChannelKeyMap, err := getScopedBannedChannelKeysMap( + c.Request.Context(), + selectionScope, + modelName, + ) if err != nil { if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { @@ -469,9 +940,13 @@ func getInitialChannel(c *gin.Context, modelName string, m mode.Mode) (*initialC log.Errorf("get %s auto banned channels failed: %+v", modelName, err) } - log.Debugf("%s model banned channels: %+v", modelName, ignoreChannelIDs) + log.Debugf("%s model banned channels: %+v", modelName, ignoreChannelKeyMap) - errorRates, err := monitor.GetModelChannelErrorRate(c.Request.Context(), modelName) + errorRates, err := getScopedModelChannelErrorRateByKey( + c.Request.Context(), + selectionScope, + modelName, + ) if err != nil { if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { @@ -481,30 +956,33 @@ func getInitialChannel(c *gin.Context, modelName string, m mode.Mode) (*initialC log.Errorf("get channel model error rates failed: %+v", err) } - preferChannelIDs := getPreferChannelIDs(c, modelName, m) + preferChannelKeys := getPreferChannelKeys(c, modelName, m) - if len(preferChannelIDs) > 0 { - log.Data["prefer_channels"] = fmt.Sprintf("%v", preferChannelIDs) + if len(preferChannelKeys) > 0 { + log.Data["prefer_channels"] = fmt.Sprintf("%v", preferChannelKeys) } - channel, migratedChannels, err := getChannelWithFallback( + selectedChannel, migratedChannels, err := getScopedChannelWithFallback( mc, availableSet, + groupChannels, + groupMode == middleware.GroupChannelModeOwn, modelName, m, - preferChannelIDs, + preferChannelKeys, errorRates, - ignoreChannelIDs, + ignoreChannelKeyMap, ) if err != nil { return nil, err } return &initialChannel{ - channel: channel, - preferChannelIDs: preferChannelIDs, - ignoreChannelIDs: ignoreChannelIDs, - migratedChannels: migratedChannels, + channel: selectedChannel, + groupRetryOnly: groupMode == middleware.GroupChannelModeOwn, + preferChannelKeys: preferChannelKeys, + ignoreChannelIDs: ignoreChannelKeyMap, + migratedChannels: migratedChannels, }, nil } @@ -532,17 +1010,7 @@ func supportsCacheFollowMode(m mode.Mode) bool { } } -func getCacheFollowConfig(c *gin.Context) (cachefollow.Config, bool) { - v, ok := c.Get(middleware.ModelConfig) - if !ok { - return cachefollow.Config{}, false - } - - modelConfig, ok := v.(model.ModelConfig) - if !ok { - panic(fmt.Sprintf("model config type error: %T, %v", v, v)) - } - +func getCacheFollowConfig(modelConfig model.ModelConfig) (cachefollow.Config, bool) { pluginConfig := cachefollow.Config{} if err := modelConfig.LoadPluginConfig(cachefollow.PluginName, &pluginConfig); err != nil { return cachefollow.Config{}, false @@ -555,70 +1023,110 @@ func getCacheFollowConfig(c *gin.Context) (cachefollow.Config, bool) { return pluginConfig, true } -func getPreferChannelIDs(c *gin.Context, modelName string, m mode.Mode) []int { - pluginConfig, ok := getCacheFollowConfig(c) - if !supportsCacheFollowMode(m) || !ok { +func getPreferChannelKeys(c *gin.Context, modelName string, m mode.Mode) []string { + if !supportsCacheFollowMode(m) { return nil } group := middleware.GetGroup(c) token := middleware.GetToken(c) user := middleware.GetRequestUser(c) - preferChannelIDs := make([]int, 0, 6) - seen := make(map[int]struct{}, 6) + modelCaches := middleware.GetModelCaches(c) + groupMode := middleware.GetGroupChannelMode(c) + preferChannelKeys := make([]string, 0, 6) + seen := make(map[string]struct{}, 6) + + scopes := []model.ChannelScope{model.ChannelScopeGlobal} + if groupMode == middleware.GroupChannelModeOwn && group.ID != "" { + scopes = []model.ChannelScope{model.ChannelScopeGroup} + } - appendChannelID := func(storeID string) { + appendChannelKey := func(storeID string, scope model.ChannelScope) { if storeID == "" { return } - store, err := model.CacheGetStore(group.ID, token.ID, storeID) - if err != nil || store.ChannelID == 0 { + store, err := model.CacheGetStoreByScope(group.ID, token.ID, storeID, scope) + if err != nil { + return + } + + channelKey := model.StoreChannelKey(store, scope) + if channelKey == "" { return } - if _, ok := seen[store.ChannelID]; ok { + if _, ok := seen[channelKey]; ok { return } - seen[store.ChannelID] = struct{}{} - preferChannelIDs = append(preferChannelIDs, store.ChannelID) + seen[channelKey] = struct{}{} + preferChannelKeys = append(preferChannelKeys, channelKey) } - if supportsPromptCacheKeyMode(m) { - if promptCacheKey := middleware.GetPromptCacheKey(c); promptCacheKey != "" { - appendChannelID( - model.PromptCacheStoreID( - modelName, - promptCacheKey, - model.CacheKeyTypeStable, - ), + appendScopeKeys := func(scope model.ChannelScope) { + modelConfig, ok := resolveScopedModelConfig( + group, + modelCaches, + &scopedChannel{scope: scope}, + modelName, + ) + if !ok { + return + } + + pluginConfig, ok := getCacheFollowConfig(modelConfig) + if !ok { + return + } + + if supportsPromptCacheKeyMode(m) { + if promptCacheKey := middleware.GetPromptCacheKey(c); promptCacheKey != "" { + appendChannelKey( + model.PromptCacheStoreID( + modelName, + promptCacheKey, + model.CacheKeyTypeStable, + ), + scope, + ) + appendChannelKey( + model.PromptCacheStoreID( + modelName, + promptCacheKey, + model.CacheKeyTypeRecent, + ), + scope, + ) + } + } + + if user != "" { + appendChannelKey( + model.CacheFollowUserStoreID(modelName, user, model.CacheKeyTypeStable), + scope, ) - appendChannelID( - model.PromptCacheStoreID( - modelName, - promptCacheKey, - model.CacheKeyTypeRecent, - ), + appendChannelKey( + model.CacheFollowUserStoreID(modelName, user, model.CacheKeyTypeRecent), + scope, ) } - } - if user != "" { - appendChannelID(model.CacheFollowUserStoreID(modelName, user, model.CacheKeyTypeStable)) - appendChannelID(model.CacheFollowUserStoreID(modelName, user, model.CacheKeyTypeRecent)) + if pluginConfig.EnableGenericFollow { + appendChannelKey(model.CacheFollowStoreID(modelName, model.CacheKeyTypeStable), scope) + appendChannelKey(model.CacheFollowStoreID(modelName, model.CacheKeyTypeRecent), scope) + } } - if pluginConfig.EnableGenericFollow { - appendChannelID(model.CacheFollowStoreID(modelName, model.CacheKeyTypeStable)) - appendChannelID(model.CacheFollowStoreID(modelName, model.CacheKeyTypeRecent)) + for _, scope := range scopes { + appendScopeKeys(scope) } - if len(preferChannelIDs) == 0 { + if len(preferChannelKeys) == 0 { return nil } - return preferChannelIDs + return preferChannelKeys } func getWebSearchChannel( @@ -626,29 +1134,40 @@ func getWebSearchChannel( mc *model.ModelCaches, modelName string, ) (*model.Channel, error) { - ignoreChannelIDs, _ := monitor.GetBannedChannelsMapWithModel(ctx, modelName) - errorRates, _ := monitor.GetModelChannelErrorRate(ctx, modelName) + ignoreChannelKeyMap, _ := monitor.GetBannedChannelKeysMapWithModel(ctx, modelName) + errorRates, _ := monitor.GetModelChannelErrorRateByKey(ctx, modelName) - channel, _, err := getChannelWithFallback( + channel, _, err := getScopedChannelWithFallback( mc, nil, + nil, + false, modelName, mode.ChatCompletions, nil, errorRates, - ignoreChannelIDs) + ignoreChannelKeyMap) if err != nil { return nil, err } - return channel, nil + return channel.channel, nil } func getRetryChannel( ctx context.Context, state *retryState, -) (*model.Channel, error) { - errorRates, err := monitor.GetModelChannelErrorRate(ctx, state.meta.OriginModel) +) (*scopedChannel, error) { + retryScope := model.ChannelScopeGlobal + if state.groupRetryOnly { + retryScope = model.ChannelScopeGroup + } + + errorRates, err := getScopedModelChannelErrorRateByKey( + ctx, + retryScope, + state.meta.OriginModel, + ) if err != nil { if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { @@ -656,6 +1175,11 @@ func getRetryChannel( } } + candidates := state.migratedChannels + if state.groupRetryOnly { + candidates = filterScopedChannelsByScope(candidates, model.ChannelScopeGroup) + } + if state.exhausted { if state.lastMinErrorRateHasPermissionChannel == nil { return nil, ErrChannelsExhausted @@ -663,7 +1187,7 @@ func getRetryChannel( // Check if the lowest-error has-permission channel has high error rate. // If so, return exhausted to prevent retrying with a bad channel - channelID := int64(state.lastMinErrorRateHasPermissionChannel.ID) + channelID := state.lastMinErrorRateHasPermissionChannel.monitorKey() if errorRate := getChannelErrorRate(errorRates, channelID); errorRate > maxRetryErrorRate { return nil, ErrChannelsExhausted } @@ -672,17 +1196,17 @@ func getRetryChannel( } filteredChannels := filterChannels( - state.migratedChannels, + candidates, errorRates, maxRetryErrorRate, state.ignoreChannelIDs, state.failedChannelIDs, ) - if len(state.preferChannelIDs) > 0 { + if len(state.preferChannelKeys) > 0 { newChannel := pickPreferredChannel( filteredChannels, - state.preferChannelIDs, + state.preferChannelKeys, ) if newChannel != nil { return newChannel, nil @@ -701,7 +1225,7 @@ func getRetryChannel( // Check if the lowest-error has-permission channel has high error rate. // If so, return exhausted to prevent retrying with a bad channel - channelID := int64(state.lastMinErrorRateHasPermissionChannel.ID) + channelID := state.lastMinErrorRateHasPermissionChannel.monitorKey() if errorRate := getChannelErrorRate(errorRates, channelID); errorRate > maxRetryErrorRate { return nil, ErrChannelsExhausted } @@ -715,13 +1239,29 @@ func getRetryChannel( return newChannel, nil } +func filterScopedChannelsByScope( + channels []*scopedChannel, + scope model.ChannelScope, +) []*scopedChannel { + filtered := make([]*scopedChannel, 0, len(channels)) + for _, channel := range channels { + if channel == nil || channel.scope != scope { + continue + } + + filtered = append(filtered, channel) + } + + return filtered +} + func filterChannels( - channels []*model.Channel, - errorRates map[int64]float64, + channels []*scopedChannel, + errorRates map[string]float64, maxErrorRate float64, - ignoreChannel ...map[int64]struct{}, -) []*model.Channel { - filtered := make([]*model.Channel, 0) + ignoreChannel ...map[string]struct{}, +) []*scopedChannel { + filtered := make([]*scopedChannel, 0) for _, channel := range channels { if !isChannelSelectable(channel, errorRates, maxErrorRate, ignoreChannel...) { continue @@ -734,16 +1274,17 @@ func filterChannels( } func isChannelSelectable( - channel *model.Channel, - errorRates map[int64]float64, + channel *scopedChannel, + errorRates map[string]float64, maxErrorRate float64, - ignoreChannel ...map[int64]struct{}, + ignoreChannel ...map[string]struct{}, ) bool { - if channel == nil || channel.Status != model.ChannelStatusEnabled { + if channel == nil || channel.channel == nil || + channel.channel.Status != model.ChannelStatusEnabled { return false } - chid := int64(channel.ID) + chid := channel.monitorKey() if maxErrorRate != 0 { // Filter out channels with error rate higher than threshold diff --git a/core/controller/relay-channel_test.go b/core/controller/relay-channel_test.go index 32e78625..ddbd936c 100644 --- a/core/controller/relay-channel_test.go +++ b/core/controller/relay-channel_test.go @@ -3,19 +3,77 @@ package controller import ( "context" + "net/http" "net/http/httptest" "path/filepath" + "strconv" "testing" + "time" "github.com/gin-gonic/gin" "github.com/labring/aiproxy/core/middleware" "github.com/labring/aiproxy/core/model" + relaycontroller "github.com/labring/aiproxy/core/relay/controller" "github.com/labring/aiproxy/core/relay/meta" "github.com/labring/aiproxy/core/relay/mode" + relaymodel "github.com/labring/aiproxy/core/relay/model" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) +func getChannelWithFallback( + cache *model.ModelCaches, + preferChannelIDs []int, + errorRates map[int64]float64, + ignoreChannelIDs map[int64]struct{}, +) (*model.Channel, []*model.Channel, error) { + errorRateKeys := make(map[string]float64, len(errorRates)) + for id, rate := range errorRates { + errorRateKeys[strconv.FormatInt(id, 10)] = rate + } + + scoped, scopedChannels, err := getScopedChannelWithFallback( + cache, + []string{model.ChannelDefaultSet}, + nil, + false, + "gpt-5", + mode.Responses, + channelIDsToKeys(preferChannelIDs), + errorRateKeys, + int64SetToStringSet(ignoreChannelIDs), + ) + if err != nil { + return nil, nil, err + } + + channels := make([]*model.Channel, len(scopedChannels)) + for i, channel := range scopedChannels { + channels[i] = channel.channel + } + + return scoped.channel, channels, nil +} + +func getPreferChannelIDs(c *gin.Context) []int { + keys := getPreferChannelKeys(c, "gpt-5", mode.ChatCompletions) + if len(keys) == 0 { + return nil + } + + ids := make([]int, 0, len(keys)) + for _, key := range keys { + id, err := strconv.Atoi(key) + if err != nil { + continue + } + + ids = append(ids, id) + } + + return ids +} + func TestGetChannelWithFallbackPreferred(t *testing.T) { t.Parallel() @@ -49,9 +107,6 @@ func TestGetChannelWithFallbackPreferred(t *testing.T) { channel, migratedChannels, err := getChannelWithFallback( mc, - []string{model.ChannelDefaultSet}, - "gpt-5", - mode.Responses, []int{2}, map[int64]float64{}, nil, @@ -68,9 +123,6 @@ func TestGetChannelWithFallbackPreferred(t *testing.T) { channel, _, err := getChannelWithFallback( mc, - []string{model.ChannelDefaultSet}, - "gpt-5", - mode.Responses, []int{2, 1}, map[int64]float64{}, nil, @@ -86,9 +138,6 @@ func TestGetChannelWithFallbackPreferred(t *testing.T) { channel, _, err := getChannelWithFallback( mc, - []string{model.ChannelDefaultSet}, - "gpt-5", - mode.Responses, []int{2}, map[int64]float64{2: 0.9, 1: 0.1}, nil, @@ -104,9 +153,6 @@ func TestGetChannelWithFallbackPreferred(t *testing.T) { channel, _, err := getChannelWithFallback( mc, - []string{model.ChannelDefaultSet}, - "gpt-5", - mode.Responses, []int{2}, map[int64]float64{2: 0.9}, map[int64]struct{}{1: {}}, @@ -116,6 +162,598 @@ func TestGetChannelWithFallbackPreferred(t *testing.T) { }) } +func TestGetScopedChannelWithFallbackUsesScopedPreferredKey(t *testing.T) { + t.Parallel() + + require.NoError(t, model.CacheSetGroupScopeModelConfigs(&model.GroupScopeModelConfigsCache{ + GroupID: "group-1", + Models: []string{"gpt-5"}, + Configs: map[string]model.ModelConfig{ + "gpt-5": {Model: "gpt-5", Type: mode.Responses}, + }, + List: []model.GroupScopeModelConfig{ + { + GroupID: "group-1", + ModelConfig: model.ModelConfig{Model: "gpt-5", Type: mode.Responses}, + }, + }, + })) + t.Cleanup(func() { + require.NoError(t, model.CacheDeleteGroupScopeModelConfig("group-1")) + }) + + globalChannel := &model.Channel{ + ID: 22, + Type: model.ChannelTypeOpenAI, + Status: model.ChannelStatusEnabled, + Priority: 10, + } + groupChannel := &model.GroupChannel{ + ID: 22, + GroupID: "group-1", + Type: model.ChannelTypeOpenAI, + Status: model.ChannelStatusEnabled, + Models: []string{"gpt-5"}, + } + mc := &model.ModelCaches{ + EnabledModel2ChannelsBySet: map[string]map[string][]*model.Channel{ + model.ChannelDefaultSet: { + "gpt-5": {globalChannel}, + }, + }, + } + + selected, _, err := getScopedChannelWithFallback( + mc, + []string{model.ChannelDefaultSet}, + []*model.GroupChannel{groupChannel}, + false, + "gpt-5", + mode.Responses, + []string{model.GroupChannelMonitorKey("group-1", 22)}, + map[string]float64{}, + nil, + ) + require.NoError(t, err) + require.NotNil(t, selected) + require.Equal(t, model.ChannelScopeGroup, selected.scope) + require.Equal(t, 22, selected.channel.ID) +} + +func TestGetChannelFromHeaderUsesModelCacheChannel(t *testing.T) { + t.Parallel() + + channel := &model.Channel{ + ID: 901, + Type: model.ChannelTypeOpenAI, + Status: model.ChannelStatusEnabled, + Models: []string{"gpt-5"}, + } + mc := &model.ModelCaches{ + EnabledModel2ChannelsBySet: map[string]map[string][]*model.Channel{ + model.ChannelDefaultSet: { + "gpt-5": {channel}, + }, + }, + ModelConfig: testModelConfigCache{ + "gpt-5": {Model: "gpt-5", Type: mode.ChatCompletions}, + }, + } + + selected, err := GetChannelFromHeader("901", mc, "gpt-5", mode.ChatCompletions) + require.NoError(t, err) + require.Same(t, channel, selected) +} + +func TestGetChannelFromHeaderAllowsDisabledModelCacheChannel(t *testing.T) { + t.Parallel() + + channel := &model.Channel{ + ID: 902, + Type: model.ChannelTypeOpenAI, + Status: model.ChannelStatusDisabled, + Models: []string{"gpt-5"}, + } + mc := &model.ModelCaches{ + DisabledModel2ChannelsBySet: map[string]map[string][]*model.Channel{ + model.ChannelDefaultSet: { + "gpt-5": {channel}, + }, + }, + ModelConfig: testModelConfigCache{ + "gpt-5": {Model: "gpt-5", Type: mode.ChatCompletions}, + }, + } + + selected, err := GetChannelFromHeader("902", mc, "gpt-5", mode.ChatCompletions) + require.NoError(t, err) + require.Same(t, channel, selected) +} + +func TestGetScopedChannelFromRequestLoadsPinnedGroupChannel(t *testing.T) { + gin.SetMode(gin.TestMode) + + withTestStoreDB(t, func() { + groupID := "group-pinned" + require.NoError(t, model.DB.Create(&model.GroupChannel{ + ID: 22, + GroupID: groupID, + Type: model.ChannelTypeOpenAI, + Status: model.ChannelStatusEnabled, + Models: []string{"gpt-5"}, + Key: "group-key", + }).Error) + require.NoError(t, model.DB.Create(&model.GroupScopeModelConfig{ + GroupID: groupID, + ModelConfig: model.ModelConfig{ + Model: "gpt-5", + Type: mode.Responses, + }, + }).Error) + + globalChannel := &model.Channel{ + ID: 22, + Type: model.ChannelTypeGoogleGeminiOpenAI, + Status: model.ChannelStatusEnabled, + Models: []string{"gpt-5"}, + } + mc := &model.ModelCaches{ + EnabledModel2ChannelsBySet: map[string]map[string][]*model.Channel{ + model.ChannelDefaultSet: { + "gpt-5": {globalChannel}, + }, + }, + } + + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Set(middleware.Group, model.GroupCache{ID: groupID}) + c.Set(middleware.ChannelID, 22) + c.Set(middleware.ChannelScope, model.ChannelScopeGroup) + + selected, err := GetScopedChannelFromRequest( + c, + mc, + []string{model.ChannelDefaultSet}, + false, + "gpt-5", + mode.ResponsesGet, + ) + require.NoError(t, err) + require.NotNil(t, selected) + require.Equal(t, model.ChannelScopeGroup, selected.scope) + require.Equal(t, groupID, selected.groupID) + require.Equal(t, model.ChannelTypeOpenAI, selected.channel.Type) + }) +} + +func TestGetScopedChannelFromRequestAllowsPinnedGroupChannelModelMapping(t *testing.T) { + gin.SetMode(gin.TestMode) + + withTestStoreDB(t, func() { + groupID := "group-mapping" + require.NoError(t, model.DB.Create(&model.GroupChannel{ + ID: 23, + GroupID: groupID, + Type: model.ChannelTypeOpenAI, + Status: model.ChannelStatusEnabled, + Models: []string{"provider-model"}, + ModelMapping: map[string]string{"public-model": "provider-model"}, + Key: "group-key", + }).Error) + require.NoError(t, model.DB.Create(&model.GroupScopeModelConfig{ + GroupID: groupID, + ModelConfig: model.ModelConfig{ + Model: "public-model", + Type: mode.Responses, + }, + }).Error) + + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Set(middleware.Group, model.GroupCache{ID: groupID}) + c.Set(middleware.ChannelID, 23) + c.Set(middleware.ChannelScope, model.ChannelScopeGroup) + + selected, err := GetScopedChannelFromRequest( + c, + &model.ModelCaches{}, + []string{model.ChannelDefaultSet}, + false, + "public-model", + mode.ResponsesGet, + ) + require.NoError(t, err) + require.NotNil(t, selected) + require.Equal(t, model.ChannelScopeGroup, selected.scope) + require.Equal(t, groupID, selected.groupID) + require.Equal(t, "provider-model", selected.channel.ModelMapping["public-model"]) + }) +} + +func TestGetScopedChannelFromRequestAllowsInternalGroupChannelOutsideAvailableSet(t *testing.T) { + gin.SetMode(gin.TestMode) + + withTestStoreDB(t, func() { + groupID := "internal-group-stored-pin" + require.NoError(t, model.DB.Create(&model.GroupChannel{ + ID: 24, + GroupID: groupID, + Type: model.ChannelTypeOpenAI, + Status: model.ChannelStatusEnabled, + Models: []string{"gpt-5"}, + Sets: []string{"private-set"}, + Key: "group-key", + }).Error) + require.NoError(t, model.DB.Create(&model.GroupScopeModelConfig{ + GroupID: groupID, + ModelConfig: model.ModelConfig{ + Model: "gpt-5", + Type: mode.Responses, + }, + }).Error) + + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Set(middleware.Group, model.GroupCache{ + ID: groupID, + Status: model.GroupStatusInternal, + }) + c.Set(middleware.ChannelID, 24) + c.Set(middleware.ChannelScope, model.ChannelScopeGroup) + + selected, err := GetScopedChannelFromRequest( + c, + &model.ModelCaches{}, + []string{model.ChannelDefaultSet}, + true, + "gpt-5", + mode.ResponsesGet, + ) + require.NoError(t, err) + require.NotNil(t, selected) + require.Equal(t, model.ChannelScopeGroup, selected.scope) + require.Equal(t, groupID, selected.groupID) + require.Equal(t, 24, selected.channel.ID) + }) +} + +func TestGetScopedChannelFromRequestRejectsGroupChannelWhenUserSetIntersectionEmpty(t *testing.T) { + gin.SetMode(gin.TestMode) + + withTestStoreDB(t, func() { + groupID := "user-group-empty-set-pin" + require.NoError(t, model.DB.Create(&model.GroupChannel{ + ID: 25, + GroupID: groupID, + Type: model.ChannelTypeOpenAI, + Status: model.ChannelStatusEnabled, + Models: []string{"gpt-5"}, + Sets: []string{"private-set"}, + Key: "group-key", + }).Error) + require.NoError(t, model.DB.Create(&model.GroupScopeModelConfig{ + GroupID: groupID, + ModelConfig: model.ModelConfig{ + Model: "gpt-5", + Type: mode.Responses, + }, + }).Error) + + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Set(middleware.Group, model.GroupCache{ + ID: groupID, + Status: model.GroupStatusEnabled, + }) + c.Set(middleware.ChannelID, 25) + c.Set(middleware.ChannelScope, model.ChannelScopeGroup) + + selected, err := GetScopedChannelFromRequest( + c, + &model.ModelCaches{}, + []string{}, + false, + "gpt-5", + mode.ResponsesGet, + ) + require.Error(t, err) + require.Nil(t, selected) + require.Contains(t, err.Error(), "pinned group channel 25 not supported") + }) +} + +func TestGetInitialChannelAllowsInternalGroupChannelMode(t *testing.T) { + gin.SetMode(gin.TestMode) + + groupID := "internal-initial-group-channel" + require.NoError(t, model.CacheSetGroupScopeModelConfigs(&model.GroupScopeModelConfigsCache{ + GroupID: groupID, + Models: []string{"gpt-5"}, + Configs: map[string]model.ModelConfig{ + "gpt-5": {Model: "gpt-5", Type: mode.Responses}, + }, + List: []model.GroupScopeModelConfig{ + { + GroupID: groupID, + ModelConfig: model.ModelConfig{Model: "gpt-5", Type: mode.Responses}, + }, + }, + })) + require.NoError(t, model.CacheSetGroupChannels(&model.GroupChannelsCache{ + GroupID: groupID, + Channels: []*model.GroupChannel{ + { + ID: 31, + GroupID: groupID, + Type: model.ChannelTypeOpenAI, + Status: model.ChannelStatusEnabled, + Models: []string{"gpt-5"}, + Sets: []string{model.ChannelDefaultSet}, + Priority: 1, + }, + }, + })) + t.Cleanup(func() { + require.NoError(t, model.CacheDeleteGroupScopeModelConfig(groupID)) + require.NoError(t, model.CacheDeleteGroupChannels(groupID)) + }) + + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/v1/responses", nil) + c.Set(middleware.Group, model.GroupCache{ + ID: groupID, + Status: model.GroupStatusInternal, + AvailableSets: []string{model.ChannelDefaultSet}, + }) + c.Set(middleware.Token, model.TokenCache{}) + c.Set(middleware.AvailableSets, []string{model.ChannelDefaultSet}) + c.Set(middleware.ModelCaches, &model.ModelCaches{ + EnabledModel2ChannelsBySet: map[string]map[string][]*model.Channel{}, + }) + c.Set(middleware.GroupChannelMode, middleware.GroupChannelModeOwn) + + initial, err := getInitialChannel(c, "gpt-5", mode.Responses) + require.NoError(t, err) + require.NotNil(t, initial) + require.NotNil(t, initial.channel) + require.Equal(t, model.ChannelScopeGroup, initial.channel.scope) + require.Equal(t, groupID, initial.channel.groupID) + require.Equal(t, 31, initial.channel.channel.ID) + require.True(t, initial.groupRetryOnly) +} + +func TestGetInitialChannelUsesGroupPinnedHeaderInOwnMode(t *testing.T) { + gin.SetMode(gin.TestMode) + + groupID := "internal-pinned-group-channel" + require.NoError(t, model.CacheSetGroupScopeModelConfigs(&model.GroupScopeModelConfigsCache{ + GroupID: groupID, + Models: []string{"gpt-5"}, + Configs: map[string]model.ModelConfig{ + "gpt-5": {Model: "gpt-5", Type: mode.Responses}, + }, + List: []model.GroupScopeModelConfig{ + { + GroupID: groupID, + ModelConfig: model.ModelConfig{Model: "gpt-5", Type: mode.Responses}, + }, + }, + })) + t.Cleanup(func() { + require.NoError(t, model.CacheDeleteGroupScopeModelConfig(groupID)) + }) + + withTestStoreDB(t, func() { + require.NoError(t, model.DB.Create(&model.GroupChannel{ + ID: 41, + GroupID: groupID, + Type: model.ChannelTypeOpenAI, + Status: model.ChannelStatusEnabled, + Models: []string{"gpt-5"}, + Sets: []string{model.ChannelDefaultSet}, + Key: "group-key", + }).Error) + + globalChannel := &model.Channel{ + ID: 41, + Type: model.ChannelTypeAnthropic, + Status: model.ChannelStatusEnabled, + Models: []string{"gpt-5"}, + } + + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequestWithContext( + t.Context(), + http.MethodPost, + "/v1/responses", + nil, + ) + c.Request.Header.Set(AIProxyChannelHeader, "41") + c.Set(middleware.Group, model.GroupCache{ + ID: groupID, + Status: model.GroupStatusInternal, + AvailableSets: []string{model.ChannelDefaultSet}, + }) + c.Set(middleware.Token, model.TokenCache{}) + c.Set(middleware.AvailableSets, []string{model.ChannelDefaultSet}) + c.Set(middleware.ModelCaches, &model.ModelCaches{ + EnabledModel2ChannelsBySet: map[string]map[string][]*model.Channel{ + model.ChannelDefaultSet: { + "gpt-5": {globalChannel}, + }, + }, + DisabledModel2ChannelsBySet: map[string]map[string][]*model.Channel{}, + }) + c.Set(middleware.GroupChannelMode, middleware.GroupChannelModeOwn) + + initial, err := getInitialChannel(c, "gpt-5", mode.Responses) + require.NoError(t, err) + require.NotNil(t, initial) + require.NotNil(t, initial.channel) + require.True(t, initial.designatedChannel) + require.True(t, initial.groupRetryOnly) + require.Equal(t, model.ChannelScopeGroup, initial.channel.scope) + require.Equal(t, groupID, initial.channel.groupID) + require.Equal(t, model.ChannelTypeOpenAI, initial.channel.channel.Type) + }) +} + +func TestGetInitialChannelGroupPinnedHeaderHonorsUserSets(t *testing.T) { + gin.SetMode(gin.TestMode) + + groupID := "user-pinned-group-channel-sets" + require.NoError(t, model.CacheSetGroupScopeModelConfigs(&model.GroupScopeModelConfigsCache{ + GroupID: groupID, + Models: []string{"gpt-5"}, + Configs: map[string]model.ModelConfig{ + "gpt-5": {Model: "gpt-5", Type: mode.Responses}, + }, + List: []model.GroupScopeModelConfig{ + { + GroupID: groupID, + ModelConfig: model.ModelConfig{Model: "gpt-5", Type: mode.Responses}, + }, + }, + })) + t.Cleanup(func() { + require.NoError(t, model.CacheDeleteGroupScopeModelConfig(groupID)) + }) + + withTestStoreDB(t, func() { + require.NoError(t, model.DB.Create(&model.GroupChannel{ + ID: 43, + GroupID: groupID, + Type: model.ChannelTypeOpenAI, + Status: model.ChannelStatusEnabled, + Models: []string{"gpt-5"}, + Sets: []string{"beta"}, + Key: "group-key", + }).Error) + + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequestWithContext( + t.Context(), + http.MethodPost, + "/v1/responses", + nil, + ) + c.Request.Header.Set(AIProxyChannelHeader, "43") + c.Set(middleware.Group, model.GroupCache{ + ID: groupID, + Status: model.GroupStatusEnabled, + AvailableSets: []string{model.ChannelDefaultSet, "beta"}, + }) + c.Set(middleware.Token, model.TokenCache{}) + c.Set(middleware.AvailableSets, []string{model.ChannelDefaultSet}) + c.Set(middleware.ModelCaches, &model.ModelCaches{}) + c.Set(middleware.GroupChannelMode, middleware.GroupChannelModeOwn) + + initial, err := getInitialChannel(c, "gpt-5", mode.Responses) + require.Error(t, err) + require.Nil(t, initial) + require.Contains(t, err.Error(), "group channel 43 not supported") + }) +} + +func TestGetInitialChannelGroupPinnedHeaderInternalIgnoresSets(t *testing.T) { + gin.SetMode(gin.TestMode) + + groupID := "internal-pinned-group-channel-sets" + require.NoError(t, model.CacheSetGroupScopeModelConfigs(&model.GroupScopeModelConfigsCache{ + GroupID: groupID, + Models: []string{"gpt-5"}, + Configs: map[string]model.ModelConfig{ + "gpt-5": {Model: "gpt-5", Type: mode.Responses}, + }, + List: []model.GroupScopeModelConfig{ + { + GroupID: groupID, + ModelConfig: model.ModelConfig{Model: "gpt-5", Type: mode.Responses}, + }, + }, + })) + t.Cleanup(func() { + require.NoError(t, model.CacheDeleteGroupScopeModelConfig(groupID)) + }) + + withTestStoreDB(t, func() { + require.NoError(t, model.DB.Create(&model.GroupChannel{ + ID: 44, + GroupID: groupID, + Type: model.ChannelTypeOpenAI, + Status: model.ChannelStatusEnabled, + Models: []string{"gpt-5"}, + Sets: []string{"beta"}, + Key: "group-key", + }).Error) + + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequestWithContext( + t.Context(), + http.MethodPost, + "/v1/responses", + nil, + ) + c.Request.Header.Set(AIProxyChannelHeader, "44") + c.Set(middleware.Group, model.GroupCache{ + ID: groupID, + Status: model.GroupStatusInternal, + AvailableSets: []string{model.ChannelDefaultSet}, + }) + c.Set(middleware.Token, model.TokenCache{}) + c.Set(middleware.AvailableSets, []string{model.ChannelDefaultSet}) + c.Set(middleware.ModelCaches, &model.ModelCaches{}) + c.Set(middleware.GroupChannelMode, middleware.GroupChannelModeOwn) + + initial, err := getInitialChannel(c, "gpt-5", mode.Responses) + require.NoError(t, err) + require.NotNil(t, initial) + require.Equal(t, 44, initial.channel.channel.ID) + require.Equal(t, model.ChannelScopeGroup, initial.channel.scope) + }) +} + +func TestGetChannelFromRequestRejectsGlobalChannelWhenUserSetIntersectionEmpty(t *testing.T) { + gin.SetMode(gin.TestMode) + + channel := &model.Channel{ + ID: 903, + Type: model.ChannelTypeOpenAI, + Status: model.ChannelStatusEnabled, + Models: []string{"gpt-5"}, + } + mc := &model.ModelCaches{ + EnabledModel2ChannelsBySet: map[string]map[string][]*model.Channel{ + "private-set": { + "gpt-5": {channel}, + }, + }, + ModelConfig: testModelConfigCache{ + "gpt-5": {Model: "gpt-5", Type: mode.ChatCompletions}, + }, + } + + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Set(middleware.ChannelID, 903) + + selected, err := GetChannelFromRequest( + c, + mc, + []string{}, + "gpt-5", + mode.ChatCompletions, + ) + require.Error(t, err) + require.Nil(t, selected) + require.Contains(t, err.Error(), "pinned channel 903 not found") +} + func TestGetRetryChannelPrefersPreferredChannels(t *testing.T) { t.Parallel() @@ -134,15 +772,18 @@ func TestGetRetryChannelPrefersPreferredChannels(t *testing.T) { } return &retryState{ - preferChannelIDs: []int{2}, + preferChannelKeys: []string{"2"}, meta: meta.NewMeta( ch1, mode.Responses, "gpt-5", model.ModelConfig{}, ), - migratedChannels: []*model.Channel{ch1, ch2}, - failedChannelIDs: map[int64]struct{}{}, + migratedChannels: []*scopedChannel{ + newGlobalScopedChannel(ch1), + newGlobalScopedChannel(ch2), + }, + failedChannelIDs: map[string]struct{}{}, } } @@ -153,7 +794,7 @@ func TestGetRetryChannelPrefersPreferredChannels(t *testing.T) { channel, err := getRetryChannel(context.Background(), state) require.NoError(t, err) - assert.Equal(t, 2, channel.ID) + assert.Equal(t, 2, channel.channel.ID) }) t.Run("retry skips preferred channel after it failed", func(t *testing.T) { @@ -161,10 +802,10 @@ func TestGetRetryChannelPrefersPreferredChannels(t *testing.T) { state := newRetryState() - state.failedChannelIDs = map[int64]struct{}{2: {}} + state.failedChannelIDs = map[string]struct{}{"2": {}} channel, err := getRetryChannel(context.Background(), state) require.NoError(t, err) - assert.Equal(t, 1, channel.ID) + assert.Equal(t, 1, channel.channel.ID) }) t.Run( @@ -174,11 +815,11 @@ func TestGetRetryChannelPrefersPreferredChannels(t *testing.T) { state := newRetryState() - state.preferChannelIDs = nil - state.failedChannelIDs = map[int64]struct{}{1: {}, 2: {}} + state.preferChannelKeys = nil + state.failedChannelIDs = map[string]struct{}{"1": {}, "2": {}} state.ignoreChannelIDs = nil state.meta = meta.NewMeta( - state.migratedChannels[0], + state.migratedChannels[0].channel, mode.Responses, "gpt-5", model.ModelConfig{}, @@ -194,8 +835,8 @@ func TestGetRetryChannelPrefersPreferredChannels(t *testing.T) { func TestPickMinErrorRateHasPermissionChannel(t *testing.T) { t.Parallel() - current := &model.Channel{ID: 1} - candidate := &model.Channel{ID: 2} + current := newGlobalScopedChannel(&model.Channel{ID: 1}) + candidate := newGlobalScopedChannel(&model.Channel{ID: 2}) t.Run("returns candidate when current is nil", func(t *testing.T) { t.Parallel() @@ -207,7 +848,7 @@ func TestPickMinErrorRateHasPermissionChannel(t *testing.T) { 0.2, ) require.NotNil(t, picked) - assert.Equal(t, 2, picked.ID) + assert.Equal(t, 2, picked.channel.ID) }) t.Run("keeps current when candidate error rate is higher", func(t *testing.T) { @@ -220,7 +861,7 @@ func TestPickMinErrorRateHasPermissionChannel(t *testing.T) { 0.3, ) require.NotNil(t, picked) - assert.Equal(t, 1, picked.ID) + assert.Equal(t, 1, picked.channel.ID) }) t.Run("switches to candidate when candidate error rate is lower", func(t *testing.T) { @@ -233,7 +874,7 @@ func TestPickMinErrorRateHasPermissionChannel(t *testing.T) { 0.2, ) require.NotNil(t, picked) - assert.Equal(t, 2, picked.ID) + assert.Equal(t, 2, picked.channel.ID) }) } @@ -260,23 +901,83 @@ func TestGetRetryChannelFallsBackToLowestErrorRateHasPermissionChannel(t *testin "gpt-5", model.ModelConfig{}, ), - migratedChannels: []*model.Channel{ch1, ch2}, - failedChannelIDs: map[int64]struct{}{}, - ignoreChannelIDs: map[int64]struct{}{1: {}, 2: {}}, - lastMinErrorRateHasPermissionChannel: ch2, + migratedChannels: []*scopedChannel{ + newGlobalScopedChannel(ch1), + newGlobalScopedChannel(ch2), + }, + failedChannelIDs: map[string]struct{}{}, + ignoreChannelIDs: map[string]struct{}{"1": {}, "2": {}}, + lastMinErrorRateHasPermissionChannel: newGlobalScopedChannel(ch2), } channel, err := getRetryChannel(context.Background(), state) require.NoError(t, err) require.NotNil(t, channel) - assert.Equal(t, 2, channel.ID) + assert.Equal(t, 2, channel.channel.ID) assert.True(t, state.exhausted) } +func TestInitRetryStateKeepsGroupOnlyRetryScope(t *testing.T) { + t.Parallel() + + globalChannel := newGlobalScopedChannel(&model.Channel{ + ID: 1, + Type: model.ChannelTypeOpenAI, + Status: model.ChannelStatusEnabled, + Priority: 10, + }) + groupChannel := newGroupScopedChannel("group-1", &model.Channel{ + ID: 2, + Type: model.ChannelTypeOpenAI, + Status: model.ChannelStatusEnabled, + Priority: 10, + }) + requestMeta := meta.NewMeta( + groupChannel.channel, + mode.Responses, + "gpt-5", + model.ModelConfig{}, + ) + requestMeta.Channel.Scope = model.ChannelScopeGroup + requestMeta.Channel.GroupID = "group-1" + + state := initRetryState( + 1, + &initialChannel{ + channel: groupChannel, + groupRetryOnly: true, + migratedChannels: []*scopedChannel{ + groupChannel, + globalChannel, + }, + }, + requestMeta, + &relaycontroller.HandleResult{ + Error: relaymodel.WrapperOpenAIErrorWithMessage( + "upstream error", + "upstream_error", + http.StatusInternalServerError, + ), + }, + model.Price{}, + time.Now(), + &model.ModelCaches{}, + "gpt-5", + RelayController{}, + true, + ) + + channel, err := getRetryChannel(context.Background(), state) + require.NoError(t, err) + require.NotNil(t, channel) + assert.Equal(t, model.ChannelScopeGroup, channel.scope) + assert.Equal(t, 2, channel.channel.ID) +} + func TestGetPriorityWeight(t *testing.T) { t.Parallel() - channel := &model.Channel{Priority: 10} + channel := newGlobalScopedChannel(&model.Channel{Priority: 10}) t.Run("applies stronger than linear penalty for higher error rates", func(t *testing.T) { t.Parallel() @@ -304,9 +1005,9 @@ func TestGetPriorityWeight(t *testing.T) { func TestGetPriorityWeightHandlesNilErrorRatesMap(t *testing.T) { t.Parallel() - channel := &model.Channel{Priority: 10} + channel := newGlobalScopedChannel(&model.Channel{Priority: 10}) - assert.InDelta(t, 1000.0, getPriorityWeight(channel, getChannelErrorRate(nil, 123)), 0.0001) + assert.InDelta(t, 1000.0, getPriorityWeight(channel, getChannelErrorRate(nil, "123")), 0.0001) } func TestGetChannelWithFallbackHandlesNilInputs(t *testing.T) { @@ -329,9 +1030,6 @@ func TestGetChannelWithFallbackHandlesNilInputs(t *testing.T) { channel, migratedChannels, err := getChannelWithFallback( mc, - []string{model.ChannelDefaultSet}, - "gpt-5", - mode.Responses, nil, nil, nil, @@ -353,22 +1051,22 @@ func TestGetRetryChannelHandlesNilInputs(t *testing.T) { } state := &retryState{ - preferChannelIDs: nil, - ignoreChannelIDs: nil, + preferChannelKeys: nil, + ignoreChannelIDs: nil, meta: meta.NewMeta( ch1, mode.Responses, "gpt-5", model.ModelConfig{}, ), - migratedChannels: []*model.Channel{ch1}, - failedChannelIDs: map[int64]struct{}{}, + migratedChannels: []*scopedChannel{newGlobalScopedChannel(ch1)}, + failedChannelIDs: map[string]struct{}{}, } channel, err := getRetryChannel(context.Background(), state) require.NoError(t, err) require.NotNil(t, channel) - assert.Equal(t, 1, channel.ID) + assert.Equal(t, 1, channel.channel.ID) } func TestGetPreferChannelIDs(t *testing.T) { @@ -398,14 +1096,14 @@ func TestGetPreferChannelIDs(t *testing.T) { c.Set(middleware.Group, model.GroupCache{ID: "group-1"}) c.Set(middleware.Token, model.TokenCache{ID: 7}) c.Set(middleware.PromptCacheKey, "cache-key") - c.Set(middleware.ModelConfig, model.ModelConfig{ + setTestCacheFollowModelConfig(c, model.ModelConfig{ Model: "gpt-5", Plugin: map[string]map[string]any{ "cachefollow": {"enable": true}, }, }) - assert.Equal(t, []int{11}, getPreferChannelIDs(c, "gpt-5", mode.ChatCompletions)) + assert.Equal(t, []int{11}, getPreferChannelIDs(c)) }) } @@ -436,14 +1134,14 @@ func TestGetPreferChannelIDsDeduplicatesPromptCacheAndCacheFollow(t *testing.T) c.Set(middleware.Group, model.GroupCache{ID: "group-1"}) c.Set(middleware.Token, model.TokenCache{ID: 7}) c.Set(middleware.PromptCacheKey, "cache-key") - c.Set(middleware.ModelConfig, model.ModelConfig{ + setTestCacheFollowModelConfig(c, model.ModelConfig{ Model: "gpt-5", Plugin: map[string]map[string]any{ "cachefollow": {"enable": true}, }, }) - assert.Equal(t, []int{11}, getPreferChannelIDs(c, "gpt-5", mode.ChatCompletions)) + assert.Equal(t, []int{11}, getPreferChannelIDs(c)) }) } @@ -475,14 +1173,14 @@ func TestGetPreferChannelIDsFallsBackToUserWhenPromptCacheKeyExists(t *testing.T c.Set(middleware.Token, model.TokenCache{ID: 7}) c.Set(middleware.PromptCacheKey, "missing-cache-key") c.Set(middleware.RequestUser, "user-1") - c.Set(middleware.ModelConfig, model.ModelConfig{ + setTestCacheFollowModelConfig(c, model.ModelConfig{ Model: "gpt-5", Plugin: map[string]map[string]any{ "cachefollow": {"enable": true}, }, }) - assert.Equal(t, []int{33}, getPreferChannelIDs(c, "gpt-5", mode.ChatCompletions)) + assert.Equal(t, []int{33}, getPreferChannelIDs(c)) }) } @@ -503,9 +1201,9 @@ func TestGetPreferChannelIDsDisabledByDefault(t *testing.T) { c, _ := gin.CreateTestContext(recorder) c.Set(middleware.Group, model.GroupCache{ID: "group-1"}) c.Set(middleware.Token, model.TokenCache{ID: 7}) - c.Set(middleware.ModelConfig, model.ModelConfig{Model: "gpt-5"}) + setTestCacheFollowModelConfig(c, model.ModelConfig{Model: "gpt-5"}) - assert.Nil(t, getPreferChannelIDs(c, "gpt-5", mode.ChatCompletions)) + assert.Nil(t, getPreferChannelIDs(c)) }) } @@ -535,14 +1233,14 @@ func TestGetPreferChannelIDsReadsStableBeforeRecent(t *testing.T) { c, _ := gin.CreateTestContext(recorder) c.Set(middleware.Group, model.GroupCache{ID: "group-1"}) c.Set(middleware.Token, model.TokenCache{ID: 7}) - c.Set(middleware.ModelConfig, model.ModelConfig{ + setTestCacheFollowModelConfig(c, model.ModelConfig{ Model: "gpt-5", Plugin: map[string]map[string]any{ "cachefollow": {"enable": true, "enable_generic_follow": true}, }, }) - assert.Equal(t, []int{11, 22}, getPreferChannelIDs(c, "gpt-5", mode.ChatCompletions)) + assert.Equal(t, []int{11, 22}, getPreferChannelIDs(c)) }) } @@ -605,7 +1303,7 @@ func TestGetPreferChannelIDsReadsPromptThenUserThenGeneric(t *testing.T) { c.Set(middleware.Token, model.TokenCache{ID: 7}) c.Set(middleware.PromptCacheKey, "cache-key") c.Set(middleware.RequestUser, "user-1") - c.Set(middleware.ModelConfig, model.ModelConfig{ + setTestCacheFollowModelConfig(c, model.ModelConfig{ Model: "gpt-5", Plugin: map[string]map[string]any{ "cachefollow": {"enable": true, "enable_generic_follow": true}, @@ -615,7 +1313,7 @@ func TestGetPreferChannelIDsReadsPromptThenUserThenGeneric(t *testing.T) { assert.Equal( t, []int{11, 12, 21, 22, 31, 32}, - getPreferChannelIDs(c, "gpt-5", mode.ChatCompletions), + getPreferChannelIDs(c), ) }) } @@ -646,14 +1344,95 @@ func TestGetPreferChannelIDsReadsGenericOnlyWhenExplicitlyEnabled(t *testing.T) c, _ := gin.CreateTestContext(recorder) c.Set(middleware.Group, model.GroupCache{ID: "group-1"}) c.Set(middleware.Token, model.TokenCache{ID: 7}) - c.Set(middleware.ModelConfig, model.ModelConfig{ + setTestCacheFollowModelConfig(c, model.ModelConfig{ Model: "gpt-5", Plugin: map[string]map[string]any{ "cachefollow": {"enable": true, "enable_generic_follow": true}, }, }) - assert.Equal(t, []int{31, 32}, getPreferChannelIDs(c, "gpt-5", mode.ChatCompletions)) + assert.Equal(t, []int{31, 32}, getPreferChannelIDs(c)) + }) +} + +func TestGetPreferChannelKeysSkipsGroupScopeModelConfigPlugins(t *testing.T) { + gin.SetMode(gin.TestMode) + + withTestStoreDB(t, func() { + modelName := "gpt-5-scoped-pref" + groupChannelKey := model.GroupChannelMonitorKey("group-1", 22) + _, err := model.SaveStoreByScope(&model.StoreV2{ + ID: model.CacheFollowStoreID(modelName, model.CacheKeyTypeStable), + GroupID: "group-1", + TokenID: 7, + ChannelID: 22, + Model: modelName, + }, model.ChannelScopeGroup) + require.NoError(t, err) + + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Set(middleware.Group, model.GroupCache{ID: "group-1"}) + c.Set(middleware.Token, model.TokenCache{ID: 7}) + setTestCacheFollowModelConfig(c, model.ModelConfig{ + Model: modelName, + Plugin: map[string]map[string]any{ + "cachefollow": {"enable": true, "enable_generic_follow": true}, + }, + }) + + require.NotEmpty(t, groupChannelKey) + require.Nil(t, getPreferChannelKeys(c, modelName, mode.ChatCompletions)) + }) +} + +func TestGetPreferChannelKeysDefaultsToGlobalScopedStores(t *testing.T) { + gin.SetMode(gin.TestMode) + + withTestStoreDB(t, func() { + modelName := "gpt-5-global-scoped-pref" + storeID := model.CacheFollowStoreID(modelName, model.CacheKeyTypeStable) + + _, err := model.SaveStore(&model.StoreV2{ + ID: storeID, + GroupID: "group-1", + TokenID: 7, + ChannelID: 11, + Model: modelName, + }) + require.NoError(t, err) + + _, err = model.SaveStoreByScope(&model.StoreV2{ + ID: storeID, + GroupID: "group-1", + TokenID: 7, + ChannelID: 22, + Model: modelName, + }, model.ChannelScopeGroup) + require.NoError(t, err) + + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Set(middleware.Group, model.GroupCache{ID: "group-1"}) + c.Set(middleware.Token, model.TokenCache{ID: 7}) + setTestCacheFollowModelConfig(c, model.ModelConfig{ + Model: modelName, + Plugin: map[string]map[string]any{ + "cachefollow": {"enable": true, "enable_generic_follow": true}, + }, + }) + + require.NotEmpty(t, model.GroupChannelMonitorKey("group-1", 22)) + require.Equal(t, []string{"11"}, getPreferChannelKeys(c, modelName, mode.ChatCompletions)) + }) +} + +func setTestCacheFollowModelConfig(c *gin.Context, modelConfig model.ModelConfig) { + c.Set(middleware.ModelConfig, modelConfig) + c.Set(middleware.ModelCaches, &model.ModelCaches{ + ModelConfig: testModelConfigCache{ + modelConfig.Model: modelConfig, + }, }) } @@ -665,7 +1444,12 @@ func withTestStoreDB(t *testing.T, fn func()) { db, err := model.OpenSQLite(filepath.Join(t.TempDir(), "relay_channel_store_test.db")) require.NoError(t, err) - require.NoError(t, db.AutoMigrate(&model.StoreV2{})) + require.NoError(t, db.AutoMigrate( + &model.StoreV2{}, + &model.GroupChannelStoreV2{}, + &model.GroupChannel{}, + &model.GroupScopeModelConfig{}, + )) model.LogDB = db model.DB = db diff --git a/core/controller/relay-controller.go b/core/controller/relay-controller.go index dad01cd0..55c360b1 100644 --- a/core/controller/relay-controller.go +++ b/core/controller/relay-controller.go @@ -20,7 +20,6 @@ import ( "github.com/labring/aiproxy/core/common/conv" "github.com/labring/aiproxy/core/middleware" "github.com/labring/aiproxy/core/model" - "github.com/labring/aiproxy/core/monitor" "github.com/labring/aiproxy/core/relay/adaptor" "github.com/labring/aiproxy/core/relay/adaptors" "github.com/labring/aiproxy/core/relay/controller" @@ -48,6 +47,38 @@ type ( ValidateRequest func(*gin.Context, model.ModelConfig) error ) +var ( + errRelayModeMismatch = errors.New("relay mode mismatch") + errModelConfigNotFound = errors.New("model config not found") +) + +type relayModeMismatchError struct { + modelName string +} + +func (e relayModeMismatchError) Error() string { + return fmt.Sprintf("The model `%s` does not exist on this endpoint.", e.modelName) +} + +func (e relayModeMismatchError) Is(target error) bool { + return target == errRelayModeMismatch +} + +type modelConfigNotFoundError struct { + modelName string +} + +func (e modelConfigNotFoundError) Error() string { + return fmt.Sprintf( + "The model `%s` does not exist or you do not have access to it.", + e.modelName, + ) +} + +func (e modelConfigNotFoundError) Is(target error) bool { + return target == errModelConfigNotFound +} + type RelayController struct { GetRequestUsage GetRequestUsage GetRequestPrice GetRequestPrice @@ -59,8 +90,13 @@ var AdaptorStore adaptor.Store = &storeImpl{} type storeImpl struct{} -func (s *storeImpl) GetStore(group string, tokenID int, id string) (adaptor.StoreCache, error) { - store, err := model.CacheGetStore(group, tokenID, id) +func (s *storeImpl) GetStoreByScope( + group string, + tokenID int, + id string, + scope model.ChannelScope, +) (adaptor.StoreCache, error) { + store, err := model.CacheGetStoreByScope(group, tokenID, id, scope) if err != nil { return adaptor.StoreCache{}, err } @@ -78,15 +114,16 @@ func (s *storeImpl) GetStore(group string, tokenID int, id string) (adaptor.Stor }, nil } -func (s *storeImpl) SaveStore(store adaptor.StoreCache) error { - return s.SaveStoreWithOption(store, adaptor.SaveStoreOption{}) +func (s *storeImpl) SaveStore(store adaptor.StoreCache, scope model.ChannelScope) error { + return s.SaveStoreWithOption(store, scope, adaptor.SaveStoreOption{}) } func (s *storeImpl) SaveStoreWithOption( store adaptor.StoreCache, + scope model.ChannelScope, opt adaptor.SaveStoreOption, ) error { - _, err := model.SaveStoreWithOption(&model.StoreV2{ + _, err := model.SaveStoreWithOptionByScope(&model.StoreV2{ ID: store.ID, GroupID: store.GroupID, TokenID: store.TokenID, @@ -96,15 +133,18 @@ func (s *storeImpl) SaveStoreWithOption( CreatedAt: store.CreatedAt, UpdatedAt: store.UpdatedAt, ExpiresAt: store.ExpiresAt, - }, model.SaveStoreOption{ + }, scope, model.SaveStoreOption{ MinUpdateInterval: opt.MinUpdateInterval, }) return err } -func (s *storeImpl) SaveIfNotExistStore(store adaptor.StoreCache) error { - _, err := model.SaveIfNotExistStore(&model.StoreV2{ +func (s *storeImpl) SaveIfNotExistStore( + store adaptor.StoreCache, + scope model.ChannelScope, +) error { + _, err := model.SaveIfNotExistStoreByScope(&model.StoreV2{ ID: store.ID, GroupID: store.GroupID, TokenID: store.TokenID, @@ -114,11 +154,87 @@ func (s *storeImpl) SaveIfNotExistStore(store adaptor.StoreCache) error { CreatedAt: store.CreatedAt, UpdatedAt: store.UpdatedAt, ExpiresAt: store.ExpiresAt, - }) + }, scope) return err } +type scopedAdaptorStore struct { + adaptor.Store + meta *meta.Meta +} + +func newScopedAdaptorStore(base adaptor.Store, meta *meta.Meta) adaptor.Store { + return &scopedAdaptorStore{Store: base, meta: meta} +} + +func (s *scopedAdaptorStore) storeWithMetaDefaults(store adaptor.StoreCache) adaptor.StoreCache { + if s.meta != nil { + if store.GroupID == "" { + store.GroupID = s.meta.Group.ID + } + + if store.TokenID == 0 { + store.TokenID = s.meta.Token.ID + } + + if store.ChannelID == 0 { + store.ChannelID = s.meta.Channel.ID + } + + if store.Model == "" { + store.Model = s.meta.OriginModel + } + } + + return store +} + +func (s *scopedAdaptorStore) effectiveScope(scope model.ChannelScope) model.ChannelScope { + scope = model.NormalizeChannelScope(scope) + + if s.meta != nil && s.meta.Channel.Scope == model.ChannelScopeGroup { + return model.ChannelScopeGroup + } + + return scope +} + +func (s *scopedAdaptorStore) GetStoreByScope( + group string, + tokenID int, + id string, + scope model.ChannelScope, +) (adaptor.StoreCache, error) { + scope = s.effectiveScope(scope) + return s.Store.GetStoreByScope(group, tokenID, id, scope) +} + +func (s *scopedAdaptorStore) SaveStore( + store adaptor.StoreCache, + scope model.ChannelScope, +) error { + scope = s.effectiveScope(scope) + return s.Store.SaveStore(s.storeWithMetaDefaults(store), scope) +} + +func (s *scopedAdaptorStore) SaveStoreWithOption( + store adaptor.StoreCache, + scope model.ChannelScope, + opt adaptor.SaveStoreOption, +) error { + scope = s.effectiveScope(scope) + return s.Store.SaveStoreWithOption(s.storeWithMetaDefaults(store), scope, opt) +} + +func (s *scopedAdaptorStore) SaveIfNotExistStore( + store adaptor.StoreCache, + scope model.ChannelScope, +) error { + scope = s.effectiveScope(scope) + return s.Store.SaveIfNotExistStore(s.storeWithMetaDefaults(store), scope) +} + func wrapPlugin(ctx context.Context, mc *model.ModelCaches, a adaptor.Adaptor) adaptor.Adaptor { return plugin.WrapperAdaptor(a, monitorplugin.NewGroupMonitorPlugin(), @@ -152,7 +268,13 @@ func relayHandler(c *gin.Context, meta *meta.Meta, mc *model.ModelCaches) *contr adaptor = wrapPlugin(c.Request.Context(), mc, adaptor) - return controller.Handle(adaptor, c, meta, AdaptorStore, buildBodyDetailOption(meta)) + return controller.Handle( + adaptor, + c, + meta, + newScopedAdaptorStore(AdaptorStore, meta), + buildBodyDetailOption(meta), + ) } func defaultPriceFunc(_ *gin.Context, mc model.ModelConfig) (model.Price, error) { @@ -250,85 +372,211 @@ func NewMetaByContext( return middleware.NewMetaByContext(c, channel, mode, opts...) } -func relay(c *gin.Context, mode mode.Mode, relayController RelayController) { - requestModel := middleware.GetRequestModel(c) - mc := middleware.GetModelConfig(c) +func NewMetaByScopedChannel( + c *gin.Context, + channel *scopedChannel, + mode mode.Mode, + opts ...meta.Option, +) *meta.Meta { + if channel == nil { + return middleware.NewMetaByContext(c, nil, mode, opts...) + } - if relayController.ValidateRequest != nil { - if err := relayController.ValidateRequest(c, mc); err != nil { - statusCode := http.StatusInternalServerError + opts = append(opts, meta.WithChannelScope(channel.scope, channel.groupID)) - var requestParamErr *controller.RequestParamError - if errors.As(err, &requestParamErr) { - statusCode = requestParamErr.StatusCode - } - - middleware.AbortLogWithMessageWithMode(mode, c, - statusCode, - err.Error(), - ) + return middleware.NewMetaByContext(c, channel.channel, mode, opts...) +} - return +func resolveScopedModelConfig( + group model.GroupCache, + modelCaches *model.ModelCaches, + channel *scopedChannel, + modelName string, +) (model.ModelConfig, bool) { + if channel != nil && channel.isGroupChannel() { + modelConfig, ok := model.ResolveGroupScopeModelConfig(group.ID, modelName) + if !ok { + return model.ModelConfig{}, false } + + return middleware.GetGroupScopeAdjustedModelConfig(group, modelConfig), true } - // Get initial channel - initialChannel, err := getInitialChannel(c, requestModel, mode) - if err != nil || initialChannel == nil || initialChannel.channel == nil { - middleware.AbortLogWithMessageWithMode(mode, c, - http.StatusServiceUnavailable, - "the upstream load is saturated, please try again later", - ) + if modelCaches == nil || modelCaches.ModelConfig == nil { + return model.ModelConfig{}, false + } - return + modelConfig, ok := modelCaches.ModelConfig.GetModelConfig(modelName) + if !ok { + return model.ModelConfig{}, false } - price := model.Price{} - if relayController.GetRequestPrice != nil { - price, err = relayController.GetRequestPrice(c, mc) - if err != nil { - middleware.AbortLogWithMessageWithMode(mode, c, - http.StatusInternalServerError, - "get request price failed: "+err.Error(), + return middleware.GetGroupAdjustedModelConfig(group, modelConfig), true +} + +type relayAttempt struct { + channel *scopedChannel + modelConfig model.ModelConfig + price model.Price + requestUsage model.Usage + requestUsageContext model.UsageContext + meta *meta.Meta +} + +func prepareRelayAttempt( + c *gin.Context, + m mode.Mode, + relayController RelayController, + channel *scopedChannel, + modelCaches *model.ModelCaches, + modelName string, + checkGroupModelLimit bool, +) (*relayAttempt, error) { + group := middleware.GetGroup(c) + + modelConfig, ok := resolveScopedModelConfig(group, modelCaches, channel, modelName) + if !ok { + return nil, modelConfigNotFoundError{modelName: modelName} + } + + if err := checkRelayModeForAttempt(m, modelName, modelConfig); err != nil { + return nil, err + } + + if err := validateRelayRequest(c, relayController, modelConfig); err != nil { + return nil, err + } + + attemptMeta := NewMetaByScopedChannel( + c, + channel, + m, + meta.WithModelConfig(modelConfig), + ) + + token := middleware.GetToken(c) + if checkGroupModelLimit { + if err := middleware.CheckGroupModelRPMAndTPM( + c, + group, + modelConfig, + token.Name, + channel.scope, + channel.channel.ID, + ); err != nil { + consume.Summary( + http.StatusTooManyRequests, + time.Time{}, + attemptMeta, + model.Usage{}, + model.UsageContext{ServiceTier: attemptMeta.RequestServiceTier}, + model.Price{}, + true, ) - return + return nil, err } } - meta := NewMetaByContext(c, initialChannel.channel, mode) + price := model.Price{} - if relayController.GetRequestUsage != nil { - requestUsage, err := relayController.GetRequestUsage(c, mc) + var err error + if relayController.GetRequestPrice != nil { + price, err = relayController.GetRequestPrice(c, modelConfig) if err != nil { - middleware.AbortLogWithMessageWithMode(mode, c, - http.StatusInternalServerError, - "get request usage failed: "+err.Error(), - ) + return nil, fmt.Errorf("get request price failed: %w", err) + } + } - return + if relayController.GetRequestUsage != nil { + requestUsage, err := relayController.GetRequestUsage(c, modelConfig) + if err != nil { + return nil, fmt.Errorf("get request usage failed: %w", err) } - meta.RequestUsage = requestUsage.Usage - meta.RequestUsageContext = requestUsage.Context + attemptMeta.RequestUsage = requestUsage.Usage + attemptMeta.RequestUsageContext = requestUsage.Context + } + + attemptMeta.RequestUsageContext.ServiceTier = attemptMeta.RequestServiceTier + + return &relayAttempt{ + channel: channel, + modelConfig: modelConfig, + price: price, + requestUsage: attemptMeta.RequestUsage, + requestUsageContext: attemptMeta.RequestUsageContext, + meta: attemptMeta, + }, nil +} + +func validateRelayRequest( + c *gin.Context, + relayController RelayController, + modelConfig model.ModelConfig, +) error { + if relayController.ValidateRequest == nil { + return nil + } + + return relayController.ValidateRequest(c, modelConfig) +} + +func checkRelayModeForAttempt(m mode.Mode, modelName string, modelConfig model.ModelConfig) error { + if middleware.CheckRelayMode(m, modelConfig.Type) { + return nil } - meta.RequestUsageContext.ServiceTier = meta.RequestServiceTier + return relayModeMismatchError{modelName: modelName} +} + +func relay(c *gin.Context, mode mode.Mode, relayController RelayController) { + requestModel := middleware.GetRequestModel(c) + modelCaches := middleware.GetModelCaches(c) + + // Get initial channel + initialChannel, err := getInitialChannel(c, requestModel, mode) + if err != nil || initialChannel == nil || initialChannel.channel == nil { + middleware.AbortLogWithMessageWithMode(mode, c, + http.StatusServiceUnavailable, + "the upstream load is saturated, please try again later", + ) + + return + } + + attempt, err := prepareRelayAttempt( + c, + mode, + relayController, + initialChannel.channel, + modelCaches, + requestModel, + true, + ) + if err != nil { + abortRelayPreparationError(c, mode, err) + return + } gbc := middleware.GetGroupBalanceConsumerFromContext(c) requiredBalance := math.Max( consume.CalculateAmountWithOptions( http.StatusOK, - meta.RequestUsage, - meta.RequestUsageContext, - price, + attempt.meta.RequestUsage, + attempt.meta.RequestUsageContext, + attempt.price, model.PriceSelectionOptions{ - DisableResolutionFuzzyMatch: mc.DisableResolutionFuzzyMatch, + DisableResolutionFuzzyMatch: attempt.modelConfig.DisableResolutionFuzzyMatch, }, ), middleware.GroupMinimumBalance, ) + if attempt.channel.isGroupChannel() { + requiredBalance = middleware.GroupMinimumBalance + } + if !gbc.CheckBalance(requiredBalance) { middleware.AbortLogWithMessageWithMode(mode, c, http.StatusForbidden, @@ -340,18 +588,18 @@ func relay(c *gin.Context, mode mode.Mode, relayController RelayController) { } // First attempt - result, retry := RelayHelper(c, meta, relayController.Handler) + result, retry := RelayHelper(c, attempt.meta, relayController.Handler) retryTimes := int(config.GetRetryTimes()) - if mc.RetryTimes > 0 { - retryTimes = int(mc.RetryTimes) + if attempt.modelConfig.RetryTimes > 0 { + retryTimes = int(attempt.modelConfig.RetryTimes) } if handleRelayResult(c, result.Error, retry, retryTimes) { recordResult( c, - meta, - price, + attempt.meta, + attempt.price, result, 0, true, @@ -365,16 +613,39 @@ func relay(c *gin.Context, mode mode.Mode, relayController RelayController) { retryState := initRetryState( retryTimes, initialChannel, - meta, + attempt.meta, result, - price, + attempt.price, time.Now(), + modelCaches, + requestModel, + relayController, + true, ) // Retry loop retryLoop(c, mode, retryState, relayController.Handler) } +func abortRelayPreparationError(c *gin.Context, m mode.Mode, err error) { + statusCode := http.StatusInternalServerError + + var requestParamErr *controller.RequestParamError + switch { + case errors.As(err, &requestParamErr): + statusCode = requestParamErr.StatusCode + case errors.Is(err, middleware.ErrRequestRateLimitExceeded), + errors.Is(err, middleware.ErrRequestTpmLimitExceeded): + statusCode = http.StatusTooManyRequests + case errors.Is(err, errRelayModeMismatch): + statusCode = http.StatusNotFound + case errors.Is(err, errModelConfigNotFound): + statusCode = http.StatusNotFound + } + + middleware.AbortLogWithMessageWithMode(m, c, statusCode, err.Error()) +} + // recordResult records the consumption for the final result func recordResult( c *gin.Context, @@ -429,7 +700,7 @@ func recordResult( } asyncUsageStatus := model.AsyncUsageStatusNone - if downstreamResult && result.Error == nil && result.AsyncUsage { + if shouldRecordAsyncUsage(meta, result, downstreamResult) { asyncUsageStatus = model.AsyncUsageStatusPending } @@ -456,11 +727,26 @@ func recordResult( } } +func shouldRecordAsyncUsage( + meta *meta.Meta, + result *controller.HandleResult, + downstreamResult bool, +) bool { + return meta.Channel.Scope != model.ChannelScopeGroup && + downstreamResult && + result.Error == nil && + result.AsyncUsage +} + func saveAsyncUsageInfo( meta *meta.Meta, price model.Price, result *controller.HandleResult, ) { + if meta.Channel.Scope == model.ChannelScopeGroup { + return + } + if result.UpstreamID == "" { log.Warnf("skip async usage without upstream id, request_id: %s", meta.RequestID) return @@ -547,19 +833,25 @@ func buildBodyDetailOption(meta *meta.Meta) controller.BodyDetailOption { type retryState struct { retryTimes int - lastMinErrorRateHasPermissionChannel *model.Channel - preferChannelIDs []int - ignoreChannelIDs map[int64]struct{} + lastMinErrorRateHasPermissionChannel *scopedChannel + preferChannelKeys []string + ignoreChannelIDs map[string]struct{} exhausted bool - failedChannelIDs map[int64]struct{} // Track all failed channels in this request - - meta *meta.Meta - price model.Price - requestUsage model.Usage - requestUsageContext model.UsageContext - result *controller.HandleResult - migratedChannels []*model.Channel - channelRetryInfo map[int]channelRetryInfo + groupRetryOnly bool + failedChannelIDs map[string]struct{} // Track all failed channel monitor keys in this request + + meta *meta.Meta + price model.Price + modelCaches *model.ModelCaches + modelName string + relayController RelayController + groupModelLimitChecked bool + groupModelLimitKeys map[string]struct{} + requestUsage model.Usage + requestUsageContext model.UsageContext + result *controller.HandleResult + migratedChannels []*scopedChannel + channelRetryInfo map[string]channelRetryInfo } type channelRetryInfo struct { @@ -600,25 +892,39 @@ func initRetryState( result *controller.HandleResult, price model.Price, initialEndAt time.Time, + modelCaches *model.ModelCaches, + modelName string, + relayController RelayController, + groupModelLimitChecked bool, ) *retryState { state := &retryState{ - retryTimes: retryTimes, - preferChannelIDs: channel.preferChannelIDs, - ignoreChannelIDs: channel.ignoreChannelIDs, - meta: meta, - result: result, - price: price, - requestUsage: meta.RequestUsage, - requestUsageContext: meta.RequestUsageContext, - migratedChannels: channel.migratedChannels, - failedChannelIDs: make(map[int64]struct{}), - channelRetryInfo: make(map[int]channelRetryInfo), + retryTimes: retryTimes, + preferChannelKeys: channel.preferChannelKeys, + ignoreChannelIDs: channel.ignoreChannelIDs, + groupRetryOnly: channel.groupRetryOnly, + meta: meta, + result: result, + price: price, + modelCaches: modelCaches, + modelName: modelName, + relayController: relayController, + groupModelLimitChecked: groupModelLimitChecked, + requestUsage: meta.RequestUsage, + requestUsageContext: meta.RequestUsageContext, + migratedChannels: channel.migratedChannels, + failedChannelIDs: make(map[string]struct{}), + channelRetryInfo: make(map[string]channelRetryInfo), + groupModelLimitKeys: make(map[string]struct{}), + } + + if groupModelLimitChecked { + state.markGroupModelLimitChecked(channel.channel) } // Record initial failed channel - state.failedChannelIDs[int64(meta.Channel.ID)] = struct{}{} + state.failedChannelIDs[meta.ChannelMonitorKey()] = struct{}{} if shouldBackoffStatus(result.Error.StatusCode()) { - state.recordChannelFailure(meta.Channel.ID, initialEndAt) + state.recordChannelFailure(meta.ChannelMonitorKey(), initialEndAt) } if channel.designatedChannel { @@ -627,10 +933,10 @@ func initRetryState( if !monitorplugin.ChannelHasPermission(result.Error) { if state.ignoreChannelIDs == nil { - state.ignoreChannelIDs = make(map[int64]struct{}) + state.ignoreChannelIDs = make(map[string]struct{}) } - state.ignoreChannelIDs[int64(channel.channel.ID)] = struct{}{} + state.ignoreChannelIDs[channel.channel.monitorKey()] = struct{}{} } else { state.lastMinErrorRateHasPermissionChannel = channel.channel } @@ -638,9 +944,40 @@ func initRetryState( return state } -func (s *retryState) recordChannelFailure(channelID int, endAt time.Time) { +func (s *retryState) markGroupModelLimitChecked(channel *scopedChannel) { + if channel == nil { + return + } + + if channel.isGroupChannel() { + if s.groupModelLimitKeys == nil { + s.groupModelLimitKeys = make(map[string]struct{}) + } + + s.groupModelLimitKeys[channel.monitorKey()] = struct{}{} + + return + } + + s.groupModelLimitChecked = true +} + +func (s *retryState) shouldCheckGroupModelLimit(channel *scopedChannel) bool { + if channel == nil { + return false + } + + if channel.isGroupChannel() { + _, ok := s.groupModelLimitKeys[channel.monitorKey()] + return !ok + } + + return !s.groupModelLimitChecked +} + +func (s *retryState) recordChannelFailure(channelID string, endAt time.Time) { if s.channelRetryInfo == nil { - s.channelRetryInfo = make(map[int]channelRetryInfo) + s.channelRetryInfo = make(map[string]channelRetryInfo) } info := s.channelRetryInfo[channelID] @@ -671,7 +1008,7 @@ func calculateRelayBackoffDelay(failures int, jitter time.Duration) time.Duratio } func (s *retryState) remainingRelayDelay( - channelID int, + channelID string, now time.Time, jitter time.Duration, ) time.Duration { @@ -739,35 +1076,50 @@ func retryLoop(c *gin.Context, mode mode.Mode, state *retryState, relayControlle log.Data["retry"] = strconv.Itoa(i + 1) log.Warnf("using channel %s (type: %d, id: %d) to retry (remain times %d)", - newChannel.Name, - newChannel.Type, - newChannel.ID, + newChannel.channel.Name, + newChannel.channel.Type, + newChannel.channel.ID, state.retryTimes-i, ) - relayDelay(state, newChannel.ID) + relayDelay(state, newChannel.monitorKey()) - state.meta = NewMetaByContext( + attempt, err := prepareRelayAttempt( c, - newChannel, mode, - meta.WithRequestUsage(state.requestUsage), - meta.WithRequestUsageContext(state.requestUsageContext), - meta.WithRetryAt(time.Now()), + state.relayController, + newChannel, + state.modelCaches, + state.modelName, + state.shouldCheckGroupModelLimit(newChannel), ) + if err != nil { + abortRelayPreparationError(c, mode, err) + + state.result = nil + return + } + + state.markGroupModelLimitChecked(newChannel) + + state.meta = attempt.meta + state.meta.RetryAt = time.Now() + state.price = attempt.price + state.requestUsage = attempt.requestUsage + state.requestUsageContext = attempt.requestUsageContext var retry bool state.result, retry = RelayHelper(c, state.meta, relayController) if state.result.Error != nil && shouldBackoffStatus(state.result.Error.StatusCode()) { - state.recordChannelFailure(newChannel.ID, time.Now()) + state.recordChannelFailure(newChannel.monitorKey(), time.Now()) } done := handleRetryResult(c, retry, newChannel, state) // Record failed channel if retry is needed if !done && state.result.Error != nil { - state.failedChannelIDs[int64(newChannel.ID)] = struct{}{} + state.failedChannelIDs[newChannel.monitorKey()] = struct{}{} } if done || i == state.retryTimes-1 { @@ -787,7 +1139,7 @@ func retryLoop(c *gin.Context, mode mode.Mode, state *retryState, relayControlle i++ } - if state.result.Error != nil { + if state.result != nil && state.result.Error != nil { ErrorWithRequestID(c, state.result.Error) } } @@ -806,7 +1158,7 @@ func prepareRetry(c *gin.Context) error { func handleRetryResult( ctx *gin.Context, retry bool, - newChannel *model.Channel, + newChannel *scopedChannel, state *retryState, ) (done bool) { if ctx.Request.Context().Err() != nil { @@ -826,10 +1178,10 @@ func handleRetryResult( } else { if !hasPermission { if state.ignoreChannelIDs == nil { - state.ignoreChannelIDs = make(map[int64]struct{}) + state.ignoreChannelIDs = make(map[string]struct{}) } - state.ignoreChannelIDs[int64(newChannel.ID)] = struct{}{} + state.ignoreChannelIDs[newChannel.monitorKey()] = struct{}{} state.retryTimes++ } else { if state.lastMinErrorRateHasPermissionChannel == nil { @@ -837,19 +1189,21 @@ func handleRetryResult( return false } - currentErrorRate, err := monitor.GetChannelModelErrorRate( + currentErrorRate, err := getScopedChannelModelErrorRateByKey( ctx.Request.Context(), + state.lastMinErrorRateHasPermissionChannel.scope, state.meta.OriginModel, - int64(state.lastMinErrorRateHasPermissionChannel.ID), + state.lastMinErrorRateHasPermissionChannel.monitorKey(), ) if err != nil { return false } - newErrorRate, err := monitor.GetChannelModelErrorRate( + newErrorRate, err := getScopedChannelModelErrorRateByKey( ctx.Request.Context(), + newChannel.scope, state.meta.OriginModel, - int64(newChannel.ID), + newChannel.monitorKey(), ) if err != nil { return false @@ -872,7 +1226,7 @@ func shouldBackoffStatus(statusCode int) bool { statusCode == http.StatusServiceUnavailable } -func relayDelay(state *retryState, channelID int) { +func relayDelay(state *retryState, channelID string) { jitter := time.Duration(rand.Int64N(int64(relayRetryMaxJitter))) delay := state.remainingRelayDelay(channelID, time.Now(), jitter) diff --git a/core/controller/relay-controller_test.go b/core/controller/relay-controller_test.go index 140cb50d..b81a9035 100644 --- a/core/controller/relay-controller_test.go +++ b/core/controller/relay-controller_test.go @@ -2,13 +2,21 @@ package controller import ( + "errors" "net/http" + "net/http/httptest" + "path/filepath" "reflect" "testing" "time" + "github.com/gin-gonic/gin" "github.com/glebarez/sqlite" + "github.com/labring/aiproxy/core/common/config" + "github.com/labring/aiproxy/core/common/consume" + "github.com/labring/aiproxy/core/middleware" "github.com/labring/aiproxy/core/model" + "github.com/labring/aiproxy/core/relay/adaptor" relaycontroller "github.com/labring/aiproxy/core/relay/controller" "github.com/labring/aiproxy/core/relay/meta" "github.com/labring/aiproxy/core/relay/mode" @@ -17,6 +25,50 @@ import ( "gorm.io/gorm" ) +type recordingAdaptorStore struct { + saved []adaptor.StoreCache + saveScope model.ChannelScope + getScope model.ChannelScope +} + +func (s *recordingAdaptorStore) GetStoreByScope( + _ string, + _ int, + _ string, + scope model.ChannelScope, +) (adaptor.StoreCache, error) { + s.getScope = scope + return adaptor.StoreCache{}, errors.New("unused") +} + +func (s *recordingAdaptorStore) SaveStore( + store adaptor.StoreCache, + scope model.ChannelScope, +) error { + s.saveScope = scope + s.saved = append(s.saved, store) + return nil +} + +func (s *recordingAdaptorStore) SaveStoreWithOption( + store adaptor.StoreCache, + scope model.ChannelScope, + _ adaptor.SaveStoreOption, +) error { + s.saveScope = scope + s.saved = append(s.saved, store) + return nil +} + +func (s *recordingAdaptorStore) SaveIfNotExistStore( + store adaptor.StoreCache, + scope model.ChannelScope, +) error { + s.saveScope = scope + s.saved = append(s.saved, store) + return nil +} + func TestRetryStateRemainingRelayDelay(t *testing.T) { t.Parallel() @@ -27,26 +79,26 @@ func TestRetryStateRemainingRelayDelay(t *testing.T) { base := time.Unix(100, 0) jitter := 400 * time.Millisecond - state.recordChannelFailure(7, base) + state.recordChannelFailure("7", base) - assert.Equal(t, 1400*time.Millisecond, state.remainingRelayDelay(7, base, jitter)) + assert.Equal(t, 1400*time.Millisecond, state.remainingRelayDelay("7", base, jitter)) assert.Equal( t, 900*time.Millisecond, - state.remainingRelayDelay(7, base.Add(500*time.Millisecond), jitter), + state.remainingRelayDelay("7", base.Add(500*time.Millisecond), jitter), ) - state.recordChannelFailure(7, base.Add(2*time.Second)) + state.recordChannelFailure("7", base.Add(2*time.Second)) assert.Equal( t, 2400*time.Millisecond, - state.remainingRelayDelay(7, base.Add(2*time.Second), jitter), + state.remainingRelayDelay("7", base.Add(2*time.Second), jitter), ) assert.Equal( t, 1150*time.Millisecond, - state.remainingRelayDelay(7, base.Add(3250*time.Millisecond), jitter), + state.remainingRelayDelay("7", base.Add(3250*time.Millisecond), jitter), ) }) @@ -56,13 +108,16 @@ func TestRetryStateRemainingRelayDelay(t *testing.T) { state := &retryState{} base := time.Unix(200, 0) - state.recordChannelFailure(9, base) + state.recordChannelFailure("9", base) assert.Zero( t, - state.remainingRelayDelay(9, base.Add(1500*time.Millisecond), 400*time.Millisecond), + state.remainingRelayDelay("9", base.Add(1500*time.Millisecond), 400*time.Millisecond), + ) + assert.Zero( + t, + state.remainingRelayDelay("9", base.Add(2*time.Second), 400*time.Millisecond), ) - assert.Zero(t, state.remainingRelayDelay(9, base.Add(2*time.Second), 400*time.Millisecond)) }) t.Run("tracks each channel independently", func(t *testing.T) { @@ -71,21 +126,21 @@ func TestRetryStateRemainingRelayDelay(t *testing.T) { state := &retryState{} base := time.Unix(300, 0) - state.recordChannelFailure(1, base) - state.recordChannelFailure(2, base) - state.recordChannelFailure(2, base.Add(100*time.Millisecond)) + state.recordChannelFailure("1", base) + state.recordChannelFailure("2", base) + state.recordChannelFailure("2", base.Add(100*time.Millisecond)) assert.Equal( t, 500*time.Millisecond, - state.remainingRelayDelay(1, base.Add(800*time.Millisecond), 300*time.Millisecond), + state.remainingRelayDelay("1", base.Add(800*time.Millisecond), 300*time.Millisecond), ) assert.Equal( t, 1500*time.Millisecond, - state.remainingRelayDelay(2, base.Add(900*time.Millisecond), 300*time.Millisecond), + state.remainingRelayDelay("2", base.Add(900*time.Millisecond), 300*time.Millisecond), ) - assert.Zero(t, state.remainingRelayDelay(3, base, 300*time.Millisecond)) + assert.Zero(t, state.remainingRelayDelay("3", base, 300*time.Millisecond)) }) t.Run("caps backoff at five seconds", func(t *testing.T) { @@ -95,11 +150,11 @@ func TestRetryStateRemainingRelayDelay(t *testing.T) { state := &retryState{} for range 20 { - state.recordChannelFailure(5, base) + state.recordChannelFailure("5", base) } - assert.Equal(t, 5*time.Second, state.remainingRelayDelay(5, base, time.Second)) - assert.Zero(t, state.remainingRelayDelay(5, base.Add(5*time.Second), time.Second)) + assert.Equal(t, 5*time.Second, state.remainingRelayDelay("5", base, time.Second)) + assert.Zero(t, state.remainingRelayDelay("5", base.Add(5*time.Second), time.Second)) }) } @@ -168,15 +223,339 @@ func TestRelayControllerVideoModesValidateRequests(t *testing.T) { } } +func TestRelayChecksModeBeforeSelectingChannel(t *testing.T) { + gin.SetMode(gin.TestMode) + + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequestWithContext( + t.Context(), + http.MethodPost, + "/v1/chat/completions", + nil, + ) + c.Set(middleware.RequestModel, "pdf-only") + c.Set(middleware.ModelConfig, model.ModelConfig{ + Model: "pdf-only", + Type: mode.ParsePdf, + }) + c.Set(middleware.ModelCaches, &model.ModelCaches{ + ModelConfig: testModelConfigCache{ + "pdf-only": { + Model: "pdf-only", + Type: mode.ParsePdf, + }, + }, + EnabledModel2ChannelsBySet: map[string]map[string][]*model.Channel{ + model.ChannelDefaultSet: { + "pdf-only": { + { + ID: 1, + Type: model.ChannelTypeOpenAI, + Status: model.ChannelStatusEnabled, + Models: []string{"pdf-only"}, + }, + }, + }, + }, + }) + c.Set(middleware.Group, model.GroupCache{ + ID: "group-1", + Status: model.GroupStatusEnabled, + AvailableSets: []string{model.ChannelDefaultSet}, + }) + c.Set(middleware.Token, model.TokenCache{}) + c.Set(middleware.AvailableSets, []string{model.ChannelDefaultSet}) + c.Set(middleware.GroupChannelMode, middleware.GroupChannelModeGlobal) + + relay(c, mode.ChatCompletions, RelayController{}) + + require.Equal(t, http.StatusNotFound, recorder.Code) + require.Contains(t, recorder.Body.String(), "does not exist on this endpoint") +} + +func TestRelayOwnModeUsesGroupScopeConfigForModeCheck(t *testing.T) { + gin.SetMode(gin.TestMode) + + groupID := "group-scope-mode-check" + setTestGroupScopeModelConfigs(t, groupID, model.ModelConfig{ + Model: "public-model", + Type: mode.ChatCompletions, + }) + require.NoError(t, model.CacheSetGroupChannels(&model.GroupChannelsCache{ + GroupID: groupID, + Channels: []*model.GroupChannel{ + { + ID: 7, + GroupID: groupID, + Type: model.ChannelTypeOpenAI, + Status: model.ChannelStatusEnabled, + Models: []string{"public-model"}, + }, + }, + })) + t.Cleanup(func() { + require.NoError(t, model.CacheDeleteGroupChannels(groupID)) + }) + + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequestWithContext( + t.Context(), + http.MethodPost, + "/v1/chat/completions", + nil, + ) + c.Set(middleware.RequestModel, "public-model") + c.Set(middleware.ModelConfig, model.ModelConfig{ + Model: "public-model", + Type: mode.ParsePdf, + }) + c.Set(middleware.ModelCaches, &model.ModelCaches{ + ModelConfig: testModelConfigCache{ + "public-model": { + Model: "public-model", + Type: mode.ParsePdf, + }, + }, + EnabledModel2ChannelsBySet: map[string]map[string][]*model.Channel{}, + }) + c.Set(middleware.Group, model.GroupCache{ + ID: groupID, + Status: model.GroupStatusEnabled, + }) + c.Set(middleware.Token, model.TokenCache{ID: 1, Name: "token-1"}) + c.Set(middleware.AvailableSets, []string{model.ChannelDefaultSet}) + c.Set(middleware.GroupChannelMode, middleware.GroupChannelModeOwn) + c.Set(middleware.RequestAt, time.Now()) + c.Set(middleware.RequestServiceTier, "") + c.Set(middleware.RequestMetadata, map[string]string{}) + c.Set(middleware.PromptCacheKey, "") + c.Set(middleware.RequestUser, "") + c.Set(middleware.GroupBalance, &middleware.GroupBalanceConsumer{ + Group: groupID, + CheckBalance: func(float64) bool { return true }, + }) + + called := false + relay(c, mode.ChatCompletions, RelayController{ + Handler: func(_ *gin.Context, meta *meta.Meta) *relaycontroller.HandleResult { + called = true + + require.Equal(t, model.ChannelScopeGroup, meta.Channel.Scope) + require.Equal(t, mode.ChatCompletions, meta.ModelConfig.Type) + + return &relaycontroller.HandleResult{} + }, + }) + + require.True(t, called) + require.False(t, c.IsAborted()) +} + +func TestResolveScopedModelConfigForGroupChannelUsesGroupScopeConfig(t *testing.T) { + t.Parallel() + + groupID := "group-scope-resolve" + setTestGroupScopeModelConfigs(t, groupID, model.ModelConfig{ + Model: "public-model", + Type: mode.ChatCompletions, + RetryTimes: 9, + RPM: 100, + TPM: 200, + Price: model.Price{ + PerRequestPrice: 2, + }, + Plugin: map[string]map[string]any{ + "cachefollow": {"enable": true}, + }, + }) + + group := model.GroupCache{ + ID: groupID, + RPMRatio: 2, + TPMRatio: 3, + ModelConfigs: map[string]model.GroupModelConfig{ + "public-model": { + Model: "public-model", + OverrideLimit: true, + RPM: 10, + TPM: 20, + OverrideRetryTimes: true, + RetryTimes: 4, + OverridePrice: true, + Price: model.Price{ + PerRequestPrice: 1, + }, + }, + }, + } + modelCaches := &model.ModelCaches{ + ModelConfig: testModelConfigCache{ + "public-model": { + Model: "public-model", + Type: mode.GeminiImage, + RetryTimes: 9, + RPM: 100, + TPM: 200, + Plugin: map[string]map[string]any{ + "cachefollow": {"enable": true}, + }, + }, + }, + } + + got, ok := resolveScopedModelConfig( + group, + modelCaches, + newGroupScopedChannel(groupID, &model.Channel{ID: 7}), + "public-model", + ) + + require.True(t, ok) + require.Equal(t, "public-model", got.Model) + require.Equal(t, mode.ChatCompletions, got.Type) + require.Equal(t, int64(200), got.RPM) + require.Equal(t, int64(600), got.TPM) + require.Equal(t, int64(9), got.RetryTimes) + require.Equal(t, float64(2), float64(got.Price.PerRequestPrice)) + require.Equal(t, map[string]map[string]any{"cachefollow": {"enable": true}}, got.Plugin) +} + +func TestResolveScopedModelConfigForGroupChannelUsesDefaultWhenModelConfigDisabled(t *testing.T) { + oldDisableModelConfig := config.DisableModelConfig + config.DisableModelConfig = true + t.Cleanup(func() { + config.DisableModelConfig = oldDisableModelConfig + }) + + got, ok := resolveScopedModelConfig( + model.GroupCache{ID: "group-disabled-model-config"}, + &model.ModelCaches{ModelConfig: testModelConfigCache{}}, + newGroupScopedChannel("group-disabled-model-config", &model.Channel{ID: 7}), + "dynamic-model", + ) + + require.True(t, ok) + require.Equal(t, model.NewDefaultModelConfig("dynamic-model"), got) +} + +func TestResolveScopedModelConfigForGlobalChannelUsesGlobalWithGroupOverride(t *testing.T) { + t.Parallel() + + group := model.GroupCache{ + ID: "group-1", + RPMRatio: 2, + TPMRatio: 3, + ModelConfigs: map[string]model.GroupModelConfig{ + "public-model": { + Model: "public-model", + OverrideRetryTimes: true, + RetryTimes: 4, + }, + }, + } + modelCaches := &model.ModelCaches{ + ModelConfig: testModelConfigCache{ + "public-model": { + Model: "public-model", + Type: mode.GeminiImage, + RetryTimes: 3, + RPM: 60, + TPM: 600, + Plugin: map[string]map[string]any{ + "cachefollow": {"enable": true}, + }, + }, + }, + } + + got, ok := resolveScopedModelConfig( + group, + modelCaches, + newGlobalScopedChannel(&model.Channel{ID: 7}), + "public-model", + ) + + require.True(t, ok) + require.Equal(t, mode.GeminiImage, got.Type) + require.Equal(t, int64(120), got.RPM) + require.Equal(t, int64(1800), got.TPM) + require.Equal(t, int64(4), got.RetryTimes) + require.Equal(t, map[string]map[string]any{"cachefollow": {"enable": true}}, got.Plugin) +} + +func TestResolveScopedModelConfigForGlobalChannelRequiresGlobalConfig(t *testing.T) { + t.Parallel() + + got, ok := resolveScopedModelConfig( + model.GroupCache{ID: "group-1"}, + &model.ModelCaches{ModelConfig: testModelConfigCache{}}, + newGlobalScopedChannel(&model.Channel{ID: 7}), + "public-model", + ) + + require.False(t, ok) + require.Empty(t, got) +} + +func TestNewMetaByScopedChannelCanOverrideModelConfig(t *testing.T) { + t.Parallel() + gin.SetMode(gin.TestMode) + + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequestWithContext( + t.Context(), + http.MethodPost, + "/v1/chat/completions", + nil, + ) + c.Set(middleware.RequestModel, "public-model") + c.Set(middleware.ModelConfig, model.ModelConfig{ + Model: "public-model", + RetryTimes: 9, + RPM: 90, + }) + c.Set(middleware.Group, model.GroupCache{ID: "group-1"}) + c.Set(middleware.Token, model.TokenCache{ID: 1, Name: "token-1"}) + + defaultConfig := model.NewDefaultModelConfig("public-model") + meta := NewMetaByScopedChannel( + c, + newGroupScopedChannel("group-1", &model.Channel{ID: 7}), + mode.ChatCompletions, + meta.WithModelConfig(defaultConfig), + ) + + require.Equal(t, defaultConfig, meta.ModelConfig) + require.Equal(t, model.ChannelScopeGroup, meta.Channel.Scope) + require.Equal(t, "group-1", meta.Channel.GroupID) +} + func TestSaveAsyncUsageInfoDoesNotStoreInitialUsage(t *testing.T) { db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) require.NoError(t, err) - require.NoError(t, db.AutoMigrate(&model.AsyncUsageInfo{})) + require.NoError(t, db.AutoMigrate( + &model.AsyncUsageInfo{}, + &model.GroupChannel{}, + &model.GroupChannelLog{}, + &model.GroupChannelSummary{}, + &model.GroupChannelSummaryMinute{}, + &model.GroupChannelTokenSummary{}, + &model.GroupChannelTokenSummaryMinute{}, + )) oldLogDB := model.LogDB + oldDB := model.DB model.LogDB = db + model.DB = db t.Cleanup(func() { + consume.Wait() + model.ProcessBatchUpdatesSummary() + model.LogDB = oldLogDB + model.DB = oldDB }) m := meta.NewMeta( @@ -211,6 +590,191 @@ func TestSaveAsyncUsageInfoDoesNotStoreInitialUsage(t *testing.T) { require.Equal(t, "priority", captured.UsageContext.ServiceTier) } +func TestSaveAsyncUsageInfoSkipsGroupChannel(t *testing.T) { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&model.AsyncUsageInfo{})) + + oldLogDB := model.LogDB + model.LogDB = db + t.Cleanup(func() { + model.LogDB = oldLogDB + }) + + m := meta.NewMeta( + &model.Channel{ID: 12, BaseURL: "https://example.com"}, + mode.Videos, + "test-video-model", + model.ModelConfig{}, + meta.WithRequestID("request-async-2"), + meta.WithGroup(model.GroupCache{ID: "group-1"}), + meta.WithToken(model.TokenCache{ID: 22, Name: "token-1"}), + meta.WithChannelScope(model.ChannelScopeGroup, "group-1"), + ) + + saveAsyncUsageInfo(m, model.Price{}, &relaycontroller.HandleResult{ + UpstreamID: "video-456", + }) + + var count int64 + require.NoError(t, db.Model(&model.AsyncUsageInfo{}).Count(&count).Error) + require.Zero(t, count) +} + +func TestShouldRecordAsyncUsageSkipsGroupChannel(t *testing.T) { + m := meta.NewMeta( + &model.Channel{ID: 12, BaseURL: "https://example.com"}, + mode.Videos, + "test-video-model", + model.ModelConfig{}, + meta.WithRequestID("request-group-async"), + meta.WithGroup(model.GroupCache{ID: "group-1"}), + meta.WithToken(model.TokenCache{ID: 22, Name: "token-1"}), + meta.WithChannelScope(model.ChannelScopeGroup, "group-1"), + ) + + require.False(t, shouldRecordAsyncUsage(m, &relaycontroller.HandleResult{ + UpstreamID: "video-789", + AsyncUsage: true, + Usage: model.Usage{OutputTokens: 10, TotalTokens: 10}, + }, true)) +} + +func TestRecordResultGroupChannelForcesSynchronousUsage(t *testing.T) { + db, err := model.OpenSQLite(filepath.Join(t.TempDir(), "record_result_group_async.db")) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate( + &model.GroupChannel{}, + &model.GroupChannelLog{}, + &model.GroupChannelSummary{}, + &model.GroupChannelSummaryMinute{}, + &model.GroupChannelTokenSummary{}, + &model.GroupChannelTokenSummaryMinute{}, + )) + require.NoError(t, db.Create(&model.GroupChannel{ + ID: 7, + GroupID: "group-1", + Name: "group-channel-7", + }).Error) + + oldLogDB := model.LogDB + oldDB := model.DB + model.LogDB = db + model.DB = db + t.Cleanup(func() { + consume.Wait() + model.ProcessBatchUpdatesSummary() + + model.LogDB = oldLogDB + model.DB = oldDB + }) + + gin.SetMode(gin.TestMode) + + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequestWithContext( + t.Context(), + http.MethodPost, + "/v1/videos", + nil, + ) + c.Set(middleware.GroupBalance, &middleware.GroupBalanceConsumer{ + Group: "group-1", + CheckBalance: func(float64) bool { return true }, + }) + + requestMeta := meta.NewMeta( + &model.Channel{ID: 7, BaseURL: "https://example.com"}, + mode.Videos, + "video-model", + model.ModelConfig{}, + meta.WithRequestID("group-record-result-async"), + meta.WithGroup(model.GroupCache{ID: "group-1"}), + meta.WithToken(model.TokenCache{ID: 22, Name: "token-1"}), + meta.WithChannelScope(model.ChannelScopeGroup, "group-1"), + ) + + recordResult( + c, + requestMeta, + model.Price{OutputPrice: 1, OutputPriceUnit: 1}, + &relaycontroller.HandleResult{ + Usage: model.Usage{ + OutputTokens: 5, + TotalTokens: 5, + }, + UpstreamID: "video-789", + AsyncUsage: true, + }, + 0, + true, + nil, + ) + consume.Wait() + model.ProcessBatchUpdatesSummary() + + var logEntry model.GroupChannelLog + require.NoError(t, db.Where("request_id = ?", requestMeta.RequestID).First(&logEntry).Error) + require.Equal(t, model.AsyncUsageStatusNone, logEntry.AsyncUsageStatus) + require.Equal(t, model.ZeroNullInt64(5), logEntry.Usage.OutputTokens) + require.Equal(t, 5.0, logEntry.Amount.UsedAmount) +} + +func TestScopedAdaptorStoreFillsEmptyStoreFields(t *testing.T) { + base := &recordingAdaptorStore{} + m := meta.NewMeta( + &model.Channel{ID: 12}, + mode.Responses, + "gpt-5", + model.ModelConfig{}, + meta.WithGroup(model.GroupCache{ID: "group-1"}), + meta.WithToken(model.TokenCache{ID: 22}), + meta.WithChannelScope(model.ChannelScopeGroup, "group-1"), + ) + store := newScopedAdaptorStore(base, m) + + require.NoError(t, store.SaveStore(adaptor.StoreCache{ + ID: model.ResponseStoreID("resp_scope_fields"), + }, model.ChannelScopeGroup)) + + require.Len(t, base.saved, 1) + require.Equal(t, "group-1", base.saved[0].GroupID) + require.Equal(t, 22, base.saved[0].TokenID) + require.Equal(t, model.ChannelScopeGroup, base.saveScope) + require.Equal(t, 12, base.saved[0].ChannelID) + require.Equal(t, "gpt-5", base.saved[0].Model) +} + +func TestScopedAdaptorStorePreservesExplicitStoreFieldsAndOverridesScope(t *testing.T) { + base := &recordingAdaptorStore{} + m := meta.NewMeta( + &model.Channel{ID: 12}, + mode.Responses, + "gpt-5", + model.ModelConfig{}, + meta.WithGroup(model.GroupCache{ID: "group-1"}), + meta.WithToken(model.TokenCache{ID: 22}), + meta.WithChannelScope(model.ChannelScopeGroup, "group-1"), + ) + store := newScopedAdaptorStore(base, m) + + require.NoError(t, store.SaveStore(adaptor.StoreCache{ + ID: model.ResponseStoreID("resp_explicit_scope_fields"), + GroupID: "other-group", + TokenID: 33, + ChannelID: 44, + Model: "other-model", + }, model.ChannelScopeGroup)) + + require.Len(t, base.saved, 1) + require.Equal(t, "other-group", base.saved[0].GroupID) + require.Equal(t, 33, base.saved[0].TokenID) + require.Equal(t, model.ChannelScopeGroup, base.saveScope) + require.Equal(t, 44, base.saved[0].ChannelID) + require.Equal(t, "other-model", base.saved[0].Model) +} + func TestBuildRequestDetailForLogSkipsRequestBodyForUpstreamOnlyStatuses(t *testing.T) { t.Parallel() diff --git a/core/controller/relay-model.go b/core/controller/relay-model.go index bd85906b..eff9dbee 100644 --- a/core/controller/relay-model.go +++ b/core/controller/relay-model.go @@ -6,6 +6,7 @@ import ( "github.com/gin-gonic/gin" "github.com/labring/aiproxy/core/middleware" + "github.com/labring/aiproxy/core/model" relaymodel "github.com/labring/aiproxy/core/relay/model" ) @@ -19,26 +20,37 @@ import ( // @Success 200 {object} object{object=string,data=[]OpenAIModels} // @Router /v1/models [get] func ListModels(c *gin.Context) { - enabledModelConfigsMap := middleware.GetModelCaches(c).EnabledModelConfigsMap - token := middleware.GetToken(c) + modelCaches := middleware.GetModelCaches(c) + group := middleware.GetGroup(c) + groupChannelMode := middleware.GetGroupChannelMode(c) + allowedModels := middleware.GetActiveTokenModels(c) availableOpenAIModels := make([]*OpenAIModels, 0) - token.Range(func(model string) bool { - if mc, ok := enabledModelConfigsMap[model]; ok { - availableOpenAIModels = append(availableOpenAIModels, &OpenAIModels{ - ID: model, - Object: "model", - Created: 1626777600, - OwnedBy: string(mc.Owner), - Root: model, - Permission: permission, - Parent: nil, - }) - } + model.RangeModelsWithAllowList( + allowedModels, + middleware.GetActiveAvailableSets(c), + middleware.GetActiveAvailableModels(c), + func(modelName string) bool { + if mc, ok := middleware.ResolveModelConfig( + group, + groupChannelMode, + modelCaches, + modelName, + ); ok { + availableOpenAIModels = append(availableOpenAIModels, &OpenAIModels{ + ID: modelName, + Object: "model", + Created: 1626777600, + OwnedBy: string(mc.Owner), + Root: modelName, + Permission: permission, + Parent: nil, + }) + } - return true - }) + return true + }) c.JSON(http.StatusOK, gin.H{ "object": "list", @@ -56,12 +68,33 @@ func ListModels(c *gin.Context) { // @Success 200 {object} OpenAIModels // @Router /v1/models/{model} [get] func RetrieveModel(c *gin.Context) { - token := middleware.GetToken(c) modelName := c.Param("model") - findModelName := token.FindModel(modelName) - enabledModelConfigsMap := middleware.GetModelCaches(c).EnabledModelConfigsMap + findModelName := model.FindModelWithAllowList( + middleware.GetActiveTokenModels(c), + modelName, + middleware.GetActiveAvailableSets(c), + middleware.GetActiveAvailableModels(c), + ) + + if findModelName == "" { + c.JSON(http.StatusNotFound, gin.H{ + "error": &relaymodel.OpenAIError{ + Message: fmt.Sprintf("the model '%s' does not exist", modelName), + Type: "invalid_request_error", + Param: "model", + Code: "model_not_found", + }, + }) + + return + } - mc, ok := enabledModelConfigsMap[findModelName] + mc, ok := middleware.ResolveModelConfig( + middleware.GetGroup(c), + middleware.GetGroupChannelMode(c), + middleware.GetModelCaches(c), + findModelName, + ) if !ok { c.JSON(http.StatusNotFound, gin.H{ "error": &relaymodel.OpenAIError{ @@ -76,11 +109,11 @@ func RetrieveModel(c *gin.Context) { } c.JSON(http.StatusOK, &OpenAIModels{ - ID: modelName, + ID: findModelName, Object: "model", Created: 1626777600, OwnedBy: string(mc.Owner), - Root: modelName, + Root: findModelName, Permission: permission, Parent: nil, }) diff --git a/core/controller/relay-model_test.go b/core/controller/relay-model_test.go new file mode 100644 index 00000000..6da343e1 --- /dev/null +++ b/core/controller/relay-model_test.go @@ -0,0 +1,374 @@ +//nolint:testpackage +package controller + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "slices" + "testing" + + "github.com/gin-gonic/gin" + "github.com/labring/aiproxy/core/common/config" + "github.com/labring/aiproxy/core/middleware" + "github.com/labring/aiproxy/core/model" + "github.com/labring/aiproxy/core/relay/mode" + "github.com/stretchr/testify/require" +) + +func setTestGroupScopeModelConfigs( + t *testing.T, + groupID string, + configs ...model.ModelConfig, +) { + t.Helper() + + cache := &model.GroupScopeModelConfigsCache{ + GroupID: groupID, + Models: make([]string, 0, len(configs)), + Configs: make(map[string]model.ModelConfig, len(configs)), + List: make([]model.GroupScopeModelConfig, 0, len(configs)), + } + for _, config := range configs { + cache.Models = append(cache.Models, config.Model) + cache.Configs[config.Model] = config + cache.List = append(cache.List, model.GroupScopeModelConfig{ + GroupID: groupID, + ModelConfig: config, + }) + } + + require.NoError(t, model.CacheSetGroupScopeModelConfigs(cache)) + t.Cleanup(func() { + require.NoError(t, model.CacheDeleteGroupScopeModelConfig(groupID)) + }) +} + +type testModelConfigCache map[string]model.ModelConfig + +func (c testModelConfigCache) GetModelConfig(modelName string) (model.ModelConfig, bool) { + config, ok := c[modelName] + return config, ok +} + +func newRelayModelTestContext() (*httptest.ResponseRecorder, *gin.Context) { + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Set(middleware.Group, model.GroupCache{ + ID: "group-1", + Status: model.GroupStatusEnabled, + AvailableSets: []string{model.ChannelDefaultSet}, + }) + + availableModels := map[string][]string{ + model.ChannelDefaultSet: {"public-model"}, + } + + c.Set(middleware.Token, model.TokenCache{}) + c.Set(middleware.AvailableSets, []string{model.ChannelDefaultSet}) + c.Set(middleware.AvailableModels, availableModels) + c.Set(middleware.ModelCaches, &model.ModelCaches{ + ModelConfig: testModelConfigCache{}, + }) + c.Set(middleware.GroupChannelMode, middleware.GroupChannelModeOwn) + + return recorder, c +} + +func newRelayModelGlobalTestContext() (*httptest.ResponseRecorder, *gin.Context) { + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Set(middleware.Group, model.GroupCache{ + ID: "group-1", + Status: model.GroupStatusEnabled, + AvailableSets: []string{model.ChannelDefaultSet}, + }) + + availableModels := map[string][]string{ + model.ChannelDefaultSet: {"public-model"}, + } + + c.Set(middleware.Token, model.TokenCache{}) + c.Set(middleware.AvailableSets, []string{model.ChannelDefaultSet}) + c.Set(middleware.AvailableModels, availableModels) + c.Set(middleware.ModelCaches, &model.ModelCaches{ + ModelConfig: testModelConfigCache{}, + }) + c.Set(middleware.GroupChannelMode, middleware.GroupChannelModeGlobal) + + return recorder, c +} + +func TestListModelsIncludesGroupChannelOnlyModels(t *testing.T) { + gin.SetMode(gin.TestMode) + setTestGroupScopeModelConfigs(t, "group-1", model.ModelConfig{ + Model: "public-model", + Type: mode.ChatCompletions, + }) + + recorder, c := newRelayModelTestContext() + c.Request = httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/v1/models", nil) + + ListModels(c) + + require.Equal(t, http.StatusOK, recorder.Code) + + var response struct { + Object string `json:"object"` + Data []OpenAIModels `json:"data"` + } + require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &response)) + require.Equal(t, "list", response.Object) + require.Len(t, response.Data, 1) + require.Equal(t, "public-model", response.Data[0].ID) + require.Equal(t, "public-model", response.Data[0].Root) +} + +func TestRetrieveModelIncludesGroupChannelOnlyModel(t *testing.T) { + gin.SetMode(gin.TestMode) + setTestGroupScopeModelConfigs(t, "group-1", model.ModelConfig{ + Model: "public-model", + Type: mode.ChatCompletions, + }) + + recorder, c := newRelayModelTestContext() + c.Params = gin.Params{{Key: "model", Value: "public-model"}} + c.Request = httptest.NewRequestWithContext( + t.Context(), + http.MethodGet, + "/v1/models/public-model", + nil, + ) + + RetrieveModel(c) + + require.Equal(t, http.StatusOK, recorder.Code) + + var response OpenAIModels + require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &response)) + require.Equal(t, "public-model", response.ID) + require.Equal(t, "public-model", response.Root) +} + +func TestRetrieveModelReturnsCanonicalModelName(t *testing.T) { + gin.SetMode(gin.TestMode) + setTestGroupScopeModelConfigs(t, "group-1", model.ModelConfig{ + Model: "Public-Model", + Type: mode.ChatCompletions, + }) + + recorder, c := newRelayModelTestContext() + c.Set(middleware.AvailableModels, map[string][]string{ + model.ChannelDefaultSet: {"Public-Model"}, + }) + c.Params = gin.Params{{Key: "model", Value: "public-model"}} + c.Request = httptest.NewRequestWithContext( + t.Context(), + http.MethodGet, + "/v1/models/public-model", + nil, + ) + + RetrieveModel(c) + + require.Equal(t, http.StatusOK, recorder.Code) + + var response OpenAIModels + require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &response)) + require.Equal(t, "Public-Model", response.ID) + require.Equal(t, "Public-Model", response.Root) +} + +func TestRetrieveModelGroupChannelModeUsesGroupChannelModels(t *testing.T) { + gin.SetMode(gin.TestMode) + setTestGroupScopeModelConfigs(t, "group-1", model.ModelConfig{ + Model: "group-model", + Type: mode.ChatCompletions, + }) + + recorder, c := newRelayModelTestContext() + c.Set(middleware.Token, model.TokenCache{ + Models: []string{"global-model"}, + GroupChannelModels: []string{"group-model"}, + }) + c.Set(middleware.GroupChannelAvailableSets, []string{model.ChannelDefaultSet}) + c.Set(middleware.GroupChannelAvailableModels, map[string][]string{ + model.ChannelDefaultSet: {"group-model"}, + }) + c.Params = gin.Params{{Key: "model", Value: "group-model"}} + c.Request = httptest.NewRequestWithContext( + t.Context(), + http.MethodGet, + "/v1/models/group-model", + nil, + ) + + RetrieveModel(c) + + require.Equal(t, http.StatusOK, recorder.Code) + + var response OpenAIModels + require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &response)) + require.Equal(t, "group-model", response.ID) +} + +func TestRetrieveModelGlobalModeUsesGlobalModels(t *testing.T) { + gin.SetMode(gin.TestMode) + + recorder, c := newRelayModelGlobalTestContext() + c.Set(middleware.Token, model.TokenCache{ + Models: []string{"public-model"}, + GroupChannelModels: []string{"group-model"}, + }) + c.Set(middleware.ModelCaches, &model.ModelCaches{ + ModelConfig: testModelConfigCache{ + "public-model": {Model: "public-model", Type: mode.ChatCompletions}, + }, + }) + c.Params = gin.Params{{Key: "model", Value: "group-model"}} + c.Request = httptest.NewRequestWithContext( + t.Context(), + http.MethodGet, + "/v1/models/group-model", + nil, + ) + + RetrieveModel(c) + + require.Equal(t, http.StatusNotFound, recorder.Code) +} + +func TestRetrieveModelDefaultGlobalRequiresGlobalModelConfig(t *testing.T) { + gin.SetMode(gin.TestMode) + + recorder, c := newRelayModelGlobalTestContext() + c.Params = gin.Params{{Key: "model", Value: "public-model"}} + c.Request = httptest.NewRequestWithContext( + t.Context(), + http.MethodGet, + "/v1/models/public-model", + nil, + ) + + RetrieveModel(c) + + require.Equal(t, http.StatusNotFound, recorder.Code) +} + +func TestResolveModelConfigGroupOnlyUsesGroupScopeModelConfig(t *testing.T) { + t.Parallel() + setTestGroupScopeModelConfigs(t, "group-resolve", model.ModelConfig{ + Model: "public-model", + Type: mode.ChatCompletions, + RetryTimes: 5, + RPM: 60, + TPM: 120, + }) + + got, ok := middleware.ResolveModelConfig( + model.GroupCache{ + ID: "group-resolve", + RPMRatio: 2, + TPMRatio: 3, + ModelConfigs: map[string]model.GroupModelConfig{ + "public-model": { + Model: "public-model", + OverrideRetryTimes: true, + RetryTimes: 5, + }, + }, + }, + middleware.GroupChannelModeOwn, + &model.ModelCaches{ + ModelConfig: testModelConfigCache{ + "public-model": { + Model: "public-model", + Type: mode.GeminiImage, + RetryTimes: 9, + }, + }, + }, + "public-model", + ) + + require.True(t, ok) + require.Equal(t, "public-model", got.Model) + require.Equal(t, mode.ChatCompletions, got.Type) + require.Equal(t, int64(5), got.RetryTimes) + require.Equal(t, int64(120), got.RPM) + require.Equal(t, int64(360), got.TPM) +} + +func TestGroupChannelTestModelConfigUsesGroupScopeModelConfig(t *testing.T) { + t.Parallel() + setTestGroupScopeModelConfigs(t, "group-test-config", model.ModelConfig{ + Model: "public-model", + Type: mode.Anthropic, + RetryTimes: 5, + RPM: 60, + TPM: 120, + }) + + got, err := groupChannelTestModelConfig( + model.GroupCache{ + ID: "group-test-config", + RPMRatio: 2, + TPMRatio: 3, + ModelConfigs: map[string]model.GroupModelConfig{ + "public-model": { + Model: "public-model", + OverrideRetryTimes: true, + RetryTimes: 5, + OverrideLimit: true, + RPM: 60, + TPM: 120, + }, + }, + }, + "public-model", + ) + + require.NoError(t, err) + require.Equal(t, "public-model", got.Model) + require.Equal(t, mode.Anthropic, got.Type) + require.Equal(t, int64(5), got.RetryTimes) + require.Equal(t, int64(120), got.RPM) + require.Equal(t, int64(360), got.TPM) + require.Empty(t, got.Plugin) + require.Empty(t, got.Config) +} + +func TestGroupChannelTestableModelsUsesChannelModelsWhenModelConfigDisabled(t *testing.T) { + oldDisableModelConfig := config.DisableModelConfig + config.DisableModelConfig = true + t.Cleanup(func() { + config.DisableModelConfig = oldDisableModelConfig + }) + + got, err := groupChannelTestableModels("group-disabled-model-config", &model.GroupChannel{ + Models: []string{"z-model", "a-model"}, + ModelMapping: map[string]string{ + "mapped-model": "actual-model", + "a-model": "actual-a-model", + }, + }) + + require.NoError(t, err) + require.True(t, slices.IsSorted(got)) + require.Equal(t, []string{"a-model", "mapped-model", "z-model"}, got) +} + +func TestTestRequestModelConfigGuessesTypeWithoutDroppingOverrides(t *testing.T) { + t.Parallel() + + got := testRequestModelConfig(model.ModelConfig{ + Model: "claude-sonnet-4-5", + RetryTimes: 5, + RPM: 60, + }) + + require.Equal(t, "claude-sonnet-4-5", got.Model) + require.Equal(t, mode.ChatCompletions, got.Type) + require.Equal(t, int64(5), got.RetryTimes) + require.Equal(t, int64(60), got.RPM) +} diff --git a/core/controller/relay.go b/core/controller/relay.go index 205fceb7..53a5c5bc 100644 --- a/core/controller/relay.go +++ b/core/controller/relay.go @@ -17,15 +17,17 @@ import ( // @Tags relay // @Produce json // @Security ApiKeyAuth -// @Param request body model.GeneralOpenAIRequest true "Request" -// @Param Aiproxy-Channel header string false "Optional Aiproxy-Channel header" -// @Success 200 {object} model.TextResponse -// @Header all {integer} X-RateLimit-Limit-Requests "X-RateLimit-Limit-Requests" -// @Header all {integer} X-RateLimit-Limit-Tokens "X-RateLimit-Limit-Tokens" -// @Header all {integer} X-RateLimit-Remaining-Requests "X-RateLimit-Remaining-Requests" -// @Header all {integer} X-RateLimit-Remaining-Tokens "X-RateLimit-Remaining-Tokens" -// @Header all {string} X-RateLimit-Reset-Requests "X-RateLimit-Reset-Requests" -// @Header all {string} X-RateLimit-Reset-Tokens "X-RateLimit-Reset-Tokens" +// @Param request body model.GeneralOpenAIRequest true "Request" +// @Param Aiproxy-Channel header string false "Optional Aiproxy-Channel header" +// @Param X-Aiproxy-Group header string false "Optional group ID for internal token requests" +// @Param X-Aiproxy-Group-Channel-Mode header string false "Optional group channel mode for internal token requests. Values: global, own" Enums(global, own) +// @Success 200 {object} model.TextResponse +// @Header all {integer} X-RateLimit-Limit-Requests "X-RateLimit-Limit-Requests" +// @Header all {integer} X-RateLimit-Limit-Tokens "X-RateLimit-Limit-Tokens" +// @Header all {integer} X-RateLimit-Remaining-Requests "X-RateLimit-Remaining-Requests" +// @Header all {integer} X-RateLimit-Remaining-Tokens "X-RateLimit-Remaining-Tokens" +// @Header all {string} X-RateLimit-Reset-Requests "X-RateLimit-Reset-Requests" +// @Header all {string} X-RateLimit-Reset-Tokens "X-RateLimit-Reset-Tokens" // @Router /v1/completions [post] func Completions() []gin.HandlerFunc { return []gin.HandlerFunc{ @@ -41,15 +43,17 @@ func Completions() []gin.HandlerFunc { // @Tags relay // @Produce json // @Security ApiKeyAuth -// @Param request body model.AnthropicMessageRequest true "Request" -// @Param Aiproxy-Channel header string false "Optional Aiproxy-Channel header" -// @Success 200 {object} model.TextResponse -// @Header all {integer} X-RateLimit-Limit-Requests "X-RateLimit-Limit-Requests" -// @Header all {integer} X-RateLimit-Limit-Tokens "X-RateLimit-Limit-Tokens" -// @Header all {integer} X-RateLimit-Remaining-Requests "X-RateLimit-Remaining-Requests" -// @Header all {integer} X-RateLimit-Remaining-Tokens "X-RateLimit-Remaining-Tokens" -// @Header all {string} X-RateLimit-Reset-Requests "X-RateLimit-Reset-Requests" -// @Header all {string} X-RateLimit-Reset-Tokens "X-RateLimit-Reset-Tokens" +// @Param request body model.AnthropicMessageRequest true "Request" +// @Param Aiproxy-Channel header string false "Optional Aiproxy-Channel header" +// @Param X-Aiproxy-Group header string false "Optional group ID for internal token requests" +// @Param X-Aiproxy-Group-Channel-Mode header string false "Optional group channel mode for internal token requests. Values: global, own" Enums(global, own) +// @Success 200 {object} model.TextResponse +// @Header all {integer} X-RateLimit-Limit-Requests "X-RateLimit-Limit-Requests" +// @Header all {integer} X-RateLimit-Limit-Tokens "X-RateLimit-Limit-Tokens" +// @Header all {integer} X-RateLimit-Remaining-Requests "X-RateLimit-Remaining-Requests" +// @Header all {integer} X-RateLimit-Remaining-Tokens "X-RateLimit-Remaining-Tokens" +// @Header all {string} X-RateLimit-Reset-Requests "X-RateLimit-Reset-Requests" +// @Header all {string} X-RateLimit-Reset-Tokens "X-RateLimit-Reset-Tokens" // @Router /v1/messages [post] func Anthropic() []gin.HandlerFunc { return []gin.HandlerFunc{ @@ -65,15 +69,17 @@ func Anthropic() []gin.HandlerFunc { // @Tags relay // @Produce json // @Security ApiKeyAuth -// @Param request body model.GeneralOpenAIRequest true "Request" -// @Param Aiproxy-Channel header string false "Optional Aiproxy-Channel header" -// @Success 200 {object} model.TextResponse | model.ChatCompletionsStreamResponse -// @Header all {integer} X-RateLimit-Limit-Requests "X-RateLimit-Limit-Requests" -// @Header all {integer} X-RateLimit-Limit-Tokens "X-RateLimit-Limit-Tokens" -// @Header all {integer} X-RateLimit-Remaining-Requests "X-RateLimit-Remaining-Requests" -// @Header all {integer} X-RateLimit-Remaining-Tokens "X-RateLimit-Remaining-Tokens" -// @Header all {string} X-RateLimit-Reset-Requests "X-RateLimit-Reset-Requests" -// @Header all {string} X-RateLimit-Reset-Tokens "X-RateLimit-Reset-Tokens" +// @Param request body model.GeneralOpenAIRequest true "Request" +// @Param Aiproxy-Channel header string false "Optional Aiproxy-Channel header" +// @Param X-Aiproxy-Group header string false "Optional group ID for internal token requests" +// @Param X-Aiproxy-Group-Channel-Mode header string false "Optional group channel mode for internal token requests. Values: global, own" Enums(global, own) +// @Success 200 {object} model.TextResponse | model.ChatCompletionsStreamResponse +// @Header all {integer} X-RateLimit-Limit-Requests "X-RateLimit-Limit-Requests" +// @Header all {integer} X-RateLimit-Limit-Tokens "X-RateLimit-Limit-Tokens" +// @Header all {integer} X-RateLimit-Remaining-Requests "X-RateLimit-Remaining-Requests" +// @Header all {integer} X-RateLimit-Remaining-Tokens "X-RateLimit-Remaining-Tokens" +// @Header all {string} X-RateLimit-Reset-Requests "X-RateLimit-Reset-Requests" +// @Header all {string} X-RateLimit-Reset-Tokens "X-RateLimit-Reset-Tokens" // @Router /v1/chat/completions [post] func ChatCompletions() []gin.HandlerFunc { return []gin.HandlerFunc{ @@ -89,15 +95,17 @@ func ChatCompletions() []gin.HandlerFunc { // @Tags relay // @Produce json // @Security ApiKeyAuth -// @Param request body model.EmbeddingRequest true "Request" -// @Param Aiproxy-Channel header string false "Optional Aiproxy-Channel header" -// @Success 200 {object} model.EmbeddingResponse -// @Header all {integer} X-RateLimit-Limit-Requests "X-RateLimit-Limit-Requests" -// @Header all {integer} X-RateLimit-Limit-Tokens "X-RateLimit-Limit-Tokens" -// @Header all {integer} X-RateLimit-Remaining-Requests "X-RateLimit-Remaining-Requests" -// @Header all {integer} X-RateLimit-Remaining-Tokens "X-RateLimit-Remaining-Tokens" -// @Header all {string} X-RateLimit-Reset-Requests "X-RateLimit-Reset-Requests" -// @Header all {string} X-RateLimit-Reset-Tokens "X-RateLimit-Reset-Tokens" +// @Param request body model.EmbeddingRequest true "Request" +// @Param Aiproxy-Channel header string false "Optional Aiproxy-Channel header" +// @Param X-Aiproxy-Group header string false "Optional group ID for internal token requests" +// @Param X-Aiproxy-Group-Channel-Mode header string false "Optional group channel mode for internal token requests. Values: global, own" Enums(global, own) +// @Success 200 {object} model.EmbeddingResponse +// @Header all {integer} X-RateLimit-Limit-Requests "X-RateLimit-Limit-Requests" +// @Header all {integer} X-RateLimit-Limit-Tokens "X-RateLimit-Limit-Tokens" +// @Header all {integer} X-RateLimit-Remaining-Requests "X-RateLimit-Remaining-Requests" +// @Header all {integer} X-RateLimit-Remaining-Tokens "X-RateLimit-Remaining-Tokens" +// @Header all {string} X-RateLimit-Reset-Requests "X-RateLimit-Reset-Requests" +// @Header all {string} X-RateLimit-Reset-Tokens "X-RateLimit-Reset-Tokens" // @Router /v1/embeddings [post] func Embeddings() []gin.HandlerFunc { return []gin.HandlerFunc{ @@ -113,17 +121,19 @@ func Embeddings() []gin.HandlerFunc { // @Tags relay // @Produce json // @Security ApiKeyAuth -// @Param prompt formData string true "Prompt" -// @Param model formData string true "Model" -// @Param image formData file true "Images" -// @Param Aiproxy-Channel header string false "Optional Aiproxy-Channel header" -// @Success 200 {object} model.SttJSONResponse -// @Header all {integer} X-RateLimit-Limit-Requests "X-RateLimit-Limit-Requests" -// @Header all {integer} X-RateLimit-Limit-Tokens "X-RateLimit-Limit-Tokens" -// @Header all {integer} X-RateLimit-Remaining-Requests "X-RateLimit-Remaining-Requests" -// @Header all {integer} X-RateLimit-Remaining-Tokens "X-RateLimit-Remaining-Tokens" -// @Header all {string} X-RateLimit-Reset-Requests "X-RateLimit-Reset-Requests" -// @Header all {string} X-RateLimit-Reset-Tokens "X-RateLimit-Reset-Tokens" +// @Param prompt formData string true "Prompt" +// @Param model formData string true "Model" +// @Param image formData file true "Images" +// @Param Aiproxy-Channel header string false "Optional Aiproxy-Channel header" +// @Param X-Aiproxy-Group header string false "Optional group ID for internal token requests" +// @Param X-Aiproxy-Group-Channel-Mode header string false "Optional group channel mode for internal token requests. Values: global, own" Enums(global, own) +// @Success 200 {object} model.SttJSONResponse +// @Header all {integer} X-RateLimit-Limit-Requests "X-RateLimit-Limit-Requests" +// @Header all {integer} X-RateLimit-Limit-Tokens "X-RateLimit-Limit-Tokens" +// @Header all {integer} X-RateLimit-Remaining-Requests "X-RateLimit-Remaining-Requests" +// @Header all {integer} X-RateLimit-Remaining-Tokens "X-RateLimit-Remaining-Tokens" +// @Header all {string} X-RateLimit-Reset-Requests "X-RateLimit-Reset-Requests" +// @Header all {string} X-RateLimit-Reset-Tokens "X-RateLimit-Reset-Tokens" // @Router /v1/images/edits [post] func ImagesEdits() []gin.HandlerFunc { return []gin.HandlerFunc{ @@ -139,15 +149,17 @@ func ImagesEdits() []gin.HandlerFunc { // @Tags relay // @Produce json // @Security ApiKeyAuth -// @Param request body model.ImageRequest true "Request" -// @Param Aiproxy-Channel header string false "Optional Aiproxy-Channel header" -// @Success 200 {object} model.ImageResponse -// @Header all {integer} X-RateLimit-Limit-Requests "X-RateLimit-Limit-Requests" -// @Header all {integer} X-RateLimit-Limit-Tokens "X-RateLimit-Limit-Tokens" -// @Header all {integer} X-RateLimit-Remaining-Requests "X-RateLimit-Remaining-Requests" -// @Header all {integer} X-RateLimit-Remaining-Tokens "X-RateLimit-Remaining-Tokens" -// @Header all {string} X-RateLimit-Reset-Requests "X-RateLimit-Reset-Requests" -// @Header all {string} X-RateLimit-Reset-Tokens "X-RateLimit-Reset-Tokens" +// @Param request body model.ImageRequest true "Request" +// @Param Aiproxy-Channel header string false "Optional Aiproxy-Channel header" +// @Param X-Aiproxy-Group header string false "Optional group ID for internal token requests" +// @Param X-Aiproxy-Group-Channel-Mode header string false "Optional group channel mode for internal token requests. Values: global, own" Enums(global, own) +// @Success 200 {object} model.ImageResponse +// @Header all {integer} X-RateLimit-Limit-Requests "X-RateLimit-Limit-Requests" +// @Header all {integer} X-RateLimit-Limit-Tokens "X-RateLimit-Limit-Tokens" +// @Header all {integer} X-RateLimit-Remaining-Requests "X-RateLimit-Remaining-Requests" +// @Header all {integer} X-RateLimit-Remaining-Tokens "X-RateLimit-Remaining-Tokens" +// @Header all {string} X-RateLimit-Reset-Requests "X-RateLimit-Reset-Requests" +// @Header all {string} X-RateLimit-Reset-Tokens "X-RateLimit-Reset-Tokens" // @Router /v1/images/generations [post] func ImagesGenerations() []gin.HandlerFunc { return []gin.HandlerFunc{ @@ -163,15 +175,17 @@ func ImagesGenerations() []gin.HandlerFunc { // @Tags relay // @Produce json // @Security ApiKeyAuth -// @Param request body model.TextToSpeechRequest true "Request" -// @Param Aiproxy-Channel header string false "Optional Aiproxy-Channel header" -// @Success 200 {file} file "audio binary" -// @Header all {integer} X-RateLimit-Limit-Requests "X-RateLimit-Limit-Requests" -// @Header all {integer} X-RateLimit-Limit-Tokens "X-RateLimit-Limit-Tokens" -// @Header all {integer} X-RateLimit-Remaining-Requests "X-RateLimit-Remaining-Requests" -// @Header all {integer} X-RateLimit-Remaining-Tokens "X-RateLimit-Remaining-Tokens" -// @Header all {string} X-RateLimit-Reset-Requests "X-RateLimit-Reset-Requests" -// @Header all {string} X-RateLimit-Reset-Tokens "X-RateLimit-Reset-Tokens" +// @Param request body model.TextToSpeechRequest true "Request" +// @Param Aiproxy-Channel header string false "Optional Aiproxy-Channel header" +// @Param X-Aiproxy-Group header string false "Optional group ID for internal token requests" +// @Param X-Aiproxy-Group-Channel-Mode header string false "Optional group channel mode for internal token requests. Values: global, own" Enums(global, own) +// @Success 200 {file} file "audio binary" +// @Header all {integer} X-RateLimit-Limit-Requests "X-RateLimit-Limit-Requests" +// @Header all {integer} X-RateLimit-Limit-Tokens "X-RateLimit-Limit-Tokens" +// @Header all {integer} X-RateLimit-Remaining-Requests "X-RateLimit-Remaining-Requests" +// @Header all {integer} X-RateLimit-Remaining-Tokens "X-RateLimit-Remaining-Tokens" +// @Header all {string} X-RateLimit-Reset-Requests "X-RateLimit-Reset-Requests" +// @Header all {string} X-RateLimit-Reset-Tokens "X-RateLimit-Reset-Tokens" // @Router /v1/audio/speech [post] func AudioSpeech() []gin.HandlerFunc { return []gin.HandlerFunc{ @@ -187,16 +201,18 @@ func AudioSpeech() []gin.HandlerFunc { // @Tags relay // @Produce json // @Security ApiKeyAuth -// @Param model formData string true "Model" -// @Param file formData file true "File" -// @Param Aiproxy-Channel header string false "Optional Aiproxy-Channel header" -// @Success 200 {object} model.SttJSONResponse -// @Header all {integer} X-RateLimit-Limit-Requests "X-RateLimit-Limit-Requests" -// @Header all {integer} X-RateLimit-Limit-Tokens "X-RateLimit-Limit-Tokens" -// @Header all {integer} X-RateLimit-Remaining-Requests "X-RateLimit-Remaining-Requests" -// @Header all {integer} X-RateLimit-Remaining-Tokens "X-RateLimit-Remaining-Tokens" -// @Header all {string} X-RateLimit-Reset-Requests "X-RateLimit-Reset-Requests" -// @Header all {string} X-RateLimit-Reset-Tokens "X-RateLimit-Reset-Tokens" +// @Param model formData string true "Model" +// @Param file formData file true "File" +// @Param Aiproxy-Channel header string false "Optional Aiproxy-Channel header" +// @Param X-Aiproxy-Group header string false "Optional group ID for internal token requests" +// @Param X-Aiproxy-Group-Channel-Mode header string false "Optional group channel mode for internal token requests. Values: global, own" Enums(global, own) +// @Success 200 {object} model.SttJSONResponse +// @Header all {integer} X-RateLimit-Limit-Requests "X-RateLimit-Limit-Requests" +// @Header all {integer} X-RateLimit-Limit-Tokens "X-RateLimit-Limit-Tokens" +// @Header all {integer} X-RateLimit-Remaining-Requests "X-RateLimit-Remaining-Requests" +// @Header all {integer} X-RateLimit-Remaining-Tokens "X-RateLimit-Remaining-Tokens" +// @Header all {string} X-RateLimit-Reset-Requests "X-RateLimit-Reset-Requests" +// @Header all {string} X-RateLimit-Reset-Tokens "X-RateLimit-Reset-Tokens" // @Router /v1/audio/transcriptions [post] func AudioTranscription() []gin.HandlerFunc { return []gin.HandlerFunc{ @@ -212,16 +228,18 @@ func AudioTranscription() []gin.HandlerFunc { // @Tags relay // @Produce json // @Security ApiKeyAuth -// @Param model formData string true "Model" -// @Param file formData file true "File" -// @Param Aiproxy-Channel header string false "Optional Aiproxy-Channel header" -// @Success 200 {object} model.SttJSONResponse -// @Header all {integer} X-RateLimit-Limit-Requests "X-RateLimit-Limit-Requests" -// @Header all {integer} X-RateLimit-Limit-Tokens "X-RateLimit-Limit-Tokens" -// @Header all {integer} X-RateLimit-Remaining-Requests "X-RateLimit-Remaining-Requests" -// @Header all {integer} X-RateLimit-Remaining-Tokens "X-RateLimit-Remaining-Tokens" -// @Header all {string} X-RateLimit-Reset-Requests "X-RateLimit-Reset-Requests" -// @Header all {string} X-RateLimit-Reset-Tokens "X-RateLimit-Reset-Tokens" +// @Param model formData string true "Model" +// @Param file formData file true "File" +// @Param Aiproxy-Channel header string false "Optional Aiproxy-Channel header" +// @Param X-Aiproxy-Group header string false "Optional group ID for internal token requests" +// @Param X-Aiproxy-Group-Channel-Mode header string false "Optional group channel mode for internal token requests. Values: global, own" Enums(global, own) +// @Success 200 {object} model.SttJSONResponse +// @Header all {integer} X-RateLimit-Limit-Requests "X-RateLimit-Limit-Requests" +// @Header all {integer} X-RateLimit-Limit-Tokens "X-RateLimit-Limit-Tokens" +// @Header all {integer} X-RateLimit-Remaining-Requests "X-RateLimit-Remaining-Requests" +// @Header all {integer} X-RateLimit-Remaining-Tokens "X-RateLimit-Remaining-Tokens" +// @Header all {string} X-RateLimit-Reset-Requests "X-RateLimit-Reset-Requests" +// @Header all {string} X-RateLimit-Reset-Tokens "X-RateLimit-Reset-Tokens" // @Router /v1/audio/translations [post] func AudioTranslation() []gin.HandlerFunc { return []gin.HandlerFunc{ @@ -244,15 +262,17 @@ func Moderations() []gin.HandlerFunc { // @Tags relay // @Produce json // @Security ApiKeyAuth -// @Param request body model.RerankRequest true "Request" -// @Param Aiproxy-Channel header string false "Optional Aiproxy-Channel header" -// @Success 200 {object} model.RerankResponse -// @Header all {integer} X-RateLimit-Limit-Requests "X-RateLimit-Limit-Requests" -// @Header all {integer} X-RateLimit-Limit-Tokens "X-RateLimit-Limit-Tokens" -// @Header all {integer} X-RateLimit-Remaining-Requests "X-RateLimit-Remaining-Requests" -// @Header all {integer} X-RateLimit-Remaining-Tokens "X-RateLimit-Remaining-Tokens" -// @Header all {string} X-RateLimit-Reset-Requests "X-RateLimit-Reset-Requests" -// @Header all {string} X-RateLimit-Reset-Tokens "X-RateLimit-Reset-Tokens" +// @Param request body model.RerankRequest true "Request" +// @Param Aiproxy-Channel header string false "Optional Aiproxy-Channel header" +// @Param X-Aiproxy-Group header string false "Optional group ID for internal token requests" +// @Param X-Aiproxy-Group-Channel-Mode header string false "Optional group channel mode for internal token requests. Values: global, own" Enums(global, own) +// @Success 200 {object} model.RerankResponse +// @Header all {integer} X-RateLimit-Limit-Requests "X-RateLimit-Limit-Requests" +// @Header all {integer} X-RateLimit-Limit-Tokens "X-RateLimit-Limit-Tokens" +// @Header all {integer} X-RateLimit-Remaining-Requests "X-RateLimit-Remaining-Requests" +// @Header all {integer} X-RateLimit-Remaining-Tokens "X-RateLimit-Remaining-Tokens" +// @Header all {string} X-RateLimit-Reset-Requests "X-RateLimit-Reset-Requests" +// @Header all {string} X-RateLimit-Reset-Tokens "X-RateLimit-Reset-Tokens" // @Router /v1/rerank [post] func Rerank() []gin.HandlerFunc { return []gin.HandlerFunc{ @@ -268,16 +288,18 @@ func Rerank() []gin.HandlerFunc { // @Tags relay // @Produce json // @Security ApiKeyAuth -// @Param model formData string true "Model" -// @Param file formData file true "File" -// @Param Aiproxy-Channel header string false "Optional Aiproxy-Channel header" -// @Success 200 {object} model.ParsePdfResponse -// @Header all {integer} X-RateLimit-Limit-Requests "X-RateLimit-Limit-Requests" -// @Header all {integer} X-RateLimit-Limit-Tokens "X-RateLimit-Limit-Tokens" -// @Header all {integer} X-RateLimit-Remaining-Requests "X-RateLimit-Remaining-Requests" -// @Header all {integer} X-RateLimit-Remaining-Tokens "X-RateLimit-Remaining-Tokens" -// @Header all {string} X-RateLimit-Reset-Requests "X-RateLimit-Reset-Requests" -// @Header all {string} X-RateLimit-Reset-Tokens "X-RateLimit-Reset-Tokens" +// @Param model formData string true "Model" +// @Param file formData file true "File" +// @Param Aiproxy-Channel header string false "Optional Aiproxy-Channel header" +// @Param X-Aiproxy-Group header string false "Optional group ID for internal token requests" +// @Param X-Aiproxy-Group-Channel-Mode header string false "Optional group channel mode for internal token requests. Values: global, own" Enums(global, own) +// @Success 200 {object} model.ParsePdfResponse +// @Header all {integer} X-RateLimit-Limit-Requests "X-RateLimit-Limit-Requests" +// @Header all {integer} X-RateLimit-Limit-Tokens "X-RateLimit-Limit-Tokens" +// @Header all {integer} X-RateLimit-Remaining-Requests "X-RateLimit-Remaining-Requests" +// @Header all {integer} X-RateLimit-Remaining-Tokens "X-RateLimit-Remaining-Tokens" +// @Header all {string} X-RateLimit-Reset-Requests "X-RateLimit-Reset-Requests" +// @Header all {string} X-RateLimit-Reset-Tokens "X-RateLimit-Reset-Tokens" // @Router /v1/parse/pdf [post] func ParsePdf() []gin.HandlerFunc { return []gin.HandlerFunc{ @@ -293,15 +315,17 @@ func ParsePdf() []gin.HandlerFunc { // @Tags relay // @Produce json // @Security ApiKeyAuth -// @Param request body model.VideoGenerationJobRequest true "Request" -// @Param Aiproxy-Channel header string false "Optional Aiproxy-Channel header" -// @Success 200 {object} model.VideoGenerationJob -// @Header all {integer} X-RateLimit-Limit-Requests "X-RateLimit-Limit-Requests" -// @Header all {integer} X-RateLimit-Limit-Tokens "X-RateLimit-Limit-Tokens" -// @Header all {integer} X-RateLimit-Remaining-Requests "X-RateLimit-Remaining-Requests" -// @Header all {integer} X-RateLimit-Remaining-Tokens "X-RateLimit-Remaining-Tokens" -// @Header all {string} X-RateLimit-Reset-Requests "X-RateLimit-Reset-Requests" -// @Header all {string} X-RateLimit-Reset-Tokens "X-RateLimit-Reset-Tokens" +// @Param request body model.VideoGenerationJobRequest true "Request" +// @Param Aiproxy-Channel header string false "Optional Aiproxy-Channel header" +// @Param X-Aiproxy-Group header string false "Optional group ID for internal token requests" +// @Param X-Aiproxy-Group-Channel-Mode header string false "Optional group channel mode for internal token requests. Values: global, own" Enums(global, own) +// @Success 200 {object} model.VideoGenerationJob +// @Header all {integer} X-RateLimit-Limit-Requests "X-RateLimit-Limit-Requests" +// @Header all {integer} X-RateLimit-Limit-Tokens "X-RateLimit-Limit-Tokens" +// @Header all {integer} X-RateLimit-Remaining-Requests "X-RateLimit-Remaining-Requests" +// @Header all {integer} X-RateLimit-Remaining-Tokens "X-RateLimit-Remaining-Tokens" +// @Header all {string} X-RateLimit-Reset-Requests "X-RateLimit-Reset-Requests" +// @Header all {string} X-RateLimit-Reset-Tokens "X-RateLimit-Reset-Tokens" // @Router /v1/video/generations/jobs [post] func VideoGenerationsJobs() []gin.HandlerFunc { return []gin.HandlerFunc{ @@ -318,15 +342,17 @@ func VideoGenerationsJobs() []gin.HandlerFunc { // @Accept json // @Produce json // @Security ApiKeyAuth -// @Param request body object true "Ali DashScope video synthesis request" -// @Param Aiproxy-Channel header string false "Optional Aiproxy-Channel header" -// @Success 200 {object} object -// @Header all {integer} X-RateLimit-Limit-Requests "X-RateLimit-Limit-Requests" -// @Header all {integer} X-RateLimit-Limit-Tokens "X-RateLimit-Limit-Tokens" -// @Header all {integer} X-RateLimit-Remaining-Requests "X-RateLimit-Remaining-Requests" -// @Header all {integer} X-RateLimit-Remaining-Tokens "X-RateLimit-Remaining-Tokens" -// @Header all {string} X-RateLimit-Reset-Requests "X-RateLimit-Reset-Requests" -// @Header all {string} X-RateLimit-Reset-Tokens "X-RateLimit-Reset-Tokens" +// @Param request body object true "Ali DashScope video synthesis request" +// @Param Aiproxy-Channel header string false "Optional Aiproxy-Channel header" +// @Param X-Aiproxy-Group header string false "Optional group ID for internal token requests" +// @Param X-Aiproxy-Group-Channel-Mode header string false "Optional group channel mode for internal token requests. Values: global, own" Enums(global, own) +// @Success 200 {object} object +// @Header all {integer} X-RateLimit-Limit-Requests "X-RateLimit-Limit-Requests" +// @Header all {integer} X-RateLimit-Limit-Tokens "X-RateLimit-Limit-Tokens" +// @Header all {integer} X-RateLimit-Remaining-Requests "X-RateLimit-Remaining-Requests" +// @Header all {integer} X-RateLimit-Remaining-Tokens "X-RateLimit-Remaining-Tokens" +// @Header all {string} X-RateLimit-Reset-Requests "X-RateLimit-Reset-Requests" +// @Header all {string} X-RateLimit-Reset-Tokens "X-RateLimit-Reset-Tokens" // @Router /api/v1/services/aigc/video-generation/video-synthesis [post] func AliVideo() []gin.HandlerFunc { return []gin.HandlerFunc{ @@ -342,15 +368,17 @@ func AliVideo() []gin.HandlerFunc { // @Tags relay // @Produce json // @Security ApiKeyAuth -// @Param task_id path string true "Task ID" -// @Param Aiproxy-Channel header string false "Optional Aiproxy-Channel header" -// @Success 200 {object} object -// @Header all {integer} X-RateLimit-Limit-Requests "X-RateLimit-Limit-Requests" -// @Header all {integer} X-RateLimit-Limit-Tokens "X-RateLimit-Limit-Tokens" -// @Header all {integer} X-RateLimit-Remaining-Requests "X-RateLimit-Remaining-Requests" -// @Header all {integer} X-RateLimit-Remaining-Tokens "X-RateLimit-Remaining-Tokens" -// @Header all {string} X-RateLimit-Reset-Requests "X-RateLimit-Reset-Requests" -// @Header all {string} X-RateLimit-Reset-Tokens "X-RateLimit-Reset-Tokens" +// @Param task_id path string true "Task ID" +// @Param Aiproxy-Channel header string false "Optional Aiproxy-Channel header" +// @Param X-Aiproxy-Group header string false "Optional group ID for internal token requests" +// @Param X-Aiproxy-Group-Channel-Mode header string false "Optional group channel mode for internal token requests. Values: global, own" Enums(global, own) +// @Success 200 {object} object +// @Header all {integer} X-RateLimit-Limit-Requests "X-RateLimit-Limit-Requests" +// @Header all {integer} X-RateLimit-Limit-Tokens "X-RateLimit-Limit-Tokens" +// @Header all {integer} X-RateLimit-Remaining-Requests "X-RateLimit-Remaining-Requests" +// @Header all {integer} X-RateLimit-Remaining-Tokens "X-RateLimit-Remaining-Tokens" +// @Header all {string} X-RateLimit-Reset-Requests "X-RateLimit-Reset-Requests" +// @Header all {string} X-RateLimit-Reset-Tokens "X-RateLimit-Reset-Tokens" // @Router /api/v1/tasks/{task_id} [get] func AliVideoTask() []gin.HandlerFunc { return []gin.HandlerFunc{ @@ -367,15 +395,17 @@ func AliVideoTask() []gin.HandlerFunc { // @Accept json // @Produce json // @Security ApiKeyAuth -// @Param request body object true "Doubao video generation task request" -// @Param Aiproxy-Channel header string false "Optional Aiproxy-Channel header" -// @Success 200 {object} object -// @Header all {integer} X-RateLimit-Limit-Requests "X-RateLimit-Limit-Requests" -// @Header all {integer} X-RateLimit-Limit-Tokens "X-RateLimit-Limit-Tokens" -// @Header all {integer} X-RateLimit-Remaining-Requests "X-RateLimit-Remaining-Requests" -// @Header all {integer} X-RateLimit-Remaining-Tokens "X-RateLimit-Remaining-Tokens" -// @Header all {string} X-RateLimit-Reset-Requests "X-RateLimit-Reset-Requests" -// @Header all {string} X-RateLimit-Reset-Tokens "X-RateLimit-Reset-Tokens" +// @Param request body object true "Doubao video generation task request" +// @Param Aiproxy-Channel header string false "Optional Aiproxy-Channel header" +// @Param X-Aiproxy-Group header string false "Optional group ID for internal token requests" +// @Param X-Aiproxy-Group-Channel-Mode header string false "Optional group channel mode for internal token requests. Values: global, own" Enums(global, own) +// @Success 200 {object} object +// @Header all {integer} X-RateLimit-Limit-Requests "X-RateLimit-Limit-Requests" +// @Header all {integer} X-RateLimit-Limit-Tokens "X-RateLimit-Limit-Tokens" +// @Header all {integer} X-RateLimit-Remaining-Requests "X-RateLimit-Remaining-Requests" +// @Header all {integer} X-RateLimit-Remaining-Tokens "X-RateLimit-Remaining-Tokens" +// @Header all {string} X-RateLimit-Reset-Requests "X-RateLimit-Reset-Requests" +// @Header all {string} X-RateLimit-Reset-Tokens "X-RateLimit-Reset-Tokens" // @Router /api/v3/contents/generations/tasks [post] func DoubaoVideo() []gin.HandlerFunc { return []gin.HandlerFunc{ @@ -391,15 +421,17 @@ func DoubaoVideo() []gin.HandlerFunc { // @Tags relay // @Produce json // @Security ApiKeyAuth -// @Param task_id path string true "Task ID" -// @Param Aiproxy-Channel header string false "Optional Aiproxy-Channel header" -// @Success 200 {object} object -// @Header all {integer} X-RateLimit-Limit-Requests "X-RateLimit-Limit-Requests" -// @Header all {integer} X-RateLimit-Limit-Tokens "X-RateLimit-Limit-Tokens" -// @Header all {integer} X-RateLimit-Remaining-Requests "X-RateLimit-Remaining-Requests" -// @Header all {integer} X-RateLimit-Remaining-Tokens "X-RateLimit-Remaining-Tokens" -// @Header all {string} X-RateLimit-Reset-Requests "X-RateLimit-Reset-Requests" -// @Header all {string} X-RateLimit-Reset-Tokens "X-RateLimit-Reset-Tokens" +// @Param task_id path string true "Task ID" +// @Param Aiproxy-Channel header string false "Optional Aiproxy-Channel header" +// @Param X-Aiproxy-Group header string false "Optional group ID for internal token requests" +// @Param X-Aiproxy-Group-Channel-Mode header string false "Optional group channel mode for internal token requests. Values: global, own" Enums(global, own) +// @Success 200 {object} object +// @Header all {integer} X-RateLimit-Limit-Requests "X-RateLimit-Limit-Requests" +// @Header all {integer} X-RateLimit-Limit-Tokens "X-RateLimit-Limit-Tokens" +// @Header all {integer} X-RateLimit-Remaining-Requests "X-RateLimit-Remaining-Requests" +// @Header all {integer} X-RateLimit-Remaining-Tokens "X-RateLimit-Remaining-Tokens" +// @Header all {string} X-RateLimit-Reset-Requests "X-RateLimit-Reset-Requests" +// @Header all {string} X-RateLimit-Reset-Tokens "X-RateLimit-Reset-Tokens" // @Router /api/v3/contents/generations/tasks/{task_id} [get] func DoubaoVideoTask() []gin.HandlerFunc { return []gin.HandlerFunc{ @@ -415,16 +447,18 @@ func DoubaoVideoTask() []gin.HandlerFunc { // @Tags relay // @Produce json // @Security ApiKeyAuth -// @Param task_id path string true "Task ID" -// @Param Aiproxy-Channel header string false "Optional Aiproxy-Channel header" -// @Success 200 {object} object -// @Success 204 {object} nil -// @Header all {integer} X-RateLimit-Limit-Requests "X-RateLimit-Limit-Requests" -// @Header all {integer} X-RateLimit-Limit-Tokens "X-RateLimit-Limit-Tokens" -// @Header all {integer} X-RateLimit-Remaining-Requests "X-RateLimit-Remaining-Requests" -// @Header all {integer} X-RateLimit-Remaining-Tokens "X-RateLimit-Remaining-Tokens" -// @Header all {string} X-RateLimit-Reset-Requests "X-RateLimit-Reset-Requests" -// @Header all {string} X-RateLimit-Reset-Tokens "X-RateLimit-Reset-Tokens" +// @Param task_id path string true "Task ID" +// @Param Aiproxy-Channel header string false "Optional Aiproxy-Channel header" +// @Param X-Aiproxy-Group header string false "Optional group ID for internal token requests" +// @Param X-Aiproxy-Group-Channel-Mode header string false "Optional group channel mode for internal token requests. Values: global, own" Enums(global, own) +// @Success 200 {object} object +// @Success 204 {object} nil +// @Header all {integer} X-RateLimit-Limit-Requests "X-RateLimit-Limit-Requests" +// @Header all {integer} X-RateLimit-Limit-Tokens "X-RateLimit-Limit-Tokens" +// @Header all {integer} X-RateLimit-Remaining-Requests "X-RateLimit-Remaining-Requests" +// @Header all {integer} X-RateLimit-Remaining-Tokens "X-RateLimit-Remaining-Tokens" +// @Header all {string} X-RateLimit-Reset-Requests "X-RateLimit-Reset-Requests" +// @Header all {string} X-RateLimit-Reset-Tokens "X-RateLimit-Reset-Tokens" // @Router /api/v3/contents/generations/tasks/{task_id} [delete] func DeleteDoubaoVideoTask() []gin.HandlerFunc { return []gin.HandlerFunc{ @@ -440,15 +474,17 @@ func DeleteDoubaoVideoTask() []gin.HandlerFunc { // @Tags relay // @Produce json // @Security ApiKeyAuth -// @Param request body model.VideoGenerationJobRequest true "Request" -// @Param Aiproxy-Channel header string false "Optional Aiproxy-Channel header" -// @Success 200 {object} model.VideoGenerationJob -// @Header all {integer} X-RateLimit-Limit-Requests "X-RateLimit-Limit-Requests" -// @Header all {integer} X-RateLimit-Limit-Tokens "X-RateLimit-Limit-Tokens" -// @Header all {integer} X-RateLimit-Remaining-Requests "X-RateLimit-Remaining-Requests" -// @Header all {integer} X-RateLimit-Remaining-Tokens "X-RateLimit-Remaining-Tokens" -// @Header all {string} X-RateLimit-Reset-Requests "X-RateLimit-Reset-Requests" -// @Header all {string} X-RateLimit-Reset-Tokens "X-RateLimit-Reset-Tokens" +// @Param request body model.VideoGenerationJobRequest true "Request" +// @Param Aiproxy-Channel header string false "Optional Aiproxy-Channel header" +// @Param X-Aiproxy-Group header string false "Optional group ID for internal token requests" +// @Param X-Aiproxy-Group-Channel-Mode header string false "Optional group channel mode for internal token requests. Values: global, own" Enums(global, own) +// @Success 200 {object} model.VideoGenerationJob +// @Header all {integer} X-RateLimit-Limit-Requests "X-RateLimit-Limit-Requests" +// @Header all {integer} X-RateLimit-Limit-Tokens "X-RateLimit-Limit-Tokens" +// @Header all {integer} X-RateLimit-Remaining-Requests "X-RateLimit-Remaining-Requests" +// @Header all {integer} X-RateLimit-Remaining-Tokens "X-RateLimit-Remaining-Tokens" +// @Header all {string} X-RateLimit-Reset-Requests "X-RateLimit-Reset-Requests" +// @Header all {string} X-RateLimit-Reset-Tokens "X-RateLimit-Reset-Tokens" // @Router /v1/video/generations/jobs/{id} [get] func VideoGenerationsGetJobs() []gin.HandlerFunc { return []gin.HandlerFunc{ @@ -464,15 +500,17 @@ func VideoGenerationsGetJobs() []gin.HandlerFunc { // @Tags relay // @Produce json // @Security ApiKeyAuth -// @Param request body model.VideoGenerationJobRequest true "Request" -// @Param Aiproxy-Channel header string false "Optional Aiproxy-Channel header" -// @Success 200 {file} file "video binary" -// @Header all {integer} X-RateLimit-Limit-Requests "X-RateLimit-Limit-Requests" -// @Header all {integer} X-RateLimit-Limit-Tokens "X-RateLimit-Limit-Tokens" -// @Header all {integer} X-RateLimit-Remaining-Requests "X-RateLimit-Remaining-Requests" -// @Header all {integer} X-RateLimit-Remaining-Tokens "X-RateLimit-Remaining-Tokens" -// @Header all {string} X-RateLimit-Reset-Requests "X-RateLimit-Reset-Requests" -// @Header all {string} X-RateLimit-Reset-Tokens "X-RateLimit-Reset-Tokens" +// @Param request body model.VideoGenerationJobRequest true "Request" +// @Param Aiproxy-Channel header string false "Optional Aiproxy-Channel header" +// @Param X-Aiproxy-Group header string false "Optional group ID for internal token requests" +// @Param X-Aiproxy-Group-Channel-Mode header string false "Optional group channel mode for internal token requests. Values: global, own" Enums(global, own) +// @Success 200 {file} file "video binary" +// @Header all {integer} X-RateLimit-Limit-Requests "X-RateLimit-Limit-Requests" +// @Header all {integer} X-RateLimit-Limit-Tokens "X-RateLimit-Limit-Tokens" +// @Header all {integer} X-RateLimit-Remaining-Requests "X-RateLimit-Remaining-Requests" +// @Header all {integer} X-RateLimit-Remaining-Tokens "X-RateLimit-Remaining-Tokens" +// @Header all {string} X-RateLimit-Reset-Requests "X-RateLimit-Reset-Requests" +// @Header all {string} X-RateLimit-Reset-Tokens "X-RateLimit-Reset-Tokens" // @Router /v1/video/generations/{id}/content/video [get] func VideoGenerationsContent() []gin.HandlerFunc { return []gin.HandlerFunc{ @@ -488,15 +526,17 @@ func VideoGenerationsContent() []gin.HandlerFunc { // @Tags relay // @Produce json // @Security ApiKeyAuth -// @Param request body model.VideosRequest true "Request" -// @Param Aiproxy-Channel header string false "Optional Aiproxy-Channel header" -// @Success 200 {object} model.Video -// @Header all {integer} X-RateLimit-Limit-Requests "X-RateLimit-Limit-Requests" -// @Header all {integer} X-RateLimit-Limit-Tokens "X-RateLimit-Limit-Tokens" -// @Header all {integer} X-RateLimit-Remaining-Requests "X-RateLimit-Remaining-Requests" -// @Header all {integer} X-RateLimit-Remaining-Tokens "X-RateLimit-Remaining-Tokens" -// @Header all {string} X-RateLimit-Reset-Requests "X-RateLimit-Reset-Requests" -// @Header all {string} X-RateLimit-Reset-Tokens "X-RateLimit-Reset-Tokens" +// @Param request body model.VideosRequest true "Request" +// @Param Aiproxy-Channel header string false "Optional Aiproxy-Channel header" +// @Param X-Aiproxy-Group header string false "Optional group ID for internal token requests" +// @Param X-Aiproxy-Group-Channel-Mode header string false "Optional group channel mode for internal token requests. Values: global, own" Enums(global, own) +// @Success 200 {object} model.Video +// @Header all {integer} X-RateLimit-Limit-Requests "X-RateLimit-Limit-Requests" +// @Header all {integer} X-RateLimit-Limit-Tokens "X-RateLimit-Limit-Tokens" +// @Header all {integer} X-RateLimit-Remaining-Requests "X-RateLimit-Remaining-Requests" +// @Header all {integer} X-RateLimit-Remaining-Tokens "X-RateLimit-Remaining-Tokens" +// @Header all {string} X-RateLimit-Reset-Requests "X-RateLimit-Reset-Requests" +// @Header all {string} X-RateLimit-Reset-Tokens "X-RateLimit-Reset-Tokens" // @Router /v1/videos [post] func Videos() []gin.HandlerFunc { return []gin.HandlerFunc{ @@ -512,9 +552,11 @@ func Videos() []gin.HandlerFunc { // @Tags relay // @Produce json // @Security ApiKeyAuth -// @Param request body model.VideosEditRequest true "Request" -// @Param Aiproxy-Channel header string false "Optional Aiproxy-Channel header" -// @Success 200 {object} model.Video +// @Param request body model.VideosEditRequest true "Request" +// @Param Aiproxy-Channel header string false "Optional Aiproxy-Channel header" +// @Param X-Aiproxy-Group header string false "Optional group ID for internal token requests" +// @Param X-Aiproxy-Group-Channel-Mode header string false "Optional group channel mode for internal token requests. Values: global, own" Enums(global, own) +// @Success 200 {object} model.Video // @Router /v1/videos/edits [post] func EditVideo() []gin.HandlerFunc { return []gin.HandlerFunc{ @@ -530,9 +572,11 @@ func EditVideo() []gin.HandlerFunc { // @Tags relay // @Produce json // @Security ApiKeyAuth -// @Param request body model.VideosExtensionRequest true "Request" -// @Param Aiproxy-Channel header string false "Optional Aiproxy-Channel header" -// @Success 200 {object} model.Video +// @Param request body model.VideosExtensionRequest true "Request" +// @Param Aiproxy-Channel header string false "Optional Aiproxy-Channel header" +// @Param X-Aiproxy-Group header string false "Optional group ID for internal token requests" +// @Param X-Aiproxy-Group-Channel-Mode header string false "Optional group channel mode for internal token requests. Values: global, own" Enums(global, own) +// @Success 200 {object} model.Video // @Router /v1/videos/extensions [post] func ExtendVideo() []gin.HandlerFunc { return []gin.HandlerFunc{ @@ -548,9 +592,11 @@ func ExtendVideo() []gin.HandlerFunc { // @Tags relay // @Produce json // @Security ApiKeyAuth -// @Param video_id path string true "Video ID" -// @Param Aiproxy-Channel header string false "Optional Aiproxy-Channel header" -// @Success 200 {object} model.Video +// @Param video_id path string true "Video ID" +// @Param Aiproxy-Channel header string false "Optional Aiproxy-Channel header" +// @Param X-Aiproxy-Group header string false "Optional group ID for internal token requests" +// @Param X-Aiproxy-Group-Channel-Mode header string false "Optional group channel mode for internal token requests. Values: global, own" Enums(global, own) +// @Success 200 {object} model.Video // @Router /v1/videos/{video_id} [get] func GetVideo() []gin.HandlerFunc { return []gin.HandlerFunc{ @@ -566,9 +612,11 @@ func GetVideo() []gin.HandlerFunc { // @Tags relay // @Produce application/octet-stream // @Security ApiKeyAuth -// @Param video_id path string true "Video ID" -// @Param Aiproxy-Channel header string false "Optional Aiproxy-Channel header" -// @Success 200 {file} file "video binary" +// @Param video_id path string true "Video ID" +// @Param Aiproxy-Channel header string false "Optional Aiproxy-Channel header" +// @Param X-Aiproxy-Group header string false "Optional group ID for internal token requests" +// @Param X-Aiproxy-Group-Channel-Mode header string false "Optional group channel mode for internal token requests. Values: global, own" Enums(global, own) +// @Success 200 {file} file "video binary" // @Router /v1/videos/{video_id}/content [get] func GetVideoContent() []gin.HandlerFunc { return []gin.HandlerFunc{ @@ -584,8 +632,10 @@ func GetVideoContent() []gin.HandlerFunc { // @Tags relay // @Produce json // @Security ApiKeyAuth -// @Param video_id path string true "Video ID" -// @Param Aiproxy-Channel header string false "Optional Aiproxy-Channel header" +// @Param video_id path string true "Video ID" +// @Param Aiproxy-Channel header string false "Optional Aiproxy-Channel header" +// @Param X-Aiproxy-Group header string false "Optional group ID for internal token requests" +// @Param X-Aiproxy-Group-Channel-Mode header string false "Optional group channel mode for internal token requests. Values: global, own" Enums(global, own) // @Success 204 // @Router /v1/videos/{video_id} [delete] func DeleteVideo() []gin.HandlerFunc { @@ -602,10 +652,12 @@ func DeleteVideo() []gin.HandlerFunc { // @Tags relay // @Produce json // @Security ApiKeyAuth -// @Param video_id path string true "Video ID" -// @Param request body model.VideosRemixRequest true "Request" -// @Param Aiproxy-Channel header string false "Optional Aiproxy-Channel header" -// @Success 200 {object} model.Video +// @Param video_id path string true "Video ID" +// @Param request body model.VideosRemixRequest true "Request" +// @Param Aiproxy-Channel header string false "Optional Aiproxy-Channel header" +// @Param X-Aiproxy-Group header string false "Optional group ID for internal token requests" +// @Param X-Aiproxy-Group-Channel-Mode header string false "Optional group channel mode for internal token requests. Values: global, own" Enums(global, own) +// @Success 200 {object} model.Video // @Router /v1/videos/{video_id}/remix [post] func RemixVideo() []gin.HandlerFunc { return []gin.HandlerFunc{ @@ -621,15 +673,17 @@ func RemixVideo() []gin.HandlerFunc { // @Tags relay // @Produce json // @Security ApiKeyAuth -// @Param request body model.CreateResponseRequest true "Request" -// @Param Aiproxy-Channel header string false "Optional Aiproxy-Channel header" -// @Success 200 {object} model.Response -// @Header all {integer} X-RateLimit-Limit-Requests "X-RateLimit-Limit-Requests" -// @Header all {integer} X-RateLimit-Limit-Tokens "X-RateLimit-Limit-Tokens" -// @Header all {integer} X-RateLimit-Remaining-Requests "X-RateLimit-Remaining-Requests" -// @Header all {integer} X-RateLimit-Remaining-Tokens "X-RateLimit-Remaining-Tokens" -// @Header all {string} X-RateLimit-Reset-Requests "X-RateLimit-Reset-Requests" -// @Header all {string} X-RateLimit-Reset-Tokens "X-RateLimit-Reset-Tokens" +// @Param request body model.CreateResponseRequest true "Request" +// @Param Aiproxy-Channel header string false "Optional Aiproxy-Channel header" +// @Param X-Aiproxy-Group header string false "Optional group ID for internal token requests" +// @Param X-Aiproxy-Group-Channel-Mode header string false "Optional group channel mode for internal token requests. Values: global, own" Enums(global, own) +// @Success 200 {object} model.Response +// @Header all {integer} X-RateLimit-Limit-Requests "X-RateLimit-Limit-Requests" +// @Header all {integer} X-RateLimit-Limit-Tokens "X-RateLimit-Limit-Tokens" +// @Header all {integer} X-RateLimit-Remaining-Requests "X-RateLimit-Remaining-Requests" +// @Header all {integer} X-RateLimit-Remaining-Tokens "X-RateLimit-Remaining-Tokens" +// @Header all {string} X-RateLimit-Reset-Requests "X-RateLimit-Reset-Requests" +// @Header all {string} X-RateLimit-Reset-Tokens "X-RateLimit-Reset-Tokens" // @Router /v1/responses [post] func CreateResponse() []gin.HandlerFunc { return []gin.HandlerFunc{ @@ -645,9 +699,11 @@ func CreateResponse() []gin.HandlerFunc { // @Tags relay // @Produce json // @Security ApiKeyAuth -// @Param response_id path string true "Response ID" -// @Param Aiproxy-Channel header string false "Optional Aiproxy-Channel header" -// @Success 200 {object} model.Response +// @Param response_id path string true "Response ID" +// @Param Aiproxy-Channel header string false "Optional Aiproxy-Channel header" +// @Param X-Aiproxy-Group header string false "Optional group ID for internal token requests" +// @Param X-Aiproxy-Group-Channel-Mode header string false "Optional group channel mode for internal token requests. Values: global, own" Enums(global, own) +// @Success 200 {object} model.Response // @Router /v1/responses/{response_id} [get] func GetResponse() []gin.HandlerFunc { return []gin.HandlerFunc{ @@ -663,8 +719,10 @@ func GetResponse() []gin.HandlerFunc { // @Tags relay // @Produce json // @Security ApiKeyAuth -// @Param response_id path string true "Response ID" -// @Param Aiproxy-Channel header string false "Optional Aiproxy-Channel header" +// @Param response_id path string true "Response ID" +// @Param Aiproxy-Channel header string false "Optional Aiproxy-Channel header" +// @Param X-Aiproxy-Group header string false "Optional group ID for internal token requests" +// @Param X-Aiproxy-Group-Channel-Mode header string false "Optional group channel mode for internal token requests. Values: global, own" Enums(global, own) // @Success 204 // @Router /v1/responses/{response_id} [delete] func DeleteResponse() []gin.HandlerFunc { @@ -681,9 +739,11 @@ func DeleteResponse() []gin.HandlerFunc { // @Tags relay // @Produce json // @Security ApiKeyAuth -// @Param response_id path string true "Response ID" -// @Param Aiproxy-Channel header string false "Optional Aiproxy-Channel header" -// @Success 200 {object} model.Response +// @Param response_id path string true "Response ID" +// @Param Aiproxy-Channel header string false "Optional Aiproxy-Channel header" +// @Param X-Aiproxy-Group header string false "Optional group ID for internal token requests" +// @Param X-Aiproxy-Group-Channel-Mode header string false "Optional group channel mode for internal token requests. Values: global, own" Enums(global, own) +// @Success 200 {object} model.Response // @Router /v1/responses/{response_id}/cancel [post] func CancelResponse() []gin.HandlerFunc { return []gin.HandlerFunc{ @@ -699,9 +759,11 @@ func CancelResponse() []gin.HandlerFunc { // @Tags relay // @Produce json // @Security ApiKeyAuth -// @Param response_id path string true "Response ID" -// @Param Aiproxy-Channel header string false "Optional Aiproxy-Channel header" -// @Success 200 {object} model.InputItemList +// @Param response_id path string true "Response ID" +// @Param Aiproxy-Channel header string false "Optional Aiproxy-Channel header" +// @Param X-Aiproxy-Group header string false "Optional group ID for internal token requests" +// @Param X-Aiproxy-Group-Channel-Mode header string false "Optional group channel mode for internal token requests. Values: global, own" Enums(global, own) +// @Success 200 {object} model.InputItemList // @Router /v1/responses/{response_id}/input_items [get] func GetResponseInputItems() []gin.HandlerFunc { return []gin.HandlerFunc{ @@ -717,17 +779,19 @@ func GetResponseInputItems() []gin.HandlerFunc { // @Tags relay // @Produce json // @Security ApiKeyAuth -// @Param version path string true "API Version (v1 or v1beta)" -// @Param model path string true "Model name with action (e.g., gemini-2.0-flash:generateContent)" -// @Param request body object true "Request" -// @Param Aiproxy-Channel header string false "Optional Aiproxy-Channel header" -// @Success 200 {object} object -// @Header all {integer} X-RateLimit-Limit-Requests "X-RateLimit-Limit-Requests" -// @Header all {integer} X-RateLimit-Limit-Tokens "X-RateLimit-Limit-Tokens" -// @Header all {integer} X-RateLimit-Remaining-Requests "X-RateLimit-Remaining-Requests" -// @Header all {integer} X-RateLimit-Remaining-Tokens "X-RateLimit-Remaining-Tokens" -// @Header all {string} X-RateLimit-Reset-Requests "X-RateLimit-Reset-Requests" -// @Header all {string} X-RateLimit-Reset-Tokens "X-RateLimit-Reset-Tokens" +// @Param version path string true "API Version (v1 or v1beta)" +// @Param model path string true "Model name with action (e.g., gemini-2.0-flash:generateContent)" +// @Param request body object true "Request" +// @Param Aiproxy-Channel header string false "Optional Aiproxy-Channel header" +// @Param X-Aiproxy-Group header string false "Optional group ID for internal token requests" +// @Param X-Aiproxy-Group-Channel-Mode header string false "Optional group channel mode for internal token requests. Values: global, own" Enums(global, own) +// @Success 200 {object} object +// @Header all {integer} X-RateLimit-Limit-Requests "X-RateLimit-Limit-Requests" +// @Header all {integer} X-RateLimit-Limit-Tokens "X-RateLimit-Limit-Tokens" +// @Header all {integer} X-RateLimit-Remaining-Requests "X-RateLimit-Remaining-Requests" +// @Header all {integer} X-RateLimit-Remaining-Tokens "X-RateLimit-Remaining-Tokens" +// @Header all {string} X-RateLimit-Reset-Requests "X-RateLimit-Reset-Requests" +// @Header all {string} X-RateLimit-Reset-Tokens "X-RateLimit-Reset-Tokens" // @Router /{version}/models/{model} [post] func GeminiByPath() []gin.HandlerFunc { return []gin.HandlerFunc{ @@ -761,16 +825,18 @@ func GeminiByPath() []gin.HandlerFunc { // @Tags relay // @Produce json // @Security ApiKeyAuth -// @Param version path string true "API Version (v1 or v1beta)" -// @Param operation_id path string true "Operation ID" -// @Param Aiproxy-Channel header string false "Optional Aiproxy-Channel header" -// @Success 200 {object} object -// @Header all {integer} X-RateLimit-Limit-Requests "X-RateLimit-Limit-Requests" -// @Header all {integer} X-RateLimit-Limit-Tokens "X-RateLimit-Limit-Tokens" -// @Header all {integer} X-RateLimit-Remaining-Requests "X-RateLimit-Remaining-Requests" -// @Header all {integer} X-RateLimit-Remaining-Tokens "X-RateLimit-Remaining-Tokens" -// @Header all {string} X-RateLimit-Reset-Requests "X-RateLimit-Reset-Requests" -// @Header all {string} X-RateLimit-Reset-Tokens "X-RateLimit-Reset-Tokens" +// @Param version path string true "API Version (v1 or v1beta)" +// @Param operation_id path string true "Operation ID" +// @Param Aiproxy-Channel header string false "Optional Aiproxy-Channel header" +// @Param X-Aiproxy-Group header string false "Optional group ID for internal token requests" +// @Param X-Aiproxy-Group-Channel-Mode header string false "Optional group channel mode for internal token requests. Values: global, own" Enums(global, own) +// @Success 200 {object} object +// @Header all {integer} X-RateLimit-Limit-Requests "X-RateLimit-Limit-Requests" +// @Header all {integer} X-RateLimit-Limit-Tokens "X-RateLimit-Limit-Tokens" +// @Header all {integer} X-RateLimit-Remaining-Requests "X-RateLimit-Remaining-Requests" +// @Header all {integer} X-RateLimit-Remaining-Tokens "X-RateLimit-Remaining-Tokens" +// @Header all {string} X-RateLimit-Reset-Requests "X-RateLimit-Reset-Requests" +// @Header all {string} X-RateLimit-Reset-Tokens "X-RateLimit-Reset-Tokens" // @Router /{version}/operations/{operation_id} [get] func GeminiOperation() []gin.HandlerFunc { return []gin.HandlerFunc{ diff --git a/core/controller/token.go b/core/controller/token.go index d7b0ef07..03022f55 100644 --- a/core/controller/token.go +++ b/core/controller/token.go @@ -1,9 +1,11 @@ package controller import ( + "errors" "fmt" "net/http" "strconv" + "strings" "time" "github.com/bytedance/sonic" @@ -46,6 +48,10 @@ type ( Name string `json:"name"` Subnets []string `json:"subnets"` Models []string `json:"models"` + Sets []string `json:"sets"` + GroupChannelModels []string `json:"group_channel_models"` + GroupChannelSets []string `json:"group_channel_sets"` + Scope string `json:"scope"` Quota float64 `json:"quota"` PeriodQuota float64 `json:"period_quota"` PeriodType string `json:"period_type"` @@ -63,12 +69,16 @@ type ( func (at *AddTokenRequest) ToToken() *model.Token { token := &model.Token{ - Name: model.EmptyNullString(at.Name), - Subnets: at.Subnets, - Models: at.Models, - Quota: at.Quota, - PeriodQuota: at.PeriodQuota, - PeriodType: model.EmptyNullString(at.PeriodType), + Name: model.EmptyNullString(at.Name), + Subnets: at.Subnets, + Models: at.Models, + Sets: at.Sets, + GroupChannelModels: at.GroupChannelModels, + GroupChannelSets: at.GroupChannelSets, + Scope: model.ParseChannelScope(at.Scope), + Quota: at.Quota, + PeriodQuota: at.PeriodQuota, + PeriodType: model.EmptyNullString(at.PeriodType), } if at.PeriodLastUpdateTime > 0 { @@ -79,10 +89,18 @@ func (at *AddTokenRequest) ToToken() *model.Token { } func validateToken(token AddTokenRequest) error { + if strings.TrimSpace(token.Name) == "" { + return errors.New("name is required") + } + if err := network.IsValidSubnets(token.Subnets); err != nil { return fmt.Errorf("invalid subnet: %w", err) } + if token.Scope != "" && model.ParseChannelScope(token.Scope) == "" { + return fmt.Errorf("invalid scope: %s", token.Scope) + } + return nil } @@ -103,14 +121,60 @@ func buildTokenResponse(token *model.Token) *TokenResponse { } func buildTokenResponses(tokens []*model.Token) []*TokenResponse { + lastRequestTimes := getTokenLastRequestTimes(tokens) + responses := make([]*TokenResponse, len(tokens)) for i, token := range tokens { - responses[i] = buildTokenResponse(token) + responses[i] = &TokenResponse{ + Token: token, + AccessedAt: lastRequestTimes[tokenLastRequestTimeKey(token.GroupID, string(token.Name))], + } } return responses } +func getTokenLastRequestTimes(tokens []*model.Token) map[string]time.Time { + tokenNamesByGroup := make(map[string][]string) + seen := make(map[string]struct{}) + + for _, token := range tokens { + if token == nil || token.GroupID == "" || token.Name == "" { + continue + } + + key := tokenLastRequestTimeKey(token.GroupID, string(token.Name)) + if _, ok := seen[key]; ok { + continue + } + + seen[key] = struct{}{} + + tokenNamesByGroup[token.GroupID] = append( + tokenNamesByGroup[token.GroupID], + string(token.Name), + ) + } + + lastRequestTimes := make(map[string]time.Time) + for group, tokenNames := range tokenNamesByGroup { + groupLastRequestTimes, err := model.GetGroupTokenLastRequestTimesMinute(group, tokenNames) + if err != nil { + continue + } + + for tokenName, lastRequestAt := range groupLastRequestTimes { + lastRequestTimes[tokenLastRequestTimeKey(group, tokenName)] = lastRequestAt + } + } + + return lastRequestTimes +} + +func tokenLastRequestTimeKey(group, tokenName string) string { + return group + "\x00" + tokenName +} + // GetTokens godoc // // @Summary Get all tokens @@ -156,7 +220,7 @@ func GetTokens(c *gin.Context) { // @Param order query string false "Order" // @Param status query int false "Status" // @Success 200 {object} middleware.APIResponse{data=map[string]any{tokens=[]TokenResponse,total=int}} -// @Router /api/tokens/{group} [get] +// @Router /api/token/{group} [get] func GetGroupTokens(c *gin.Context) { group := c.Param("group") if group == "" { diff --git a/core/controller/token_test.go b/core/controller/token_test.go new file mode 100644 index 00000000..804f5113 --- /dev/null +++ b/core/controller/token_test.go @@ -0,0 +1,32 @@ +//nolint:testpackage +package controller + +import ( + "testing" + + "github.com/labring/aiproxy/core/model" + "github.com/stretchr/testify/require" +) + +func TestValidateTokenRequiresName(t *testing.T) { + err := validateToken(AddTokenRequest{Name: " "}) + require.Error(t, err) + require.Contains(t, err.Error(), "name is required") + + require.NoError(t, validateToken(AddTokenRequest{Name: "token"})) +} + +func TestAddTokenRequestToTokenIncludesGroupChannelModels(t *testing.T) { + request := AddTokenRequest{ + Name: "token", + Models: []string{"global-model"}, + GroupChannelModels: []string{"group-model"}, + GroupChannelSets: []string{"group-set"}, + } + token := request.ToToken() + + require.Equal(t, model.EmptyNullString("token"), token.Name) + require.Equal(t, []string{"global-model"}, token.Models) + require.Equal(t, []string{"group-model"}, token.GroupChannelModels) + require.Equal(t, []string{"group-set"}, token.GroupChannelSets) +} diff --git a/core/docs/docs.go b/core/docs/docs.go index 239df11b..c076058f 100644 --- a/core/docs/docs.go +++ b/core/docs/docs.go @@ -54,7 +54,7 @@ const docTemplate = `{ } } }, - "/api/channel/test": { + "/api/channel/test-preview": { "post": { "security": [ { @@ -105,7 +105,7 @@ const docTemplate = `{ } } }, - "/api/channel/test-all": { + "/api/channel/test-preview-all": { "post": { "security": [ { @@ -311,53 +311,6 @@ const docTemplate = `{ } } }, - "/api/channel/{id}/balance": { - "get": { - "security": [ - { - "ApiKeyAuth": [] - } - ], - "description": "Updates the balance for a single channel", - "produces": [ - "application/json" - ], - "tags": [ - "channel" - ], - "summary": "Update channel balance", - "parameters": [ - { - "type": "integer", - "description": "Channel ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/middleware.APIResponse" - }, - { - "type": "object", - "properties": { - "data": { - "type": "number", - "format": "float64" - } - } - } - ] - } - } - } - } - }, "/api/channel/{id}/status": { "post": { "security": [ @@ -471,7 +424,7 @@ const docTemplate = `{ } } }, - "/api/channel/{id}/{model}": { + "/api/channel/{id}/test/{model}": { "get": { "security": [ { @@ -524,6 +477,53 @@ const docTemplate = `{ } } }, + "/api/channel/{id}/update_balance": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Updates the balance for a single channel", + "produces": [ + "application/json" + ], + "tags": [ + "channel" + ], + "summary": "Update channel balance", + "parameters": [ + { + "type": "integer", + "description": "Channel ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/middleware.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "type": "number", + "format": "float64" + } + } + } + ] + } + } + } + } + }, "/api/channels/": { "get": { "security": [ @@ -710,31 +710,6 @@ const docTemplate = `{ } } }, - "/api/channels/balance": { - "get": { - "security": [ - { - "ApiKeyAuth": [] - } - ], - "description": "Updates the balance for all channels", - "produces": [ - "application/json" - ], - "tags": [ - "channel" - ], - "summary": "Update all channels balance", - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/middleware.APIResponse" - } - } - } - } - }, "/api/channels/batch_delete": { "post": { "security": [ @@ -1102,6 +1077,31 @@ const docTemplate = `{ } } }, + "/api/channels/update_balance": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Updates the balance for all channels", + "produces": [ + "application/json" + ], + "tags": [ + "channel" + ], + "summary": "Update all channels balance", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/middleware.APIResponse" + } + } + } + } + }, "/api/dashboard/": { "get": { "security": [ @@ -1185,6 +1185,95 @@ const docTemplate = `{ } } }, + "/api/dashboard/group_channel": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Returns group-channel usage statistics across groups, optionally filtered by group", + "produces": [ + "application/json" + ], + "tags": [ + "dashboard" + ], + "summary": "Get global group channel dashboard data", + "parameters": [ + { + "type": "string", + "description": "Filter by group", + "name": "group", + "in": "query" + }, + { + "type": "integer", + "description": "Group channel ID", + "name": "group_channel", + "in": "query" + }, + { + "type": "string", + "description": "Model name", + "name": "model", + "in": "query" + }, + { + "type": "integer", + "format": "int64", + "description": "Start second timestamp", + "name": "start_timestamp", + "in": "query" + }, + { + "type": "integer", + "format": "int64", + "description": "End second timestamp", + "name": "end_timestamp", + "in": "query" + }, + { + "type": "string", + "description": "Timezone, default is Local", + "name": "timezone", + "in": "query" + }, + { + "type": "string", + "description": "Time span type (minute, hour, day, month)", + "name": "timespan", + "in": "query" + }, + { + "type": "string", + "description": "Comma-separated list of fields to select", + "name": "fields", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/middleware.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.DashboardResponse" + } + } + } + ] + } + } + } + } + }, "/api/dashboard/{group}": { "get": { "security": [ @@ -1410,33 +1499,32 @@ const docTemplate = `{ } } }, - "/api/dashboardv2/{group}": { + "/api/dashboardv2/group_channel": { "get": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Returns model-specific metrics and usage data for the given group", + "description": "Returns group-channel model usage time series across groups, optionally filtered by group", "produces": [ "application/json" ], "tags": [ "dashboard" ], - "summary": "Get model usage data for a specific group", + "summary": "Get global group channel time series data", "parameters": [ { "type": "string", - "description": "Group", + "description": "Filter by group", "name": "group", - "in": "path", - "required": true + "in": "query" }, { - "type": "string", - "description": "Token name", - "name": "token_name", + "type": "integer", + "description": "Group channel ID", + "name": "group_channel", "in": "query" }, { @@ -1473,7 +1561,7 @@ const docTemplate = `{ }, { "type": "string", - "description": "Comma-separated list of fields to select (e.g., request_count,exception_count,cache_hit_count). Available: request_count,retry_count,exception_count,status4xx_count,status5xx_count,status400_count,status429_count,status500_count,cache_hit_count,input_tokens,image_input_tokens,audio_input_tokens,video_input_tokens,output_tokens,image_output_tokens,audio_output_tokens,cached_tokens,cache_creation_tokens,total_tokens,web_search_count,used_amount,total_time,total_ttfb. Groups: count,usage,time,all", + "description": "Comma-separated list of fields to select", "name": "fields", "in": "query" } @@ -1503,26 +1591,33 @@ const docTemplate = `{ } } }, - "/api/dashboardv3/": { + "/api/dashboardv2/{group}": { "get": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Returns model-specific metrics and usage data for the given channel with detailed amount breakdown", + "description": "Returns model-specific metrics and usage data for the given group", "produces": [ "application/json" ], "tags": [ "dashboard" ], - "summary": "Get model usage data for a specific channel (V3 with detailed amounts)", + "summary": "Get model usage data for a specific group", "parameters": [ { - "type": "integer", - "description": "Channel ID", - "name": "channel", + "type": "string", + "description": "Group", + "name": "group", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Token name", + "name": "token_name", "in": "query" }, { @@ -1576,9 +1671,184 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "$ref": "#/definitions/model.DashboardV3Response" - } - } + "type": "array", + "items": { + "$ref": "#/definitions/model.TimeSummaryDataV2" + } + } + } + } + ] + } + } + } + } + }, + "/api/dashboardv3/": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Returns model-specific metrics and usage data for the given channel with detailed amount breakdown", + "produces": [ + "application/json" + ], + "tags": [ + "dashboard" + ], + "summary": "Get model usage data for a specific channel (V3 with detailed amounts)", + "parameters": [ + { + "type": "integer", + "description": "Channel ID", + "name": "channel", + "in": "query" + }, + { + "type": "string", + "description": "Model name", + "name": "model", + "in": "query" + }, + { + "type": "integer", + "format": "int64", + "description": "Start timestamp", + "name": "start_timestamp", + "in": "query" + }, + { + "type": "integer", + "format": "int64", + "description": "End timestamp", + "name": "end_timestamp", + "in": "query" + }, + { + "type": "string", + "description": "Timezone, default is Local", + "name": "timezone", + "in": "query" + }, + { + "type": "string", + "description": "Time span type (minute, hour, day, month)", + "name": "timespan", + "in": "query" + }, + { + "type": "string", + "description": "Comma-separated list of fields to select (e.g., request_count,exception_count,cache_hit_count). Available: request_count,retry_count,exception_count,status4xx_count,status5xx_count,status400_count,status429_count,status500_count,cache_hit_count,input_tokens,image_input_tokens,audio_input_tokens,video_input_tokens,output_tokens,image_output_tokens,audio_output_tokens,cached_tokens,cache_creation_tokens,total_tokens,web_search_count,used_amount,total_time,total_ttfb. Groups: count,usage,time,all", + "name": "fields", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/middleware.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.DashboardV3Response" + } + } + } + ] + } + } + } + } + }, + "/api/dashboardv3/group_channel": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Returns group-channel dashboard V3 data across groups, optionally filtered by group", + "produces": [ + "application/json" + ], + "tags": [ + "dashboard" + ], + "summary": "Get global group channel dashboard V3 data", + "parameters": [ + { + "type": "string", + "description": "Filter by group", + "name": "group", + "in": "query" + }, + { + "type": "integer", + "description": "Group channel ID", + "name": "group_channel", + "in": "query" + }, + { + "type": "string", + "description": "Model name", + "name": "model", + "in": "query" + }, + { + "type": "integer", + "format": "int64", + "description": "Start timestamp", + "name": "start_timestamp", + "in": "query" + }, + { + "type": "integer", + "format": "int64", + "description": "End timestamp", + "name": "end_timestamp", + "in": "query" + }, + { + "type": "string", + "description": "Timezone, default is Local", + "name": "timezone", + "in": "query" + }, + { + "type": "string", + "description": "Time span type (minute, hour, day, month)", + "name": "timespan", + "in": "query" + }, + { + "type": "string", + "description": "Comma-separated list of fields to select", + "name": "fields", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/middleware.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.DashboardV3Response" + } + } } ] } @@ -1931,58 +2201,71 @@ const docTemplate = `{ } } }, - "/api/group/{group}/mcp": { + "/api/group/{group}/channel-dashboard": { "get": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Get MCPs by group", + "description": "Returns group-channel usage statistics for a specific group", "produces": [ "application/json" ], "tags": [ - "mcp", - "group" + "dashboard" ], - "summary": "Get MCPs by group", + "summary": "Get group channel dashboard data", "parameters": [ { "type": "string", - "description": "Group ID", + "description": "Group", "name": "group", "in": "path", "required": true }, { "type": "integer", - "description": "Page", - "name": "page", + "description": "Group channel ID", + "name": "group_channel", + "in": "query" + }, + { + "type": "string", + "description": "Model name", + "name": "model", "in": "query" }, { "type": "integer", - "description": "Per Page", - "name": "per_page", + "format": "int64", + "description": "Start second timestamp", + "name": "start_timestamp", + "in": "query" + }, + { + "type": "integer", + "format": "int64", + "description": "End second timestamp", + "name": "end_timestamp", "in": "query" }, { "type": "string", - "description": "MCP ID", - "name": "id", + "description": "Timezone, default is Local", + "name": "timezone", "in": "query" }, { "type": "string", - "description": "hosted or local", - "name": "type", + "description": "Time span type (minute, hour, day, month)", + "name": "timespan", "in": "query" }, { "type": "string", - "description": "Keyword", - "name": "keyword", + "description": "Comma-separated list of fields to select", + "name": "fields", "in": "query" } ], @@ -1998,10 +2281,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "type": "array", - "items": { - "$ref": "#/definitions/controller.GroupPublicMCPResponse" - } + "$ref": "#/definitions/model.DashboardResponse" } } } @@ -2011,36 +2291,28 @@ const docTemplate = `{ } } }, - "/api/group/{group}/mcp/{id}": { + "/api/group/{group}/channel-dashboard/models": { "get": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Get a specific MCP by its ID", + "description": "Returns group-channel model configs backed by enabled group channels for the given group", "produces": [ "application/json" ], "tags": [ - "mcp", - "group" + "dashboard" ], - "summary": "Get MCP by ID", + "summary": "Get group channel dashboard models", "parameters": [ { "type": "string", - "description": "Group ID", + "description": "Group", "name": "group", "in": "path", "required": true - }, - { - "type": "string", - "description": "MCP ID", - "name": "id", - "in": "path", - "required": true } ], "responses": { @@ -2055,7 +2327,10 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "$ref": "#/definitions/controller.GroupPublicMCPDetailResponse" + "type": "array", + "items": { + "$ref": "#/definitions/controller.GroupModel" + } } } } @@ -2065,50 +2340,90 @@ const docTemplate = `{ } } }, - "/api/group/{group}/model_config/{model}": { + "/api/group/{group}/channel-dashboardv2": { "get": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Get group model config", + "description": "Returns group-channel model usage time series for a specific group", "produces": [ "application/json" ], "tags": [ - "group" + "dashboard" ], - "summary": "Get group model config", + "summary": "Get group channel time series data", "parameters": [ { "type": "string", - "description": "Group name", + "description": "Group", "name": "group", "in": "path", "required": true }, + { + "type": "integer", + "description": "Group channel ID", + "name": "group_channel", + "in": "query" + }, { "type": "string", "description": "Model name", "name": "model", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/middleware.APIResponse" + "in": "query" + }, + { + "type": "integer", + "format": "int64", + "description": "Start timestamp", + "name": "start_timestamp", + "in": "query" + }, + { + "type": "integer", + "format": "int64", + "description": "End timestamp", + "name": "end_timestamp", + "in": "query" + }, + { + "type": "string", + "description": "Timezone, default is Local", + "name": "timezone", + "in": "query" + }, + { + "type": "string", + "description": "Time span type (minute, hour, day, month)", + "name": "timespan", + "in": "query" + }, + { + "type": "string", + "description": "Comma-separated list of fields to select", + "name": "fields", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/middleware.APIResponse" }, { "type": "object", "properties": { "data": { - "$ref": "#/definitions/model.GroupModelConfig" + "type": "array", + "items": { + "$ref": "#/definitions/model.TimeSummaryDataV2" + } } } } @@ -2116,164 +2431,179 @@ const docTemplate = `{ } } } - }, - "put": { + } + }, + "/api/group/{group}/channel-dashboardv3": { + "get": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Update group model config", - "consumes": [ - "application/json" - ], + "description": "Returns group-channel dashboard V3 data for a specific group", "produces": [ "application/json" ], "tags": [ - "group" + "dashboard" ], - "summary": "Update group model config", + "summary": "Get group channel dashboard V3 data", "parameters": [ { "type": "string", - "description": "Group name", + "description": "Group", "name": "group", "in": "path", "required": true }, + { + "type": "integer", + "description": "Group channel ID", + "name": "group_channel", + "in": "query" + }, { "type": "string", "description": "Model name", "name": "model", - "in": "path", - "required": true + "in": "query" }, { - "description": "Group model config information", - "name": "data", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/controller.SaveGroupModelConfigRequest" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/middleware.APIResponse" - } - } - } - }, - "post": { - "security": [ + "type": "integer", + "format": "int64", + "description": "Start timestamp", + "name": "start_timestamp", + "in": "query" + }, { - "ApiKeyAuth": [] - } - ], - "description": "Save group model config", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "group" - ], - "summary": "Save group model config", - "parameters": [ + "type": "integer", + "format": "int64", + "description": "End timestamp", + "name": "end_timestamp", + "in": "query" + }, { "type": "string", - "description": "Group name", - "name": "group", - "in": "path", - "required": true + "description": "Timezone, default is Local", + "name": "timezone", + "in": "query" }, { - "description": "Group model config information", - "name": "data", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/controller.SaveGroupModelConfigRequest" - } + "type": "string", + "description": "Time span type (minute, hour, day, month)", + "name": "timespan", + "in": "query" + }, + { + "type": "string", + "description": "Comma-separated list of fields to select", + "name": "fields", + "in": "query" } ], "responses": { "200": { "description": "OK", "schema": { - "$ref": "#/definitions/middleware.APIResponse" + "allOf": [ + { + "$ref": "#/definitions/middleware.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.DashboardV3Response" + } + } + } + ] } } } - }, - "delete": { + } + }, + "/api/group/{group}/channel-models/enabled": { + "get": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Delete group model config", + "description": "Returns group channel model configs grouped by set for a group", "produces": [ "application/json" ], "tags": [ - "group" + "group-channel" ], - "summary": "Delete group model config", + "summary": "Get enabled group channel models", "parameters": [ { "type": "string", - "description": "Group name", + "description": "Group ID", "name": "group", "in": "path", "required": true - }, - { - "type": "string", - "description": "Model name", - "name": "model", - "in": "path", - "required": true } ], "responses": { "200": { "description": "OK", "schema": { - "$ref": "#/definitions/middleware.APIResponse" + "allOf": [ + { + "$ref": "#/definitions/middleware.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "$ref": "#/definitions/model.ModelConfig" + } + } + } + } + } + ] } } } } }, - "/api/group/{group}/model_configs/": { + "/api/group/{group}/channel-models/enabled/{set}": { "get": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Get group model configs", + "description": "Returns group channel model configs for a specific set", "produces": [ "application/json" ], "tags": [ - "group" + "group-channel" ], - "summary": "Get group model configs", + "summary": "Get enabled group channel models by set", "parameters": [ { "type": "string", - "description": "Group name", + "description": "Group ID", "name": "group", "in": "path", "required": true + }, + { + "type": "string", + "description": "Models set", + "name": "set", + "in": "path", + "required": true } ], "responses": { @@ -2290,7 +2620,7 @@ const docTemplate = `{ "data": { "type": "array", "items": { - "$ref": "#/definitions/model.GroupModelConfig" + "$ref": "#/definitions/model.ModelConfig" } } } @@ -2299,14 +2629,16 @@ const docTemplate = `{ } } } - }, - "put": { + } + }, + "/api/group/{group}/channel/": { + "post": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Update group model configs", + "description": "Adds a group channel to a specific group", "consumes": [ "application/json" ], @@ -2314,27 +2646,24 @@ const docTemplate = `{ "application/json" ], "tags": [ - "group" + "group-channel" ], - "summary": "Update group model configs", + "summary": "Add a group channel", "parameters": [ { "type": "string", - "description": "Group name", + "description": "Group ID", "name": "group", "in": "path", "required": true }, { - "description": "Group model config information", - "name": "data", + "description": "Group channel information", + "name": "channel", "in": "body", "required": true, "schema": { - "type": "array", - "items": { - "$ref": "#/definitions/controller.SaveGroupModelConfigRequest" - } + "$ref": "#/definitions/controller.AddGroupChannelRequest" } } ], @@ -2346,14 +2675,16 @@ const docTemplate = `{ } } } - }, + } + }, + "/api/group/{group}/channel/test-preview": { "post": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Save group model configs", + "description": "Test a single model in a group channel without saving to database", "consumes": [ "application/json" ], @@ -2361,27 +2692,30 @@ const docTemplate = `{ "application/json" ], "tags": [ - "group" + "group-channel" ], - "summary": "Save group model configs", + "summary": "Test group channel preview", "parameters": [ { "type": "string", - "description": "Group name", + "description": "Group ID", "name": "group", "in": "path", "required": true }, { - "description": "Group model config information", - "name": "data", + "type": "boolean", + "description": "Success body", + "name": "success_body", + "in": "query" + }, + { + "description": "Group channel test request", + "name": "request", "in": "body", "required": true, "schema": { - "type": "array", - "items": { - "$ref": "#/definitions/controller.SaveGroupModelConfigRequest" - } + "$ref": "#/definitions/controller.TestSingleGroupChannelRequest" } } ], @@ -2389,43 +2723,75 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/middleware.APIResponse" + "allOf": [ + { + "$ref": "#/definitions/middleware.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.GroupChannelTest" + } + } + } + ] } } } - }, - "delete": { + } + }, + "/api/group/{group}/channel/test-preview-all": { + "post": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Delete group model configs", + "description": "Test all models in a group channel without saving to database", + "consumes": [ + "application/json" + ], "produces": [ "application/json" ], "tags": [ - "group" + "group-channel" ], - "summary": "Delete group model configs", + "summary": "Test group channel preview models", "parameters": [ { "type": "string", - "description": "Group name", + "description": "Group ID", "name": "group", "in": "path", "required": true }, { - "description": "Model names", - "name": "models", + "type": "boolean", + "description": "Return success", + "name": "return_success", + "in": "query" + }, + { + "type": "boolean", + "description": "Success body", + "name": "success_body", + "in": "query" + }, + { + "type": "boolean", + "description": "Stream mode (SSE)", + "name": "stream", + "in": "query" + }, + { + "description": "Group channel test request", + "name": "request", "in": "body", "required": true, "schema": { - "type": "array", - "items": { - "type": "string" - } + "$ref": "#/definitions/controller.TestGroupChannelRequest" } } ], @@ -2433,66 +2799,86 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/middleware.APIResponse" + "allOf": [ + { + "$ref": "#/definitions/middleware.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/controller.GroupChannelTestResult" + } + } + } + } + ] } } } } }, - "/api/group/{group}/rpm_ratio": { - "post": { + "/api/group/{group}/channel/{id}": { + "get": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Updates the RPM (Requests Per Minute) ratio for a group", - "consumes": [ - "application/json" - ], + "description": "Returns detailed information about a group channel in a specific group", "produces": [ "application/json" ], "tags": [ - "group" + "group-channel" ], - "summary": "Update group RPM ratio", + "summary": "Get a group channel by ID", "parameters": [ { "type": "string", - "description": "Group name", + "description": "Group ID", "name": "group", "in": "path", "required": true }, { - "description": "RPM ratio information", - "name": "data", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/controller.UpdateGroupRPMRatioRequest" - } + "type": "integer", + "description": "Group channel ID", + "name": "id", + "in": "path", + "required": true } ], "responses": { "200": { "description": "OK", "schema": { - "$ref": "#/definitions/middleware.APIResponse" + "allOf": [ + { + "$ref": "#/definitions/middleware.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.GroupChannel" + } + } + } + ] } } } - } - }, - "/api/group/{group}/status": { - "post": { + }, + "put": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Updates the status of a group", + "description": "Updates a group channel by ID in a specific group", "consumes": [ "application/json" ], @@ -2500,26 +2886,84 @@ const docTemplate = `{ "application/json" ], "tags": [ - "group" + "group-channel" ], - "summary": "Update group status", + "summary": "Update a group channel", "parameters": [ { "type": "string", - "description": "Group name", + "description": "Group ID", "name": "group", "in": "path", "required": true }, { - "description": "Status information", - "name": "status", + "type": "integer", + "description": "Group channel ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Updated group channel information", + "name": "channel", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/controller.UpdateGroupStatusRequest" + "$ref": "#/definitions/controller.AddGroupChannelRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/middleware.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.GroupChannel" + } + } + } + ] } } + } + }, + "delete": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Deletes a group channel by ID in a specific group", + "produces": [ + "application/json" + ], + "tags": [ + "group-channel" + ], + "summary": "Delete a group channel", + "parameters": [ + { + "type": "string", + "description": "Group ID", + "name": "group", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "Group channel ID", + "name": "id", + "in": "path", + "required": true + } ], "responses": { "200": { @@ -2531,14 +2975,14 @@ const docTemplate = `{ } } }, - "/api/group/{group}/tpm_ratio": { + "/api/group/{group}/channel/{id}/status": { "post": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Updates the TPM (Tokens Per Minute) ratio for a group", + "description": "Updates the status of a group channel by ID in a specific group", "consumes": [ "application/json" ], @@ -2546,24 +2990,31 @@ const docTemplate = `{ "application/json" ], "tags": [ - "group" + "group-channel" ], - "summary": "Update group TPM ratio", + "summary": "Update group channel status", "parameters": [ { "type": "string", - "description": "Group name", + "description": "Group ID", "name": "group", "in": "path", "required": true }, { - "description": "TPM ratio information", - "name": "data", + "type": "integer", + "description": "Group channel ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Status information", + "name": "status", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/controller.UpdateGroupTPMRatioRequest" + "$ref": "#/definitions/controller.UpdateChannelStatusRequest" } } ], @@ -2577,32 +3028,52 @@ const docTemplate = `{ } } }, - "/api/groups/": { + "/api/group/{group}/channel/{id}/test": { "get": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Returns a list of all groups with pagination", + "description": "Tests all models in the group channel", "produces": [ "application/json" ], "tags": [ - "groups" + "group-channel" ], - "summary": "Get all groups", + "summary": "Test group channel models", "parameters": [ + { + "type": "string", + "description": "Group ID", + "name": "group", + "in": "path", + "required": true + }, { "type": "integer", - "description": "Page number", - "name": "page", + "description": "Group Channel ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "boolean", + "description": "Return success", + "name": "return_success", "in": "query" }, { - "type": "integer", - "description": "Items per page", - "name": "per_page", + "type": "boolean", + "description": "Success body", + "name": "success_body", + "in": "query" + }, + { + "type": "boolean", + "description": "Stream", + "name": "stream", "in": "query" } ], @@ -2618,25 +3089,9 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "type": "object", - "additionalProperties": { - "allOf": [ - {}, - { - "type": "object", - "properties": { - "groups": { - "type": "array", - "items": { - "$ref": "#/definitions/controller.GroupResponse" - } - }, - "total": { - "type": "integer" - } - } - } - ] + "type": "array", + "items": { + "$ref": "#/definitions/controller.GroupChannelTestResult" } } } @@ -2647,146 +3102,47 @@ const docTemplate = `{ } } }, - "/api/groups/batch_delete": { - "post": { + "/api/group/{group}/channel/{id}/test/{model}": { + "get": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Deletes multiple groups by their IDs", - "consumes": [ - "application/json" - ], + "description": "Tests a single model in the group channel", "produces": [ "application/json" ], "tags": [ - "groups" + "group-channel" ], - "summary": "Delete multiple groups", - "parameters": [ - { - "description": "Group IDs", - "name": "ids", - "in": "body", - "required": true, - "schema": { - "type": "array", - "items": { - "type": "string" - } - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/middleware.APIResponse" - } - } - } - } - }, - "/api/groups/batch_status": { - "post": { - "security": [ - { - "ApiKeyAuth": [] - } - ], - "description": "Updates the status of multiple groups", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "groups" - ], - "summary": "Update multiple groups status", - "parameters": [ - { - "description": "Group IDs and status", - "name": "data", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/controller.UpdateGroupsStatusRequest" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/middleware.APIResponse" - } - } - } - } - }, - "/api/groups/consumption_ranking": { - "get": { - "security": [ - { - "ApiKeyAuth": [] - } - ], - "description": "Returns channel, model, or group consumption ranking aggregated from summary data", - "produces": [ - "application/json" - ], - "tags": [ - "groups" - ], - "summary": "Get consumption ranking", + "summary": "Test group channel model", "parameters": [ { "type": "string", - "default": "group", - "description": "Ranking type: channel, model, group", - "name": "type", - "in": "query" - }, - { - "type": "integer", - "format": "int64", - "description": "Start timestamp", - "name": "start_timestamp", - "in": "query" + "description": "Group ID", + "name": "group", + "in": "path", + "required": true }, { "type": "integer", - "format": "int64", - "description": "End timestamp", - "name": "end_timestamp", - "in": "query" + "description": "Group Channel ID", + "name": "id", + "in": "path", + "required": true }, { "type": "string", - "description": "Timezone, default is Local", - "name": "timezone", - "in": "query" - }, - { - "type": "integer", - "description": "Page number", - "name": "page", - "in": "query" - }, - { - "type": "integer", - "description": "Items per page", - "name": "per_page", - "in": "query" + "description": "Model name", + "name": "model", + "in": "path", + "required": true }, { - "type": "string", - "description": "Order: used_amount_desc, used_amount_asc, request_count_desc, request_count_asc, total_tokens_desc, total_tokens_asc, channel_id_asc, channel_id_desc, model_asc, model_desc, group_id_asc, group_id_desc", - "name": "order", + "type": "boolean", + "description": "Success body", + "name": "success_body", "in": "query" } ], @@ -2802,8 +3158,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "type": "object", - "additionalProperties": true + "$ref": "#/definitions/model.GroupChannelTest" } } } @@ -2813,38 +3168,40 @@ const docTemplate = `{ } } }, - "/api/groups/ip_groups": { + "/api/group/{group}/channel/{id}/tests": { "get": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Get IP group list", + "description": "Returns persisted test results for a group channel", "produces": [ "application/json" ], "tags": [ - "groups" + "group-channel" ], - "summary": "Get IP group list", + "summary": "Get group channel test results", "parameters": [ { - "type": "integer", - "description": "Threshold", - "name": "threshold", - "in": "query" + "type": "string", + "description": "Group ID", + "name": "group", + "in": "path", + "required": true }, { "type": "integer", - "description": "Start timestamp", - "name": "start_timestamp", - "in": "query" + "description": "Group Channel ID", + "name": "id", + "in": "path", + "required": true }, { - "type": "integer", - "description": "End timestamp", - "name": "end_timestamp", + "type": "boolean", + "description": "Success body", + "name": "success_body", "in": "query" } ], @@ -2860,12 +3217,9 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "type": "object", - "additionalProperties": { - "type": "array", - "items": { - "type": "string" - } + "type": "array", + "items": { + "$ref": "#/definitions/model.GroupChannelTest" } } } @@ -2876,48 +3230,28 @@ const docTemplate = `{ } } }, - "/api/groups/ranking": { + "/api/group/{group}/channels/": { "get": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Returns channel, model, or group consumption ranking aggregated from summary data", + "description": "Returns a paginated list of group channels for a specific group", "produces": [ "application/json" ], "tags": [ - "groups" + "group-channel" ], - "summary": "Get consumption ranking", + "summary": "Get group channels with pagination", "parameters": [ { "type": "string", - "default": "group", - "description": "Ranking type: channel, model, group", - "name": "type", - "in": "query" - }, - { - "type": "integer", - "format": "int64", - "description": "Start timestamp", - "name": "start_timestamp", - "in": "query" - }, - { - "type": "integer", - "format": "int64", - "description": "End timestamp", - "name": "end_timestamp", - "in": "query" - }, - { - "type": "string", - "description": "Timezone, default is Local", - "name": "timezone", - "in": "query" + "description": "Group ID", + "name": "group", + "in": "path", + "required": true }, { "type": "integer", @@ -2932,79 +3266,38 @@ const docTemplate = `{ "in": "query" }, { - "type": "string", - "description": "Order: used_amount_desc, used_amount_asc, request_count_desc, request_count_asc, total_tokens_desc, total_tokens_asc, channel_id_asc, channel_id_desc, model_asc, model_desc, group_id_asc, group_id_desc", - "name": "order", + "type": "integer", + "description": "Filter by id", + "name": "id", "in": "query" - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/middleware.APIResponse" - }, - { - "type": "object", - "properties": { - "data": { - "type": "object", - "additionalProperties": true - } - } - } - ] - } - } - } - } - }, - "/api/groups/search": { - "get": { - "security": [ - { - "ApiKeyAuth": [] - } - ], - "description": "Search groups with keyword and pagination", - "produces": [ - "application/json" - ], - "tags": [ - "groups" - ], - "summary": "Search groups", - "parameters": [ + }, { "type": "string", - "description": "Search keyword", - "name": "keyword", - "in": "query", - "required": true + "description": "Filter by name", + "name": "name", + "in": "query" }, { - "type": "integer", - "description": "Page number", - "name": "page", + "type": "string", + "description": "Filter by key", + "name": "key", "in": "query" }, { "type": "integer", - "description": "Items per page", - "name": "per_page", + "description": "Filter by channel type", + "name": "channel_type", "in": "query" }, { - "type": "integer", - "description": "Status", - "name": "status", + "type": "string", + "description": "Filter by base URL", + "name": "base_url", "in": "query" }, { "type": "string", - "description": "Order", + "description": "Order by field", "name": "order", "in": "query" } @@ -3028,10 +3321,10 @@ const docTemplate = `{ { "type": "object", "properties": { - "groups": { + "channels": { "type": "array", "items": { - "$ref": "#/definitions/controller.GroupResponse" + "$ref": "#/definitions/model.GroupChannel" } }, "total": { @@ -3048,126 +3341,141 @@ const docTemplate = `{ } } } - } - }, - "/api/log/{group}": { - "get": { + }, + "post": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Get logs for a specific group", - "produces": [ + "description": "Adds group channels to a specific group", + "consumes": [ + "application/json" + ], + "produces": [ "application/json" ], "tags": [ - "log" + "group-channel" ], - "summary": "Get group logs", + "summary": "Add multiple group channels", "parameters": [ { "type": "string", - "description": "Group name", + "description": "Group ID", "name": "group", "in": "path", "required": true }, { - "type": "integer", - "description": "Page number", - "name": "page", - "in": "query" - }, - { - "type": "integer", - "description": "Items per page", - "name": "per_page", - "in": "query" - }, - { - "type": "integer", - "description": "Start timestamp (milliseconds)", - "name": "start_timestamp", - "in": "query" - }, - { - "type": "integer", - "description": "End timestamp (milliseconds)", - "name": "end_timestamp", - "in": "query" - }, - { - "type": "string", - "description": "Token name", - "name": "token_name", - "in": "query" - }, - { - "type": "string", - "description": "Model name", - "name": "model_name", - "in": "query" - }, - { - "type": "integer", - "description": "Channel ID", - "name": "channel", - "in": "query" - }, - { - "type": "integer", - "description": "Token ID", - "name": "token_id", - "in": "query" - }, - { - "type": "string", - "description": "Order", - "name": "order", - "in": "query" - }, - { - "type": "string", - "description": "Request ID", - "name": "request_id", - "in": "query" - }, + "description": "Group channel information", + "name": "channels", + "in": "body", + "required": true, + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/controller.AddGroupChannelRequest" + } + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/middleware.APIResponse" + } + } + } + } + }, + "/api/group/{group}/channels/batch_delete": { + "post": { + "security": [ { - "type": "string", - "description": "Upstream ID", - "name": "upstream_id", - "in": "query" - }, + "ApiKeyAuth": [] + } + ], + "description": "Deletes group channels by IDs in a specific group", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "group-channel" + ], + "summary": "Delete multiple group channels", + "parameters": [ { "type": "string", - "description": "Status code type", - "name": "code_type", - "in": "query" + "description": "Group ID", + "name": "group", + "in": "path", + "required": true }, { - "type": "integer", - "description": "Status code", - "name": "code", - "in": "query" - }, + "description": "Group channel IDs", + "name": "ids", + "in": "body", + "required": true, + "schema": { + "type": "array", + "items": { + "type": "integer" + } + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/middleware.APIResponse" + } + } + } + } + }, + "/api/group/{group}/channels/batch_info": { + "post": { + "security": [ { - "type": "boolean", - "description": "Include request and response detail", - "name": "include_detail", - "in": "query" - }, + "ApiKeyAuth": [] + } + ], + "description": "Returns id, group, name, and type for a batch of group channel IDs filtered by group", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "group-channel" + ], + "summary": "Get basic info for multiple group channels in a group", + "parameters": [ { "type": "string", - "description": "IP", - "name": "ip", - "in": "query" + "description": "Group ID", + "name": "group", + "in": "path", + "required": true }, { - "type": "string", - "description": "User", - "name": "user", - "in": "query" + "description": "Group channel IDs", + "name": "ids", + "in": "body", + "required": true, + "schema": { + "type": "array", + "items": { + "type": "integer" + } + } } ], "responses": { @@ -3182,7 +3490,10 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "$ref": "#/definitions/model.GetGroupLogsResult" + "type": "array", + "items": { + "$ref": "#/definitions/model.GroupChannelBasicInfo" + } } } } @@ -3192,35 +3503,37 @@ const docTemplate = `{ } } }, - "/api/log/{group}/detail/{log_id}": { - "get": { + "/api/group/{group}/channels/import/oneapi": { + "post": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Get detailed information about a specific log entry in a group", + "description": "Imports group channels from OneAPI into a group", "produces": [ "application/json" ], "tags": [ - "log" + "group-channel" ], - "summary": "Get group log detail", + "summary": "Import group channel from OneAPI", "parameters": [ { "type": "string", - "description": "Group name", + "description": "Group ID", "name": "group", "in": "path", "required": true }, { - "type": "string", - "description": "Log ID", - "name": "log_id", - "in": "path", - "required": true + "description": "Import group channel from OneAPI request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/controller.ImportChannelFromOneAPIRequest" + } } ], "responses": { @@ -3235,7 +3548,8 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "$ref": "#/definitions/model.RequestDetail" + "type": "array", + "items": {} } } } @@ -3245,258 +3559,217 @@ const docTemplate = `{ } } }, - "/api/log/{group}/export": { + "/api/group/{group}/channels/search": { "get": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Streams filtered group logs as a CSV table file", + "description": "Search group channels for a specific group with keyword and optional filters", "produces": [ - "text/csv" + "application/json" ], "tags": [ - "log" + "group-channel" ], - "summary": "Export group logs", + "summary": "Search group channels", "parameters": [ { "type": "string", - "description": "Group name", + "description": "Group ID", "name": "group", "in": "path", "required": true }, { - "type": "integer", - "description": "Start timestamp, max span 30 days", - "name": "start_timestamp", + "type": "string", + "description": "Search keyword", + "name": "keyword", "in": "query" }, { "type": "integer", - "description": "End timestamp, max span 30 days", - "name": "end_timestamp", + "description": "Page number", + "name": "page", "in": "query" }, { - "type": "string", - "description": "Model name", - "name": "model_name", + "type": "integer", + "description": "Items per page", + "name": "per_page", "in": "query" }, { "type": "integer", - "description": "Token ID", - "name": "token_id", + "description": "Filter by id", + "name": "id", "in": "query" }, { "type": "string", - "description": "Token name", - "name": "token_name", + "description": "Filter by name", + "name": "name", "in": "query" }, { "type": "string", - "description": "Sort order for created_at, supports desc or asc", - "name": "order", + "description": "Filter by key", + "name": "key", "in": "query" }, { - "type": "string", - "description": "Request ID", - "name": "request_id", + "type": "integer", + "description": "Filter by channel type", + "name": "channel_type", "in": "query" }, { "type": "string", - "description": "Upstream ID", - "name": "upstream_id", + "description": "Filter by base URL", + "name": "base_url", "in": "query" }, { "type": "string", - "description": "Status code type", - "name": "code_type", + "description": "Order by field", + "name": "order", "in": "query" - }, - { - "type": "integer", - "description": "Status code", - "name": "code", - "in": "query" - }, - { - "type": "boolean", - "description": "Include request and response detail, default false", - "name": "include_detail", - "in": "query" - }, - { - "type": "string", - "description": "IP", - "name": "ip", - "in": "query" - }, - { - "type": "string", - "description": "User", - "name": "user", - "in": "query" - }, - { - "type": "string", - "description": "Timezone, default is Local", - "name": "timezone", - "in": "query" - }, - { - "type": "integer", - "description": "Maximum exported rows; zero or negative means unlimited", - "name": "max_entries", - "in": "query" - }, - { - "type": "boolean", - "description": "Include channel column, default false", - "name": "include_channel", - "in": "query" - }, - { - "type": "boolean", - "description": "Include retry_at column, default false", - "name": "include_retry_at", - "in": "query" - }, + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/middleware.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "type": "object", + "additionalProperties": { + "allOf": [ + {}, + { + "type": "object", + "properties": { + "channels": { + "type": "array", + "items": { + "$ref": "#/definitions/model.GroupChannel" + } + }, + "total": { + "type": "integer" + } + } + } + ] + } + } + } + } + ] + } + } + } + } + }, + "/api/group/{group}/channels/type_metas": { + "get": { + "security": [ { - "type": "string", - "description": "Chunk interval, default 30m, min 10m, max 4h, e.g. 10m, 30m, 1h", - "name": "chunk_interval", - "in": "query" + "ApiKeyAuth": [] } ], - "responses": {} + "description": "Returns metadata for all channel types", + "produces": [ + "application/json" + ], + "tags": [ + "channels" + ], + "summary": "Get channel type metadata", + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/middleware.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/adaptors.AdaptorMeta" + } + } + } + } + ] + } + } + } } }, - "/api/log/{group}/search": { + "/api/group/{group}/mcp/": { "get": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Search logs for a specific group with filters", + "description": "Get MCPs by group", "produces": [ "application/json" ], "tags": [ - "log" + "mcp", + "group" ], - "summary": "Search group logs", + "summary": "Get MCPs by group", "parameters": [ { "type": "string", - "description": "Group name", + "description": "Group ID", "name": "group", "in": "path", "required": true }, - { - "type": "string", - "description": "Keyword", - "name": "keyword", - "in": "query" - }, { "type": "integer", - "description": "Page number", + "description": "Page", "name": "page", "in": "query" }, { "type": "integer", - "description": "Items per page", + "description": "Per Page", "name": "per_page", "in": "query" }, - { - "type": "integer", - "description": "Start timestamp (milliseconds)", - "name": "start_timestamp", - "in": "query" - }, - { - "type": "integer", - "description": "End timestamp (milliseconds)", - "name": "end_timestamp", - "in": "query" - }, - { - "type": "string", - "description": "Filter by token name", - "name": "token_name", - "in": "query" - }, - { - "type": "string", - "description": "Filter by model name", - "name": "model_name", - "in": "query" - }, - { - "type": "integer", - "description": "Filter by token id", - "name": "token_id", - "in": "query" - }, - { - "type": "string", - "description": "Order", - "name": "order", - "in": "query" - }, - { - "type": "string", - "description": "Request ID", - "name": "request_id", - "in": "query" - }, - { - "type": "string", - "description": "Upstream ID", - "name": "upstream_id", - "in": "query" - }, { "type": "string", - "description": "Status code type", - "name": "code_type", - "in": "query" - }, - { - "type": "integer", - "description": "Status code", - "name": "code", - "in": "query" - }, - { - "type": "boolean", - "description": "Include request and response detail", - "name": "include_detail", + "description": "MCP ID", + "name": "id", "in": "query" }, { "type": "string", - "description": "IP", - "name": "ip", + "description": "hosted or local", + "name": "type", "in": "query" }, { "type": "string", - "description": "User", - "name": "user", + "description": "Keyword", + "name": "keyword", "in": "query" } ], @@ -3512,7 +3785,10 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "$ref": "#/definitions/model.GetGroupLogsResult" + "type": "array", + "items": { + "$ref": "#/definitions/controller.GroupPublicMCPResponse" + } } } } @@ -3522,105 +3798,36 @@ const docTemplate = `{ } } }, - "/api/logs/": { + "/api/group/{group}/mcp/{id}": { "get": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Returns a paginated list of all logs with optional filters", + "description": "Get a specific MCP by its ID", "produces": [ "application/json" ], "tags": [ - "logs" + "mcp", + "group" ], - "summary": "Get all logs", + "summary": "Get MCP by ID", "parameters": [ { - "type": "integer", - "description": "Page number", - "name": "page", - "in": "query" - }, - { - "type": "integer", - "description": "Items per page", - "name": "per_page", - "in": "query" - }, - { - "type": "integer", - "description": "Start timestamp (milliseconds)", - "name": "start_timestamp", - "in": "query" - }, - { - "type": "integer", - "description": "End timestamp (milliseconds)", - "name": "end_timestamp", - "in": "query" - }, - { - "type": "string", - "description": "Model name", - "name": "model_name", - "in": "query" - }, - { - "type": "integer", - "description": "Channel ID", - "name": "channel", - "in": "query" - }, - { - "type": "string", - "description": "Order", - "name": "order", - "in": "query" - }, - { - "type": "string", - "description": "Request ID", - "name": "request_id", - "in": "query" - }, - { - "type": "string", - "description": "Upstream ID", - "name": "upstream_id", - "in": "query" - }, - { - "type": "string", - "description": "Status code type", - "name": "code_type", - "in": "query" - }, - { - "type": "integer", - "description": "Status code", - "name": "code", - "in": "query" - }, - { - "type": "boolean", - "description": "Include request and response detail", - "name": "include_detail", - "in": "query" - }, - { - "type": "string", - "description": "IP", - "name": "ip", - "in": "query" + "type": "string", + "description": "Group ID", + "name": "group", + "in": "path", + "required": true }, { "type": "string", - "description": "User", - "name": "user", - "in": "query" + "description": "MCP ID", + "name": "id", + "in": "path", + "required": true } ], "responses": { @@ -3635,7 +3842,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "$ref": "#/definitions/model.GetLogsResult" + "$ref": "#/definitions/controller.GroupPublicMCPDetailResponse" } } } @@ -3643,27 +3850,36 @@ const docTemplate = `{ } } } - }, - "delete": { + } + }, + "/api/group/{group}/model_config/{model}": { + "get": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Deletes logs older than the specified retention period", + "description": "Get group model config", "produces": [ "application/json" ], "tags": [ - "logs" + "group" ], - "summary": "Delete historical logs", + "summary": "Get group model config", "parameters": [ { - "type": "integer", - "description": "Timestamp (milliseconds)", - "name": "timestamp", - "in": "query", + "type": "string", + "description": "Group name", + "name": "group", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Model name", + "name": "model", + "in": "path", "required": true } ], @@ -3679,7 +3895,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "type": "integer" + "$ref": "#/definitions/model.GroupModelConfig" } } } @@ -3687,418 +3903,164 @@ const docTemplate = `{ } } } - } - }, - "/api/logs/consume_error": { - "get": { + }, + "put": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Search for logs with consumption errors", + "description": "Update group model config", + "consumes": [ + "application/json" + ], "produces": [ "application/json" ], "tags": [ - "logs" + "group" ], - "summary": "Search consumption errors", + "summary": "Update group model config", "parameters": [ - { - "type": "integer", - "description": "Page number", - "name": "page", - "in": "query" - }, - { - "type": "integer", - "description": "Items per page", - "name": "per_page", - "in": "query" - }, - { - "type": "integer", - "description": "Start timestamp (milliseconds)", - "name": "start_timestamp", - "in": "query" - }, - { - "type": "integer", - "description": "End timestamp (milliseconds)", - "name": "end_timestamp", - "in": "query" - }, - { - "type": "string", - "description": "Keyword", - "name": "keyword", - "in": "query" - }, { "type": "string", - "description": "Group", + "description": "Group name", "name": "group", - "in": "query" - }, - { - "type": "string", - "description": "Token name", - "name": "token_name", - "in": "query" + "in": "path", + "required": true }, { "type": "string", "description": "Model name", - "name": "model_name", - "in": "query" - }, - { - "type": "string", - "description": "Content", - "name": "content", - "in": "query" - }, - { - "type": "integer", - "description": "Token ID", - "name": "token_id", - "in": "query" - }, - { - "type": "string", - "description": "Order", - "name": "order", - "in": "query" + "name": "model", + "in": "path", + "required": true }, { - "type": "string", - "description": "Request ID", - "name": "request_id", - "in": "query" + "description": "Group model config information", + "name": "data", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/controller.SaveGroupModelConfigRequest" + } } ], "responses": { "200": { "description": "OK", "schema": { - "allOf": [ - { - "$ref": "#/definitions/middleware.APIResponse" - }, - { - "type": "object", - "properties": { - "data": { - "type": "object", - "additionalProperties": { - "allOf": [ - {}, - { - "type": "object", - "properties": { - "logs": { - "type": "array", - "items": { - "$ref": "#/definitions/model.RequestDetail" - } - }, - "total": { - "type": "integer" - } - } - } - ] - } - } - } - } - ] + "$ref": "#/definitions/middleware.APIResponse" } } } - } - }, - "/api/logs/detail/{log_id}": { - "get": { + }, + "post": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Get detailed information about a specific log entry", + "description": "Save group model config", + "consumes": [ + "application/json" + ], "produces": [ "application/json" ], "tags": [ - "logs" + "group" ], - "summary": "Get log detail", + "summary": "Save group model config", "parameters": [ { "type": "string", - "description": "Log ID", - "name": "log_id", + "description": "Group name", + "name": "group", "in": "path", "required": true + }, + { + "description": "Group model config information", + "name": "data", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/controller.SaveGroupModelConfigRequest" + } } ], "responses": { "200": { "description": "OK", "schema": { - "allOf": [ - { - "$ref": "#/definitions/middleware.APIResponse" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/model.RequestDetail" - } - } - } - ] + "$ref": "#/definitions/middleware.APIResponse" } } } - } - }, - "/api/logs/export": { - "get": { + }, + "delete": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Streams filtered global logs as a CSV table file", + "description": "Delete group model config", "produces": [ - "text/csv" + "application/json" ], "tags": [ - "logs" + "group" ], - "summary": "Export global logs", + "summary": "Delete group model config", "parameters": [ - { - "type": "integer", - "description": "Start timestamp, max span 30 days", - "name": "start_timestamp", - "in": "query" - }, - { - "type": "integer", - "description": "End timestamp, max span 30 days", - "name": "end_timestamp", - "in": "query" - }, - { - "type": "string", - "description": "Model name", - "name": "model_name", - "in": "query" - }, - { - "type": "integer", - "description": "Channel ID", - "name": "channel", - "in": "query" - }, - { - "type": "string", - "description": "Sort order for created_at, supports desc or asc", - "name": "order", - "in": "query" - }, - { - "type": "string", - "description": "Request ID", - "name": "request_id", - "in": "query" - }, - { - "type": "string", - "description": "Upstream ID", - "name": "upstream_id", - "in": "query" - }, - { - "type": "string", - "description": "Status code type", - "name": "code_type", - "in": "query" - }, - { - "type": "integer", - "description": "Status code", - "name": "code", - "in": "query" - }, - { - "type": "boolean", - "description": "Include request and response detail, default false", - "name": "include_detail", - "in": "query" - }, - { - "type": "string", - "description": "IP", - "name": "ip", - "in": "query" - }, - { - "type": "string", - "description": "User", - "name": "user", - "in": "query" - }, { "type": "string", - "description": "Timezone, default is Local", - "name": "timezone", - "in": "query" - }, - { - "type": "integer", - "description": "Maximum exported rows; zero or negative means unlimited", - "name": "max_entries", - "in": "query" + "description": "Group name", + "name": "group", + "in": "path", + "required": true }, { "type": "string", - "description": "Chunk interval, default 30m, min 10m, max 4h, e.g. 10m, 30m, 1h", - "name": "chunk_interval", - "in": "query" + "description": "Model name", + "name": "model", + "in": "path", + "required": true } ], - "responses": {} + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/middleware.APIResponse" + } + } + } } }, - "/api/logs/search": { + "/api/group/{group}/model_configs/": { "get": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Search logs with various filters", + "description": "Get group model configs", "produces": [ "application/json" ], "tags": [ - "logs" + "group" ], - "summary": "Search logs", + "summary": "Get group model configs", "parameters": [ - { - "type": "string", - "description": "Keyword", - "name": "keyword", - "in": "query" - }, - { - "type": "integer", - "description": "Page number", - "name": "page", - "in": "query" - }, - { - "type": "integer", - "description": "Items per page", - "name": "per_page", - "in": "query" - }, - { - "type": "integer", - "description": "Start timestamp (milliseconds)", - "name": "start_timestamp", - "in": "query" - }, - { - "type": "integer", - "description": "End timestamp (milliseconds)", - "name": "end_timestamp", - "in": "query" - }, - { - "type": "string", - "description": "Filter by model name", - "name": "model_name", - "in": "query" - }, - { - "type": "integer", - "description": "Filter by channel", - "name": "channel", - "in": "query" - }, { "type": "string", "description": "Group name", "name": "group", - "in": "query", + "in": "path", "required": true - }, - { - "type": "integer", - "description": "Filter by token id", - "name": "token_id", - "in": "query" - }, - { - "type": "string", - "description": "Filter by token name", - "name": "token_name", - "in": "query" - }, - { - "type": "string", - "description": "Order", - "name": "order", - "in": "query" - }, - { - "type": "string", - "description": "Request ID", - "name": "request_id", - "in": "query" - }, - { - "type": "string", - "description": "Upstream ID", - "name": "upstream_id", - "in": "query" - }, - { - "type": "string", - "description": "Status code type", - "name": "code_type", - "in": "query" - }, - { - "type": "integer", - "description": "Status code", - "name": "code", - "in": "query" - }, - { - "type": "boolean", - "description": "Include request and response detail", - "name": "include_detail", - "in": "query" - }, - { - "type": "string", - "description": "IP", - "name": "ip", - "in": "query" - }, - { - "type": "string", - "description": "User", - "name": "user", - "in": "query" } ], "responses": { @@ -4113,7 +4075,10 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "$ref": "#/definitions/model.GetLogsResult" + "type": "array", + "items": { + "$ref": "#/definitions/model.GroupModelConfig" + } } } } @@ -4121,147 +4086,154 @@ const docTemplate = `{ } } } - } - }, - "/api/mcp/group/all": { - "get": { + }, + "put": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Get all Group MCPs with filtering", + "description": "Update group model configs", + "consumes": [ + "application/json" + ], "produces": [ "application/json" ], "tags": [ - "mcp" + "group" ], - "summary": "Get all Group MCPs", + "summary": "Update group model configs", "parameters": [ { - "type": "integer", - "description": "MCP status", - "name": "status", - "in": "query" + "type": "string", + "description": "Group name", + "name": "group", + "in": "path", + "required": true + }, + { + "description": "Group model config information", + "name": "data", + "in": "body", + "required": true, + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/controller.SaveGroupModelConfigRequest" + } + } } ], "responses": { "200": { "description": "OK", "schema": { - "allOf": [ - { - "$ref": "#/definitions/middleware.APIResponse" - }, - { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "$ref": "#/definitions/controller.GroupMCPResponse" - } - } - } - } - ] + "$ref": "#/definitions/middleware.APIResponse" } } } - } - }, - "/api/mcp/group/{group}": { - "get": { + }, + "post": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Get a list of Group MCPs with pagination and filtering", + "description": "Save group model configs", + "consumes": [ + "application/json" + ], "produces": [ "application/json" ], "tags": [ - "mcp" + "group" ], - "summary": "Get Group MCPs", + "summary": "Save group model configs", "parameters": [ { "type": "string", - "description": "Group ID", + "description": "Group name", "name": "group", "in": "path", "required": true }, { - "type": "integer", - "description": "Page number", - "name": "page", - "in": "query" - }, - { - "type": "integer", - "description": "Items per page", - "name": "per_page", - "in": "query" - }, - { - "type": "string", - "description": "MCP id", - "name": "id", - "in": "query" - }, + "description": "Group model config information", + "name": "data", + "in": "body", + "required": true, + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/controller.SaveGroupModelConfigRequest" + } + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/middleware.APIResponse" + } + } + } + }, + "delete": { + "security": [ { - "type": "string", - "description": "MCP type, mcp_proxy_sse, mcp_proxy_streamable, mcp_openapi", - "name": "type", - "in": "query" - }, + "ApiKeyAuth": [] + } + ], + "description": "Delete group model configs", + "produces": [ + "application/json" + ], + "tags": [ + "group" + ], + "summary": "Delete group model configs", + "parameters": [ { "type": "string", - "description": "Search keyword", - "name": "keyword", - "in": "query" + "description": "Group name", + "name": "group", + "in": "path", + "required": true }, { - "type": "integer", - "description": "MCP status", - "name": "status", - "in": "query" + "description": "Model names", + "name": "models", + "in": "body", + "required": true, + "schema": { + "type": "array", + "items": { + "type": "string" + } + } } ], "responses": { "200": { "description": "OK", "schema": { - "allOf": [ - { - "$ref": "#/definitions/middleware.APIResponse" - }, - { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "$ref": "#/definitions/controller.GroupMCPResponse" - } - } - } - } - ] + "$ref": "#/definitions/middleware.APIResponse" } } } - }, + } + }, + "/api/group/{group}/rpm_ratio": { "post": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Create a new Group MCP", + "description": "Updates the RPM (Requests Per Minute) ratio for a group", "consumes": [ "application/json" ], @@ -4269,24 +4241,24 @@ const docTemplate = `{ "application/json" ], "tags": [ - "mcp" + "group" ], - "summary": "Create Group MCP", + "summary": "Update group RPM ratio", "parameters": [ { "type": "string", - "description": "Group ID", + "description": "Group name", "name": "group", "in": "path", "required": true }, { - "description": "Group MCP object", - "name": "mcp", + "description": "RPM ratio information", + "name": "data", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/model.GroupMCP" + "$ref": "#/definitions/controller.UpdateGroupRPMRatioRequest" } } ], @@ -4294,51 +4266,39 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "allOf": [ - { - "$ref": "#/definitions/middleware.APIResponse" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/controller.GroupMCPResponse" - } - } - } - ] + "$ref": "#/definitions/middleware.APIResponse" } } } } }, - "/api/mcp/group/{group}/{id}": { + "/api/group/{group}/scope_model_config/{model}": { "get": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Get a specific Group MCP by its ID and Group ID", + "description": "Returns a group-channel scope model config for a specific group", "produces": [ "application/json" ], "tags": [ - "mcp" + "group-scope-modelconfig" ], - "summary": "Get Group MCP by ID", + "summary": "Get group scope model config", "parameters": [ { "type": "string", - "description": "MCP ID", - "name": "id", + "description": "Group name", + "name": "group", "in": "path", "required": true }, { "type": "string", - "description": "Group ID", - "name": "group", + "description": "Model name", + "name": "model", "in": "path", "required": true } @@ -4355,7 +4315,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "$ref": "#/definitions/controller.GroupMCPResponse" + "$ref": "#/definitions/model.GroupScopeModelConfig" } } } @@ -4364,13 +4324,13 @@ const docTemplate = `{ } } }, - "put": { + "post": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Update an existing Group MCP", + "description": "Saves a group-channel scope model config for a specific group", "consumes": [ "application/json" ], @@ -4378,31 +4338,31 @@ const docTemplate = `{ "application/json" ], "tags": [ - "mcp" + "group-scope-modelconfig" ], - "summary": "Update Group MCP", + "summary": "Save group scope model config", "parameters": [ { "type": "string", - "description": "MCP ID", - "name": "id", + "description": "Group name", + "name": "group", "in": "path", "required": true }, { "type": "string", - "description": "Group ID", - "name": "group", + "description": "Model name", + "name": "model", "in": "path", "required": true }, { - "description": "Group MCP object", - "name": "mcp", + "description": "Model config", + "name": "config", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/model.GroupMCP" + "$ref": "#/definitions/controller.SaveGroupScopeModelConfigsRequest" } } ], @@ -4410,19 +4370,7 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "allOf": [ - { - "$ref": "#/definitions/middleware.APIResponse" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/controller.GroupMCPResponse" - } - } - } - ] + "$ref": "#/definitions/middleware.APIResponse" } } } @@ -4433,26 +4381,26 @@ const docTemplate = `{ "ApiKeyAuth": [] } ], - "description": "Delete a Group MCP by ID and Group ID", + "description": "Deletes a group-channel scope model config for a specific group", "produces": [ "application/json" ], "tags": [ - "mcp" + "group-scope-modelconfig" ], - "summary": "Delete Group MCP", + "summary": "Delete group scope model config", "parameters": [ { "type": "string", - "description": "MCP ID", - "name": "id", + "description": "Group name", + "name": "group", "in": "path", "required": true }, { "type": "string", - "description": "Group ID", - "name": "group", + "description": "Model name", + "name": "model", "in": "path", "required": true } @@ -4467,67 +4415,95 @@ const docTemplate = `{ } } }, - "/api/mcp/group/{group}/{id}/status": { - "post": { + "/api/group/{group}/scope_model_configs/": { + "get": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Update the status of a Group MCP", - "consumes": [ - "application/json" - ], + "description": "Returns group-channel scope model configs with pagination for a specific group", "produces": [ "application/json" ], "tags": [ - "mcp" + "group-scope-modelconfig" ], - "summary": "Update Group MCP status", + "summary": "Get group scope model configs", "parameters": [ { "type": "string", - "description": "MCP ID", - "name": "id", + "description": "Group name", + "name": "group", "in": "path", "required": true }, { "type": "string", - "description": "Group ID", - "name": "group", - "in": "path", - "required": true + "description": "Model name", + "name": "model", + "in": "query" }, { - "description": "MCP status", - "name": "status", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/controller.UpdateGroupMCPStatusRequest" - } + "type": "integer", + "description": "Page", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Per page", + "name": "per_page", + "in": "query" } ], "responses": { "200": { "description": "OK", "schema": { - "$ref": "#/definitions/middleware.APIResponse" + "allOf": [ + { + "$ref": "#/definitions/middleware.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "type": "object", + "additionalProperties": { + "allOf": [ + {}, + { + "type": "object", + "properties": { + "configs": { + "type": "array", + "items": { + "$ref": "#/definitions/model.GroupScopeModelConfig" + } + }, + "total": { + "type": "integer" + } + } + } + ] + } + } + } + } + ] } } } - } - }, - "/api/mcp/public/": { + }, "post": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Create a new MCP", + "description": "Saves group-channel scope model configs for a specific group", "consumes": [ "application/json" ], @@ -4535,17 +4511,27 @@ const docTemplate = `{ "application/json" ], "tags": [ - "mcp" + "group-scope-modelconfig" ], - "summary": "Create MCP", + "summary": "Save group scope model configs", "parameters": [ { - "description": "MCP object", - "name": "mcp", + "type": "string", + "description": "Group name", + "name": "group", + "in": "path", + "required": true + }, + { + "description": "Model configs", + "name": "configs", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/model.PublicMCP" + "type": "array", + "items": { + "$ref": "#/definitions/controller.SaveGroupScopeModelConfigsRequest" + } } } ], @@ -4553,44 +4539,32 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "allOf": [ - { - "$ref": "#/definitions/middleware.APIResponse" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/controller.PublicMCPResponse" - } - } - } - ] + "$ref": "#/definitions/middleware.APIResponse" } } } } }, - "/api/mcp/public/{id}": { + "/api/group/{group}/scope_model_configs/all": { "get": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Get a specific MCP by its ID", + "description": "Returns all group-channel scope model configs for a specific group", "produces": [ "application/json" ], "tags": [ - "mcp" + "group-scope-modelconfig" ], - "summary": "Get MCP by ID", + "summary": "Get all group scope model configs", "parameters": [ { "type": "string", - "description": "MCP ID", - "name": "id", + "description": "Group name", + "name": "group", "in": "path", "required": true } @@ -4607,7 +4581,10 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "$ref": "#/definitions/controller.PublicMCPResponse" + "type": "array", + "items": { + "$ref": "#/definitions/model.GroupScopeModelConfig" + } } } } @@ -4615,14 +4592,16 @@ const docTemplate = `{ } } } - }, - "put": { + } + }, + "/api/group/{group}/scope_model_configs/batch_delete": { + "post": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Save a MCP", + "description": "Deletes group-channel scope model configs by model names for a specific group", "consumes": [ "application/json" ], @@ -4630,24 +4609,27 @@ const docTemplate = `{ "application/json" ], "tags": [ - "mcp" + "group-scope-modelconfig" ], - "summary": "Save MCP", + "summary": "Delete group scope model configs", "parameters": [ { "type": "string", - "description": "MCP ID", - "name": "id", + "description": "Group name", + "name": "group", "in": "path", "required": true }, { - "description": "MCP object", - "name": "mcp", + "description": "Model names", + "name": "models", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/model.PublicMCP" + "type": "array", + "items": { + "type": "string" + } } } ], @@ -4655,30 +4637,20 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "allOf": [ - { - "$ref": "#/definitions/middleware.APIResponse" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/controller.PublicMCPResponse" - } - } - } - ] + "$ref": "#/definitions/middleware.APIResponse" } } } - }, + } + }, + "/api/group/{group}/scope_model_configs/contains": { "post": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Update an existing MCP", + "description": "Returns group-channel scope model configs for the requested models in a specific group", "consumes": [ "application/json" ], @@ -4686,24 +4658,24 @@ const docTemplate = `{ "application/json" ], "tags": [ - "mcp" + "group-scope-modelconfig" ], - "summary": "Update MCP", + "summary": "Get group scope model configs by models", "parameters": [ { "type": "string", - "description": "MCP ID", - "name": "id", + "description": "Group name", + "name": "group", "in": "path", "required": true }, { - "description": "MCP object", - "name": "mcp", + "description": "Models", + "name": "models", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/model.PublicMCP" + "$ref": "#/definitions/controller.GetModelConfigsByModelsContainsRequest" } } ], @@ -4719,7 +4691,10 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "$ref": "#/definitions/controller.PublicMCPResponse" + "type": "array", + "items": { + "$ref": "#/definitions/model.GroupScopeModelConfig" + } } } } @@ -4727,69 +4702,60 @@ const docTemplate = `{ } } } - }, - "delete": { - "security": [ - { - "ApiKeyAuth": [] - } - ], - "description": "Delete an MCP by ID", - "produces": [ - "application/json" - ], - "tags": [ - "mcp" - ], - "summary": "Delete MCP", - "parameters": [ - { - "type": "string", - "description": "MCP ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/middleware.APIResponse" - } - } - } } }, - "/api/mcp/public/{id}/group/{group}/params": { + "/api/group/{group}/scope_model_configs/search": { "get": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Get reusing parameters for a specific group and MCP", + "description": "Returns group-channel scope model configs by keyword for a specific group", "produces": [ "application/json" ], "tags": [ - "mcp" + "group-scope-modelconfig" ], - "summary": "Get group MCP reusing parameters", + "summary": "Search group scope model configs", "parameters": [ { "type": "string", - "description": "MCP ID", - "name": "id", + "description": "Group name", + "name": "group", "in": "path", "required": true }, { "type": "string", - "description": "Group ID", - "name": "group", - "in": "path", - "required": true + "description": "Keyword", + "name": "keyword", + "in": "query" + }, + { + "type": "string", + "description": "Model name", + "name": "model", + "in": "query" + }, + { + "type": "string", + "description": "Owner", + "name": "owner", + "in": "query" + }, + { + "type": "integer", + "description": "Page", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Per page", + "name": "per_page", + "in": "query" } ], "responses": { @@ -4804,7 +4770,26 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "$ref": "#/definitions/model.PublicMCPReusingParam" + "type": "object", + "additionalProperties": { + "allOf": [ + {}, + { + "type": "object", + "properties": { + "configs": { + "type": "array", + "items": { + "$ref": "#/definitions/model.GroupScopeModelConfig" + } + }, + "total": { + "type": "integer" + } + } + } + ] + } } } } @@ -4812,14 +4797,16 @@ const docTemplate = `{ } } } - }, + } + }, + "/api/group/{group}/status": { "post": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Create or update reusing parameters for a specific group and MCP", + "description": "Updates the status of a group", "consumes": [ "application/json" ], @@ -4827,31 +4814,24 @@ const docTemplate = `{ "application/json" ], "tags": [ - "mcp" + "group" ], - "summary": "Create or update group MCP reusing parameters", + "summary": "Update group status", "parameters": [ { "type": "string", - "description": "MCP ID", - "name": "id", + "description": "Group name", + "name": "group", "in": "path", "required": true }, { - "type": "string", - "description": "Group ID", - "name": "group", - "in": "path", - "required": true - }, - { - "description": "Reusing parameters", - "name": "params", + "description": "Status information", + "name": "status", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/model.PublicMCPReusingParam" + "$ref": "#/definitions/controller.UpdateGroupStatusRequest" } } ], @@ -4865,14 +4845,14 @@ const docTemplate = `{ } } }, - "/api/mcp/public/{id}/status": { + "/api/group/{group}/tpm_ratio": { "post": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Update the status of an MCP", + "description": "Updates the TPM (Tokens Per Minute) ratio for a group", "consumes": [ "application/json" ], @@ -4880,24 +4860,24 @@ const docTemplate = `{ "application/json" ], "tags": [ - "mcp" + "group" ], - "summary": "Update MCP status", + "summary": "Update group TPM ratio", "parameters": [ { "type": "string", - "description": "MCP ID", - "name": "id", + "description": "Group name", + "name": "group", "in": "path", "required": true }, { - "description": "MCP status", - "name": "status", + "description": "TPM ratio information", + "name": "data", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/controller.UpdatePublicMCPStatusRequest" + "$ref": "#/definitions/controller.UpdateGroupTPMRatioRequest" } } ], @@ -4911,57 +4891,78 @@ const docTemplate = `{ } } }, - "/api/mcp/publics/": { - "get": { + "/api/group_channel/": { + "post": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Get a list of MCPs with pagination and filtering", + "description": "Adds a group channel from the global management view. The request body must include group_id.", + "consumes": [ + "application/json" + ], "produces": [ "application/json" ], "tags": [ - "mcp" + "group_channel" ], - "summary": "Get MCPs", + "summary": "Add a group channel", "parameters": [ { - "type": "integer", - "description": "Page number", - "name": "page", - "in": "query" - }, - { - "type": "integer", - "description": "Items per page", - "name": "per_page", - "in": "query" - }, - { - "type": "string", - "description": "hosted or local", - "name": "type", - "in": "query" - }, + "description": "Group channel information", + "name": "channel", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/controller.AddGroupChannelRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/middleware.APIResponse" + } + } + } + } + }, + "/api/group_channel/test-preview": { + "post": { + "security": [ { - "type": "string", - "description": "MCP id", - "name": "id", - "in": "query" - }, + "ApiKeyAuth": [] + } + ], + "description": "Test a single model in a group channel without saving to database from the global management view. The request body must include group_id.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "group_channel" + ], + "summary": "Test group channel preview", + "parameters": [ { - "type": "string", - "description": "Search keyword", - "name": "keyword", + "type": "boolean", + "description": "Success body", + "name": "success_body", "in": "query" }, { - "type": "integer", - "description": "MCP status", - "name": "status", - "in": "query" + "description": "Group channel test request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/controller.TestSingleGroupChannelRequest" + } } ], "responses": { @@ -4976,10 +4977,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "type": "array", - "items": { - "$ref": "#/definitions/controller.PublicMCPResponse" - } + "$ref": "#/definitions/model.GroupChannelTest" } } } @@ -4987,14 +4985,16 @@ const docTemplate = `{ } } } - }, + } + }, + "/api/group_channel/test-preview-all": { "post": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Save a list of MCPs", + "description": "Test all models in a group channel without saving to database from the global management view. The request body must include group_id.", "consumes": [ "application/json" ], @@ -5002,20 +5002,35 @@ const docTemplate = `{ "application/json" ], "tags": [ - "mcp" + "group_channel" ], - "summary": "Save MCPs", + "summary": "Test group channel preview models", "parameters": [ { - "description": "MCP object", - "name": "mcp", + "type": "boolean", + "description": "Return success", + "name": "return_success", + "in": "query" + }, + { + "type": "boolean", + "description": "Success body", + "name": "success_body", + "in": "query" + }, + { + "type": "boolean", + "description": "Stream mode (SSE)", + "name": "stream", + "in": "query" + }, + { + "description": "Group channel test request", + "name": "request", "in": "body", "required": true, "schema": { - "type": "array", - "items": { - "$ref": "#/definitions/model.PublicMCP" - } + "$ref": "#/definitions/controller.TestGroupChannelRequest" } } ], @@ -5023,33 +5038,49 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/middleware.APIResponse" + "allOf": [ + { + "$ref": "#/definitions/middleware.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/controller.GroupChannelTestResult" + } + } + } + } + ] } } } } }, - "/api/mcp/publics/all": { + "/api/group_channel/{id}": { "get": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Get all MCPs with filtering", + "description": "Returns detailed information about a group channel across groups", "produces": [ "application/json" ], "tags": [ - "mcp" + "group_channel" ], - "summary": "Get all MCPs", + "summary": "Get a group channel by ID", "parameters": [ { "type": "integer", - "description": "MCP status", - "name": "status", - "in": "query" + "description": "Group channel ID", + "name": "id", + "in": "path", + "required": true } ], "responses": { @@ -5064,10 +5095,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "type": "array", - "items": { - "$ref": "#/definitions/controller.PublicMCPResponse" - } + "$ref": "#/definitions/model.GroupChannel" } } } @@ -5075,30 +5103,40 @@ const docTemplate = `{ } } } - } - }, - "/api/model_config/{model}": { - "get": { + }, + "put": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Returns a model config", + "description": "Updates a group channel by ID from the global management view", + "consumes": [ + "application/json" + ], "produces": [ "application/json" ], "tags": [ - "modelconfig" + "group_channel" ], - "summary": "Get model config", + "summary": "Update a group channel", "parameters": [ { - "type": "string", - "description": "Model name", - "name": "model", + "type": "integer", + "description": "Group channel ID", + "name": "id", "in": "path", "required": true + }, + { + "description": "Updated group channel information", + "name": "channel", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/controller.AddGroupChannelRequest" + } } ], "responses": { @@ -5113,7 +5151,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "$ref": "#/definitions/model.ModelConfig" + "$ref": "#/definitions/model.GroupChannel" } } } @@ -5122,29 +5160,27 @@ const docTemplate = `{ } } }, - "post": { + "delete": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Saves a model config", + "description": "Deletes a group channel by ID from the global management view", "produces": [ "application/json" ], "tags": [ - "modelconfig" + "group_channel" ], - "summary": "Save model config", + "summary": "Delete a group channel", "parameters": [ { - "description": "Model config", - "name": "config", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/controller.SaveModelConfigsRequest" - } + "type": "integer", + "description": "Group channel ID", + "name": "id", + "in": "path", + "required": true } ], "responses": { @@ -5155,28 +5191,42 @@ const docTemplate = `{ } } } - }, - "delete": { + } + }, + "/api/group_channel/{id}/status": { + "post": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Deletes a model config", + "description": "Updates the status of a group channel by ID from the global management view", + "consumes": [ + "application/json" + ], "produces": [ "application/json" ], "tags": [ - "modelconfig" + "group_channel" ], - "summary": "Delete model config", + "summary": "Update group channel status", "parameters": [ { - "type": "string", - "description": "Model name", - "name": "model", + "type": "integer", + "description": "Group channel ID", + "name": "id", "in": "path", "required": true + }, + { + "description": "Status information", + "name": "status", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/controller.UpdateChannelStatusRequest" + } } ], "responses": { @@ -5189,29 +5239,48 @@ const docTemplate = `{ } } }, - "/api/model_configs/": { + "/api/group_channel/{id}/test": { "get": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Returns a list of model configs with pagination", + "description": "Tests all models in a group channel from the global management view", "produces": [ "application/json" ], "tags": [ - "modelconfig" + "group_channel" ], - "summary": "Get model configs", + "summary": "Test group channel models", "parameters": [ { - "type": "string", - "description": "Model name", - "name": "model", + "type": "integer", + "description": "Group Channel ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "boolean", + "description": "Return success", + "name": "return_success", "in": "query" - } - ], + }, + { + "type": "boolean", + "description": "Success body", + "name": "success_body", + "in": "query" + }, + { + "type": "boolean", + "description": "Stream", + "name": "stream", + "in": "query" + } + ], "responses": { "200": { "description": "OK", @@ -5224,25 +5293,9 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "type": "object", - "additionalProperties": { - "allOf": [ - {}, - { - "type": "object", - "properties": { - "configs": { - "type": "array", - "items": { - "$ref": "#/definitions/model.ModelConfig" - } - }, - "total": { - "type": "integer" - } - } - } - ] + "type": "array", + "items": { + "$ref": "#/definitions/controller.GroupChannelTestResult" } } } @@ -5251,60 +5304,45 @@ const docTemplate = `{ } } } - }, - "post": { + } + }, + "/api/group_channel/{id}/test/{model}": { + "get": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Saves a list of model configs", + "description": "Tests a single model in a group channel from the global management view", "produces": [ "application/json" ], "tags": [ - "modelconfig" + "group_channel" ], - "summary": "Save model configs", + "summary": "Test group channel model", "parameters": [ { - "description": "Model configs", - "name": "configs", - "in": "body", - "required": true, - "schema": { - "type": "array", - "items": { - "$ref": "#/definitions/controller.SaveModelConfigsRequest" - } - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/middleware.APIResponse" - } - } - } - } - }, - "/api/model_configs/all": { - "get": { - "security": [ + "type": "integer", + "description": "Group Channel ID", + "name": "id", + "in": "path", + "required": true + }, { - "ApiKeyAuth": [] + "type": "string", + "description": "Model name", + "name": "model", + "in": "path", + "required": true + }, + { + "type": "boolean", + "description": "Success body", + "name": "success_body", + "in": "query" } ], - "description": "Returns a list of all model configs", - "produces": [ - "application/json" - ], - "tags": [ - "modelconfig" - ], - "summary": "Get all model configs", "responses": { "200": { "description": "OK", @@ -5317,10 +5355,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "type": "array", - "items": { - "$ref": "#/definitions/model.ModelConfig" - } + "$ref": "#/definitions/model.GroupChannelTest" } } } @@ -5330,69 +5365,34 @@ const docTemplate = `{ } } }, - "/api/model_configs/batch_delete": { - "post": { + "/api/group_channel/{id}/tests": { + "get": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Deletes a list of model configs", + "description": "Returns persisted test results for a group channel from the global management view", "produces": [ "application/json" ], "tags": [ - "modelconfig" + "group_channel" ], - "summary": "Delete model configs", + "summary": "Get group channel test results", "parameters": [ { - "description": "Model names", - "name": "models", - "in": "body", - "required": true, - "schema": { - "type": "array", - "items": { - "type": "string" - } - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/middleware.APIResponse" - } - } - } - } - }, - "/api/model_configs/contains": { - "post": { - "security": [ - { - "ApiKeyAuth": [] - } - ], - "description": "Returns a list of model configs by models contains", - "produces": [ - "application/json" - ], - "tags": [ - "modelconfig" - ], - "summary": "Get model configs by models contains", - "parameters": [ + "type": "integer", + "description": "Group Channel ID", + "name": "id", + "in": "path", + "required": true + }, { - "description": "Models", - "name": "models", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/controller.GetModelConfigsByModelsContainsRequest" - } + "type": "boolean", + "description": "Success body", + "name": "success_body", + "in": "query" } ], "responses": { @@ -5409,7 +5409,7 @@ const docTemplate = `{ "data": { "type": "array", "items": { - "$ref": "#/definitions/model.ModelConfig" + "$ref": "#/definitions/model.GroupChannelTest" } } } @@ -5420,50 +5420,74 @@ const docTemplate = `{ } } }, - "/api/model_configs/search": { + "/api/group_channels/": { "get": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Returns a list of model configs by keyword", + "description": "Returns a paginated list of group channels across groups with optional filters", "produces": [ "application/json" ], "tags": [ - "modelconfig" + "group_channels" ], - "summary": "Search model configs", + "summary": "Get group channels with pagination", "parameters": [ { "type": "string", - "description": "Keyword", - "name": "keyword", + "description": "Filter by group", + "name": "group", + "in": "query" + }, + { + "type": "integer", + "description": "Page number", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Items per page", + "name": "per_page", + "in": "query" + }, + { + "type": "integer", + "description": "Filter by id", + "name": "id", "in": "query" }, { "type": "string", - "description": "Model name", - "name": "model", + "description": "Filter by name", + "name": "name", "in": "query" }, { "type": "string", - "description": "Owner", - "name": "owner", + "description": "Filter by key", + "name": "key", "in": "query" }, { "type": "integer", - "description": "Page", - "name": "page", + "description": "Filter by channel type", + "name": "channel_type", "in": "query" }, { - "type": "integer", - "description": "Per page", - "name": "per_page", + "type": "string", + "description": "Filter by base URL", + "name": "base_url", + "in": "query" + }, + { + "type": "string", + "description": "Order by field", + "name": "order", "in": "query" } ], @@ -5486,10 +5510,10 @@ const docTemplate = `{ { "type": "object", "properties": { - "configs": { + "channels": { "type": "array", "items": { - "$ref": "#/definitions/model.ModelConfig" + "$ref": "#/definitions/model.GroupChannel" } }, "total": { @@ -5506,158 +5530,120 @@ const docTemplate = `{ } } } - } - }, - "/api/models/builtin": { - "get": { + }, + "post": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Returns a list of builtin models", + "description": "Adds group channels from the global management view. Each request item must include group_id.", + "consumes": [ + "application/json" + ], "produces": [ "application/json" ], "tags": [ - "model" + "group_channels" + ], + "summary": "Add multiple group channels", + "parameters": [ + { + "description": "Group channel information", + "name": "channels", + "in": "body", + "required": true, + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/controller.AddGroupChannelRequest" + } + } + } ], - "summary": "Get builtin models", "responses": { "200": { "description": "OK", "schema": { - "allOf": [ - { - "$ref": "#/definitions/middleware.APIResponse" - }, - { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "$ref": "#/definitions/controller.BuiltinModelConfig" - } - } - } - } - ] + "$ref": "#/definitions/middleware.APIResponse" } } } } }, - "/api/models/builtin/channel": { - "get": { + "/api/group_channels/batch_delete": { + "post": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Returns a list of channel builtin models", - "produces": [ + "description": "Deletes group channels by IDs from the global management view", + "consumes": [ + "application/json" + ], + "produces": [ "application/json" ], "tags": [ - "model" + "group_channels" + ], + "summary": "Delete multiple group channels", + "parameters": [ + { + "description": "Group channel IDs", + "name": "ids", + "in": "body", + "required": true, + "schema": { + "type": "array", + "items": { + "type": "integer" + } + } + } ], - "summary": "Get channel builtin models", "responses": { "200": { "description": "OK", "schema": { - "allOf": [ - { - "$ref": "#/definitions/middleware.APIResponse" - }, - { - "type": "object", - "properties": { - "data": { - "type": "object", - "additionalProperties": { - "type": "array", - "items": { - "$ref": "#/definitions/controller.BuiltinModelConfig" - } - } - } - } - } - ] + "$ref": "#/definitions/middleware.APIResponse" } } } } }, - "/api/models/builtin/channel/{type}": { - "get": { + "/api/group_channels/batch_info": { + "post": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Returns a list of channel builtin models by type", + "description": "Returns id, group, name, and type for a batch of group channel IDs across groups", + "consumes": [ + "application/json" + ], "produces": [ "application/json" ], "tags": [ - "model" + "group_channels" ], - "summary": "Get channel builtin models by type", + "summary": "Get basic info for multiple group channels", "parameters": [ { - "enum": [ - 1, - 3, - 4, - 12, - 13, - 14, - 15, - 16, - 17, - 18, - 19, - 20, - 23, - 24, - 25, - 26, - 27, - 28, - 29, - 30, - 31, - 32, - 33, - 34, - 35, - 36, - 37, - 40, - 41, - 42, - 43, - 44, - 45, - 46, - 47, - 48, - 49, - 50, - 51, - 52, - 53, - 54, - 55 - ], - "type": "integer", - "description": "Channel type", - "name": "type", - "in": "path", - "required": true + "description": "Group channel IDs", + "name": "ids", + "in": "body", + "required": true, + "schema": { + "type": "array", + "items": { + "type": "integer" + } + } } ], "responses": { @@ -5674,7 +5660,7 @@ const docTemplate = `{ "data": { "type": "array", "items": { - "$ref": "#/definitions/controller.BuiltinModelConfig" + "$ref": "#/definitions/model.GroupChannelBasicInfo" } } } @@ -5685,21 +5671,32 @@ const docTemplate = `{ } } }, - "/api/models/default": { - "get": { + "/api/group_channels/import/oneapi": { + "post": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Returns a list of channel default models and mapping", + "description": "Imports group channels from OneAPI from the global management view. The request body must include group_id.", "produces": [ "application/json" ], "tags": [ - "model" + "group_channels" + ], + "summary": "Import group channel from OneAPI", + "parameters": [ + { + "description": "Import group channel from OneAPI request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/controller.ImportChannelFromOneAPIRequest" + } + } ], - "summary": "Get channel default models and mapping", "responses": { "200": { "description": "OK", @@ -5712,29 +5709,8 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "type": "object", - "additionalProperties": { - "allOf": [ - {}, - { - "type": "object", - "properties": { - "mapping": { - "type": "object", - "additionalProperties": { - "type": "string" - } - }, - "models": { - "type": "array", - "items": { - "type": "string" - } - } - } - } - ] - } + "type": "array", + "items": {} } } } @@ -5744,28 +5720,81 @@ const docTemplate = `{ } } }, - "/api/models/default/{type}": { + "/api/group_channels/search": { "get": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Returns a list of channel default models and mapping by type", + "description": "Search group channels across groups with keyword and optional filters", "produces": [ "application/json" ], "tags": [ - "model" + "group_channels" ], - "summary": "Get channel default models and mapping by type", + "summary": "Search group channels", "parameters": [ { "type": "string", - "description": "Channel type", - "name": "type", - "in": "path", - "required": true + "description": "Filter by group", + "name": "group", + "in": "query" + }, + { + "type": "string", + "description": "Search keyword", + "name": "keyword", + "in": "query" + }, + { + "type": "integer", + "description": "Page number", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Items per page", + "name": "per_page", + "in": "query" + }, + { + "type": "integer", + "description": "Filter by id", + "name": "id", + "in": "query" + }, + { + "type": "string", + "description": "Filter by name", + "name": "name", + "in": "query" + }, + { + "type": "string", + "description": "Filter by key", + "name": "key", + "in": "query" + }, + { + "type": "integer", + "description": "Filter by channel type", + "name": "channel_type", + "in": "query" + }, + { + "type": "string", + "description": "Filter by base URL", + "name": "base_url", + "in": "query" + }, + { + "type": "string", + "description": "Order by field", + "name": "order", + "in": "query" } ], "responses": { @@ -5787,17 +5816,14 @@ const docTemplate = `{ { "type": "object", "properties": { - "mapping": { - "type": "object", - "additionalProperties": { - "type": "string" - } - }, - "models": { + "channels": { "type": "array", "items": { - "type": "string" + "$ref": "#/definitions/model.GroupChannel" } + }, + "total": { + "type": "integer" } } } @@ -5812,21 +5838,21 @@ const docTemplate = `{ } } }, - "/api/models/enabled": { + "/api/group_channels/type_metas": { "get": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Returns a list of enabled models", + "description": "Returns metadata for all channel types", "produces": [ "application/json" ], "tags": [ - "model" + "channels" ], - "summary": "Get enabled models", + "summary": "Get channel type metadata", "responses": { "200": { "description": "OK", @@ -5841,10 +5867,7 @@ const docTemplate = `{ "data": { "type": "object", "additionalProperties": { - "type": "array", - "items": { - "$ref": "#/definitions/model.ModelConfig" - } + "$ref": "#/definitions/adaptors.AdaptorMeta" } } } @@ -5855,70 +5878,35 @@ const docTemplate = `{ } } }, - "/api/models/enabled/{set}": { + "/api/groups/": { "get": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Returns a list of enabled models by set", + "description": "Returns a list of all groups with pagination", "produces": [ "application/json" ], "tags": [ - "model" + "groups" ], - "summary": "Get enabled models by set", + "summary": "Get all groups", "parameters": [ { - "type": "string", - "description": "Models set", - "name": "set", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/middleware.APIResponse" - }, - { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "$ref": "#/definitions/model.ModelConfig" - } - } - } - } - ] - } - } - } - } - }, - "/api/models/sets": { - "get": { - "security": [ + "type": "integer", + "description": "Page number", + "name": "page", + "in": "query" + }, { - "ApiKeyAuth": [] + "type": "integer", + "description": "Items per page", + "name": "per_page", + "in": "query" } ], - "description": "Returns a list of enabled models and channels sets", - "produces": [ - "application/json" - ], - "tags": [ - "model" - ], - "summary": "Get enabled models and channels sets", "responses": { "200": { "description": "OK", @@ -5933,13 +5921,23 @@ const docTemplate = `{ "data": { "type": "object", "additionalProperties": { - "type": "object", - "additionalProperties": { - "type": "array", - "items": { - "$ref": "#/definitions/controller.EnabledModelChannel" + "allOf": [ + {}, + { + "type": "object", + "properties": { + "groups": { + "type": "array", + "items": { + "$ref": "#/definitions/controller.GroupResponse" + } + }, + "total": { + "type": "integer" + } + } } - } + ] } } } @@ -5950,63 +5948,77 @@ const docTemplate = `{ } } }, - "/api/monitor/": { - "get": { + "/api/groups/batch_delete": { + "post": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Returns a list of all channel model error rates", + "description": "Deletes multiple groups by their IDs", + "consumes": [ + "application/json" + ], "produces": [ "application/json" ], "tags": [ - "monitor" + "groups" + ], + "summary": "Delete multiple groups", + "parameters": [ + { + "description": "Group IDs", + "name": "ids", + "in": "body", + "required": true, + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + } ], - "summary": "Get all channel model error rates", "responses": { "200": { "description": "OK", "schema": { - "allOf": [ - { - "$ref": "#/definitions/middleware.APIResponse" - }, - { - "type": "object", - "properties": { - "data": { - "type": "object", - "additionalProperties": { - "type": "object", - "additionalProperties": { - "type": "number", - "format": "float64" - } - } - } - } - } - ] + "$ref": "#/definitions/middleware.APIResponse" } } } - }, - "delete": { + } + }, + "/api/groups/batch_status": { + "post": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Clears all model errors", + "description": "Updates the status of multiple groups", + "consumes": [ + "application/json" + ], "produces": [ "application/json" ], "tags": [ - "monitor" + "groups" + ], + "summary": "Update multiple groups status", + "parameters": [ + { + "description": "Group IDs and status", + "name": "data", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/controller.UpdateGroupsStatusRequest" + } + } ], - "summary": "Clear all model errors", "responses": { "200": { "description": "OK", @@ -6017,21 +6029,68 @@ const docTemplate = `{ } } }, - "/api/monitor/banned_channels": { + "/api/groups/consumption_ranking": { "get": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Returns a list of all banned model channels", + "description": "Returns channel, model, or group consumption ranking aggregated from summary data", "produces": [ "application/json" ], "tags": [ - "monitor" + "groups" + ], + "summary": "Get consumption ranking", + "parameters": [ + { + "type": "string", + "default": "group", + "description": "Ranking type: channel, model, group", + "name": "type", + "in": "query" + }, + { + "type": "integer", + "format": "int64", + "description": "Start timestamp", + "name": "start_timestamp", + "in": "query" + }, + { + "type": "integer", + "format": "int64", + "description": "End timestamp", + "name": "end_timestamp", + "in": "query" + }, + { + "type": "string", + "description": "Timezone, default is Local", + "name": "timezone", + "in": "query" + }, + { + "type": "integer", + "description": "Page number", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Items per page", + "name": "per_page", + "in": "query" + }, + { + "type": "string", + "description": "Order: used_amount_desc, used_amount_asc, request_count_desc, request_count_asc, total_tokens_desc, total_tokens_asc, channel_id_asc, channel_id_desc, model_asc, model_desc, group_id_asc, group_id_desc", + "name": "order", + "in": "query" + } ], - "summary": "Get all banned model channels", "responses": { "200": { "description": "OK", @@ -6045,13 +6104,7 @@ const docTemplate = `{ "properties": { "data": { "type": "object", - "additionalProperties": { - "type": "array", - "items": { - "type": "integer", - "format": "int64" - } - } + "additionalProperties": true } } } @@ -6061,21 +6114,41 @@ const docTemplate = `{ } } }, - "/api/monitor/batch_group_token_metrics": { - "post": { + "/api/groups/ip_groups": { + "get": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Returns runtime rpm/tpm summary metrics for explicit group and token-name pairs", + "description": "Get IP group list", "produces": [ "application/json" ], "tags": [ - "monitor" + "groups" + ], + "summary": "Get IP group list", + "parameters": [ + { + "type": "integer", + "description": "Threshold", + "name": "threshold", + "in": "query" + }, + { + "type": "integer", + "description": "Start timestamp", + "name": "start_timestamp", + "in": "query" + }, + { + "type": "integer", + "description": "End timestamp", + "name": "end_timestamp", + "in": "query" + } ], - "summary": "Batch get runtime metrics for api key rows", "responses": { "200": { "description": "OK", @@ -6088,7 +6161,13 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "$ref": "#/definitions/controller.BatchGroupTokenMetricsResponse" + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "type": "string" + } + } } } } @@ -6098,33 +6177,71 @@ const docTemplate = `{ } } }, - "/api/monitor/group_model_metrics/{group}": { + "/api/groups/ranking": { "get": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Returns runtime rpm/tpm metrics grouped by model in a group", + "description": "Returns channel, model, or group consumption ranking aggregated from summary data", "produces": [ "application/json" ], "tags": [ - "monitor" + "groups" ], - "summary": "Get model runtime metrics for a group", + "summary": "Get consumption ranking", "parameters": [ { "type": "string", - "description": "Group ID", - "name": "group", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", + "default": "group", + "description": "Ranking type: channel, model, group", + "name": "type", + "in": "query" + }, + { + "type": "integer", + "format": "int64", + "description": "Start timestamp", + "name": "start_timestamp", + "in": "query" + }, + { + "type": "integer", + "format": "int64", + "description": "End timestamp", + "name": "end_timestamp", + "in": "query" + }, + { + "type": "string", + "description": "Timezone, default is Local", + "name": "timezone", + "in": "query" + }, + { + "type": "integer", + "description": "Page number", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Items per page", + "name": "per_page", + "in": "query" + }, + { + "type": "string", + "description": "Order: used_amount_desc, used_amount_asc, request_count_desc, request_count_asc, total_tokens_desc, total_tokens_asc, channel_id_asc, channel_id_desc, model_asc, model_desc, group_id_asc, group_id_desc", + "name": "order", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", "schema": { "allOf": [ { @@ -6134,7 +6251,8 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "$ref": "#/definitions/controller.GroupModelMetricsResponse" + "type": "object", + "additionalProperties": true } } } @@ -6144,21 +6262,54 @@ const docTemplate = `{ } } }, - "/api/monitor/group_summary_metrics": { + "/api/groups/search": { "get": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Returns rpm/tpm summary metrics for one or more groups", + "description": "Search groups with keyword and pagination", "produces": [ "application/json" ], "tags": [ - "monitor" + "groups" + ], + "summary": "Search groups", + "parameters": [ + { + "type": "string", + "description": "Search keyword", + "name": "keyword", + "in": "query", + "required": true + }, + { + "type": "integer", + "description": "Page number", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Items per page", + "name": "per_page", + "in": "query" + }, + { + "type": "integer", + "description": "Status", + "name": "status", + "in": "query" + }, + { + "type": "string", + "description": "Order", + "name": "order", + "in": "query" + } ], - "summary": "Get summary metrics for multiple groups", "responses": { "200": { "description": "OK", @@ -6171,7 +6322,26 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "$ref": "#/definitions/controller.GroupSummaryMetricsResponse" + "type": "object", + "additionalProperties": { + "allOf": [ + {}, + { + "type": "object", + "properties": { + "groups": { + "type": "array", + "items": { + "$ref": "#/definitions/controller.GroupResponse" + } + }, + "total": { + "type": "integer" + } + } + } + ] + } } } } @@ -6181,28 +6351,124 @@ const docTemplate = `{ } } }, - "/api/monitor/group_token_metrics/{group}": { + "/api/log/{group}": { "get": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Returns runtime rpm/tpm metrics grouped by token name in a group", + "description": "Get logs for a specific group", "produces": [ "application/json" ], "tags": [ - "monitor" + "log" ], - "summary": "Get token runtime metrics for a group", + "summary": "Get group logs", "parameters": [ { "type": "string", - "description": "Group ID", + "description": "Group name", "name": "group", "in": "path", "required": true + }, + { + "type": "integer", + "description": "Page number", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Items per page", + "name": "per_page", + "in": "query" + }, + { + "type": "integer", + "description": "Start timestamp (milliseconds)", + "name": "start_timestamp", + "in": "query" + }, + { + "type": "integer", + "description": "End timestamp (milliseconds)", + "name": "end_timestamp", + "in": "query" + }, + { + "type": "string", + "description": "Token name", + "name": "token_name", + "in": "query" + }, + { + "type": "string", + "description": "Model name", + "name": "model_name", + "in": "query" + }, + { + "type": "integer", + "description": "Channel ID", + "name": "channel", + "in": "query" + }, + { + "type": "integer", + "description": "Token ID", + "name": "token_id", + "in": "query" + }, + { + "type": "string", + "description": "Order", + "name": "order", + "in": "query" + }, + { + "type": "string", + "description": "Request ID", + "name": "request_id", + "in": "query" + }, + { + "type": "string", + "description": "Upstream ID", + "name": "upstream_id", + "in": "query" + }, + { + "type": "string", + "description": "Status code type", + "name": "code_type", + "in": "query" + }, + { + "type": "integer", + "description": "Status code", + "name": "code", + "in": "query" + }, + { + "type": "boolean", + "description": "Include request and response detail", + "name": "include_detail", + "in": "query" + }, + { + "type": "string", + "description": "IP", + "name": "ip", + "in": "query" + }, + { + "type": "string", + "description": "User", + "name": "user", + "in": "query" } ], "responses": { @@ -6217,7 +6483,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "$ref": "#/definitions/controller.GroupTokenMetricsResponse" + "$ref": "#/definitions/model.GetGroupLogsResult" } } } @@ -6227,28 +6493,35 @@ const docTemplate = `{ } } }, - "/api/monitor/group_tokenname_model_metrics/{group}": { + "/api/log/{group}/detail/{log_id}": { "get": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Returns runtime rpm/tpm metrics for token name and model combinations in a group", + "description": "Get detailed information about a specific log entry in a group", "produces": [ "application/json" ], "tags": [ - "monitor" + "log" ], - "summary": "Get token-model runtime metrics for a group", + "summary": "Get group log detail", "parameters": [ { "type": "string", - "description": "Group ID", + "description": "Group name", "name": "group", "in": "path", "required": true + }, + { + "type": "string", + "description": "Log ID", + "name": "log_id", + "in": "path", + "required": true } ], "responses": { @@ -6263,7 +6536,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "$ref": "#/definitions/controller.GroupTokennameModelMetricsResponse" + "$ref": "#/definitions/model.RequestDetail" } } } @@ -6273,37 +6546,4308 @@ const docTemplate = `{ } } }, - "/api/monitor/models": { + "/api/log/{group}/export": { "get": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Returns a list of models error rate", + "description": "Streams filtered group logs as a CSV table file", "produces": [ - "application/json" + "text/csv" ], "tags": [ - "monitor" + "log" ], - "summary": "Get models error rate", - "responses": { - "200": { - "description": "OK", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/middleware.APIResponse" - }, + "summary": "Export group logs", + "parameters": [ + { + "type": "string", + "description": "Group name", + "name": "group", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "Start timestamp, max span 30 days", + "name": "start_timestamp", + "in": "query" + }, + { + "type": "integer", + "description": "End timestamp, max span 30 days", + "name": "end_timestamp", + "in": "query" + }, + { + "type": "string", + "description": "Model name", + "name": "model_name", + "in": "query" + }, + { + "type": "integer", + "description": "Token ID", + "name": "token_id", + "in": "query" + }, + { + "type": "string", + "description": "Token name", + "name": "token_name", + "in": "query" + }, + { + "type": "string", + "description": "Sort order for created_at, supports desc or asc", + "name": "order", + "in": "query" + }, + { + "type": "string", + "description": "Request ID", + "name": "request_id", + "in": "query" + }, + { + "type": "string", + "description": "Upstream ID", + "name": "upstream_id", + "in": "query" + }, + { + "type": "string", + "description": "Status code type", + "name": "code_type", + "in": "query" + }, + { + "type": "integer", + "description": "Status code", + "name": "code", + "in": "query" + }, + { + "type": "boolean", + "description": "Include request and response detail, default false", + "name": "include_detail", + "in": "query" + }, + { + "type": "string", + "description": "IP", + "name": "ip", + "in": "query" + }, + { + "type": "string", + "description": "User", + "name": "user", + "in": "query" + }, + { + "type": "string", + "description": "Timezone, default is Local", + "name": "timezone", + "in": "query" + }, + { + "type": "integer", + "description": "Maximum exported rows; zero or negative means unlimited", + "name": "max_entries", + "in": "query" + }, + { + "type": "boolean", + "description": "Include channel column, default false", + "name": "include_channel", + "in": "query" + }, + { + "type": "boolean", + "description": "Include retry_at column, default false", + "name": "include_retry_at", + "in": "query" + }, + { + "type": "string", + "description": "Chunk interval, default 30m, min 10m, max 4h, e.g. 10m, 30m, 1h", + "name": "chunk_interval", + "in": "query" + } + ], + "responses": {} + } + }, + "/api/log/{group}/group_channel": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Get group-channel logs for a specific group", + "produces": [ + "application/json" + ], + "tags": [ + "log" + ], + "summary": "Get group channel logs", + "parameters": [ + { + "type": "string", + "description": "Group name", + "name": "group", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "Page number", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Items per page", + "name": "per_page", + "in": "query" + }, + { + "type": "integer", + "description": "Start timestamp (milliseconds)", + "name": "start_timestamp", + "in": "query" + }, + { + "type": "integer", + "description": "End timestamp (milliseconds)", + "name": "end_timestamp", + "in": "query" + }, + { + "type": "string", + "description": "Token name", + "name": "token_name", + "in": "query" + }, + { + "type": "string", + "description": "Model name", + "name": "model_name", + "in": "query" + }, + { + "type": "integer", + "description": "Group channel ID", + "name": "channel", + "in": "query" + }, + { + "type": "integer", + "description": "Token ID", + "name": "token_id", + "in": "query" + }, + { + "type": "string", + "description": "Order", + "name": "order", + "in": "query" + }, + { + "type": "string", + "description": "Request ID", + "name": "request_id", + "in": "query" + }, + { + "type": "string", + "description": "Upstream ID", + "name": "upstream_id", + "in": "query" + }, + { + "type": "string", + "description": "Status code type", + "name": "code_type", + "in": "query" + }, + { + "type": "integer", + "description": "Status code", + "name": "code", + "in": "query" + }, + { + "type": "boolean", + "description": "Include request and response detail", + "name": "include_detail", + "in": "query" + }, + { + "type": "string", + "description": "IP", + "name": "ip", + "in": "query" + }, + { + "type": "string", + "description": "User", + "name": "user", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/middleware.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.GetGroupChannelLogsResult" + } + } + } + ] + } + } + } + }, + "delete": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Deletes group-channel logs older than the specified retention period", + "produces": [ + "application/json" + ], + "tags": [ + "log" + ], + "summary": "Delete historical group channel logs", + "parameters": [ + { + "type": "integer", + "description": "Timestamp (milliseconds)", + "name": "timestamp", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/middleware.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "type": "integer" + } + } + } + ] + } + } + } + } + }, + "/api/log/{group}/group_channel/detail/{log_id}": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Get detailed information about a group channel log entry in a group", + "produces": [ + "application/json" + ], + "tags": [ + "log" + ], + "summary": "Get group channel log detail for a group", + "parameters": [ + { + "type": "string", + "description": "Group name", + "name": "group", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Log ID", + "name": "log_id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/middleware.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.RequestDetail" + } + } + } + ] + } + } + } + } + }, + "/api/log/{group}/group_channel/export": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Streams filtered group-channel logs as a CSV table file", + "produces": [ + "text/csv" + ], + "tags": [ + "log" + ], + "summary": "Export group channel logs", + "parameters": [ + { + "type": "string", + "description": "Group name", + "name": "group", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "Start timestamp, max span 30 days", + "name": "start_timestamp", + "in": "query" + }, + { + "type": "integer", + "description": "End timestamp, max span 30 days", + "name": "end_timestamp", + "in": "query" + }, + { + "type": "string", + "description": "Model name", + "name": "model_name", + "in": "query" + }, + { + "type": "integer", + "description": "Group channel ID", + "name": "channel", + "in": "query" + }, + { + "type": "integer", + "description": "Token ID", + "name": "token_id", + "in": "query" + }, + { + "type": "string", + "description": "Token name", + "name": "token_name", + "in": "query" + }, + { + "type": "string", + "description": "Sort order for created_at, supports desc or asc", + "name": "order", + "in": "query" + }, + { + "type": "string", + "description": "Request ID", + "name": "request_id", + "in": "query" + }, + { + "type": "string", + "description": "Upstream ID", + "name": "upstream_id", + "in": "query" + }, + { + "type": "string", + "description": "Status code type", + "name": "code_type", + "in": "query" + }, + { + "type": "integer", + "description": "Status code", + "name": "code", + "in": "query" + }, + { + "type": "boolean", + "description": "Include request and response detail, default false", + "name": "include_detail", + "in": "query" + }, + { + "type": "string", + "description": "IP", + "name": "ip", + "in": "query" + }, + { + "type": "string", + "description": "User", + "name": "user", + "in": "query" + }, + { + "type": "string", + "description": "Timezone, default is Local", + "name": "timezone", + "in": "query" + }, + { + "type": "integer", + "description": "Maximum exported rows; zero or negative means unlimited", + "name": "max_entries", + "in": "query" + }, + { + "type": "boolean", + "description": "Include retry_at column, default false", + "name": "include_retry_at", + "in": "query" + }, + { + "type": "string", + "description": "Chunk interval, default 30m, min 10m, max 4h, e.g. 10m, 30m, 1h", + "name": "chunk_interval", + "in": "query" + } + ], + "responses": {} + } + }, + "/api/log/{group}/group_channel/search": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Search group-channel logs for a specific group", + "produces": [ + "application/json" + ], + "tags": [ + "log" + ], + "summary": "Search group channel logs", + "parameters": [ + { + "type": "string", + "description": "Group name", + "name": "group", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Keyword", + "name": "keyword", + "in": "query" + }, + { + "type": "integer", + "description": "Page number", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Items per page", + "name": "per_page", + "in": "query" + }, + { + "type": "integer", + "description": "Start timestamp (milliseconds)", + "name": "start_timestamp", + "in": "query" + }, + { + "type": "integer", + "description": "End timestamp (milliseconds)", + "name": "end_timestamp", + "in": "query" + }, + { + "type": "string", + "description": "Filter by token name", + "name": "token_name", + "in": "query" + }, + { + "type": "string", + "description": "Filter by model name", + "name": "model_name", + "in": "query" + }, + { + "type": "integer", + "description": "Filter by token id", + "name": "token_id", + "in": "query" + }, + { + "type": "integer", + "description": "Filter by group channel", + "name": "channel", + "in": "query" + }, + { + "type": "string", + "description": "Order", + "name": "order", + "in": "query" + }, + { + "type": "string", + "description": "Request ID", + "name": "request_id", + "in": "query" + }, + { + "type": "string", + "description": "Upstream ID", + "name": "upstream_id", + "in": "query" + }, + { + "type": "string", + "description": "Status code type", + "name": "code_type", + "in": "query" + }, + { + "type": "integer", + "description": "Status code", + "name": "code", + "in": "query" + }, + { + "type": "boolean", + "description": "Include request and response detail", + "name": "include_detail", + "in": "query" + }, + { + "type": "string", + "description": "IP", + "name": "ip", + "in": "query" + }, + { + "type": "string", + "description": "User", + "name": "user", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/middleware.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.GetGroupChannelLogsResult" + } + } + } + ] + } + } + } + } + }, + "/api/log/{group}/search": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Search logs for a specific group with filters", + "produces": [ + "application/json" + ], + "tags": [ + "log" + ], + "summary": "Search group logs", + "parameters": [ + { + "type": "string", + "description": "Group name", + "name": "group", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Keyword", + "name": "keyword", + "in": "query" + }, + { + "type": "integer", + "description": "Page number", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Items per page", + "name": "per_page", + "in": "query" + }, + { + "type": "integer", + "description": "Start timestamp (milliseconds)", + "name": "start_timestamp", + "in": "query" + }, + { + "type": "integer", + "description": "End timestamp (milliseconds)", + "name": "end_timestamp", + "in": "query" + }, + { + "type": "string", + "description": "Filter by token name", + "name": "token_name", + "in": "query" + }, + { + "type": "string", + "description": "Filter by model name", + "name": "model_name", + "in": "query" + }, + { + "type": "integer", + "description": "Filter by token id", + "name": "token_id", + "in": "query" + }, + { + "type": "string", + "description": "Order", + "name": "order", + "in": "query" + }, + { + "type": "string", + "description": "Request ID", + "name": "request_id", + "in": "query" + }, + { + "type": "string", + "description": "Upstream ID", + "name": "upstream_id", + "in": "query" + }, + { + "type": "string", + "description": "Status code type", + "name": "code_type", + "in": "query" + }, + { + "type": "integer", + "description": "Status code", + "name": "code", + "in": "query" + }, + { + "type": "boolean", + "description": "Include request and response detail", + "name": "include_detail", + "in": "query" + }, + { + "type": "string", + "description": "IP", + "name": "ip", + "in": "query" + }, + { + "type": "string", + "description": "User", + "name": "user", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/middleware.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.GetGroupLogsResult" + } + } + } + ] + } + } + } + } + }, + "/api/logs/": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Returns a paginated list of all logs with optional filters", + "produces": [ + "application/json" + ], + "tags": [ + "logs" + ], + "summary": "Get all logs", + "parameters": [ + { + "type": "integer", + "description": "Page number", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Items per page", + "name": "per_page", + "in": "query" + }, + { + "type": "integer", + "description": "Start timestamp (milliseconds)", + "name": "start_timestamp", + "in": "query" + }, + { + "type": "integer", + "description": "End timestamp (milliseconds)", + "name": "end_timestamp", + "in": "query" + }, + { + "type": "string", + "description": "Model name", + "name": "model_name", + "in": "query" + }, + { + "type": "integer", + "description": "Channel ID", + "name": "channel", + "in": "query" + }, + { + "type": "string", + "description": "Order", + "name": "order", + "in": "query" + }, + { + "type": "string", + "description": "Request ID", + "name": "request_id", + "in": "query" + }, + { + "type": "string", + "description": "Upstream ID", + "name": "upstream_id", + "in": "query" + }, + { + "type": "string", + "description": "Status code type", + "name": "code_type", + "in": "query" + }, + { + "type": "integer", + "description": "Status code", + "name": "code", + "in": "query" + }, + { + "type": "boolean", + "description": "Include request and response detail", + "name": "include_detail", + "in": "query" + }, + { + "type": "string", + "description": "IP", + "name": "ip", + "in": "query" + }, + { + "type": "string", + "description": "User", + "name": "user", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/middleware.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.GetLogsResult" + } + } + } + ] + } + } + } + }, + "delete": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Deletes logs older than the specified retention period", + "produces": [ + "application/json" + ], + "tags": [ + "logs" + ], + "summary": "Delete historical logs", + "parameters": [ + { + "type": "integer", + "description": "Timestamp (milliseconds)", + "name": "timestamp", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/middleware.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "type": "integer" + } + } + } + ] + } + } + } + } + }, + "/api/logs/consume_error": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Search for logs with consumption errors", + "produces": [ + "application/json" + ], + "tags": [ + "logs" + ], + "summary": "Search consumption errors", + "parameters": [ + { + "type": "integer", + "description": "Page number", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Items per page", + "name": "per_page", + "in": "query" + }, + { + "type": "integer", + "description": "Start timestamp (milliseconds)", + "name": "start_timestamp", + "in": "query" + }, + { + "type": "integer", + "description": "End timestamp (milliseconds)", + "name": "end_timestamp", + "in": "query" + }, + { + "type": "string", + "description": "Keyword", + "name": "keyword", + "in": "query" + }, + { + "type": "string", + "description": "Group", + "name": "group", + "in": "query" + }, + { + "type": "string", + "description": "Token name", + "name": "token_name", + "in": "query" + }, + { + "type": "string", + "description": "Model name", + "name": "model_name", + "in": "query" + }, + { + "type": "string", + "description": "Content", + "name": "content", + "in": "query" + }, + { + "type": "integer", + "description": "Token ID", + "name": "token_id", + "in": "query" + }, + { + "type": "string", + "description": "Order", + "name": "order", + "in": "query" + }, + { + "type": "string", + "description": "Request ID", + "name": "request_id", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/middleware.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "type": "object", + "additionalProperties": { + "allOf": [ + {}, + { + "type": "object", + "properties": { + "logs": { + "type": "array", + "items": { + "$ref": "#/definitions/model.RequestDetail" + } + }, + "total": { + "type": "integer" + } + } + } + ] + } + } + } + } + ] + } + } + } + } + }, + "/api/logs/detail/{log_id}": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Get detailed information about a specific log entry", + "produces": [ + "application/json" + ], + "tags": [ + "logs" + ], + "summary": "Get log detail", + "parameters": [ + { + "type": "string", + "description": "Log ID", + "name": "log_id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/middleware.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.RequestDetail" + } + } + } + ] + } + } + } + } + }, + "/api/logs/export": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Streams filtered global logs as a CSV table file", + "produces": [ + "text/csv" + ], + "tags": [ + "logs" + ], + "summary": "Export global logs", + "parameters": [ + { + "type": "integer", + "description": "Start timestamp, max span 30 days", + "name": "start_timestamp", + "in": "query" + }, + { + "type": "integer", + "description": "End timestamp, max span 30 days", + "name": "end_timestamp", + "in": "query" + }, + { + "type": "string", + "description": "Model name", + "name": "model_name", + "in": "query" + }, + { + "type": "integer", + "description": "Channel ID", + "name": "channel", + "in": "query" + }, + { + "type": "string", + "description": "Sort order for created_at, supports desc or asc", + "name": "order", + "in": "query" + }, + { + "type": "string", + "description": "Request ID", + "name": "request_id", + "in": "query" + }, + { + "type": "string", + "description": "Upstream ID", + "name": "upstream_id", + "in": "query" + }, + { + "type": "string", + "description": "Status code type", + "name": "code_type", + "in": "query" + }, + { + "type": "integer", + "description": "Status code", + "name": "code", + "in": "query" + }, + { + "type": "boolean", + "description": "Include request and response detail, default false", + "name": "include_detail", + "in": "query" + }, + { + "type": "string", + "description": "IP", + "name": "ip", + "in": "query" + }, + { + "type": "string", + "description": "User", + "name": "user", + "in": "query" + }, + { + "type": "string", + "description": "Timezone, default is Local", + "name": "timezone", + "in": "query" + }, + { + "type": "integer", + "description": "Maximum exported rows; zero or negative means unlimited", + "name": "max_entries", + "in": "query" + }, + { + "type": "string", + "description": "Chunk interval, default 30m, min 10m, max 4h, e.g. 10m, 30m, 1h", + "name": "chunk_interval", + "in": "query" + } + ], + "responses": {} + } + }, + "/api/logs/group_channel": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Get group-channel logs across groups with optional filters", + "produces": [ + "application/json" + ], + "tags": [ + "logs" + ], + "summary": "Get global group channel logs", + "parameters": [ + { + "type": "string", + "description": "Filter by group", + "name": "group", + "in": "query" + }, + { + "type": "integer", + "description": "Page number", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Items per page", + "name": "per_page", + "in": "query" + }, + { + "type": "integer", + "description": "Start timestamp (milliseconds)", + "name": "start_timestamp", + "in": "query" + }, + { + "type": "integer", + "description": "End timestamp (milliseconds)", + "name": "end_timestamp", + "in": "query" + }, + { + "type": "string", + "description": "Token name", + "name": "token_name", + "in": "query" + }, + { + "type": "string", + "description": "Model name", + "name": "model_name", + "in": "query" + }, + { + "type": "integer", + "description": "Group channel ID", + "name": "channel", + "in": "query" + }, + { + "type": "integer", + "description": "Token ID", + "name": "token_id", + "in": "query" + }, + { + "type": "string", + "description": "Order", + "name": "order", + "in": "query" + }, + { + "type": "string", + "description": "Request ID", + "name": "request_id", + "in": "query" + }, + { + "type": "string", + "description": "Upstream ID", + "name": "upstream_id", + "in": "query" + }, + { + "type": "string", + "description": "Status code type", + "name": "code_type", + "in": "query" + }, + { + "type": "integer", + "description": "Status code", + "name": "code", + "in": "query" + }, + { + "type": "boolean", + "description": "Include request and response detail", + "name": "include_detail", + "in": "query" + }, + { + "type": "string", + "description": "IP", + "name": "ip", + "in": "query" + }, + { + "type": "string", + "description": "User", + "name": "user", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/middleware.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.GetGroupChannelLogsResult" + } + } + } + ] + } + } + } + }, + "delete": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Deletes group-channel logs older than the specified retention period, optionally filtered by group", + "produces": [ + "application/json" + ], + "tags": [ + "logs" + ], + "summary": "Delete historical group channel logs", + "parameters": [ + { + "type": "string", + "description": "Filter by group", + "name": "group", + "in": "query" + }, + { + "type": "integer", + "description": "Timestamp (milliseconds)", + "name": "timestamp", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/middleware.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "type": "integer" + } + } + } + ] + } + } + } + } + }, + "/api/logs/group_channel/detail/{log_id}": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Get detailed information about a group channel log entry across groups", + "produces": [ + "application/json" + ], + "tags": [ + "logs" + ], + "summary": "Get global group channel log detail", + "parameters": [ + { + "type": "string", + "description": "Log ID", + "name": "log_id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/middleware.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.RequestDetail" + } + } + } + ] + } + } + } + } + }, + "/api/logs/group_channel/export": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Streams filtered group-channel logs across groups as a CSV table file", + "produces": [ + "text/csv" + ], + "tags": [ + "logs" + ], + "summary": "Export global group channel logs", + "parameters": [ + { + "type": "string", + "description": "Filter by group", + "name": "group", + "in": "query" + }, + { + "type": "integer", + "description": "Start timestamp, max span 30 days", + "name": "start_timestamp", + "in": "query" + }, + { + "type": "integer", + "description": "End timestamp, max span 30 days", + "name": "end_timestamp", + "in": "query" + }, + { + "type": "string", + "description": "Model name", + "name": "model_name", + "in": "query" + }, + { + "type": "integer", + "description": "Group channel ID", + "name": "channel", + "in": "query" + }, + { + "type": "integer", + "description": "Token ID", + "name": "token_id", + "in": "query" + }, + { + "type": "string", + "description": "Token name", + "name": "token_name", + "in": "query" + }, + { + "type": "string", + "description": "Sort order for created_at, supports desc or asc", + "name": "order", + "in": "query" + }, + { + "type": "string", + "description": "Request ID", + "name": "request_id", + "in": "query" + }, + { + "type": "string", + "description": "Upstream ID", + "name": "upstream_id", + "in": "query" + }, + { + "type": "string", + "description": "Status code type", + "name": "code_type", + "in": "query" + }, + { + "type": "integer", + "description": "Status code", + "name": "code", + "in": "query" + }, + { + "type": "boolean", + "description": "Include request and response detail, default false", + "name": "include_detail", + "in": "query" + }, + { + "type": "string", + "description": "IP", + "name": "ip", + "in": "query" + }, + { + "type": "string", + "description": "User", + "name": "user", + "in": "query" + }, + { + "type": "string", + "description": "Timezone, default is Local", + "name": "timezone", + "in": "query" + }, + { + "type": "integer", + "description": "Maximum exported rows; zero or negative means unlimited", + "name": "max_entries", + "in": "query" + }, + { + "type": "boolean", + "description": "Include retry_at column, default false", + "name": "include_retry_at", + "in": "query" + }, + { + "type": "string", + "description": "Chunk interval, default 30m, min 10m, max 4h, e.g. 10m, 30m, 1h", + "name": "chunk_interval", + "in": "query" + } + ], + "responses": {} + } + }, + "/api/logs/group_channel/search": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Search group-channel logs across groups with optional filters", + "produces": [ + "application/json" + ], + "tags": [ + "logs" + ], + "summary": "Search global group channel logs", + "parameters": [ + { + "type": "string", + "description": "Filter by group", + "name": "group", + "in": "query" + }, + { + "type": "string", + "description": "Keyword", + "name": "keyword", + "in": "query" + }, + { + "type": "integer", + "description": "Page number", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Items per page", + "name": "per_page", + "in": "query" + }, + { + "type": "integer", + "description": "Start timestamp (milliseconds)", + "name": "start_timestamp", + "in": "query" + }, + { + "type": "integer", + "description": "End timestamp (milliseconds)", + "name": "end_timestamp", + "in": "query" + }, + { + "type": "string", + "description": "Filter by token name", + "name": "token_name", + "in": "query" + }, + { + "type": "string", + "description": "Filter by model name", + "name": "model_name", + "in": "query" + }, + { + "type": "integer", + "description": "Filter by token id", + "name": "token_id", + "in": "query" + }, + { + "type": "integer", + "description": "Filter by group channel", + "name": "channel", + "in": "query" + }, + { + "type": "string", + "description": "Order", + "name": "order", + "in": "query" + }, + { + "type": "string", + "description": "Request ID", + "name": "request_id", + "in": "query" + }, + { + "type": "string", + "description": "Upstream ID", + "name": "upstream_id", + "in": "query" + }, + { + "type": "string", + "description": "Status code type", + "name": "code_type", + "in": "query" + }, + { + "type": "integer", + "description": "Status code", + "name": "code", + "in": "query" + }, + { + "type": "boolean", + "description": "Include request and response detail", + "name": "include_detail", + "in": "query" + }, + { + "type": "string", + "description": "IP", + "name": "ip", + "in": "query" + }, + { + "type": "string", + "description": "User", + "name": "user", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/middleware.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.GetGroupChannelLogsResult" + } + } + } + ] + } + } + } + } + }, + "/api/logs/search": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Search logs with various filters", + "produces": [ + "application/json" + ], + "tags": [ + "logs" + ], + "summary": "Search logs", + "parameters": [ + { + "type": "string", + "description": "Keyword", + "name": "keyword", + "in": "query" + }, + { + "type": "integer", + "description": "Page number", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Items per page", + "name": "per_page", + "in": "query" + }, + { + "type": "integer", + "description": "Start timestamp (milliseconds)", + "name": "start_timestamp", + "in": "query" + }, + { + "type": "integer", + "description": "End timestamp (milliseconds)", + "name": "end_timestamp", + "in": "query" + }, + { + "type": "string", + "description": "Filter by model name", + "name": "model_name", + "in": "query" + }, + { + "type": "integer", + "description": "Filter by channel", + "name": "channel", + "in": "query" + }, + { + "type": "string", + "description": "Group name", + "name": "group", + "in": "query", + "required": true + }, + { + "type": "integer", + "description": "Filter by token id", + "name": "token_id", + "in": "query" + }, + { + "type": "string", + "description": "Filter by token name", + "name": "token_name", + "in": "query" + }, + { + "type": "string", + "description": "Order", + "name": "order", + "in": "query" + }, + { + "type": "string", + "description": "Request ID", + "name": "request_id", + "in": "query" + }, + { + "type": "string", + "description": "Upstream ID", + "name": "upstream_id", + "in": "query" + }, + { + "type": "string", + "description": "Status code type", + "name": "code_type", + "in": "query" + }, + { + "type": "integer", + "description": "Status code", + "name": "code", + "in": "query" + }, + { + "type": "boolean", + "description": "Include request and response detail", + "name": "include_detail", + "in": "query" + }, + { + "type": "string", + "description": "IP", + "name": "ip", + "in": "query" + }, + { + "type": "string", + "description": "User", + "name": "user", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/middleware.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.GetLogsResult" + } + } + } + ] + } + } + } + } + }, + "/api/mcp/group/all": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Get all Group MCPs with filtering", + "produces": [ + "application/json" + ], + "tags": [ + "mcp" + ], + "summary": "Get all Group MCPs", + "parameters": [ + { + "type": "integer", + "description": "MCP status", + "name": "status", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/middleware.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/controller.GroupMCPResponse" + } + } + } + } + ] + } + } + } + } + }, + "/api/mcp/group/{group}": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Get a list of Group MCPs with pagination and filtering", + "produces": [ + "application/json" + ], + "tags": [ + "mcp" + ], + "summary": "Get Group MCPs", + "parameters": [ + { + "type": "string", + "description": "Group ID", + "name": "group", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "Page number", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Items per page", + "name": "per_page", + "in": "query" + }, + { + "type": "string", + "description": "MCP id", + "name": "id", + "in": "query" + }, + { + "type": "string", + "description": "MCP type, mcp_proxy_sse, mcp_proxy_streamable, mcp_openapi", + "name": "type", + "in": "query" + }, + { + "type": "string", + "description": "Search keyword", + "name": "keyword", + "in": "query" + }, + { + "type": "integer", + "description": "MCP status", + "name": "status", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/middleware.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/controller.GroupMCPResponse" + } + } + } + } + ] + } + } + } + }, + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Create a new Group MCP", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "mcp" + ], + "summary": "Create Group MCP", + "parameters": [ + { + "type": "string", + "description": "Group ID", + "name": "group", + "in": "path", + "required": true + }, + { + "description": "Group MCP object", + "name": "mcp", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/model.GroupMCP" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/middleware.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/controller.GroupMCPResponse" + } + } + } + ] + } + } + } + } + }, + "/api/mcp/group/{group}/{id}": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Get a specific Group MCP by its ID and Group ID", + "produces": [ + "application/json" + ], + "tags": [ + "mcp" + ], + "summary": "Get Group MCP by ID", + "parameters": [ + { + "type": "string", + "description": "MCP ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Group ID", + "name": "group", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/middleware.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/controller.GroupMCPResponse" + } + } + } + ] + } + } + } + }, + "put": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Update an existing Group MCP", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "mcp" + ], + "summary": "Update Group MCP", + "parameters": [ + { + "type": "string", + "description": "MCP ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Group ID", + "name": "group", + "in": "path", + "required": true + }, + { + "description": "Group MCP object", + "name": "mcp", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/model.GroupMCP" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/middleware.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/controller.GroupMCPResponse" + } + } + } + ] + } + } + } + }, + "delete": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Delete a Group MCP by ID and Group ID", + "produces": [ + "application/json" + ], + "tags": [ + "mcp" + ], + "summary": "Delete Group MCP", + "parameters": [ + { + "type": "string", + "description": "MCP ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Group ID", + "name": "group", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/middleware.APIResponse" + } + } + } + } + }, + "/api/mcp/group/{group}/{id}/status": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Update the status of a Group MCP", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "mcp" + ], + "summary": "Update Group MCP status", + "parameters": [ + { + "type": "string", + "description": "MCP ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Group ID", + "name": "group", + "in": "path", + "required": true + }, + { + "description": "MCP status", + "name": "status", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/controller.UpdateGroupMCPStatusRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/middleware.APIResponse" + } + } + } + } + }, + "/api/mcp/public/": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Create a new MCP", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "mcp" + ], + "summary": "Create MCP", + "parameters": [ + { + "description": "MCP object", + "name": "mcp", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/model.PublicMCP" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/middleware.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/controller.PublicMCPResponse" + } + } + } + ] + } + } + } + } + }, + "/api/mcp/public/{id}": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Get a specific MCP by its ID", + "produces": [ + "application/json" + ], + "tags": [ + "mcp" + ], + "summary": "Get MCP by ID", + "parameters": [ + { + "type": "string", + "description": "MCP ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/middleware.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/controller.PublicMCPResponse" + } + } + } + ] + } + } + } + }, + "put": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Save a MCP", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "mcp" + ], + "summary": "Save MCP", + "parameters": [ + { + "type": "string", + "description": "MCP ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "MCP object", + "name": "mcp", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/model.PublicMCP" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/middleware.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/controller.PublicMCPResponse" + } + } + } + ] + } + } + } + }, + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Update an existing MCP", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "mcp" + ], + "summary": "Update MCP", + "parameters": [ + { + "type": "string", + "description": "MCP ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "MCP object", + "name": "mcp", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/model.PublicMCP" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/middleware.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/controller.PublicMCPResponse" + } + } + } + ] + } + } + } + }, + "delete": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Delete an MCP by ID", + "produces": [ + "application/json" + ], + "tags": [ + "mcp" + ], + "summary": "Delete MCP", + "parameters": [ + { + "type": "string", + "description": "MCP ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/middleware.APIResponse" + } + } + } + } + }, + "/api/mcp/public/{id}/group/{group}/params": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Get reusing parameters for a specific group and MCP", + "produces": [ + "application/json" + ], + "tags": [ + "mcp" + ], + "summary": "Get group MCP reusing parameters", + "parameters": [ + { + "type": "string", + "description": "MCP ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Group ID", + "name": "group", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/middleware.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.PublicMCPReusingParam" + } + } + } + ] + } + } + } + }, + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Create or update reusing parameters for a specific group and MCP", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "mcp" + ], + "summary": "Create or update group MCP reusing parameters", + "parameters": [ + { + "type": "string", + "description": "MCP ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Group ID", + "name": "group", + "in": "path", + "required": true + }, + { + "description": "Reusing parameters", + "name": "params", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/model.PublicMCPReusingParam" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/middleware.APIResponse" + } + } + } + } + }, + "/api/mcp/public/{id}/status": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Update the status of an MCP", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "mcp" + ], + "summary": "Update MCP status", + "parameters": [ + { + "type": "string", + "description": "MCP ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "MCP status", + "name": "status", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/controller.UpdatePublicMCPStatusRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/middleware.APIResponse" + } + } + } + } + }, + "/api/mcp/publics/": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Get a list of MCPs with pagination and filtering", + "produces": [ + "application/json" + ], + "tags": [ + "mcp" + ], + "summary": "Get MCPs", + "parameters": [ + { + "type": "integer", + "description": "Page number", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Items per page", + "name": "per_page", + "in": "query" + }, + { + "type": "string", + "description": "hosted or local", + "name": "type", + "in": "query" + }, + { + "type": "string", + "description": "MCP id", + "name": "id", + "in": "query" + }, + { + "type": "string", + "description": "Search keyword", + "name": "keyword", + "in": "query" + }, + { + "type": "integer", + "description": "MCP status", + "name": "status", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/middleware.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/controller.PublicMCPResponse" + } + } + } + } + ] + } + } + } + }, + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Save a list of MCPs", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "mcp" + ], + "summary": "Save MCPs", + "parameters": [ + { + "description": "MCP object", + "name": "mcp", + "in": "body", + "required": true, + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/model.PublicMCP" + } + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/middleware.APIResponse" + } + } + } + } + }, + "/api/mcp/publics/all": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Get all MCPs with filtering", + "produces": [ + "application/json" + ], + "tags": [ + "mcp" + ], + "summary": "Get all MCPs", + "parameters": [ + { + "type": "integer", + "description": "MCP status", + "name": "status", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/middleware.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/controller.PublicMCPResponse" + } + } + } + } + ] + } + } + } + } + }, + "/api/model_config/{model}": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Returns a model config", + "produces": [ + "application/json" + ], + "tags": [ + "modelconfig" + ], + "summary": "Get model config", + "parameters": [ + { + "type": "string", + "description": "Model name", + "name": "model", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/middleware.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.ModelConfig" + } + } + } + ] + } + } + } + }, + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Saves a model config", + "produces": [ + "application/json" + ], + "tags": [ + "modelconfig" + ], + "summary": "Save model config", + "parameters": [ + { + "description": "Model config", + "name": "config", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/controller.SaveModelConfigsRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/middleware.APIResponse" + } + } + } + }, + "delete": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Deletes a model config", + "produces": [ + "application/json" + ], + "tags": [ + "modelconfig" + ], + "summary": "Delete model config", + "parameters": [ + { + "type": "string", + "description": "Model name", + "name": "model", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/middleware.APIResponse" + } + } + } + } + }, + "/api/model_configs/": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Returns a list of model configs with pagination", + "produces": [ + "application/json" + ], + "tags": [ + "modelconfig" + ], + "summary": "Get model configs", + "parameters": [ + { + "type": "string", + "description": "Model name", + "name": "model", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/middleware.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "type": "object", + "additionalProperties": { + "allOf": [ + {}, + { + "type": "object", + "properties": { + "configs": { + "type": "array", + "items": { + "$ref": "#/definitions/model.ModelConfig" + } + }, + "total": { + "type": "integer" + } + } + } + ] + } + } + } + } + ] + } + } + } + }, + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Saves a list of model configs", + "produces": [ + "application/json" + ], + "tags": [ + "modelconfig" + ], + "summary": "Save model configs", + "parameters": [ + { + "description": "Model configs", + "name": "configs", + "in": "body", + "required": true, + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/controller.SaveModelConfigsRequest" + } + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/middleware.APIResponse" + } + } + } + } + }, + "/api/model_configs/all": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Returns a list of all model configs", + "produces": [ + "application/json" + ], + "tags": [ + "modelconfig" + ], + "summary": "Get all model configs", + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/middleware.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/model.ModelConfig" + } + } + } + } + ] + } + } + } + } + }, + "/api/model_configs/batch_delete": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Deletes a list of model configs", + "produces": [ + "application/json" + ], + "tags": [ + "modelconfig" + ], + "summary": "Delete model configs", + "parameters": [ + { + "description": "Model names", + "name": "models", + "in": "body", + "required": true, + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/middleware.APIResponse" + } + } + } + } + }, + "/api/model_configs/contains": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Returns a list of model configs by models contains", + "produces": [ + "application/json" + ], + "tags": [ + "modelconfig" + ], + "summary": "Get model configs by models contains", + "parameters": [ + { + "description": "Models", + "name": "models", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/controller.GetModelConfigsByModelsContainsRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/middleware.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/model.ModelConfig" + } + } + } + } + ] + } + } + } + } + }, + "/api/model_configs/search": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Returns a list of model configs by keyword", + "produces": [ + "application/json" + ], + "tags": [ + "modelconfig" + ], + "summary": "Search model configs", + "parameters": [ + { + "type": "string", + "description": "Keyword", + "name": "keyword", + "in": "query" + }, + { + "type": "string", + "description": "Model name", + "name": "model", + "in": "query" + }, + { + "type": "string", + "description": "Owner", + "name": "owner", + "in": "query" + }, + { + "type": "integer", + "description": "Page", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Per page", + "name": "per_page", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/middleware.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "type": "object", + "additionalProperties": { + "allOf": [ + {}, + { + "type": "object", + "properties": { + "configs": { + "type": "array", + "items": { + "$ref": "#/definitions/model.ModelConfig" + } + }, + "total": { + "type": "integer" + } + } + } + ] + } + } + } + } + ] + } + } + } + } + }, + "/api/models/builtin": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Returns a list of builtin models", + "produces": [ + "application/json" + ], + "tags": [ + "model" + ], + "summary": "Get builtin models", + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/middleware.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/controller.BuiltinModelConfig" + } + } + } + } + ] + } + } + } + } + }, + "/api/models/builtin/channel": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Returns a list of channel builtin models", + "produces": [ + "application/json" + ], + "tags": [ + "model" + ], + "summary": "Get channel builtin models", + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/middleware.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "$ref": "#/definitions/controller.BuiltinModelConfig" + } + } + } + } + } + ] + } + } + } + } + }, + "/api/models/builtin/channel/{type}": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Returns a list of channel builtin models by type", + "produces": [ + "application/json" + ], + "tags": [ + "model" + ], + "summary": "Get channel builtin models by type", + "parameters": [ + { + "enum": [ + 1, + 3, + 4, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 23, + 24, + 25, + 26, + 27, + 28, + 29, + 30, + 31, + 32, + 33, + 34, + 35, + 36, + 37, + 40, + 41, + 42, + 43, + 44, + 45, + 46, + 47, + 48, + 49, + 50, + 51, + 52, + 53, + 54, + 55 + ], + "type": "integer", + "description": "Channel type", + "name": "type", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/middleware.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/controller.BuiltinModelConfig" + } + } + } + } + ] + } + } + } + } + }, + "/api/models/default": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Returns a list of channel default models and mapping", + "produces": [ + "application/json" + ], + "tags": [ + "model" + ], + "summary": "Get channel default models and mapping", + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/middleware.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "type": "object", + "additionalProperties": { + "allOf": [ + {}, + { + "type": "object", + "properties": { + "mapping": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "models": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + ] + } + } + } + } + ] + } + } + } + } + }, + "/api/models/default/{type}": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Returns a list of channel default models and mapping by type", + "produces": [ + "application/json" + ], + "tags": [ + "model" + ], + "summary": "Get channel default models and mapping by type", + "parameters": [ + { + "type": "string", + "description": "Channel type", + "name": "type", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/middleware.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "type": "object", + "additionalProperties": { + "allOf": [ + {}, + { + "type": "object", + "properties": { + "mapping": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "models": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + ] + } + } + } + } + ] + } + } + } + } + }, + "/api/models/enabled": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Returns a list of enabled models", + "produces": [ + "application/json" + ], + "tags": [ + "model" + ], + "summary": "Get enabled models", + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/middleware.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "$ref": "#/definitions/model.ModelConfig" + } + } + } + } + } + ] + } + } + } + } + }, + "/api/models/enabled/{set}": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Returns a list of enabled models by set", + "produces": [ + "application/json" + ], + "tags": [ + "model" + ], + "summary": "Get enabled models by set", + "parameters": [ + { + "type": "string", + "description": "Models set", + "name": "set", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/middleware.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/model.ModelConfig" + } + } + } + } + ] + } + } + } + } + }, + "/api/models/sets": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Returns a list of enabled models and channels sets", + "produces": [ + "application/json" + ], + "tags": [ + "model" + ], + "summary": "Get enabled models and channels sets", + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/middleware.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "type": "object", + "additionalProperties": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "$ref": "#/definitions/controller.EnabledModelChannel" + } + } + } + } + } + } + ] + } + } + } + } + }, + "/api/monitor/": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Returns a list of all channel model error rates", + "produces": [ + "application/json" + ], + "tags": [ + "monitor" + ], + "summary": "Get all channel model error rates", + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/middleware.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "type": "object", + "additionalProperties": { + "type": "object", + "additionalProperties": { + "type": "number", + "format": "float64" + } + } + } + } + } + ] + } + } + } + }, + "delete": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Clears all model errors", + "produces": [ + "application/json" + ], + "tags": [ + "monitor" + ], + "summary": "Clear all model errors", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/middleware.APIResponse" + } + } + } + } + }, + "/api/monitor/banned_channels": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Returns a list of all banned model channels", + "produces": [ + "application/json" + ], + "tags": [ + "monitor" + ], + "summary": "Get all banned model channels", + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/middleware.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "type": "integer", + "format": "int64" + } + } + } + } + } + ] + } + } + } + } + }, + "/api/monitor/batch_group_token_metrics": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Returns runtime rpm/tpm summary metrics for explicit group and token-name pairs", + "produces": [ + "application/json" + ], + "tags": [ + "monitor" + ], + "summary": "Batch get runtime metrics for api key rows", + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/middleware.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/controller.BatchGroupTokenMetricsResponse" + } + } + } + ] + } + } + } + } + }, + "/api/monitor/group_model_metrics/{group}": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Returns runtime rpm/tpm metrics grouped by model in a group", + "produces": [ + "application/json" + ], + "tags": [ + "monitor" + ], + "summary": "Get model runtime metrics for a group", + "parameters": [ + { + "type": "string", + "description": "Group ID", + "name": "group", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/middleware.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/controller.GroupModelMetricsResponse" + } + } + } + ] + } + } + } + } + }, + "/api/monitor/group_summary_metrics": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Returns rpm/tpm summary metrics for one or more groups", + "produces": [ + "application/json" + ], + "tags": [ + "monitor" + ], + "summary": "Get summary metrics for multiple groups", + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/middleware.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/controller.GroupSummaryMetricsResponse" + } + } + } + ] + } + } + } + } + }, + "/api/monitor/group_token_metrics/{group}": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Returns runtime rpm/tpm metrics grouped by token name in a group", + "produces": [ + "application/json" + ], + "tags": [ + "monitor" + ], + "summary": "Get token runtime metrics for a group", + "parameters": [ + { + "type": "string", + "description": "Group ID", + "name": "group", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/middleware.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/controller.GroupTokenMetricsResponse" + } + } + } + ] + } + } + } + } + }, + "/api/monitor/group_tokenname_model_metrics/{group}": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Returns runtime rpm/tpm metrics for token name and model combinations in a group", + "produces": [ + "application/json" + ], + "tags": [ + "monitor" + ], + "summary": "Get token-model runtime metrics for a group", + "parameters": [ + { + "type": "string", + "description": "Group ID", + "name": "group", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/middleware.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/controller.GroupTokennameModelMetricsResponse" + } + } + } + ] + } + } + } + } + }, + "/api/monitor/models": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Returns a list of models error rate", + "produces": [ + "application/json" + ], + "tags": [ + "monitor" + ], + "summary": "Get models error rate", + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/middleware.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "type": "object", + "additionalProperties": { + "type": "number", + "format": "float64" + } + } + } + } + ] + } + } + } + } + }, + "/api/monitor/runtime_metrics": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Returns runtime rpm/tpm/error metrics sourced from reqlimit and monitor", + "produces": [ + "application/json" + ], + "tags": [ + "monitor" + ], + "summary": "Get runtime metrics for models and channels", + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/middleware.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/controller.RuntimeMetricsResponse" + } + } + } + ] + } + } + } + } + }, + "/api/monitor/{id}": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Returns a list of channel model error rates", + "produces": [ + "application/json" + ], + "tags": [ + "monitor" + ], + "summary": "Get channel model error rates", + "parameters": [ + { + "type": "integer", + "description": "Channel ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/middleware.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": { + "type": "number", + "format": "float64" + } + } + } + } + } + ] + } + } + } + }, + "delete": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Clears all model errors for a specific channel", + "produces": [ + "application/json" + ], + "tags": [ + "monitor" + ], + "summary": "Clear channel all model errors", + "parameters": [ + { + "type": "integer", + "description": "Channel ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/middleware.APIResponse" + } + } + } + } + }, + "/api/monitor/{id}/{model}": { + "delete": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Clears model errors for a specific channel and model", + "produces": [ + "application/json" + ], + "tags": [ + "monitor" + ], + "summary": "Clear channel model errors", + "parameters": [ + { + "type": "integer", + "description": "Channel ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Model name", + "name": "model", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/middleware.APIResponse" + } + } + } + } + }, + "/api/option/": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Returns a list of options", + "produces": [ + "application/json" + ], + "tags": [ + "option" + ], + "summary": "Get options", + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/middleware.APIResponse" + }, { "type": "object", "properties": { "data": { "type": "object", "additionalProperties": { - "type": "number", - "format": "float64" + "type": "string" } } } @@ -6312,23 +10856,139 @@ const docTemplate = `{ } } } + }, + "put": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Updates a single option", + "produces": [ + "application/json" + ], + "tags": [ + "option" + ], + "summary": "Update option", + "parameters": [ + { + "description": "Option value", + "name": "value", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/model.Option" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/middleware.APIResponse" + } + } + } + }, + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Updates a single option", + "produces": [ + "application/json" + ], + "tags": [ + "option" + ], + "summary": "Update option", + "parameters": [ + { + "description": "Option value", + "name": "value", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/model.Option" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/middleware.APIResponse" + } + } + } } }, - "/api/monitor/runtime_metrics": { + "/api/option/batch": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Updates multiple options", + "produces": [ + "application/json" + ], + "tags": [ + "option" + ], + "summary": "Update options", + "parameters": [ + { + "description": "Options", + "name": "options", + "in": "body", + "required": true, + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/middleware.APIResponse" + } + } + } + } + }, + "/api/option/{key}": { "get": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Returns runtime rpm/tpm/error metrics sourced from reqlimit and monitor", + "description": "Returns a single option", "produces": [ "application/json" ], "tags": [ - "monitor" + "option" + ], + "summary": "Get option", + "parameters": [ + { + "type": "string", + "description": "Option key", + "name": "key", + "in": "path", + "required": true + } ], - "summary": "Get runtime metrics for models and channels", "responses": { "200": { "description": "OK", @@ -6341,7 +11001,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "$ref": "#/definitions/controller.RuntimeMetricsResponse" + "$ref": "#/definitions/model.Option" } } } @@ -6349,32 +11009,59 @@ const docTemplate = `{ } } } - } - }, - "/api/monitor/{id}": { - "get": { + }, + "put": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Returns a list of channel model error rates", + "description": "Updates a single option by key", "produces": [ "application/json" ], "tags": [ - "monitor" + "option" ], - "summary": "Get channel model error rates", + "summary": "Update option by key", "parameters": [ { - "type": "integer", - "description": "Channel ID", - "name": "id", + "type": "string", + "description": "Option key", + "name": "key", "in": "path", "required": true + }, + { + "description": "Option value", + "name": "value", + "in": "body", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/middleware.APIResponse" + } } + } + } + }, + "/api/status": { + "get": { + "description": "Returns the status of the server", + "produces": [ + "application/json" + ], + "tags": [ + "misc" ], + "summary": "Get status", "responses": { "200": { "description": "OK", @@ -6387,14 +11074,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "type": "array", - "items": { - "type": "object", - "additionalProperties": { - "type": "number", - "format": "float64" - } - } + "$ref": "#/definitions/controller.StatusData" } } } @@ -6402,250 +11082,354 @@ const docTemplate = `{ } } } + } + }, + "/api/test-embedmcp/{id}": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Test Embed MCP Streamable Server with various HTTP methods", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "embedmcp" + ], + "summary": "Test Embed MCP Streamable Server", + "parameters": [ + { + "type": "string", + "description": "MCP ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Initial configuration parameters (e.g. config[host]=http://localhost:3000)", + "name": "config[key]", + "in": "query" + }, + { + "type": "string", + "description": "Reusing configuration parameters (e.g., reusing[authorization]=apikey)", + "name": "reusing[key]", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad Request" + } + } }, - "delete": { + "post": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Clears all model errors for a specific channel", + "description": "Test Embed MCP Streamable Server with various HTTP methods", + "consumes": [ + "application/json" + ], "produces": [ "application/json" ], "tags": [ - "monitor" + "embedmcp" ], - "summary": "Clear channel all model errors", + "summary": "Test Embed MCP Streamable Server", "parameters": [ { - "type": "integer", - "description": "Channel ID", + "type": "string", + "description": "MCP ID", "name": "id", "in": "path", "required": true + }, + { + "type": "string", + "description": "Initial configuration parameters (e.g. config[host]=http://localhost:3000)", + "name": "config[key]", + "in": "query" + }, + { + "type": "string", + "description": "Reusing configuration parameters (e.g., reusing[authorization]=apikey)", + "name": "reusing[key]", + "in": "query" } ], "responses": { "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/middleware.APIResponse" - } + "description": "OK" + }, + "400": { + "description": "Bad Request" } } - } - }, - "/api/monitor/{id}/{model}": { + }, "delete": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Clears model errors for a specific channel and model", + "description": "Test Embed MCP Streamable Server with various HTTP methods", + "consumes": [ + "application/json" + ], "produces": [ "application/json" ], "tags": [ - "monitor" + "embedmcp" ], - "summary": "Clear channel model errors", + "summary": "Test Embed MCP Streamable Server", "parameters": [ { - "type": "integer", - "description": "Channel ID", + "type": "string", + "description": "MCP ID", "name": "id", "in": "path", "required": true }, { "type": "string", - "description": "Model name", - "name": "model", - "in": "path", - "required": true + "description": "Initial configuration parameters (e.g. config[host]=http://localhost:3000)", + "name": "config[key]", + "in": "query" + }, + { + "type": "string", + "description": "Reusing configuration parameters (e.g., reusing[authorization]=apikey)", + "name": "reusing[key]", + "in": "query" } ], "responses": { "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/middleware.APIResponse" - } + "description": "OK" + }, + "400": { + "description": "Bad Request" } } } }, - "/api/option/": { + "/api/test-embedmcp/{id}/sse": { "get": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Returns a list of options", - "produces": [ - "application/json" - ], - "tags": [ - "option" - ], - "summary": "Get options", - "responses": { - "200": { - "description": "OK", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/middleware.APIResponse" - }, - { - "type": "object", - "properties": { - "data": { - "type": "object", - "additionalProperties": { - "type": "string" - } - } - } - } - ] - } - } - } - }, - "put": { - "security": [ - { - "ApiKeyAuth": [] - } - ], - "description": "Updates a single option", - "produces": [ - "application/json" - ], + "description": "Test Embed MCP SSE Server", "tags": [ - "option" + "embedmcp" ], - "summary": "Update option", + "summary": "Test Embed MCP SSE Server", "parameters": [ { - "description": "Option value", - "name": "value", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/model.Option" - } + "type": "string", + "description": "MCP ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Initial configuration parameters (e.g. config[host]=http://localhost:3000)", + "name": "config[key]", + "in": "query" + }, + { + "type": "string", + "description": "Reusing configuration parameters (e.g. reusing[authorization]=apikey)", + "name": "reusing[key]", + "in": "query" } ], "responses": { "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/middleware.APIResponse" - } + "description": "OK" + }, + "400": { + "description": "Bad Request" } } - }, - "post": { + } + }, + "/api/test-publicmcp/{group}/{id}/sse": { + "get": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Updates a single option", - "produces": [ - "application/json" - ], - "tags": [ - "option" - ], - "summary": "Update option", + "summary": "Test Public MCP SSE Server", "parameters": [ { - "description": "Option value", - "name": "value", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/model.Option" - } + "type": "string", + "description": "Group ID", + "name": "group", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "MCP ID", + "name": "id", + "in": "path", + "required": true } ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/middleware.APIResponse" - } - } - } + "responses": {} } }, - "/api/option/batch": { - "post": { + "/api/token/{group}": { + "get": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Updates multiple options", + "description": "Returns a paginated list of all tokens for a specific group", "produces": [ "application/json" ], "tags": [ - "option" + "tokens" ], - "summary": "Update options", + "summary": "Get all tokens for a specific group", "parameters": [ { - "description": "Options", - "name": "options", - "in": "body", - "required": true, - "schema": { - "type": "object", - "additionalProperties": { - "type": "string" - } - } + "type": "string", + "description": "Group name", + "name": "group", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "Page number", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Items per page", + "name": "per_page", + "in": "query" + }, + { + "type": "string", + "description": "Order", + "name": "order", + "in": "query" + }, + { + "type": "integer", + "description": "Status", + "name": "status", + "in": "query" } ], "responses": { "200": { "description": "OK", "schema": { - "$ref": "#/definitions/middleware.APIResponse" + "allOf": [ + { + "$ref": "#/definitions/middleware.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "type": "object", + "additionalProperties": { + "allOf": [ + {}, + { + "type": "object", + "properties": { + "tokens": { + "type": "array", + "items": { + "$ref": "#/definitions/controller.TokenResponse" + } + }, + "total": { + "type": "integer" + } + } + } + ] + } + } + } + } + ] } } } - } - }, - "/api/option/{key}": { - "get": { + }, + "post": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Returns a single option", + "description": "Adds a new token to a specific group", + "consumes": [ + "application/json" + ], "produces": [ "application/json" ], "tags": [ - "option" + "token" ], - "summary": "Get option", + "summary": "Add group token", "parameters": [ { "type": "string", - "description": "Option key", - "name": "key", + "description": "Group name", + "name": "group", "in": "path", "required": true + }, + { + "type": "boolean", + "description": "Auto create group", + "name": "auto_create_group", + "in": "query" + }, + { + "type": "boolean", + "description": "Ignore exist", + "name": "ignore_exist", + "in": "query" + }, + { + "description": "Token information", + "name": "token", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/controller.AddTokenRequest" + } } ], "responses": { @@ -6660,7 +11444,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "$ref": "#/definitions/model.Option" + "$ref": "#/definitions/controller.TokenResponse" } } } @@ -6668,36 +11452,41 @@ const docTemplate = `{ } } } - }, - "put": { + } + }, + "/api/token/{group}/batch_delete": { + "post": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Updates a single option by key", + "description": "Deletes multiple tokens from a specific group", "produces": [ "application/json" ], "tags": [ - "option" + "token" ], - "summary": "Update option by key", + "summary": "Delete group tokens", "parameters": [ { "type": "string", - "description": "Option key", - "name": "key", + "description": "Group name", + "name": "group", "in": "path", "required": true }, { - "description": "Option value", - "name": "value", + "description": "Token IDs", + "name": "ids", "in": "body", "required": true, "schema": { - "type": "string" + "type": "array", + "items": { + "type": "integer" + } } } ], @@ -6711,16 +11500,72 @@ const docTemplate = `{ } } }, - "/api/status": { + "/api/token/{group}/search": { "get": { - "description": "Returns the status of the server", + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Returns a paginated list of tokens for a specific group based on search criteria", "produces": [ "application/json" ], "tags": [ - "misc" + "token" + ], + "summary": "Search tokens for a specific group", + "parameters": [ + { + "type": "string", + "description": "Group name", + "name": "group", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Keyword", + "name": "keyword", + "in": "query" + }, + { + "type": "integer", + "description": "Page number", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Items per page", + "name": "per_page", + "in": "query" + }, + { + "type": "string", + "description": "Order", + "name": "order", + "in": "query" + }, + { + "type": "string", + "description": "Name", + "name": "name", + "in": "query" + }, + { + "type": "string", + "description": "Key", + "name": "key", + "in": "query" + }, + { + "type": "integer", + "description": "Status", + "name": "status", + "in": "query" + } ], - "summary": "Get status", "responses": { "200": { "description": "OK", @@ -6733,7 +11578,26 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "$ref": "#/definitions/controller.StatusData" + "type": "object", + "additionalProperties": { + "allOf": [ + {}, + { + "type": "object", + "properties": { + "tokens": { + "type": "array", + "items": { + "$ref": "#/definitions/controller.TokenResponse" + } + }, + "total": { + "type": "integer" + } + } + } + ] + } } } } @@ -6743,61 +11607,65 @@ const docTemplate = `{ } } }, - "/api/test-embedmcp/{id}": { + "/api/token/{group}/{id}": { "get": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Test Embed MCP Streamable Server with various HTTP methods", - "consumes": [ - "application/json" - ], + "description": "Returns detailed information about a specific token for a specific group", "produces": [ "application/json" ], "tags": [ - "embedmcp" + "token" ], - "summary": "Test Embed MCP Streamable Server", + "summary": "Get token by ID for a specific group", "parameters": [ { "type": "string", - "description": "MCP ID", - "name": "id", + "description": "Group name", + "name": "group", "in": "path", "required": true }, { - "type": "string", - "description": "Initial configuration parameters (e.g. config[host]=http://localhost:3000)", - "name": "config[key]", - "in": "query" - }, - { - "type": "string", - "description": "Reusing configuration parameters (e.g., reusing[authorization]=apikey)", - "name": "reusing[key]", - "in": "query" + "type": "integer", + "description": "Token ID", + "name": "id", + "in": "path", + "required": true } ], "responses": { "200": { - "description": "OK" - }, - "400": { - "description": "Bad Request" + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/middleware.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/controller.TokenResponse" + } + } + } + ] + } } } }, - "post": { + "put": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Test Embed MCP Streamable Server with various HTTP methods", + "description": "Updates an existing token in a specific group", "consumes": [ "application/json" ], @@ -6805,36 +11673,52 @@ const docTemplate = `{ "application/json" ], "tags": [ - "embedmcp" + "token" ], - "summary": "Test Embed MCP Streamable Server", + "summary": "Update group token", "parameters": [ { "type": "string", - "description": "MCP ID", - "name": "id", + "description": "Group name", + "name": "group", "in": "path", "required": true }, { - "type": "string", - "description": "Initial configuration parameters (e.g. config[host]=http://localhost:3000)", - "name": "config[key]", - "in": "query" + "type": "integer", + "description": "Token ID", + "name": "id", + "in": "path", + "required": true }, - { - "type": "string", - "description": "Reusing configuration parameters (e.g., reusing[authorization]=apikey)", - "name": "reusing[key]", - "in": "query" + { + "description": "Updated token information", + "name": "token", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/model.UpdateTokenRequest" + } } ], "responses": { "200": { - "description": "OK" - }, - "400": { - "description": "Bad Request" + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/middleware.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/controller.TokenResponse" + } + } + } + ] + } } } }, @@ -6844,164 +11728,191 @@ const docTemplate = `{ "ApiKeyAuth": [] } ], - "description": "Test Embed MCP Streamable Server with various HTTP methods", - "consumes": [ - "application/json" - ], + "description": "Deletes a specific token from a group", "produces": [ "application/json" ], "tags": [ - "embedmcp" + "token" ], - "summary": "Test Embed MCP Streamable Server", + "summary": "Delete group token", "parameters": [ { "type": "string", - "description": "MCP ID", - "name": "id", + "description": "Group name", + "name": "group", "in": "path", "required": true }, { - "type": "string", - "description": "Initial configuration parameters (e.g. config[host]=http://localhost:3000)", - "name": "config[key]", - "in": "query" - }, - { - "type": "string", - "description": "Reusing configuration parameters (e.g., reusing[authorization]=apikey)", - "name": "reusing[key]", - "in": "query" + "type": "integer", + "description": "Token ID", + "name": "id", + "in": "path", + "required": true } ], "responses": { "200": { - "description": "OK" - }, - "400": { - "description": "Bad Request" + "description": "OK", + "schema": { + "$ref": "#/definitions/middleware.APIResponse" + } } } } }, - "/api/test-embedmcp/{id}/sse": { - "get": { + "/api/token/{group}/{id}/name": { + "post": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Test Embed MCP SSE Server", + "description": "Updates the name of a token in a specific group", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], "tags": [ - "embedmcp" + "token" ], - "summary": "Test Embed MCP SSE Server", + "summary": "Update group token name", "parameters": [ { "type": "string", - "description": "MCP ID", - "name": "id", + "description": "Group name", + "name": "group", "in": "path", "required": true }, { - "type": "string", - "description": "Initial configuration parameters (e.g. config[host]=http://localhost:3000)", - "name": "config[key]", - "in": "query" + "type": "integer", + "description": "Token ID", + "name": "id", + "in": "path", + "required": true }, { - "type": "string", - "description": "Reusing configuration parameters (e.g. reusing[authorization]=apikey)", - "name": "reusing[key]", - "in": "query" + "description": "Name information", + "name": "name", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/controller.UpdateTokenNameRequest" + } } ], "responses": { "200": { - "description": "OK" - }, - "400": { - "description": "Bad Request" + "description": "OK", + "schema": { + "$ref": "#/definitions/middleware.APIResponse" + } } } } }, - "/api/test-publicmcp/{group}/{id}/sse": { - "get": { + "/api/token/{group}/{id}/status": { + "post": { "security": [ { "ApiKeyAuth": [] } ], - "summary": "Test Public MCP SSE Server", + "description": "Updates the status of a token in a specific group", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "token" + ], + "summary": "Update group token status", "parameters": [ { "type": "string", - "description": "Group ID", + "description": "Group name", "name": "group", "in": "path", "required": true }, { - "type": "string", - "description": "MCP ID", + "type": "integer", + "description": "Token ID", "name": "id", "in": "path", "required": true + }, + { + "description": "Status information", + "name": "status", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/controller.UpdateTokenStatusRequest" + } } ], - "responses": {} + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/middleware.APIResponse" + } + } + } } }, - "/api/token/{group}": { - "post": { + "/api/tokens/": { + "get": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Adds a new token to a specific group", - "consumes": [ - "application/json" - ], + "description": "Returns a paginated list of all tokens", "produces": [ "application/json" ], "tags": [ - "token" + "tokens" ], - "summary": "Add group token", + "summary": "Get all tokens", "parameters": [ + { + "type": "integer", + "description": "Page number", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Items per page", + "name": "per_page", + "in": "query" + }, { "type": "string", "description": "Group name", "name": "group", - "in": "path", - "required": true - }, - { - "type": "boolean", - "description": "Auto create group", - "name": "auto_create_group", "in": "query" }, { - "type": "boolean", - "description": "Ignore exist", - "name": "ignore_exist", + "type": "string", + "description": "Order", + "name": "order", "in": "query" }, { - "description": "Token information", - "name": "token", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/controller.AddTokenRequest" - } + "type": "integer", + "description": "Status", + "name": "status", + "in": "query" } ], "responses": { @@ -7016,7 +11927,26 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "$ref": "#/definitions/controller.TokenResponse" + "type": "object", + "additionalProperties": { + "allOf": [ + {}, + { + "type": "object", + "properties": { + "tokens": { + "type": "array", + "items": { + "$ref": "#/definitions/controller.TokenResponse" + } + }, + "total": { + "type": "integer" + } + } + } + ] + } } } } @@ -7026,29 +11956,25 @@ const docTemplate = `{ } } }, - "/api/token/{group}/batch_delete": { + "/api/tokens/batch_delete": { "post": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Deletes multiple tokens from a specific group", + "description": "Deletes multiple tokens by their IDs", + "consumes": [ + "application/json" + ], "produces": [ "application/json" ], "tags": [ - "token" + "tokens" ], - "summary": "Delete group tokens", + "summary": "Delete multiple tokens", "parameters": [ - { - "type": "string", - "description": "Group name", - "name": "group", - "in": "path", - "required": true - }, { "description": "Token IDs", "name": "ids", @@ -7072,29 +11998,22 @@ const docTemplate = `{ } } }, - "/api/token/{group}/search": { + "/api/tokens/search": { "get": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Returns a paginated list of tokens for a specific group based on search criteria", + "description": "Returns a paginated list of tokens based on search criteria", "produces": [ "application/json" ], - "tags": [ - "token" - ], - "summary": "Search tokens for a specific group", - "parameters": [ - { - "type": "string", - "description": "Group name", - "name": "group", - "in": "path", - "required": true - }, + "tags": [ + "tokens" + ], + "summary": "Search tokens", + "parameters": [ { "type": "string", "description": "Keyword", @@ -7136,6 +12055,12 @@ const docTemplate = `{ "description": "Status", "name": "status", "in": "query" + }, + { + "type": "string", + "description": "Group", + "name": "group", + "in": "query" } ], "responses": { @@ -7179,29 +12104,22 @@ const docTemplate = `{ } } }, - "/api/token/{group}/{id}": { + "/api/tokens/{id}": { "get": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Returns detailed information about a specific token for a specific group", + "description": "Returns detailed information about a specific token", "produces": [ "application/json" ], "tags": [ - "token" + "tokens" ], - "summary": "Get token by ID for a specific group", + "summary": "Get token by ID", "parameters": [ - { - "type": "string", - "description": "Group name", - "name": "group", - "in": "path", - "required": true - }, { "type": "integer", "description": "Token ID", @@ -7237,7 +12155,7 @@ const docTemplate = `{ "ApiKeyAuth": [] } ], - "description": "Updates an existing token in a specific group", + "description": "Updates an existing token's information", "consumes": [ "application/json" ], @@ -7245,17 +12163,10 @@ const docTemplate = `{ "application/json" ], "tags": [ - "token" + "tokens" ], - "summary": "Update group token", + "summary": "Update token", "parameters": [ - { - "type": "string", - "description": "Group name", - "name": "group", - "in": "path", - "required": true - }, { "type": "integer", "description": "Token ID", @@ -7268,223 +12179,9 @@ const docTemplate = `{ "name": "token", "in": "body", "required": true, - "schema": { - "$ref": "#/definitions/model.UpdateTokenRequest" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/middleware.APIResponse" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/controller.TokenResponse" - } - } - } - ] - } - } - } - }, - "delete": { - "security": [ - { - "ApiKeyAuth": [] - } - ], - "description": "Deletes a specific token from a group", - "produces": [ - "application/json" - ], - "tags": [ - "token" - ], - "summary": "Delete group token", - "parameters": [ - { - "type": "string", - "description": "Group name", - "name": "group", - "in": "path", - "required": true - }, - { - "type": "integer", - "description": "Token ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/middleware.APIResponse" - } - } - } - } - }, - "/api/token/{group}/{id}/name": { - "post": { - "security": [ - { - "ApiKeyAuth": [] - } - ], - "description": "Updates the name of a token in a specific group", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "token" - ], - "summary": "Update group token name", - "parameters": [ - { - "type": "string", - "description": "Group name", - "name": "group", - "in": "path", - "required": true - }, - { - "type": "integer", - "description": "Token ID", - "name": "id", - "in": "path", - "required": true - }, - { - "description": "Name information", - "name": "name", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/controller.UpdateTokenNameRequest" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/middleware.APIResponse" - } - } - } - } - }, - "/api/token/{group}/{id}/status": { - "post": { - "security": [ - { - "ApiKeyAuth": [] - } - ], - "description": "Updates the status of a token in a specific group", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "token" - ], - "summary": "Update group token status", - "parameters": [ - { - "type": "string", - "description": "Group name", - "name": "group", - "in": "path", - "required": true - }, - { - "type": "integer", - "description": "Token ID", - "name": "id", - "in": "path", - "required": true - }, - { - "description": "Status information", - "name": "status", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/controller.UpdateTokenStatusRequest" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/middleware.APIResponse" - } - } - } - } - }, - "/api/tokens/": { - "get": { - "security": [ - { - "ApiKeyAuth": [] - } - ], - "description": "Returns a paginated list of all tokens", - "produces": [ - "application/json" - ], - "tags": [ - "tokens" - ], - "summary": "Get all tokens", - "parameters": [ - { - "type": "integer", - "description": "Page number", - "name": "page", - "in": "query" - }, - { - "type": "integer", - "description": "Items per page", - "name": "per_page", - "in": "query" - }, - { - "type": "string", - "description": "Group name", - "name": "group", - "in": "query" - }, - { - "type": "string", - "description": "Order", - "name": "order", - "in": "query" - }, - { - "type": "integer", - "description": "Status", - "name": "status", - "in": "query" + "schema": { + "$ref": "#/definitions/model.UpdateTokenRequest" + } } ], "responses": { @@ -7499,26 +12196,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "type": "object", - "additionalProperties": { - "allOf": [ - {}, - { - "type": "object", - "properties": { - "tokens": { - "type": "array", - "items": { - "$ref": "#/definitions/controller.TokenResponse" - } - }, - "total": { - "type": "integer" - } - } - } - ] - } + "$ref": "#/definitions/controller.TokenResponse" } } } @@ -7526,16 +12204,48 @@ const docTemplate = `{ } } } + }, + "delete": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Deletes a specific token by ID", + "produces": [ + "application/json" + ], + "tags": [ + "tokens" + ], + "summary": "Delete token", + "parameters": [ + { + "type": "integer", + "description": "Token ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/middleware.APIResponse" + } + } + } } }, - "/api/tokens/batch_delete": { + "/api/tokens/{id}/name": { "post": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Deletes multiple tokens by their IDs", + "description": "Updates the name of a specific token", "consumes": [ "application/json" ], @@ -7545,18 +12255,22 @@ const docTemplate = `{ "tags": [ "tokens" ], - "summary": "Delete multiple tokens", + "summary": "Update token name", "parameters": [ { - "description": "Token IDs", - "name": "ids", + "type": "integer", + "description": "Token ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Name information", + "name": "name", "in": "body", "required": true, "schema": { - "type": "array", - "items": { - "type": "integer" - } + "$ref": "#/definitions/controller.UpdateTokenNameRequest" } } ], @@ -7570,253 +12284,229 @@ const docTemplate = `{ } } }, - "/api/tokens/search": { - "get": { + "/api/tokens/{id}/status": { + "post": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Returns a paginated list of tokens based on search criteria", + "description": "Updates the status of a specific token", + "consumes": [ + "application/json" + ], "produces": [ "application/json" ], "tags": [ "tokens" ], - "summary": "Search tokens", + "summary": "Update token status", "parameters": [ - { - "type": "string", - "description": "Keyword", - "name": "keyword", - "in": "query" - }, - { - "type": "integer", - "description": "Page number", - "name": "page", - "in": "query" - }, { "type": "integer", - "description": "Items per page", - "name": "per_page", - "in": "query" - }, - { - "type": "string", - "description": "Order", - "name": "order", - "in": "query" - }, - { - "type": "string", - "description": "Name", - "name": "name", - "in": "query" - }, - { - "type": "string", - "description": "Key", - "name": "key", - "in": "query" + "description": "Token ID", + "name": "id", + "in": "path", + "required": true }, { - "type": "integer", - "description": "Status", + "description": "Status information", "name": "status", - "in": "query" - }, - { - "type": "string", - "description": "Group", - "name": "group", - "in": "query" + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/controller.UpdateTokenStatusRequest" + } } ], "responses": { "200": { "description": "OK", "schema": { - "allOf": [ - { - "$ref": "#/definitions/middleware.APIResponse" - }, - { - "type": "object", - "properties": { - "data": { - "type": "object", - "additionalProperties": { - "allOf": [ - {}, - { - "type": "object", - "properties": { - "tokens": { - "type": "array", - "items": { - "$ref": "#/definitions/controller.TokenResponse" - } - }, - "total": { - "type": "integer" - } - } - } - ] - } - } - } - } - ] + "$ref": "#/definitions/middleware.APIResponse" } } } } }, - "/api/tokens/{group}": { - "get": { + "/api/v1/services/aigc/video-generation/video-synthesis": { + "post": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Returns a paginated list of all tokens for a specific group", + "description": "Create an Ali DashScope native video generation task.", + "consumes": [ + "application/json" + ], "produces": [ "application/json" ], "tags": [ - "tokens" + "relay" ], - "summary": "Get all tokens for a specific group", + "summary": "Create Ali native video task", "parameters": [ { - "type": "string", - "description": "Group name", - "name": "group", - "in": "path", - "required": true - }, - { - "type": "integer", - "description": "Page number", - "name": "page", - "in": "query" + "description": "Ali DashScope video synthesis request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "type": "object" + } }, { - "type": "integer", - "description": "Items per page", - "name": "per_page", - "in": "query" + "type": "string", + "description": "Optional Aiproxy-Channel header", + "name": "Aiproxy-Channel", + "in": "header" }, { "type": "string", - "description": "Order", - "name": "order", - "in": "query" + "description": "Optional group ID for internal token requests", + "name": "X-Aiproxy-Group", + "in": "header" }, { - "type": "integer", - "description": "Status", - "name": "status", - "in": "query" + "enum": [ + "global", + "own" + ], + "type": "string", + "description": "Optional group channel mode for internal token requests. Values: global, own", + "name": "X-Aiproxy-Group-Channel-Mode", + "in": "header" } ], "responses": { "200": { "description": "OK", "schema": { - "allOf": [ - { - "$ref": "#/definitions/middleware.APIResponse" - }, - { - "type": "object", - "properties": { - "data": { - "type": "object", - "additionalProperties": { - "allOf": [ - {}, - { - "type": "object", - "properties": { - "tokens": { - "type": "array", - "items": { - "$ref": "#/definitions/controller.TokenResponse" - } - }, - "total": { - "type": "integer" - } - } - } - ] - } - } - } - } - ] + "type": "object" + }, + "headers": { + "X-RateLimit-Limit-Requests": { + "type": "integer", + "description": "X-RateLimit-Limit-Requests" + }, + "X-RateLimit-Limit-Tokens": { + "type": "integer", + "description": "X-RateLimit-Limit-Tokens" + }, + "X-RateLimit-Remaining-Requests": { + "type": "integer", + "description": "X-RateLimit-Remaining-Requests" + }, + "X-RateLimit-Remaining-Tokens": { + "type": "integer", + "description": "X-RateLimit-Remaining-Tokens" + }, + "X-RateLimit-Reset-Requests": { + "type": "string", + "description": "X-RateLimit-Reset-Requests" + }, + "X-RateLimit-Reset-Tokens": { + "type": "string", + "description": "X-RateLimit-Reset-Tokens" + } } } } } }, - "/api/tokens/{id}": { + "/api/v1/tasks/{task_id}": { "get": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Returns detailed information about a specific token", + "description": "Get an Ali DashScope native video generation task.", "produces": [ "application/json" ], "tags": [ - "tokens" + "relay" ], - "summary": "Get token by ID", + "summary": "Get Ali native video task", "parameters": [ { - "type": "integer", - "description": "Token ID", - "name": "id", + "type": "string", + "description": "Task ID", + "name": "task_id", "in": "path", "required": true + }, + { + "type": "string", + "description": "Optional Aiproxy-Channel header", + "name": "Aiproxy-Channel", + "in": "header" + }, + { + "type": "string", + "description": "Optional group ID for internal token requests", + "name": "X-Aiproxy-Group", + "in": "header" + }, + { + "enum": [ + "global", + "own" + ], + "type": "string", + "description": "Optional group channel mode for internal token requests. Values: global, own", + "name": "X-Aiproxy-Group-Channel-Mode", + "in": "header" } ], "responses": { "200": { "description": "OK", "schema": { - "allOf": [ - { - "$ref": "#/definitions/middleware.APIResponse" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/controller.TokenResponse" - } - } - } - ] + "type": "object" + }, + "headers": { + "X-RateLimit-Limit-Requests": { + "type": "integer", + "description": "X-RateLimit-Limit-Requests" + }, + "X-RateLimit-Limit-Tokens": { + "type": "integer", + "description": "X-RateLimit-Limit-Tokens" + }, + "X-RateLimit-Remaining-Requests": { + "type": "integer", + "description": "X-RateLimit-Remaining-Requests" + }, + "X-RateLimit-Remaining-Tokens": { + "type": "integer", + "description": "X-RateLimit-Remaining-Tokens" + }, + "X-RateLimit-Reset-Requests": { + "type": "string", + "description": "X-RateLimit-Reset-Requests" + }, + "X-RateLimit-Reset-Tokens": { + "type": "string", + "description": "X-RateLimit-Reset-Tokens" + } } } } - }, - "put": { + } + }, + "/api/v3/contents/generations/tasks": { + "post": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Updates an existing token's information", + "description": "Create a Doubao native video generation task.", "consumes": [ "application/json" ], @@ -7824,168 +12514,264 @@ const docTemplate = `{ "application/json" ], "tags": [ - "tokens" + "relay" ], - "summary": "Update token", + "summary": "Create Doubao native video task", "parameters": [ { - "type": "integer", - "description": "Token ID", - "name": "id", - "in": "path", - "required": true - }, - { - "description": "Updated token information", - "name": "token", + "description": "Doubao video generation task request", + "name": "request", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/model.UpdateTokenRequest" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/middleware.APIResponse" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/controller.TokenResponse" - } - } - } - ] + "type": "object" } - } - } - }, - "delete": { - "security": [ + }, { - "ApiKeyAuth": [] - } - ], - "description": "Deletes a specific token by ID", - "produces": [ - "application/json" - ], - "tags": [ - "tokens" - ], - "summary": "Delete token", - "parameters": [ + "type": "string", + "description": "Optional Aiproxy-Channel header", + "name": "Aiproxy-Channel", + "in": "header" + }, { - "type": "integer", - "description": "Token ID", - "name": "id", - "in": "path", - "required": true + "type": "string", + "description": "Optional group ID for internal token requests", + "name": "X-Aiproxy-Group", + "in": "header" + }, + { + "enum": [ + "global", + "own" + ], + "type": "string", + "description": "Optional group channel mode for internal token requests. Values: global, own", + "name": "X-Aiproxy-Group-Channel-Mode", + "in": "header" } ], "responses": { "200": { "description": "OK", "schema": { - "$ref": "#/definitions/middleware.APIResponse" + "type": "object" + }, + "headers": { + "X-RateLimit-Limit-Requests": { + "type": "integer", + "description": "X-RateLimit-Limit-Requests" + }, + "X-RateLimit-Limit-Tokens": { + "type": "integer", + "description": "X-RateLimit-Limit-Tokens" + }, + "X-RateLimit-Remaining-Requests": { + "type": "integer", + "description": "X-RateLimit-Remaining-Requests" + }, + "X-RateLimit-Remaining-Tokens": { + "type": "integer", + "description": "X-RateLimit-Remaining-Tokens" + }, + "X-RateLimit-Reset-Requests": { + "type": "string", + "description": "X-RateLimit-Reset-Requests" + }, + "X-RateLimit-Reset-Tokens": { + "type": "string", + "description": "X-RateLimit-Reset-Tokens" + } } } } } }, - "/api/tokens/{id}/name": { - "post": { + "/api/v3/contents/generations/tasks/{task_id}": { + "get": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Updates the name of a specific token", - "consumes": [ - "application/json" - ], + "description": "Get a Doubao native video generation task.", "produces": [ "application/json" ], "tags": [ - "tokens" + "relay" ], - "summary": "Update token name", + "summary": "Get Doubao native video task", "parameters": [ { - "type": "integer", - "description": "Token ID", - "name": "id", + "type": "string", + "description": "Task ID", + "name": "task_id", "in": "path", "required": true }, { - "description": "Name information", - "name": "name", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/controller.UpdateTokenNameRequest" - } + "type": "string", + "description": "Optional Aiproxy-Channel header", + "name": "Aiproxy-Channel", + "in": "header" + }, + { + "type": "string", + "description": "Optional group ID for internal token requests", + "name": "X-Aiproxy-Group", + "in": "header" + }, + { + "enum": [ + "global", + "own" + ], + "type": "string", + "description": "Optional group channel mode for internal token requests. Values: global, own", + "name": "X-Aiproxy-Group-Channel-Mode", + "in": "header" } ], "responses": { "200": { "description": "OK", "schema": { - "$ref": "#/definitions/middleware.APIResponse" + "type": "object" + }, + "headers": { + "X-RateLimit-Limit-Requests": { + "type": "integer", + "description": "X-RateLimit-Limit-Requests" + }, + "X-RateLimit-Limit-Tokens": { + "type": "integer", + "description": "X-RateLimit-Limit-Tokens" + }, + "X-RateLimit-Remaining-Requests": { + "type": "integer", + "description": "X-RateLimit-Remaining-Requests" + }, + "X-RateLimit-Remaining-Tokens": { + "type": "integer", + "description": "X-RateLimit-Remaining-Tokens" + }, + "X-RateLimit-Reset-Requests": { + "type": "string", + "description": "X-RateLimit-Reset-Requests" + }, + "X-RateLimit-Reset-Tokens": { + "type": "string", + "description": "X-RateLimit-Reset-Tokens" + } } } } - } - }, - "/api/tokens/{id}/status": { - "post": { + }, + "delete": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Updates the status of a specific token", - "consumes": [ - "application/json" - ], + "description": "Delete a Doubao native video generation task.", "produces": [ "application/json" ], "tags": [ - "tokens" + "relay" ], - "summary": "Update token status", + "summary": "Delete Doubao native video task", "parameters": [ { - "type": "integer", - "description": "Token ID", - "name": "id", + "type": "string", + "description": "Task ID", + "name": "task_id", "in": "path", "required": true }, { - "description": "Status information", - "name": "status", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/controller.UpdateTokenStatusRequest" - } + "type": "string", + "description": "Optional Aiproxy-Channel header", + "name": "Aiproxy-Channel", + "in": "header" + }, + { + "type": "string", + "description": "Optional group ID for internal token requests", + "name": "X-Aiproxy-Group", + "in": "header" + }, + { + "enum": [ + "global", + "own" + ], + "type": "string", + "description": "Optional group channel mode for internal token requests. Values: global, own", + "name": "X-Aiproxy-Group-Channel-Mode", + "in": "header" } ], "responses": { "200": { "description": "OK", "schema": { - "$ref": "#/definitions/middleware.APIResponse" + "type": "object" + }, + "headers": { + "X-RateLimit-Limit-Requests": { + "type": "integer", + "description": "X-RateLimit-Limit-Requests" + }, + "X-RateLimit-Limit-Tokens": { + "type": "integer", + "description": "X-RateLimit-Limit-Tokens" + }, + "X-RateLimit-Remaining-Requests": { + "type": "integer", + "description": "X-RateLimit-Remaining-Requests" + }, + "X-RateLimit-Remaining-Tokens": { + "type": "integer", + "description": "X-RateLimit-Remaining-Tokens" + }, + "X-RateLimit-Reset-Requests": { + "type": "string", + "description": "X-RateLimit-Reset-Requests" + }, + "X-RateLimit-Reset-Tokens": { + "type": "string", + "description": "X-RateLimit-Reset-Tokens" + } + } + }, + "204": { + "description": "No Content", + "headers": { + "X-RateLimit-Limit-Requests": { + "type": "integer", + "description": "X-RateLimit-Limit-Requests" + }, + "X-RateLimit-Limit-Tokens": { + "type": "integer", + "description": "X-RateLimit-Limit-Tokens" + }, + "X-RateLimit-Remaining-Requests": { + "type": "integer", + "description": "X-RateLimit-Remaining-Requests" + }, + "X-RateLimit-Remaining-Tokens": { + "type": "integer", + "description": "X-RateLimit-Remaining-Tokens" + }, + "X-RateLimit-Reset-Requests": { + "type": "string", + "description": "X-RateLimit-Reset-Requests" + }, + "X-RateLimit-Reset-Tokens": { + "type": "string", + "description": "X-RateLimit-Reset-Tokens" + } } } } @@ -8147,6 +12933,22 @@ const docTemplate = `{ "description": "Optional Aiproxy-Channel header", "name": "Aiproxy-Channel", "in": "header" + }, + { + "type": "string", + "description": "Optional group ID for internal token requests", + "name": "X-Aiproxy-Group", + "in": "header" + }, + { + "enum": [ + "global", + "own" + ], + "type": "string", + "description": "Optional group channel mode for internal token requests. Values: global, own", + "name": "X-Aiproxy-Group-Channel-Mode", + "in": "header" } ], "responses": { @@ -8220,6 +13022,22 @@ const docTemplate = `{ "description": "Optional Aiproxy-Channel header", "name": "Aiproxy-Channel", "in": "header" + }, + { + "type": "string", + "description": "Optional group ID for internal token requests", + "name": "X-Aiproxy-Group", + "in": "header" + }, + { + "enum": [ + "global", + "own" + ], + "type": "string", + "description": "Optional group channel mode for internal token requests. Values: global, own", + "name": "X-Aiproxy-Group-Channel-Mode", + "in": "header" } ], "responses": { @@ -8293,6 +13111,22 @@ const docTemplate = `{ "description": "Optional Aiproxy-Channel header", "name": "Aiproxy-Channel", "in": "header" + }, + { + "type": "string", + "description": "Optional group ID for internal token requests", + "name": "X-Aiproxy-Group", + "in": "header" + }, + { + "enum": [ + "global", + "own" + ], + "type": "string", + "description": "Optional group channel mode for internal token requests. Values: global, own", + "name": "X-Aiproxy-Group-Channel-Mode", + "in": "header" } ], "responses": { @@ -8361,6 +13195,22 @@ const docTemplate = `{ "description": "Optional Aiproxy-Channel header", "name": "Aiproxy-Channel", "in": "header" + }, + { + "type": "string", + "description": "Optional group ID for internal token requests", + "name": "X-Aiproxy-Group", + "in": "header" + }, + { + "enum": [ + "global", + "own" + ], + "type": "string", + "description": "Optional group channel mode for internal token requests. Values: global, own", + "name": "X-Aiproxy-Group-Channel-Mode", + "in": "header" } ], "responses": { @@ -8429,6 +13279,22 @@ const docTemplate = `{ "description": "Optional Aiproxy-Channel header", "name": "Aiproxy-Channel", "in": "header" + }, + { + "type": "string", + "description": "Optional group ID for internal token requests", + "name": "X-Aiproxy-Group", + "in": "header" + }, + { + "enum": [ + "global", + "own" + ], + "type": "string", + "description": "Optional group channel mode for internal token requests. Values: global, own", + "name": "X-Aiproxy-Group-Channel-Mode", + "in": "header" } ], "responses": { @@ -8572,6 +13438,22 @@ const docTemplate = `{ "description": "Optional Aiproxy-Channel header", "name": "Aiproxy-Channel", "in": "header" + }, + { + "type": "string", + "description": "Optional group ID for internal token requests", + "name": "X-Aiproxy-Group", + "in": "header" + }, + { + "enum": [ + "global", + "own" + ], + "type": "string", + "description": "Optional group channel mode for internal token requests. Values: global, own", + "name": "X-Aiproxy-Group-Channel-Mode", + "in": "header" } ], "responses": { @@ -8652,6 +13534,22 @@ const docTemplate = `{ "description": "Optional Aiproxy-Channel header", "name": "Aiproxy-Channel", "in": "header" + }, + { + "type": "string", + "description": "Optional group ID for internal token requests", + "name": "X-Aiproxy-Group", + "in": "header" + }, + { + "enum": [ + "global", + "own" + ], + "type": "string", + "description": "Optional group channel mode for internal token requests. Values: global, own", + "name": "X-Aiproxy-Group-Channel-Mode", + "in": "header" } ], "responses": { @@ -8720,6 +13618,22 @@ const docTemplate = `{ "description": "Optional Aiproxy-Channel header", "name": "Aiproxy-Channel", "in": "header" + }, + { + "type": "string", + "description": "Optional group ID for internal token requests", + "name": "X-Aiproxy-Group", + "in": "header" + }, + { + "enum": [ + "global", + "own" + ], + "type": "string", + "description": "Optional group channel mode for internal token requests. Values: global, own", + "name": "X-Aiproxy-Group-Channel-Mode", + "in": "header" } ], "responses": { @@ -8788,6 +13702,22 @@ const docTemplate = `{ "description": "Optional Aiproxy-Channel header", "name": "Aiproxy-Channel", "in": "header" + }, + { + "type": "string", + "description": "Optional group ID for internal token requests", + "name": "X-Aiproxy-Group", + "in": "header" + }, + { + "enum": [ + "global", + "own" + ], + "type": "string", + "description": "Optional group channel mode for internal token requests. Values: global, own", + "name": "X-Aiproxy-Group-Channel-Mode", + "in": "header" } ], "responses": { @@ -8922,6 +13852,22 @@ const docTemplate = `{ "description": "Optional Aiproxy-Channel header", "name": "Aiproxy-Channel", "in": "header" + }, + { + "type": "string", + "description": "Optional group ID for internal token requests", + "name": "X-Aiproxy-Group", + "in": "header" + }, + { + "enum": [ + "global", + "own" + ], + "type": "string", + "description": "Optional group channel mode for internal token requests. Values: global, own", + "name": "X-Aiproxy-Group-Channel-Mode", + "in": "header" } ], "responses": { @@ -8990,6 +13936,22 @@ const docTemplate = `{ "description": "Optional Aiproxy-Channel header", "name": "Aiproxy-Channel", "in": "header" + }, + { + "type": "string", + "description": "Optional group ID for internal token requests", + "name": "X-Aiproxy-Group", + "in": "header" + }, + { + "enum": [ + "global", + "own" + ], + "type": "string", + "description": "Optional group channel mode for internal token requests. Values: global, own", + "name": "X-Aiproxy-Group-Channel-Mode", + "in": "header" } ], "responses": { @@ -9058,6 +14020,22 @@ const docTemplate = `{ "description": "Optional Aiproxy-Channel header", "name": "Aiproxy-Channel", "in": "header" + }, + { + "type": "string", + "description": "Optional group ID for internal token requests", + "name": "X-Aiproxy-Group", + "in": "header" + }, + { + "enum": [ + "global", + "own" + ], + "type": "string", + "description": "Optional group channel mode for internal token requests. Values: global, own", + "name": "X-Aiproxy-Group-Channel-Mode", + "in": "header" } ], "responses": { @@ -9124,6 +14102,22 @@ const docTemplate = `{ "description": "Optional Aiproxy-Channel header", "name": "Aiproxy-Channel", "in": "header" + }, + { + "type": "string", + "description": "Optional group ID for internal token requests", + "name": "X-Aiproxy-Group", + "in": "header" + }, + { + "enum": [ + "global", + "own" + ], + "type": "string", + "description": "Optional group channel mode for internal token requests. Values: global, own", + "name": "X-Aiproxy-Group-Channel-Mode", + "in": "header" } ], "responses": { @@ -9162,6 +14156,22 @@ const docTemplate = `{ "description": "Optional Aiproxy-Channel header", "name": "Aiproxy-Channel", "in": "header" + }, + { + "type": "string", + "description": "Optional group ID for internal token requests", + "name": "X-Aiproxy-Group", + "in": "header" + }, + { + "enum": [ + "global", + "own" + ], + "type": "string", + "description": "Optional group channel mode for internal token requests. Values: global, own", + "name": "X-Aiproxy-Group-Channel-Mode", + "in": "header" } ], "responses": { @@ -9199,6 +14209,22 @@ const docTemplate = `{ "description": "Optional Aiproxy-Channel header", "name": "Aiproxy-Channel", "in": "header" + }, + { + "type": "string", + "description": "Optional group ID for internal token requests", + "name": "X-Aiproxy-Group", + "in": "header" + }, + { + "enum": [ + "global", + "own" + ], + "type": "string", + "description": "Optional group channel mode for internal token requests. Values: global, own", + "name": "X-Aiproxy-Group-Channel-Mode", + "in": "header" } ], "responses": { @@ -9236,8 +14262,24 @@ const docTemplate = `{ }, { "type": "string", - "description": "Optional Aiproxy-Channel header", - "name": "Aiproxy-Channel", + "description": "Optional Aiproxy-Channel header", + "name": "Aiproxy-Channel", + "in": "header" + }, + { + "type": "string", + "description": "Optional group ID for internal token requests", + "name": "X-Aiproxy-Group", + "in": "header" + }, + { + "enum": [ + "global", + "own" + ], + "type": "string", + "description": "Optional group channel mode for internal token requests. Values: global, own", + "name": "X-Aiproxy-Group-Channel-Mode", "in": "header" } ], @@ -9281,6 +14323,22 @@ const docTemplate = `{ "description": "Optional Aiproxy-Channel header", "name": "Aiproxy-Channel", "in": "header" + }, + { + "type": "string", + "description": "Optional group ID for internal token requests", + "name": "X-Aiproxy-Group", + "in": "header" + }, + { + "enum": [ + "global", + "own" + ], + "type": "string", + "description": "Optional group channel mode for internal token requests. Values: global, own", + "name": "X-Aiproxy-Group-Channel-Mode", + "in": "header" } ], "responses": { @@ -9349,6 +14407,22 @@ const docTemplate = `{ "description": "Optional Aiproxy-Channel header", "name": "Aiproxy-Channel", "in": "header" + }, + { + "type": "string", + "description": "Optional group ID for internal token requests", + "name": "X-Aiproxy-Group", + "in": "header" + }, + { + "enum": [ + "global", + "own" + ], + "type": "string", + "description": "Optional group channel mode for internal token requests. Values: global, own", + "name": "X-Aiproxy-Group-Channel-Mode", + "in": "header" } ], "responses": { @@ -9417,6 +14491,22 @@ const docTemplate = `{ "description": "Optional Aiproxy-Channel header", "name": "Aiproxy-Channel", "in": "header" + }, + { + "type": "string", + "description": "Optional group ID for internal token requests", + "name": "X-Aiproxy-Group", + "in": "header" + }, + { + "enum": [ + "global", + "own" + ], + "type": "string", + "description": "Optional group channel mode for internal token requests. Values: global, own", + "name": "X-Aiproxy-Group-Channel-Mode", + "in": "header" } ], "responses": { @@ -9485,6 +14575,22 @@ const docTemplate = `{ "description": "Optional Aiproxy-Channel header", "name": "Aiproxy-Channel", "in": "header" + }, + { + "type": "string", + "description": "Optional group ID for internal token requests", + "name": "X-Aiproxy-Group", + "in": "header" + }, + { + "enum": [ + "global", + "own" + ], + "type": "string", + "description": "Optional group channel mode for internal token requests. Values: global, own", + "name": "X-Aiproxy-Group-Channel-Mode", + "in": "header" } ], "responses": { @@ -9523,6 +14629,122 @@ const docTemplate = `{ } } }, + "/v1/videos/edits": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Create a new video by editing a source video", + "produces": [ + "application/json" + ], + "tags": [ + "relay" + ], + "summary": "Edit video", + "parameters": [ + { + "description": "Request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/model.VideosEditRequest" + } + }, + { + "type": "string", + "description": "Optional Aiproxy-Channel header", + "name": "Aiproxy-Channel", + "in": "header" + }, + { + "type": "string", + "description": "Optional group ID for internal token requests", + "name": "X-Aiproxy-Group", + "in": "header" + }, + { + "enum": [ + "global", + "own" + ], + "type": "string", + "description": "Optional group channel mode for internal token requests. Values: global, own", + "name": "X-Aiproxy-Group-Channel-Mode", + "in": "header" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/model.Video" + } + } + } + } + }, + "/v1/videos/extensions": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Create an extension of a completed video", + "produces": [ + "application/json" + ], + "tags": [ + "relay" + ], + "summary": "Extend video", + "parameters": [ + { + "description": "Request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/model.VideosExtensionRequest" + } + }, + { + "type": "string", + "description": "Optional Aiproxy-Channel header", + "name": "Aiproxy-Channel", + "in": "header" + }, + { + "type": "string", + "description": "Optional group ID for internal token requests", + "name": "X-Aiproxy-Group", + "in": "header" + }, + { + "enum": [ + "global", + "own" + ], + "type": "string", + "description": "Optional group channel mode for internal token requests. Values: global, own", + "name": "X-Aiproxy-Group-Channel-Mode", + "in": "header" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/model.Video" + } + } + } + } + }, "/v1/videos/{video_id}": { "get": { "security": [ @@ -9551,6 +14773,22 @@ const docTemplate = `{ "description": "Optional Aiproxy-Channel header", "name": "Aiproxy-Channel", "in": "header" + }, + { + "type": "string", + "description": "Optional group ID for internal token requests", + "name": "X-Aiproxy-Group", + "in": "header" + }, + { + "enum": [ + "global", + "own" + ], + "type": "string", + "description": "Optional group channel mode for internal token requests. Values: global, own", + "name": "X-Aiproxy-Group-Channel-Mode", + "in": "header" } ], "responses": { @@ -9589,6 +14827,22 @@ const docTemplate = `{ "description": "Optional Aiproxy-Channel header", "name": "Aiproxy-Channel", "in": "header" + }, + { + "type": "string", + "description": "Optional group ID for internal token requests", + "name": "X-Aiproxy-Group", + "in": "header" + }, + { + "enum": [ + "global", + "own" + ], + "type": "string", + "description": "Optional group channel mode for internal token requests. Values: global, own", + "name": "X-Aiproxy-Group-Channel-Mode", + "in": "header" } ], "responses": { @@ -9626,6 +14880,22 @@ const docTemplate = `{ "description": "Optional Aiproxy-Channel header", "name": "Aiproxy-Channel", "in": "header" + }, + { + "type": "string", + "description": "Optional group ID for internal token requests", + "name": "X-Aiproxy-Group", + "in": "header" + }, + { + "enum": [ + "global", + "own" + ], + "type": "string", + "description": "Optional group channel mode for internal token requests. Values: global, own", + "name": "X-Aiproxy-Group-Channel-Mode", + "in": "header" } ], "responses": { @@ -9675,6 +14945,22 @@ const docTemplate = `{ "description": "Optional Aiproxy-Channel header", "name": "Aiproxy-Channel", "in": "header" + }, + { + "type": "string", + "description": "Optional group ID for internal token requests", + "name": "X-Aiproxy-Group", + "in": "header" + }, + { + "enum": [ + "global", + "own" + ], + "type": "string", + "description": "Optional group channel mode for internal token requests. Values: global, own", + "name": "X-Aiproxy-Group-Channel-Mode", + "in": "header" } ], "responses": { @@ -9731,6 +15017,22 @@ const docTemplate = `{ "description": "Optional Aiproxy-Channel header", "name": "Aiproxy-Channel", "in": "header" + }, + { + "type": "string", + "description": "Optional group ID for internal token requests", + "name": "X-Aiproxy-Group", + "in": "header" + }, + { + "enum": [ + "global", + "own" + ], + "type": "string", + "description": "Optional group channel mode for internal token requests. Values: global, own", + "name": "X-Aiproxy-Group-Channel-Mode", + "in": "header" } ], "responses": { @@ -9804,6 +15106,22 @@ const docTemplate = `{ "description": "Optional Aiproxy-Channel header", "name": "Aiproxy-Channel", "in": "header" + }, + { + "type": "string", + "description": "Optional group ID for internal token requests", + "name": "X-Aiproxy-Group", + "in": "header" + }, + { + "enum": [ + "global", + "own" + ], + "type": "string", + "description": "Optional group channel mode for internal token requests. Values: global, own", + "name": "X-Aiproxy-Group-Channel-Mode", + "in": "header" } ], "responses": { @@ -9871,12 +15189,74 @@ const docTemplate = `{ "remain": { "type": "number" }, - "total": { + "total": { + "type": "number" + } + } + }, + "controller.AddChannelRequest": { + "type": "object", + "properties": { + "base_url": { + "type": "string" + }, + "configs": { + "$ref": "#/definitions/model.ChannelConfigs" + }, + "enabled_auto_balance_check": { + "type": "boolean" + }, + "enabled_no_permission_ban": { + "type": "boolean" + }, + "key": { + "type": "string" + }, + "max_error_rate": { + "type": "number" + }, + "model_mapping": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "models": { + "type": "array", + "items": { + "type": "string" + } + }, + "name": { + "type": "string" + }, + "priority": { + "type": "integer" + }, + "proxy_url": { + "type": "string" + }, + "sets": { + "type": "array", + "items": { + "type": "string" + } + }, + "skip_tls_verify": { + "type": "boolean" + }, + "status": { + "type": "integer" + }, + "type": { + "$ref": "#/definitions/model.ChannelType" + }, + "warn_error_rate": { "type": "number" } } }, - "controller.AddChannelRequest": { + "controller.AddGroupChannelRequest": { "type": "object", "properties": { "base_url": { @@ -9885,12 +15265,12 @@ const docTemplate = `{ "configs": { "$ref": "#/definitions/model.ChannelConfigs" }, - "enabled_auto_balance_check": { - "type": "boolean" - }, "enabled_no_permission_ban": { "type": "boolean" }, + "group_id": { + "type": "string" + }, "key": { "type": "string" }, @@ -9932,15 +15312,24 @@ const docTemplate = `{ }, "type": { "$ref": "#/definitions/model.ChannelType" - }, - "warn_error_rate": { - "type": "number" } } }, "controller.AddTokenRequest": { "type": "object", "properties": { + "group_channel_models": { + "type": "array", + "items": { + "type": "string" + } + }, + "group_channel_sets": { + "type": "array", + "items": { + "type": "string" + } + }, "models": { "type": "array", "items": { @@ -9962,6 +15351,15 @@ const docTemplate = `{ "quota": { "type": "number" }, + "scope": { + "type": "string" + }, + "sets": { + "type": "array", + "items": { + "type": "string" + } + }, "subnets": { "type": "array", "items": { @@ -10007,6 +15405,12 @@ const docTemplate = `{ "controller.BuiltinModelConfig": { "type": "object", "properties": { + "allowed_resolutions": { + "type": "array", + "items": { + "type": "string" + } + }, "config": { "type": "object", "additionalProperties": {} @@ -10014,6 +15418,9 @@ const docTemplate = `{ "created_at": { "type": "string" }, + "disable_resolution_fuzzy_match": { + "type": "boolean" + }, "exclude_from_tests": { "type": "boolean" }, @@ -10199,6 +15606,20 @@ const docTemplate = `{ } } }, + "controller.GroupChannelTestResult": { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.GroupChannelTest" + }, + "message": { + "type": "string" + }, + "success": { + "type": "boolean" + } + } + }, "controller.GroupMCPResponse": { "type": "object", "properties": { @@ -10496,6 +15917,12 @@ const docTemplate = `{ "created_at": { "type": "string" }, + "group_channel_request_count": { + "type": "integer" + }, + "group_channel_used_amount": { + "type": "number" + }, "id": { "type": "string" }, @@ -10580,6 +16007,9 @@ const docTemplate = `{ "properties": { "dsn": { "type": "string" + }, + "group_id": { + "type": "string" } } }, @@ -10997,9 +16427,100 @@ const docTemplate = `{ } } }, + "controller.SaveGroupScopeModelConfigsRequest": { + "type": "object", + "properties": { + "allowed_resolutions": { + "type": "array", + "items": { + "type": "string" + } + }, + "config": { + "type": "object", + "additionalProperties": {} + }, + "created_at": { + "type": "string" + }, + "disable_resolution_fuzzy_match": { + "type": "boolean" + }, + "exclude_from_tests": { + "type": "boolean" + }, + "force_save_detail": { + "type": "boolean" + }, + "group_id": { + "type": "string" + }, + "max_image_generation_count": { + "type": "integer" + }, + "max_video_generation_count": { + "type": "integer" + }, + "max_video_generation_seconds": { + "type": "integer" + }, + "model": { + "type": "string" + }, + "owner": { + "$ref": "#/definitions/model.ModelOwner" + }, + "plugin": { + "type": "object", + "additionalProperties": { + "type": "object", + "additionalProperties": {} + } + }, + "price": { + "$ref": "#/definitions/model.Price" + }, + "request_body_storage_max_size": { + "type": "integer" + }, + "response_body_storage_max_size": { + "type": "integer" + }, + "retry_times": { + "type": "integer" + }, + "rpm": { + "type": "integer" + }, + "summary_claude_long_context": { + "type": "boolean" + }, + "summary_service_tier": { + "type": "boolean" + }, + "timeout_config": { + "$ref": "#/definitions/model.TimeoutConfig" + }, + "tpm": { + "type": "integer" + }, + "type": { + "$ref": "#/definitions/mode.Mode" + }, + "updated_at": { + "type": "string" + } + } + }, "controller.SaveModelConfigsRequest": { "type": "object", "properties": { + "allowed_resolutions": { + "type": "array", + "items": { + "type": "string" + } + }, "config": { "type": "object", "additionalProperties": {} @@ -11007,6 +16528,9 @@ const docTemplate = `{ "created_at": { "type": "string" }, + "disable_resolution_fuzzy_match": { + "type": "boolean" + }, "exclude_from_tests": { "type": "boolean" }, @@ -11078,10 +16602,127 @@ const docTemplate = `{ } } }, - "controller.TestChannelRequest": { + "controller.TestChannelRequest": { + "type": "object", + "required": [ + "key", + "type" + ], + "properties": { + "base_url": { + "type": "string" + }, + "configs": { + "type": "object", + "additionalProperties": {} + }, + "key": { + "type": "string" + }, + "model_mapping": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "models": { + "type": "array", + "items": { + "type": "string" + } + }, + "name": { + "type": "string" + }, + "proxy_url": { + "type": "string" + }, + "skip_tls_verify": { + "type": "boolean" + }, + "type": { + "type": "integer" + } + } + }, + "controller.TestGroupChannelRequest": { + "type": "object", + "properties": { + "base_url": { + "type": "string" + }, + "configs": { + "$ref": "#/definitions/model.ChannelConfigs" + }, + "enabled_no_permission_ban": { + "type": "boolean" + }, + "group_id": { + "type": "string" + }, + "key": { + "type": "string" + }, + "max_error_rate": { + "type": "number" + }, + "model_mapping": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "models": { + "type": "array", + "items": { + "type": "string" + } + }, + "name": { + "type": "string" + }, + "priority": { + "type": "integer" + }, + "proxy_url": { + "type": "string" + }, + "sets": { + "type": "array", + "items": { + "type": "string" + } + }, + "skip_tls_verify": { + "type": "boolean" + }, + "status": { + "type": "integer" + }, + "type": { + "$ref": "#/definitions/model.ChannelType" + } + } + }, + "controller.TestResult": { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.ChannelTest" + }, + "message": { + "type": "string" + }, + "success": { + "type": "boolean" + } + } + }, + "controller.TestSingleGroupChannelRequest": { "type": "object", "required": [ "key", + "model", "type" ], "properties": { @@ -11089,30 +16730,35 @@ const docTemplate = `{ "type": "string" }, "configs": { - "type": "object", - "additionalProperties": {} + "$ref": "#/definitions/model.ChannelConfigs" + }, + "group_id": { + "type": "string" }, "key": { "type": "string" }, + "model": { + "type": "string" + }, "model_mapping": { "type": "object", "additionalProperties": { "type": "string" } }, - "models": { - "type": "array", - "items": { - "type": "string" - } - }, "name": { "type": "string" }, "proxy_url": { "type": "string" }, + "sets": { + "type": "array", + "items": { + "type": "string" + } + }, "skip_tls_verify": { "type": "boolean" }, @@ -11121,20 +16767,6 @@ const docTemplate = `{ } } }, - "controller.TestResult": { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/model.ChannelTest" - }, - "message": { - "type": "string" - }, - "success": { - "type": "boolean" - } - } - }, "controller.TestSingleModelRequest": { "type": "object", "required": [ @@ -11188,6 +16820,24 @@ const docTemplate = `{ "group": { "type": "string" }, + "group_channel_models": { + "type": "array", + "items": { + "type": "string" + } + }, + "group_channel_request_count": { + "type": "integer" + }, + "group_channel_sets": { + "type": "array", + "items": { + "type": "string" + } + }, + "group_channel_used_amount": { + "type": "number" + }, "id": { "type": "integer" }, @@ -11224,6 +16874,15 @@ const docTemplate = `{ "request_count": { "type": "integer" }, + "scope": { + "$ref": "#/definitions/model.ChannelScope" + }, + "sets": { + "type": "array", + "items": { + "type": "string" + } + }, "status": { "type": "integer" }, @@ -11578,7 +17237,15 @@ const docTemplate = `{ 27, 28, 29, - 30 + 30, + 31, + 32, + 33, + 34, + 35, + 36, + 37, + 38 ], "x-enum-varnames": [ "Unknown", @@ -11611,7 +17278,15 @@ const docTemplate = `{ "GeminiVideo", "GeminiVideoOperations", "GeminiTTS", - "GeminiImage" + "GeminiImage", + "GeminiFiles", + "VideosEdits", + "VideosExtensions", + "AliVideo", + "AliVideoTasks", + "DoubaoVideo", + "DoubaoVideoTasks", + "DoubaoVideoTasksDelete" ] }, "model.Amount": { @@ -11805,6 +17480,17 @@ const docTemplate = `{ "type": "object", "additionalProperties": {} }, + "model.ChannelScope": { + "type": "string", + "enum": [ + "global", + "group" + ], + "x-enum-varnames": [ + "ChannelScopeGlobal", + "ChannelScopeGroup" + ] + }, "model.ChannelTest": { "type": "object", "properties": { @@ -12150,234 +17836,548 @@ const docTemplate = `{ "instructions": { "type": "string" }, - "max_output_tokens": { + "max_output_tokens": { + "type": "integer" + }, + "max_tool_calls": { + "type": "integer" + }, + "metadata": { + "type": "object", + "additionalProperties": {} + }, + "model": { + "type": "string" + }, + "parallel_tool_calls": { + "type": "boolean" + }, + "previous_response_id": { + "type": "string" + }, + "prompt_cache_key": { + "type": "string" + }, + "prompt_cache_retention": { + "type": "string" + }, + "reasoning": { + "$ref": "#/definitions/model.ResponseReasoning" + }, + "safety_identifier": { + "type": "string" + }, + "service_tier": { + "type": "string" + }, + "store": { + "type": "boolean" + }, + "stream": { + "type": "boolean" + }, + "temperature": { + "type": "number" + }, + "text": { + "$ref": "#/definitions/model.ResponseText" + }, + "tool_choice": {}, + "tools": { + "type": "array", + "items": { + "$ref": "#/definitions/model.ResponseTool" + } + }, + "top_logprobs": { + "type": "integer" + }, + "top_p": { + "type": "number" + }, + "truncation": { + "type": "string" + }, + "user": { + "description": "Deprecated, use prompt_cache_key", + "type": "string" + } + } + }, + "model.DashboardResponse": { + "type": "object", + "properties": { + "audio_input_amount": { + "type": "number" + }, + "audio_input_tokens": { + "type": "integer" + }, + "audio_output_amount": { + "type": "number" + }, + "audio_output_tokens": { + "type": "integer" + }, + "cache_creation_amount": { + "type": "number" + }, + "cache_creation_count": { + "type": "integer" + }, + "cache_creation_tokens": { + "type": "integer" + }, + "cache_hit_count": { + "type": "integer" + }, + "cached_amount": { + "type": "number" + }, + "cached_tokens": { + "type": "integer" + }, + "channels": { + "type": "array", + "items": { + "type": "integer" + } + }, + "chart_data": { + "type": "array", + "items": { + "$ref": "#/definitions/model.ChartData" + } + }, + "claude_long_context": { + "$ref": "#/definitions/model.SummaryDataSet" + }, + "exception_count": { + "type": "integer" + }, + "image_input_amount": { + "type": "number" + }, + "image_input_tokens": { + "type": "integer" + }, + "image_output_amount": { + "type": "number" + }, + "image_output_tokens": { + "type": "integer" + }, + "input_amount": { + "type": "number" + }, + "input_tokens": { + "type": "integer" + }, + "max_rpm": { + "type": "integer" + }, + "max_tpm": { + "type": "integer" + }, + "models": { + "type": "array", + "items": { + "type": "string" + } + }, + "output_amount": { + "type": "number" + }, + "output_tokens": { + "type": "integer" + }, + "reasoning_tokens": { + "type": "integer" + }, + "request_count": { + "type": "integer" + }, + "retry_count": { + "type": "integer" + }, + "rpm": { + "type": "integer" + }, + "service_tier_flex": { + "$ref": "#/definitions/model.SummaryDataSet" + }, + "service_tier_priority": { + "$ref": "#/definitions/model.SummaryDataSet" + }, + "status_2xx_count": { + "type": "integer" + }, + "status_400_count": { "type": "integer" }, - "max_tool_calls": { + "status_429_count": { "type": "integer" }, - "metadata": { - "type": "object", - "additionalProperties": {} + "status_4xx_count": { + "type": "integer" }, - "model": { - "type": "string" + "status_500_count": { + "type": "integer" }, - "parallel_tool_calls": { - "type": "boolean" + "status_5xx_count": { + "type": "integer" }, - "previous_response_id": { - "type": "string" + "status_other_count": { + "type": "integer" }, - "prompt_cache_key": { - "type": "string" + "total_count": { + "description": "use Count.RequestCount instead", + "type": "integer" }, - "prompt_cache_retention": { - "type": "string" + "total_time_milliseconds": { + "type": "integer" }, - "reasoning": { - "$ref": "#/definitions/model.ResponseReasoning" + "total_tokens": { + "type": "integer" }, - "safety_identifier": { - "type": "string" + "total_ttfb_milliseconds": { + "type": "integer" }, - "service_tier": { - "type": "string" + "tpm": { + "type": "integer" }, - "store": { - "type": "boolean" + "used_amount": { + "type": "number" }, - "stream": { - "type": "boolean" + "video_input_amount": { + "type": "number" }, - "temperature": { + "video_input_tokens": { + "type": "integer" + }, + "web_search_amount": { "type": "number" }, - "text": { - "$ref": "#/definitions/model.ResponseText" + "web_search_count": { + "type": "integer" + } + } + }, + "model.DashboardV3Response": { + "type": "object", + "properties": { + "channels": { + "type": "array", + "items": { + "type": "integer" + } }, - "tool_choice": {}, - "tools": { + "models": { "type": "array", "items": { - "$ref": "#/definitions/model.ResponseTool" + "type": "string" } }, - "top_logprobs": { + "rpm": { "type": "integer" }, - "top_p": { - "type": "number" - }, - "truncation": { - "type": "string" + "time_series": { + "type": "array", + "items": { + "$ref": "#/definitions/model.TimeSummaryDataV2" + } }, - "user": { - "description": "Deprecated, use prompt_cache_key", + "tpm": { + "type": "integer" + } + } + }, + "model.Document": { + "type": "object", + "properties": { + "text": { "type": "string" } } }, - "model.DashboardResponse": { + "model.EmbeddingPromptTokensDetails": { "type": "object", "properties": { - "audio_input_amount": { - "type": "number" - }, - "audio_input_tokens": { - "type": "integer" - }, - "audio_output_amount": { - "type": "number" - }, - "audio_output_tokens": { + "image_tokens": { "type": "integer" }, - "cache_creation_amount": { - "type": "number" - }, - "cache_creation_count": { + "text_tokens": { "type": "integer" }, - "cache_creation_tokens": { + "video_tokens": { "type": "integer" - }, - "cache_hit_count": { + } + } + }, + "model.EmbeddingRequest": { + "type": "object", + "properties": { + "dimensions": { "type": "integer" }, - "cached_amount": { - "type": "number" + "encoding_format": { + "type": "string" }, - "cached_tokens": { - "type": "integer" + "input": { + "type": "string" }, - "channels": { + "model": { + "type": "string" + } + } + }, + "model.EmbeddingResponse": { + "type": "object", + "properties": { + "data": { "type": "array", "items": { - "type": "integer" + "$ref": "#/definitions/model.EmbeddingResponseItem" } }, - "chart_data": { + "model": { + "type": "string" + }, + "object": { + "type": "string" + }, + "usage": { + "$ref": "#/definitions/model.EmbeddingUsage" + } + } + }, + "model.EmbeddingResponseItem": { + "type": "object", + "properties": { + "embedding": { "type": "array", "items": { - "$ref": "#/definitions/model.ChartData" + "type": "number" } }, - "claude_long_context": { - "$ref": "#/definitions/model.SummaryDataSet" + "index": { + "type": "integer" }, - "exception_count": { + "object": { + "type": "string" + } + } + }, + "model.EmbeddingUsage": { + "type": "object", + "properties": { + "prompt_tokens": { "type": "integer" }, - "image_input_amount": { - "type": "number" + "prompt_tokens_details": { + "$ref": "#/definitions/model.EmbeddingPromptTokensDetails" }, - "image_input_tokens": { + "total_tokens": { "type": "integer" + } + } + }, + "model.ExtraContent": { + "type": "object", + "properties": { + "google": { + "$ref": "#/definitions/model.GoogleExtraContent" + } + } + }, + "model.FinishReason": { + "type": "string", + "enum": [ + "stop", + "length", + "content_filter", + "tool_calls", + "function_call" + ], + "x-enum-varnames": [ + "FinishReasonStop", + "FinishReasonLength", + "FinishReasonContentFilter", + "FinishReasonToolCalls", + "FinishReasonFunctionCall" + ] + }, + "model.Function": { + "type": "object", + "properties": { + "arguments": { + "type": "string" }, - "image_output_amount": { - "type": "number" + "description": { + "type": "string" }, - "image_output_tokens": { - "type": "integer" + "name": { + "type": "string" }, - "input_amount": { + "parameters": {} + } + }, + "model.GeneralOpenAIRequest": { + "type": "object", + "properties": { + "audio": { + "$ref": "#/definitions/model.Audio" + }, + "enable_thinking": { + "type": "boolean" + }, + "frequency_penalty": { "type": "number" }, - "input_tokens": { - "type": "integer" + "function_call": {}, + "functions": {}, + "input": {}, + "logit_bias": {}, + "logprobs": { + "type": "boolean" }, - "max_rpm": { + "max_completion_tokens": { "type": "integer" }, - "max_tpm": { + "max_tokens": { "type": "integer" }, - "models": { + "messages": { + "type": "array", + "items": { + "$ref": "#/definitions/model.Message" + } + }, + "metadata": {}, + "modalities": { "type": "array", "items": { "type": "string" } }, - "output_amount": { - "type": "number" + "model": { + "type": "string" }, - "output_tokens": { + "n": { "type": "integer" }, - "reasoning_tokens": { + "num_ctx": { "type": "integer" }, - "request_count": { - "type": "integer" + "parallel_tool_calls": { + "type": "boolean" }, - "retry_count": { - "type": "integer" + "presence_penalty": { + "type": "number" }, - "rpm": { - "type": "integer" + "prompt": {}, + "prompt_cache_key": { + "type": "string" }, - "service_tier_flex": { - "$ref": "#/definitions/model.SummaryDataSet" + "prompt_cache_retention": { + "type": "string" }, - "service_tier_priority": { - "$ref": "#/definitions/model.SummaryDataSet" + "reasoning_effort": { + "type": "string" }, - "status_2xx_count": { - "type": "integer" + "response_format": { + "$ref": "#/definitions/model.ResponseFormat" }, - "status_400_count": { - "type": "integer" + "seed": { + "type": "number" }, - "status_429_count": { - "type": "integer" + "service_tier": { + "type": "string" }, - "status_4xx_count": { - "type": "integer" + "size": { + "type": "string" }, - "status_500_count": { - "type": "integer" + "stop": {}, + "stream": { + "type": "boolean" }, - "status_5xx_count": { - "type": "integer" + "stream_options": { + "$ref": "#/definitions/model.StreamOptions" }, - "status_other_count": { - "type": "integer" + "temperature": { + "type": "number" }, - "total_count": { - "description": "use Count.RequestCount instead", - "type": "integer" + "thinking": { + "description": "aiproxy control field", + "allOf": [ + { + "$ref": "#/definitions/model.GeneralThinking" + } + ] }, - "total_time_milliseconds": { + "thinking_budget": { "type": "integer" }, - "total_tokens": { - "type": "integer" + "tool_choice": {}, + "tools": { + "type": "array", + "items": { + "$ref": "#/definitions/model.Tool" + } }, - "total_ttfb_milliseconds": { + "top_k": { "type": "integer" }, - "tpm": { + "top_logprobs": { "type": "integer" }, - "used_amount": { - "type": "number" - }, - "video_input_amount": { + "top_p": { "type": "number" }, - "video_input_tokens": { + "user": { + "type": "string" + } + } + }, + "model.GeneralThinking": { + "type": "object", + "properties": { + "budget_tokens": { + "description": "when type is \"disabled\", this field must be 0", "type": "integer" }, - "web_search_amount": { - "type": "number" + "type": { + "$ref": "#/definitions/model.ClaudeThinkingType" + } + } + }, + "model.GetGroupChannelLogsResult": { + "type": "object", + "properties": { + "logs": { + "type": "array", + "items": { + "$ref": "#/definitions/model.GroupChannelLog" + } }, - "web_search_count": { + "models": { + "type": "array", + "items": { + "type": "string" + } + }, + "token_names": { + "type": "array", + "items": { + "type": "string" + } + }, + "total": { "type": "integer" } } }, - "model.DashboardV3Response": { + "model.GetGroupLogsResult": { "type": "object", "properties": { "channels": { @@ -12386,373 +18386,356 @@ const docTemplate = `{ "type": "integer" } }, + "logs": { + "type": "array", + "items": { + "$ref": "#/definitions/model.Log" + } + }, "models": { "type": "array", "items": { "type": "string" } }, - "rpm": { + "token_names": { + "type": "array", + "items": { + "type": "string" + } + }, + "total": { "type": "integer" + } + } + }, + "model.GetLogsResult": { + "type": "object", + "properties": { + "channels": { + "type": "array", + "items": { + "type": "integer" + } }, - "time_series": { + "logs": { "type": "array", "items": { - "$ref": "#/definitions/model.TimeSummaryDataV2" + "$ref": "#/definitions/model.Log" } }, - "tpm": { + "models": { + "type": "array", + "items": { + "type": "string" + } + }, + "total": { "type": "integer" } } }, - "model.Document": { + "model.GoogleExtraContent": { "type": "object", "properties": { - "text": { + "thought_signature": { "type": "string" } } }, - "model.EmbeddingPromptTokensDetails": { + "model.Group": { "type": "object", "properties": { - "image_tokens": { + "available_sets": { + "type": "array", + "items": { + "type": "string" + } + }, + "balance_alert_enabled": { + "type": "boolean" + }, + "balance_alert_threshold": { + "type": "number" + }, + "created_at": { + "type": "string" + }, + "group_channel_request_count": { "type": "integer" }, - "text_tokens": { + "group_channel_used_amount": { + "type": "number" + }, + "id": { + "type": "string" + }, + "request_count": { "type": "integer" }, - "video_tokens": { + "rpm_ratio": { + "type": "number" + }, + "status": { "type": "integer" + }, + "tpm_ratio": { + "type": "number" + }, + "used_amount": { + "type": "number" } } }, - "model.EmbeddingRequest": { + "model.GroupChannel": { "type": "object", "properties": { - "dimensions": { - "type": "integer" + "base_url": { + "type": "string" }, - "encoding_format": { + "configs": { + "$ref": "#/definitions/model.ChannelConfigs" + }, + "created_at": { "type": "string" }, - "input": { + "enabled_no_permission_ban": { + "type": "boolean" + }, + "group_channel_tests": { + "type": "array", + "items": { + "$ref": "#/definitions/model.GroupChannelTest" + } + }, + "group_id": { "type": "string" }, - "model": { + "id": { + "type": "integer" + }, + "key": { "type": "string" - } - } - }, - "model.EmbeddingResponse": { - "type": "object", - "properties": { - "data": { + }, + "last_test_error_at": { + "type": "string" + }, + "max_error_rate": { + "type": "number" + }, + "model_mapping": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "models": { "type": "array", "items": { - "$ref": "#/definitions/model.EmbeddingResponseItem" + "type": "string" } }, - "model": { + "name": { "type": "string" }, - "object": { + "priority": { + "type": "integer" + }, + "proxy_url": { "type": "string" }, - "usage": { - "$ref": "#/definitions/model.EmbeddingUsage" - } - } - }, - "model.EmbeddingResponseItem": { - "type": "object", - "properties": { - "embedding": { + "request_count": { + "type": "integer" + }, + "retry_count": { + "type": "integer" + }, + "sets": { "type": "array", "items": { - "type": "number" + "type": "string" } }, - "index": { - "type": "integer" + "skip_tls_verify": { + "type": "boolean" }, - "object": { - "type": "string" - } - } - }, - "model.EmbeddingUsage": { - "type": "object", - "properties": { - "prompt_tokens": { + "status": { "type": "integer" }, - "prompt_tokens_details": { - "$ref": "#/definitions/model.EmbeddingPromptTokensDetails" + "type": { + "$ref": "#/definitions/model.ChannelType" }, - "total_tokens": { - "type": "integer" - } - } - }, - "model.ExtraContent": { - "type": "object", - "properties": { - "google": { - "$ref": "#/definitions/model.GoogleExtraContent" + "used_amount": { + "type": "number" } } }, - "model.FinishReason": { - "type": "string", - "enum": [ - "stop", - "length", - "content_filter", - "tool_calls", - "function_call" - ], - "x-enum-varnames": [ - "FinishReasonStop", - "FinishReasonLength", - "FinishReasonContentFilter", - "FinishReasonToolCalls", - "FinishReasonFunctionCall" - ] - }, - "model.Function": { + "model.GroupChannelBasicInfo": { "type": "object", "properties": { - "arguments": { + "group_id": { "type": "string" }, - "description": { - "type": "string" + "id": { + "type": "integer" }, "name": { "type": "string" }, - "parameters": {} + "type": { + "$ref": "#/definitions/model.ChannelType" + } } }, - "model.GeneralOpenAIRequest": { + "model.GroupChannelLog": { "type": "object", "properties": { - "audio": { - "$ref": "#/definitions/model.Audio" - }, - "enable_thinking": { - "type": "boolean" - }, - "frequency_penalty": { - "type": "number" + "amount": { + "$ref": "#/definitions/model.Amount" }, - "function_call": {}, - "functions": {}, - "input": {}, - "logit_bias": {}, - "logprobs": { - "type": "boolean" + "async_usage_status": { + "$ref": "#/definitions/model.AsyncUsageStatus" }, - "max_completion_tokens": { + "code": { "type": "integer" }, - "max_tokens": { - "type": "integer" + "content": { + "type": "string" }, - "messages": { - "type": "array", - "items": { - "$ref": "#/definitions/model.Message" - } + "created_at": { + "type": "string" }, - "metadata": {}, - "model": { + "endpoint": { "type": "string" }, - "num_ctx": { + "group": { + "type": "string" + }, + "group_channel_id": { "type": "integer" }, - "presence_penalty": { - "type": "number" + "id": { + "type": "integer" }, - "prompt": {}, - "prompt_cache_key": { + "ip": { "type": "string" }, - "prompt_cache_retention": { - "type": "string" + "metadata": { + "type": "object", + "additionalProperties": { + "type": "string" + } }, - "reasoning_effort": { - "type": "string" + "mode": { + "type": "integer" }, - "response_format": { - "$ref": "#/definitions/model.ResponseFormat" + "model": { + "type": "string" }, - "seed": { - "type": "number" + "price": { + "$ref": "#/definitions/model.Price" }, - "service_tier": { + "prompt_cache_key": { "type": "string" }, - "size": { + "request_at": { "type": "string" }, - "stop": {}, - "stream": { - "type": "boolean" + "request_detail": { + "$ref": "#/definitions/model.GroupChannelRequestDetail" }, - "stream_options": { - "$ref": "#/definitions/model.StreamOptions" + "request_id": { + "type": "string" }, - "temperature": { - "type": "number" + "retry_at": { + "type": "string" }, - "thinking": { - "description": "aiproxy control field", - "allOf": [ - { - "$ref": "#/definitions/model.GeneralThinking" - } - ] + "retry_times": { + "type": "integer" }, - "thinking_budget": { + "token_id": { "type": "integer" }, - "tool_choice": {}, - "tools": { - "type": "array", - "items": { - "$ref": "#/definitions/model.Tool" - } + "token_name": { + "type": "string" }, - "top_k": { + "ttfb_milliseconds": { "type": "integer" }, - "top_logprobs": { - "type": "integer" + "upstream_id": { + "type": "string" }, - "top_p": { - "type": "number" + "usage": { + "$ref": "#/definitions/model.Usage" + }, + "usage_context": { + "$ref": "#/definitions/model.UsageContext" }, "user": { "type": "string" } } }, - "model.GeneralThinking": { + "model.GroupChannelRequestDetail": { "type": "object", "properties": { - "budget_tokens": { - "description": "when type is \"disabled\", this field must be 0", + "id": { "type": "integer" }, - "type": { - "$ref": "#/definitions/model.ClaudeThinkingType" - } - } - }, - "model.GetGroupLogsResult": { - "type": "object", - "properties": { - "channels": { - "type": "array", - "items": { - "type": "integer" - } - }, - "logs": { - "type": "array", - "items": { - "$ref": "#/definitions/model.Log" - } - }, - "models": { - "type": "array", - "items": { - "type": "string" - } - }, - "token_names": { - "type": "array", - "items": { - "type": "string" - } - }, - "total": { + "log_id": { "type": "integer" - } - } - }, - "model.GetLogsResult": { - "type": "object", - "properties": { - "channels": { - "type": "array", - "items": { - "type": "integer" - } }, - "logs": { - "type": "array", - "items": { - "$ref": "#/definitions/model.Log" - } + "request_body": { + "type": "string" }, - "models": { - "type": "array", - "items": { - "type": "string" - } + "request_body_truncated": { + "type": "boolean" }, - "total": { - "type": "integer" - } - } - }, - "model.GoogleExtraContent": { - "type": "object", - "properties": { - "thought_signature": { + "response_body": { "type": "string" + }, + "response_body_truncated": { + "type": "boolean" } } }, - "model.Group": { + "model.GroupChannelTest": { "type": "object", "properties": { - "available_sets": { - "type": "array", - "items": { - "type": "string" - } + "actual_model": { + "type": "string" }, - "balance_alert_enabled": { - "type": "boolean" + "channel_name": { + "type": "string" }, - "balance_alert_threshold": { - "type": "number" + "channel_type": { + "$ref": "#/definitions/model.ChannelType" }, - "created_at": { - "type": "string" + "code": { + "type": "integer" }, - "id": { + "group_channel_id": { + "type": "integer" + }, + "group_id": { "type": "string" }, - "request_count": { - "type": "integer" + "mode": { + "$ref": "#/definitions/mode.Mode" }, - "rpm_ratio": { - "type": "number" + "model": { + "type": "string" }, - "status": { - "type": "integer" + "response": { + "type": "string" }, - "tpm_ratio": { - "type": "number" + "success": { + "type": "boolean" }, - "used_amount": { + "test_at": { + "type": "string" + }, + "took": { "type": "number" } } @@ -13087,6 +19070,91 @@ const docTemplate = `{ } } }, + "model.GroupScopeModelConfig": { + "type": "object", + "properties": { + "allowed_resolutions": { + "type": "array", + "items": { + "type": "string" + } + }, + "config": { + "type": "object", + "additionalProperties": {} + }, + "created_at": { + "type": "string" + }, + "disable_resolution_fuzzy_match": { + "type": "boolean" + }, + "exclude_from_tests": { + "type": "boolean" + }, + "force_save_detail": { + "type": "boolean" + }, + "group_id": { + "type": "string" + }, + "max_image_generation_count": { + "type": "integer" + }, + "max_video_generation_count": { + "type": "integer" + }, + "max_video_generation_seconds": { + "type": "integer" + }, + "model": { + "type": "string" + }, + "owner": { + "$ref": "#/definitions/model.ModelOwner" + }, + "plugin": { + "type": "object", + "additionalProperties": { + "type": "object", + "additionalProperties": {} + } + }, + "price": { + "$ref": "#/definitions/model.Price" + }, + "request_body_storage_max_size": { + "type": "integer" + }, + "response_body_storage_max_size": { + "type": "integer" + }, + "retry_times": { + "type": "integer" + }, + "rpm": { + "type": "integer" + }, + "summary_claude_long_context": { + "type": "boolean" + }, + "summary_service_tier": { + "type": "boolean" + }, + "timeout_config": { + "$ref": "#/definitions/model.TimeoutConfig" + }, + "tpm": { + "type": "integer" + }, + "type": { + "$ref": "#/definitions/mode.Mode" + }, + "updated_at": { + "type": "string" + } + } + }, "model.ImageData": { "type": "object", "properties": { @@ -13252,10 +19320,20 @@ const docTemplate = `{ "description": "Fields for function_result type", "type": "string" }, + "detail": { + "type": "string" + }, + "file_id": { + "type": "string" + }, "id": { "description": "Fields for function_call type", "type": "string" }, + "image_url": { + "description": "Fields for input_image type", + "type": "string" + }, "name": { "type": "string" }, @@ -13411,9 +19489,6 @@ const docTemplate = `{ "retry_times": { "type": "integer" }, - "service_tier": { - "type": "string" - }, "token_id": { "type": "integer" }, @@ -13523,6 +19598,12 @@ const docTemplate = `{ "model.ModelConfig": { "type": "object", "properties": { + "allowed_resolutions": { + "type": "array", + "items": { + "type": "string" + } + }, "config": { "type": "object", "additionalProperties": {} @@ -13530,6 +19611,9 @@ const docTemplate = `{ "created_at": { "type": "string" }, + "disable_resolution_fuzzy_match": { + "type": "boolean" + }, "exclude_from_tests": { "type": "boolean" }, @@ -13852,12 +19936,21 @@ const docTemplate = `{ "description": "Unix timestamp, 0 means no end limit", "type": "integer" }, + "input_media": { + "type": "boolean" + }, "input_token_max": { "type": "integer" }, "input_token_min": { "type": "integer" }, + "input_video": { + "type": "boolean" + }, + "output_audio": { + "type": "boolean" + }, "output_token_max": { "type": "integer" }, @@ -14354,6 +20447,19 @@ const docTemplate = `{ "model.ResponseTextFormat": { "type": "object", "properties": { + "description": { + "type": "string" + }, + "name": { + "type": "string" + }, + "schema": { + "type": "object", + "additionalProperties": {} + }, + "strict": { + "type": "boolean" + }, "type": { "type": "string" } @@ -14652,6 +20758,9 @@ const docTemplate = `{ "exception_count": { "type": "integer" }, + "group_channel_id": { + "type": "integer" + }, "group_id": { "type": "string" }, @@ -14923,6 +21032,18 @@ const docTemplate = `{ "model.UpdateTokenRequest": { "type": "object", "properties": { + "group_channel_models": { + "type": "array", + "items": { + "type": "string" + } + }, + "group_channel_sets": { + "type": "array", + "items": { + "type": "string" + } + }, "models": { "type": "array", "items": { @@ -14945,6 +21066,15 @@ const docTemplate = `{ "description": "Quota system", "type": "number" }, + "scope": { + "type": "string" + }, + "sets": { + "type": "array", + "items": { + "type": "string" + } + }, "status": { "type": "integer" }, @@ -15000,23 +21130,26 @@ const docTemplate = `{ "model.UsageContext": { "type": "object", "properties": { - "quality": { - "type": "string" + "input_media": { + "type": "boolean" }, - "resolution": { - "type": "string" + "input_video": { + "type": "boolean" }, "native_resolution": { "type": "string" }, - "service_tier": { + "output_audio": { + "type": "boolean" + }, + "quality": { "type": "string" }, - "input_video": { - "type": "boolean" + "resolution": { + "type": "string" }, - "output_audio": { - "type": "boolean" + "service_tier": { + "type": "string" } } }, @@ -15127,9 +21260,6 @@ const docTemplate = `{ "seconds": { "type": "integer" }, - "size": { - "type": "string" - }, "width": { "type": "integer" } @@ -15198,6 +21328,44 @@ const docTemplate = `{ "VideoStatusCancelled" ] }, + "model.VideosEditRequest": { + "type": "object", + "properties": { + "input_reference": { + "type": "string" + }, + "model": { + "type": "string" + }, + "prompt": { + "type": "string" + }, + "seconds": {}, + "size": { + "type": "string" + }, + "video": {} + } + }, + "model.VideosExtensionRequest": { + "type": "object", + "properties": { + "input_reference": { + "type": "string" + }, + "model": { + "type": "string" + }, + "prompt": { + "type": "string" + }, + "seconds": {}, + "size": { + "type": "string" + }, + "video": {} + } + }, "model.VideosRemixRequest": { "type": "object", "properties": { diff --git a/core/docs/swagger.json b/core/docs/swagger.json index 1e712e32..0d73189a 100644 --- a/core/docs/swagger.json +++ b/core/docs/swagger.json @@ -45,7 +45,7 @@ } } }, - "/api/channel/test": { + "/api/channel/test-preview": { "post": { "security": [ { @@ -96,7 +96,7 @@ } } }, - "/api/channel/test-all": { + "/api/channel/test-preview-all": { "post": { "security": [ { @@ -302,53 +302,6 @@ } } }, - "/api/channel/{id}/balance": { - "get": { - "security": [ - { - "ApiKeyAuth": [] - } - ], - "description": "Updates the balance for a single channel", - "produces": [ - "application/json" - ], - "tags": [ - "channel" - ], - "summary": "Update channel balance", - "parameters": [ - { - "type": "integer", - "description": "Channel ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/middleware.APIResponse" - }, - { - "type": "object", - "properties": { - "data": { - "type": "number", - "format": "float64" - } - } - } - ] - } - } - } - } - }, "/api/channel/{id}/status": { "post": { "security": [ @@ -462,7 +415,7 @@ } } }, - "/api/channel/{id}/{model}": { + "/api/channel/{id}/test/{model}": { "get": { "security": [ { @@ -515,6 +468,53 @@ } } }, + "/api/channel/{id}/update_balance": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Updates the balance for a single channel", + "produces": [ + "application/json" + ], + "tags": [ + "channel" + ], + "summary": "Update channel balance", + "parameters": [ + { + "type": "integer", + "description": "Channel ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/middleware.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "type": "number", + "format": "float64" + } + } + } + ] + } + } + } + } + }, "/api/channels/": { "get": { "security": [ @@ -701,31 +701,6 @@ } } }, - "/api/channels/balance": { - "get": { - "security": [ - { - "ApiKeyAuth": [] - } - ], - "description": "Updates the balance for all channels", - "produces": [ - "application/json" - ], - "tags": [ - "channel" - ], - "summary": "Update all channels balance", - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/middleware.APIResponse" - } - } - } - } - }, "/api/channels/batch_delete": { "post": { "security": [ @@ -1093,6 +1068,31 @@ } } }, + "/api/channels/update_balance": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Updates the balance for all channels", + "produces": [ + "application/json" + ], + "tags": [ + "channel" + ], + "summary": "Update all channels balance", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/middleware.APIResponse" + } + } + } + } + }, "/api/dashboard/": { "get": { "security": [ @@ -1176,6 +1176,95 @@ } } }, + "/api/dashboard/group_channel": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Returns group-channel usage statistics across groups, optionally filtered by group", + "produces": [ + "application/json" + ], + "tags": [ + "dashboard" + ], + "summary": "Get global group channel dashboard data", + "parameters": [ + { + "type": "string", + "description": "Filter by group", + "name": "group", + "in": "query" + }, + { + "type": "integer", + "description": "Group channel ID", + "name": "group_channel", + "in": "query" + }, + { + "type": "string", + "description": "Model name", + "name": "model", + "in": "query" + }, + { + "type": "integer", + "format": "int64", + "description": "Start second timestamp", + "name": "start_timestamp", + "in": "query" + }, + { + "type": "integer", + "format": "int64", + "description": "End second timestamp", + "name": "end_timestamp", + "in": "query" + }, + { + "type": "string", + "description": "Timezone, default is Local", + "name": "timezone", + "in": "query" + }, + { + "type": "string", + "description": "Time span type (minute, hour, day, month)", + "name": "timespan", + "in": "query" + }, + { + "type": "string", + "description": "Comma-separated list of fields to select", + "name": "fields", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/middleware.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.DashboardResponse" + } + } + } + ] + } + } + } + } + }, "/api/dashboard/{group}": { "get": { "security": [ @@ -1401,33 +1490,32 @@ } } }, - "/api/dashboardv2/{group}": { + "/api/dashboardv2/group_channel": { "get": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Returns model-specific metrics and usage data for the given group", + "description": "Returns group-channel model usage time series across groups, optionally filtered by group", "produces": [ "application/json" ], "tags": [ "dashboard" ], - "summary": "Get model usage data for a specific group", + "summary": "Get global group channel time series data", "parameters": [ { "type": "string", - "description": "Group", + "description": "Filter by group", "name": "group", - "in": "path", - "required": true + "in": "query" }, { - "type": "string", - "description": "Token name", - "name": "token_name", + "type": "integer", + "description": "Group channel ID", + "name": "group_channel", "in": "query" }, { @@ -1464,7 +1552,7 @@ }, { "type": "string", - "description": "Comma-separated list of fields to select (e.g., request_count,exception_count,cache_hit_count). Available: request_count,retry_count,exception_count,status4xx_count,status5xx_count,status400_count,status429_count,status500_count,cache_hit_count,input_tokens,image_input_tokens,audio_input_tokens,video_input_tokens,output_tokens,image_output_tokens,audio_output_tokens,cached_tokens,cache_creation_tokens,total_tokens,web_search_count,used_amount,total_time,total_ttfb. Groups: count,usage,time,all", + "description": "Comma-separated list of fields to select", "name": "fields", "in": "query" } @@ -1494,26 +1582,33 @@ } } }, - "/api/dashboardv3/": { + "/api/dashboardv2/{group}": { "get": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Returns model-specific metrics and usage data for the given channel with detailed amount breakdown", + "description": "Returns model-specific metrics and usage data for the given group", "produces": [ "application/json" ], "tags": [ "dashboard" ], - "summary": "Get model usage data for a specific channel (V3 with detailed amounts)", + "summary": "Get model usage data for a specific group", "parameters": [ { - "type": "integer", - "description": "Channel ID", - "name": "channel", + "type": "string", + "description": "Group", + "name": "group", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Token name", + "name": "token_name", "in": "query" }, { @@ -1567,9 +1662,184 @@ "type": "object", "properties": { "data": { - "$ref": "#/definitions/model.DashboardV3Response" - } - } + "type": "array", + "items": { + "$ref": "#/definitions/model.TimeSummaryDataV2" + } + } + } + } + ] + } + } + } + } + }, + "/api/dashboardv3/": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Returns model-specific metrics and usage data for the given channel with detailed amount breakdown", + "produces": [ + "application/json" + ], + "tags": [ + "dashboard" + ], + "summary": "Get model usage data for a specific channel (V3 with detailed amounts)", + "parameters": [ + { + "type": "integer", + "description": "Channel ID", + "name": "channel", + "in": "query" + }, + { + "type": "string", + "description": "Model name", + "name": "model", + "in": "query" + }, + { + "type": "integer", + "format": "int64", + "description": "Start timestamp", + "name": "start_timestamp", + "in": "query" + }, + { + "type": "integer", + "format": "int64", + "description": "End timestamp", + "name": "end_timestamp", + "in": "query" + }, + { + "type": "string", + "description": "Timezone, default is Local", + "name": "timezone", + "in": "query" + }, + { + "type": "string", + "description": "Time span type (minute, hour, day, month)", + "name": "timespan", + "in": "query" + }, + { + "type": "string", + "description": "Comma-separated list of fields to select (e.g., request_count,exception_count,cache_hit_count). Available: request_count,retry_count,exception_count,status4xx_count,status5xx_count,status400_count,status429_count,status500_count,cache_hit_count,input_tokens,image_input_tokens,audio_input_tokens,video_input_tokens,output_tokens,image_output_tokens,audio_output_tokens,cached_tokens,cache_creation_tokens,total_tokens,web_search_count,used_amount,total_time,total_ttfb. Groups: count,usage,time,all", + "name": "fields", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/middleware.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.DashboardV3Response" + } + } + } + ] + } + } + } + } + }, + "/api/dashboardv3/group_channel": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Returns group-channel dashboard V3 data across groups, optionally filtered by group", + "produces": [ + "application/json" + ], + "tags": [ + "dashboard" + ], + "summary": "Get global group channel dashboard V3 data", + "parameters": [ + { + "type": "string", + "description": "Filter by group", + "name": "group", + "in": "query" + }, + { + "type": "integer", + "description": "Group channel ID", + "name": "group_channel", + "in": "query" + }, + { + "type": "string", + "description": "Model name", + "name": "model", + "in": "query" + }, + { + "type": "integer", + "format": "int64", + "description": "Start timestamp", + "name": "start_timestamp", + "in": "query" + }, + { + "type": "integer", + "format": "int64", + "description": "End timestamp", + "name": "end_timestamp", + "in": "query" + }, + { + "type": "string", + "description": "Timezone, default is Local", + "name": "timezone", + "in": "query" + }, + { + "type": "string", + "description": "Time span type (minute, hour, day, month)", + "name": "timespan", + "in": "query" + }, + { + "type": "string", + "description": "Comma-separated list of fields to select", + "name": "fields", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/middleware.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.DashboardV3Response" + } + } } ] } @@ -1922,58 +2192,71 @@ } } }, - "/api/group/{group}/mcp": { + "/api/group/{group}/channel-dashboard": { "get": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Get MCPs by group", + "description": "Returns group-channel usage statistics for a specific group", "produces": [ "application/json" ], "tags": [ - "mcp", - "group" + "dashboard" ], - "summary": "Get MCPs by group", + "summary": "Get group channel dashboard data", "parameters": [ { "type": "string", - "description": "Group ID", + "description": "Group", "name": "group", "in": "path", "required": true }, { "type": "integer", - "description": "Page", - "name": "page", + "description": "Group channel ID", + "name": "group_channel", + "in": "query" + }, + { + "type": "string", + "description": "Model name", + "name": "model", "in": "query" }, { "type": "integer", - "description": "Per Page", - "name": "per_page", + "format": "int64", + "description": "Start second timestamp", + "name": "start_timestamp", + "in": "query" + }, + { + "type": "integer", + "format": "int64", + "description": "End second timestamp", + "name": "end_timestamp", "in": "query" }, { "type": "string", - "description": "MCP ID", - "name": "id", + "description": "Timezone, default is Local", + "name": "timezone", "in": "query" }, { "type": "string", - "description": "hosted or local", - "name": "type", + "description": "Time span type (minute, hour, day, month)", + "name": "timespan", "in": "query" }, { "type": "string", - "description": "Keyword", - "name": "keyword", + "description": "Comma-separated list of fields to select", + "name": "fields", "in": "query" } ], @@ -1989,10 +2272,7 @@ "type": "object", "properties": { "data": { - "type": "array", - "items": { - "$ref": "#/definitions/controller.GroupPublicMCPResponse" - } + "$ref": "#/definitions/model.DashboardResponse" } } } @@ -2002,36 +2282,28 @@ } } }, - "/api/group/{group}/mcp/{id}": { + "/api/group/{group}/channel-dashboard/models": { "get": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Get a specific MCP by its ID", + "description": "Returns group-channel model configs backed by enabled group channels for the given group", "produces": [ "application/json" ], "tags": [ - "mcp", - "group" + "dashboard" ], - "summary": "Get MCP by ID", + "summary": "Get group channel dashboard models", "parameters": [ { "type": "string", - "description": "Group ID", + "description": "Group", "name": "group", "in": "path", "required": true - }, - { - "type": "string", - "description": "MCP ID", - "name": "id", - "in": "path", - "required": true } ], "responses": { @@ -2046,7 +2318,10 @@ "type": "object", "properties": { "data": { - "$ref": "#/definitions/controller.GroupPublicMCPDetailResponse" + "type": "array", + "items": { + "$ref": "#/definitions/controller.GroupModel" + } } } } @@ -2056,50 +2331,90 @@ } } }, - "/api/group/{group}/model_config/{model}": { + "/api/group/{group}/channel-dashboardv2": { "get": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Get group model config", + "description": "Returns group-channel model usage time series for a specific group", "produces": [ "application/json" ], "tags": [ - "group" + "dashboard" ], - "summary": "Get group model config", + "summary": "Get group channel time series data", "parameters": [ { "type": "string", - "description": "Group name", + "description": "Group", "name": "group", "in": "path", "required": true }, + { + "type": "integer", + "description": "Group channel ID", + "name": "group_channel", + "in": "query" + }, { "type": "string", "description": "Model name", "name": "model", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/middleware.APIResponse" + "in": "query" + }, + { + "type": "integer", + "format": "int64", + "description": "Start timestamp", + "name": "start_timestamp", + "in": "query" + }, + { + "type": "integer", + "format": "int64", + "description": "End timestamp", + "name": "end_timestamp", + "in": "query" + }, + { + "type": "string", + "description": "Timezone, default is Local", + "name": "timezone", + "in": "query" + }, + { + "type": "string", + "description": "Time span type (minute, hour, day, month)", + "name": "timespan", + "in": "query" + }, + { + "type": "string", + "description": "Comma-separated list of fields to select", + "name": "fields", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/middleware.APIResponse" }, { "type": "object", "properties": { "data": { - "$ref": "#/definitions/model.GroupModelConfig" + "type": "array", + "items": { + "$ref": "#/definitions/model.TimeSummaryDataV2" + } } } } @@ -2107,164 +2422,179 @@ } } } - }, - "put": { + } + }, + "/api/group/{group}/channel-dashboardv3": { + "get": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Update group model config", - "consumes": [ - "application/json" - ], + "description": "Returns group-channel dashboard V3 data for a specific group", "produces": [ "application/json" ], "tags": [ - "group" + "dashboard" ], - "summary": "Update group model config", + "summary": "Get group channel dashboard V3 data", "parameters": [ { "type": "string", - "description": "Group name", + "description": "Group", "name": "group", "in": "path", "required": true }, + { + "type": "integer", + "description": "Group channel ID", + "name": "group_channel", + "in": "query" + }, { "type": "string", "description": "Model name", "name": "model", - "in": "path", - "required": true + "in": "query" }, { - "description": "Group model config information", - "name": "data", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/controller.SaveGroupModelConfigRequest" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/middleware.APIResponse" - } - } - } - }, - "post": { - "security": [ + "type": "integer", + "format": "int64", + "description": "Start timestamp", + "name": "start_timestamp", + "in": "query" + }, { - "ApiKeyAuth": [] - } - ], - "description": "Save group model config", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "group" - ], - "summary": "Save group model config", - "parameters": [ + "type": "integer", + "format": "int64", + "description": "End timestamp", + "name": "end_timestamp", + "in": "query" + }, { "type": "string", - "description": "Group name", - "name": "group", - "in": "path", - "required": true + "description": "Timezone, default is Local", + "name": "timezone", + "in": "query" }, { - "description": "Group model config information", - "name": "data", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/controller.SaveGroupModelConfigRequest" - } + "type": "string", + "description": "Time span type (minute, hour, day, month)", + "name": "timespan", + "in": "query" + }, + { + "type": "string", + "description": "Comma-separated list of fields to select", + "name": "fields", + "in": "query" } ], "responses": { "200": { "description": "OK", "schema": { - "$ref": "#/definitions/middleware.APIResponse" + "allOf": [ + { + "$ref": "#/definitions/middleware.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.DashboardV3Response" + } + } + } + ] } } } - }, - "delete": { + } + }, + "/api/group/{group}/channel-models/enabled": { + "get": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Delete group model config", + "description": "Returns group channel model configs grouped by set for a group", "produces": [ "application/json" ], "tags": [ - "group" + "group-channel" ], - "summary": "Delete group model config", + "summary": "Get enabled group channel models", "parameters": [ { "type": "string", - "description": "Group name", + "description": "Group ID", "name": "group", "in": "path", "required": true - }, - { - "type": "string", - "description": "Model name", - "name": "model", - "in": "path", - "required": true } ], "responses": { "200": { "description": "OK", "schema": { - "$ref": "#/definitions/middleware.APIResponse" + "allOf": [ + { + "$ref": "#/definitions/middleware.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "$ref": "#/definitions/model.ModelConfig" + } + } + } + } + } + ] } } } } }, - "/api/group/{group}/model_configs/": { + "/api/group/{group}/channel-models/enabled/{set}": { "get": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Get group model configs", + "description": "Returns group channel model configs for a specific set", "produces": [ "application/json" ], "tags": [ - "group" + "group-channel" ], - "summary": "Get group model configs", + "summary": "Get enabled group channel models by set", "parameters": [ { "type": "string", - "description": "Group name", + "description": "Group ID", "name": "group", "in": "path", "required": true + }, + { + "type": "string", + "description": "Models set", + "name": "set", + "in": "path", + "required": true } ], "responses": { @@ -2281,7 +2611,7 @@ "data": { "type": "array", "items": { - "$ref": "#/definitions/model.GroupModelConfig" + "$ref": "#/definitions/model.ModelConfig" } } } @@ -2290,14 +2620,16 @@ } } } - }, - "put": { + } + }, + "/api/group/{group}/channel/": { + "post": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Update group model configs", + "description": "Adds a group channel to a specific group", "consumes": [ "application/json" ], @@ -2305,27 +2637,24 @@ "application/json" ], "tags": [ - "group" + "group-channel" ], - "summary": "Update group model configs", + "summary": "Add a group channel", "parameters": [ { "type": "string", - "description": "Group name", + "description": "Group ID", "name": "group", "in": "path", "required": true }, { - "description": "Group model config information", - "name": "data", + "description": "Group channel information", + "name": "channel", "in": "body", "required": true, "schema": { - "type": "array", - "items": { - "$ref": "#/definitions/controller.SaveGroupModelConfigRequest" - } + "$ref": "#/definitions/controller.AddGroupChannelRequest" } } ], @@ -2337,14 +2666,16 @@ } } } - }, + } + }, + "/api/group/{group}/channel/test-preview": { "post": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Save group model configs", + "description": "Test a single model in a group channel without saving to database", "consumes": [ "application/json" ], @@ -2352,27 +2683,30 @@ "application/json" ], "tags": [ - "group" + "group-channel" ], - "summary": "Save group model configs", + "summary": "Test group channel preview", "parameters": [ { "type": "string", - "description": "Group name", + "description": "Group ID", "name": "group", "in": "path", "required": true }, { - "description": "Group model config information", - "name": "data", + "type": "boolean", + "description": "Success body", + "name": "success_body", + "in": "query" + }, + { + "description": "Group channel test request", + "name": "request", "in": "body", "required": true, "schema": { - "type": "array", - "items": { - "$ref": "#/definitions/controller.SaveGroupModelConfigRequest" - } + "$ref": "#/definitions/controller.TestSingleGroupChannelRequest" } } ], @@ -2380,43 +2714,75 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/middleware.APIResponse" + "allOf": [ + { + "$ref": "#/definitions/middleware.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.GroupChannelTest" + } + } + } + ] } } } - }, - "delete": { + } + }, + "/api/group/{group}/channel/test-preview-all": { + "post": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Delete group model configs", + "description": "Test all models in a group channel without saving to database", + "consumes": [ + "application/json" + ], "produces": [ "application/json" ], "tags": [ - "group" + "group-channel" ], - "summary": "Delete group model configs", + "summary": "Test group channel preview models", "parameters": [ { "type": "string", - "description": "Group name", + "description": "Group ID", "name": "group", "in": "path", "required": true }, { - "description": "Model names", - "name": "models", + "type": "boolean", + "description": "Return success", + "name": "return_success", + "in": "query" + }, + { + "type": "boolean", + "description": "Success body", + "name": "success_body", + "in": "query" + }, + { + "type": "boolean", + "description": "Stream mode (SSE)", + "name": "stream", + "in": "query" + }, + { + "description": "Group channel test request", + "name": "request", "in": "body", "required": true, "schema": { - "type": "array", - "items": { - "type": "string" - } + "$ref": "#/definitions/controller.TestGroupChannelRequest" } } ], @@ -2424,66 +2790,86 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/middleware.APIResponse" + "allOf": [ + { + "$ref": "#/definitions/middleware.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/controller.GroupChannelTestResult" + } + } + } + } + ] } } } } }, - "/api/group/{group}/rpm_ratio": { - "post": { + "/api/group/{group}/channel/{id}": { + "get": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Updates the RPM (Requests Per Minute) ratio for a group", - "consumes": [ - "application/json" - ], + "description": "Returns detailed information about a group channel in a specific group", "produces": [ "application/json" ], "tags": [ - "group" + "group-channel" ], - "summary": "Update group RPM ratio", + "summary": "Get a group channel by ID", "parameters": [ { "type": "string", - "description": "Group name", + "description": "Group ID", "name": "group", "in": "path", "required": true }, { - "description": "RPM ratio information", - "name": "data", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/controller.UpdateGroupRPMRatioRequest" - } + "type": "integer", + "description": "Group channel ID", + "name": "id", + "in": "path", + "required": true } ], "responses": { "200": { "description": "OK", "schema": { - "$ref": "#/definitions/middleware.APIResponse" + "allOf": [ + { + "$ref": "#/definitions/middleware.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.GroupChannel" + } + } + } + ] } } } - } - }, - "/api/group/{group}/status": { - "post": { + }, + "put": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Updates the status of a group", + "description": "Updates a group channel by ID in a specific group", "consumes": [ "application/json" ], @@ -2491,26 +2877,84 @@ "application/json" ], "tags": [ - "group" + "group-channel" ], - "summary": "Update group status", + "summary": "Update a group channel", "parameters": [ { "type": "string", - "description": "Group name", + "description": "Group ID", "name": "group", "in": "path", "required": true }, { - "description": "Status information", - "name": "status", + "type": "integer", + "description": "Group channel ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Updated group channel information", + "name": "channel", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/controller.UpdateGroupStatusRequest" + "$ref": "#/definitions/controller.AddGroupChannelRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/middleware.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.GroupChannel" + } + } + } + ] } } + } + }, + "delete": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Deletes a group channel by ID in a specific group", + "produces": [ + "application/json" + ], + "tags": [ + "group-channel" + ], + "summary": "Delete a group channel", + "parameters": [ + { + "type": "string", + "description": "Group ID", + "name": "group", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "Group channel ID", + "name": "id", + "in": "path", + "required": true + } ], "responses": { "200": { @@ -2522,14 +2966,14 @@ } } }, - "/api/group/{group}/tpm_ratio": { + "/api/group/{group}/channel/{id}/status": { "post": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Updates the TPM (Tokens Per Minute) ratio for a group", + "description": "Updates the status of a group channel by ID in a specific group", "consumes": [ "application/json" ], @@ -2537,24 +2981,31 @@ "application/json" ], "tags": [ - "group" + "group-channel" ], - "summary": "Update group TPM ratio", + "summary": "Update group channel status", "parameters": [ { "type": "string", - "description": "Group name", + "description": "Group ID", "name": "group", "in": "path", "required": true }, { - "description": "TPM ratio information", - "name": "data", + "type": "integer", + "description": "Group channel ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Status information", + "name": "status", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/controller.UpdateGroupTPMRatioRequest" + "$ref": "#/definitions/controller.UpdateChannelStatusRequest" } } ], @@ -2568,32 +3019,52 @@ } } }, - "/api/groups/": { + "/api/group/{group}/channel/{id}/test": { "get": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Returns a list of all groups with pagination", + "description": "Tests all models in the group channel", "produces": [ "application/json" ], "tags": [ - "groups" + "group-channel" ], - "summary": "Get all groups", + "summary": "Test group channel models", "parameters": [ + { + "type": "string", + "description": "Group ID", + "name": "group", + "in": "path", + "required": true + }, { "type": "integer", - "description": "Page number", - "name": "page", + "description": "Group Channel ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "boolean", + "description": "Return success", + "name": "return_success", "in": "query" }, { - "type": "integer", - "description": "Items per page", - "name": "per_page", + "type": "boolean", + "description": "Success body", + "name": "success_body", + "in": "query" + }, + { + "type": "boolean", + "description": "Stream", + "name": "stream", "in": "query" } ], @@ -2609,25 +3080,9 @@ "type": "object", "properties": { "data": { - "type": "object", - "additionalProperties": { - "allOf": [ - {}, - { - "type": "object", - "properties": { - "groups": { - "type": "array", - "items": { - "$ref": "#/definitions/controller.GroupResponse" - } - }, - "total": { - "type": "integer" - } - } - } - ] + "type": "array", + "items": { + "$ref": "#/definitions/controller.GroupChannelTestResult" } } } @@ -2638,146 +3093,47 @@ } } }, - "/api/groups/batch_delete": { - "post": { + "/api/group/{group}/channel/{id}/test/{model}": { + "get": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Deletes multiple groups by their IDs", - "consumes": [ - "application/json" - ], + "description": "Tests a single model in the group channel", "produces": [ "application/json" ], "tags": [ - "groups" + "group-channel" ], - "summary": "Delete multiple groups", - "parameters": [ - { - "description": "Group IDs", - "name": "ids", - "in": "body", - "required": true, - "schema": { - "type": "array", - "items": { - "type": "string" - } - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/middleware.APIResponse" - } - } - } - } - }, - "/api/groups/batch_status": { - "post": { - "security": [ - { - "ApiKeyAuth": [] - } - ], - "description": "Updates the status of multiple groups", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "groups" - ], - "summary": "Update multiple groups status", - "parameters": [ - { - "description": "Group IDs and status", - "name": "data", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/controller.UpdateGroupsStatusRequest" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/middleware.APIResponse" - } - } - } - } - }, - "/api/groups/consumption_ranking": { - "get": { - "security": [ - { - "ApiKeyAuth": [] - } - ], - "description": "Returns channel, model, or group consumption ranking aggregated from summary data", - "produces": [ - "application/json" - ], - "tags": [ - "groups" - ], - "summary": "Get consumption ranking", + "summary": "Test group channel model", "parameters": [ { "type": "string", - "default": "group", - "description": "Ranking type: channel, model, group", - "name": "type", - "in": "query" - }, - { - "type": "integer", - "format": "int64", - "description": "Start timestamp", - "name": "start_timestamp", - "in": "query" + "description": "Group ID", + "name": "group", + "in": "path", + "required": true }, { "type": "integer", - "format": "int64", - "description": "End timestamp", - "name": "end_timestamp", - "in": "query" + "description": "Group Channel ID", + "name": "id", + "in": "path", + "required": true }, { "type": "string", - "description": "Timezone, default is Local", - "name": "timezone", - "in": "query" - }, - { - "type": "integer", - "description": "Page number", - "name": "page", - "in": "query" - }, - { - "type": "integer", - "description": "Items per page", - "name": "per_page", - "in": "query" + "description": "Model name", + "name": "model", + "in": "path", + "required": true }, { - "type": "string", - "description": "Order: used_amount_desc, used_amount_asc, request_count_desc, request_count_asc, total_tokens_desc, total_tokens_asc, channel_id_asc, channel_id_desc, model_asc, model_desc, group_id_asc, group_id_desc", - "name": "order", + "type": "boolean", + "description": "Success body", + "name": "success_body", "in": "query" } ], @@ -2793,8 +3149,7 @@ "type": "object", "properties": { "data": { - "type": "object", - "additionalProperties": true + "$ref": "#/definitions/model.GroupChannelTest" } } } @@ -2804,38 +3159,40 @@ } } }, - "/api/groups/ip_groups": { + "/api/group/{group}/channel/{id}/tests": { "get": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Get IP group list", + "description": "Returns persisted test results for a group channel", "produces": [ "application/json" ], "tags": [ - "groups" + "group-channel" ], - "summary": "Get IP group list", + "summary": "Get group channel test results", "parameters": [ { - "type": "integer", - "description": "Threshold", - "name": "threshold", - "in": "query" + "type": "string", + "description": "Group ID", + "name": "group", + "in": "path", + "required": true }, { "type": "integer", - "description": "Start timestamp", - "name": "start_timestamp", - "in": "query" + "description": "Group Channel ID", + "name": "id", + "in": "path", + "required": true }, { - "type": "integer", - "description": "End timestamp", - "name": "end_timestamp", + "type": "boolean", + "description": "Success body", + "name": "success_body", "in": "query" } ], @@ -2851,12 +3208,9 @@ "type": "object", "properties": { "data": { - "type": "object", - "additionalProperties": { - "type": "array", - "items": { - "type": "string" - } + "type": "array", + "items": { + "$ref": "#/definitions/model.GroupChannelTest" } } } @@ -2867,48 +3221,28 @@ } } }, - "/api/groups/ranking": { + "/api/group/{group}/channels/": { "get": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Returns channel, model, or group consumption ranking aggregated from summary data", + "description": "Returns a paginated list of group channels for a specific group", "produces": [ "application/json" ], "tags": [ - "groups" + "group-channel" ], - "summary": "Get consumption ranking", + "summary": "Get group channels with pagination", "parameters": [ { "type": "string", - "default": "group", - "description": "Ranking type: channel, model, group", - "name": "type", - "in": "query" - }, - { - "type": "integer", - "format": "int64", - "description": "Start timestamp", - "name": "start_timestamp", - "in": "query" - }, - { - "type": "integer", - "format": "int64", - "description": "End timestamp", - "name": "end_timestamp", - "in": "query" - }, - { - "type": "string", - "description": "Timezone, default is Local", - "name": "timezone", - "in": "query" + "description": "Group ID", + "name": "group", + "in": "path", + "required": true }, { "type": "integer", @@ -2923,79 +3257,38 @@ "in": "query" }, { - "type": "string", - "description": "Order: used_amount_desc, used_amount_asc, request_count_desc, request_count_asc, total_tokens_desc, total_tokens_asc, channel_id_asc, channel_id_desc, model_asc, model_desc, group_id_asc, group_id_desc", - "name": "order", + "type": "integer", + "description": "Filter by id", + "name": "id", "in": "query" - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/middleware.APIResponse" - }, - { - "type": "object", - "properties": { - "data": { - "type": "object", - "additionalProperties": true - } - } - } - ] - } - } - } - } - }, - "/api/groups/search": { - "get": { - "security": [ - { - "ApiKeyAuth": [] - } - ], - "description": "Search groups with keyword and pagination", - "produces": [ - "application/json" - ], - "tags": [ - "groups" - ], - "summary": "Search groups", - "parameters": [ + }, { "type": "string", - "description": "Search keyword", - "name": "keyword", - "in": "query", - "required": true + "description": "Filter by name", + "name": "name", + "in": "query" }, { - "type": "integer", - "description": "Page number", - "name": "page", + "type": "string", + "description": "Filter by key", + "name": "key", "in": "query" }, { "type": "integer", - "description": "Items per page", - "name": "per_page", + "description": "Filter by channel type", + "name": "channel_type", "in": "query" }, { - "type": "integer", - "description": "Status", - "name": "status", + "type": "string", + "description": "Filter by base URL", + "name": "base_url", "in": "query" }, { "type": "string", - "description": "Order", + "description": "Order by field", "name": "order", "in": "query" } @@ -3019,10 +3312,10 @@ { "type": "object", "properties": { - "groups": { + "channels": { "type": "array", "items": { - "$ref": "#/definitions/controller.GroupResponse" + "$ref": "#/definitions/model.GroupChannel" } }, "total": { @@ -3039,126 +3332,141 @@ } } } - } - }, - "/api/log/{group}": { - "get": { + }, + "post": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Get logs for a specific group", - "produces": [ + "description": "Adds group channels to a specific group", + "consumes": [ + "application/json" + ], + "produces": [ "application/json" ], "tags": [ - "log" + "group-channel" ], - "summary": "Get group logs", + "summary": "Add multiple group channels", "parameters": [ { "type": "string", - "description": "Group name", + "description": "Group ID", "name": "group", "in": "path", "required": true }, { - "type": "integer", - "description": "Page number", - "name": "page", - "in": "query" - }, - { - "type": "integer", - "description": "Items per page", - "name": "per_page", - "in": "query" - }, - { - "type": "integer", - "description": "Start timestamp (milliseconds)", - "name": "start_timestamp", - "in": "query" - }, - { - "type": "integer", - "description": "End timestamp (milliseconds)", - "name": "end_timestamp", - "in": "query" - }, - { - "type": "string", - "description": "Token name", - "name": "token_name", - "in": "query" - }, - { - "type": "string", - "description": "Model name", - "name": "model_name", - "in": "query" - }, - { - "type": "integer", - "description": "Channel ID", - "name": "channel", - "in": "query" - }, - { - "type": "integer", - "description": "Token ID", - "name": "token_id", - "in": "query" - }, - { - "type": "string", - "description": "Order", - "name": "order", - "in": "query" - }, - { - "type": "string", - "description": "Request ID", - "name": "request_id", - "in": "query" - }, + "description": "Group channel information", + "name": "channels", + "in": "body", + "required": true, + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/controller.AddGroupChannelRequest" + } + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/middleware.APIResponse" + } + } + } + } + }, + "/api/group/{group}/channels/batch_delete": { + "post": { + "security": [ { - "type": "string", - "description": "Upstream ID", - "name": "upstream_id", - "in": "query" - }, + "ApiKeyAuth": [] + } + ], + "description": "Deletes group channels by IDs in a specific group", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "group-channel" + ], + "summary": "Delete multiple group channels", + "parameters": [ { "type": "string", - "description": "Status code type", - "name": "code_type", - "in": "query" + "description": "Group ID", + "name": "group", + "in": "path", + "required": true }, { - "type": "integer", - "description": "Status code", - "name": "code", - "in": "query" - }, + "description": "Group channel IDs", + "name": "ids", + "in": "body", + "required": true, + "schema": { + "type": "array", + "items": { + "type": "integer" + } + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/middleware.APIResponse" + } + } + } + } + }, + "/api/group/{group}/channels/batch_info": { + "post": { + "security": [ { - "type": "boolean", - "description": "Include request and response detail", - "name": "include_detail", - "in": "query" - }, + "ApiKeyAuth": [] + } + ], + "description": "Returns id, group, name, and type for a batch of group channel IDs filtered by group", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "group-channel" + ], + "summary": "Get basic info for multiple group channels in a group", + "parameters": [ { "type": "string", - "description": "IP", - "name": "ip", - "in": "query" + "description": "Group ID", + "name": "group", + "in": "path", + "required": true }, { - "type": "string", - "description": "User", - "name": "user", - "in": "query" + "description": "Group channel IDs", + "name": "ids", + "in": "body", + "required": true, + "schema": { + "type": "array", + "items": { + "type": "integer" + } + } } ], "responses": { @@ -3173,7 +3481,10 @@ "type": "object", "properties": { "data": { - "$ref": "#/definitions/model.GetGroupLogsResult" + "type": "array", + "items": { + "$ref": "#/definitions/model.GroupChannelBasicInfo" + } } } } @@ -3183,35 +3494,37 @@ } } }, - "/api/log/{group}/detail/{log_id}": { - "get": { + "/api/group/{group}/channels/import/oneapi": { + "post": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Get detailed information about a specific log entry in a group", + "description": "Imports group channels from OneAPI into a group", "produces": [ "application/json" ], "tags": [ - "log" + "group-channel" ], - "summary": "Get group log detail", + "summary": "Import group channel from OneAPI", "parameters": [ { "type": "string", - "description": "Group name", + "description": "Group ID", "name": "group", "in": "path", "required": true }, { - "type": "string", - "description": "Log ID", - "name": "log_id", - "in": "path", - "required": true + "description": "Import group channel from OneAPI request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/controller.ImportChannelFromOneAPIRequest" + } } ], "responses": { @@ -3226,7 +3539,8 @@ "type": "object", "properties": { "data": { - "$ref": "#/definitions/model.RequestDetail" + "type": "array", + "items": {} } } } @@ -3236,258 +3550,217 @@ } } }, - "/api/log/{group}/export": { + "/api/group/{group}/channels/search": { "get": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Streams filtered group logs as a CSV table file", + "description": "Search group channels for a specific group with keyword and optional filters", "produces": [ - "text/csv" + "application/json" ], "tags": [ - "log" + "group-channel" ], - "summary": "Export group logs", + "summary": "Search group channels", "parameters": [ { "type": "string", - "description": "Group name", + "description": "Group ID", "name": "group", "in": "path", "required": true }, { - "type": "integer", - "description": "Start timestamp, max span 30 days", - "name": "start_timestamp", + "type": "string", + "description": "Search keyword", + "name": "keyword", "in": "query" }, { "type": "integer", - "description": "End timestamp, max span 30 days", - "name": "end_timestamp", + "description": "Page number", + "name": "page", "in": "query" }, { - "type": "string", - "description": "Model name", - "name": "model_name", + "type": "integer", + "description": "Items per page", + "name": "per_page", "in": "query" }, { "type": "integer", - "description": "Token ID", - "name": "token_id", + "description": "Filter by id", + "name": "id", "in": "query" }, { "type": "string", - "description": "Token name", - "name": "token_name", + "description": "Filter by name", + "name": "name", "in": "query" }, { "type": "string", - "description": "Sort order for created_at, supports desc or asc", - "name": "order", + "description": "Filter by key", + "name": "key", "in": "query" }, { - "type": "string", - "description": "Request ID", - "name": "request_id", + "type": "integer", + "description": "Filter by channel type", + "name": "channel_type", "in": "query" }, { "type": "string", - "description": "Upstream ID", - "name": "upstream_id", + "description": "Filter by base URL", + "name": "base_url", "in": "query" }, { "type": "string", - "description": "Status code type", - "name": "code_type", + "description": "Order by field", + "name": "order", "in": "query" - }, - { - "type": "integer", - "description": "Status code", - "name": "code", - "in": "query" - }, - { - "type": "boolean", - "description": "Include request and response detail, default false", - "name": "include_detail", - "in": "query" - }, - { - "type": "string", - "description": "IP", - "name": "ip", - "in": "query" - }, - { - "type": "string", - "description": "User", - "name": "user", - "in": "query" - }, - { - "type": "string", - "description": "Timezone, default is Local", - "name": "timezone", - "in": "query" - }, - { - "type": "integer", - "description": "Maximum exported rows; zero or negative means unlimited", - "name": "max_entries", - "in": "query" - }, - { - "type": "boolean", - "description": "Include channel column, default false", - "name": "include_channel", - "in": "query" - }, - { - "type": "boolean", - "description": "Include retry_at column, default false", - "name": "include_retry_at", - "in": "query" - }, + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/middleware.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "type": "object", + "additionalProperties": { + "allOf": [ + {}, + { + "type": "object", + "properties": { + "channels": { + "type": "array", + "items": { + "$ref": "#/definitions/model.GroupChannel" + } + }, + "total": { + "type": "integer" + } + } + } + ] + } + } + } + } + ] + } + } + } + } + }, + "/api/group/{group}/channels/type_metas": { + "get": { + "security": [ { - "type": "string", - "description": "Chunk interval, default 30m, min 10m, max 4h, e.g. 10m, 30m, 1h", - "name": "chunk_interval", - "in": "query" + "ApiKeyAuth": [] } ], - "responses": {} + "description": "Returns metadata for all channel types", + "produces": [ + "application/json" + ], + "tags": [ + "channels" + ], + "summary": "Get channel type metadata", + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/middleware.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/adaptors.AdaptorMeta" + } + } + } + } + ] + } + } + } } }, - "/api/log/{group}/search": { + "/api/group/{group}/mcp/": { "get": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Search logs for a specific group with filters", + "description": "Get MCPs by group", "produces": [ "application/json" ], "tags": [ - "log" + "mcp", + "group" ], - "summary": "Search group logs", + "summary": "Get MCPs by group", "parameters": [ { "type": "string", - "description": "Group name", + "description": "Group ID", "name": "group", "in": "path", "required": true }, - { - "type": "string", - "description": "Keyword", - "name": "keyword", - "in": "query" - }, { "type": "integer", - "description": "Page number", + "description": "Page", "name": "page", "in": "query" }, { "type": "integer", - "description": "Items per page", + "description": "Per Page", "name": "per_page", "in": "query" }, - { - "type": "integer", - "description": "Start timestamp (milliseconds)", - "name": "start_timestamp", - "in": "query" - }, - { - "type": "integer", - "description": "End timestamp (milliseconds)", - "name": "end_timestamp", - "in": "query" - }, - { - "type": "string", - "description": "Filter by token name", - "name": "token_name", - "in": "query" - }, - { - "type": "string", - "description": "Filter by model name", - "name": "model_name", - "in": "query" - }, - { - "type": "integer", - "description": "Filter by token id", - "name": "token_id", - "in": "query" - }, - { - "type": "string", - "description": "Order", - "name": "order", - "in": "query" - }, - { - "type": "string", - "description": "Request ID", - "name": "request_id", - "in": "query" - }, - { - "type": "string", - "description": "Upstream ID", - "name": "upstream_id", - "in": "query" - }, { "type": "string", - "description": "Status code type", - "name": "code_type", - "in": "query" - }, - { - "type": "integer", - "description": "Status code", - "name": "code", - "in": "query" - }, - { - "type": "boolean", - "description": "Include request and response detail", - "name": "include_detail", + "description": "MCP ID", + "name": "id", "in": "query" }, { "type": "string", - "description": "IP", - "name": "ip", + "description": "hosted or local", + "name": "type", "in": "query" }, { "type": "string", - "description": "User", - "name": "user", + "description": "Keyword", + "name": "keyword", "in": "query" } ], @@ -3503,7 +3776,10 @@ "type": "object", "properties": { "data": { - "$ref": "#/definitions/model.GetGroupLogsResult" + "type": "array", + "items": { + "$ref": "#/definitions/controller.GroupPublicMCPResponse" + } } } } @@ -3513,105 +3789,36 @@ } } }, - "/api/logs/": { + "/api/group/{group}/mcp/{id}": { "get": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Returns a paginated list of all logs with optional filters", + "description": "Get a specific MCP by its ID", "produces": [ "application/json" ], "tags": [ - "logs" + "mcp", + "group" ], - "summary": "Get all logs", + "summary": "Get MCP by ID", "parameters": [ { - "type": "integer", - "description": "Page number", - "name": "page", - "in": "query" - }, - { - "type": "integer", - "description": "Items per page", - "name": "per_page", - "in": "query" - }, - { - "type": "integer", - "description": "Start timestamp (milliseconds)", - "name": "start_timestamp", - "in": "query" - }, - { - "type": "integer", - "description": "End timestamp (milliseconds)", - "name": "end_timestamp", - "in": "query" - }, - { - "type": "string", - "description": "Model name", - "name": "model_name", - "in": "query" - }, - { - "type": "integer", - "description": "Channel ID", - "name": "channel", - "in": "query" - }, - { - "type": "string", - "description": "Order", - "name": "order", - "in": "query" - }, - { - "type": "string", - "description": "Request ID", - "name": "request_id", - "in": "query" - }, - { - "type": "string", - "description": "Upstream ID", - "name": "upstream_id", - "in": "query" - }, - { - "type": "string", - "description": "Status code type", - "name": "code_type", - "in": "query" - }, - { - "type": "integer", - "description": "Status code", - "name": "code", - "in": "query" - }, - { - "type": "boolean", - "description": "Include request and response detail", - "name": "include_detail", - "in": "query" - }, - { - "type": "string", - "description": "IP", - "name": "ip", - "in": "query" + "type": "string", + "description": "Group ID", + "name": "group", + "in": "path", + "required": true }, { "type": "string", - "description": "User", - "name": "user", - "in": "query" + "description": "MCP ID", + "name": "id", + "in": "path", + "required": true } ], "responses": { @@ -3626,7 +3833,7 @@ "type": "object", "properties": { "data": { - "$ref": "#/definitions/model.GetLogsResult" + "$ref": "#/definitions/controller.GroupPublicMCPDetailResponse" } } } @@ -3634,27 +3841,36 @@ } } } - }, - "delete": { + } + }, + "/api/group/{group}/model_config/{model}": { + "get": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Deletes logs older than the specified retention period", + "description": "Get group model config", "produces": [ "application/json" ], "tags": [ - "logs" + "group" ], - "summary": "Delete historical logs", + "summary": "Get group model config", "parameters": [ { - "type": "integer", - "description": "Timestamp (milliseconds)", - "name": "timestamp", - "in": "query", + "type": "string", + "description": "Group name", + "name": "group", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Model name", + "name": "model", + "in": "path", "required": true } ], @@ -3670,7 +3886,7 @@ "type": "object", "properties": { "data": { - "type": "integer" + "$ref": "#/definitions/model.GroupModelConfig" } } } @@ -3678,418 +3894,164 @@ } } } - } - }, - "/api/logs/consume_error": { - "get": { + }, + "put": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Search for logs with consumption errors", + "description": "Update group model config", + "consumes": [ + "application/json" + ], "produces": [ "application/json" ], "tags": [ - "logs" + "group" ], - "summary": "Search consumption errors", + "summary": "Update group model config", "parameters": [ - { - "type": "integer", - "description": "Page number", - "name": "page", - "in": "query" - }, - { - "type": "integer", - "description": "Items per page", - "name": "per_page", - "in": "query" - }, - { - "type": "integer", - "description": "Start timestamp (milliseconds)", - "name": "start_timestamp", - "in": "query" - }, - { - "type": "integer", - "description": "End timestamp (milliseconds)", - "name": "end_timestamp", - "in": "query" - }, - { - "type": "string", - "description": "Keyword", - "name": "keyword", - "in": "query" - }, { "type": "string", - "description": "Group", + "description": "Group name", "name": "group", - "in": "query" - }, - { - "type": "string", - "description": "Token name", - "name": "token_name", - "in": "query" + "in": "path", + "required": true }, { "type": "string", "description": "Model name", - "name": "model_name", - "in": "query" - }, - { - "type": "string", - "description": "Content", - "name": "content", - "in": "query" - }, - { - "type": "integer", - "description": "Token ID", - "name": "token_id", - "in": "query" - }, - { - "type": "string", - "description": "Order", - "name": "order", - "in": "query" + "name": "model", + "in": "path", + "required": true }, { - "type": "string", - "description": "Request ID", - "name": "request_id", - "in": "query" + "description": "Group model config information", + "name": "data", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/controller.SaveGroupModelConfigRequest" + } } ], "responses": { "200": { "description": "OK", "schema": { - "allOf": [ - { - "$ref": "#/definitions/middleware.APIResponse" - }, - { - "type": "object", - "properties": { - "data": { - "type": "object", - "additionalProperties": { - "allOf": [ - {}, - { - "type": "object", - "properties": { - "logs": { - "type": "array", - "items": { - "$ref": "#/definitions/model.RequestDetail" - } - }, - "total": { - "type": "integer" - } - } - } - ] - } - } - } - } - ] + "$ref": "#/definitions/middleware.APIResponse" } } } - } - }, - "/api/logs/detail/{log_id}": { - "get": { + }, + "post": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Get detailed information about a specific log entry", + "description": "Save group model config", + "consumes": [ + "application/json" + ], "produces": [ "application/json" ], "tags": [ - "logs" + "group" ], - "summary": "Get log detail", + "summary": "Save group model config", "parameters": [ { "type": "string", - "description": "Log ID", - "name": "log_id", + "description": "Group name", + "name": "group", "in": "path", "required": true + }, + { + "description": "Group model config information", + "name": "data", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/controller.SaveGroupModelConfigRequest" + } } ], "responses": { "200": { "description": "OK", "schema": { - "allOf": [ - { - "$ref": "#/definitions/middleware.APIResponse" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/model.RequestDetail" - } - } - } - ] + "$ref": "#/definitions/middleware.APIResponse" } } } - } - }, - "/api/logs/export": { - "get": { + }, + "delete": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Streams filtered global logs as a CSV table file", + "description": "Delete group model config", "produces": [ - "text/csv" + "application/json" ], "tags": [ - "logs" + "group" ], - "summary": "Export global logs", + "summary": "Delete group model config", "parameters": [ - { - "type": "integer", - "description": "Start timestamp, max span 30 days", - "name": "start_timestamp", - "in": "query" - }, - { - "type": "integer", - "description": "End timestamp, max span 30 days", - "name": "end_timestamp", - "in": "query" - }, - { - "type": "string", - "description": "Model name", - "name": "model_name", - "in": "query" - }, - { - "type": "integer", - "description": "Channel ID", - "name": "channel", - "in": "query" - }, - { - "type": "string", - "description": "Sort order for created_at, supports desc or asc", - "name": "order", - "in": "query" - }, - { - "type": "string", - "description": "Request ID", - "name": "request_id", - "in": "query" - }, - { - "type": "string", - "description": "Upstream ID", - "name": "upstream_id", - "in": "query" - }, - { - "type": "string", - "description": "Status code type", - "name": "code_type", - "in": "query" - }, - { - "type": "integer", - "description": "Status code", - "name": "code", - "in": "query" - }, - { - "type": "boolean", - "description": "Include request and response detail, default false", - "name": "include_detail", - "in": "query" - }, - { - "type": "string", - "description": "IP", - "name": "ip", - "in": "query" - }, - { - "type": "string", - "description": "User", - "name": "user", - "in": "query" - }, { "type": "string", - "description": "Timezone, default is Local", - "name": "timezone", - "in": "query" - }, - { - "type": "integer", - "description": "Maximum exported rows; zero or negative means unlimited", - "name": "max_entries", - "in": "query" + "description": "Group name", + "name": "group", + "in": "path", + "required": true }, { "type": "string", - "description": "Chunk interval, default 30m, min 10m, max 4h, e.g. 10m, 30m, 1h", - "name": "chunk_interval", - "in": "query" + "description": "Model name", + "name": "model", + "in": "path", + "required": true } ], - "responses": {} + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/middleware.APIResponse" + } + } + } } }, - "/api/logs/search": { + "/api/group/{group}/model_configs/": { "get": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Search logs with various filters", + "description": "Get group model configs", "produces": [ "application/json" ], "tags": [ - "logs" + "group" ], - "summary": "Search logs", + "summary": "Get group model configs", "parameters": [ - { - "type": "string", - "description": "Keyword", - "name": "keyword", - "in": "query" - }, - { - "type": "integer", - "description": "Page number", - "name": "page", - "in": "query" - }, - { - "type": "integer", - "description": "Items per page", - "name": "per_page", - "in": "query" - }, - { - "type": "integer", - "description": "Start timestamp (milliseconds)", - "name": "start_timestamp", - "in": "query" - }, - { - "type": "integer", - "description": "End timestamp (milliseconds)", - "name": "end_timestamp", - "in": "query" - }, - { - "type": "string", - "description": "Filter by model name", - "name": "model_name", - "in": "query" - }, - { - "type": "integer", - "description": "Filter by channel", - "name": "channel", - "in": "query" - }, { "type": "string", "description": "Group name", "name": "group", - "in": "query", + "in": "path", "required": true - }, - { - "type": "integer", - "description": "Filter by token id", - "name": "token_id", - "in": "query" - }, - { - "type": "string", - "description": "Filter by token name", - "name": "token_name", - "in": "query" - }, - { - "type": "string", - "description": "Order", - "name": "order", - "in": "query" - }, - { - "type": "string", - "description": "Request ID", - "name": "request_id", - "in": "query" - }, - { - "type": "string", - "description": "Upstream ID", - "name": "upstream_id", - "in": "query" - }, - { - "type": "string", - "description": "Status code type", - "name": "code_type", - "in": "query" - }, - { - "type": "integer", - "description": "Status code", - "name": "code", - "in": "query" - }, - { - "type": "boolean", - "description": "Include request and response detail", - "name": "include_detail", - "in": "query" - }, - { - "type": "string", - "description": "IP", - "name": "ip", - "in": "query" - }, - { - "type": "string", - "description": "User", - "name": "user", - "in": "query" } ], "responses": { @@ -4104,7 +4066,10 @@ "type": "object", "properties": { "data": { - "$ref": "#/definitions/model.GetLogsResult" + "type": "array", + "items": { + "$ref": "#/definitions/model.GroupModelConfig" + } } } } @@ -4112,147 +4077,154 @@ } } } - } - }, - "/api/mcp/group/all": { - "get": { + }, + "put": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Get all Group MCPs with filtering", + "description": "Update group model configs", + "consumes": [ + "application/json" + ], "produces": [ "application/json" ], "tags": [ - "mcp" + "group" ], - "summary": "Get all Group MCPs", + "summary": "Update group model configs", "parameters": [ { - "type": "integer", - "description": "MCP status", - "name": "status", - "in": "query" + "type": "string", + "description": "Group name", + "name": "group", + "in": "path", + "required": true + }, + { + "description": "Group model config information", + "name": "data", + "in": "body", + "required": true, + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/controller.SaveGroupModelConfigRequest" + } + } } ], "responses": { "200": { "description": "OK", "schema": { - "allOf": [ - { - "$ref": "#/definitions/middleware.APIResponse" - }, - { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "$ref": "#/definitions/controller.GroupMCPResponse" - } - } - } - } - ] + "$ref": "#/definitions/middleware.APIResponse" } } } - } - }, - "/api/mcp/group/{group}": { - "get": { + }, + "post": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Get a list of Group MCPs with pagination and filtering", + "description": "Save group model configs", + "consumes": [ + "application/json" + ], "produces": [ "application/json" ], "tags": [ - "mcp" + "group" ], - "summary": "Get Group MCPs", + "summary": "Save group model configs", "parameters": [ { "type": "string", - "description": "Group ID", + "description": "Group name", "name": "group", "in": "path", "required": true }, { - "type": "integer", - "description": "Page number", - "name": "page", - "in": "query" - }, - { - "type": "integer", - "description": "Items per page", - "name": "per_page", - "in": "query" - }, - { - "type": "string", - "description": "MCP id", - "name": "id", - "in": "query" - }, + "description": "Group model config information", + "name": "data", + "in": "body", + "required": true, + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/controller.SaveGroupModelConfigRequest" + } + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/middleware.APIResponse" + } + } + } + }, + "delete": { + "security": [ { - "type": "string", - "description": "MCP type, mcp_proxy_sse, mcp_proxy_streamable, mcp_openapi", - "name": "type", - "in": "query" - }, + "ApiKeyAuth": [] + } + ], + "description": "Delete group model configs", + "produces": [ + "application/json" + ], + "tags": [ + "group" + ], + "summary": "Delete group model configs", + "parameters": [ { "type": "string", - "description": "Search keyword", - "name": "keyword", - "in": "query" + "description": "Group name", + "name": "group", + "in": "path", + "required": true }, { - "type": "integer", - "description": "MCP status", - "name": "status", - "in": "query" + "description": "Model names", + "name": "models", + "in": "body", + "required": true, + "schema": { + "type": "array", + "items": { + "type": "string" + } + } } ], "responses": { "200": { "description": "OK", "schema": { - "allOf": [ - { - "$ref": "#/definitions/middleware.APIResponse" - }, - { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "$ref": "#/definitions/controller.GroupMCPResponse" - } - } - } - } - ] + "$ref": "#/definitions/middleware.APIResponse" } } } - }, + } + }, + "/api/group/{group}/rpm_ratio": { "post": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Create a new Group MCP", + "description": "Updates the RPM (Requests Per Minute) ratio for a group", "consumes": [ "application/json" ], @@ -4260,24 +4232,24 @@ "application/json" ], "tags": [ - "mcp" + "group" ], - "summary": "Create Group MCP", + "summary": "Update group RPM ratio", "parameters": [ { "type": "string", - "description": "Group ID", + "description": "Group name", "name": "group", "in": "path", "required": true }, { - "description": "Group MCP object", - "name": "mcp", + "description": "RPM ratio information", + "name": "data", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/model.GroupMCP" + "$ref": "#/definitions/controller.UpdateGroupRPMRatioRequest" } } ], @@ -4285,51 +4257,39 @@ "200": { "description": "OK", "schema": { - "allOf": [ - { - "$ref": "#/definitions/middleware.APIResponse" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/controller.GroupMCPResponse" - } - } - } - ] + "$ref": "#/definitions/middleware.APIResponse" } } } } }, - "/api/mcp/group/{group}/{id}": { + "/api/group/{group}/scope_model_config/{model}": { "get": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Get a specific Group MCP by its ID and Group ID", + "description": "Returns a group-channel scope model config for a specific group", "produces": [ "application/json" ], "tags": [ - "mcp" + "group-scope-modelconfig" ], - "summary": "Get Group MCP by ID", + "summary": "Get group scope model config", "parameters": [ { "type": "string", - "description": "MCP ID", - "name": "id", + "description": "Group name", + "name": "group", "in": "path", "required": true }, { "type": "string", - "description": "Group ID", - "name": "group", + "description": "Model name", + "name": "model", "in": "path", "required": true } @@ -4346,7 +4306,7 @@ "type": "object", "properties": { "data": { - "$ref": "#/definitions/controller.GroupMCPResponse" + "$ref": "#/definitions/model.GroupScopeModelConfig" } } } @@ -4355,13 +4315,13 @@ } } }, - "put": { + "post": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Update an existing Group MCP", + "description": "Saves a group-channel scope model config for a specific group", "consumes": [ "application/json" ], @@ -4369,31 +4329,31 @@ "application/json" ], "tags": [ - "mcp" + "group-scope-modelconfig" ], - "summary": "Update Group MCP", + "summary": "Save group scope model config", "parameters": [ { "type": "string", - "description": "MCP ID", - "name": "id", + "description": "Group name", + "name": "group", "in": "path", "required": true }, { "type": "string", - "description": "Group ID", - "name": "group", + "description": "Model name", + "name": "model", "in": "path", "required": true }, { - "description": "Group MCP object", - "name": "mcp", + "description": "Model config", + "name": "config", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/model.GroupMCP" + "$ref": "#/definitions/controller.SaveGroupScopeModelConfigsRequest" } } ], @@ -4401,19 +4361,7 @@ "200": { "description": "OK", "schema": { - "allOf": [ - { - "$ref": "#/definitions/middleware.APIResponse" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/controller.GroupMCPResponse" - } - } - } - ] + "$ref": "#/definitions/middleware.APIResponse" } } } @@ -4424,26 +4372,26 @@ "ApiKeyAuth": [] } ], - "description": "Delete a Group MCP by ID and Group ID", + "description": "Deletes a group-channel scope model config for a specific group", "produces": [ "application/json" ], "tags": [ - "mcp" + "group-scope-modelconfig" ], - "summary": "Delete Group MCP", + "summary": "Delete group scope model config", "parameters": [ { "type": "string", - "description": "MCP ID", - "name": "id", + "description": "Group name", + "name": "group", "in": "path", "required": true }, { "type": "string", - "description": "Group ID", - "name": "group", + "description": "Model name", + "name": "model", "in": "path", "required": true } @@ -4458,67 +4406,95 @@ } } }, - "/api/mcp/group/{group}/{id}/status": { - "post": { + "/api/group/{group}/scope_model_configs/": { + "get": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Update the status of a Group MCP", - "consumes": [ - "application/json" - ], + "description": "Returns group-channel scope model configs with pagination for a specific group", "produces": [ "application/json" ], "tags": [ - "mcp" + "group-scope-modelconfig" ], - "summary": "Update Group MCP status", + "summary": "Get group scope model configs", "parameters": [ { "type": "string", - "description": "MCP ID", - "name": "id", + "description": "Group name", + "name": "group", "in": "path", "required": true }, { "type": "string", - "description": "Group ID", - "name": "group", - "in": "path", - "required": true + "description": "Model name", + "name": "model", + "in": "query" }, { - "description": "MCP status", - "name": "status", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/controller.UpdateGroupMCPStatusRequest" - } + "type": "integer", + "description": "Page", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Per page", + "name": "per_page", + "in": "query" } ], "responses": { "200": { "description": "OK", "schema": { - "$ref": "#/definitions/middleware.APIResponse" + "allOf": [ + { + "$ref": "#/definitions/middleware.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "type": "object", + "additionalProperties": { + "allOf": [ + {}, + { + "type": "object", + "properties": { + "configs": { + "type": "array", + "items": { + "$ref": "#/definitions/model.GroupScopeModelConfig" + } + }, + "total": { + "type": "integer" + } + } + } + ] + } + } + } + } + ] } } } - } - }, - "/api/mcp/public/": { + }, "post": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Create a new MCP", + "description": "Saves group-channel scope model configs for a specific group", "consumes": [ "application/json" ], @@ -4526,17 +4502,27 @@ "application/json" ], "tags": [ - "mcp" + "group-scope-modelconfig" ], - "summary": "Create MCP", + "summary": "Save group scope model configs", "parameters": [ { - "description": "MCP object", - "name": "mcp", + "type": "string", + "description": "Group name", + "name": "group", + "in": "path", + "required": true + }, + { + "description": "Model configs", + "name": "configs", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/model.PublicMCP" + "type": "array", + "items": { + "$ref": "#/definitions/controller.SaveGroupScopeModelConfigsRequest" + } } } ], @@ -4544,44 +4530,32 @@ "200": { "description": "OK", "schema": { - "allOf": [ - { - "$ref": "#/definitions/middleware.APIResponse" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/controller.PublicMCPResponse" - } - } - } - ] + "$ref": "#/definitions/middleware.APIResponse" } } } } }, - "/api/mcp/public/{id}": { + "/api/group/{group}/scope_model_configs/all": { "get": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Get a specific MCP by its ID", + "description": "Returns all group-channel scope model configs for a specific group", "produces": [ "application/json" ], "tags": [ - "mcp" + "group-scope-modelconfig" ], - "summary": "Get MCP by ID", + "summary": "Get all group scope model configs", "parameters": [ { "type": "string", - "description": "MCP ID", - "name": "id", + "description": "Group name", + "name": "group", "in": "path", "required": true } @@ -4598,7 +4572,10 @@ "type": "object", "properties": { "data": { - "$ref": "#/definitions/controller.PublicMCPResponse" + "type": "array", + "items": { + "$ref": "#/definitions/model.GroupScopeModelConfig" + } } } } @@ -4606,14 +4583,16 @@ } } } - }, - "put": { + } + }, + "/api/group/{group}/scope_model_configs/batch_delete": { + "post": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Save a MCP", + "description": "Deletes group-channel scope model configs by model names for a specific group", "consumes": [ "application/json" ], @@ -4621,24 +4600,27 @@ "application/json" ], "tags": [ - "mcp" + "group-scope-modelconfig" ], - "summary": "Save MCP", + "summary": "Delete group scope model configs", "parameters": [ { "type": "string", - "description": "MCP ID", - "name": "id", + "description": "Group name", + "name": "group", "in": "path", "required": true }, { - "description": "MCP object", - "name": "mcp", + "description": "Model names", + "name": "models", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/model.PublicMCP" + "type": "array", + "items": { + "type": "string" + } } } ], @@ -4646,30 +4628,20 @@ "200": { "description": "OK", "schema": { - "allOf": [ - { - "$ref": "#/definitions/middleware.APIResponse" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/controller.PublicMCPResponse" - } - } - } - ] + "$ref": "#/definitions/middleware.APIResponse" } } } - }, + } + }, + "/api/group/{group}/scope_model_configs/contains": { "post": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Update an existing MCP", + "description": "Returns group-channel scope model configs for the requested models in a specific group", "consumes": [ "application/json" ], @@ -4677,24 +4649,24 @@ "application/json" ], "tags": [ - "mcp" + "group-scope-modelconfig" ], - "summary": "Update MCP", + "summary": "Get group scope model configs by models", "parameters": [ { "type": "string", - "description": "MCP ID", - "name": "id", + "description": "Group name", + "name": "group", "in": "path", "required": true }, { - "description": "MCP object", - "name": "mcp", + "description": "Models", + "name": "models", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/model.PublicMCP" + "$ref": "#/definitions/controller.GetModelConfigsByModelsContainsRequest" } } ], @@ -4710,7 +4682,10 @@ "type": "object", "properties": { "data": { - "$ref": "#/definitions/controller.PublicMCPResponse" + "type": "array", + "items": { + "$ref": "#/definitions/model.GroupScopeModelConfig" + } } } } @@ -4718,69 +4693,60 @@ } } } - }, - "delete": { - "security": [ - { - "ApiKeyAuth": [] - } - ], - "description": "Delete an MCP by ID", - "produces": [ - "application/json" - ], - "tags": [ - "mcp" - ], - "summary": "Delete MCP", - "parameters": [ - { - "type": "string", - "description": "MCP ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/middleware.APIResponse" - } - } - } } }, - "/api/mcp/public/{id}/group/{group}/params": { + "/api/group/{group}/scope_model_configs/search": { "get": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Get reusing parameters for a specific group and MCP", + "description": "Returns group-channel scope model configs by keyword for a specific group", "produces": [ "application/json" ], "tags": [ - "mcp" + "group-scope-modelconfig" ], - "summary": "Get group MCP reusing parameters", + "summary": "Search group scope model configs", "parameters": [ { "type": "string", - "description": "MCP ID", - "name": "id", + "description": "Group name", + "name": "group", "in": "path", "required": true }, { "type": "string", - "description": "Group ID", - "name": "group", - "in": "path", - "required": true + "description": "Keyword", + "name": "keyword", + "in": "query" + }, + { + "type": "string", + "description": "Model name", + "name": "model", + "in": "query" + }, + { + "type": "string", + "description": "Owner", + "name": "owner", + "in": "query" + }, + { + "type": "integer", + "description": "Page", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Per page", + "name": "per_page", + "in": "query" } ], "responses": { @@ -4795,7 +4761,26 @@ "type": "object", "properties": { "data": { - "$ref": "#/definitions/model.PublicMCPReusingParam" + "type": "object", + "additionalProperties": { + "allOf": [ + {}, + { + "type": "object", + "properties": { + "configs": { + "type": "array", + "items": { + "$ref": "#/definitions/model.GroupScopeModelConfig" + } + }, + "total": { + "type": "integer" + } + } + } + ] + } } } } @@ -4803,14 +4788,16 @@ } } } - }, + } + }, + "/api/group/{group}/status": { "post": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Create or update reusing parameters for a specific group and MCP", + "description": "Updates the status of a group", "consumes": [ "application/json" ], @@ -4818,31 +4805,24 @@ "application/json" ], "tags": [ - "mcp" + "group" ], - "summary": "Create or update group MCP reusing parameters", + "summary": "Update group status", "parameters": [ { "type": "string", - "description": "MCP ID", - "name": "id", + "description": "Group name", + "name": "group", "in": "path", "required": true }, { - "type": "string", - "description": "Group ID", - "name": "group", - "in": "path", - "required": true - }, - { - "description": "Reusing parameters", - "name": "params", + "description": "Status information", + "name": "status", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/model.PublicMCPReusingParam" + "$ref": "#/definitions/controller.UpdateGroupStatusRequest" } } ], @@ -4856,14 +4836,14 @@ } } }, - "/api/mcp/public/{id}/status": { + "/api/group/{group}/tpm_ratio": { "post": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Update the status of an MCP", + "description": "Updates the TPM (Tokens Per Minute) ratio for a group", "consumes": [ "application/json" ], @@ -4871,24 +4851,24 @@ "application/json" ], "tags": [ - "mcp" + "group" ], - "summary": "Update MCP status", + "summary": "Update group TPM ratio", "parameters": [ { "type": "string", - "description": "MCP ID", - "name": "id", + "description": "Group name", + "name": "group", "in": "path", "required": true }, { - "description": "MCP status", - "name": "status", + "description": "TPM ratio information", + "name": "data", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/controller.UpdatePublicMCPStatusRequest" + "$ref": "#/definitions/controller.UpdateGroupTPMRatioRequest" } } ], @@ -4902,57 +4882,78 @@ } } }, - "/api/mcp/publics/": { - "get": { + "/api/group_channel/": { + "post": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Get a list of MCPs with pagination and filtering", + "description": "Adds a group channel from the global management view. The request body must include group_id.", + "consumes": [ + "application/json" + ], "produces": [ "application/json" ], "tags": [ - "mcp" + "group_channel" ], - "summary": "Get MCPs", + "summary": "Add a group channel", "parameters": [ { - "type": "integer", - "description": "Page number", - "name": "page", - "in": "query" - }, - { - "type": "integer", - "description": "Items per page", - "name": "per_page", - "in": "query" - }, - { - "type": "string", - "description": "hosted or local", - "name": "type", - "in": "query" - }, + "description": "Group channel information", + "name": "channel", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/controller.AddGroupChannelRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/middleware.APIResponse" + } + } + } + } + }, + "/api/group_channel/test-preview": { + "post": { + "security": [ { - "type": "string", - "description": "MCP id", - "name": "id", - "in": "query" - }, + "ApiKeyAuth": [] + } + ], + "description": "Test a single model in a group channel without saving to database from the global management view. The request body must include group_id.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "group_channel" + ], + "summary": "Test group channel preview", + "parameters": [ { - "type": "string", - "description": "Search keyword", - "name": "keyword", + "type": "boolean", + "description": "Success body", + "name": "success_body", "in": "query" }, { - "type": "integer", - "description": "MCP status", - "name": "status", - "in": "query" + "description": "Group channel test request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/controller.TestSingleGroupChannelRequest" + } } ], "responses": { @@ -4967,10 +4968,7 @@ "type": "object", "properties": { "data": { - "type": "array", - "items": { - "$ref": "#/definitions/controller.PublicMCPResponse" - } + "$ref": "#/definitions/model.GroupChannelTest" } } } @@ -4978,14 +4976,16 @@ } } } - }, + } + }, + "/api/group_channel/test-preview-all": { "post": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Save a list of MCPs", + "description": "Test all models in a group channel without saving to database from the global management view. The request body must include group_id.", "consumes": [ "application/json" ], @@ -4993,20 +4993,35 @@ "application/json" ], "tags": [ - "mcp" + "group_channel" ], - "summary": "Save MCPs", + "summary": "Test group channel preview models", "parameters": [ { - "description": "MCP object", - "name": "mcp", + "type": "boolean", + "description": "Return success", + "name": "return_success", + "in": "query" + }, + { + "type": "boolean", + "description": "Success body", + "name": "success_body", + "in": "query" + }, + { + "type": "boolean", + "description": "Stream mode (SSE)", + "name": "stream", + "in": "query" + }, + { + "description": "Group channel test request", + "name": "request", "in": "body", "required": true, "schema": { - "type": "array", - "items": { - "$ref": "#/definitions/model.PublicMCP" - } + "$ref": "#/definitions/controller.TestGroupChannelRequest" } } ], @@ -5014,33 +5029,49 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/middleware.APIResponse" + "allOf": [ + { + "$ref": "#/definitions/middleware.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/controller.GroupChannelTestResult" + } + } + } + } + ] } } } } }, - "/api/mcp/publics/all": { + "/api/group_channel/{id}": { "get": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Get all MCPs with filtering", + "description": "Returns detailed information about a group channel across groups", "produces": [ "application/json" ], "tags": [ - "mcp" + "group_channel" ], - "summary": "Get all MCPs", + "summary": "Get a group channel by ID", "parameters": [ { "type": "integer", - "description": "MCP status", - "name": "status", - "in": "query" + "description": "Group channel ID", + "name": "id", + "in": "path", + "required": true } ], "responses": { @@ -5055,10 +5086,7 @@ "type": "object", "properties": { "data": { - "type": "array", - "items": { - "$ref": "#/definitions/controller.PublicMCPResponse" - } + "$ref": "#/definitions/model.GroupChannel" } } } @@ -5066,30 +5094,40 @@ } } } - } - }, - "/api/model_config/{model}": { - "get": { + }, + "put": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Returns a model config", + "description": "Updates a group channel by ID from the global management view", + "consumes": [ + "application/json" + ], "produces": [ "application/json" ], "tags": [ - "modelconfig" + "group_channel" ], - "summary": "Get model config", + "summary": "Update a group channel", "parameters": [ { - "type": "string", - "description": "Model name", - "name": "model", + "type": "integer", + "description": "Group channel ID", + "name": "id", "in": "path", "required": true + }, + { + "description": "Updated group channel information", + "name": "channel", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/controller.AddGroupChannelRequest" + } } ], "responses": { @@ -5104,7 +5142,7 @@ "type": "object", "properties": { "data": { - "$ref": "#/definitions/model.ModelConfig" + "$ref": "#/definitions/model.GroupChannel" } } } @@ -5113,29 +5151,27 @@ } } }, - "post": { + "delete": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Saves a model config", + "description": "Deletes a group channel by ID from the global management view", "produces": [ "application/json" ], "tags": [ - "modelconfig" + "group_channel" ], - "summary": "Save model config", + "summary": "Delete a group channel", "parameters": [ { - "description": "Model config", - "name": "config", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/controller.SaveModelConfigsRequest" - } + "type": "integer", + "description": "Group channel ID", + "name": "id", + "in": "path", + "required": true } ], "responses": { @@ -5146,28 +5182,42 @@ } } } - }, - "delete": { + } + }, + "/api/group_channel/{id}/status": { + "post": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Deletes a model config", + "description": "Updates the status of a group channel by ID from the global management view", + "consumes": [ + "application/json" + ], "produces": [ "application/json" ], "tags": [ - "modelconfig" + "group_channel" ], - "summary": "Delete model config", + "summary": "Update group channel status", "parameters": [ { - "type": "string", - "description": "Model name", - "name": "model", + "type": "integer", + "description": "Group channel ID", + "name": "id", "in": "path", "required": true + }, + { + "description": "Status information", + "name": "status", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/controller.UpdateChannelStatusRequest" + } } ], "responses": { @@ -5180,29 +5230,48 @@ } } }, - "/api/model_configs/": { + "/api/group_channel/{id}/test": { "get": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Returns a list of model configs with pagination", + "description": "Tests all models in a group channel from the global management view", "produces": [ "application/json" ], "tags": [ - "modelconfig" + "group_channel" ], - "summary": "Get model configs", + "summary": "Test group channel models", "parameters": [ { - "type": "string", - "description": "Model name", - "name": "model", + "type": "integer", + "description": "Group Channel ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "boolean", + "description": "Return success", + "name": "return_success", "in": "query" - } - ], + }, + { + "type": "boolean", + "description": "Success body", + "name": "success_body", + "in": "query" + }, + { + "type": "boolean", + "description": "Stream", + "name": "stream", + "in": "query" + } + ], "responses": { "200": { "description": "OK", @@ -5215,25 +5284,9 @@ "type": "object", "properties": { "data": { - "type": "object", - "additionalProperties": { - "allOf": [ - {}, - { - "type": "object", - "properties": { - "configs": { - "type": "array", - "items": { - "$ref": "#/definitions/model.ModelConfig" - } - }, - "total": { - "type": "integer" - } - } - } - ] + "type": "array", + "items": { + "$ref": "#/definitions/controller.GroupChannelTestResult" } } } @@ -5242,60 +5295,45 @@ } } } - }, - "post": { + } + }, + "/api/group_channel/{id}/test/{model}": { + "get": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Saves a list of model configs", + "description": "Tests a single model in a group channel from the global management view", "produces": [ "application/json" ], "tags": [ - "modelconfig" + "group_channel" ], - "summary": "Save model configs", + "summary": "Test group channel model", "parameters": [ { - "description": "Model configs", - "name": "configs", - "in": "body", - "required": true, - "schema": { - "type": "array", - "items": { - "$ref": "#/definitions/controller.SaveModelConfigsRequest" - } - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/middleware.APIResponse" - } - } - } - } - }, - "/api/model_configs/all": { - "get": { - "security": [ + "type": "integer", + "description": "Group Channel ID", + "name": "id", + "in": "path", + "required": true + }, { - "ApiKeyAuth": [] + "type": "string", + "description": "Model name", + "name": "model", + "in": "path", + "required": true + }, + { + "type": "boolean", + "description": "Success body", + "name": "success_body", + "in": "query" } ], - "description": "Returns a list of all model configs", - "produces": [ - "application/json" - ], - "tags": [ - "modelconfig" - ], - "summary": "Get all model configs", "responses": { "200": { "description": "OK", @@ -5308,10 +5346,7 @@ "type": "object", "properties": { "data": { - "type": "array", - "items": { - "$ref": "#/definitions/model.ModelConfig" - } + "$ref": "#/definitions/model.GroupChannelTest" } } } @@ -5321,69 +5356,34 @@ } } }, - "/api/model_configs/batch_delete": { - "post": { + "/api/group_channel/{id}/tests": { + "get": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Deletes a list of model configs", + "description": "Returns persisted test results for a group channel from the global management view", "produces": [ "application/json" ], "tags": [ - "modelconfig" + "group_channel" ], - "summary": "Delete model configs", + "summary": "Get group channel test results", "parameters": [ { - "description": "Model names", - "name": "models", - "in": "body", - "required": true, - "schema": { - "type": "array", - "items": { - "type": "string" - } - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/middleware.APIResponse" - } - } - } - } - }, - "/api/model_configs/contains": { - "post": { - "security": [ - { - "ApiKeyAuth": [] - } - ], - "description": "Returns a list of model configs by models contains", - "produces": [ - "application/json" - ], - "tags": [ - "modelconfig" - ], - "summary": "Get model configs by models contains", - "parameters": [ + "type": "integer", + "description": "Group Channel ID", + "name": "id", + "in": "path", + "required": true + }, { - "description": "Models", - "name": "models", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/controller.GetModelConfigsByModelsContainsRequest" - } + "type": "boolean", + "description": "Success body", + "name": "success_body", + "in": "query" } ], "responses": { @@ -5400,7 +5400,7 @@ "data": { "type": "array", "items": { - "$ref": "#/definitions/model.ModelConfig" + "$ref": "#/definitions/model.GroupChannelTest" } } } @@ -5411,50 +5411,74 @@ } } }, - "/api/model_configs/search": { + "/api/group_channels/": { "get": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Returns a list of model configs by keyword", + "description": "Returns a paginated list of group channels across groups with optional filters", "produces": [ "application/json" ], "tags": [ - "modelconfig" + "group_channels" ], - "summary": "Search model configs", + "summary": "Get group channels with pagination", "parameters": [ { "type": "string", - "description": "Keyword", - "name": "keyword", + "description": "Filter by group", + "name": "group", + "in": "query" + }, + { + "type": "integer", + "description": "Page number", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Items per page", + "name": "per_page", + "in": "query" + }, + { + "type": "integer", + "description": "Filter by id", + "name": "id", "in": "query" }, { "type": "string", - "description": "Model name", - "name": "model", + "description": "Filter by name", + "name": "name", "in": "query" }, { "type": "string", - "description": "Owner", - "name": "owner", + "description": "Filter by key", + "name": "key", "in": "query" }, { "type": "integer", - "description": "Page", - "name": "page", + "description": "Filter by channel type", + "name": "channel_type", "in": "query" }, { - "type": "integer", - "description": "Per page", - "name": "per_page", + "type": "string", + "description": "Filter by base URL", + "name": "base_url", + "in": "query" + }, + { + "type": "string", + "description": "Order by field", + "name": "order", "in": "query" } ], @@ -5477,10 +5501,10 @@ { "type": "object", "properties": { - "configs": { + "channels": { "type": "array", "items": { - "$ref": "#/definitions/model.ModelConfig" + "$ref": "#/definitions/model.GroupChannel" } }, "total": { @@ -5497,158 +5521,120 @@ } } } - } - }, - "/api/models/builtin": { - "get": { + }, + "post": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Returns a list of builtin models", + "description": "Adds group channels from the global management view. Each request item must include group_id.", + "consumes": [ + "application/json" + ], "produces": [ "application/json" ], "tags": [ - "model" + "group_channels" + ], + "summary": "Add multiple group channels", + "parameters": [ + { + "description": "Group channel information", + "name": "channels", + "in": "body", + "required": true, + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/controller.AddGroupChannelRequest" + } + } + } ], - "summary": "Get builtin models", "responses": { "200": { "description": "OK", "schema": { - "allOf": [ - { - "$ref": "#/definitions/middleware.APIResponse" - }, - { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "$ref": "#/definitions/controller.BuiltinModelConfig" - } - } - } - } - ] + "$ref": "#/definitions/middleware.APIResponse" } } } } }, - "/api/models/builtin/channel": { - "get": { + "/api/group_channels/batch_delete": { + "post": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Returns a list of channel builtin models", - "produces": [ + "description": "Deletes group channels by IDs from the global management view", + "consumes": [ + "application/json" + ], + "produces": [ "application/json" ], "tags": [ - "model" + "group_channels" + ], + "summary": "Delete multiple group channels", + "parameters": [ + { + "description": "Group channel IDs", + "name": "ids", + "in": "body", + "required": true, + "schema": { + "type": "array", + "items": { + "type": "integer" + } + } + } ], - "summary": "Get channel builtin models", "responses": { "200": { "description": "OK", "schema": { - "allOf": [ - { - "$ref": "#/definitions/middleware.APIResponse" - }, - { - "type": "object", - "properties": { - "data": { - "type": "object", - "additionalProperties": { - "type": "array", - "items": { - "$ref": "#/definitions/controller.BuiltinModelConfig" - } - } - } - } - } - ] + "$ref": "#/definitions/middleware.APIResponse" } } } } }, - "/api/models/builtin/channel/{type}": { - "get": { + "/api/group_channels/batch_info": { + "post": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Returns a list of channel builtin models by type", + "description": "Returns id, group, name, and type for a batch of group channel IDs across groups", + "consumes": [ + "application/json" + ], "produces": [ "application/json" ], "tags": [ - "model" + "group_channels" ], - "summary": "Get channel builtin models by type", + "summary": "Get basic info for multiple group channels", "parameters": [ { - "enum": [ - 1, - 3, - 4, - 12, - 13, - 14, - 15, - 16, - 17, - 18, - 19, - 20, - 23, - 24, - 25, - 26, - 27, - 28, - 29, - 30, - 31, - 32, - 33, - 34, - 35, - 36, - 37, - 40, - 41, - 42, - 43, - 44, - 45, - 46, - 47, - 48, - 49, - 50, - 51, - 52, - 53, - 54, - 55 - ], - "type": "integer", - "description": "Channel type", - "name": "type", - "in": "path", - "required": true + "description": "Group channel IDs", + "name": "ids", + "in": "body", + "required": true, + "schema": { + "type": "array", + "items": { + "type": "integer" + } + } } ], "responses": { @@ -5665,7 +5651,7 @@ "data": { "type": "array", "items": { - "$ref": "#/definitions/controller.BuiltinModelConfig" + "$ref": "#/definitions/model.GroupChannelBasicInfo" } } } @@ -5676,21 +5662,32 @@ } } }, - "/api/models/default": { - "get": { + "/api/group_channels/import/oneapi": { + "post": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Returns a list of channel default models and mapping", + "description": "Imports group channels from OneAPI from the global management view. The request body must include group_id.", "produces": [ "application/json" ], "tags": [ - "model" + "group_channels" + ], + "summary": "Import group channel from OneAPI", + "parameters": [ + { + "description": "Import group channel from OneAPI request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/controller.ImportChannelFromOneAPIRequest" + } + } ], - "summary": "Get channel default models and mapping", "responses": { "200": { "description": "OK", @@ -5703,29 +5700,8 @@ "type": "object", "properties": { "data": { - "type": "object", - "additionalProperties": { - "allOf": [ - {}, - { - "type": "object", - "properties": { - "mapping": { - "type": "object", - "additionalProperties": { - "type": "string" - } - }, - "models": { - "type": "array", - "items": { - "type": "string" - } - } - } - } - ] - } + "type": "array", + "items": {} } } } @@ -5735,28 +5711,81 @@ } } }, - "/api/models/default/{type}": { + "/api/group_channels/search": { "get": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Returns a list of channel default models and mapping by type", + "description": "Search group channels across groups with keyword and optional filters", "produces": [ "application/json" ], "tags": [ - "model" + "group_channels" ], - "summary": "Get channel default models and mapping by type", + "summary": "Search group channels", "parameters": [ { "type": "string", - "description": "Channel type", - "name": "type", - "in": "path", - "required": true + "description": "Filter by group", + "name": "group", + "in": "query" + }, + { + "type": "string", + "description": "Search keyword", + "name": "keyword", + "in": "query" + }, + { + "type": "integer", + "description": "Page number", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Items per page", + "name": "per_page", + "in": "query" + }, + { + "type": "integer", + "description": "Filter by id", + "name": "id", + "in": "query" + }, + { + "type": "string", + "description": "Filter by name", + "name": "name", + "in": "query" + }, + { + "type": "string", + "description": "Filter by key", + "name": "key", + "in": "query" + }, + { + "type": "integer", + "description": "Filter by channel type", + "name": "channel_type", + "in": "query" + }, + { + "type": "string", + "description": "Filter by base URL", + "name": "base_url", + "in": "query" + }, + { + "type": "string", + "description": "Order by field", + "name": "order", + "in": "query" } ], "responses": { @@ -5778,17 +5807,14 @@ { "type": "object", "properties": { - "mapping": { - "type": "object", - "additionalProperties": { - "type": "string" - } - }, - "models": { + "channels": { "type": "array", "items": { - "type": "string" + "$ref": "#/definitions/model.GroupChannel" } + }, + "total": { + "type": "integer" } } } @@ -5803,21 +5829,21 @@ } } }, - "/api/models/enabled": { + "/api/group_channels/type_metas": { "get": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Returns a list of enabled models", + "description": "Returns metadata for all channel types", "produces": [ "application/json" ], "tags": [ - "model" + "channels" ], - "summary": "Get enabled models", + "summary": "Get channel type metadata", "responses": { "200": { "description": "OK", @@ -5832,10 +5858,7 @@ "data": { "type": "object", "additionalProperties": { - "type": "array", - "items": { - "$ref": "#/definitions/model.ModelConfig" - } + "$ref": "#/definitions/adaptors.AdaptorMeta" } } } @@ -5846,70 +5869,35 @@ } } }, - "/api/models/enabled/{set}": { + "/api/groups/": { "get": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Returns a list of enabled models by set", + "description": "Returns a list of all groups with pagination", "produces": [ "application/json" ], "tags": [ - "model" + "groups" ], - "summary": "Get enabled models by set", + "summary": "Get all groups", "parameters": [ { - "type": "string", - "description": "Models set", - "name": "set", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/middleware.APIResponse" - }, - { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "$ref": "#/definitions/model.ModelConfig" - } - } - } - } - ] - } - } - } - } - }, - "/api/models/sets": { - "get": { - "security": [ + "type": "integer", + "description": "Page number", + "name": "page", + "in": "query" + }, { - "ApiKeyAuth": [] + "type": "integer", + "description": "Items per page", + "name": "per_page", + "in": "query" } ], - "description": "Returns a list of enabled models and channels sets", - "produces": [ - "application/json" - ], - "tags": [ - "model" - ], - "summary": "Get enabled models and channels sets", "responses": { "200": { "description": "OK", @@ -5924,13 +5912,23 @@ "data": { "type": "object", "additionalProperties": { - "type": "object", - "additionalProperties": { - "type": "array", - "items": { - "$ref": "#/definitions/controller.EnabledModelChannel" + "allOf": [ + {}, + { + "type": "object", + "properties": { + "groups": { + "type": "array", + "items": { + "$ref": "#/definitions/controller.GroupResponse" + } + }, + "total": { + "type": "integer" + } + } } - } + ] } } } @@ -5941,63 +5939,77 @@ } } }, - "/api/monitor/": { - "get": { + "/api/groups/batch_delete": { + "post": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Returns a list of all channel model error rates", + "description": "Deletes multiple groups by their IDs", + "consumes": [ + "application/json" + ], "produces": [ "application/json" ], "tags": [ - "monitor" + "groups" + ], + "summary": "Delete multiple groups", + "parameters": [ + { + "description": "Group IDs", + "name": "ids", + "in": "body", + "required": true, + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + } ], - "summary": "Get all channel model error rates", "responses": { "200": { "description": "OK", "schema": { - "allOf": [ - { - "$ref": "#/definitions/middleware.APIResponse" - }, - { - "type": "object", - "properties": { - "data": { - "type": "object", - "additionalProperties": { - "type": "object", - "additionalProperties": { - "type": "number", - "format": "float64" - } - } - } - } - } - ] + "$ref": "#/definitions/middleware.APIResponse" } } } - }, - "delete": { + } + }, + "/api/groups/batch_status": { + "post": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Clears all model errors", + "description": "Updates the status of multiple groups", + "consumes": [ + "application/json" + ], "produces": [ "application/json" ], "tags": [ - "monitor" + "groups" + ], + "summary": "Update multiple groups status", + "parameters": [ + { + "description": "Group IDs and status", + "name": "data", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/controller.UpdateGroupsStatusRequest" + } + } ], - "summary": "Clear all model errors", "responses": { "200": { "description": "OK", @@ -6008,21 +6020,68 @@ } } }, - "/api/monitor/banned_channels": { + "/api/groups/consumption_ranking": { "get": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Returns a list of all banned model channels", + "description": "Returns channel, model, or group consumption ranking aggregated from summary data", "produces": [ "application/json" ], "tags": [ - "monitor" + "groups" + ], + "summary": "Get consumption ranking", + "parameters": [ + { + "type": "string", + "default": "group", + "description": "Ranking type: channel, model, group", + "name": "type", + "in": "query" + }, + { + "type": "integer", + "format": "int64", + "description": "Start timestamp", + "name": "start_timestamp", + "in": "query" + }, + { + "type": "integer", + "format": "int64", + "description": "End timestamp", + "name": "end_timestamp", + "in": "query" + }, + { + "type": "string", + "description": "Timezone, default is Local", + "name": "timezone", + "in": "query" + }, + { + "type": "integer", + "description": "Page number", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Items per page", + "name": "per_page", + "in": "query" + }, + { + "type": "string", + "description": "Order: used_amount_desc, used_amount_asc, request_count_desc, request_count_asc, total_tokens_desc, total_tokens_asc, channel_id_asc, channel_id_desc, model_asc, model_desc, group_id_asc, group_id_desc", + "name": "order", + "in": "query" + } ], - "summary": "Get all banned model channels", "responses": { "200": { "description": "OK", @@ -6036,13 +6095,7 @@ "properties": { "data": { "type": "object", - "additionalProperties": { - "type": "array", - "items": { - "type": "integer", - "format": "int64" - } - } + "additionalProperties": true } } } @@ -6052,21 +6105,41 @@ } } }, - "/api/monitor/batch_group_token_metrics": { - "post": { + "/api/groups/ip_groups": { + "get": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Returns runtime rpm/tpm summary metrics for explicit group and token-name pairs", + "description": "Get IP group list", "produces": [ "application/json" ], "tags": [ - "monitor" + "groups" + ], + "summary": "Get IP group list", + "parameters": [ + { + "type": "integer", + "description": "Threshold", + "name": "threshold", + "in": "query" + }, + { + "type": "integer", + "description": "Start timestamp", + "name": "start_timestamp", + "in": "query" + }, + { + "type": "integer", + "description": "End timestamp", + "name": "end_timestamp", + "in": "query" + } ], - "summary": "Batch get runtime metrics for api key rows", "responses": { "200": { "description": "OK", @@ -6079,7 +6152,13 @@ "type": "object", "properties": { "data": { - "$ref": "#/definitions/controller.BatchGroupTokenMetricsResponse" + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "type": "string" + } + } } } } @@ -6089,33 +6168,71 @@ } } }, - "/api/monitor/group_model_metrics/{group}": { + "/api/groups/ranking": { "get": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Returns runtime rpm/tpm metrics grouped by model in a group", + "description": "Returns channel, model, or group consumption ranking aggregated from summary data", "produces": [ "application/json" ], "tags": [ - "monitor" + "groups" ], - "summary": "Get model runtime metrics for a group", + "summary": "Get consumption ranking", "parameters": [ { "type": "string", - "description": "Group ID", - "name": "group", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", + "default": "group", + "description": "Ranking type: channel, model, group", + "name": "type", + "in": "query" + }, + { + "type": "integer", + "format": "int64", + "description": "Start timestamp", + "name": "start_timestamp", + "in": "query" + }, + { + "type": "integer", + "format": "int64", + "description": "End timestamp", + "name": "end_timestamp", + "in": "query" + }, + { + "type": "string", + "description": "Timezone, default is Local", + "name": "timezone", + "in": "query" + }, + { + "type": "integer", + "description": "Page number", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Items per page", + "name": "per_page", + "in": "query" + }, + { + "type": "string", + "description": "Order: used_amount_desc, used_amount_asc, request_count_desc, request_count_asc, total_tokens_desc, total_tokens_asc, channel_id_asc, channel_id_desc, model_asc, model_desc, group_id_asc, group_id_desc", + "name": "order", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", "schema": { "allOf": [ { @@ -6125,7 +6242,8 @@ "type": "object", "properties": { "data": { - "$ref": "#/definitions/controller.GroupModelMetricsResponse" + "type": "object", + "additionalProperties": true } } } @@ -6135,21 +6253,54 @@ } } }, - "/api/monitor/group_summary_metrics": { + "/api/groups/search": { "get": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Returns rpm/tpm summary metrics for one or more groups", + "description": "Search groups with keyword and pagination", "produces": [ "application/json" ], "tags": [ - "monitor" + "groups" + ], + "summary": "Search groups", + "parameters": [ + { + "type": "string", + "description": "Search keyword", + "name": "keyword", + "in": "query", + "required": true + }, + { + "type": "integer", + "description": "Page number", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Items per page", + "name": "per_page", + "in": "query" + }, + { + "type": "integer", + "description": "Status", + "name": "status", + "in": "query" + }, + { + "type": "string", + "description": "Order", + "name": "order", + "in": "query" + } ], - "summary": "Get summary metrics for multiple groups", "responses": { "200": { "description": "OK", @@ -6162,7 +6313,26 @@ "type": "object", "properties": { "data": { - "$ref": "#/definitions/controller.GroupSummaryMetricsResponse" + "type": "object", + "additionalProperties": { + "allOf": [ + {}, + { + "type": "object", + "properties": { + "groups": { + "type": "array", + "items": { + "$ref": "#/definitions/controller.GroupResponse" + } + }, + "total": { + "type": "integer" + } + } + } + ] + } } } } @@ -6172,28 +6342,124 @@ } } }, - "/api/monitor/group_token_metrics/{group}": { + "/api/log/{group}": { "get": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Returns runtime rpm/tpm metrics grouped by token name in a group", + "description": "Get logs for a specific group", "produces": [ "application/json" ], "tags": [ - "monitor" + "log" ], - "summary": "Get token runtime metrics for a group", + "summary": "Get group logs", "parameters": [ { "type": "string", - "description": "Group ID", + "description": "Group name", "name": "group", "in": "path", "required": true + }, + { + "type": "integer", + "description": "Page number", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Items per page", + "name": "per_page", + "in": "query" + }, + { + "type": "integer", + "description": "Start timestamp (milliseconds)", + "name": "start_timestamp", + "in": "query" + }, + { + "type": "integer", + "description": "End timestamp (milliseconds)", + "name": "end_timestamp", + "in": "query" + }, + { + "type": "string", + "description": "Token name", + "name": "token_name", + "in": "query" + }, + { + "type": "string", + "description": "Model name", + "name": "model_name", + "in": "query" + }, + { + "type": "integer", + "description": "Channel ID", + "name": "channel", + "in": "query" + }, + { + "type": "integer", + "description": "Token ID", + "name": "token_id", + "in": "query" + }, + { + "type": "string", + "description": "Order", + "name": "order", + "in": "query" + }, + { + "type": "string", + "description": "Request ID", + "name": "request_id", + "in": "query" + }, + { + "type": "string", + "description": "Upstream ID", + "name": "upstream_id", + "in": "query" + }, + { + "type": "string", + "description": "Status code type", + "name": "code_type", + "in": "query" + }, + { + "type": "integer", + "description": "Status code", + "name": "code", + "in": "query" + }, + { + "type": "boolean", + "description": "Include request and response detail", + "name": "include_detail", + "in": "query" + }, + { + "type": "string", + "description": "IP", + "name": "ip", + "in": "query" + }, + { + "type": "string", + "description": "User", + "name": "user", + "in": "query" } ], "responses": { @@ -6208,7 +6474,7 @@ "type": "object", "properties": { "data": { - "$ref": "#/definitions/controller.GroupTokenMetricsResponse" + "$ref": "#/definitions/model.GetGroupLogsResult" } } } @@ -6218,28 +6484,35 @@ } } }, - "/api/monitor/group_tokenname_model_metrics/{group}": { + "/api/log/{group}/detail/{log_id}": { "get": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Returns runtime rpm/tpm metrics for token name and model combinations in a group", + "description": "Get detailed information about a specific log entry in a group", "produces": [ "application/json" ], "tags": [ - "monitor" + "log" ], - "summary": "Get token-model runtime metrics for a group", + "summary": "Get group log detail", "parameters": [ { "type": "string", - "description": "Group ID", + "description": "Group name", "name": "group", "in": "path", "required": true + }, + { + "type": "string", + "description": "Log ID", + "name": "log_id", + "in": "path", + "required": true } ], "responses": { @@ -6254,7 +6527,7 @@ "type": "object", "properties": { "data": { - "$ref": "#/definitions/controller.GroupTokennameModelMetricsResponse" + "$ref": "#/definitions/model.RequestDetail" } } } @@ -6264,37 +6537,4308 @@ } } }, - "/api/monitor/models": { + "/api/log/{group}/export": { "get": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Returns a list of models error rate", + "description": "Streams filtered group logs as a CSV table file", "produces": [ - "application/json" + "text/csv" ], "tags": [ - "monitor" + "log" ], - "summary": "Get models error rate", - "responses": { - "200": { - "description": "OK", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/middleware.APIResponse" - }, + "summary": "Export group logs", + "parameters": [ + { + "type": "string", + "description": "Group name", + "name": "group", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "Start timestamp, max span 30 days", + "name": "start_timestamp", + "in": "query" + }, + { + "type": "integer", + "description": "End timestamp, max span 30 days", + "name": "end_timestamp", + "in": "query" + }, + { + "type": "string", + "description": "Model name", + "name": "model_name", + "in": "query" + }, + { + "type": "integer", + "description": "Token ID", + "name": "token_id", + "in": "query" + }, + { + "type": "string", + "description": "Token name", + "name": "token_name", + "in": "query" + }, + { + "type": "string", + "description": "Sort order for created_at, supports desc or asc", + "name": "order", + "in": "query" + }, + { + "type": "string", + "description": "Request ID", + "name": "request_id", + "in": "query" + }, + { + "type": "string", + "description": "Upstream ID", + "name": "upstream_id", + "in": "query" + }, + { + "type": "string", + "description": "Status code type", + "name": "code_type", + "in": "query" + }, + { + "type": "integer", + "description": "Status code", + "name": "code", + "in": "query" + }, + { + "type": "boolean", + "description": "Include request and response detail, default false", + "name": "include_detail", + "in": "query" + }, + { + "type": "string", + "description": "IP", + "name": "ip", + "in": "query" + }, + { + "type": "string", + "description": "User", + "name": "user", + "in": "query" + }, + { + "type": "string", + "description": "Timezone, default is Local", + "name": "timezone", + "in": "query" + }, + { + "type": "integer", + "description": "Maximum exported rows; zero or negative means unlimited", + "name": "max_entries", + "in": "query" + }, + { + "type": "boolean", + "description": "Include channel column, default false", + "name": "include_channel", + "in": "query" + }, + { + "type": "boolean", + "description": "Include retry_at column, default false", + "name": "include_retry_at", + "in": "query" + }, + { + "type": "string", + "description": "Chunk interval, default 30m, min 10m, max 4h, e.g. 10m, 30m, 1h", + "name": "chunk_interval", + "in": "query" + } + ], + "responses": {} + } + }, + "/api/log/{group}/group_channel": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Get group-channel logs for a specific group", + "produces": [ + "application/json" + ], + "tags": [ + "log" + ], + "summary": "Get group channel logs", + "parameters": [ + { + "type": "string", + "description": "Group name", + "name": "group", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "Page number", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Items per page", + "name": "per_page", + "in": "query" + }, + { + "type": "integer", + "description": "Start timestamp (milliseconds)", + "name": "start_timestamp", + "in": "query" + }, + { + "type": "integer", + "description": "End timestamp (milliseconds)", + "name": "end_timestamp", + "in": "query" + }, + { + "type": "string", + "description": "Token name", + "name": "token_name", + "in": "query" + }, + { + "type": "string", + "description": "Model name", + "name": "model_name", + "in": "query" + }, + { + "type": "integer", + "description": "Group channel ID", + "name": "channel", + "in": "query" + }, + { + "type": "integer", + "description": "Token ID", + "name": "token_id", + "in": "query" + }, + { + "type": "string", + "description": "Order", + "name": "order", + "in": "query" + }, + { + "type": "string", + "description": "Request ID", + "name": "request_id", + "in": "query" + }, + { + "type": "string", + "description": "Upstream ID", + "name": "upstream_id", + "in": "query" + }, + { + "type": "string", + "description": "Status code type", + "name": "code_type", + "in": "query" + }, + { + "type": "integer", + "description": "Status code", + "name": "code", + "in": "query" + }, + { + "type": "boolean", + "description": "Include request and response detail", + "name": "include_detail", + "in": "query" + }, + { + "type": "string", + "description": "IP", + "name": "ip", + "in": "query" + }, + { + "type": "string", + "description": "User", + "name": "user", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/middleware.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.GetGroupChannelLogsResult" + } + } + } + ] + } + } + } + }, + "delete": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Deletes group-channel logs older than the specified retention period", + "produces": [ + "application/json" + ], + "tags": [ + "log" + ], + "summary": "Delete historical group channel logs", + "parameters": [ + { + "type": "integer", + "description": "Timestamp (milliseconds)", + "name": "timestamp", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/middleware.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "type": "integer" + } + } + } + ] + } + } + } + } + }, + "/api/log/{group}/group_channel/detail/{log_id}": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Get detailed information about a group channel log entry in a group", + "produces": [ + "application/json" + ], + "tags": [ + "log" + ], + "summary": "Get group channel log detail for a group", + "parameters": [ + { + "type": "string", + "description": "Group name", + "name": "group", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Log ID", + "name": "log_id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/middleware.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.RequestDetail" + } + } + } + ] + } + } + } + } + }, + "/api/log/{group}/group_channel/export": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Streams filtered group-channel logs as a CSV table file", + "produces": [ + "text/csv" + ], + "tags": [ + "log" + ], + "summary": "Export group channel logs", + "parameters": [ + { + "type": "string", + "description": "Group name", + "name": "group", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "Start timestamp, max span 30 days", + "name": "start_timestamp", + "in": "query" + }, + { + "type": "integer", + "description": "End timestamp, max span 30 days", + "name": "end_timestamp", + "in": "query" + }, + { + "type": "string", + "description": "Model name", + "name": "model_name", + "in": "query" + }, + { + "type": "integer", + "description": "Group channel ID", + "name": "channel", + "in": "query" + }, + { + "type": "integer", + "description": "Token ID", + "name": "token_id", + "in": "query" + }, + { + "type": "string", + "description": "Token name", + "name": "token_name", + "in": "query" + }, + { + "type": "string", + "description": "Sort order for created_at, supports desc or asc", + "name": "order", + "in": "query" + }, + { + "type": "string", + "description": "Request ID", + "name": "request_id", + "in": "query" + }, + { + "type": "string", + "description": "Upstream ID", + "name": "upstream_id", + "in": "query" + }, + { + "type": "string", + "description": "Status code type", + "name": "code_type", + "in": "query" + }, + { + "type": "integer", + "description": "Status code", + "name": "code", + "in": "query" + }, + { + "type": "boolean", + "description": "Include request and response detail, default false", + "name": "include_detail", + "in": "query" + }, + { + "type": "string", + "description": "IP", + "name": "ip", + "in": "query" + }, + { + "type": "string", + "description": "User", + "name": "user", + "in": "query" + }, + { + "type": "string", + "description": "Timezone, default is Local", + "name": "timezone", + "in": "query" + }, + { + "type": "integer", + "description": "Maximum exported rows; zero or negative means unlimited", + "name": "max_entries", + "in": "query" + }, + { + "type": "boolean", + "description": "Include retry_at column, default false", + "name": "include_retry_at", + "in": "query" + }, + { + "type": "string", + "description": "Chunk interval, default 30m, min 10m, max 4h, e.g. 10m, 30m, 1h", + "name": "chunk_interval", + "in": "query" + } + ], + "responses": {} + } + }, + "/api/log/{group}/group_channel/search": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Search group-channel logs for a specific group", + "produces": [ + "application/json" + ], + "tags": [ + "log" + ], + "summary": "Search group channel logs", + "parameters": [ + { + "type": "string", + "description": "Group name", + "name": "group", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Keyword", + "name": "keyword", + "in": "query" + }, + { + "type": "integer", + "description": "Page number", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Items per page", + "name": "per_page", + "in": "query" + }, + { + "type": "integer", + "description": "Start timestamp (milliseconds)", + "name": "start_timestamp", + "in": "query" + }, + { + "type": "integer", + "description": "End timestamp (milliseconds)", + "name": "end_timestamp", + "in": "query" + }, + { + "type": "string", + "description": "Filter by token name", + "name": "token_name", + "in": "query" + }, + { + "type": "string", + "description": "Filter by model name", + "name": "model_name", + "in": "query" + }, + { + "type": "integer", + "description": "Filter by token id", + "name": "token_id", + "in": "query" + }, + { + "type": "integer", + "description": "Filter by group channel", + "name": "channel", + "in": "query" + }, + { + "type": "string", + "description": "Order", + "name": "order", + "in": "query" + }, + { + "type": "string", + "description": "Request ID", + "name": "request_id", + "in": "query" + }, + { + "type": "string", + "description": "Upstream ID", + "name": "upstream_id", + "in": "query" + }, + { + "type": "string", + "description": "Status code type", + "name": "code_type", + "in": "query" + }, + { + "type": "integer", + "description": "Status code", + "name": "code", + "in": "query" + }, + { + "type": "boolean", + "description": "Include request and response detail", + "name": "include_detail", + "in": "query" + }, + { + "type": "string", + "description": "IP", + "name": "ip", + "in": "query" + }, + { + "type": "string", + "description": "User", + "name": "user", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/middleware.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.GetGroupChannelLogsResult" + } + } + } + ] + } + } + } + } + }, + "/api/log/{group}/search": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Search logs for a specific group with filters", + "produces": [ + "application/json" + ], + "tags": [ + "log" + ], + "summary": "Search group logs", + "parameters": [ + { + "type": "string", + "description": "Group name", + "name": "group", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Keyword", + "name": "keyword", + "in": "query" + }, + { + "type": "integer", + "description": "Page number", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Items per page", + "name": "per_page", + "in": "query" + }, + { + "type": "integer", + "description": "Start timestamp (milliseconds)", + "name": "start_timestamp", + "in": "query" + }, + { + "type": "integer", + "description": "End timestamp (milliseconds)", + "name": "end_timestamp", + "in": "query" + }, + { + "type": "string", + "description": "Filter by token name", + "name": "token_name", + "in": "query" + }, + { + "type": "string", + "description": "Filter by model name", + "name": "model_name", + "in": "query" + }, + { + "type": "integer", + "description": "Filter by token id", + "name": "token_id", + "in": "query" + }, + { + "type": "string", + "description": "Order", + "name": "order", + "in": "query" + }, + { + "type": "string", + "description": "Request ID", + "name": "request_id", + "in": "query" + }, + { + "type": "string", + "description": "Upstream ID", + "name": "upstream_id", + "in": "query" + }, + { + "type": "string", + "description": "Status code type", + "name": "code_type", + "in": "query" + }, + { + "type": "integer", + "description": "Status code", + "name": "code", + "in": "query" + }, + { + "type": "boolean", + "description": "Include request and response detail", + "name": "include_detail", + "in": "query" + }, + { + "type": "string", + "description": "IP", + "name": "ip", + "in": "query" + }, + { + "type": "string", + "description": "User", + "name": "user", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/middleware.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.GetGroupLogsResult" + } + } + } + ] + } + } + } + } + }, + "/api/logs/": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Returns a paginated list of all logs with optional filters", + "produces": [ + "application/json" + ], + "tags": [ + "logs" + ], + "summary": "Get all logs", + "parameters": [ + { + "type": "integer", + "description": "Page number", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Items per page", + "name": "per_page", + "in": "query" + }, + { + "type": "integer", + "description": "Start timestamp (milliseconds)", + "name": "start_timestamp", + "in": "query" + }, + { + "type": "integer", + "description": "End timestamp (milliseconds)", + "name": "end_timestamp", + "in": "query" + }, + { + "type": "string", + "description": "Model name", + "name": "model_name", + "in": "query" + }, + { + "type": "integer", + "description": "Channel ID", + "name": "channel", + "in": "query" + }, + { + "type": "string", + "description": "Order", + "name": "order", + "in": "query" + }, + { + "type": "string", + "description": "Request ID", + "name": "request_id", + "in": "query" + }, + { + "type": "string", + "description": "Upstream ID", + "name": "upstream_id", + "in": "query" + }, + { + "type": "string", + "description": "Status code type", + "name": "code_type", + "in": "query" + }, + { + "type": "integer", + "description": "Status code", + "name": "code", + "in": "query" + }, + { + "type": "boolean", + "description": "Include request and response detail", + "name": "include_detail", + "in": "query" + }, + { + "type": "string", + "description": "IP", + "name": "ip", + "in": "query" + }, + { + "type": "string", + "description": "User", + "name": "user", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/middleware.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.GetLogsResult" + } + } + } + ] + } + } + } + }, + "delete": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Deletes logs older than the specified retention period", + "produces": [ + "application/json" + ], + "tags": [ + "logs" + ], + "summary": "Delete historical logs", + "parameters": [ + { + "type": "integer", + "description": "Timestamp (milliseconds)", + "name": "timestamp", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/middleware.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "type": "integer" + } + } + } + ] + } + } + } + } + }, + "/api/logs/consume_error": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Search for logs with consumption errors", + "produces": [ + "application/json" + ], + "tags": [ + "logs" + ], + "summary": "Search consumption errors", + "parameters": [ + { + "type": "integer", + "description": "Page number", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Items per page", + "name": "per_page", + "in": "query" + }, + { + "type": "integer", + "description": "Start timestamp (milliseconds)", + "name": "start_timestamp", + "in": "query" + }, + { + "type": "integer", + "description": "End timestamp (milliseconds)", + "name": "end_timestamp", + "in": "query" + }, + { + "type": "string", + "description": "Keyword", + "name": "keyword", + "in": "query" + }, + { + "type": "string", + "description": "Group", + "name": "group", + "in": "query" + }, + { + "type": "string", + "description": "Token name", + "name": "token_name", + "in": "query" + }, + { + "type": "string", + "description": "Model name", + "name": "model_name", + "in": "query" + }, + { + "type": "string", + "description": "Content", + "name": "content", + "in": "query" + }, + { + "type": "integer", + "description": "Token ID", + "name": "token_id", + "in": "query" + }, + { + "type": "string", + "description": "Order", + "name": "order", + "in": "query" + }, + { + "type": "string", + "description": "Request ID", + "name": "request_id", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/middleware.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "type": "object", + "additionalProperties": { + "allOf": [ + {}, + { + "type": "object", + "properties": { + "logs": { + "type": "array", + "items": { + "$ref": "#/definitions/model.RequestDetail" + } + }, + "total": { + "type": "integer" + } + } + } + ] + } + } + } + } + ] + } + } + } + } + }, + "/api/logs/detail/{log_id}": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Get detailed information about a specific log entry", + "produces": [ + "application/json" + ], + "tags": [ + "logs" + ], + "summary": "Get log detail", + "parameters": [ + { + "type": "string", + "description": "Log ID", + "name": "log_id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/middleware.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.RequestDetail" + } + } + } + ] + } + } + } + } + }, + "/api/logs/export": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Streams filtered global logs as a CSV table file", + "produces": [ + "text/csv" + ], + "tags": [ + "logs" + ], + "summary": "Export global logs", + "parameters": [ + { + "type": "integer", + "description": "Start timestamp, max span 30 days", + "name": "start_timestamp", + "in": "query" + }, + { + "type": "integer", + "description": "End timestamp, max span 30 days", + "name": "end_timestamp", + "in": "query" + }, + { + "type": "string", + "description": "Model name", + "name": "model_name", + "in": "query" + }, + { + "type": "integer", + "description": "Channel ID", + "name": "channel", + "in": "query" + }, + { + "type": "string", + "description": "Sort order for created_at, supports desc or asc", + "name": "order", + "in": "query" + }, + { + "type": "string", + "description": "Request ID", + "name": "request_id", + "in": "query" + }, + { + "type": "string", + "description": "Upstream ID", + "name": "upstream_id", + "in": "query" + }, + { + "type": "string", + "description": "Status code type", + "name": "code_type", + "in": "query" + }, + { + "type": "integer", + "description": "Status code", + "name": "code", + "in": "query" + }, + { + "type": "boolean", + "description": "Include request and response detail, default false", + "name": "include_detail", + "in": "query" + }, + { + "type": "string", + "description": "IP", + "name": "ip", + "in": "query" + }, + { + "type": "string", + "description": "User", + "name": "user", + "in": "query" + }, + { + "type": "string", + "description": "Timezone, default is Local", + "name": "timezone", + "in": "query" + }, + { + "type": "integer", + "description": "Maximum exported rows; zero or negative means unlimited", + "name": "max_entries", + "in": "query" + }, + { + "type": "string", + "description": "Chunk interval, default 30m, min 10m, max 4h, e.g. 10m, 30m, 1h", + "name": "chunk_interval", + "in": "query" + } + ], + "responses": {} + } + }, + "/api/logs/group_channel": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Get group-channel logs across groups with optional filters", + "produces": [ + "application/json" + ], + "tags": [ + "logs" + ], + "summary": "Get global group channel logs", + "parameters": [ + { + "type": "string", + "description": "Filter by group", + "name": "group", + "in": "query" + }, + { + "type": "integer", + "description": "Page number", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Items per page", + "name": "per_page", + "in": "query" + }, + { + "type": "integer", + "description": "Start timestamp (milliseconds)", + "name": "start_timestamp", + "in": "query" + }, + { + "type": "integer", + "description": "End timestamp (milliseconds)", + "name": "end_timestamp", + "in": "query" + }, + { + "type": "string", + "description": "Token name", + "name": "token_name", + "in": "query" + }, + { + "type": "string", + "description": "Model name", + "name": "model_name", + "in": "query" + }, + { + "type": "integer", + "description": "Group channel ID", + "name": "channel", + "in": "query" + }, + { + "type": "integer", + "description": "Token ID", + "name": "token_id", + "in": "query" + }, + { + "type": "string", + "description": "Order", + "name": "order", + "in": "query" + }, + { + "type": "string", + "description": "Request ID", + "name": "request_id", + "in": "query" + }, + { + "type": "string", + "description": "Upstream ID", + "name": "upstream_id", + "in": "query" + }, + { + "type": "string", + "description": "Status code type", + "name": "code_type", + "in": "query" + }, + { + "type": "integer", + "description": "Status code", + "name": "code", + "in": "query" + }, + { + "type": "boolean", + "description": "Include request and response detail", + "name": "include_detail", + "in": "query" + }, + { + "type": "string", + "description": "IP", + "name": "ip", + "in": "query" + }, + { + "type": "string", + "description": "User", + "name": "user", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/middleware.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.GetGroupChannelLogsResult" + } + } + } + ] + } + } + } + }, + "delete": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Deletes group-channel logs older than the specified retention period, optionally filtered by group", + "produces": [ + "application/json" + ], + "tags": [ + "logs" + ], + "summary": "Delete historical group channel logs", + "parameters": [ + { + "type": "string", + "description": "Filter by group", + "name": "group", + "in": "query" + }, + { + "type": "integer", + "description": "Timestamp (milliseconds)", + "name": "timestamp", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/middleware.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "type": "integer" + } + } + } + ] + } + } + } + } + }, + "/api/logs/group_channel/detail/{log_id}": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Get detailed information about a group channel log entry across groups", + "produces": [ + "application/json" + ], + "tags": [ + "logs" + ], + "summary": "Get global group channel log detail", + "parameters": [ + { + "type": "string", + "description": "Log ID", + "name": "log_id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/middleware.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.RequestDetail" + } + } + } + ] + } + } + } + } + }, + "/api/logs/group_channel/export": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Streams filtered group-channel logs across groups as a CSV table file", + "produces": [ + "text/csv" + ], + "tags": [ + "logs" + ], + "summary": "Export global group channel logs", + "parameters": [ + { + "type": "string", + "description": "Filter by group", + "name": "group", + "in": "query" + }, + { + "type": "integer", + "description": "Start timestamp, max span 30 days", + "name": "start_timestamp", + "in": "query" + }, + { + "type": "integer", + "description": "End timestamp, max span 30 days", + "name": "end_timestamp", + "in": "query" + }, + { + "type": "string", + "description": "Model name", + "name": "model_name", + "in": "query" + }, + { + "type": "integer", + "description": "Group channel ID", + "name": "channel", + "in": "query" + }, + { + "type": "integer", + "description": "Token ID", + "name": "token_id", + "in": "query" + }, + { + "type": "string", + "description": "Token name", + "name": "token_name", + "in": "query" + }, + { + "type": "string", + "description": "Sort order for created_at, supports desc or asc", + "name": "order", + "in": "query" + }, + { + "type": "string", + "description": "Request ID", + "name": "request_id", + "in": "query" + }, + { + "type": "string", + "description": "Upstream ID", + "name": "upstream_id", + "in": "query" + }, + { + "type": "string", + "description": "Status code type", + "name": "code_type", + "in": "query" + }, + { + "type": "integer", + "description": "Status code", + "name": "code", + "in": "query" + }, + { + "type": "boolean", + "description": "Include request and response detail, default false", + "name": "include_detail", + "in": "query" + }, + { + "type": "string", + "description": "IP", + "name": "ip", + "in": "query" + }, + { + "type": "string", + "description": "User", + "name": "user", + "in": "query" + }, + { + "type": "string", + "description": "Timezone, default is Local", + "name": "timezone", + "in": "query" + }, + { + "type": "integer", + "description": "Maximum exported rows; zero or negative means unlimited", + "name": "max_entries", + "in": "query" + }, + { + "type": "boolean", + "description": "Include retry_at column, default false", + "name": "include_retry_at", + "in": "query" + }, + { + "type": "string", + "description": "Chunk interval, default 30m, min 10m, max 4h, e.g. 10m, 30m, 1h", + "name": "chunk_interval", + "in": "query" + } + ], + "responses": {} + } + }, + "/api/logs/group_channel/search": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Search group-channel logs across groups with optional filters", + "produces": [ + "application/json" + ], + "tags": [ + "logs" + ], + "summary": "Search global group channel logs", + "parameters": [ + { + "type": "string", + "description": "Filter by group", + "name": "group", + "in": "query" + }, + { + "type": "string", + "description": "Keyword", + "name": "keyword", + "in": "query" + }, + { + "type": "integer", + "description": "Page number", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Items per page", + "name": "per_page", + "in": "query" + }, + { + "type": "integer", + "description": "Start timestamp (milliseconds)", + "name": "start_timestamp", + "in": "query" + }, + { + "type": "integer", + "description": "End timestamp (milliseconds)", + "name": "end_timestamp", + "in": "query" + }, + { + "type": "string", + "description": "Filter by token name", + "name": "token_name", + "in": "query" + }, + { + "type": "string", + "description": "Filter by model name", + "name": "model_name", + "in": "query" + }, + { + "type": "integer", + "description": "Filter by token id", + "name": "token_id", + "in": "query" + }, + { + "type": "integer", + "description": "Filter by group channel", + "name": "channel", + "in": "query" + }, + { + "type": "string", + "description": "Order", + "name": "order", + "in": "query" + }, + { + "type": "string", + "description": "Request ID", + "name": "request_id", + "in": "query" + }, + { + "type": "string", + "description": "Upstream ID", + "name": "upstream_id", + "in": "query" + }, + { + "type": "string", + "description": "Status code type", + "name": "code_type", + "in": "query" + }, + { + "type": "integer", + "description": "Status code", + "name": "code", + "in": "query" + }, + { + "type": "boolean", + "description": "Include request and response detail", + "name": "include_detail", + "in": "query" + }, + { + "type": "string", + "description": "IP", + "name": "ip", + "in": "query" + }, + { + "type": "string", + "description": "User", + "name": "user", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/middleware.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.GetGroupChannelLogsResult" + } + } + } + ] + } + } + } + } + }, + "/api/logs/search": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Search logs with various filters", + "produces": [ + "application/json" + ], + "tags": [ + "logs" + ], + "summary": "Search logs", + "parameters": [ + { + "type": "string", + "description": "Keyword", + "name": "keyword", + "in": "query" + }, + { + "type": "integer", + "description": "Page number", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Items per page", + "name": "per_page", + "in": "query" + }, + { + "type": "integer", + "description": "Start timestamp (milliseconds)", + "name": "start_timestamp", + "in": "query" + }, + { + "type": "integer", + "description": "End timestamp (milliseconds)", + "name": "end_timestamp", + "in": "query" + }, + { + "type": "string", + "description": "Filter by model name", + "name": "model_name", + "in": "query" + }, + { + "type": "integer", + "description": "Filter by channel", + "name": "channel", + "in": "query" + }, + { + "type": "string", + "description": "Group name", + "name": "group", + "in": "query", + "required": true + }, + { + "type": "integer", + "description": "Filter by token id", + "name": "token_id", + "in": "query" + }, + { + "type": "string", + "description": "Filter by token name", + "name": "token_name", + "in": "query" + }, + { + "type": "string", + "description": "Order", + "name": "order", + "in": "query" + }, + { + "type": "string", + "description": "Request ID", + "name": "request_id", + "in": "query" + }, + { + "type": "string", + "description": "Upstream ID", + "name": "upstream_id", + "in": "query" + }, + { + "type": "string", + "description": "Status code type", + "name": "code_type", + "in": "query" + }, + { + "type": "integer", + "description": "Status code", + "name": "code", + "in": "query" + }, + { + "type": "boolean", + "description": "Include request and response detail", + "name": "include_detail", + "in": "query" + }, + { + "type": "string", + "description": "IP", + "name": "ip", + "in": "query" + }, + { + "type": "string", + "description": "User", + "name": "user", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/middleware.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.GetLogsResult" + } + } + } + ] + } + } + } + } + }, + "/api/mcp/group/all": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Get all Group MCPs with filtering", + "produces": [ + "application/json" + ], + "tags": [ + "mcp" + ], + "summary": "Get all Group MCPs", + "parameters": [ + { + "type": "integer", + "description": "MCP status", + "name": "status", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/middleware.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/controller.GroupMCPResponse" + } + } + } + } + ] + } + } + } + } + }, + "/api/mcp/group/{group}": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Get a list of Group MCPs with pagination and filtering", + "produces": [ + "application/json" + ], + "tags": [ + "mcp" + ], + "summary": "Get Group MCPs", + "parameters": [ + { + "type": "string", + "description": "Group ID", + "name": "group", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "Page number", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Items per page", + "name": "per_page", + "in": "query" + }, + { + "type": "string", + "description": "MCP id", + "name": "id", + "in": "query" + }, + { + "type": "string", + "description": "MCP type, mcp_proxy_sse, mcp_proxy_streamable, mcp_openapi", + "name": "type", + "in": "query" + }, + { + "type": "string", + "description": "Search keyword", + "name": "keyword", + "in": "query" + }, + { + "type": "integer", + "description": "MCP status", + "name": "status", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/middleware.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/controller.GroupMCPResponse" + } + } + } + } + ] + } + } + } + }, + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Create a new Group MCP", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "mcp" + ], + "summary": "Create Group MCP", + "parameters": [ + { + "type": "string", + "description": "Group ID", + "name": "group", + "in": "path", + "required": true + }, + { + "description": "Group MCP object", + "name": "mcp", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/model.GroupMCP" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/middleware.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/controller.GroupMCPResponse" + } + } + } + ] + } + } + } + } + }, + "/api/mcp/group/{group}/{id}": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Get a specific Group MCP by its ID and Group ID", + "produces": [ + "application/json" + ], + "tags": [ + "mcp" + ], + "summary": "Get Group MCP by ID", + "parameters": [ + { + "type": "string", + "description": "MCP ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Group ID", + "name": "group", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/middleware.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/controller.GroupMCPResponse" + } + } + } + ] + } + } + } + }, + "put": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Update an existing Group MCP", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "mcp" + ], + "summary": "Update Group MCP", + "parameters": [ + { + "type": "string", + "description": "MCP ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Group ID", + "name": "group", + "in": "path", + "required": true + }, + { + "description": "Group MCP object", + "name": "mcp", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/model.GroupMCP" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/middleware.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/controller.GroupMCPResponse" + } + } + } + ] + } + } + } + }, + "delete": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Delete a Group MCP by ID and Group ID", + "produces": [ + "application/json" + ], + "tags": [ + "mcp" + ], + "summary": "Delete Group MCP", + "parameters": [ + { + "type": "string", + "description": "MCP ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Group ID", + "name": "group", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/middleware.APIResponse" + } + } + } + } + }, + "/api/mcp/group/{group}/{id}/status": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Update the status of a Group MCP", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "mcp" + ], + "summary": "Update Group MCP status", + "parameters": [ + { + "type": "string", + "description": "MCP ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Group ID", + "name": "group", + "in": "path", + "required": true + }, + { + "description": "MCP status", + "name": "status", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/controller.UpdateGroupMCPStatusRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/middleware.APIResponse" + } + } + } + } + }, + "/api/mcp/public/": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Create a new MCP", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "mcp" + ], + "summary": "Create MCP", + "parameters": [ + { + "description": "MCP object", + "name": "mcp", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/model.PublicMCP" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/middleware.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/controller.PublicMCPResponse" + } + } + } + ] + } + } + } + } + }, + "/api/mcp/public/{id}": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Get a specific MCP by its ID", + "produces": [ + "application/json" + ], + "tags": [ + "mcp" + ], + "summary": "Get MCP by ID", + "parameters": [ + { + "type": "string", + "description": "MCP ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/middleware.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/controller.PublicMCPResponse" + } + } + } + ] + } + } + } + }, + "put": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Save a MCP", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "mcp" + ], + "summary": "Save MCP", + "parameters": [ + { + "type": "string", + "description": "MCP ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "MCP object", + "name": "mcp", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/model.PublicMCP" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/middleware.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/controller.PublicMCPResponse" + } + } + } + ] + } + } + } + }, + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Update an existing MCP", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "mcp" + ], + "summary": "Update MCP", + "parameters": [ + { + "type": "string", + "description": "MCP ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "MCP object", + "name": "mcp", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/model.PublicMCP" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/middleware.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/controller.PublicMCPResponse" + } + } + } + ] + } + } + } + }, + "delete": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Delete an MCP by ID", + "produces": [ + "application/json" + ], + "tags": [ + "mcp" + ], + "summary": "Delete MCP", + "parameters": [ + { + "type": "string", + "description": "MCP ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/middleware.APIResponse" + } + } + } + } + }, + "/api/mcp/public/{id}/group/{group}/params": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Get reusing parameters for a specific group and MCP", + "produces": [ + "application/json" + ], + "tags": [ + "mcp" + ], + "summary": "Get group MCP reusing parameters", + "parameters": [ + { + "type": "string", + "description": "MCP ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Group ID", + "name": "group", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/middleware.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.PublicMCPReusingParam" + } + } + } + ] + } + } + } + }, + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Create or update reusing parameters for a specific group and MCP", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "mcp" + ], + "summary": "Create or update group MCP reusing parameters", + "parameters": [ + { + "type": "string", + "description": "MCP ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Group ID", + "name": "group", + "in": "path", + "required": true + }, + { + "description": "Reusing parameters", + "name": "params", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/model.PublicMCPReusingParam" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/middleware.APIResponse" + } + } + } + } + }, + "/api/mcp/public/{id}/status": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Update the status of an MCP", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "mcp" + ], + "summary": "Update MCP status", + "parameters": [ + { + "type": "string", + "description": "MCP ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "MCP status", + "name": "status", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/controller.UpdatePublicMCPStatusRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/middleware.APIResponse" + } + } + } + } + }, + "/api/mcp/publics/": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Get a list of MCPs with pagination and filtering", + "produces": [ + "application/json" + ], + "tags": [ + "mcp" + ], + "summary": "Get MCPs", + "parameters": [ + { + "type": "integer", + "description": "Page number", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Items per page", + "name": "per_page", + "in": "query" + }, + { + "type": "string", + "description": "hosted or local", + "name": "type", + "in": "query" + }, + { + "type": "string", + "description": "MCP id", + "name": "id", + "in": "query" + }, + { + "type": "string", + "description": "Search keyword", + "name": "keyword", + "in": "query" + }, + { + "type": "integer", + "description": "MCP status", + "name": "status", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/middleware.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/controller.PublicMCPResponse" + } + } + } + } + ] + } + } + } + }, + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Save a list of MCPs", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "mcp" + ], + "summary": "Save MCPs", + "parameters": [ + { + "description": "MCP object", + "name": "mcp", + "in": "body", + "required": true, + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/model.PublicMCP" + } + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/middleware.APIResponse" + } + } + } + } + }, + "/api/mcp/publics/all": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Get all MCPs with filtering", + "produces": [ + "application/json" + ], + "tags": [ + "mcp" + ], + "summary": "Get all MCPs", + "parameters": [ + { + "type": "integer", + "description": "MCP status", + "name": "status", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/middleware.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/controller.PublicMCPResponse" + } + } + } + } + ] + } + } + } + } + }, + "/api/model_config/{model}": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Returns a model config", + "produces": [ + "application/json" + ], + "tags": [ + "modelconfig" + ], + "summary": "Get model config", + "parameters": [ + { + "type": "string", + "description": "Model name", + "name": "model", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/middleware.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.ModelConfig" + } + } + } + ] + } + } + } + }, + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Saves a model config", + "produces": [ + "application/json" + ], + "tags": [ + "modelconfig" + ], + "summary": "Save model config", + "parameters": [ + { + "description": "Model config", + "name": "config", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/controller.SaveModelConfigsRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/middleware.APIResponse" + } + } + } + }, + "delete": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Deletes a model config", + "produces": [ + "application/json" + ], + "tags": [ + "modelconfig" + ], + "summary": "Delete model config", + "parameters": [ + { + "type": "string", + "description": "Model name", + "name": "model", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/middleware.APIResponse" + } + } + } + } + }, + "/api/model_configs/": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Returns a list of model configs with pagination", + "produces": [ + "application/json" + ], + "tags": [ + "modelconfig" + ], + "summary": "Get model configs", + "parameters": [ + { + "type": "string", + "description": "Model name", + "name": "model", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/middleware.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "type": "object", + "additionalProperties": { + "allOf": [ + {}, + { + "type": "object", + "properties": { + "configs": { + "type": "array", + "items": { + "$ref": "#/definitions/model.ModelConfig" + } + }, + "total": { + "type": "integer" + } + } + } + ] + } + } + } + } + ] + } + } + } + }, + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Saves a list of model configs", + "produces": [ + "application/json" + ], + "tags": [ + "modelconfig" + ], + "summary": "Save model configs", + "parameters": [ + { + "description": "Model configs", + "name": "configs", + "in": "body", + "required": true, + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/controller.SaveModelConfigsRequest" + } + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/middleware.APIResponse" + } + } + } + } + }, + "/api/model_configs/all": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Returns a list of all model configs", + "produces": [ + "application/json" + ], + "tags": [ + "modelconfig" + ], + "summary": "Get all model configs", + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/middleware.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/model.ModelConfig" + } + } + } + } + ] + } + } + } + } + }, + "/api/model_configs/batch_delete": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Deletes a list of model configs", + "produces": [ + "application/json" + ], + "tags": [ + "modelconfig" + ], + "summary": "Delete model configs", + "parameters": [ + { + "description": "Model names", + "name": "models", + "in": "body", + "required": true, + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/middleware.APIResponse" + } + } + } + } + }, + "/api/model_configs/contains": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Returns a list of model configs by models contains", + "produces": [ + "application/json" + ], + "tags": [ + "modelconfig" + ], + "summary": "Get model configs by models contains", + "parameters": [ + { + "description": "Models", + "name": "models", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/controller.GetModelConfigsByModelsContainsRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/middleware.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/model.ModelConfig" + } + } + } + } + ] + } + } + } + } + }, + "/api/model_configs/search": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Returns a list of model configs by keyword", + "produces": [ + "application/json" + ], + "tags": [ + "modelconfig" + ], + "summary": "Search model configs", + "parameters": [ + { + "type": "string", + "description": "Keyword", + "name": "keyword", + "in": "query" + }, + { + "type": "string", + "description": "Model name", + "name": "model", + "in": "query" + }, + { + "type": "string", + "description": "Owner", + "name": "owner", + "in": "query" + }, + { + "type": "integer", + "description": "Page", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Per page", + "name": "per_page", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/middleware.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "type": "object", + "additionalProperties": { + "allOf": [ + {}, + { + "type": "object", + "properties": { + "configs": { + "type": "array", + "items": { + "$ref": "#/definitions/model.ModelConfig" + } + }, + "total": { + "type": "integer" + } + } + } + ] + } + } + } + } + ] + } + } + } + } + }, + "/api/models/builtin": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Returns a list of builtin models", + "produces": [ + "application/json" + ], + "tags": [ + "model" + ], + "summary": "Get builtin models", + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/middleware.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/controller.BuiltinModelConfig" + } + } + } + } + ] + } + } + } + } + }, + "/api/models/builtin/channel": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Returns a list of channel builtin models", + "produces": [ + "application/json" + ], + "tags": [ + "model" + ], + "summary": "Get channel builtin models", + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/middleware.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "$ref": "#/definitions/controller.BuiltinModelConfig" + } + } + } + } + } + ] + } + } + } + } + }, + "/api/models/builtin/channel/{type}": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Returns a list of channel builtin models by type", + "produces": [ + "application/json" + ], + "tags": [ + "model" + ], + "summary": "Get channel builtin models by type", + "parameters": [ + { + "enum": [ + 1, + 3, + 4, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 23, + 24, + 25, + 26, + 27, + 28, + 29, + 30, + 31, + 32, + 33, + 34, + 35, + 36, + 37, + 40, + 41, + 42, + 43, + 44, + 45, + 46, + 47, + 48, + 49, + 50, + 51, + 52, + 53, + 54, + 55 + ], + "type": "integer", + "description": "Channel type", + "name": "type", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/middleware.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/controller.BuiltinModelConfig" + } + } + } + } + ] + } + } + } + } + }, + "/api/models/default": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Returns a list of channel default models and mapping", + "produces": [ + "application/json" + ], + "tags": [ + "model" + ], + "summary": "Get channel default models and mapping", + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/middleware.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "type": "object", + "additionalProperties": { + "allOf": [ + {}, + { + "type": "object", + "properties": { + "mapping": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "models": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + ] + } + } + } + } + ] + } + } + } + } + }, + "/api/models/default/{type}": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Returns a list of channel default models and mapping by type", + "produces": [ + "application/json" + ], + "tags": [ + "model" + ], + "summary": "Get channel default models and mapping by type", + "parameters": [ + { + "type": "string", + "description": "Channel type", + "name": "type", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/middleware.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "type": "object", + "additionalProperties": { + "allOf": [ + {}, + { + "type": "object", + "properties": { + "mapping": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "models": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + ] + } + } + } + } + ] + } + } + } + } + }, + "/api/models/enabled": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Returns a list of enabled models", + "produces": [ + "application/json" + ], + "tags": [ + "model" + ], + "summary": "Get enabled models", + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/middleware.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "$ref": "#/definitions/model.ModelConfig" + } + } + } + } + } + ] + } + } + } + } + }, + "/api/models/enabled/{set}": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Returns a list of enabled models by set", + "produces": [ + "application/json" + ], + "tags": [ + "model" + ], + "summary": "Get enabled models by set", + "parameters": [ + { + "type": "string", + "description": "Models set", + "name": "set", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/middleware.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/model.ModelConfig" + } + } + } + } + ] + } + } + } + } + }, + "/api/models/sets": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Returns a list of enabled models and channels sets", + "produces": [ + "application/json" + ], + "tags": [ + "model" + ], + "summary": "Get enabled models and channels sets", + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/middleware.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "type": "object", + "additionalProperties": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "$ref": "#/definitions/controller.EnabledModelChannel" + } + } + } + } + } + } + ] + } + } + } + } + }, + "/api/monitor/": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Returns a list of all channel model error rates", + "produces": [ + "application/json" + ], + "tags": [ + "monitor" + ], + "summary": "Get all channel model error rates", + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/middleware.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "type": "object", + "additionalProperties": { + "type": "object", + "additionalProperties": { + "type": "number", + "format": "float64" + } + } + } + } + } + ] + } + } + } + }, + "delete": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Clears all model errors", + "produces": [ + "application/json" + ], + "tags": [ + "monitor" + ], + "summary": "Clear all model errors", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/middleware.APIResponse" + } + } + } + } + }, + "/api/monitor/banned_channels": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Returns a list of all banned model channels", + "produces": [ + "application/json" + ], + "tags": [ + "monitor" + ], + "summary": "Get all banned model channels", + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/middleware.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "type": "integer", + "format": "int64" + } + } + } + } + } + ] + } + } + } + } + }, + "/api/monitor/batch_group_token_metrics": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Returns runtime rpm/tpm summary metrics for explicit group and token-name pairs", + "produces": [ + "application/json" + ], + "tags": [ + "monitor" + ], + "summary": "Batch get runtime metrics for api key rows", + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/middleware.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/controller.BatchGroupTokenMetricsResponse" + } + } + } + ] + } + } + } + } + }, + "/api/monitor/group_model_metrics/{group}": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Returns runtime rpm/tpm metrics grouped by model in a group", + "produces": [ + "application/json" + ], + "tags": [ + "monitor" + ], + "summary": "Get model runtime metrics for a group", + "parameters": [ + { + "type": "string", + "description": "Group ID", + "name": "group", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/middleware.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/controller.GroupModelMetricsResponse" + } + } + } + ] + } + } + } + } + }, + "/api/monitor/group_summary_metrics": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Returns rpm/tpm summary metrics for one or more groups", + "produces": [ + "application/json" + ], + "tags": [ + "monitor" + ], + "summary": "Get summary metrics for multiple groups", + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/middleware.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/controller.GroupSummaryMetricsResponse" + } + } + } + ] + } + } + } + } + }, + "/api/monitor/group_token_metrics/{group}": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Returns runtime rpm/tpm metrics grouped by token name in a group", + "produces": [ + "application/json" + ], + "tags": [ + "monitor" + ], + "summary": "Get token runtime metrics for a group", + "parameters": [ + { + "type": "string", + "description": "Group ID", + "name": "group", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/middleware.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/controller.GroupTokenMetricsResponse" + } + } + } + ] + } + } + } + } + }, + "/api/monitor/group_tokenname_model_metrics/{group}": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Returns runtime rpm/tpm metrics for token name and model combinations in a group", + "produces": [ + "application/json" + ], + "tags": [ + "monitor" + ], + "summary": "Get token-model runtime metrics for a group", + "parameters": [ + { + "type": "string", + "description": "Group ID", + "name": "group", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/middleware.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/controller.GroupTokennameModelMetricsResponse" + } + } + } + ] + } + } + } + } + }, + "/api/monitor/models": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Returns a list of models error rate", + "produces": [ + "application/json" + ], + "tags": [ + "monitor" + ], + "summary": "Get models error rate", + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/middleware.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "type": "object", + "additionalProperties": { + "type": "number", + "format": "float64" + } + } + } + } + ] + } + } + } + } + }, + "/api/monitor/runtime_metrics": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Returns runtime rpm/tpm/error metrics sourced from reqlimit and monitor", + "produces": [ + "application/json" + ], + "tags": [ + "monitor" + ], + "summary": "Get runtime metrics for models and channels", + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/middleware.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/controller.RuntimeMetricsResponse" + } + } + } + ] + } + } + } + } + }, + "/api/monitor/{id}": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Returns a list of channel model error rates", + "produces": [ + "application/json" + ], + "tags": [ + "monitor" + ], + "summary": "Get channel model error rates", + "parameters": [ + { + "type": "integer", + "description": "Channel ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/middleware.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": { + "type": "number", + "format": "float64" + } + } + } + } + } + ] + } + } + } + }, + "delete": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Clears all model errors for a specific channel", + "produces": [ + "application/json" + ], + "tags": [ + "monitor" + ], + "summary": "Clear channel all model errors", + "parameters": [ + { + "type": "integer", + "description": "Channel ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/middleware.APIResponse" + } + } + } + } + }, + "/api/monitor/{id}/{model}": { + "delete": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Clears model errors for a specific channel and model", + "produces": [ + "application/json" + ], + "tags": [ + "monitor" + ], + "summary": "Clear channel model errors", + "parameters": [ + { + "type": "integer", + "description": "Channel ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Model name", + "name": "model", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/middleware.APIResponse" + } + } + } + } + }, + "/api/option/": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Returns a list of options", + "produces": [ + "application/json" + ], + "tags": [ + "option" + ], + "summary": "Get options", + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/middleware.APIResponse" + }, { "type": "object", "properties": { "data": { "type": "object", "additionalProperties": { - "type": "number", - "format": "float64" + "type": "string" } } } @@ -6303,23 +10847,139 @@ } } } + }, + "put": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Updates a single option", + "produces": [ + "application/json" + ], + "tags": [ + "option" + ], + "summary": "Update option", + "parameters": [ + { + "description": "Option value", + "name": "value", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/model.Option" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/middleware.APIResponse" + } + } + } + }, + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Updates a single option", + "produces": [ + "application/json" + ], + "tags": [ + "option" + ], + "summary": "Update option", + "parameters": [ + { + "description": "Option value", + "name": "value", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/model.Option" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/middleware.APIResponse" + } + } + } } }, - "/api/monitor/runtime_metrics": { + "/api/option/batch": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Updates multiple options", + "produces": [ + "application/json" + ], + "tags": [ + "option" + ], + "summary": "Update options", + "parameters": [ + { + "description": "Options", + "name": "options", + "in": "body", + "required": true, + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/middleware.APIResponse" + } + } + } + } + }, + "/api/option/{key}": { "get": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Returns runtime rpm/tpm/error metrics sourced from reqlimit and monitor", + "description": "Returns a single option", "produces": [ "application/json" ], "tags": [ - "monitor" + "option" + ], + "summary": "Get option", + "parameters": [ + { + "type": "string", + "description": "Option key", + "name": "key", + "in": "path", + "required": true + } ], - "summary": "Get runtime metrics for models and channels", "responses": { "200": { "description": "OK", @@ -6332,7 +10992,7 @@ "type": "object", "properties": { "data": { - "$ref": "#/definitions/controller.RuntimeMetricsResponse" + "$ref": "#/definitions/model.Option" } } } @@ -6340,32 +11000,59 @@ } } } - } - }, - "/api/monitor/{id}": { - "get": { + }, + "put": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Returns a list of channel model error rates", + "description": "Updates a single option by key", "produces": [ "application/json" ], "tags": [ - "monitor" + "option" ], - "summary": "Get channel model error rates", + "summary": "Update option by key", "parameters": [ { - "type": "integer", - "description": "Channel ID", - "name": "id", + "type": "string", + "description": "Option key", + "name": "key", "in": "path", "required": true + }, + { + "description": "Option value", + "name": "value", + "in": "body", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/middleware.APIResponse" + } } + } + } + }, + "/api/status": { + "get": { + "description": "Returns the status of the server", + "produces": [ + "application/json" + ], + "tags": [ + "misc" ], + "summary": "Get status", "responses": { "200": { "description": "OK", @@ -6378,14 +11065,7 @@ "type": "object", "properties": { "data": { - "type": "array", - "items": { - "type": "object", - "additionalProperties": { - "type": "number", - "format": "float64" - } - } + "$ref": "#/definitions/controller.StatusData" } } } @@ -6393,250 +11073,354 @@ } } } + } + }, + "/api/test-embedmcp/{id}": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Test Embed MCP Streamable Server with various HTTP methods", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "embedmcp" + ], + "summary": "Test Embed MCP Streamable Server", + "parameters": [ + { + "type": "string", + "description": "MCP ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Initial configuration parameters (e.g. config[host]=http://localhost:3000)", + "name": "config[key]", + "in": "query" + }, + { + "type": "string", + "description": "Reusing configuration parameters (e.g., reusing[authorization]=apikey)", + "name": "reusing[key]", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad Request" + } + } }, - "delete": { + "post": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Clears all model errors for a specific channel", + "description": "Test Embed MCP Streamable Server with various HTTP methods", + "consumes": [ + "application/json" + ], "produces": [ "application/json" ], "tags": [ - "monitor" + "embedmcp" ], - "summary": "Clear channel all model errors", + "summary": "Test Embed MCP Streamable Server", "parameters": [ { - "type": "integer", - "description": "Channel ID", + "type": "string", + "description": "MCP ID", "name": "id", "in": "path", "required": true + }, + { + "type": "string", + "description": "Initial configuration parameters (e.g. config[host]=http://localhost:3000)", + "name": "config[key]", + "in": "query" + }, + { + "type": "string", + "description": "Reusing configuration parameters (e.g., reusing[authorization]=apikey)", + "name": "reusing[key]", + "in": "query" } ], "responses": { "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/middleware.APIResponse" - } + "description": "OK" + }, + "400": { + "description": "Bad Request" } } - } - }, - "/api/monitor/{id}/{model}": { + }, "delete": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Clears model errors for a specific channel and model", + "description": "Test Embed MCP Streamable Server with various HTTP methods", + "consumes": [ + "application/json" + ], "produces": [ "application/json" ], "tags": [ - "monitor" + "embedmcp" ], - "summary": "Clear channel model errors", + "summary": "Test Embed MCP Streamable Server", "parameters": [ { - "type": "integer", - "description": "Channel ID", + "type": "string", + "description": "MCP ID", "name": "id", "in": "path", "required": true }, { "type": "string", - "description": "Model name", - "name": "model", - "in": "path", - "required": true + "description": "Initial configuration parameters (e.g. config[host]=http://localhost:3000)", + "name": "config[key]", + "in": "query" + }, + { + "type": "string", + "description": "Reusing configuration parameters (e.g., reusing[authorization]=apikey)", + "name": "reusing[key]", + "in": "query" } ], "responses": { "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/middleware.APIResponse" - } + "description": "OK" + }, + "400": { + "description": "Bad Request" } } } }, - "/api/option/": { + "/api/test-embedmcp/{id}/sse": { "get": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Returns a list of options", - "produces": [ - "application/json" - ], - "tags": [ - "option" - ], - "summary": "Get options", - "responses": { - "200": { - "description": "OK", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/middleware.APIResponse" - }, - { - "type": "object", - "properties": { - "data": { - "type": "object", - "additionalProperties": { - "type": "string" - } - } - } - } - ] - } - } - } - }, - "put": { - "security": [ - { - "ApiKeyAuth": [] - } - ], - "description": "Updates a single option", - "produces": [ - "application/json" - ], + "description": "Test Embed MCP SSE Server", "tags": [ - "option" + "embedmcp" ], - "summary": "Update option", + "summary": "Test Embed MCP SSE Server", "parameters": [ { - "description": "Option value", - "name": "value", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/model.Option" - } + "type": "string", + "description": "MCP ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Initial configuration parameters (e.g. config[host]=http://localhost:3000)", + "name": "config[key]", + "in": "query" + }, + { + "type": "string", + "description": "Reusing configuration parameters (e.g. reusing[authorization]=apikey)", + "name": "reusing[key]", + "in": "query" } ], "responses": { "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/middleware.APIResponse" - } + "description": "OK" + }, + "400": { + "description": "Bad Request" } } - }, - "post": { + } + }, + "/api/test-publicmcp/{group}/{id}/sse": { + "get": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Updates a single option", - "produces": [ - "application/json" - ], - "tags": [ - "option" - ], - "summary": "Update option", + "summary": "Test Public MCP SSE Server", "parameters": [ { - "description": "Option value", - "name": "value", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/model.Option" - } + "type": "string", + "description": "Group ID", + "name": "group", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "MCP ID", + "name": "id", + "in": "path", + "required": true } ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/middleware.APIResponse" - } - } - } + "responses": {} } }, - "/api/option/batch": { - "post": { + "/api/token/{group}": { + "get": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Updates multiple options", + "description": "Returns a paginated list of all tokens for a specific group", "produces": [ "application/json" ], "tags": [ - "option" + "tokens" ], - "summary": "Update options", + "summary": "Get all tokens for a specific group", "parameters": [ { - "description": "Options", - "name": "options", - "in": "body", - "required": true, - "schema": { - "type": "object", - "additionalProperties": { - "type": "string" - } - } + "type": "string", + "description": "Group name", + "name": "group", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "Page number", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Items per page", + "name": "per_page", + "in": "query" + }, + { + "type": "string", + "description": "Order", + "name": "order", + "in": "query" + }, + { + "type": "integer", + "description": "Status", + "name": "status", + "in": "query" } ], "responses": { "200": { "description": "OK", "schema": { - "$ref": "#/definitions/middleware.APIResponse" + "allOf": [ + { + "$ref": "#/definitions/middleware.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "type": "object", + "additionalProperties": { + "allOf": [ + {}, + { + "type": "object", + "properties": { + "tokens": { + "type": "array", + "items": { + "$ref": "#/definitions/controller.TokenResponse" + } + }, + "total": { + "type": "integer" + } + } + } + ] + } + } + } + } + ] } } } - } - }, - "/api/option/{key}": { - "get": { + }, + "post": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Returns a single option", + "description": "Adds a new token to a specific group", + "consumes": [ + "application/json" + ], "produces": [ "application/json" ], "tags": [ - "option" + "token" ], - "summary": "Get option", + "summary": "Add group token", "parameters": [ { "type": "string", - "description": "Option key", - "name": "key", + "description": "Group name", + "name": "group", "in": "path", "required": true + }, + { + "type": "boolean", + "description": "Auto create group", + "name": "auto_create_group", + "in": "query" + }, + { + "type": "boolean", + "description": "Ignore exist", + "name": "ignore_exist", + "in": "query" + }, + { + "description": "Token information", + "name": "token", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/controller.AddTokenRequest" + } } ], "responses": { @@ -6651,7 +11435,7 @@ "type": "object", "properties": { "data": { - "$ref": "#/definitions/model.Option" + "$ref": "#/definitions/controller.TokenResponse" } } } @@ -6659,36 +11443,41 @@ } } } - }, - "put": { + } + }, + "/api/token/{group}/batch_delete": { + "post": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Updates a single option by key", + "description": "Deletes multiple tokens from a specific group", "produces": [ "application/json" ], "tags": [ - "option" + "token" ], - "summary": "Update option by key", + "summary": "Delete group tokens", "parameters": [ { "type": "string", - "description": "Option key", - "name": "key", + "description": "Group name", + "name": "group", "in": "path", "required": true }, { - "description": "Option value", - "name": "value", + "description": "Token IDs", + "name": "ids", "in": "body", "required": true, "schema": { - "type": "string" + "type": "array", + "items": { + "type": "integer" + } } } ], @@ -6702,16 +11491,72 @@ } } }, - "/api/status": { + "/api/token/{group}/search": { "get": { - "description": "Returns the status of the server", + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Returns a paginated list of tokens for a specific group based on search criteria", "produces": [ "application/json" ], "tags": [ - "misc" + "token" + ], + "summary": "Search tokens for a specific group", + "parameters": [ + { + "type": "string", + "description": "Group name", + "name": "group", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Keyword", + "name": "keyword", + "in": "query" + }, + { + "type": "integer", + "description": "Page number", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Items per page", + "name": "per_page", + "in": "query" + }, + { + "type": "string", + "description": "Order", + "name": "order", + "in": "query" + }, + { + "type": "string", + "description": "Name", + "name": "name", + "in": "query" + }, + { + "type": "string", + "description": "Key", + "name": "key", + "in": "query" + }, + { + "type": "integer", + "description": "Status", + "name": "status", + "in": "query" + } ], - "summary": "Get status", "responses": { "200": { "description": "OK", @@ -6724,7 +11569,26 @@ "type": "object", "properties": { "data": { - "$ref": "#/definitions/controller.StatusData" + "type": "object", + "additionalProperties": { + "allOf": [ + {}, + { + "type": "object", + "properties": { + "tokens": { + "type": "array", + "items": { + "$ref": "#/definitions/controller.TokenResponse" + } + }, + "total": { + "type": "integer" + } + } + } + ] + } } } } @@ -6734,61 +11598,65 @@ } } }, - "/api/test-embedmcp/{id}": { + "/api/token/{group}/{id}": { "get": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Test Embed MCP Streamable Server with various HTTP methods", - "consumes": [ - "application/json" - ], + "description": "Returns detailed information about a specific token for a specific group", "produces": [ "application/json" ], "tags": [ - "embedmcp" + "token" ], - "summary": "Test Embed MCP Streamable Server", + "summary": "Get token by ID for a specific group", "parameters": [ { "type": "string", - "description": "MCP ID", - "name": "id", + "description": "Group name", + "name": "group", "in": "path", "required": true }, { - "type": "string", - "description": "Initial configuration parameters (e.g. config[host]=http://localhost:3000)", - "name": "config[key]", - "in": "query" - }, - { - "type": "string", - "description": "Reusing configuration parameters (e.g., reusing[authorization]=apikey)", - "name": "reusing[key]", - "in": "query" + "type": "integer", + "description": "Token ID", + "name": "id", + "in": "path", + "required": true } ], "responses": { "200": { - "description": "OK" - }, - "400": { - "description": "Bad Request" + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/middleware.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/controller.TokenResponse" + } + } + } + ] + } } } }, - "post": { + "put": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Test Embed MCP Streamable Server with various HTTP methods", + "description": "Updates an existing token in a specific group", "consumes": [ "application/json" ], @@ -6796,36 +11664,52 @@ "application/json" ], "tags": [ - "embedmcp" + "token" ], - "summary": "Test Embed MCP Streamable Server", + "summary": "Update group token", "parameters": [ { "type": "string", - "description": "MCP ID", - "name": "id", + "description": "Group name", + "name": "group", "in": "path", "required": true }, { - "type": "string", - "description": "Initial configuration parameters (e.g. config[host]=http://localhost:3000)", - "name": "config[key]", - "in": "query" + "type": "integer", + "description": "Token ID", + "name": "id", + "in": "path", + "required": true }, - { - "type": "string", - "description": "Reusing configuration parameters (e.g., reusing[authorization]=apikey)", - "name": "reusing[key]", - "in": "query" + { + "description": "Updated token information", + "name": "token", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/model.UpdateTokenRequest" + } } ], "responses": { "200": { - "description": "OK" - }, - "400": { - "description": "Bad Request" + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/middleware.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/controller.TokenResponse" + } + } + } + ] + } } } }, @@ -6835,164 +11719,191 @@ "ApiKeyAuth": [] } ], - "description": "Test Embed MCP Streamable Server with various HTTP methods", - "consumes": [ - "application/json" - ], + "description": "Deletes a specific token from a group", "produces": [ "application/json" ], "tags": [ - "embedmcp" + "token" ], - "summary": "Test Embed MCP Streamable Server", + "summary": "Delete group token", "parameters": [ { "type": "string", - "description": "MCP ID", - "name": "id", + "description": "Group name", + "name": "group", "in": "path", "required": true }, { - "type": "string", - "description": "Initial configuration parameters (e.g. config[host]=http://localhost:3000)", - "name": "config[key]", - "in": "query" - }, - { - "type": "string", - "description": "Reusing configuration parameters (e.g., reusing[authorization]=apikey)", - "name": "reusing[key]", - "in": "query" + "type": "integer", + "description": "Token ID", + "name": "id", + "in": "path", + "required": true } ], "responses": { "200": { - "description": "OK" - }, - "400": { - "description": "Bad Request" + "description": "OK", + "schema": { + "$ref": "#/definitions/middleware.APIResponse" + } } } } }, - "/api/test-embedmcp/{id}/sse": { - "get": { + "/api/token/{group}/{id}/name": { + "post": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Test Embed MCP SSE Server", + "description": "Updates the name of a token in a specific group", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], "tags": [ - "embedmcp" + "token" ], - "summary": "Test Embed MCP SSE Server", + "summary": "Update group token name", "parameters": [ { "type": "string", - "description": "MCP ID", - "name": "id", + "description": "Group name", + "name": "group", "in": "path", "required": true }, { - "type": "string", - "description": "Initial configuration parameters (e.g. config[host]=http://localhost:3000)", - "name": "config[key]", - "in": "query" + "type": "integer", + "description": "Token ID", + "name": "id", + "in": "path", + "required": true }, { - "type": "string", - "description": "Reusing configuration parameters (e.g. reusing[authorization]=apikey)", - "name": "reusing[key]", - "in": "query" + "description": "Name information", + "name": "name", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/controller.UpdateTokenNameRequest" + } } ], "responses": { "200": { - "description": "OK" - }, - "400": { - "description": "Bad Request" + "description": "OK", + "schema": { + "$ref": "#/definitions/middleware.APIResponse" + } } } } }, - "/api/test-publicmcp/{group}/{id}/sse": { - "get": { + "/api/token/{group}/{id}/status": { + "post": { "security": [ { "ApiKeyAuth": [] } ], - "summary": "Test Public MCP SSE Server", + "description": "Updates the status of a token in a specific group", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "token" + ], + "summary": "Update group token status", "parameters": [ { "type": "string", - "description": "Group ID", + "description": "Group name", "name": "group", "in": "path", "required": true }, { - "type": "string", - "description": "MCP ID", + "type": "integer", + "description": "Token ID", "name": "id", "in": "path", "required": true + }, + { + "description": "Status information", + "name": "status", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/controller.UpdateTokenStatusRequest" + } } ], - "responses": {} + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/middleware.APIResponse" + } + } + } } }, - "/api/token/{group}": { - "post": { + "/api/tokens/": { + "get": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Adds a new token to a specific group", - "consumes": [ - "application/json" - ], + "description": "Returns a paginated list of all tokens", "produces": [ "application/json" ], "tags": [ - "token" + "tokens" ], - "summary": "Add group token", + "summary": "Get all tokens", "parameters": [ + { + "type": "integer", + "description": "Page number", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Items per page", + "name": "per_page", + "in": "query" + }, { "type": "string", "description": "Group name", "name": "group", - "in": "path", - "required": true - }, - { - "type": "boolean", - "description": "Auto create group", - "name": "auto_create_group", "in": "query" }, { - "type": "boolean", - "description": "Ignore exist", - "name": "ignore_exist", + "type": "string", + "description": "Order", + "name": "order", "in": "query" }, { - "description": "Token information", - "name": "token", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/controller.AddTokenRequest" - } + "type": "integer", + "description": "Status", + "name": "status", + "in": "query" } ], "responses": { @@ -7007,7 +11918,26 @@ "type": "object", "properties": { "data": { - "$ref": "#/definitions/controller.TokenResponse" + "type": "object", + "additionalProperties": { + "allOf": [ + {}, + { + "type": "object", + "properties": { + "tokens": { + "type": "array", + "items": { + "$ref": "#/definitions/controller.TokenResponse" + } + }, + "total": { + "type": "integer" + } + } + } + ] + } } } } @@ -7017,29 +11947,25 @@ } } }, - "/api/token/{group}/batch_delete": { + "/api/tokens/batch_delete": { "post": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Deletes multiple tokens from a specific group", + "description": "Deletes multiple tokens by their IDs", + "consumes": [ + "application/json" + ], "produces": [ "application/json" ], "tags": [ - "token" + "tokens" ], - "summary": "Delete group tokens", + "summary": "Delete multiple tokens", "parameters": [ - { - "type": "string", - "description": "Group name", - "name": "group", - "in": "path", - "required": true - }, { "description": "Token IDs", "name": "ids", @@ -7063,29 +11989,22 @@ } } }, - "/api/token/{group}/search": { + "/api/tokens/search": { "get": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Returns a paginated list of tokens for a specific group based on search criteria", + "description": "Returns a paginated list of tokens based on search criteria", "produces": [ "application/json" ], - "tags": [ - "token" - ], - "summary": "Search tokens for a specific group", - "parameters": [ - { - "type": "string", - "description": "Group name", - "name": "group", - "in": "path", - "required": true - }, + "tags": [ + "tokens" + ], + "summary": "Search tokens", + "parameters": [ { "type": "string", "description": "Keyword", @@ -7127,6 +12046,12 @@ "description": "Status", "name": "status", "in": "query" + }, + { + "type": "string", + "description": "Group", + "name": "group", + "in": "query" } ], "responses": { @@ -7170,29 +12095,22 @@ } } }, - "/api/token/{group}/{id}": { + "/api/tokens/{id}": { "get": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Returns detailed information about a specific token for a specific group", + "description": "Returns detailed information about a specific token", "produces": [ "application/json" ], "tags": [ - "token" + "tokens" ], - "summary": "Get token by ID for a specific group", + "summary": "Get token by ID", "parameters": [ - { - "type": "string", - "description": "Group name", - "name": "group", - "in": "path", - "required": true - }, { "type": "integer", "description": "Token ID", @@ -7228,7 +12146,7 @@ "ApiKeyAuth": [] } ], - "description": "Updates an existing token in a specific group", + "description": "Updates an existing token's information", "consumes": [ "application/json" ], @@ -7236,17 +12154,10 @@ "application/json" ], "tags": [ - "token" + "tokens" ], - "summary": "Update group token", + "summary": "Update token", "parameters": [ - { - "type": "string", - "description": "Group name", - "name": "group", - "in": "path", - "required": true - }, { "type": "integer", "description": "Token ID", @@ -7259,223 +12170,9 @@ "name": "token", "in": "body", "required": true, - "schema": { - "$ref": "#/definitions/model.UpdateTokenRequest" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/middleware.APIResponse" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/controller.TokenResponse" - } - } - } - ] - } - } - } - }, - "delete": { - "security": [ - { - "ApiKeyAuth": [] - } - ], - "description": "Deletes a specific token from a group", - "produces": [ - "application/json" - ], - "tags": [ - "token" - ], - "summary": "Delete group token", - "parameters": [ - { - "type": "string", - "description": "Group name", - "name": "group", - "in": "path", - "required": true - }, - { - "type": "integer", - "description": "Token ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/middleware.APIResponse" - } - } - } - } - }, - "/api/token/{group}/{id}/name": { - "post": { - "security": [ - { - "ApiKeyAuth": [] - } - ], - "description": "Updates the name of a token in a specific group", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "token" - ], - "summary": "Update group token name", - "parameters": [ - { - "type": "string", - "description": "Group name", - "name": "group", - "in": "path", - "required": true - }, - { - "type": "integer", - "description": "Token ID", - "name": "id", - "in": "path", - "required": true - }, - { - "description": "Name information", - "name": "name", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/controller.UpdateTokenNameRequest" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/middleware.APIResponse" - } - } - } - } - }, - "/api/token/{group}/{id}/status": { - "post": { - "security": [ - { - "ApiKeyAuth": [] - } - ], - "description": "Updates the status of a token in a specific group", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "token" - ], - "summary": "Update group token status", - "parameters": [ - { - "type": "string", - "description": "Group name", - "name": "group", - "in": "path", - "required": true - }, - { - "type": "integer", - "description": "Token ID", - "name": "id", - "in": "path", - "required": true - }, - { - "description": "Status information", - "name": "status", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/controller.UpdateTokenStatusRequest" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/middleware.APIResponse" - } - } - } - } - }, - "/api/tokens/": { - "get": { - "security": [ - { - "ApiKeyAuth": [] - } - ], - "description": "Returns a paginated list of all tokens", - "produces": [ - "application/json" - ], - "tags": [ - "tokens" - ], - "summary": "Get all tokens", - "parameters": [ - { - "type": "integer", - "description": "Page number", - "name": "page", - "in": "query" - }, - { - "type": "integer", - "description": "Items per page", - "name": "per_page", - "in": "query" - }, - { - "type": "string", - "description": "Group name", - "name": "group", - "in": "query" - }, - { - "type": "string", - "description": "Order", - "name": "order", - "in": "query" - }, - { - "type": "integer", - "description": "Status", - "name": "status", - "in": "query" + "schema": { + "$ref": "#/definitions/model.UpdateTokenRequest" + } } ], "responses": { @@ -7490,26 +12187,7 @@ "type": "object", "properties": { "data": { - "type": "object", - "additionalProperties": { - "allOf": [ - {}, - { - "type": "object", - "properties": { - "tokens": { - "type": "array", - "items": { - "$ref": "#/definitions/controller.TokenResponse" - } - }, - "total": { - "type": "integer" - } - } - } - ] - } + "$ref": "#/definitions/controller.TokenResponse" } } } @@ -7517,16 +12195,48 @@ } } } + }, + "delete": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Deletes a specific token by ID", + "produces": [ + "application/json" + ], + "tags": [ + "tokens" + ], + "summary": "Delete token", + "parameters": [ + { + "type": "integer", + "description": "Token ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/middleware.APIResponse" + } + } + } } }, - "/api/tokens/batch_delete": { + "/api/tokens/{id}/name": { "post": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Deletes multiple tokens by their IDs", + "description": "Updates the name of a specific token", "consumes": [ "application/json" ], @@ -7536,18 +12246,22 @@ "tags": [ "tokens" ], - "summary": "Delete multiple tokens", + "summary": "Update token name", "parameters": [ { - "description": "Token IDs", - "name": "ids", + "type": "integer", + "description": "Token ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Name information", + "name": "name", "in": "body", "required": true, "schema": { - "type": "array", - "items": { - "type": "integer" - } + "$ref": "#/definitions/controller.UpdateTokenNameRequest" } } ], @@ -7561,253 +12275,229 @@ } } }, - "/api/tokens/search": { - "get": { + "/api/tokens/{id}/status": { + "post": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Returns a paginated list of tokens based on search criteria", + "description": "Updates the status of a specific token", + "consumes": [ + "application/json" + ], "produces": [ "application/json" ], "tags": [ "tokens" ], - "summary": "Search tokens", + "summary": "Update token status", "parameters": [ - { - "type": "string", - "description": "Keyword", - "name": "keyword", - "in": "query" - }, - { - "type": "integer", - "description": "Page number", - "name": "page", - "in": "query" - }, { "type": "integer", - "description": "Items per page", - "name": "per_page", - "in": "query" - }, - { - "type": "string", - "description": "Order", - "name": "order", - "in": "query" - }, - { - "type": "string", - "description": "Name", - "name": "name", - "in": "query" - }, - { - "type": "string", - "description": "Key", - "name": "key", - "in": "query" + "description": "Token ID", + "name": "id", + "in": "path", + "required": true }, { - "type": "integer", - "description": "Status", + "description": "Status information", "name": "status", - "in": "query" - }, - { - "type": "string", - "description": "Group", - "name": "group", - "in": "query" + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/controller.UpdateTokenStatusRequest" + } } ], "responses": { "200": { "description": "OK", "schema": { - "allOf": [ - { - "$ref": "#/definitions/middleware.APIResponse" - }, - { - "type": "object", - "properties": { - "data": { - "type": "object", - "additionalProperties": { - "allOf": [ - {}, - { - "type": "object", - "properties": { - "tokens": { - "type": "array", - "items": { - "$ref": "#/definitions/controller.TokenResponse" - } - }, - "total": { - "type": "integer" - } - } - } - ] - } - } - } - } - ] + "$ref": "#/definitions/middleware.APIResponse" } } } } }, - "/api/tokens/{group}": { - "get": { + "/api/v1/services/aigc/video-generation/video-synthesis": { + "post": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Returns a paginated list of all tokens for a specific group", + "description": "Create an Ali DashScope native video generation task.", + "consumes": [ + "application/json" + ], "produces": [ "application/json" ], "tags": [ - "tokens" + "relay" ], - "summary": "Get all tokens for a specific group", + "summary": "Create Ali native video task", "parameters": [ { - "type": "string", - "description": "Group name", - "name": "group", - "in": "path", - "required": true - }, - { - "type": "integer", - "description": "Page number", - "name": "page", - "in": "query" + "description": "Ali DashScope video synthesis request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "type": "object" + } }, { - "type": "integer", - "description": "Items per page", - "name": "per_page", - "in": "query" + "type": "string", + "description": "Optional Aiproxy-Channel header", + "name": "Aiproxy-Channel", + "in": "header" }, { "type": "string", - "description": "Order", - "name": "order", - "in": "query" + "description": "Optional group ID for internal token requests", + "name": "X-Aiproxy-Group", + "in": "header" }, { - "type": "integer", - "description": "Status", - "name": "status", - "in": "query" + "enum": [ + "global", + "own" + ], + "type": "string", + "description": "Optional group channel mode for internal token requests. Values: global, own", + "name": "X-Aiproxy-Group-Channel-Mode", + "in": "header" } ], "responses": { "200": { "description": "OK", "schema": { - "allOf": [ - { - "$ref": "#/definitions/middleware.APIResponse" - }, - { - "type": "object", - "properties": { - "data": { - "type": "object", - "additionalProperties": { - "allOf": [ - {}, - { - "type": "object", - "properties": { - "tokens": { - "type": "array", - "items": { - "$ref": "#/definitions/controller.TokenResponse" - } - }, - "total": { - "type": "integer" - } - } - } - ] - } - } - } - } - ] + "type": "object" + }, + "headers": { + "X-RateLimit-Limit-Requests": { + "type": "integer", + "description": "X-RateLimit-Limit-Requests" + }, + "X-RateLimit-Limit-Tokens": { + "type": "integer", + "description": "X-RateLimit-Limit-Tokens" + }, + "X-RateLimit-Remaining-Requests": { + "type": "integer", + "description": "X-RateLimit-Remaining-Requests" + }, + "X-RateLimit-Remaining-Tokens": { + "type": "integer", + "description": "X-RateLimit-Remaining-Tokens" + }, + "X-RateLimit-Reset-Requests": { + "type": "string", + "description": "X-RateLimit-Reset-Requests" + }, + "X-RateLimit-Reset-Tokens": { + "type": "string", + "description": "X-RateLimit-Reset-Tokens" + } } } } } }, - "/api/tokens/{id}": { + "/api/v1/tasks/{task_id}": { "get": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Returns detailed information about a specific token", + "description": "Get an Ali DashScope native video generation task.", "produces": [ "application/json" ], "tags": [ - "tokens" + "relay" ], - "summary": "Get token by ID", + "summary": "Get Ali native video task", "parameters": [ { - "type": "integer", - "description": "Token ID", - "name": "id", + "type": "string", + "description": "Task ID", + "name": "task_id", "in": "path", "required": true + }, + { + "type": "string", + "description": "Optional Aiproxy-Channel header", + "name": "Aiproxy-Channel", + "in": "header" + }, + { + "type": "string", + "description": "Optional group ID for internal token requests", + "name": "X-Aiproxy-Group", + "in": "header" + }, + { + "enum": [ + "global", + "own" + ], + "type": "string", + "description": "Optional group channel mode for internal token requests. Values: global, own", + "name": "X-Aiproxy-Group-Channel-Mode", + "in": "header" } ], "responses": { "200": { "description": "OK", "schema": { - "allOf": [ - { - "$ref": "#/definitions/middleware.APIResponse" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/controller.TokenResponse" - } - } - } - ] + "type": "object" + }, + "headers": { + "X-RateLimit-Limit-Requests": { + "type": "integer", + "description": "X-RateLimit-Limit-Requests" + }, + "X-RateLimit-Limit-Tokens": { + "type": "integer", + "description": "X-RateLimit-Limit-Tokens" + }, + "X-RateLimit-Remaining-Requests": { + "type": "integer", + "description": "X-RateLimit-Remaining-Requests" + }, + "X-RateLimit-Remaining-Tokens": { + "type": "integer", + "description": "X-RateLimit-Remaining-Tokens" + }, + "X-RateLimit-Reset-Requests": { + "type": "string", + "description": "X-RateLimit-Reset-Requests" + }, + "X-RateLimit-Reset-Tokens": { + "type": "string", + "description": "X-RateLimit-Reset-Tokens" + } } } } - }, - "put": { + } + }, + "/api/v3/contents/generations/tasks": { + "post": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Updates an existing token's information", + "description": "Create a Doubao native video generation task.", "consumes": [ "application/json" ], @@ -7815,168 +12505,264 @@ "application/json" ], "tags": [ - "tokens" + "relay" ], - "summary": "Update token", + "summary": "Create Doubao native video task", "parameters": [ { - "type": "integer", - "description": "Token ID", - "name": "id", - "in": "path", - "required": true - }, - { - "description": "Updated token information", - "name": "token", + "description": "Doubao video generation task request", + "name": "request", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/model.UpdateTokenRequest" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/middleware.APIResponse" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/controller.TokenResponse" - } - } - } - ] + "type": "object" } - } - } - }, - "delete": { - "security": [ + }, { - "ApiKeyAuth": [] - } - ], - "description": "Deletes a specific token by ID", - "produces": [ - "application/json" - ], - "tags": [ - "tokens" - ], - "summary": "Delete token", - "parameters": [ + "type": "string", + "description": "Optional Aiproxy-Channel header", + "name": "Aiproxy-Channel", + "in": "header" + }, { - "type": "integer", - "description": "Token ID", - "name": "id", - "in": "path", - "required": true + "type": "string", + "description": "Optional group ID for internal token requests", + "name": "X-Aiproxy-Group", + "in": "header" + }, + { + "enum": [ + "global", + "own" + ], + "type": "string", + "description": "Optional group channel mode for internal token requests. Values: global, own", + "name": "X-Aiproxy-Group-Channel-Mode", + "in": "header" } ], "responses": { "200": { "description": "OK", "schema": { - "$ref": "#/definitions/middleware.APIResponse" + "type": "object" + }, + "headers": { + "X-RateLimit-Limit-Requests": { + "type": "integer", + "description": "X-RateLimit-Limit-Requests" + }, + "X-RateLimit-Limit-Tokens": { + "type": "integer", + "description": "X-RateLimit-Limit-Tokens" + }, + "X-RateLimit-Remaining-Requests": { + "type": "integer", + "description": "X-RateLimit-Remaining-Requests" + }, + "X-RateLimit-Remaining-Tokens": { + "type": "integer", + "description": "X-RateLimit-Remaining-Tokens" + }, + "X-RateLimit-Reset-Requests": { + "type": "string", + "description": "X-RateLimit-Reset-Requests" + }, + "X-RateLimit-Reset-Tokens": { + "type": "string", + "description": "X-RateLimit-Reset-Tokens" + } } } } } }, - "/api/tokens/{id}/name": { - "post": { + "/api/v3/contents/generations/tasks/{task_id}": { + "get": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Updates the name of a specific token", - "consumes": [ - "application/json" - ], + "description": "Get a Doubao native video generation task.", "produces": [ "application/json" ], "tags": [ - "tokens" + "relay" ], - "summary": "Update token name", + "summary": "Get Doubao native video task", "parameters": [ { - "type": "integer", - "description": "Token ID", - "name": "id", + "type": "string", + "description": "Task ID", + "name": "task_id", "in": "path", "required": true }, { - "description": "Name information", - "name": "name", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/controller.UpdateTokenNameRequest" - } + "type": "string", + "description": "Optional Aiproxy-Channel header", + "name": "Aiproxy-Channel", + "in": "header" + }, + { + "type": "string", + "description": "Optional group ID for internal token requests", + "name": "X-Aiproxy-Group", + "in": "header" + }, + { + "enum": [ + "global", + "own" + ], + "type": "string", + "description": "Optional group channel mode for internal token requests. Values: global, own", + "name": "X-Aiproxy-Group-Channel-Mode", + "in": "header" } ], "responses": { "200": { "description": "OK", "schema": { - "$ref": "#/definitions/middleware.APIResponse" + "type": "object" + }, + "headers": { + "X-RateLimit-Limit-Requests": { + "type": "integer", + "description": "X-RateLimit-Limit-Requests" + }, + "X-RateLimit-Limit-Tokens": { + "type": "integer", + "description": "X-RateLimit-Limit-Tokens" + }, + "X-RateLimit-Remaining-Requests": { + "type": "integer", + "description": "X-RateLimit-Remaining-Requests" + }, + "X-RateLimit-Remaining-Tokens": { + "type": "integer", + "description": "X-RateLimit-Remaining-Tokens" + }, + "X-RateLimit-Reset-Requests": { + "type": "string", + "description": "X-RateLimit-Reset-Requests" + }, + "X-RateLimit-Reset-Tokens": { + "type": "string", + "description": "X-RateLimit-Reset-Tokens" + } } } } - } - }, - "/api/tokens/{id}/status": { - "post": { + }, + "delete": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Updates the status of a specific token", - "consumes": [ - "application/json" - ], + "description": "Delete a Doubao native video generation task.", "produces": [ "application/json" ], "tags": [ - "tokens" + "relay" ], - "summary": "Update token status", + "summary": "Delete Doubao native video task", "parameters": [ { - "type": "integer", - "description": "Token ID", - "name": "id", + "type": "string", + "description": "Task ID", + "name": "task_id", "in": "path", "required": true }, { - "description": "Status information", - "name": "status", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/controller.UpdateTokenStatusRequest" - } + "type": "string", + "description": "Optional Aiproxy-Channel header", + "name": "Aiproxy-Channel", + "in": "header" + }, + { + "type": "string", + "description": "Optional group ID for internal token requests", + "name": "X-Aiproxy-Group", + "in": "header" + }, + { + "enum": [ + "global", + "own" + ], + "type": "string", + "description": "Optional group channel mode for internal token requests. Values: global, own", + "name": "X-Aiproxy-Group-Channel-Mode", + "in": "header" } ], "responses": { "200": { "description": "OK", "schema": { - "$ref": "#/definitions/middleware.APIResponse" + "type": "object" + }, + "headers": { + "X-RateLimit-Limit-Requests": { + "type": "integer", + "description": "X-RateLimit-Limit-Requests" + }, + "X-RateLimit-Limit-Tokens": { + "type": "integer", + "description": "X-RateLimit-Limit-Tokens" + }, + "X-RateLimit-Remaining-Requests": { + "type": "integer", + "description": "X-RateLimit-Remaining-Requests" + }, + "X-RateLimit-Remaining-Tokens": { + "type": "integer", + "description": "X-RateLimit-Remaining-Tokens" + }, + "X-RateLimit-Reset-Requests": { + "type": "string", + "description": "X-RateLimit-Reset-Requests" + }, + "X-RateLimit-Reset-Tokens": { + "type": "string", + "description": "X-RateLimit-Reset-Tokens" + } + } + }, + "204": { + "description": "No Content", + "headers": { + "X-RateLimit-Limit-Requests": { + "type": "integer", + "description": "X-RateLimit-Limit-Requests" + }, + "X-RateLimit-Limit-Tokens": { + "type": "integer", + "description": "X-RateLimit-Limit-Tokens" + }, + "X-RateLimit-Remaining-Requests": { + "type": "integer", + "description": "X-RateLimit-Remaining-Requests" + }, + "X-RateLimit-Remaining-Tokens": { + "type": "integer", + "description": "X-RateLimit-Remaining-Tokens" + }, + "X-RateLimit-Reset-Requests": { + "type": "string", + "description": "X-RateLimit-Reset-Requests" + }, + "X-RateLimit-Reset-Tokens": { + "type": "string", + "description": "X-RateLimit-Reset-Tokens" + } } } } @@ -8138,6 +12924,22 @@ "description": "Optional Aiproxy-Channel header", "name": "Aiproxy-Channel", "in": "header" + }, + { + "type": "string", + "description": "Optional group ID for internal token requests", + "name": "X-Aiproxy-Group", + "in": "header" + }, + { + "enum": [ + "global", + "own" + ], + "type": "string", + "description": "Optional group channel mode for internal token requests. Values: global, own", + "name": "X-Aiproxy-Group-Channel-Mode", + "in": "header" } ], "responses": { @@ -8211,6 +13013,22 @@ "description": "Optional Aiproxy-Channel header", "name": "Aiproxy-Channel", "in": "header" + }, + { + "type": "string", + "description": "Optional group ID for internal token requests", + "name": "X-Aiproxy-Group", + "in": "header" + }, + { + "enum": [ + "global", + "own" + ], + "type": "string", + "description": "Optional group channel mode for internal token requests. Values: global, own", + "name": "X-Aiproxy-Group-Channel-Mode", + "in": "header" } ], "responses": { @@ -8284,6 +13102,22 @@ "description": "Optional Aiproxy-Channel header", "name": "Aiproxy-Channel", "in": "header" + }, + { + "type": "string", + "description": "Optional group ID for internal token requests", + "name": "X-Aiproxy-Group", + "in": "header" + }, + { + "enum": [ + "global", + "own" + ], + "type": "string", + "description": "Optional group channel mode for internal token requests. Values: global, own", + "name": "X-Aiproxy-Group-Channel-Mode", + "in": "header" } ], "responses": { @@ -8352,6 +13186,22 @@ "description": "Optional Aiproxy-Channel header", "name": "Aiproxy-Channel", "in": "header" + }, + { + "type": "string", + "description": "Optional group ID for internal token requests", + "name": "X-Aiproxy-Group", + "in": "header" + }, + { + "enum": [ + "global", + "own" + ], + "type": "string", + "description": "Optional group channel mode for internal token requests. Values: global, own", + "name": "X-Aiproxy-Group-Channel-Mode", + "in": "header" } ], "responses": { @@ -8420,6 +13270,22 @@ "description": "Optional Aiproxy-Channel header", "name": "Aiproxy-Channel", "in": "header" + }, + { + "type": "string", + "description": "Optional group ID for internal token requests", + "name": "X-Aiproxy-Group", + "in": "header" + }, + { + "enum": [ + "global", + "own" + ], + "type": "string", + "description": "Optional group channel mode for internal token requests. Values: global, own", + "name": "X-Aiproxy-Group-Channel-Mode", + "in": "header" } ], "responses": { @@ -8563,6 +13429,22 @@ "description": "Optional Aiproxy-Channel header", "name": "Aiproxy-Channel", "in": "header" + }, + { + "type": "string", + "description": "Optional group ID for internal token requests", + "name": "X-Aiproxy-Group", + "in": "header" + }, + { + "enum": [ + "global", + "own" + ], + "type": "string", + "description": "Optional group channel mode for internal token requests. Values: global, own", + "name": "X-Aiproxy-Group-Channel-Mode", + "in": "header" } ], "responses": { @@ -8643,6 +13525,22 @@ "description": "Optional Aiproxy-Channel header", "name": "Aiproxy-Channel", "in": "header" + }, + { + "type": "string", + "description": "Optional group ID for internal token requests", + "name": "X-Aiproxy-Group", + "in": "header" + }, + { + "enum": [ + "global", + "own" + ], + "type": "string", + "description": "Optional group channel mode for internal token requests. Values: global, own", + "name": "X-Aiproxy-Group-Channel-Mode", + "in": "header" } ], "responses": { @@ -8711,6 +13609,22 @@ "description": "Optional Aiproxy-Channel header", "name": "Aiproxy-Channel", "in": "header" + }, + { + "type": "string", + "description": "Optional group ID for internal token requests", + "name": "X-Aiproxy-Group", + "in": "header" + }, + { + "enum": [ + "global", + "own" + ], + "type": "string", + "description": "Optional group channel mode for internal token requests. Values: global, own", + "name": "X-Aiproxy-Group-Channel-Mode", + "in": "header" } ], "responses": { @@ -8779,6 +13693,22 @@ "description": "Optional Aiproxy-Channel header", "name": "Aiproxy-Channel", "in": "header" + }, + { + "type": "string", + "description": "Optional group ID for internal token requests", + "name": "X-Aiproxy-Group", + "in": "header" + }, + { + "enum": [ + "global", + "own" + ], + "type": "string", + "description": "Optional group channel mode for internal token requests. Values: global, own", + "name": "X-Aiproxy-Group-Channel-Mode", + "in": "header" } ], "responses": { @@ -8913,6 +13843,22 @@ "description": "Optional Aiproxy-Channel header", "name": "Aiproxy-Channel", "in": "header" + }, + { + "type": "string", + "description": "Optional group ID for internal token requests", + "name": "X-Aiproxy-Group", + "in": "header" + }, + { + "enum": [ + "global", + "own" + ], + "type": "string", + "description": "Optional group channel mode for internal token requests. Values: global, own", + "name": "X-Aiproxy-Group-Channel-Mode", + "in": "header" } ], "responses": { @@ -8981,6 +13927,22 @@ "description": "Optional Aiproxy-Channel header", "name": "Aiproxy-Channel", "in": "header" + }, + { + "type": "string", + "description": "Optional group ID for internal token requests", + "name": "X-Aiproxy-Group", + "in": "header" + }, + { + "enum": [ + "global", + "own" + ], + "type": "string", + "description": "Optional group channel mode for internal token requests. Values: global, own", + "name": "X-Aiproxy-Group-Channel-Mode", + "in": "header" } ], "responses": { @@ -9049,6 +14011,22 @@ "description": "Optional Aiproxy-Channel header", "name": "Aiproxy-Channel", "in": "header" + }, + { + "type": "string", + "description": "Optional group ID for internal token requests", + "name": "X-Aiproxy-Group", + "in": "header" + }, + { + "enum": [ + "global", + "own" + ], + "type": "string", + "description": "Optional group channel mode for internal token requests. Values: global, own", + "name": "X-Aiproxy-Group-Channel-Mode", + "in": "header" } ], "responses": { @@ -9115,6 +14093,22 @@ "description": "Optional Aiproxy-Channel header", "name": "Aiproxy-Channel", "in": "header" + }, + { + "type": "string", + "description": "Optional group ID for internal token requests", + "name": "X-Aiproxy-Group", + "in": "header" + }, + { + "enum": [ + "global", + "own" + ], + "type": "string", + "description": "Optional group channel mode for internal token requests. Values: global, own", + "name": "X-Aiproxy-Group-Channel-Mode", + "in": "header" } ], "responses": { @@ -9153,6 +14147,22 @@ "description": "Optional Aiproxy-Channel header", "name": "Aiproxy-Channel", "in": "header" + }, + { + "type": "string", + "description": "Optional group ID for internal token requests", + "name": "X-Aiproxy-Group", + "in": "header" + }, + { + "enum": [ + "global", + "own" + ], + "type": "string", + "description": "Optional group channel mode for internal token requests. Values: global, own", + "name": "X-Aiproxy-Group-Channel-Mode", + "in": "header" } ], "responses": { @@ -9190,6 +14200,22 @@ "description": "Optional Aiproxy-Channel header", "name": "Aiproxy-Channel", "in": "header" + }, + { + "type": "string", + "description": "Optional group ID for internal token requests", + "name": "X-Aiproxy-Group", + "in": "header" + }, + { + "enum": [ + "global", + "own" + ], + "type": "string", + "description": "Optional group channel mode for internal token requests. Values: global, own", + "name": "X-Aiproxy-Group-Channel-Mode", + "in": "header" } ], "responses": { @@ -9227,8 +14253,24 @@ }, { "type": "string", - "description": "Optional Aiproxy-Channel header", - "name": "Aiproxy-Channel", + "description": "Optional Aiproxy-Channel header", + "name": "Aiproxy-Channel", + "in": "header" + }, + { + "type": "string", + "description": "Optional group ID for internal token requests", + "name": "X-Aiproxy-Group", + "in": "header" + }, + { + "enum": [ + "global", + "own" + ], + "type": "string", + "description": "Optional group channel mode for internal token requests. Values: global, own", + "name": "X-Aiproxy-Group-Channel-Mode", "in": "header" } ], @@ -9272,6 +14314,22 @@ "description": "Optional Aiproxy-Channel header", "name": "Aiproxy-Channel", "in": "header" + }, + { + "type": "string", + "description": "Optional group ID for internal token requests", + "name": "X-Aiproxy-Group", + "in": "header" + }, + { + "enum": [ + "global", + "own" + ], + "type": "string", + "description": "Optional group channel mode for internal token requests. Values: global, own", + "name": "X-Aiproxy-Group-Channel-Mode", + "in": "header" } ], "responses": { @@ -9340,6 +14398,22 @@ "description": "Optional Aiproxy-Channel header", "name": "Aiproxy-Channel", "in": "header" + }, + { + "type": "string", + "description": "Optional group ID for internal token requests", + "name": "X-Aiproxy-Group", + "in": "header" + }, + { + "enum": [ + "global", + "own" + ], + "type": "string", + "description": "Optional group channel mode for internal token requests. Values: global, own", + "name": "X-Aiproxy-Group-Channel-Mode", + "in": "header" } ], "responses": { @@ -9408,6 +14482,22 @@ "description": "Optional Aiproxy-Channel header", "name": "Aiproxy-Channel", "in": "header" + }, + { + "type": "string", + "description": "Optional group ID for internal token requests", + "name": "X-Aiproxy-Group", + "in": "header" + }, + { + "enum": [ + "global", + "own" + ], + "type": "string", + "description": "Optional group channel mode for internal token requests. Values: global, own", + "name": "X-Aiproxy-Group-Channel-Mode", + "in": "header" } ], "responses": { @@ -9476,6 +14566,22 @@ "description": "Optional Aiproxy-Channel header", "name": "Aiproxy-Channel", "in": "header" + }, + { + "type": "string", + "description": "Optional group ID for internal token requests", + "name": "X-Aiproxy-Group", + "in": "header" + }, + { + "enum": [ + "global", + "own" + ], + "type": "string", + "description": "Optional group channel mode for internal token requests. Values: global, own", + "name": "X-Aiproxy-Group-Channel-Mode", + "in": "header" } ], "responses": { @@ -9514,6 +14620,122 @@ } } }, + "/v1/videos/edits": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Create a new video by editing a source video", + "produces": [ + "application/json" + ], + "tags": [ + "relay" + ], + "summary": "Edit video", + "parameters": [ + { + "description": "Request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/model.VideosEditRequest" + } + }, + { + "type": "string", + "description": "Optional Aiproxy-Channel header", + "name": "Aiproxy-Channel", + "in": "header" + }, + { + "type": "string", + "description": "Optional group ID for internal token requests", + "name": "X-Aiproxy-Group", + "in": "header" + }, + { + "enum": [ + "global", + "own" + ], + "type": "string", + "description": "Optional group channel mode for internal token requests. Values: global, own", + "name": "X-Aiproxy-Group-Channel-Mode", + "in": "header" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/model.Video" + } + } + } + } + }, + "/v1/videos/extensions": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Create an extension of a completed video", + "produces": [ + "application/json" + ], + "tags": [ + "relay" + ], + "summary": "Extend video", + "parameters": [ + { + "description": "Request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/model.VideosExtensionRequest" + } + }, + { + "type": "string", + "description": "Optional Aiproxy-Channel header", + "name": "Aiproxy-Channel", + "in": "header" + }, + { + "type": "string", + "description": "Optional group ID for internal token requests", + "name": "X-Aiproxy-Group", + "in": "header" + }, + { + "enum": [ + "global", + "own" + ], + "type": "string", + "description": "Optional group channel mode for internal token requests. Values: global, own", + "name": "X-Aiproxy-Group-Channel-Mode", + "in": "header" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/model.Video" + } + } + } + } + }, "/v1/videos/{video_id}": { "get": { "security": [ @@ -9542,6 +14764,22 @@ "description": "Optional Aiproxy-Channel header", "name": "Aiproxy-Channel", "in": "header" + }, + { + "type": "string", + "description": "Optional group ID for internal token requests", + "name": "X-Aiproxy-Group", + "in": "header" + }, + { + "enum": [ + "global", + "own" + ], + "type": "string", + "description": "Optional group channel mode for internal token requests. Values: global, own", + "name": "X-Aiproxy-Group-Channel-Mode", + "in": "header" } ], "responses": { @@ -9580,6 +14818,22 @@ "description": "Optional Aiproxy-Channel header", "name": "Aiproxy-Channel", "in": "header" + }, + { + "type": "string", + "description": "Optional group ID for internal token requests", + "name": "X-Aiproxy-Group", + "in": "header" + }, + { + "enum": [ + "global", + "own" + ], + "type": "string", + "description": "Optional group channel mode for internal token requests. Values: global, own", + "name": "X-Aiproxy-Group-Channel-Mode", + "in": "header" } ], "responses": { @@ -9617,6 +14871,22 @@ "description": "Optional Aiproxy-Channel header", "name": "Aiproxy-Channel", "in": "header" + }, + { + "type": "string", + "description": "Optional group ID for internal token requests", + "name": "X-Aiproxy-Group", + "in": "header" + }, + { + "enum": [ + "global", + "own" + ], + "type": "string", + "description": "Optional group channel mode for internal token requests. Values: global, own", + "name": "X-Aiproxy-Group-Channel-Mode", + "in": "header" } ], "responses": { @@ -9666,6 +14936,22 @@ "description": "Optional Aiproxy-Channel header", "name": "Aiproxy-Channel", "in": "header" + }, + { + "type": "string", + "description": "Optional group ID for internal token requests", + "name": "X-Aiproxy-Group", + "in": "header" + }, + { + "enum": [ + "global", + "own" + ], + "type": "string", + "description": "Optional group channel mode for internal token requests. Values: global, own", + "name": "X-Aiproxy-Group-Channel-Mode", + "in": "header" } ], "responses": { @@ -9722,6 +15008,22 @@ "description": "Optional Aiproxy-Channel header", "name": "Aiproxy-Channel", "in": "header" + }, + { + "type": "string", + "description": "Optional group ID for internal token requests", + "name": "X-Aiproxy-Group", + "in": "header" + }, + { + "enum": [ + "global", + "own" + ], + "type": "string", + "description": "Optional group channel mode for internal token requests. Values: global, own", + "name": "X-Aiproxy-Group-Channel-Mode", + "in": "header" } ], "responses": { @@ -9795,6 +15097,22 @@ "description": "Optional Aiproxy-Channel header", "name": "Aiproxy-Channel", "in": "header" + }, + { + "type": "string", + "description": "Optional group ID for internal token requests", + "name": "X-Aiproxy-Group", + "in": "header" + }, + { + "enum": [ + "global", + "own" + ], + "type": "string", + "description": "Optional group channel mode for internal token requests. Values: global, own", + "name": "X-Aiproxy-Group-Channel-Mode", + "in": "header" } ], "responses": { @@ -9862,12 +15180,74 @@ "remain": { "type": "number" }, - "total": { + "total": { + "type": "number" + } + } + }, + "controller.AddChannelRequest": { + "type": "object", + "properties": { + "base_url": { + "type": "string" + }, + "configs": { + "$ref": "#/definitions/model.ChannelConfigs" + }, + "enabled_auto_balance_check": { + "type": "boolean" + }, + "enabled_no_permission_ban": { + "type": "boolean" + }, + "key": { + "type": "string" + }, + "max_error_rate": { + "type": "number" + }, + "model_mapping": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "models": { + "type": "array", + "items": { + "type": "string" + } + }, + "name": { + "type": "string" + }, + "priority": { + "type": "integer" + }, + "proxy_url": { + "type": "string" + }, + "sets": { + "type": "array", + "items": { + "type": "string" + } + }, + "skip_tls_verify": { + "type": "boolean" + }, + "status": { + "type": "integer" + }, + "type": { + "$ref": "#/definitions/model.ChannelType" + }, + "warn_error_rate": { "type": "number" } } }, - "controller.AddChannelRequest": { + "controller.AddGroupChannelRequest": { "type": "object", "properties": { "base_url": { @@ -9876,12 +15256,12 @@ "configs": { "$ref": "#/definitions/model.ChannelConfigs" }, - "enabled_auto_balance_check": { - "type": "boolean" - }, "enabled_no_permission_ban": { "type": "boolean" }, + "group_id": { + "type": "string" + }, "key": { "type": "string" }, @@ -9923,15 +15303,24 @@ }, "type": { "$ref": "#/definitions/model.ChannelType" - }, - "warn_error_rate": { - "type": "number" } } }, "controller.AddTokenRequest": { "type": "object", "properties": { + "group_channel_models": { + "type": "array", + "items": { + "type": "string" + } + }, + "group_channel_sets": { + "type": "array", + "items": { + "type": "string" + } + }, "models": { "type": "array", "items": { @@ -9953,6 +15342,15 @@ "quota": { "type": "number" }, + "scope": { + "type": "string" + }, + "sets": { + "type": "array", + "items": { + "type": "string" + } + }, "subnets": { "type": "array", "items": { @@ -9998,6 +15396,12 @@ "controller.BuiltinModelConfig": { "type": "object", "properties": { + "allowed_resolutions": { + "type": "array", + "items": { + "type": "string" + } + }, "config": { "type": "object", "additionalProperties": {} @@ -10005,6 +15409,9 @@ "created_at": { "type": "string" }, + "disable_resolution_fuzzy_match": { + "type": "boolean" + }, "exclude_from_tests": { "type": "boolean" }, @@ -10190,6 +15597,20 @@ } } }, + "controller.GroupChannelTestResult": { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.GroupChannelTest" + }, + "message": { + "type": "string" + }, + "success": { + "type": "boolean" + } + } + }, "controller.GroupMCPResponse": { "type": "object", "properties": { @@ -10487,6 +15908,12 @@ "created_at": { "type": "string" }, + "group_channel_request_count": { + "type": "integer" + }, + "group_channel_used_amount": { + "type": "number" + }, "id": { "type": "string" }, @@ -10571,6 +15998,9 @@ "properties": { "dsn": { "type": "string" + }, + "group_id": { + "type": "string" } } }, @@ -10988,9 +16418,100 @@ } } }, + "controller.SaveGroupScopeModelConfigsRequest": { + "type": "object", + "properties": { + "allowed_resolutions": { + "type": "array", + "items": { + "type": "string" + } + }, + "config": { + "type": "object", + "additionalProperties": {} + }, + "created_at": { + "type": "string" + }, + "disable_resolution_fuzzy_match": { + "type": "boolean" + }, + "exclude_from_tests": { + "type": "boolean" + }, + "force_save_detail": { + "type": "boolean" + }, + "group_id": { + "type": "string" + }, + "max_image_generation_count": { + "type": "integer" + }, + "max_video_generation_count": { + "type": "integer" + }, + "max_video_generation_seconds": { + "type": "integer" + }, + "model": { + "type": "string" + }, + "owner": { + "$ref": "#/definitions/model.ModelOwner" + }, + "plugin": { + "type": "object", + "additionalProperties": { + "type": "object", + "additionalProperties": {} + } + }, + "price": { + "$ref": "#/definitions/model.Price" + }, + "request_body_storage_max_size": { + "type": "integer" + }, + "response_body_storage_max_size": { + "type": "integer" + }, + "retry_times": { + "type": "integer" + }, + "rpm": { + "type": "integer" + }, + "summary_claude_long_context": { + "type": "boolean" + }, + "summary_service_tier": { + "type": "boolean" + }, + "timeout_config": { + "$ref": "#/definitions/model.TimeoutConfig" + }, + "tpm": { + "type": "integer" + }, + "type": { + "$ref": "#/definitions/mode.Mode" + }, + "updated_at": { + "type": "string" + } + } + }, "controller.SaveModelConfigsRequest": { "type": "object", "properties": { + "allowed_resolutions": { + "type": "array", + "items": { + "type": "string" + } + }, "config": { "type": "object", "additionalProperties": {} @@ -10998,6 +16519,9 @@ "created_at": { "type": "string" }, + "disable_resolution_fuzzy_match": { + "type": "boolean" + }, "exclude_from_tests": { "type": "boolean" }, @@ -11069,10 +16593,127 @@ } } }, - "controller.TestChannelRequest": { + "controller.TestChannelRequest": { + "type": "object", + "required": [ + "key", + "type" + ], + "properties": { + "base_url": { + "type": "string" + }, + "configs": { + "type": "object", + "additionalProperties": {} + }, + "key": { + "type": "string" + }, + "model_mapping": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "models": { + "type": "array", + "items": { + "type": "string" + } + }, + "name": { + "type": "string" + }, + "proxy_url": { + "type": "string" + }, + "skip_tls_verify": { + "type": "boolean" + }, + "type": { + "type": "integer" + } + } + }, + "controller.TestGroupChannelRequest": { + "type": "object", + "properties": { + "base_url": { + "type": "string" + }, + "configs": { + "$ref": "#/definitions/model.ChannelConfigs" + }, + "enabled_no_permission_ban": { + "type": "boolean" + }, + "group_id": { + "type": "string" + }, + "key": { + "type": "string" + }, + "max_error_rate": { + "type": "number" + }, + "model_mapping": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "models": { + "type": "array", + "items": { + "type": "string" + } + }, + "name": { + "type": "string" + }, + "priority": { + "type": "integer" + }, + "proxy_url": { + "type": "string" + }, + "sets": { + "type": "array", + "items": { + "type": "string" + } + }, + "skip_tls_verify": { + "type": "boolean" + }, + "status": { + "type": "integer" + }, + "type": { + "$ref": "#/definitions/model.ChannelType" + } + } + }, + "controller.TestResult": { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.ChannelTest" + }, + "message": { + "type": "string" + }, + "success": { + "type": "boolean" + } + } + }, + "controller.TestSingleGroupChannelRequest": { "type": "object", "required": [ "key", + "model", "type" ], "properties": { @@ -11080,30 +16721,35 @@ "type": "string" }, "configs": { - "type": "object", - "additionalProperties": {} + "$ref": "#/definitions/model.ChannelConfigs" + }, + "group_id": { + "type": "string" }, "key": { "type": "string" }, + "model": { + "type": "string" + }, "model_mapping": { "type": "object", "additionalProperties": { "type": "string" } }, - "models": { - "type": "array", - "items": { - "type": "string" - } - }, "name": { "type": "string" }, "proxy_url": { "type": "string" }, + "sets": { + "type": "array", + "items": { + "type": "string" + } + }, "skip_tls_verify": { "type": "boolean" }, @@ -11112,20 +16758,6 @@ } } }, - "controller.TestResult": { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/model.ChannelTest" - }, - "message": { - "type": "string" - }, - "success": { - "type": "boolean" - } - } - }, "controller.TestSingleModelRequest": { "type": "object", "required": [ @@ -11179,6 +16811,24 @@ "group": { "type": "string" }, + "group_channel_models": { + "type": "array", + "items": { + "type": "string" + } + }, + "group_channel_request_count": { + "type": "integer" + }, + "group_channel_sets": { + "type": "array", + "items": { + "type": "string" + } + }, + "group_channel_used_amount": { + "type": "number" + }, "id": { "type": "integer" }, @@ -11215,6 +16865,15 @@ "request_count": { "type": "integer" }, + "scope": { + "$ref": "#/definitions/model.ChannelScope" + }, + "sets": { + "type": "array", + "items": { + "type": "string" + } + }, "status": { "type": "integer" }, @@ -11569,7 +17228,15 @@ 27, 28, 29, - 30 + 30, + 31, + 32, + 33, + 34, + 35, + 36, + 37, + 38 ], "x-enum-varnames": [ "Unknown", @@ -11602,7 +17269,15 @@ "GeminiVideo", "GeminiVideoOperations", "GeminiTTS", - "GeminiImage" + "GeminiImage", + "GeminiFiles", + "VideosEdits", + "VideosExtensions", + "AliVideo", + "AliVideoTasks", + "DoubaoVideo", + "DoubaoVideoTasks", + "DoubaoVideoTasksDelete" ] }, "model.Amount": { @@ -11796,6 +17471,17 @@ "type": "object", "additionalProperties": {} }, + "model.ChannelScope": { + "type": "string", + "enum": [ + "global", + "group" + ], + "x-enum-varnames": [ + "ChannelScopeGlobal", + "ChannelScopeGroup" + ] + }, "model.ChannelTest": { "type": "object", "properties": { @@ -12141,234 +17827,548 @@ "instructions": { "type": "string" }, - "max_output_tokens": { + "max_output_tokens": { + "type": "integer" + }, + "max_tool_calls": { + "type": "integer" + }, + "metadata": { + "type": "object", + "additionalProperties": {} + }, + "model": { + "type": "string" + }, + "parallel_tool_calls": { + "type": "boolean" + }, + "previous_response_id": { + "type": "string" + }, + "prompt_cache_key": { + "type": "string" + }, + "prompt_cache_retention": { + "type": "string" + }, + "reasoning": { + "$ref": "#/definitions/model.ResponseReasoning" + }, + "safety_identifier": { + "type": "string" + }, + "service_tier": { + "type": "string" + }, + "store": { + "type": "boolean" + }, + "stream": { + "type": "boolean" + }, + "temperature": { + "type": "number" + }, + "text": { + "$ref": "#/definitions/model.ResponseText" + }, + "tool_choice": {}, + "tools": { + "type": "array", + "items": { + "$ref": "#/definitions/model.ResponseTool" + } + }, + "top_logprobs": { + "type": "integer" + }, + "top_p": { + "type": "number" + }, + "truncation": { + "type": "string" + }, + "user": { + "description": "Deprecated, use prompt_cache_key", + "type": "string" + } + } + }, + "model.DashboardResponse": { + "type": "object", + "properties": { + "audio_input_amount": { + "type": "number" + }, + "audio_input_tokens": { + "type": "integer" + }, + "audio_output_amount": { + "type": "number" + }, + "audio_output_tokens": { + "type": "integer" + }, + "cache_creation_amount": { + "type": "number" + }, + "cache_creation_count": { + "type": "integer" + }, + "cache_creation_tokens": { + "type": "integer" + }, + "cache_hit_count": { + "type": "integer" + }, + "cached_amount": { + "type": "number" + }, + "cached_tokens": { + "type": "integer" + }, + "channels": { + "type": "array", + "items": { + "type": "integer" + } + }, + "chart_data": { + "type": "array", + "items": { + "$ref": "#/definitions/model.ChartData" + } + }, + "claude_long_context": { + "$ref": "#/definitions/model.SummaryDataSet" + }, + "exception_count": { + "type": "integer" + }, + "image_input_amount": { + "type": "number" + }, + "image_input_tokens": { + "type": "integer" + }, + "image_output_amount": { + "type": "number" + }, + "image_output_tokens": { + "type": "integer" + }, + "input_amount": { + "type": "number" + }, + "input_tokens": { + "type": "integer" + }, + "max_rpm": { + "type": "integer" + }, + "max_tpm": { + "type": "integer" + }, + "models": { + "type": "array", + "items": { + "type": "string" + } + }, + "output_amount": { + "type": "number" + }, + "output_tokens": { + "type": "integer" + }, + "reasoning_tokens": { + "type": "integer" + }, + "request_count": { + "type": "integer" + }, + "retry_count": { + "type": "integer" + }, + "rpm": { + "type": "integer" + }, + "service_tier_flex": { + "$ref": "#/definitions/model.SummaryDataSet" + }, + "service_tier_priority": { + "$ref": "#/definitions/model.SummaryDataSet" + }, + "status_2xx_count": { + "type": "integer" + }, + "status_400_count": { "type": "integer" }, - "max_tool_calls": { + "status_429_count": { "type": "integer" }, - "metadata": { - "type": "object", - "additionalProperties": {} + "status_4xx_count": { + "type": "integer" }, - "model": { - "type": "string" + "status_500_count": { + "type": "integer" }, - "parallel_tool_calls": { - "type": "boolean" + "status_5xx_count": { + "type": "integer" }, - "previous_response_id": { - "type": "string" + "status_other_count": { + "type": "integer" }, - "prompt_cache_key": { - "type": "string" + "total_count": { + "description": "use Count.RequestCount instead", + "type": "integer" }, - "prompt_cache_retention": { - "type": "string" + "total_time_milliseconds": { + "type": "integer" }, - "reasoning": { - "$ref": "#/definitions/model.ResponseReasoning" + "total_tokens": { + "type": "integer" }, - "safety_identifier": { - "type": "string" + "total_ttfb_milliseconds": { + "type": "integer" }, - "service_tier": { - "type": "string" + "tpm": { + "type": "integer" }, - "store": { - "type": "boolean" + "used_amount": { + "type": "number" }, - "stream": { - "type": "boolean" + "video_input_amount": { + "type": "number" }, - "temperature": { + "video_input_tokens": { + "type": "integer" + }, + "web_search_amount": { "type": "number" }, - "text": { - "$ref": "#/definitions/model.ResponseText" + "web_search_count": { + "type": "integer" + } + } + }, + "model.DashboardV3Response": { + "type": "object", + "properties": { + "channels": { + "type": "array", + "items": { + "type": "integer" + } }, - "tool_choice": {}, - "tools": { + "models": { "type": "array", "items": { - "$ref": "#/definitions/model.ResponseTool" + "type": "string" } }, - "top_logprobs": { + "rpm": { "type": "integer" }, - "top_p": { - "type": "number" - }, - "truncation": { - "type": "string" + "time_series": { + "type": "array", + "items": { + "$ref": "#/definitions/model.TimeSummaryDataV2" + } }, - "user": { - "description": "Deprecated, use prompt_cache_key", + "tpm": { + "type": "integer" + } + } + }, + "model.Document": { + "type": "object", + "properties": { + "text": { "type": "string" } } }, - "model.DashboardResponse": { + "model.EmbeddingPromptTokensDetails": { "type": "object", "properties": { - "audio_input_amount": { - "type": "number" - }, - "audio_input_tokens": { - "type": "integer" - }, - "audio_output_amount": { - "type": "number" - }, - "audio_output_tokens": { + "image_tokens": { "type": "integer" }, - "cache_creation_amount": { - "type": "number" - }, - "cache_creation_count": { + "text_tokens": { "type": "integer" }, - "cache_creation_tokens": { + "video_tokens": { "type": "integer" - }, - "cache_hit_count": { + } + } + }, + "model.EmbeddingRequest": { + "type": "object", + "properties": { + "dimensions": { "type": "integer" }, - "cached_amount": { - "type": "number" + "encoding_format": { + "type": "string" }, - "cached_tokens": { - "type": "integer" + "input": { + "type": "string" }, - "channels": { + "model": { + "type": "string" + } + } + }, + "model.EmbeddingResponse": { + "type": "object", + "properties": { + "data": { "type": "array", "items": { - "type": "integer" + "$ref": "#/definitions/model.EmbeddingResponseItem" } }, - "chart_data": { + "model": { + "type": "string" + }, + "object": { + "type": "string" + }, + "usage": { + "$ref": "#/definitions/model.EmbeddingUsage" + } + } + }, + "model.EmbeddingResponseItem": { + "type": "object", + "properties": { + "embedding": { "type": "array", "items": { - "$ref": "#/definitions/model.ChartData" + "type": "number" } }, - "claude_long_context": { - "$ref": "#/definitions/model.SummaryDataSet" + "index": { + "type": "integer" }, - "exception_count": { + "object": { + "type": "string" + } + } + }, + "model.EmbeddingUsage": { + "type": "object", + "properties": { + "prompt_tokens": { "type": "integer" }, - "image_input_amount": { - "type": "number" + "prompt_tokens_details": { + "$ref": "#/definitions/model.EmbeddingPromptTokensDetails" }, - "image_input_tokens": { + "total_tokens": { "type": "integer" + } + } + }, + "model.ExtraContent": { + "type": "object", + "properties": { + "google": { + "$ref": "#/definitions/model.GoogleExtraContent" + } + } + }, + "model.FinishReason": { + "type": "string", + "enum": [ + "stop", + "length", + "content_filter", + "tool_calls", + "function_call" + ], + "x-enum-varnames": [ + "FinishReasonStop", + "FinishReasonLength", + "FinishReasonContentFilter", + "FinishReasonToolCalls", + "FinishReasonFunctionCall" + ] + }, + "model.Function": { + "type": "object", + "properties": { + "arguments": { + "type": "string" }, - "image_output_amount": { - "type": "number" + "description": { + "type": "string" }, - "image_output_tokens": { - "type": "integer" + "name": { + "type": "string" }, - "input_amount": { + "parameters": {} + } + }, + "model.GeneralOpenAIRequest": { + "type": "object", + "properties": { + "audio": { + "$ref": "#/definitions/model.Audio" + }, + "enable_thinking": { + "type": "boolean" + }, + "frequency_penalty": { "type": "number" }, - "input_tokens": { - "type": "integer" + "function_call": {}, + "functions": {}, + "input": {}, + "logit_bias": {}, + "logprobs": { + "type": "boolean" }, - "max_rpm": { + "max_completion_tokens": { "type": "integer" }, - "max_tpm": { + "max_tokens": { "type": "integer" }, - "models": { + "messages": { + "type": "array", + "items": { + "$ref": "#/definitions/model.Message" + } + }, + "metadata": {}, + "modalities": { "type": "array", "items": { "type": "string" } }, - "output_amount": { - "type": "number" + "model": { + "type": "string" }, - "output_tokens": { + "n": { "type": "integer" }, - "reasoning_tokens": { + "num_ctx": { "type": "integer" }, - "request_count": { - "type": "integer" + "parallel_tool_calls": { + "type": "boolean" }, - "retry_count": { - "type": "integer" + "presence_penalty": { + "type": "number" }, - "rpm": { - "type": "integer" + "prompt": {}, + "prompt_cache_key": { + "type": "string" }, - "service_tier_flex": { - "$ref": "#/definitions/model.SummaryDataSet" + "prompt_cache_retention": { + "type": "string" }, - "service_tier_priority": { - "$ref": "#/definitions/model.SummaryDataSet" + "reasoning_effort": { + "type": "string" }, - "status_2xx_count": { - "type": "integer" + "response_format": { + "$ref": "#/definitions/model.ResponseFormat" }, - "status_400_count": { - "type": "integer" + "seed": { + "type": "number" }, - "status_429_count": { - "type": "integer" + "service_tier": { + "type": "string" }, - "status_4xx_count": { - "type": "integer" + "size": { + "type": "string" }, - "status_500_count": { - "type": "integer" + "stop": {}, + "stream": { + "type": "boolean" }, - "status_5xx_count": { - "type": "integer" + "stream_options": { + "$ref": "#/definitions/model.StreamOptions" }, - "status_other_count": { - "type": "integer" + "temperature": { + "type": "number" }, - "total_count": { - "description": "use Count.RequestCount instead", - "type": "integer" + "thinking": { + "description": "aiproxy control field", + "allOf": [ + { + "$ref": "#/definitions/model.GeneralThinking" + } + ] }, - "total_time_milliseconds": { + "thinking_budget": { "type": "integer" }, - "total_tokens": { - "type": "integer" + "tool_choice": {}, + "tools": { + "type": "array", + "items": { + "$ref": "#/definitions/model.Tool" + } }, - "total_ttfb_milliseconds": { + "top_k": { "type": "integer" }, - "tpm": { + "top_logprobs": { "type": "integer" }, - "used_amount": { - "type": "number" - }, - "video_input_amount": { + "top_p": { "type": "number" }, - "video_input_tokens": { + "user": { + "type": "string" + } + } + }, + "model.GeneralThinking": { + "type": "object", + "properties": { + "budget_tokens": { + "description": "when type is \"disabled\", this field must be 0", "type": "integer" }, - "web_search_amount": { - "type": "number" + "type": { + "$ref": "#/definitions/model.ClaudeThinkingType" + } + } + }, + "model.GetGroupChannelLogsResult": { + "type": "object", + "properties": { + "logs": { + "type": "array", + "items": { + "$ref": "#/definitions/model.GroupChannelLog" + } }, - "web_search_count": { + "models": { + "type": "array", + "items": { + "type": "string" + } + }, + "token_names": { + "type": "array", + "items": { + "type": "string" + } + }, + "total": { "type": "integer" } } }, - "model.DashboardV3Response": { + "model.GetGroupLogsResult": { "type": "object", "properties": { "channels": { @@ -12377,373 +18377,356 @@ "type": "integer" } }, + "logs": { + "type": "array", + "items": { + "$ref": "#/definitions/model.Log" + } + }, "models": { "type": "array", "items": { "type": "string" } }, - "rpm": { + "token_names": { + "type": "array", + "items": { + "type": "string" + } + }, + "total": { "type": "integer" + } + } + }, + "model.GetLogsResult": { + "type": "object", + "properties": { + "channels": { + "type": "array", + "items": { + "type": "integer" + } }, - "time_series": { + "logs": { "type": "array", "items": { - "$ref": "#/definitions/model.TimeSummaryDataV2" + "$ref": "#/definitions/model.Log" } }, - "tpm": { + "models": { + "type": "array", + "items": { + "type": "string" + } + }, + "total": { "type": "integer" } } }, - "model.Document": { + "model.GoogleExtraContent": { "type": "object", "properties": { - "text": { + "thought_signature": { "type": "string" } } }, - "model.EmbeddingPromptTokensDetails": { + "model.Group": { "type": "object", "properties": { - "image_tokens": { + "available_sets": { + "type": "array", + "items": { + "type": "string" + } + }, + "balance_alert_enabled": { + "type": "boolean" + }, + "balance_alert_threshold": { + "type": "number" + }, + "created_at": { + "type": "string" + }, + "group_channel_request_count": { "type": "integer" }, - "text_tokens": { + "group_channel_used_amount": { + "type": "number" + }, + "id": { + "type": "string" + }, + "request_count": { "type": "integer" }, - "video_tokens": { + "rpm_ratio": { + "type": "number" + }, + "status": { "type": "integer" + }, + "tpm_ratio": { + "type": "number" + }, + "used_amount": { + "type": "number" } } }, - "model.EmbeddingRequest": { + "model.GroupChannel": { "type": "object", "properties": { - "dimensions": { - "type": "integer" + "base_url": { + "type": "string" }, - "encoding_format": { + "configs": { + "$ref": "#/definitions/model.ChannelConfigs" + }, + "created_at": { "type": "string" }, - "input": { + "enabled_no_permission_ban": { + "type": "boolean" + }, + "group_channel_tests": { + "type": "array", + "items": { + "$ref": "#/definitions/model.GroupChannelTest" + } + }, + "group_id": { "type": "string" }, - "model": { + "id": { + "type": "integer" + }, + "key": { "type": "string" - } - } - }, - "model.EmbeddingResponse": { - "type": "object", - "properties": { - "data": { + }, + "last_test_error_at": { + "type": "string" + }, + "max_error_rate": { + "type": "number" + }, + "model_mapping": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "models": { "type": "array", "items": { - "$ref": "#/definitions/model.EmbeddingResponseItem" + "type": "string" } }, - "model": { + "name": { "type": "string" }, - "object": { + "priority": { + "type": "integer" + }, + "proxy_url": { "type": "string" }, - "usage": { - "$ref": "#/definitions/model.EmbeddingUsage" - } - } - }, - "model.EmbeddingResponseItem": { - "type": "object", - "properties": { - "embedding": { + "request_count": { + "type": "integer" + }, + "retry_count": { + "type": "integer" + }, + "sets": { "type": "array", "items": { - "type": "number" + "type": "string" } }, - "index": { - "type": "integer" + "skip_tls_verify": { + "type": "boolean" }, - "object": { - "type": "string" - } - } - }, - "model.EmbeddingUsage": { - "type": "object", - "properties": { - "prompt_tokens": { + "status": { "type": "integer" }, - "prompt_tokens_details": { - "$ref": "#/definitions/model.EmbeddingPromptTokensDetails" + "type": { + "$ref": "#/definitions/model.ChannelType" }, - "total_tokens": { - "type": "integer" - } - } - }, - "model.ExtraContent": { - "type": "object", - "properties": { - "google": { - "$ref": "#/definitions/model.GoogleExtraContent" + "used_amount": { + "type": "number" } } }, - "model.FinishReason": { - "type": "string", - "enum": [ - "stop", - "length", - "content_filter", - "tool_calls", - "function_call" - ], - "x-enum-varnames": [ - "FinishReasonStop", - "FinishReasonLength", - "FinishReasonContentFilter", - "FinishReasonToolCalls", - "FinishReasonFunctionCall" - ] - }, - "model.Function": { + "model.GroupChannelBasicInfo": { "type": "object", "properties": { - "arguments": { + "group_id": { "type": "string" }, - "description": { - "type": "string" + "id": { + "type": "integer" }, "name": { "type": "string" }, - "parameters": {} + "type": { + "$ref": "#/definitions/model.ChannelType" + } } }, - "model.GeneralOpenAIRequest": { + "model.GroupChannelLog": { "type": "object", "properties": { - "audio": { - "$ref": "#/definitions/model.Audio" - }, - "enable_thinking": { - "type": "boolean" - }, - "frequency_penalty": { - "type": "number" + "amount": { + "$ref": "#/definitions/model.Amount" }, - "function_call": {}, - "functions": {}, - "input": {}, - "logit_bias": {}, - "logprobs": { - "type": "boolean" + "async_usage_status": { + "$ref": "#/definitions/model.AsyncUsageStatus" }, - "max_completion_tokens": { + "code": { "type": "integer" }, - "max_tokens": { - "type": "integer" + "content": { + "type": "string" }, - "messages": { - "type": "array", - "items": { - "$ref": "#/definitions/model.Message" - } + "created_at": { + "type": "string" }, - "metadata": {}, - "model": { + "endpoint": { "type": "string" }, - "num_ctx": { + "group": { + "type": "string" + }, + "group_channel_id": { "type": "integer" }, - "presence_penalty": { - "type": "number" + "id": { + "type": "integer" }, - "prompt": {}, - "prompt_cache_key": { + "ip": { "type": "string" }, - "prompt_cache_retention": { - "type": "string" + "metadata": { + "type": "object", + "additionalProperties": { + "type": "string" + } }, - "reasoning_effort": { - "type": "string" + "mode": { + "type": "integer" }, - "response_format": { - "$ref": "#/definitions/model.ResponseFormat" + "model": { + "type": "string" }, - "seed": { - "type": "number" + "price": { + "$ref": "#/definitions/model.Price" }, - "service_tier": { + "prompt_cache_key": { "type": "string" }, - "size": { + "request_at": { "type": "string" }, - "stop": {}, - "stream": { - "type": "boolean" + "request_detail": { + "$ref": "#/definitions/model.GroupChannelRequestDetail" }, - "stream_options": { - "$ref": "#/definitions/model.StreamOptions" + "request_id": { + "type": "string" }, - "temperature": { - "type": "number" + "retry_at": { + "type": "string" }, - "thinking": { - "description": "aiproxy control field", - "allOf": [ - { - "$ref": "#/definitions/model.GeneralThinking" - } - ] + "retry_times": { + "type": "integer" }, - "thinking_budget": { + "token_id": { "type": "integer" }, - "tool_choice": {}, - "tools": { - "type": "array", - "items": { - "$ref": "#/definitions/model.Tool" - } + "token_name": { + "type": "string" }, - "top_k": { + "ttfb_milliseconds": { "type": "integer" }, - "top_logprobs": { - "type": "integer" + "upstream_id": { + "type": "string" }, - "top_p": { - "type": "number" + "usage": { + "$ref": "#/definitions/model.Usage" + }, + "usage_context": { + "$ref": "#/definitions/model.UsageContext" }, "user": { "type": "string" } } }, - "model.GeneralThinking": { + "model.GroupChannelRequestDetail": { "type": "object", "properties": { - "budget_tokens": { - "description": "when type is \"disabled\", this field must be 0", + "id": { "type": "integer" }, - "type": { - "$ref": "#/definitions/model.ClaudeThinkingType" - } - } - }, - "model.GetGroupLogsResult": { - "type": "object", - "properties": { - "channels": { - "type": "array", - "items": { - "type": "integer" - } - }, - "logs": { - "type": "array", - "items": { - "$ref": "#/definitions/model.Log" - } - }, - "models": { - "type": "array", - "items": { - "type": "string" - } - }, - "token_names": { - "type": "array", - "items": { - "type": "string" - } - }, - "total": { + "log_id": { "type": "integer" - } - } - }, - "model.GetLogsResult": { - "type": "object", - "properties": { - "channels": { - "type": "array", - "items": { - "type": "integer" - } }, - "logs": { - "type": "array", - "items": { - "$ref": "#/definitions/model.Log" - } + "request_body": { + "type": "string" }, - "models": { - "type": "array", - "items": { - "type": "string" - } + "request_body_truncated": { + "type": "boolean" }, - "total": { - "type": "integer" - } - } - }, - "model.GoogleExtraContent": { - "type": "object", - "properties": { - "thought_signature": { + "response_body": { "type": "string" + }, + "response_body_truncated": { + "type": "boolean" } } }, - "model.Group": { + "model.GroupChannelTest": { "type": "object", "properties": { - "available_sets": { - "type": "array", - "items": { - "type": "string" - } + "actual_model": { + "type": "string" }, - "balance_alert_enabled": { - "type": "boolean" + "channel_name": { + "type": "string" }, - "balance_alert_threshold": { - "type": "number" + "channel_type": { + "$ref": "#/definitions/model.ChannelType" }, - "created_at": { - "type": "string" + "code": { + "type": "integer" }, - "id": { + "group_channel_id": { + "type": "integer" + }, + "group_id": { "type": "string" }, - "request_count": { - "type": "integer" + "mode": { + "$ref": "#/definitions/mode.Mode" }, - "rpm_ratio": { - "type": "number" + "model": { + "type": "string" }, - "status": { - "type": "integer" + "response": { + "type": "string" }, - "tpm_ratio": { - "type": "number" + "success": { + "type": "boolean" }, - "used_amount": { + "test_at": { + "type": "string" + }, + "took": { "type": "number" } } @@ -13078,6 +19061,91 @@ } } }, + "model.GroupScopeModelConfig": { + "type": "object", + "properties": { + "allowed_resolutions": { + "type": "array", + "items": { + "type": "string" + } + }, + "config": { + "type": "object", + "additionalProperties": {} + }, + "created_at": { + "type": "string" + }, + "disable_resolution_fuzzy_match": { + "type": "boolean" + }, + "exclude_from_tests": { + "type": "boolean" + }, + "force_save_detail": { + "type": "boolean" + }, + "group_id": { + "type": "string" + }, + "max_image_generation_count": { + "type": "integer" + }, + "max_video_generation_count": { + "type": "integer" + }, + "max_video_generation_seconds": { + "type": "integer" + }, + "model": { + "type": "string" + }, + "owner": { + "$ref": "#/definitions/model.ModelOwner" + }, + "plugin": { + "type": "object", + "additionalProperties": { + "type": "object", + "additionalProperties": {} + } + }, + "price": { + "$ref": "#/definitions/model.Price" + }, + "request_body_storage_max_size": { + "type": "integer" + }, + "response_body_storage_max_size": { + "type": "integer" + }, + "retry_times": { + "type": "integer" + }, + "rpm": { + "type": "integer" + }, + "summary_claude_long_context": { + "type": "boolean" + }, + "summary_service_tier": { + "type": "boolean" + }, + "timeout_config": { + "$ref": "#/definitions/model.TimeoutConfig" + }, + "tpm": { + "type": "integer" + }, + "type": { + "$ref": "#/definitions/mode.Mode" + }, + "updated_at": { + "type": "string" + } + } + }, "model.ImageData": { "type": "object", "properties": { @@ -13243,10 +19311,20 @@ "description": "Fields for function_result type", "type": "string" }, + "detail": { + "type": "string" + }, + "file_id": { + "type": "string" + }, "id": { "description": "Fields for function_call type", "type": "string" }, + "image_url": { + "description": "Fields for input_image type", + "type": "string" + }, "name": { "type": "string" }, @@ -13402,9 +19480,6 @@ "retry_times": { "type": "integer" }, - "service_tier": { - "type": "string" - }, "token_id": { "type": "integer" }, @@ -13514,6 +19589,12 @@ "model.ModelConfig": { "type": "object", "properties": { + "allowed_resolutions": { + "type": "array", + "items": { + "type": "string" + } + }, "config": { "type": "object", "additionalProperties": {} @@ -13521,6 +19602,9 @@ "created_at": { "type": "string" }, + "disable_resolution_fuzzy_match": { + "type": "boolean" + }, "exclude_from_tests": { "type": "boolean" }, @@ -13843,12 +19927,21 @@ "description": "Unix timestamp, 0 means no end limit", "type": "integer" }, + "input_media": { + "type": "boolean" + }, "input_token_max": { "type": "integer" }, "input_token_min": { "type": "integer" }, + "input_video": { + "type": "boolean" + }, + "output_audio": { + "type": "boolean" + }, "output_token_max": { "type": "integer" }, @@ -14345,6 +20438,19 @@ "model.ResponseTextFormat": { "type": "object", "properties": { + "description": { + "type": "string" + }, + "name": { + "type": "string" + }, + "schema": { + "type": "object", + "additionalProperties": {} + }, + "strict": { + "type": "boolean" + }, "type": { "type": "string" } @@ -14643,6 +20749,9 @@ "exception_count": { "type": "integer" }, + "group_channel_id": { + "type": "integer" + }, "group_id": { "type": "string" }, @@ -14914,6 +21023,18 @@ "model.UpdateTokenRequest": { "type": "object", "properties": { + "group_channel_models": { + "type": "array", + "items": { + "type": "string" + } + }, + "group_channel_sets": { + "type": "array", + "items": { + "type": "string" + } + }, "models": { "type": "array", "items": { @@ -14936,6 +21057,15 @@ "description": "Quota system", "type": "number" }, + "scope": { + "type": "string" + }, + "sets": { + "type": "array", + "items": { + "type": "string" + } + }, "status": { "type": "integer" }, @@ -14991,23 +21121,26 @@ "model.UsageContext": { "type": "object", "properties": { - "quality": { - "type": "string" + "input_media": { + "type": "boolean" }, - "resolution": { - "type": "string" + "input_video": { + "type": "boolean" }, "native_resolution": { "type": "string" }, - "service_tier": { + "output_audio": { + "type": "boolean" + }, + "quality": { "type": "string" }, - "input_video": { - "type": "boolean" + "resolution": { + "type": "string" }, - "output_audio": { - "type": "boolean" + "service_tier": { + "type": "string" } } }, @@ -15118,9 +21251,6 @@ "seconds": { "type": "integer" }, - "size": { - "type": "string" - }, "width": { "type": "integer" } @@ -15189,6 +21319,44 @@ "VideoStatusCancelled" ] }, + "model.VideosEditRequest": { + "type": "object", + "properties": { + "input_reference": { + "type": "string" + }, + "model": { + "type": "string" + }, + "prompt": { + "type": "string" + }, + "seconds": {}, + "size": { + "type": "string" + }, + "video": {} + } + }, + "model.VideosExtensionRequest": { + "type": "object", + "properties": { + "input_reference": { + "type": "string" + }, + "model": { + "type": "string" + }, + "prompt": { + "type": "string" + }, + "seconds": {}, + "size": { + "type": "string" + }, + "video": {} + } + }, "model.VideosRemixRequest": { "type": "object", "properties": { @@ -15268,4 +21436,4 @@ "in": "header" } } -} +} \ No newline at end of file diff --git a/core/docs/swagger.yaml b/core/docs/swagger.yaml index f554968a..48ce6bf2 100644 --- a/core/docs/swagger.yaml +++ b/core/docs/swagger.yaml @@ -61,8 +61,55 @@ definitions: warn_error_rate: type: number type: object + controller.AddGroupChannelRequest: + properties: + base_url: + type: string + configs: + $ref: '#/definitions/model.ChannelConfigs' + enabled_no_permission_ban: + type: boolean + group_id: + type: string + key: + type: string + max_error_rate: + type: number + model_mapping: + additionalProperties: + type: string + type: object + models: + items: + type: string + type: array + name: + type: string + priority: + type: integer + proxy_url: + type: string + sets: + items: + type: string + type: array + skip_tls_verify: + type: boolean + status: + type: integer + type: + $ref: '#/definitions/model.ChannelType' + type: object controller.AddTokenRequest: properties: + group_channel_models: + items: + type: string + type: array + group_channel_sets: + items: + type: string + type: array models: items: type: string @@ -77,6 +124,12 @@ definitions: type: string quota: type: number + scope: + type: string + sets: + items: + type: string + type: array subnets: items: type: string @@ -106,11 +159,17 @@ definitions: type: object controller.BuiltinModelConfig: properties: + allowed_resolutions: + items: + type: string + type: array config: additionalProperties: {} type: object created_at: type: string + disable_resolution_fuzzy_match: + type: boolean exclude_from_tests: type: boolean force_save_detail: @@ -233,6 +292,15 @@ definitions: type: string type: array type: object + controller.GroupChannelTestResult: + properties: + data: + $ref: '#/definitions/model.GroupChannelTest' + message: + type: string + success: + type: boolean + type: object controller.GroupMCPResponse: properties: created_at: @@ -427,6 +495,10 @@ definitions: type: number created_at: type: string + group_channel_request_count: + type: integer + group_channel_used_amount: + type: number id: type: string request_count: @@ -482,6 +554,8 @@ definitions: properties: dsn: type: string + group_id: + type: string type: object controller.MCPEndpoint: properties: @@ -754,13 +828,76 @@ definitions: tpm: type: integer type: object + controller.SaveGroupScopeModelConfigsRequest: + properties: + allowed_resolutions: + items: + type: string + type: array + config: + additionalProperties: {} + type: object + created_at: + type: string + disable_resolution_fuzzy_match: + type: boolean + exclude_from_tests: + type: boolean + force_save_detail: + type: boolean + group_id: + type: string + max_image_generation_count: + type: integer + max_video_generation_count: + type: integer + max_video_generation_seconds: + type: integer + model: + type: string + owner: + $ref: '#/definitions/model.ModelOwner' + plugin: + additionalProperties: + additionalProperties: {} + type: object + type: object + price: + $ref: '#/definitions/model.Price' + request_body_storage_max_size: + type: integer + response_body_storage_max_size: + type: integer + retry_times: + type: integer + rpm: + type: integer + summary_claude_long_context: + type: boolean + summary_service_tier: + type: boolean + timeout_config: + $ref: '#/definitions/model.TimeoutConfig' + tpm: + type: integer + type: + $ref: '#/definitions/mode.Mode' + updated_at: + type: string + type: object controller.SaveModelConfigsRequest: properties: + allowed_resolutions: + items: + type: string + type: array config: additionalProperties: {} type: object created_at: type: string + disable_resolution_fuzzy_match: + type: boolean exclude_from_tests: type: boolean force_save_detail: @@ -837,6 +974,45 @@ definitions: - key - type type: object + controller.TestGroupChannelRequest: + properties: + base_url: + type: string + configs: + $ref: '#/definitions/model.ChannelConfigs' + enabled_no_permission_ban: + type: boolean + group_id: + type: string + key: + type: string + max_error_rate: + type: number + model_mapping: + additionalProperties: + type: string + type: object + models: + items: + type: string + type: array + name: + type: string + priority: + type: integer + proxy_url: + type: string + sets: + items: + type: string + type: array + skip_tls_verify: + type: boolean + status: + type: integer + type: + $ref: '#/definitions/model.ChannelType' + type: object controller.TestResult: properties: data: @@ -846,6 +1022,39 @@ definitions: success: type: boolean type: object + controller.TestSingleGroupChannelRequest: + properties: + base_url: + type: string + configs: + $ref: '#/definitions/model.ChannelConfigs' + group_id: + type: string + key: + type: string + model: + type: string + model_mapping: + additionalProperties: + type: string + type: object + name: + type: string + proxy_url: + type: string + sets: + items: + type: string + type: array + skip_tls_verify: + type: boolean + type: + type: integer + required: + - key + - model + - type + type: object controller.TestSingleModelRequest: properties: base_url: @@ -882,6 +1091,18 @@ definitions: type: string group: type: string + group_channel_models: + items: + type: string + type: array + group_channel_request_count: + type: integer + group_channel_sets: + items: + type: string + type: array + group_channel_used_amount: + type: number id: type: integer key: @@ -907,6 +1128,12 @@ definitions: type: number request_count: type: integer + scope: + $ref: '#/definitions/model.ChannelScope' + sets: + items: + type: string + type: array status: type: integer subnets: @@ -1163,6 +1390,14 @@ definitions: - 28 - 29 - 30 + - 31 + - 32 + - 33 + - 34 + - 35 + - 36 + - 37 + - 38 type: integer x-enum-varnames: - Unknown @@ -1196,6 +1431,14 @@ definitions: - GeminiVideoOperations - GeminiTTS - GeminiImage + - GeminiFiles + - VideosEdits + - VideosExtensions + - AliVideo + - AliVideoTasks + - DoubaoVideo + - DoubaoVideoTasks + - DoubaoVideoTasksDelete model.Amount: properties: audio_input_amount: @@ -1324,6 +1567,14 @@ definitions: model.ChannelConfigs: additionalProperties: {} type: object + model.ChannelScope: + enum: + - global + - group + type: string + x-enum-varnames: + - ChannelScopeGlobal + - ChannelScopeGroup model.ChannelTest: properties: actual_model: @@ -1493,16 +1744,16 @@ definitions: $ref: '#/definitions/model.SummaryDataSet' status_2xx_count: type: integer + status_4xx_count: + type: integer + status_500_count: + type: integer status_5xx_count: type: integer status_400_count: type: integer status_429_count: type: integer - status_4xx_count: - type: integer - status_500_count: - type: integer status_other_count: type: integer timestamp: @@ -1700,16 +1951,16 @@ definitions: $ref: '#/definitions/model.SummaryDataSet' status_2xx_count: type: integer - status_400_count: - type: integer - status_429_count: - type: integer status_4xx_count: type: integer status_500_count: type: integer status_5xx_count: type: integer + status_400_count: + type: integer + status_429_count: + type: integer status_other_count: type: integer total_count: @@ -1863,10 +2114,18 @@ definitions: $ref: '#/definitions/model.Message' type: array metadata: {} + modalities: + items: + type: string + type: array model: type: string + "n": + type: integer num_ctx: type: integer + parallel_tool_calls: + type: boolean presence_penalty: type: number prompt: {} @@ -1919,6 +2178,23 @@ definitions: type: $ref: '#/definitions/model.ClaudeThinkingType' type: object + model.GetGroupChannelLogsResult: + properties: + logs: + items: + $ref: '#/definitions/model.GroupChannelLog' + type: array + models: + items: + type: string + type: array + token_names: + items: + type: string + type: array + total: + type: integer + type: object model.GetGroupLogsResult: properties: channels: @@ -1974,6 +2250,10 @@ definitions: type: number created_at: type: string + group_channel_request_count: + type: integer + group_channel_used_amount: + type: number id: type: string request_count: @@ -1987,9 +2267,176 @@ definitions: used_amount: type: number type: object - model.GroupDashboardResponse: + model.GroupChannel: properties: - audio_input_amount: + base_url: + type: string + configs: + $ref: '#/definitions/model.ChannelConfigs' + created_at: + type: string + enabled_no_permission_ban: + type: boolean + group_channel_tests: + items: + $ref: '#/definitions/model.GroupChannelTest' + type: array + group_id: + type: string + id: + type: integer + key: + type: string + last_test_error_at: + type: string + max_error_rate: + type: number + model_mapping: + additionalProperties: + type: string + type: object + models: + items: + type: string + type: array + name: + type: string + priority: + type: integer + proxy_url: + type: string + request_count: + type: integer + retry_count: + type: integer + sets: + items: + type: string + type: array + skip_tls_verify: + type: boolean + status: + type: integer + type: + $ref: '#/definitions/model.ChannelType' + used_amount: + type: number + type: object + model.GroupChannelBasicInfo: + properties: + group_id: + type: string + id: + type: integer + name: + type: string + type: + $ref: '#/definitions/model.ChannelType' + type: object + model.GroupChannelLog: + properties: + amount: + $ref: '#/definitions/model.Amount' + async_usage_status: + $ref: '#/definitions/model.AsyncUsageStatus' + code: + type: integer + content: + type: string + created_at: + type: string + endpoint: + type: string + group: + type: string + group_channel_id: + type: integer + id: + type: integer + ip: + type: string + metadata: + additionalProperties: + type: string + type: object + mode: + type: integer + model: + type: string + price: + $ref: '#/definitions/model.Price' + prompt_cache_key: + type: string + request_at: + type: string + request_detail: + $ref: '#/definitions/model.GroupChannelRequestDetail' + request_id: + type: string + retry_at: + type: string + retry_times: + type: integer + token_id: + type: integer + token_name: + type: string + ttfb_milliseconds: + type: integer + upstream_id: + type: string + usage: + $ref: '#/definitions/model.Usage' + usage_context: + $ref: '#/definitions/model.UsageContext' + user: + type: string + type: object + model.GroupChannelRequestDetail: + properties: + id: + type: integer + log_id: + type: integer + request_body: + type: string + request_body_truncated: + type: boolean + response_body: + type: string + response_body_truncated: + type: boolean + type: object + model.GroupChannelTest: + properties: + actual_model: + type: string + channel_name: + type: string + channel_type: + $ref: '#/definitions/model.ChannelType' + code: + type: integer + group_channel_id: + type: integer + group_id: + type: string + mode: + $ref: '#/definitions/mode.Mode' + model: + type: string + response: + type: string + success: + type: boolean + test_at: + type: string + took: + type: number + type: object + model.GroupDashboardResponse: + properties: + audio_input_amount: type: number audio_input_tokens: type: integer @@ -2061,14 +2508,14 @@ definitions: type: integer status_400_count: type: integer - status_4xx_count: - type: integer - status_5xx_count: - type: integer status_429_count: type: integer + status_4xx_count: + type: integer status_500_count: type: integer + status_5xx_count: + type: integer status_other_count: type: integer token_names: @@ -2208,6 +2655,63 @@ definitions: tpm: type: integer type: object + model.GroupScopeModelConfig: + properties: + allowed_resolutions: + items: + type: string + type: array + config: + additionalProperties: {} + type: object + created_at: + type: string + disable_resolution_fuzzy_match: + type: boolean + exclude_from_tests: + type: boolean + force_save_detail: + type: boolean + group_id: + type: string + max_image_generation_count: + type: integer + max_video_generation_count: + type: integer + max_video_generation_seconds: + type: integer + model: + type: string + owner: + $ref: '#/definitions/model.ModelOwner' + plugin: + additionalProperties: + additionalProperties: {} + type: object + type: object + price: + $ref: '#/definitions/model.Price' + request_body_storage_max_size: + type: integer + response_body_storage_max_size: + type: integer + retry_times: + type: integer + rpm: + type: integer + summary_claude_long_context: + type: boolean + summary_service_tier: + type: boolean + timeout_config: + $ref: '#/definitions/model.TimeoutConfig' + tpm: + type: integer + type: + $ref: '#/definitions/mode.Mode' + updated_at: + type: string + type: object model.ImageData: properties: b64_json: @@ -2319,9 +2823,16 @@ definitions: call_id: description: Fields for function_result type type: string + detail: + type: string + file_id: + type: string id: description: Fields for function_call type type: string + image_url: + description: Fields for input_image type + type: string name: type: string output: @@ -2425,8 +2936,6 @@ definitions: type: string retry_times: type: integer - service_tier: - type: string token_id: type: integer token_name: @@ -2499,11 +3008,17 @@ definitions: type: object model.ModelConfig: properties: + allowed_resolutions: + items: + type: string + type: array config: additionalProperties: {} type: object created_at: type: string + disable_resolution_fuzzy_match: + type: boolean exclude_from_tests: type: boolean force_save_detail: @@ -2742,10 +3257,16 @@ definitions: end_time: description: Unix timestamp, 0 means no end limit type: integer + input_media: + type: boolean input_token_max: type: integer input_token_min: type: integer + input_video: + type: boolean + output_audio: + type: boolean output_token_max: type: integer output_token_min: @@ -3080,6 +3601,15 @@ definitions: type: object model.ResponseTextFormat: properties: + description: + type: string + name: + type: string + schema: + additionalProperties: {} + type: object + strict: + type: boolean type: type: string type: object @@ -3218,6 +3748,8 @@ definitions: type: integer status_2xx_count: type: integer + status_5xx_count: + type: integer status_400_count: type: integer status_429_count: @@ -3226,8 +3758,6 @@ definitions: type: integer status_500_count: type: integer - status_5xx_count: - type: integer status_other_count: type: integer total_time_milliseconds: @@ -3275,6 +3805,8 @@ definitions: $ref: '#/definitions/model.SummaryDataSet' exception_count: type: integer + group_channel_id: + type: integer group_id: type: string image_input_amount: @@ -3453,6 +3985,14 @@ definitions: type: object model.UpdateTokenRequest: properties: + group_channel_models: + items: + type: string + type: array + group_channel_sets: + items: + type: string + type: array models: items: type: string @@ -3468,7 +4008,13 @@ definitions: quota: description: Quota system type: number - status: + scope: + type: string + sets: + items: + type: string + type: array + status: type: integer subnets: items: @@ -3504,18 +4050,20 @@ definitions: type: object model.UsageContext: properties: + input_media: + type: boolean + input_video: + type: boolean + native_resolution: + type: string + output_audio: + type: boolean quality: type: string resolution: type: string - native_resolution: - type: string service_tier: type: string - input_video: - type: boolean - output_audio: - type: boolean type: object model.Video: properties: @@ -3588,8 +4136,6 @@ definitions: type: string seconds: type: integer - size: - type: string width: type: integer type: object @@ -3640,6 +4186,32 @@ definitions: - VideoStatusSucceeded - VideoStatusFailed - VideoStatusCancelled + model.VideosEditRequest: + properties: + input_reference: + type: string + model: + type: string + prompt: + type: string + seconds: {} + size: + type: string + video: {} + type: object + model.VideosExtensionRequest: + properties: + input_reference: + type: string + model: + type: string + prompt: + type: string + seconds: {} + size: + type: string + video: {} + type: object model.VideosRemixRequest: properties: input_reference: @@ -3716,6 +4288,18 @@ paths: in: header name: Aiproxy-Channel type: string + - description: Optional group ID for internal token requests + in: header + name: X-Aiproxy-Group + type: string + - description: 'Optional group channel mode for internal token requests. Values: + global, own' + enum: + - global + - own + in: header + name: X-Aiproxy-Group-Channel-Mode + type: string produces: - application/json responses: @@ -3766,6 +4350,18 @@ paths: in: header name: Aiproxy-Channel type: string + - description: Optional group ID for internal token requests + in: header + name: X-Aiproxy-Group + type: string + - description: 'Optional group channel mode for internal token requests. Values: + global, own' + enum: + - global + - own + in: header + name: X-Aiproxy-Group-Channel-Mode + type: string produces: - application/json responses: @@ -3900,46 +4496,56 @@ paths: summary: Update a channel tags: - channel - /api/channel/{id}/{model}: - get: - description: Tests a single model in the channel + /api/channel/{id}/status: + post: + consumes: + - application/json + description: Updates the status of a channel by its ID parameters: - description: Channel ID in: path name: id required: true type: integer - - description: Model name - in: path - name: model + - description: Status information + in: body + name: status required: true - type: string + schema: + $ref: '#/definitions/controller.UpdateChannelStatusRequest' produces: - application/json responses: "200": description: OK schema: - allOf: - - $ref: '#/definitions/middleware.APIResponse' - - properties: - data: - $ref: '#/definitions/model.ChannelTest' - type: object + $ref: '#/definitions/middleware.APIResponse' security: - ApiKeyAuth: [] - summary: Test channel model + summary: Update channel status tags: - channel - /api/channel/{id}/balance: + /api/channel/{id}/test: get: - description: Updates the balance for a single channel + description: Tests all models in the channel parameters: - description: Channel ID in: path name: id required: true type: integer + - description: Return success + in: query + name: return_success + type: boolean + - description: Success body + in: query + name: success_body + type: boolean + - description: Stream + in: query + name: stream + type: boolean produces: - application/json responses: @@ -3950,64 +4556,55 @@ paths: - $ref: '#/definitions/middleware.APIResponse' - properties: data: - format: float64 - type: number + items: + $ref: '#/definitions/controller.TestResult' + type: array type: object security: - ApiKeyAuth: [] - summary: Update channel balance + summary: Test channel models tags: - channel - /api/channel/{id}/status: - post: - consumes: - - application/json - description: Updates the status of a channel by its ID + /api/channel/{id}/test/{model}: + get: + description: Tests a single model in the channel parameters: - description: Channel ID in: path name: id required: true type: integer - - description: Status information - in: body - name: status + - description: Model name + in: path + name: model required: true - schema: - $ref: '#/definitions/controller.UpdateChannelStatusRequest' + type: string produces: - application/json responses: "200": description: OK schema: - $ref: '#/definitions/middleware.APIResponse' + allOf: + - $ref: '#/definitions/middleware.APIResponse' + - properties: + data: + $ref: '#/definitions/model.ChannelTest' + type: object security: - ApiKeyAuth: [] - summary: Update channel status + summary: Test channel model tags: - channel - /api/channel/{id}/test: + /api/channel/{id}/update_balance: get: - description: Tests all models in the channel + description: Updates the balance for a single channel parameters: - description: Channel ID in: path name: id required: true type: integer - - description: Return success - in: query - name: return_success - type: boolean - - description: Success body - in: query - name: success_body - type: boolean - - description: Stream - in: query - name: stream - type: boolean produces: - application/json responses: @@ -4018,16 +4615,15 @@ paths: - $ref: '#/definitions/middleware.APIResponse' - properties: data: - items: - $ref: '#/definitions/controller.TestResult' - type: array + format: float64 + type: number type: object security: - ApiKeyAuth: [] - summary: Test channel models + summary: Update channel balance tags: - channel - /api/channel/test: + /api/channel/test-preview: post: consumes: - application/json @@ -4056,7 +4652,7 @@ paths: summary: Test channel preview (single model) tags: - channel - /api/channel/test-all: + /api/channel/test-preview-all: post: consumes: - application/json @@ -4210,21 +4806,6 @@ paths: summary: Get all channels tags: - channels - /api/channels/balance: - get: - description: Updates the balance for all channels - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/middleware.APIResponse' - security: - - ApiKeyAuth: [] - summary: Update all channels balance - tags: - - channel /api/channels/batch_delete: post: consumes: @@ -4442,6 +5023,21 @@ paths: summary: Get channel type metadata tags: - channels + /api/channels/update_balance: + get: + description: Updates the balance for all channels + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/middleware.APIResponse' + security: + - ApiKeyAuth: [] + summary: Update all channels balance + tags: + - channel /api/dashboard/: get: description: Returns the general dashboard data including usage statistics and @@ -4582,6 +5178,62 @@ paths: summary: Get model usage data for a specific group tags: - dashboard + /api/dashboard/group_channel: + get: + description: Returns group-channel usage statistics across groups, optionally + filtered by group + parameters: + - description: Filter by group + in: query + name: group + type: string + - description: Group channel ID + in: query + name: group_channel + type: integer + - description: Model name + in: query + name: model + type: string + - description: Start second timestamp + format: int64 + in: query + name: start_timestamp + type: integer + - description: End second timestamp + format: int64 + in: query + name: end_timestamp + type: integer + - description: Timezone, default is Local + in: query + name: timezone + type: string + - description: Time span type (minute, hour, day, month) + in: query + name: timespan + type: string + - description: Comma-separated list of fields to select + in: query + name: fields + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/middleware.APIResponse' + - properties: + data: + $ref: '#/definitions/model.DashboardResponse' + type: object + security: + - ApiKeyAuth: [] + summary: Get global group channel dashboard data + tags: + - dashboard /api/dashboardv2/: get: description: Returns model-specific metrics and usage data for the given channel @@ -4697,14 +5349,18 @@ paths: summary: Get model usage data for a specific group tags: - dashboard - /api/dashboardv3/: + /api/dashboardv2/group_channel: get: - description: Returns model-specific metrics and usage data for the given channel - with detailed amount breakdown + description: Returns group-channel model usage time series across groups, optionally + filtered by group parameters: - - description: Channel ID + - description: Filter by group in: query - name: channel + name: group + type: string + - description: Group channel ID + in: query + name: group_channel type: integer - description: Model name in: query @@ -4728,9 +5384,7 @@ paths: in: query name: timespan type: string - - description: 'Comma-separated list of fields to select (e.g., request_count,exception_count,cache_hit_count). - Available: request_count,retry_count,exception_count,status4xx_count,status5xx_count,status400_count,status429_count,status500_count,cache_hit_count,input_tokens,image_input_tokens,audio_input_tokens,video_input_tokens,output_tokens,image_output_tokens,audio_output_tokens,cached_tokens,cache_creation_tokens,total_tokens,web_search_count,used_amount,total_time,total_ttfb. - Groups: count,usage,time,all' + - description: Comma-separated list of fields to select in: query name: fields type: string @@ -4744,27 +5398,24 @@ paths: - $ref: '#/definitions/middleware.APIResponse' - properties: data: - $ref: '#/definitions/model.DashboardV3Response' + items: + $ref: '#/definitions/model.TimeSummaryDataV2' + type: array type: object security: - ApiKeyAuth: [] - summary: Get model usage data for a specific channel (V3 with detailed amounts) + summary: Get global group channel time series data tags: - dashboard - /api/dashboardv3/{group}: + /api/dashboardv3/: get: - description: Returns model-specific metrics and usage data for the given group + description: Returns model-specific metrics and usage data for the given channel with detailed amount breakdown parameters: - - description: Group - in: path - name: group - required: true - type: string - - description: Token name + - description: Channel ID in: query - name: token_name - type: string + name: channel + type: integer - description: Model name in: query name: model @@ -4807,7 +5458,122 @@ paths: type: object security: - ApiKeyAuth: [] - summary: Get model usage data for a specific group (V3 with detailed amounts) + summary: Get model usage data for a specific channel (V3 with detailed amounts) + tags: + - dashboard + /api/dashboardv3/{group}: + get: + description: Returns model-specific metrics and usage data for the given group + with detailed amount breakdown + parameters: + - description: Group + in: path + name: group + required: true + type: string + - description: Token name + in: query + name: token_name + type: string + - description: Model name + in: query + name: model + type: string + - description: Start timestamp + format: int64 + in: query + name: start_timestamp + type: integer + - description: End timestamp + format: int64 + in: query + name: end_timestamp + type: integer + - description: Timezone, default is Local + in: query + name: timezone + type: string + - description: Time span type (minute, hour, day, month) + in: query + name: timespan + type: string + - description: 'Comma-separated list of fields to select (e.g., request_count,exception_count,cache_hit_count). + Available: request_count,retry_count,exception_count,status4xx_count,status5xx_count,status400_count,status429_count,status500_count,cache_hit_count,input_tokens,image_input_tokens,audio_input_tokens,video_input_tokens,output_tokens,image_output_tokens,audio_output_tokens,cached_tokens,cache_creation_tokens,total_tokens,web_search_count,used_amount,total_time,total_ttfb. + Groups: count,usage,time,all' + in: query + name: fields + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/middleware.APIResponse' + - properties: + data: + $ref: '#/definitions/model.DashboardV3Response' + type: object + security: + - ApiKeyAuth: [] + summary: Get model usage data for a specific group (V3 with detailed amounts) + tags: + - dashboard + /api/dashboardv3/group_channel: + get: + description: Returns group-channel dashboard V3 data across groups, optionally + filtered by group + parameters: + - description: Filter by group + in: query + name: group + type: string + - description: Group channel ID + in: query + name: group_channel + type: integer + - description: Model name + in: query + name: model + type: string + - description: Start timestamp + format: int64 + in: query + name: start_timestamp + type: integer + - description: End timestamp + format: int64 + in: query + name: end_timestamp + type: integer + - description: Timezone, default is Local + in: query + name: timezone + type: string + - description: Time span type (minute, hour, day, month) + in: query + name: timespan + type: string + - description: Comma-separated list of fields to select + in: query + name: fields + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/middleware.APIResponse' + - properties: + data: + $ref: '#/definitions/model.DashboardV3Response' + type: object + security: + - ApiKeyAuth: [] + summary: Get global group channel dashboard V3 data tags: - dashboard /api/embedmcp/: @@ -4962,34 +5728,44 @@ paths: summary: Update a group tags: - group - /api/group/{group}/mcp: + /api/group/{group}/channel-dashboard: get: - description: Get MCPs by group + description: Returns group-channel usage statistics for a specific group parameters: - - description: Group ID + - description: Group in: path name: group required: true type: string - - description: Page + - description: Group channel ID in: query - name: page + name: group_channel type: integer - - description: Per Page + - description: Model name in: query - name: per_page + name: model + type: string + - description: Start second timestamp + format: int64 + in: query + name: start_timestamp type: integer - - description: MCP ID + - description: End second timestamp + format: int64 in: query - name: id + name: end_timestamp + type: integer + - description: Timezone, default is Local + in: query + name: timezone type: string - - description: hosted or local + - description: Time span type (minute, hour, day, month) in: query - name: type + name: timespan type: string - - description: Keyword + - description: Comma-separated list of fields to select in: query - name: keyword + name: fields type: string produces: - application/json @@ -5001,30 +5777,23 @@ paths: - $ref: '#/definitions/middleware.APIResponse' - properties: data: - items: - $ref: '#/definitions/controller.GroupPublicMCPResponse' - type: array + $ref: '#/definitions/model.DashboardResponse' type: object security: - ApiKeyAuth: [] - summary: Get MCPs by group + summary: Get group channel dashboard data tags: - - mcp - - group - /api/group/{group}/mcp/{id}: + - dashboard + /api/group/{group}/channel-dashboard/models: get: - description: Get a specific MCP by its ID + description: Returns group-channel model configs backed by enabled group channels + for the given group parameters: - - description: Group ID + - description: Group in: path name: group required: true type: string - - description: MCP ID - in: path - name: id - required: true - type: string produces: - application/json responses: @@ -5035,27 +5804,53 @@ paths: - $ref: '#/definitions/middleware.APIResponse' - properties: data: - $ref: '#/definitions/controller.GroupPublicMCPDetailResponse' + items: + $ref: '#/definitions/controller.GroupModel' + type: array type: object security: - ApiKeyAuth: [] - summary: Get MCP by ID + summary: Get group channel dashboard models tags: - - mcp - - group - /api/group/{group}/model_config/{model}: - delete: - description: Delete group model config + - dashboard + /api/group/{group}/channel-dashboardv2: + get: + description: Returns group-channel model usage time series for a specific group parameters: - - description: Group name + - description: Group in: path name: group required: true type: string + - description: Group channel ID + in: query + name: group_channel + type: integer - description: Model name - in: path + in: query name: model - required: true + type: string + - description: Start timestamp + format: int64 + in: query + name: start_timestamp + type: integer + - description: End timestamp + format: int64 + in: query + name: end_timestamp + type: integer + - description: Timezone, default is Local + in: query + name: timezone + type: string + - description: Time span type (minute, hour, day, month) + in: query + name: timespan + type: string + - description: Comma-separated list of fields to select + in: query + name: fields type: string produces: - application/json @@ -5063,24 +5858,57 @@ paths: "200": description: OK schema: - $ref: '#/definitions/middleware.APIResponse' + allOf: + - $ref: '#/definitions/middleware.APIResponse' + - properties: + data: + items: + $ref: '#/definitions/model.TimeSummaryDataV2' + type: array + type: object security: - ApiKeyAuth: [] - summary: Delete group model config + summary: Get group channel time series data tags: - - group + - dashboard + /api/group/{group}/channel-dashboardv3: get: - description: Get group model config + description: Returns group-channel dashboard V3 data for a specific group parameters: - - description: Group name + - description: Group in: path name: group required: true type: string + - description: Group channel ID + in: query + name: group_channel + type: integer - description: Model name - in: path + in: query name: model - required: true + type: string + - description: Start timestamp + format: int64 + in: query + name: start_timestamp + type: integer + - description: End timestamp + format: int64 + in: query + name: end_timestamp + type: integer + - description: Timezone, default is Local + in: query + name: timezone + type: string + - description: Time span type (minute, hour, day, month) + in: query + name: timespan + type: string + - description: Comma-separated list of fields to select + in: query + name: fields type: string produces: - application/json @@ -5092,91 +5920,93 @@ paths: - $ref: '#/definitions/middleware.APIResponse' - properties: data: - $ref: '#/definitions/model.GroupModelConfig' + $ref: '#/definitions/model.DashboardV3Response' type: object security: - ApiKeyAuth: [] - summary: Get group model config + summary: Get group channel dashboard V3 data tags: - - group - post: - consumes: - - application/json - description: Save group model config + - dashboard + /api/group/{group}/channel-models/enabled: + get: + description: Returns group channel model configs grouped by set for a group parameters: - - description: Group name + - description: Group ID in: path name: group required: true type: string - - description: Group model config information - in: body - name: data - required: true - schema: - $ref: '#/definitions/controller.SaveGroupModelConfigRequest' produces: - application/json responses: "200": description: OK schema: - $ref: '#/definitions/middleware.APIResponse' - security: - - ApiKeyAuth: [] - summary: Save group model config + allOf: + - $ref: '#/definitions/middleware.APIResponse' + - properties: + data: + additionalProperties: + items: + $ref: '#/definitions/model.ModelConfig' + type: array + type: object + type: object + security: + - ApiKeyAuth: [] + summary: Get enabled group channel models tags: - - group - put: - consumes: - - application/json - description: Update group model config + - group-channel + /api/group/{group}/channel-models/enabled/{set}: + get: + description: Returns group channel model configs for a specific set parameters: - - description: Group name + - description: Group ID in: path name: group required: true type: string - - description: Model name + - description: Models set in: path - name: model + name: set required: true type: string - - description: Group model config information - in: body - name: data - required: true - schema: - $ref: '#/definitions/controller.SaveGroupModelConfigRequest' produces: - application/json responses: "200": description: OK schema: - $ref: '#/definitions/middleware.APIResponse' + allOf: + - $ref: '#/definitions/middleware.APIResponse' + - properties: + data: + items: + $ref: '#/definitions/model.ModelConfig' + type: array + type: object security: - ApiKeyAuth: [] - summary: Update group model config + summary: Get enabled group channel models by set tags: - - group - /api/group/{group}/model_configs/: - delete: - description: Delete group model configs + - group-channel + /api/group/{group}/channel/: + post: + consumes: + - application/json + description: Adds a group channel to a specific group parameters: - - description: Group name + - description: Group ID in: path name: group required: true type: string - - description: Model names + - description: Group channel information in: body - name: models + name: channel required: true schema: - items: - type: string - type: array + $ref: '#/definitions/controller.AddGroupChannelRequest' produces: - application/json responses: @@ -5186,113 +6016,126 @@ paths: $ref: '#/definitions/middleware.APIResponse' security: - ApiKeyAuth: [] - summary: Delete group model configs + summary: Add a group channel tags: - - group - get: - description: Get group model configs + - group-channel + /api/group/{group}/channel/{id}: + delete: + description: Deletes a group channel by ID in a specific group parameters: - - description: Group name + - description: Group ID in: path name: group required: true type: string + - description: Group channel ID + in: path + name: id + required: true + type: integer produces: - application/json responses: "200": description: OK schema: - allOf: - - $ref: '#/definitions/middleware.APIResponse' - - properties: - data: - items: - $ref: '#/definitions/model.GroupModelConfig' - type: array - type: object + $ref: '#/definitions/middleware.APIResponse' security: - ApiKeyAuth: [] - summary: Get group model configs + summary: Delete a group channel tags: - - group - post: - consumes: - - application/json - description: Save group model configs + - group-channel + get: + description: Returns detailed information about a group channel in a specific + group parameters: - - description: Group name + - description: Group ID in: path name: group required: true type: string - - description: Group model config information - in: body - name: data + - description: Group channel ID + in: path + name: id required: true - schema: - items: - $ref: '#/definitions/controller.SaveGroupModelConfigRequest' - type: array + type: integer produces: - application/json responses: "200": description: OK schema: - $ref: '#/definitions/middleware.APIResponse' + allOf: + - $ref: '#/definitions/middleware.APIResponse' + - properties: + data: + $ref: '#/definitions/model.GroupChannel' + type: object security: - ApiKeyAuth: [] - summary: Save group model configs + summary: Get a group channel by ID tags: - - group + - group-channel put: consumes: - application/json - description: Update group model configs + description: Updates a group channel by ID in a specific group parameters: - - description: Group name + - description: Group ID in: path name: group required: true type: string - - description: Group model config information + - description: Group channel ID + in: path + name: id + required: true + type: integer + - description: Updated group channel information in: body - name: data + name: channel required: true schema: - items: - $ref: '#/definitions/controller.SaveGroupModelConfigRequest' - type: array + $ref: '#/definitions/controller.AddGroupChannelRequest' produces: - application/json responses: "200": description: OK schema: - $ref: '#/definitions/middleware.APIResponse' + allOf: + - $ref: '#/definitions/middleware.APIResponse' + - properties: + data: + $ref: '#/definitions/model.GroupChannel' + type: object security: - ApiKeyAuth: [] - summary: Update group model configs + summary: Update a group channel tags: - - group - /api/group/{group}/rpm_ratio: + - group-channel + /api/group/{group}/channel/{id}/status: post: consumes: - application/json - description: Updates the RPM (Requests Per Minute) ratio for a group + description: Updates the status of a group channel by ID in a specific group parameters: - - description: Group name + - description: Group ID in: path name: group required: true type: string - - description: RPM ratio information + - description: Group channel ID + in: path + name: id + required: true + type: integer + - description: Status information in: body - name: data + name: status required: true schema: - $ref: '#/definitions/controller.UpdateGroupRPMRatioRequest' + $ref: '#/definitions/controller.UpdateChannelStatusRequest' produces: - application/json responses: @@ -5302,79 +6145,112 @@ paths: $ref: '#/definitions/middleware.APIResponse' security: - ApiKeyAuth: [] - summary: Update group RPM ratio + summary: Update group channel status tags: - - group - /api/group/{group}/status: - post: - consumes: - - application/json - description: Updates the status of a group + - group-channel + /api/group/{group}/channel/{id}/test: + get: + description: Tests all models in the group channel parameters: - - description: Group name + - description: Group ID in: path name: group required: true type: string - - description: Status information - in: body - name: status + - description: Group Channel ID + in: path + name: id required: true - schema: - $ref: '#/definitions/controller.UpdateGroupStatusRequest' + type: integer + - description: Return success + in: query + name: return_success + type: boolean + - description: Success body + in: query + name: success_body + type: boolean + - description: Stream + in: query + name: stream + type: boolean produces: - application/json responses: "200": description: OK schema: - $ref: '#/definitions/middleware.APIResponse' + allOf: + - $ref: '#/definitions/middleware.APIResponse' + - properties: + data: + items: + $ref: '#/definitions/controller.GroupChannelTestResult' + type: array + type: object security: - ApiKeyAuth: [] - summary: Update group status + summary: Test group channel models tags: - - group - /api/group/{group}/tpm_ratio: - post: - consumes: - - application/json - description: Updates the TPM (Tokens Per Minute) ratio for a group + - group-channel + /api/group/{group}/channel/{id}/test/{model}: + get: + description: Tests a single model in the group channel parameters: - - description: Group name + - description: Group ID in: path name: group required: true type: string - - description: TPM ratio information - in: body - name: data + - description: Group Channel ID + in: path + name: id required: true - schema: - $ref: '#/definitions/controller.UpdateGroupTPMRatioRequest' + type: integer + - description: Model name + in: path + name: model + required: true + type: string + - description: Success body + in: query + name: success_body + type: boolean produces: - application/json responses: "200": description: OK schema: - $ref: '#/definitions/middleware.APIResponse' + allOf: + - $ref: '#/definitions/middleware.APIResponse' + - properties: + data: + $ref: '#/definitions/model.GroupChannelTest' + type: object security: - ApiKeyAuth: [] - summary: Update group TPM ratio + summary: Test group channel model tags: - - group - /api/groups/: + - group-channel + /api/group/{group}/channel/{id}/tests: get: - description: Returns a list of all groups with pagination + description: Returns persisted test results for a group channel parameters: - - description: Page number - in: query - name: page + - description: Group ID + in: path + name: group + required: true + type: string + - description: Group Channel ID + in: path + name: id + required: true type: integer - - description: Items per page + - description: Success body in: query - name: per_page - type: integer + name: success_body + type: boolean produces: - application/json responses: @@ -5385,109 +6261,139 @@ paths: - $ref: '#/definitions/middleware.APIResponse' - properties: data: - additionalProperties: - allOf: - - {} - - properties: - groups: - items: - $ref: '#/definitions/controller.GroupResponse' - type: array - total: - type: integer - type: object - type: object + items: + $ref: '#/definitions/model.GroupChannelTest' + type: array type: object security: - ApiKeyAuth: [] - summary: Get all groups + summary: Get group channel test results tags: - - groups - /api/groups/batch_delete: + - group-channel + /api/group/{group}/channel/test-preview: post: consumes: - application/json - description: Deletes multiple groups by their IDs + description: Test a single model in a group channel without saving to database parameters: - - description: Group IDs - in: body - name: ids + - description: Group ID + in: path + name: group required: true - schema: - items: - type: string - type: array + type: string + - description: Success body + in: query + name: success_body + type: boolean + - description: Group channel test request + in: body + name: request + required: true + schema: + $ref: '#/definitions/controller.TestSingleGroupChannelRequest' produces: - application/json responses: "200": description: OK schema: - $ref: '#/definitions/middleware.APIResponse' + allOf: + - $ref: '#/definitions/middleware.APIResponse' + - properties: + data: + $ref: '#/definitions/model.GroupChannelTest' + type: object security: - ApiKeyAuth: [] - summary: Delete multiple groups + summary: Test group channel preview tags: - - groups - /api/groups/batch_status: + - group-channel + /api/group/{group}/channel/test-preview-all: post: consumes: - application/json - description: Updates the status of multiple groups + description: Test all models in a group channel without saving to database parameters: - - description: Group IDs and status + - description: Group ID + in: path + name: group + required: true + type: string + - description: Return success + in: query + name: return_success + type: boolean + - description: Success body + in: query + name: success_body + type: boolean + - description: Stream mode (SSE) + in: query + name: stream + type: boolean + - description: Group channel test request in: body - name: data + name: request required: true schema: - $ref: '#/definitions/controller.UpdateGroupsStatusRequest' + $ref: '#/definitions/controller.TestGroupChannelRequest' produces: - application/json responses: "200": description: OK schema: - $ref: '#/definitions/middleware.APIResponse' + allOf: + - $ref: '#/definitions/middleware.APIResponse' + - properties: + data: + items: + $ref: '#/definitions/controller.GroupChannelTestResult' + type: array + type: object security: - ApiKeyAuth: [] - summary: Update multiple groups status + summary: Test group channel preview models tags: - - groups - /api/groups/consumption_ranking: + - group-channel + /api/group/{group}/channels/: get: - description: Returns channel, model, or group consumption ranking aggregated - from summary data + description: Returns a paginated list of group channels for a specific group parameters: - - default: group - description: 'Ranking type: channel, model, group' - in: query - name: type + - description: Group ID + in: path + name: group + required: true type: string - - description: Start timestamp - format: int64 + - description: Page number in: query - name: start_timestamp + name: page type: integer - - description: End timestamp - format: int64 + - description: Items per page in: query - name: end_timestamp + name: per_page type: integer - - description: Timezone, default is Local + - description: Filter by id in: query - name: timezone + name: id + type: integer + - description: Filter by name + in: query + name: name type: string - - description: Page number + - description: Filter by key in: query - name: page - type: integer - - description: Items per page + name: key + type: string + - description: Filter by channel type in: query - name: per_page + name: channel_type type: integer - - description: 'Order: used_amount_desc, used_amount_asc, request_count_desc, - request_count_asc, total_tokens_desc, total_tokens_asc, channel_id_asc, - channel_id_desc, model_asc, model_desc, group_id_asc, group_id_desc' + - description: Filter by base URL + in: query + name: base_url + type: string + - description: Order by field in: query name: order type: string @@ -5501,132 +6407,105 @@ paths: - $ref: '#/definitions/middleware.APIResponse' - properties: data: - additionalProperties: true + additionalProperties: + allOf: + - {} + - properties: + channels: + items: + $ref: '#/definitions/model.GroupChannel' + type: array + total: + type: integer + type: object type: object type: object security: - ApiKeyAuth: [] - summary: Get consumption ranking + summary: Get group channels with pagination tags: - - groups - /api/groups/ip_groups: - get: - description: Get IP group list + - group-channel + post: + consumes: + - application/json + description: Adds group channels to a specific group parameters: - - description: Threshold - in: query - name: threshold - type: integer - - description: Start timestamp - in: query - name: start_timestamp - type: integer - - description: End timestamp - in: query - name: end_timestamp - type: integer + - description: Group ID + in: path + name: group + required: true + type: string + - description: Group channel information + in: body + name: channels + required: true + schema: + items: + $ref: '#/definitions/controller.AddGroupChannelRequest' + type: array produces: - application/json responses: "200": description: OK schema: - allOf: - - $ref: '#/definitions/middleware.APIResponse' - - properties: - data: - additionalProperties: - items: - type: string - type: array - type: object - type: object + $ref: '#/definitions/middleware.APIResponse' security: - ApiKeyAuth: [] - summary: Get IP group list + summary: Add multiple group channels tags: - - groups - /api/groups/ranking: - get: - description: Returns channel, model, or group consumption ranking aggregated - from summary data + - group-channel + /api/group/{group}/channels/batch_delete: + post: + consumes: + - application/json + description: Deletes group channels by IDs in a specific group parameters: - - default: group - description: 'Ranking type: channel, model, group' - in: query - name: type - type: string - - description: Start timestamp - format: int64 - in: query - name: start_timestamp - type: integer - - description: End timestamp - format: int64 - in: query - name: end_timestamp - type: integer - - description: Timezone, default is Local - in: query - name: timezone - type: string - - description: Page number - in: query - name: page - type: integer - - description: Items per page - in: query - name: per_page - type: integer - - description: 'Order: used_amount_desc, used_amount_asc, request_count_desc, - request_count_asc, total_tokens_desc, total_tokens_asc, channel_id_asc, - channel_id_desc, model_asc, model_desc, group_id_asc, group_id_desc' - in: query - name: order + - description: Group ID + in: path + name: group + required: true type: string + - description: Group channel IDs + in: body + name: ids + required: true + schema: + items: + type: integer + type: array produces: - application/json responses: "200": description: OK schema: - allOf: - - $ref: '#/definitions/middleware.APIResponse' - - properties: - data: - additionalProperties: true - type: object - type: object + $ref: '#/definitions/middleware.APIResponse' security: - ApiKeyAuth: [] - summary: Get consumption ranking + summary: Delete multiple group channels tags: - - groups - /api/groups/search: - get: - description: Search groups with keyword and pagination + - group-channel + /api/group/{group}/channels/batch_info: + post: + consumes: + - application/json + description: Returns id, group, name, and type for a batch of group channel + IDs filtered by group parameters: - - description: Search keyword - in: query - name: keyword + - description: Group ID + in: path + name: group required: true type: string - - description: Page number - in: query - name: page - type: integer - - description: Items per page - in: query - name: per_page - type: integer - - description: Status - in: query - name: status - type: integer - - description: Order - in: query - name: order - type: string + - description: Group channel IDs + in: body + name: ids + required: true + schema: + items: + type: integer + type: array produces: - application/json responses: @@ -5637,33 +6516,62 @@ paths: - $ref: '#/definitions/middleware.APIResponse' - properties: data: - additionalProperties: - allOf: - - {} - - properties: - groups: - items: - $ref: '#/definitions/controller.GroupResponse' - type: array - total: - type: integer - type: object - type: object + items: + $ref: '#/definitions/model.GroupChannelBasicInfo' + type: array type: object security: - ApiKeyAuth: [] - summary: Search groups + summary: Get basic info for multiple group channels in a group tags: - - groups - /api/log/{group}: + - group-channel + /api/group/{group}/channels/import/oneapi: + post: + description: Imports group channels from OneAPI into a group + parameters: + - description: Group ID + in: path + name: group + required: true + type: string + - description: Import group channel from OneAPI request + in: body + name: request + required: true + schema: + $ref: '#/definitions/controller.ImportChannelFromOneAPIRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/middleware.APIResponse' + - properties: + data: + items: {} + type: array + type: object + security: + - ApiKeyAuth: [] + summary: Import group channel from OneAPI + tags: + - group-channel + /api/group/{group}/channels/search: get: - description: Get logs for a specific group + description: Search group channels for a specific group with keyword and optional + filters parameters: - - description: Group name + - description: Group ID in: path name: group required: true type: string + - description: Search keyword + in: query + name: keyword + type: string - description: Page number in: query name: page @@ -5672,61 +6580,29 @@ paths: in: query name: per_page type: integer - - description: Start timestamp (milliseconds) - in: query - name: start_timestamp - type: integer - - description: End timestamp (milliseconds) - in: query - name: end_timestamp - type: integer - - description: Token name - in: query - name: token_name - type: string - - description: Model name - in: query - name: model_name - type: string - - description: Channel ID - in: query - name: channel - type: integer - - description: Token ID + - description: Filter by id in: query - name: token_id + name: id type: integer - - description: Order - in: query - name: order - type: string - - description: Request ID - in: query - name: request_id - type: string - - description: Upstream ID + - description: Filter by name in: query - name: upstream_id + name: name type: string - - description: Status code type + - description: Filter by key in: query - name: code_type + name: key type: string - - description: Status code + - description: Filter by channel type in: query - name: code + name: channel_type type: integer - - description: Include request and response detail - in: query - name: include_detail - type: boolean - - description: IP + - description: Filter by base URL in: query - name: ip + name: base_url type: string - - description: User + - description: Order by field in: query - name: user + name: order type: string produces: - application/json @@ -5738,27 +6614,27 @@ paths: - $ref: '#/definitions/middleware.APIResponse' - properties: data: - $ref: '#/definitions/model.GetGroupLogsResult' + additionalProperties: + allOf: + - {} + - properties: + channels: + items: + $ref: '#/definitions/model.GroupChannel' + type: array + total: + type: integer + type: object + type: object type: object security: - ApiKeyAuth: [] - summary: Get group logs + summary: Search group channels tags: - - log - /api/log/{group}/detail/{log_id}: + - group-channel + /api/group/{group}/channels/type_metas: get: - description: Get detailed information about a specific log entry in a group - parameters: - - description: Group name - in: path - name: group - required: true - type: string - - description: Log ID - in: path - name: log_id - required: true - type: string + description: Returns metadata for all channel types produces: - application/json responses: @@ -5769,123 +6645,2193 @@ paths: - $ref: '#/definitions/middleware.APIResponse' - properties: data: - $ref: '#/definitions/model.RequestDetail' + additionalProperties: + $ref: '#/definitions/adaptors.AdaptorMeta' + type: object type: object security: - ApiKeyAuth: [] - summary: Get group log detail + summary: Get channel type metadata tags: - - log - /api/log/{group}/export: + - channels + /api/group/{group}/mcp/: get: - description: Streams filtered group logs as a CSV table file + description: Get MCPs by group parameters: - - description: Group name + - description: Group ID in: path name: group required: true type: string - - description: Start timestamp, max span 30 days - in: query - name: start_timestamp - type: integer - - description: End timestamp, max span 30 days - in: query - name: end_timestamp - type: integer - - description: Model name - in: query - name: model_name - type: string - - description: Token ID + - description: Page in: query - name: token_id + name: page type: integer - - description: Token name - in: query - name: token_name - type: string - - description: Sort order for created_at, supports desc or asc - in: query - name: order - type: string - - description: Request ID - in: query - name: request_id - type: string - - description: Upstream ID - in: query - name: upstream_id - type: string - - description: Status code type - in: query - name: code_type - type: string - - description: Status code + - description: Per Page in: query - name: code + name: per_page type: integer - - description: Include request and response detail, default false - in: query - name: include_detail - type: boolean - - description: IP - in: query - name: ip - type: string - - description: User + - description: MCP ID in: query - name: user + name: id type: string - - description: Timezone, default is Local + - description: hosted or local in: query - name: timezone + name: type type: string - - description: Maximum exported rows; zero or negative means unlimited - in: query - name: max_entries - type: integer - - description: Include channel column, default false - in: query - name: include_channel - type: boolean - - description: Include retry_at column, default false - in: query - name: include_retry_at - type: boolean - - description: Chunk interval, default 30m, min 10m, max 4h, e.g. 10m, 30m, - 1h + - description: Keyword in: query - name: chunk_interval + name: keyword type: string produces: - - text/csv - responses: {} + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/middleware.APIResponse' + - properties: + data: + items: + $ref: '#/definitions/controller.GroupPublicMCPResponse' + type: array + type: object security: - ApiKeyAuth: [] - summary: Export group logs + summary: Get MCPs by group tags: - - log - /api/log/{group}/search: + - mcp + - group + /api/group/{group}/mcp/{id}: get: - description: Search logs for a specific group with filters + description: Get a specific MCP by its ID parameters: - - description: Group name + - description: Group ID in: path name: group required: true type: string - - description: Keyword - in: query - name: keyword + - description: MCP ID + in: path + name: id + required: true type: string - - description: Page number - in: query - name: page - type: integer - - description: Items per page - in: query - name: per_page + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/middleware.APIResponse' + - properties: + data: + $ref: '#/definitions/controller.GroupPublicMCPDetailResponse' + type: object + security: + - ApiKeyAuth: [] + summary: Get MCP by ID + tags: + - mcp + - group + /api/group/{group}/model_config/{model}: + delete: + description: Delete group model config + parameters: + - description: Group name + in: path + name: group + required: true + type: string + - description: Model name + in: path + name: model + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/middleware.APIResponse' + security: + - ApiKeyAuth: [] + summary: Delete group model config + tags: + - group + get: + description: Get group model config + parameters: + - description: Group name + in: path + name: group + required: true + type: string + - description: Model name + in: path + name: model + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/middleware.APIResponse' + - properties: + data: + $ref: '#/definitions/model.GroupModelConfig' + type: object + security: + - ApiKeyAuth: [] + summary: Get group model config + tags: + - group + post: + consumes: + - application/json + description: Save group model config + parameters: + - description: Group name + in: path + name: group + required: true + type: string + - description: Group model config information + in: body + name: data + required: true + schema: + $ref: '#/definitions/controller.SaveGroupModelConfigRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/middleware.APIResponse' + security: + - ApiKeyAuth: [] + summary: Save group model config + tags: + - group + put: + consumes: + - application/json + description: Update group model config + parameters: + - description: Group name + in: path + name: group + required: true + type: string + - description: Model name + in: path + name: model + required: true + type: string + - description: Group model config information + in: body + name: data + required: true + schema: + $ref: '#/definitions/controller.SaveGroupModelConfigRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/middleware.APIResponse' + security: + - ApiKeyAuth: [] + summary: Update group model config + tags: + - group + /api/group/{group}/model_configs/: + delete: + description: Delete group model configs + parameters: + - description: Group name + in: path + name: group + required: true + type: string + - description: Model names + in: body + name: models + required: true + schema: + items: + type: string + type: array + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/middleware.APIResponse' + security: + - ApiKeyAuth: [] + summary: Delete group model configs + tags: + - group + get: + description: Get group model configs + parameters: + - description: Group name + in: path + name: group + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/middleware.APIResponse' + - properties: + data: + items: + $ref: '#/definitions/model.GroupModelConfig' + type: array + type: object + security: + - ApiKeyAuth: [] + summary: Get group model configs + tags: + - group + post: + consumes: + - application/json + description: Save group model configs + parameters: + - description: Group name + in: path + name: group + required: true + type: string + - description: Group model config information + in: body + name: data + required: true + schema: + items: + $ref: '#/definitions/controller.SaveGroupModelConfigRequest' + type: array + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/middleware.APIResponse' + security: + - ApiKeyAuth: [] + summary: Save group model configs + tags: + - group + put: + consumes: + - application/json + description: Update group model configs + parameters: + - description: Group name + in: path + name: group + required: true + type: string + - description: Group model config information + in: body + name: data + required: true + schema: + items: + $ref: '#/definitions/controller.SaveGroupModelConfigRequest' + type: array + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/middleware.APIResponse' + security: + - ApiKeyAuth: [] + summary: Update group model configs + tags: + - group + /api/group/{group}/rpm_ratio: + post: + consumes: + - application/json + description: Updates the RPM (Requests Per Minute) ratio for a group + parameters: + - description: Group name + in: path + name: group + required: true + type: string + - description: RPM ratio information + in: body + name: data + required: true + schema: + $ref: '#/definitions/controller.UpdateGroupRPMRatioRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/middleware.APIResponse' + security: + - ApiKeyAuth: [] + summary: Update group RPM ratio + tags: + - group + /api/group/{group}/scope_model_config/{model}: + delete: + description: Deletes a group-channel scope model config for a specific group + parameters: + - description: Group name + in: path + name: group + required: true + type: string + - description: Model name + in: path + name: model + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/middleware.APIResponse' + security: + - ApiKeyAuth: [] + summary: Delete group scope model config + tags: + - group-scope-modelconfig + get: + description: Returns a group-channel scope model config for a specific group + parameters: + - description: Group name + in: path + name: group + required: true + type: string + - description: Model name + in: path + name: model + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/middleware.APIResponse' + - properties: + data: + $ref: '#/definitions/model.GroupScopeModelConfig' + type: object + security: + - ApiKeyAuth: [] + summary: Get group scope model config + tags: + - group-scope-modelconfig + post: + consumes: + - application/json + description: Saves a group-channel scope model config for a specific group + parameters: + - description: Group name + in: path + name: group + required: true + type: string + - description: Model name + in: path + name: model + required: true + type: string + - description: Model config + in: body + name: config + required: true + schema: + $ref: '#/definitions/controller.SaveGroupScopeModelConfigsRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/middleware.APIResponse' + security: + - ApiKeyAuth: [] + summary: Save group scope model config + tags: + - group-scope-modelconfig + /api/group/{group}/scope_model_configs/: + get: + description: Returns group-channel scope model configs with pagination for a + specific group + parameters: + - description: Group name + in: path + name: group + required: true + type: string + - description: Model name + in: query + name: model + type: string + - description: Page + in: query + name: page + type: integer + - description: Per page + in: query + name: per_page + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/middleware.APIResponse' + - properties: + data: + additionalProperties: + allOf: + - {} + - properties: + configs: + items: + $ref: '#/definitions/model.GroupScopeModelConfig' + type: array + total: + type: integer + type: object + type: object + type: object + security: + - ApiKeyAuth: [] + summary: Get group scope model configs + tags: + - group-scope-modelconfig + post: + consumes: + - application/json + description: Saves group-channel scope model configs for a specific group + parameters: + - description: Group name + in: path + name: group + required: true + type: string + - description: Model configs + in: body + name: configs + required: true + schema: + items: + $ref: '#/definitions/controller.SaveGroupScopeModelConfigsRequest' + type: array + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/middleware.APIResponse' + security: + - ApiKeyAuth: [] + summary: Save group scope model configs + tags: + - group-scope-modelconfig + /api/group/{group}/scope_model_configs/all: + get: + description: Returns all group-channel scope model configs for a specific group + parameters: + - description: Group name + in: path + name: group + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/middleware.APIResponse' + - properties: + data: + items: + $ref: '#/definitions/model.GroupScopeModelConfig' + type: array + type: object + security: + - ApiKeyAuth: [] + summary: Get all group scope model configs + tags: + - group-scope-modelconfig + /api/group/{group}/scope_model_configs/batch_delete: + post: + consumes: + - application/json + description: Deletes group-channel scope model configs by model names for a + specific group + parameters: + - description: Group name + in: path + name: group + required: true + type: string + - description: Model names + in: body + name: models + required: true + schema: + items: + type: string + type: array + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/middleware.APIResponse' + security: + - ApiKeyAuth: [] + summary: Delete group scope model configs + tags: + - group-scope-modelconfig + /api/group/{group}/scope_model_configs/contains: + post: + consumes: + - application/json + description: Returns group-channel scope model configs for the requested models + in a specific group + parameters: + - description: Group name + in: path + name: group + required: true + type: string + - description: Models + in: body + name: models + required: true + schema: + $ref: '#/definitions/controller.GetModelConfigsByModelsContainsRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/middleware.APIResponse' + - properties: + data: + items: + $ref: '#/definitions/model.GroupScopeModelConfig' + type: array + type: object + security: + - ApiKeyAuth: [] + summary: Get group scope model configs by models + tags: + - group-scope-modelconfig + /api/group/{group}/scope_model_configs/search: + get: + description: Returns group-channel scope model configs by keyword for a specific + group + parameters: + - description: Group name + in: path + name: group + required: true + type: string + - description: Keyword + in: query + name: keyword + type: string + - description: Model name + in: query + name: model + type: string + - description: Owner + in: query + name: owner + type: string + - description: Page + in: query + name: page + type: integer + - description: Per page + in: query + name: per_page + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/middleware.APIResponse' + - properties: + data: + additionalProperties: + allOf: + - {} + - properties: + configs: + items: + $ref: '#/definitions/model.GroupScopeModelConfig' + type: array + total: + type: integer + type: object + type: object + type: object + security: + - ApiKeyAuth: [] + summary: Search group scope model configs + tags: + - group-scope-modelconfig + /api/group/{group}/status: + post: + consumes: + - application/json + description: Updates the status of a group + parameters: + - description: Group name + in: path + name: group + required: true + type: string + - description: Status information + in: body + name: status + required: true + schema: + $ref: '#/definitions/controller.UpdateGroupStatusRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/middleware.APIResponse' + security: + - ApiKeyAuth: [] + summary: Update group status + tags: + - group + /api/group/{group}/tpm_ratio: + post: + consumes: + - application/json + description: Updates the TPM (Tokens Per Minute) ratio for a group + parameters: + - description: Group name + in: path + name: group + required: true + type: string + - description: TPM ratio information + in: body + name: data + required: true + schema: + $ref: '#/definitions/controller.UpdateGroupTPMRatioRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/middleware.APIResponse' + security: + - ApiKeyAuth: [] + summary: Update group TPM ratio + tags: + - group + /api/group_channel/: + post: + consumes: + - application/json + description: Adds a group channel from the global management view. The request + body must include group_id. + parameters: + - description: Group channel information + in: body + name: channel + required: true + schema: + $ref: '#/definitions/controller.AddGroupChannelRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/middleware.APIResponse' + security: + - ApiKeyAuth: [] + summary: Add a group channel + tags: + - group_channel + /api/group_channel/{id}: + delete: + description: Deletes a group channel by ID from the global management view + parameters: + - description: Group channel ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/middleware.APIResponse' + security: + - ApiKeyAuth: [] + summary: Delete a group channel + tags: + - group_channel + get: + description: Returns detailed information about a group channel across groups + parameters: + - description: Group channel ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/middleware.APIResponse' + - properties: + data: + $ref: '#/definitions/model.GroupChannel' + type: object + security: + - ApiKeyAuth: [] + summary: Get a group channel by ID + tags: + - group_channel + put: + consumes: + - application/json + description: Updates a group channel by ID from the global management view + parameters: + - description: Group channel ID + in: path + name: id + required: true + type: integer + - description: Updated group channel information + in: body + name: channel + required: true + schema: + $ref: '#/definitions/controller.AddGroupChannelRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/middleware.APIResponse' + - properties: + data: + $ref: '#/definitions/model.GroupChannel' + type: object + security: + - ApiKeyAuth: [] + summary: Update a group channel + tags: + - group_channel + /api/group_channel/{id}/status: + post: + consumes: + - application/json + description: Updates the status of a group channel by ID from the global management + view + parameters: + - description: Group channel ID + in: path + name: id + required: true + type: integer + - description: Status information + in: body + name: status + required: true + schema: + $ref: '#/definitions/controller.UpdateChannelStatusRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/middleware.APIResponse' + security: + - ApiKeyAuth: [] + summary: Update group channel status + tags: + - group_channel + /api/group_channel/{id}/test: + get: + description: Tests all models in a group channel from the global management + view + parameters: + - description: Group Channel ID + in: path + name: id + required: true + type: integer + - description: Return success + in: query + name: return_success + type: boolean + - description: Success body + in: query + name: success_body + type: boolean + - description: Stream + in: query + name: stream + type: boolean + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/middleware.APIResponse' + - properties: + data: + items: + $ref: '#/definitions/controller.GroupChannelTestResult' + type: array + type: object + security: + - ApiKeyAuth: [] + summary: Test group channel models + tags: + - group_channel + /api/group_channel/{id}/test/{model}: + get: + description: Tests a single model in a group channel from the global management + view + parameters: + - description: Group Channel ID + in: path + name: id + required: true + type: integer + - description: Model name + in: path + name: model + required: true + type: string + - description: Success body + in: query + name: success_body + type: boolean + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/middleware.APIResponse' + - properties: + data: + $ref: '#/definitions/model.GroupChannelTest' + type: object + security: + - ApiKeyAuth: [] + summary: Test group channel model + tags: + - group_channel + /api/group_channel/{id}/tests: + get: + description: Returns persisted test results for a group channel from the global + management view + parameters: + - description: Group Channel ID + in: path + name: id + required: true + type: integer + - description: Success body + in: query + name: success_body + type: boolean + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/middleware.APIResponse' + - properties: + data: + items: + $ref: '#/definitions/model.GroupChannelTest' + type: array + type: object + security: + - ApiKeyAuth: [] + summary: Get group channel test results + tags: + - group_channel + /api/group_channel/test-preview: + post: + consumes: + - application/json + description: Test a single model in a group channel without saving to database + from the global management view. The request body must include group_id. + parameters: + - description: Success body + in: query + name: success_body + type: boolean + - description: Group channel test request + in: body + name: request + required: true + schema: + $ref: '#/definitions/controller.TestSingleGroupChannelRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/middleware.APIResponse' + - properties: + data: + $ref: '#/definitions/model.GroupChannelTest' + type: object + security: + - ApiKeyAuth: [] + summary: Test group channel preview + tags: + - group_channel + /api/group_channel/test-preview-all: + post: + consumes: + - application/json + description: Test all models in a group channel without saving to database from + the global management view. The request body must include group_id. + parameters: + - description: Return success + in: query + name: return_success + type: boolean + - description: Success body + in: query + name: success_body + type: boolean + - description: Stream mode (SSE) + in: query + name: stream + type: boolean + - description: Group channel test request + in: body + name: request + required: true + schema: + $ref: '#/definitions/controller.TestGroupChannelRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/middleware.APIResponse' + - properties: + data: + items: + $ref: '#/definitions/controller.GroupChannelTestResult' + type: array + type: object + security: + - ApiKeyAuth: [] + summary: Test group channel preview models + tags: + - group_channel + /api/group_channels/: + get: + description: Returns a paginated list of group channels across groups with optional + filters + parameters: + - description: Filter by group + in: query + name: group + type: string + - description: Page number + in: query + name: page + type: integer + - description: Items per page + in: query + name: per_page + type: integer + - description: Filter by id + in: query + name: id + type: integer + - description: Filter by name + in: query + name: name + type: string + - description: Filter by key + in: query + name: key + type: string + - description: Filter by channel type + in: query + name: channel_type + type: integer + - description: Filter by base URL + in: query + name: base_url + type: string + - description: Order by field + in: query + name: order + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/middleware.APIResponse' + - properties: + data: + additionalProperties: + allOf: + - {} + - properties: + channels: + items: + $ref: '#/definitions/model.GroupChannel' + type: array + total: + type: integer + type: object + type: object + type: object + security: + - ApiKeyAuth: [] + summary: Get group channels with pagination + tags: + - group_channels + post: + consumes: + - application/json + description: Adds group channels from the global management view. Each request + item must include group_id. + parameters: + - description: Group channel information + in: body + name: channels + required: true + schema: + items: + $ref: '#/definitions/controller.AddGroupChannelRequest' + type: array + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/middleware.APIResponse' + security: + - ApiKeyAuth: [] + summary: Add multiple group channels + tags: + - group_channels + /api/group_channels/batch_delete: + post: + consumes: + - application/json + description: Deletes group channels by IDs from the global management view + parameters: + - description: Group channel IDs + in: body + name: ids + required: true + schema: + items: + type: integer + type: array + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/middleware.APIResponse' + security: + - ApiKeyAuth: [] + summary: Delete multiple group channels + tags: + - group_channels + /api/group_channels/batch_info: + post: + consumes: + - application/json + description: Returns id, group, name, and type for a batch of group channel + IDs across groups + parameters: + - description: Group channel IDs + in: body + name: ids + required: true + schema: + items: + type: integer + type: array + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/middleware.APIResponse' + - properties: + data: + items: + $ref: '#/definitions/model.GroupChannelBasicInfo' + type: array + type: object + security: + - ApiKeyAuth: [] + summary: Get basic info for multiple group channels + tags: + - group_channels + /api/group_channels/import/oneapi: + post: + description: Imports group channels from OneAPI from the global management view. + The request body must include group_id. + parameters: + - description: Import group channel from OneAPI request + in: body + name: request + required: true + schema: + $ref: '#/definitions/controller.ImportChannelFromOneAPIRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/middleware.APIResponse' + - properties: + data: + items: {} + type: array + type: object + security: + - ApiKeyAuth: [] + summary: Import group channel from OneAPI + tags: + - group_channels + /api/group_channels/search: + get: + description: Search group channels across groups with keyword and optional filters + parameters: + - description: Filter by group + in: query + name: group + type: string + - description: Search keyword + in: query + name: keyword + type: string + - description: Page number + in: query + name: page + type: integer + - description: Items per page + in: query + name: per_page + type: integer + - description: Filter by id + in: query + name: id + type: integer + - description: Filter by name + in: query + name: name + type: string + - description: Filter by key + in: query + name: key + type: string + - description: Filter by channel type + in: query + name: channel_type + type: integer + - description: Filter by base URL + in: query + name: base_url + type: string + - description: Order by field + in: query + name: order + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/middleware.APIResponse' + - properties: + data: + additionalProperties: + allOf: + - {} + - properties: + channels: + items: + $ref: '#/definitions/model.GroupChannel' + type: array + total: + type: integer + type: object + type: object + type: object + security: + - ApiKeyAuth: [] + summary: Search group channels + tags: + - group_channels + /api/group_channels/type_metas: + get: + description: Returns metadata for all channel types + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/middleware.APIResponse' + - properties: + data: + additionalProperties: + $ref: '#/definitions/adaptors.AdaptorMeta' + type: object + type: object + security: + - ApiKeyAuth: [] + summary: Get channel type metadata + tags: + - channels + /api/groups/: + get: + description: Returns a list of all groups with pagination + parameters: + - description: Page number + in: query + name: page + type: integer + - description: Items per page + in: query + name: per_page + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/middleware.APIResponse' + - properties: + data: + additionalProperties: + allOf: + - {} + - properties: + groups: + items: + $ref: '#/definitions/controller.GroupResponse' + type: array + total: + type: integer + type: object + type: object + type: object + security: + - ApiKeyAuth: [] + summary: Get all groups + tags: + - groups + /api/groups/batch_delete: + post: + consumes: + - application/json + description: Deletes multiple groups by their IDs + parameters: + - description: Group IDs + in: body + name: ids + required: true + schema: + items: + type: string + type: array + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/middleware.APIResponse' + security: + - ApiKeyAuth: [] + summary: Delete multiple groups + tags: + - groups + /api/groups/batch_status: + post: + consumes: + - application/json + description: Updates the status of multiple groups + parameters: + - description: Group IDs and status + in: body + name: data + required: true + schema: + $ref: '#/definitions/controller.UpdateGroupsStatusRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/middleware.APIResponse' + security: + - ApiKeyAuth: [] + summary: Update multiple groups status + tags: + - groups + /api/groups/consumption_ranking: + get: + description: Returns channel, model, or group consumption ranking aggregated + from summary data + parameters: + - default: group + description: 'Ranking type: channel, model, group' + in: query + name: type + type: string + - description: Start timestamp + format: int64 + in: query + name: start_timestamp + type: integer + - description: End timestamp + format: int64 + in: query + name: end_timestamp + type: integer + - description: Timezone, default is Local + in: query + name: timezone + type: string + - description: Page number + in: query + name: page + type: integer + - description: Items per page + in: query + name: per_page + type: integer + - description: 'Order: used_amount_desc, used_amount_asc, request_count_desc, + request_count_asc, total_tokens_desc, total_tokens_asc, channel_id_asc, + channel_id_desc, model_asc, model_desc, group_id_asc, group_id_desc' + in: query + name: order + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/middleware.APIResponse' + - properties: + data: + additionalProperties: true + type: object + type: object + security: + - ApiKeyAuth: [] + summary: Get consumption ranking + tags: + - groups + /api/groups/ip_groups: + get: + description: Get IP group list + parameters: + - description: Threshold + in: query + name: threshold + type: integer + - description: Start timestamp + in: query + name: start_timestamp + type: integer + - description: End timestamp + in: query + name: end_timestamp + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/middleware.APIResponse' + - properties: + data: + additionalProperties: + items: + type: string + type: array + type: object + type: object + security: + - ApiKeyAuth: [] + summary: Get IP group list + tags: + - groups + /api/groups/ranking: + get: + description: Returns channel, model, or group consumption ranking aggregated + from summary data + parameters: + - default: group + description: 'Ranking type: channel, model, group' + in: query + name: type + type: string + - description: Start timestamp + format: int64 + in: query + name: start_timestamp + type: integer + - description: End timestamp + format: int64 + in: query + name: end_timestamp + type: integer + - description: Timezone, default is Local + in: query + name: timezone + type: string + - description: Page number + in: query + name: page + type: integer + - description: Items per page + in: query + name: per_page + type: integer + - description: 'Order: used_amount_desc, used_amount_asc, request_count_desc, + request_count_asc, total_tokens_desc, total_tokens_asc, channel_id_asc, + channel_id_desc, model_asc, model_desc, group_id_asc, group_id_desc' + in: query + name: order + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/middleware.APIResponse' + - properties: + data: + additionalProperties: true + type: object + type: object + security: + - ApiKeyAuth: [] + summary: Get consumption ranking + tags: + - groups + /api/groups/search: + get: + description: Search groups with keyword and pagination + parameters: + - description: Search keyword + in: query + name: keyword + required: true + type: string + - description: Page number + in: query + name: page + type: integer + - description: Items per page + in: query + name: per_page + type: integer + - description: Status + in: query + name: status + type: integer + - description: Order + in: query + name: order + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/middleware.APIResponse' + - properties: + data: + additionalProperties: + allOf: + - {} + - properties: + groups: + items: + $ref: '#/definitions/controller.GroupResponse' + type: array + total: + type: integer + type: object + type: object + type: object + security: + - ApiKeyAuth: [] + summary: Search groups + tags: + - groups + /api/log/{group}: + get: + description: Get logs for a specific group + parameters: + - description: Group name + in: path + name: group + required: true + type: string + - description: Page number + in: query + name: page + type: integer + - description: Items per page + in: query + name: per_page + type: integer + - description: Start timestamp (milliseconds) + in: query + name: start_timestamp + type: integer + - description: End timestamp (milliseconds) + in: query + name: end_timestamp + type: integer + - description: Token name + in: query + name: token_name + type: string + - description: Model name + in: query + name: model_name + type: string + - description: Channel ID + in: query + name: channel + type: integer + - description: Token ID + in: query + name: token_id + type: integer + - description: Order + in: query + name: order + type: string + - description: Request ID + in: query + name: request_id + type: string + - description: Upstream ID + in: query + name: upstream_id + type: string + - description: Status code type + in: query + name: code_type + type: string + - description: Status code + in: query + name: code + type: integer + - description: Include request and response detail + in: query + name: include_detail + type: boolean + - description: IP + in: query + name: ip + type: string + - description: User + in: query + name: user + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/middleware.APIResponse' + - properties: + data: + $ref: '#/definitions/model.GetGroupLogsResult' + type: object + security: + - ApiKeyAuth: [] + summary: Get group logs + tags: + - log + /api/log/{group}/detail/{log_id}: + get: + description: Get detailed information about a specific log entry in a group + parameters: + - description: Group name + in: path + name: group + required: true + type: string + - description: Log ID + in: path + name: log_id + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/middleware.APIResponse' + - properties: + data: + $ref: '#/definitions/model.RequestDetail' + type: object + security: + - ApiKeyAuth: [] + summary: Get group log detail + tags: + - log + /api/log/{group}/export: + get: + description: Streams filtered group logs as a CSV table file + parameters: + - description: Group name + in: path + name: group + required: true + type: string + - description: Start timestamp, max span 30 days + in: query + name: start_timestamp + type: integer + - description: End timestamp, max span 30 days + in: query + name: end_timestamp + type: integer + - description: Model name + in: query + name: model_name + type: string + - description: Token ID + in: query + name: token_id + type: integer + - description: Token name + in: query + name: token_name + type: string + - description: Sort order for created_at, supports desc or asc + in: query + name: order + type: string + - description: Request ID + in: query + name: request_id + type: string + - description: Upstream ID + in: query + name: upstream_id + type: string + - description: Status code type + in: query + name: code_type + type: string + - description: Status code + in: query + name: code + type: integer + - description: Include request and response detail, default false + in: query + name: include_detail + type: boolean + - description: IP + in: query + name: ip + type: string + - description: User + in: query + name: user + type: string + - description: Timezone, default is Local + in: query + name: timezone + type: string + - description: Maximum exported rows; zero or negative means unlimited + in: query + name: max_entries + type: integer + - description: Include channel column, default false + in: query + name: include_channel + type: boolean + - description: Include retry_at column, default false + in: query + name: include_retry_at + type: boolean + - description: Chunk interval, default 30m, min 10m, max 4h, e.g. 10m, 30m, + 1h + in: query + name: chunk_interval + type: string + produces: + - text/csv + responses: {} + security: + - ApiKeyAuth: [] + summary: Export group logs + tags: + - log + /api/log/{group}/group_channel: + delete: + description: Deletes group-channel logs older than the specified retention period + parameters: + - description: Timestamp (milliseconds) + in: query + name: timestamp + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/middleware.APIResponse' + - properties: + data: + type: integer + type: object + security: + - ApiKeyAuth: [] + summary: Delete historical group channel logs + tags: + - log + get: + description: Get group-channel logs for a specific group + parameters: + - description: Group name + in: path + name: group + required: true + type: string + - description: Page number + in: query + name: page + type: integer + - description: Items per page + in: query + name: per_page + type: integer + - description: Start timestamp (milliseconds) + in: query + name: start_timestamp + type: integer + - description: End timestamp (milliseconds) + in: query + name: end_timestamp + type: integer + - description: Token name + in: query + name: token_name + type: string + - description: Model name + in: query + name: model_name + type: string + - description: Group channel ID + in: query + name: channel + type: integer + - description: Token ID + in: query + name: token_id + type: integer + - description: Order + in: query + name: order + type: string + - description: Request ID + in: query + name: request_id + type: string + - description: Upstream ID + in: query + name: upstream_id + type: string + - description: Status code type + in: query + name: code_type + type: string + - description: Status code + in: query + name: code + type: integer + - description: Include request and response detail + in: query + name: include_detail + type: boolean + - description: IP + in: query + name: ip + type: string + - description: User + in: query + name: user + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/middleware.APIResponse' + - properties: + data: + $ref: '#/definitions/model.GetGroupChannelLogsResult' + type: object + security: + - ApiKeyAuth: [] + summary: Get group channel logs + tags: + - log + /api/log/{group}/group_channel/detail/{log_id}: + get: + description: Get detailed information about a group channel log entry in a group + parameters: + - description: Group name + in: path + name: group + required: true + type: string + - description: Log ID + in: path + name: log_id + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/middleware.APIResponse' + - properties: + data: + $ref: '#/definitions/model.RequestDetail' + type: object + security: + - ApiKeyAuth: [] + summary: Get group channel log detail for a group + tags: + - log + /api/log/{group}/group_channel/export: + get: + description: Streams filtered group-channel logs as a CSV table file + parameters: + - description: Group name + in: path + name: group + required: true + type: string + - description: Start timestamp, max span 30 days + in: query + name: start_timestamp + type: integer + - description: End timestamp, max span 30 days + in: query + name: end_timestamp + type: integer + - description: Model name + in: query + name: model_name + type: string + - description: Group channel ID + in: query + name: channel + type: integer + - description: Token ID + in: query + name: token_id + type: integer + - description: Token name + in: query + name: token_name + type: string + - description: Sort order for created_at, supports desc or asc + in: query + name: order + type: string + - description: Request ID + in: query + name: request_id + type: string + - description: Upstream ID + in: query + name: upstream_id + type: string + - description: Status code type + in: query + name: code_type + type: string + - description: Status code + in: query + name: code + type: integer + - description: Include request and response detail, default false + in: query + name: include_detail + type: boolean + - description: IP + in: query + name: ip + type: string + - description: User + in: query + name: user + type: string + - description: Timezone, default is Local + in: query + name: timezone + type: string + - description: Maximum exported rows; zero or negative means unlimited + in: query + name: max_entries + type: integer + - description: Include retry_at column, default false + in: query + name: include_retry_at + type: boolean + - description: Chunk interval, default 30m, min 10m, max 4h, e.g. 10m, 30m, + 1h + in: query + name: chunk_interval + type: string + produces: + - text/csv + responses: {} + security: + - ApiKeyAuth: [] + summary: Export group channel logs + tags: + - log + /api/log/{group}/group_channel/search: + get: + description: Search group-channel logs for a specific group + parameters: + - description: Group name + in: path + name: group + required: true + type: string + - description: Keyword + in: query + name: keyword + type: string + - description: Page number + in: query + name: page + type: integer + - description: Items per page + in: query + name: per_page + type: integer + - description: Start timestamp (milliseconds) + in: query + name: start_timestamp + type: integer + - description: End timestamp (milliseconds) + in: query + name: end_timestamp + type: integer + - description: Filter by token name + in: query + name: token_name + type: string + - description: Filter by model name + in: query + name: model_name + type: string + - description: Filter by token id + in: query + name: token_id + type: integer + - description: Filter by group channel + in: query + name: channel + type: integer + - description: Order + in: query + name: order + type: string + - description: Request ID + in: query + name: request_id + type: string + - description: Upstream ID + in: query + name: upstream_id + type: string + - description: Status code type + in: query + name: code_type + type: string + - description: Status code + in: query + name: code + type: integer + - description: Include request and response detail + in: query + name: include_detail + type: boolean + - description: IP + in: query + name: ip + type: string + - description: User + in: query + name: user + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/middleware.APIResponse' + - properties: + data: + $ref: '#/definitions/model.GetGroupChannelLogsResult' + type: object + security: + - ApiKeyAuth: [] + summary: Search group channel logs + tags: + - log + /api/log/{group}/search: + get: + description: Search logs for a specific group with filters + parameters: + - description: Group name + in: path + name: group + required: true + type: string + - description: Keyword + in: query + name: keyword + type: string + - description: Page number + in: query + name: page + type: integer + - description: Items per page + in: query + name: per_page type: integer - description: Start timestamp (milliseconds) in: query @@ -5933,11 +8879,182 @@ paths: type: boolean - description: IP in: query - name: ip + name: ip + type: string + - description: User + in: query + name: user + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/middleware.APIResponse' + - properties: + data: + $ref: '#/definitions/model.GetGroupLogsResult' + type: object + security: + - ApiKeyAuth: [] + summary: Search group logs + tags: + - log + /api/logs/: + delete: + description: Deletes logs older than the specified retention period + parameters: + - description: Timestamp (milliseconds) + in: query + name: timestamp + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/middleware.APIResponse' + - properties: + data: + type: integer + type: object + security: + - ApiKeyAuth: [] + summary: Delete historical logs + tags: + - logs + get: + description: Returns a paginated list of all logs with optional filters + parameters: + - description: Page number + in: query + name: page + type: integer + - description: Items per page + in: query + name: per_page + type: integer + - description: Start timestamp (milliseconds) + in: query + name: start_timestamp + type: integer + - description: End timestamp (milliseconds) + in: query + name: end_timestamp + type: integer + - description: Model name + in: query + name: model_name + type: string + - description: Channel ID + in: query + name: channel + type: integer + - description: Order + in: query + name: order + type: string + - description: Request ID + in: query + name: request_id + type: string + - description: Upstream ID + in: query + name: upstream_id + type: string + - description: Status code type + in: query + name: code_type + type: string + - description: Status code + in: query + name: code + type: integer + - description: Include request and response detail + in: query + name: include_detail + type: boolean + - description: IP + in: query + name: ip + type: string + - description: User + in: query + name: user + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/middleware.APIResponse' + - properties: + data: + $ref: '#/definitions/model.GetLogsResult' + type: object + security: + - ApiKeyAuth: [] + summary: Get all logs + tags: + - logs + /api/logs/consume_error: + get: + description: Search for logs with consumption errors + parameters: + - description: Page number + in: query + name: page + type: integer + - description: Items per page + in: query + name: per_page + type: integer + - description: Start timestamp (milliseconds) + in: query + name: start_timestamp + type: integer + - description: End timestamp (milliseconds) + in: query + name: end_timestamp + type: integer + - description: Keyword + in: query + name: keyword + type: string + - description: Group + in: query + name: group + type: string + - description: Token name + in: query + name: token_name + type: string + - description: Model name + in: query + name: model_name + type: string + - description: Content + in: query + name: content + type: string + - description: Token ID + in: query + name: token_id + type: integer + - description: Order + in: query + name: order type: string - - description: User + - description: Request ID in: query - name: user + name: request_id type: string produces: - application/json @@ -5949,22 +9066,33 @@ paths: - $ref: '#/definitions/middleware.APIResponse' - properties: data: - $ref: '#/definitions/model.GetGroupLogsResult' + additionalProperties: + allOf: + - {} + - properties: + logs: + items: + $ref: '#/definitions/model.RequestDetail' + type: array + total: + type: integer + type: object + type: object type: object security: - ApiKeyAuth: [] - summary: Search group logs + summary: Search consumption errors tags: - - log - /api/logs/: - delete: - description: Deletes logs older than the specified retention period + - logs + /api/logs/detail/{log_id}: + get: + description: Get detailed information about a specific log entry parameters: - - description: Timestamp (milliseconds) - in: query - name: timestamp + - description: Log ID + in: path + name: log_id required: true - type: integer + type: string produces: - application/json responses: @@ -5975,29 +9103,22 @@ paths: - $ref: '#/definitions/middleware.APIResponse' - properties: data: - type: integer + $ref: '#/definitions/model.RequestDetail' type: object security: - ApiKeyAuth: [] - summary: Delete historical logs + summary: Get log detail tags: - logs + /api/logs/export: get: - description: Returns a paginated list of all logs with optional filters + description: Streams filtered global logs as a CSV table file parameters: - - description: Page number - in: query - name: page - type: integer - - description: Items per page - in: query - name: per_page - type: integer - - description: Start timestamp (milliseconds) + - description: Start timestamp, max span 30 days in: query name: start_timestamp type: integer - - description: End timestamp (milliseconds) + - description: End timestamp, max span 30 days in: query name: end_timestamp type: integer @@ -6009,7 +9130,7 @@ paths: in: query name: channel type: integer - - description: Order + - description: Sort order for created_at, supports desc or asc in: query name: order type: string @@ -6029,7 +9150,7 @@ paths: in: query name: code type: integer - - description: Include request and response detail + - description: Include request and response detail, default false in: query name: include_detail type: boolean @@ -6041,6 +9162,41 @@ paths: in: query name: user type: string + - description: Timezone, default is Local + in: query + name: timezone + type: string + - description: Maximum exported rows; zero or negative means unlimited + in: query + name: max_entries + type: integer + - description: Chunk interval, default 30m, min 10m, max 4h, e.g. 10m, 30m, + 1h + in: query + name: chunk_interval + type: string + produces: + - text/csv + responses: {} + security: + - ApiKeyAuth: [] + summary: Export global logs + tags: + - logs + /api/logs/group_channel: + delete: + description: Deletes group-channel logs older than the specified retention period, + optionally filtered by group + parameters: + - description: Filter by group + in: query + name: group + type: string + - description: Timestamp (milliseconds) + in: query + name: timestamp + required: true + type: integer produces: - application/json responses: @@ -6051,17 +9207,20 @@ paths: - $ref: '#/definitions/middleware.APIResponse' - properties: data: - $ref: '#/definitions/model.GetLogsResult' + type: integer type: object security: - ApiKeyAuth: [] - summary: Get all logs + summary: Delete historical group channel logs tags: - logs - /api/logs/consume_error: get: - description: Search for logs with consumption errors + description: Get group-channel logs across groups with optional filters parameters: + - description: Filter by group + in: query + name: group + type: string - description: Page number in: query name: page @@ -6078,14 +9237,6 @@ paths: in: query name: end_timestamp type: integer - - description: Keyword - in: query - name: keyword - type: string - - description: Group - in: query - name: group - type: string - description: Token name in: query name: token_name @@ -6094,10 +9245,10 @@ paths: in: query name: model_name type: string - - description: Content + - description: Group channel ID in: query - name: content - type: string + name: channel + type: integer - description: Token ID in: query name: token_id @@ -6110,6 +9261,30 @@ paths: in: query name: request_id type: string + - description: Upstream ID + in: query + name: upstream_id + type: string + - description: Status code type + in: query + name: code_type + type: string + - description: Status code + in: query + name: code + type: integer + - description: Include request and response detail + in: query + name: include_detail + type: boolean + - description: IP + in: query + name: ip + type: string + - description: User + in: query + name: user + type: string produces: - application/json responses: @@ -6120,27 +9295,17 @@ paths: - $ref: '#/definitions/middleware.APIResponse' - properties: data: - additionalProperties: - allOf: - - {} - - properties: - logs: - items: - $ref: '#/definitions/model.RequestDetail' - type: array - total: - type: integer - type: object - type: object + $ref: '#/definitions/model.GetGroupChannelLogsResult' type: object security: - ApiKeyAuth: [] - summary: Search consumption errors + summary: Get global group channel logs tags: - logs - /api/logs/detail/{log_id}: + /api/logs/group_channel/detail/{log_id}: get: - description: Get detailed information about a specific log entry + description: Get detailed information about a group channel log entry across + groups parameters: - description: Log ID in: path @@ -6161,30 +9326,144 @@ paths: type: object security: - ApiKeyAuth: [] - summary: Get log detail + summary: Get global group channel log detail tags: - logs - /api/logs/export: + /api/logs/group_channel/export: get: - description: Streams filtered global logs as a CSV table file + description: Streams filtered group-channel logs across groups as a CSV table + file parameters: + - description: Filter by group + in: query + name: group + type: string - description: Start timestamp, max span 30 days in: query name: start_timestamp type: integer - - description: End timestamp, max span 30 days + - description: End timestamp, max span 30 days + in: query + name: end_timestamp + type: integer + - description: Model name + in: query + name: model_name + type: string + - description: Group channel ID + in: query + name: channel + type: integer + - description: Token ID + in: query + name: token_id + type: integer + - description: Token name + in: query + name: token_name + type: string + - description: Sort order for created_at, supports desc or asc + in: query + name: order + type: string + - description: Request ID + in: query + name: request_id + type: string + - description: Upstream ID + in: query + name: upstream_id + type: string + - description: Status code type + in: query + name: code_type + type: string + - description: Status code + in: query + name: code + type: integer + - description: Include request and response detail, default false + in: query + name: include_detail + type: boolean + - description: IP + in: query + name: ip + type: string + - description: User + in: query + name: user + type: string + - description: Timezone, default is Local + in: query + name: timezone + type: string + - description: Maximum exported rows; zero or negative means unlimited + in: query + name: max_entries + type: integer + - description: Include retry_at column, default false + in: query + name: include_retry_at + type: boolean + - description: Chunk interval, default 30m, min 10m, max 4h, e.g. 10m, 30m, + 1h + in: query + name: chunk_interval + type: string + produces: + - text/csv + responses: {} + security: + - ApiKeyAuth: [] + summary: Export global group channel logs + tags: + - logs + /api/logs/group_channel/search: + get: + description: Search group-channel logs across groups with optional filters + parameters: + - description: Filter by group + in: query + name: group + type: string + - description: Keyword + in: query + name: keyword + type: string + - description: Page number + in: query + name: page + type: integer + - description: Items per page + in: query + name: per_page + type: integer + - description: Start timestamp (milliseconds) + in: query + name: start_timestamp + type: integer + - description: End timestamp (milliseconds) in: query name: end_timestamp type: integer - - description: Model name + - description: Filter by token name + in: query + name: token_name + type: string + - description: Filter by model name in: query name: model_name type: string - - description: Channel ID + - description: Filter by token id + in: query + name: token_id + type: integer + - description: Filter by group channel in: query name: channel type: integer - - description: Sort order for created_at, supports desc or asc + - description: Order in: query name: order type: string @@ -6204,7 +9483,7 @@ paths: in: query name: code type: integer - - description: Include request and response detail, default false + - description: Include request and response detail in: query name: include_detail type: boolean @@ -6216,25 +9495,21 @@ paths: in: query name: user type: string - - description: Timezone, default is Local - in: query - name: timezone - type: string - - description: Maximum exported rows; zero or negative means unlimited - in: query - name: max_entries - type: integer - - description: Chunk interval, default 30m, min 10m, max 4h, e.g. 10m, 30m, - 1h - in: query - name: chunk_interval - type: string produces: - - text/csv - responses: {} + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/middleware.APIResponse' + - properties: + data: + $ref: '#/definitions/model.GetGroupChannelLogsResult' + type: object security: - ApiKeyAuth: [] - summary: Export global logs + summary: Search global group channel logs tags: - logs /api/logs/search: @@ -8024,6 +11299,58 @@ paths: - ApiKeyAuth: [] summary: Test Public MCP SSE Server /api/token/{group}: + get: + description: Returns a paginated list of all tokens for a specific group + parameters: + - description: Group name + in: path + name: group + required: true + type: string + - description: Page number + in: query + name: page + type: integer + - description: Items per page + in: query + name: per_page + type: integer + - description: Order + in: query + name: order + type: string + - description: Status + in: query + name: status + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/middleware.APIResponse' + - properties: + data: + additionalProperties: + allOf: + - {} + - properties: + tokens: + items: + $ref: '#/definitions/controller.TokenResponse' + type: array + total: + type: integer + type: object + type: object + type: object + security: + - ApiKeyAuth: [] + summary: Get all tokens for a specific group + tags: + - tokens post: consumes: - application/json @@ -8375,31 +11702,68 @@ paths: summary: Get all tokens tags: - tokens - /api/tokens/{group}: - get: - description: Returns a paginated list of all tokens for a specific group + /api/tokens/{id}: + delete: + description: Deletes a specific token by ID parameters: - - description: Group name + - description: Token ID in: path - name: group + name: id required: true - type: string - - description: Page number - in: query - name: page type: integer - - description: Items per page - in: query - name: per_page + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/middleware.APIResponse' + security: + - ApiKeyAuth: [] + summary: Delete token + tags: + - tokens + get: + description: Returns detailed information about a specific token + parameters: + - description: Token ID + in: path + name: id + required: true type: integer - - description: Order - in: query - name: order - type: string - - description: Status - in: query - name: status + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/middleware.APIResponse' + - properties: + data: + $ref: '#/definitions/controller.TokenResponse' + type: object + security: + - ApiKeyAuth: [] + summary: Get token by ID + tags: + - tokens + put: + consumes: + - application/json + description: Updates an existing token's information + parameters: + - description: Token ID + in: path + name: id + required: true type: integer + - description: Updated token information + in: body + name: token + required: true + schema: + $ref: '#/definitions/model.UpdateTokenRequest' produces: - application/json responses: @@ -8410,33 +11774,30 @@ paths: - $ref: '#/definitions/middleware.APIResponse' - properties: data: - additionalProperties: - allOf: - - {} - - properties: - tokens: - items: - $ref: '#/definitions/controller.TokenResponse' - type: array - total: - type: integer - type: object - type: object + $ref: '#/definitions/controller.TokenResponse' type: object security: - ApiKeyAuth: [] - summary: Get all tokens for a specific group + summary: Update token tags: - tokens - /api/tokens/{id}: - delete: - description: Deletes a specific token by ID + /api/tokens/{id}/name: + post: + consumes: + - application/json + description: Updates the name of a specific token parameters: - description: Token ID in: path name: id required: true type: integer + - description: Name information + in: body + name: name + required: true + schema: + $ref: '#/definitions/controller.UpdateTokenNameRequest' produces: - application/json responses: @@ -8446,17 +11807,100 @@ paths: $ref: '#/definitions/middleware.APIResponse' security: - ApiKeyAuth: [] - summary: Delete token + summary: Update token name tags: - tokens - get: - description: Returns detailed information about a specific token + /api/tokens/{id}/status: + post: + consumes: + - application/json + description: Updates the status of a specific token parameters: - description: Token ID in: path name: id required: true type: integer + - description: Status information + in: body + name: status + required: true + schema: + $ref: '#/definitions/controller.UpdateTokenStatusRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/middleware.APIResponse' + security: + - ApiKeyAuth: [] + summary: Update token status + tags: + - tokens + /api/tokens/batch_delete: + post: + consumes: + - application/json + description: Deletes multiple tokens by their IDs + parameters: + - description: Token IDs + in: body + name: ids + required: true + schema: + items: + type: integer + type: array + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/middleware.APIResponse' + security: + - ApiKeyAuth: [] + summary: Delete multiple tokens + tags: + - tokens + /api/tokens/search: + get: + description: Returns a paginated list of tokens based on search criteria + parameters: + - description: Keyword + in: query + name: keyword + type: string + - description: Page number + in: query + name: page + type: integer + - description: Items per page + in: query + name: per_page + type: integer + - description: Order + in: query + name: order + type: string + - description: Name + in: query + name: name + type: string + - description: Key + in: query + name: key + type: string + - description: Status + in: query + name: status + type: integer + - description: Group + in: query + name: group + type: string produces: - application/json responses: @@ -8467,194 +11911,330 @@ paths: - $ref: '#/definitions/middleware.APIResponse' - properties: data: - $ref: '#/definitions/controller.TokenResponse' + additionalProperties: + allOf: + - {} + - properties: + tokens: + items: + $ref: '#/definitions/controller.TokenResponse' + type: array + total: + type: integer + type: object + type: object type: object security: - ApiKeyAuth: [] - summary: Get token by ID + summary: Search tokens tags: - tokens - put: + /api/v1/services/aigc/video-generation/video-synthesis: + post: consumes: - application/json - description: Updates an existing token's information + description: Create an Ali DashScope native video generation task. parameters: - - description: Token ID - in: path - name: id - required: true - type: integer - - description: Updated token information + - description: Ali DashScope video synthesis request in: body - name: token + name: request required: true schema: - $ref: '#/definitions/model.UpdateTokenRequest' + type: object + - description: Optional Aiproxy-Channel header + in: header + name: Aiproxy-Channel + type: string + - description: Optional group ID for internal token requests + in: header + name: X-Aiproxy-Group + type: string + - description: 'Optional group channel mode for internal token requests. Values: + global, own' + enum: + - global + - own + in: header + name: X-Aiproxy-Group-Channel-Mode + type: string produces: - application/json responses: "200": description: OK + headers: + X-RateLimit-Limit-Requests: + description: X-RateLimit-Limit-Requests + type: integer + X-RateLimit-Limit-Tokens: + description: X-RateLimit-Limit-Tokens + type: integer + X-RateLimit-Remaining-Requests: + description: X-RateLimit-Remaining-Requests + type: integer + X-RateLimit-Remaining-Tokens: + description: X-RateLimit-Remaining-Tokens + type: integer + X-RateLimit-Reset-Requests: + description: X-RateLimit-Reset-Requests + type: string + X-RateLimit-Reset-Tokens: + description: X-RateLimit-Reset-Tokens + type: string schema: - allOf: - - $ref: '#/definitions/middleware.APIResponse' - - properties: - data: - $ref: '#/definitions/controller.TokenResponse' - type: object + type: object security: - ApiKeyAuth: [] - summary: Update token + summary: Create Ali native video task tags: - - tokens - /api/tokens/{id}/name: - post: - consumes: - - application/json - description: Updates the name of a specific token + - relay + /api/v1/tasks/{task_id}: + get: + description: Get an Ali DashScope native video generation task. parameters: - - description: Token ID + - description: Task ID in: path - name: id - required: true - type: integer - - description: Name information - in: body - name: name + name: task_id required: true - schema: - $ref: '#/definitions/controller.UpdateTokenNameRequest' + type: string + - description: Optional Aiproxy-Channel header + in: header + name: Aiproxy-Channel + type: string + - description: Optional group ID for internal token requests + in: header + name: X-Aiproxy-Group + type: string + - description: 'Optional group channel mode for internal token requests. Values: + global, own' + enum: + - global + - own + in: header + name: X-Aiproxy-Group-Channel-Mode + type: string produces: - application/json responses: "200": description: OK + headers: + X-RateLimit-Limit-Requests: + description: X-RateLimit-Limit-Requests + type: integer + X-RateLimit-Limit-Tokens: + description: X-RateLimit-Limit-Tokens + type: integer + X-RateLimit-Remaining-Requests: + description: X-RateLimit-Remaining-Requests + type: integer + X-RateLimit-Remaining-Tokens: + description: X-RateLimit-Remaining-Tokens + type: integer + X-RateLimit-Reset-Requests: + description: X-RateLimit-Reset-Requests + type: string + X-RateLimit-Reset-Tokens: + description: X-RateLimit-Reset-Tokens + type: string schema: - $ref: '#/definitions/middleware.APIResponse' + type: object security: - ApiKeyAuth: [] - summary: Update token name + summary: Get Ali native video task tags: - - tokens - /api/tokens/{id}/status: + - relay + /api/v3/contents/generations/tasks: post: consumes: - application/json - description: Updates the status of a specific token + description: Create a Doubao native video generation task. parameters: - - description: Token ID - in: path - name: id - required: true - type: integer - - description: Status information + - description: Doubao video generation task request in: body - name: status + name: request required: true schema: - $ref: '#/definitions/controller.UpdateTokenStatusRequest' + type: object + - description: Optional Aiproxy-Channel header + in: header + name: Aiproxy-Channel + type: string + - description: Optional group ID for internal token requests + in: header + name: X-Aiproxy-Group + type: string + - description: 'Optional group channel mode for internal token requests. Values: + global, own' + enum: + - global + - own + in: header + name: X-Aiproxy-Group-Channel-Mode + type: string produces: - application/json responses: "200": description: OK + headers: + X-RateLimit-Limit-Requests: + description: X-RateLimit-Limit-Requests + type: integer + X-RateLimit-Limit-Tokens: + description: X-RateLimit-Limit-Tokens + type: integer + X-RateLimit-Remaining-Requests: + description: X-RateLimit-Remaining-Requests + type: integer + X-RateLimit-Remaining-Tokens: + description: X-RateLimit-Remaining-Tokens + type: integer + X-RateLimit-Reset-Requests: + description: X-RateLimit-Reset-Requests + type: string + X-RateLimit-Reset-Tokens: + description: X-RateLimit-Reset-Tokens + type: string schema: - $ref: '#/definitions/middleware.APIResponse' + type: object security: - ApiKeyAuth: [] - summary: Update token status + summary: Create Doubao native video task tags: - - tokens - /api/tokens/batch_delete: - post: - consumes: - - application/json - description: Deletes multiple tokens by their IDs + - relay + /api/v3/contents/generations/tasks/{task_id}: + delete: + description: Delete a Doubao native video generation task. parameters: - - description: Token IDs - in: body - name: ids + - description: Task ID + in: path + name: task_id required: true - schema: - items: - type: integer - type: array + type: string + - description: Optional Aiproxy-Channel header + in: header + name: Aiproxy-Channel + type: string + - description: Optional group ID for internal token requests + in: header + name: X-Aiproxy-Group + type: string + - description: 'Optional group channel mode for internal token requests. Values: + global, own' + enum: + - global + - own + in: header + name: X-Aiproxy-Group-Channel-Mode + type: string produces: - application/json responses: "200": description: OK + headers: + X-RateLimit-Limit-Requests: + description: X-RateLimit-Limit-Requests + type: integer + X-RateLimit-Limit-Tokens: + description: X-RateLimit-Limit-Tokens + type: integer + X-RateLimit-Remaining-Requests: + description: X-RateLimit-Remaining-Requests + type: integer + X-RateLimit-Remaining-Tokens: + description: X-RateLimit-Remaining-Tokens + type: integer + X-RateLimit-Reset-Requests: + description: X-RateLimit-Reset-Requests + type: string + X-RateLimit-Reset-Tokens: + description: X-RateLimit-Reset-Tokens + type: string schema: - $ref: '#/definitions/middleware.APIResponse' + type: object + "204": + description: No Content + headers: + X-RateLimit-Limit-Requests: + description: X-RateLimit-Limit-Requests + type: integer + X-RateLimit-Limit-Tokens: + description: X-RateLimit-Limit-Tokens + type: integer + X-RateLimit-Remaining-Requests: + description: X-RateLimit-Remaining-Requests + type: integer + X-RateLimit-Remaining-Tokens: + description: X-RateLimit-Remaining-Tokens + type: integer + X-RateLimit-Reset-Requests: + description: X-RateLimit-Reset-Requests + type: string + X-RateLimit-Reset-Tokens: + description: X-RateLimit-Reset-Tokens + type: string security: - ApiKeyAuth: [] - summary: Delete multiple tokens + summary: Delete Doubao native video task tags: - - tokens - /api/tokens/search: + - relay get: - description: Returns a paginated list of tokens based on search criteria + description: Get a Doubao native video generation task. parameters: - - description: Keyword - in: query - name: keyword - type: string - - description: Page number - in: query - name: page - type: integer - - description: Items per page - in: query - name: per_page - type: integer - - description: Order - in: query - name: order + - description: Task ID + in: path + name: task_id + required: true type: string - - description: Name - in: query - name: name + - description: Optional Aiproxy-Channel header + in: header + name: Aiproxy-Channel type: string - - description: Key - in: query - name: key + - description: Optional group ID for internal token requests + in: header + name: X-Aiproxy-Group type: string - - description: Status - in: query - name: status - type: integer - - description: Group - in: query - name: group + - description: 'Optional group channel mode for internal token requests. Values: + global, own' + enum: + - global + - own + in: header + name: X-Aiproxy-Group-Channel-Mode type: string produces: - application/json responses: "200": description: OK + headers: + X-RateLimit-Limit-Requests: + description: X-RateLimit-Limit-Requests + type: integer + X-RateLimit-Limit-Tokens: + description: X-RateLimit-Limit-Tokens + type: integer + X-RateLimit-Remaining-Requests: + description: X-RateLimit-Remaining-Requests + type: integer + X-RateLimit-Remaining-Tokens: + description: X-RateLimit-Remaining-Tokens + type: integer + X-RateLimit-Reset-Requests: + description: X-RateLimit-Reset-Requests + type: string + X-RateLimit-Reset-Tokens: + description: X-RateLimit-Reset-Tokens + type: string schema: - allOf: - - $ref: '#/definitions/middleware.APIResponse' - - properties: - data: - additionalProperties: - allOf: - - {} - - properties: - tokens: - items: - $ref: '#/definitions/controller.TokenResponse' - type: array - total: - type: integer - type: object - type: object - type: object + type: object security: - ApiKeyAuth: [] - summary: Search tokens + summary: Get Doubao native video task tags: - - tokens + - relay /mcp: delete: responses: {} @@ -8739,6 +12319,18 @@ paths: in: header name: Aiproxy-Channel type: string + - description: Optional group ID for internal token requests + in: header + name: X-Aiproxy-Group + type: string + - description: 'Optional group channel mode for internal token requests. Values: + global, own' + enum: + - global + - own + in: header + name: X-Aiproxy-Group-Channel-Mode + type: string produces: - application/json responses: @@ -8788,6 +12380,18 @@ paths: in: header name: Aiproxy-Channel type: string + - description: Optional group ID for internal token requests + in: header + name: X-Aiproxy-Group + type: string + - description: 'Optional group channel mode for internal token requests. Values: + global, own' + enum: + - global + - own + in: header + name: X-Aiproxy-Group-Channel-Mode + type: string produces: - application/json responses: @@ -8837,6 +12441,18 @@ paths: in: header name: Aiproxy-Channel type: string + - description: Optional group ID for internal token requests + in: header + name: X-Aiproxy-Group + type: string + - description: 'Optional group channel mode for internal token requests. Values: + global, own' + enum: + - global + - own + in: header + name: X-Aiproxy-Group-Channel-Mode + type: string produces: - application/json responses: @@ -8882,6 +12498,18 @@ paths: in: header name: Aiproxy-Channel type: string + - description: Optional group ID for internal token requests + in: header + name: X-Aiproxy-Group + type: string + - description: 'Optional group channel mode for internal token requests. Values: + global, own' + enum: + - global + - own + in: header + name: X-Aiproxy-Group-Channel-Mode + type: string produces: - application/json responses: @@ -8927,6 +12555,18 @@ paths: in: header name: Aiproxy-Channel type: string + - description: Optional group ID for internal token requests + in: header + name: X-Aiproxy-Group + type: string + - description: 'Optional group channel mode for internal token requests. Values: + global, own' + enum: + - global + - own + in: header + name: X-Aiproxy-Group-Channel-Mode + type: string produces: - application/json responses: @@ -9017,6 +12657,18 @@ paths: in: header name: Aiproxy-Channel type: string + - description: Optional group ID for internal token requests + in: header + name: X-Aiproxy-Group + type: string + - description: 'Optional group channel mode for internal token requests. Values: + global, own' + enum: + - global + - own + in: header + name: X-Aiproxy-Group-Channel-Mode + type: string produces: - application/json responses: @@ -9071,6 +12723,18 @@ paths: in: header name: Aiproxy-Channel type: string + - description: Optional group ID for internal token requests + in: header + name: X-Aiproxy-Group + type: string + - description: 'Optional group channel mode for internal token requests. Values: + global, own' + enum: + - global + - own + in: header + name: X-Aiproxy-Group-Channel-Mode + type: string produces: - application/json responses: @@ -9116,6 +12780,18 @@ paths: in: header name: Aiproxy-Channel type: string + - description: Optional group ID for internal token requests + in: header + name: X-Aiproxy-Group + type: string + - description: 'Optional group channel mode for internal token requests. Values: + global, own' + enum: + - global + - own + in: header + name: X-Aiproxy-Group-Channel-Mode + type: string produces: - application/json responses: @@ -9161,6 +12837,18 @@ paths: in: header name: Aiproxy-Channel type: string + - description: Optional group ID for internal token requests + in: header + name: X-Aiproxy-Group + type: string + - description: 'Optional group channel mode for internal token requests. Values: + global, own' + enum: + - global + - own + in: header + name: X-Aiproxy-Group-Channel-Mode + type: string produces: - application/json responses: @@ -9247,6 +12935,18 @@ paths: in: header name: Aiproxy-Channel type: string + - description: Optional group ID for internal token requests + in: header + name: X-Aiproxy-Group + type: string + - description: 'Optional group channel mode for internal token requests. Values: + global, own' + enum: + - global + - own + in: header + name: X-Aiproxy-Group-Channel-Mode + type: string produces: - application/json responses: @@ -9292,6 +12992,18 @@ paths: in: header name: Aiproxy-Channel type: string + - description: Optional group ID for internal token requests + in: header + name: X-Aiproxy-Group + type: string + - description: 'Optional group channel mode for internal token requests. Values: + global, own' + enum: + - global + - own + in: header + name: X-Aiproxy-Group-Channel-Mode + type: string produces: - application/json responses: @@ -9337,6 +13049,18 @@ paths: in: header name: Aiproxy-Channel type: string + - description: Optional group ID for internal token requests + in: header + name: X-Aiproxy-Group + type: string + - description: 'Optional group channel mode for internal token requests. Values: + global, own' + enum: + - global + - own + in: header + name: X-Aiproxy-Group-Channel-Mode + type: string produces: - application/json responses: @@ -9381,6 +13105,18 @@ paths: in: header name: Aiproxy-Channel type: string + - description: Optional group ID for internal token requests + in: header + name: X-Aiproxy-Group + type: string + - description: 'Optional group channel mode for internal token requests. Values: + global, own' + enum: + - global + - own + in: header + name: X-Aiproxy-Group-Channel-Mode + type: string produces: - application/json responses: @@ -9403,6 +13139,18 @@ paths: in: header name: Aiproxy-Channel type: string + - description: Optional group ID for internal token requests + in: header + name: X-Aiproxy-Group + type: string + - description: 'Optional group channel mode for internal token requests. Values: + global, own' + enum: + - global + - own + in: header + name: X-Aiproxy-Group-Channel-Mode + type: string produces: - application/json responses: @@ -9428,6 +13176,18 @@ paths: in: header name: Aiproxy-Channel type: string + - description: Optional group ID for internal token requests + in: header + name: X-Aiproxy-Group + type: string + - description: 'Optional group channel mode for internal token requests. Values: + global, own' + enum: + - global + - own + in: header + name: X-Aiproxy-Group-Channel-Mode + type: string produces: - application/json responses: @@ -9453,6 +13213,18 @@ paths: in: header name: Aiproxy-Channel type: string + - description: Optional group ID for internal token requests + in: header + name: X-Aiproxy-Group + type: string + - description: 'Optional group channel mode for internal token requests. Values: + global, own' + enum: + - global + - own + in: header + name: X-Aiproxy-Group-Channel-Mode + type: string produces: - application/json responses: @@ -9479,6 +13251,18 @@ paths: in: header name: Aiproxy-Channel type: string + - description: Optional group ID for internal token requests + in: header + name: X-Aiproxy-Group + type: string + - description: 'Optional group channel mode for internal token requests. Values: + global, own' + enum: + - global + - own + in: header + name: X-Aiproxy-Group-Channel-Mode + type: string produces: - application/json responses: @@ -9524,6 +13308,18 @@ paths: in: header name: Aiproxy-Channel type: string + - description: Optional group ID for internal token requests + in: header + name: X-Aiproxy-Group + type: string + - description: 'Optional group channel mode for internal token requests. Values: + global, own' + enum: + - global + - own + in: header + name: X-Aiproxy-Group-Channel-Mode + type: string produces: - application/json responses: @@ -9569,6 +13365,18 @@ paths: in: header name: Aiproxy-Channel type: string + - description: Optional group ID for internal token requests + in: header + name: X-Aiproxy-Group + type: string + - description: 'Optional group channel mode for internal token requests. Values: + global, own' + enum: + - global + - own + in: header + name: X-Aiproxy-Group-Channel-Mode + type: string produces: - application/json responses: @@ -9614,6 +13422,18 @@ paths: in: header name: Aiproxy-Channel type: string + - description: Optional group ID for internal token requests + in: header + name: X-Aiproxy-Group + type: string + - description: 'Optional group channel mode for internal token requests. Values: + global, own' + enum: + - global + - own + in: header + name: X-Aiproxy-Group-Channel-Mode + type: string produces: - application/json responses: @@ -9658,6 +13478,18 @@ paths: in: header name: Aiproxy-Channel type: string + - description: Optional group ID for internal token requests + in: header + name: X-Aiproxy-Group + type: string + - description: 'Optional group channel mode for internal token requests. Values: + global, own' + enum: + - global + - own + in: header + name: X-Aiproxy-Group-Channel-Mode + type: string produces: - application/json responses: @@ -9680,6 +13512,18 @@ paths: in: header name: Aiproxy-Channel type: string + - description: Optional group ID for internal token requests + in: header + name: X-Aiproxy-Group + type: string + - description: 'Optional group channel mode for internal token requests. Values: + global, own' + enum: + - global + - own + in: header + name: X-Aiproxy-Group-Channel-Mode + type: string produces: - application/json responses: @@ -9705,6 +13549,18 @@ paths: in: header name: Aiproxy-Channel type: string + - description: Optional group ID for internal token requests + in: header + name: X-Aiproxy-Group + type: string + - description: 'Optional group channel mode for internal token requests. Values: + global, own' + enum: + - global + - own + in: header + name: X-Aiproxy-Group-Channel-Mode + type: string produces: - application/octet-stream responses: @@ -9736,6 +13592,18 @@ paths: in: header name: Aiproxy-Channel type: string + - description: Optional group ID for internal token requests + in: header + name: X-Aiproxy-Group + type: string + - description: 'Optional group channel mode for internal token requests. Values: + global, own' + enum: + - global + - own + in: header + name: X-Aiproxy-Group-Channel-Mode + type: string produces: - application/json responses: @@ -9748,6 +13616,82 @@ paths: summary: Remix video tags: - relay + /v1/videos/edits: + post: + description: Create a new video by editing a source video + parameters: + - description: Request + in: body + name: request + required: true + schema: + $ref: '#/definitions/model.VideosEditRequest' + - description: Optional Aiproxy-Channel header + in: header + name: Aiproxy-Channel + type: string + - description: Optional group ID for internal token requests + in: header + name: X-Aiproxy-Group + type: string + - description: 'Optional group channel mode for internal token requests. Values: + global, own' + enum: + - global + - own + in: header + name: X-Aiproxy-Group-Channel-Mode + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/model.Video' + security: + - ApiKeyAuth: [] + summary: Edit video + tags: + - relay + /v1/videos/extensions: + post: + description: Create an extension of a completed video + parameters: + - description: Request + in: body + name: request + required: true + schema: + $ref: '#/definitions/model.VideosExtensionRequest' + - description: Optional Aiproxy-Channel header + in: header + name: Aiproxy-Channel + type: string + - description: Optional group ID for internal token requests + in: header + name: X-Aiproxy-Group + type: string + - description: 'Optional group channel mode for internal token requests. Values: + global, own' + enum: + - global + - own + in: header + name: X-Aiproxy-Group-Channel-Mode + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/model.Video' + security: + - ApiKeyAuth: [] + summary: Extend video + tags: + - relay securityDefinitions: ApiKeyAuth: in: header diff --git a/core/middleware/auth.go b/core/middleware/auth.go index a0b326db..6e48e438 100644 --- a/core/middleware/auth.go +++ b/core/middleware/auth.go @@ -2,7 +2,6 @@ package middleware import ( "fmt" - "maps" "net/http" "slices" "strings" @@ -18,6 +17,14 @@ import ( "github.com/sirupsen/logrus" ) +const ( + XAiproxyGroup = "X-Aiproxy-Group" + XAiproxyGroupChannelMode = "X-Aiproxy-Group-Channel-Mode" + + GroupChannelModeOwn = "own" + GroupChannelModeGlobal = "global" +) + type APIResponse struct { Data any `json:"data,omitempty"` Message string `json:"message,omitempty"` @@ -101,7 +108,7 @@ func TokenAuth(c *gin.Context) { } useInternalToken = true } else { - tokenCache, err := model.GetAndValidateToken(key) + tokenCache, err := model.GetTokenByKeyForAuth(key) if err != nil { oncall.AlertDBError("TokenAuth", err) AbortLogWithMessage(c, http.StatusUnauthorized, err.Error()) @@ -139,13 +146,22 @@ func TokenAuth(c *gin.Context) { modelCaches := model.LoadModelCaches() - var group model.GroupCache + var ( + group model.GroupCache + groupChannelMode string + ) + + groupChannelModeHeader := c.Request.Header.Get(XAiproxyGroupChannelMode) if useInternalToken { + groupID := c.Request.Header.Get(XAiproxyGroup) group = model.GroupCache{ - Status: model.GroupStatusInternal, - AvailableSets: slices.Collect(maps.Keys(modelCaches.EnabledModelsBySet)), + ID: groupID, + Status: model.GroupStatusInternal, } + groupChannelMode = getInternalTokenGroupChannelMode(groupChannelModeHeader, groupID) } else { + groupChannelMode = getTokenGroupChannelMode(token) + groupCache, err := model.CacheGetGroup(token.Group) if err != nil { AbortLogWithMessage( @@ -160,25 +176,253 @@ func TokenAuth(c *gin.Context) { group = *groupCache } + if !useInternalToken && groupChannelMode != GroupChannelModeOwn { + if err := model.ValidateTokenQuota(&token); err != nil { + AbortLogWithMessage(c, http.StatusUnauthorized, err.Error()) + return + } + } + c.Header("Group", group.ID) SetLogGroupFields(log.Data, group) + if groupChannelMode != "" { + log.Data["group_channel_mode"] = groupChannelMode + } + if group.Status != model.GroupStatusEnabled && group.Status != model.GroupStatusInternal { AbortLogWithMessage(c, http.StatusForbidden, "group is disabled") return } - token.SetAvailableSets(group.GetAvailableSets()) - token.SetModelsBySet(modelCaches.EnabledModelsBySet) + availableSets := model.ResolveTokenAvailableSets( + group.GetAvailableSets(), + token.GetConfiguredSets(), + ) + groupChannelAvailableSets := resolveRequestGroupChannelAvailableSets( + group, + token, + groupChannelMode, + ) + availableModels := model.FilterModelsBySet(modelCaches.EnabledModelsBySet, availableSets) + groupChannelAvailableModels := mergeGroupChannelModelsBySet( + group, + groupChannelAvailableSets, + groupChannelMode, + modelCaches.EnabledModelsBySet, + ) c.Set(Group, group) c.Set(Token, token) + c.Set(AvailableSets, availableSets) + c.Set(AvailableModels, availableModels) + c.Set(GroupChannelAvailableSets, groupChannelAvailableSets) + c.Set(GroupChannelAvailableModels, groupChannelAvailableModels) c.Set(ModelCaches, modelCaches) + c.Set(GroupChannelMode, groupChannelMode) c.Next() } +func parseGroupChannelMode(value string) string { + switch strings.ToLower(strings.TrimSpace(value)) { + case GroupChannelModeOwn, "group", "group-only", "group_only": + return GroupChannelModeOwn + case GroupChannelModeGlobal: + return GroupChannelModeGlobal + default: + return GroupChannelModeGlobal + } +} + +func getTokenGroupChannelMode( + token model.TokenCache, +) string { + switch token.Scope { + case model.ChannelScopeGlobal: + return GroupChannelModeGlobal + case model.ChannelScopeGroup: + return GroupChannelModeOwn + default: + return GroupChannelModeGlobal + } +} + +func getInternalTokenGroupChannelMode(header, groupID string) string { + if groupID == "" { + return GroupChannelModeGlobal + } + + return parseGroupChannelMode(header) +} + +func resolveRequestGroupChannelAvailableSets( + group model.GroupCache, + token model.TokenCache, + mode string, +) []string { + if mode != GroupChannelModeOwn || group.ID == "" { + return nil + } + + if group.Status == model.GroupStatusInternal { + return model.ResolveTokenGroupChannelAvailableSets( + group.GetAvailableSets(), + token.GetConfiguredGroupChannelSets(), + ) + } + + cache, err := model.CacheGetGroupChannels(group.ID) + if err != nil { + return nil + } + + availableSets := make([]string, 0) + for _, channel := range cache.Channels { + if channel.Status != model.ChannelStatusEnabled { + continue + } + + for _, set := range channel.GetSets() { + if !slices.Contains(availableSets, set) { + availableSets = append(availableSets, set) + } + } + } + + return model.ResolveTokenGroupChannelAvailableSets( + availableSets, + token.GetConfiguredGroupChannelSets(), + ) +} + +func mergeGroupChannelModelsBySet( + group model.GroupCache, + availableSets []string, + mode string, + global map[string][]string, +) map[string][]string { + if mode == GroupChannelModeGlobal || group.ID == "" { + return model.FilterModelsBySet(global, availableSets) + } + + result := make(map[string][]string) + + cache, err := model.CacheGetGroupChannels(group.ID) + if err != nil { + return result + } + + scopeModels := availableGroupScopeModels(group.ID, cache.Channels) + if len(scopeModels) == 0 { + return result + } + + allowedSets := make(map[string]struct{}, len(availableSets)) + for _, set := range availableSets { + allowedSets[set] = struct{}{} + } + + for _, channel := range cache.Channels { + if channel.Status != model.ChannelStatusEnabled { + continue + } + + supportedModels := groupChannelSupportedScopeModels(channel, scopeModels) + if len(supportedModels) == 0 { + continue + } + + for _, set := range channel.GetSets() { + if _, ok := allowedSets[set]; !ok { + continue + } + + for _, modelName := range supportedModels { + result[set] = appendModelIfMissing(result[set], modelName) + } + } + } + + for set := range result { + slices.Sort(result[set]) + } + + return result +} + +func availableGroupScopeModels(groupID string, channels []*model.GroupChannel) []string { + if config.DisableModelConfig { + return groupChannelAccessModels(channels) + } + + scopeConfigs, err := model.CacheGetGroupScopeModelConfigs(groupID) + if err != nil { + return nil + } + + return validGroupScopeModels(scopeConfigs) +} + +func groupChannelAccessModels(channels []*model.GroupChannel) []string { + modelCount := 0 + for _, channel := range channels { + modelCount += len(model.GroupChannelAccessModels(channel)) + } + + models := make([]string, 0, modelCount) + for _, channel := range channels { + models = append(models, model.GroupChannelAccessModels(channel)...) + } + + slices.Sort(models) + + return slices.CompactFunc(models, strings.EqualFold) +} + +func validGroupScopeModels(scopeConfigs *model.GroupScopeModelConfigsCache) []string { + models := make([]string, 0, len(scopeConfigs.Models)) + for _, modelName := range scopeConfigs.Models { + if _, ok := scopeConfigs.Configs[modelName]; ok { + models = append(models, modelName) + } + } + + return models +} + +func groupChannelSupportedScopeModels(channel *model.GroupChannel, scopeModels []string) []string { + accessModels := model.GroupChannelAccessModels(channel) + if len(accessModels) == 0 { + return nil + } + + access := make(map[string]struct{}, len(accessModels)) + for _, modelName := range accessModels { + access[strings.ToLower(modelName)] = struct{}{} + } + + models := make([]string, 0, len(scopeModels)) + for _, modelName := range scopeModels { + if _, ok := access[strings.ToLower(modelName)]; ok { + models = append(models, modelName) + } + } + + return models +} + +func appendModelIfMissing(models []string, modelName string) []string { + if slices.ContainsFunc(models, func(item string) bool { + return strings.EqualFold(item, modelName) + }) { + return models + } + + return append(models, modelName) +} + func GetGroup(c *gin.Context) model.GroupCache { v, ok := c.MustGet(Group).(model.GroupCache) if !ok { @@ -197,6 +441,77 @@ func GetToken(c *gin.Context) model.TokenCache { return v } +func GetAvailableSets(c *gin.Context) []string { + v, ok := c.MustGet(AvailableSets).([]string) + if !ok { + panic(fmt.Sprintf("available sets type error: %T, %v", v, v)) + } + + return v +} + +func GetAvailableModels(c *gin.Context) map[string][]string { + v, ok := c.MustGet(AvailableModels).(map[string][]string) + if !ok { + panic(fmt.Sprintf("available models type error: %T, %v", v, v)) + } + + return v +} + +func GetGroupChannelAvailableSets(c *gin.Context) []string { + v, exists := c.Get(GroupChannelAvailableSets) + if !exists { + return GetAvailableSets(c) + } + + sets, ok := v.([]string) + if !ok { + panic(fmt.Sprintf("group channel available sets type error: %T, %v", v, v)) + } + + return sets +} + +func GetGroupChannelAvailableModels(c *gin.Context) map[string][]string { + v, exists := c.Get(GroupChannelAvailableModels) + if !exists { + return GetAvailableModels(c) + } + + models, ok := v.(map[string][]string) + if !ok { + panic(fmt.Sprintf("group channel available models type error: %T, %v", v, v)) + } + + return models +} + +func GetActiveAvailableSets(c *gin.Context) []string { + if GetGroupChannelMode(c) == GroupChannelModeOwn { + return GetGroupChannelAvailableSets(c) + } + + return GetAvailableSets(c) +} + +func GetActiveAvailableModels(c *gin.Context) map[string][]string { + if GetGroupChannelMode(c) == GroupChannelModeOwn { + return GetGroupChannelAvailableModels(c) + } + + return GetAvailableModels(c) +} + +func GetActiveTokenModels(c *gin.Context) []string { + token := GetToken(c) + if GetGroupChannelMode(c) == GroupChannelModeOwn { + return []string(token.GroupChannelModels) + } + + return []string(token.Models) +} + func GetModelCaches(c *gin.Context) *model.ModelCaches { v, ok := c.MustGet(ModelCaches).(*model.ModelCaches) if !ok { @@ -206,6 +521,17 @@ func GetModelCaches(c *gin.Context) *model.ModelCaches { return v } +func GetGroupChannelMode(c *gin.Context) string { + mode, _ := c.Get(GroupChannelMode) + + modeString, _ := mode.(string) + if modeString == "" { + return GroupChannelModeGlobal + } + + return modeString +} + func SetLogFieldsFromMeta(m *meta.Meta, fields logrus.Fields) { SetLogServiceTier(fields, m.RequestServiceTier) SetLogPromptCacheKey(fields, m.PromptCacheKey) @@ -263,6 +589,14 @@ func SetLogChannelFields(fields logrus.Fields, channel meta.ChannelMeta) { fields["chid"] = channel.ID } + if channel.Scope != "" { + fields["chscope"] = string(channel.Scope) + } + + if channel.Scope == model.ChannelScopeGroup && channel.GroupID != "" { + fields["chgroup"] = channel.GroupID + } + if channel.Name != "" { fields["chname"] = channel.Name } diff --git a/core/middleware/auth_test.go b/core/middleware/auth_test.go new file mode 100644 index 00000000..b22fc752 --- /dev/null +++ b/core/middleware/auth_test.go @@ -0,0 +1,451 @@ +//nolint:testpackage +package middleware + +import ( + "net/http" + "net/http/httptest" + "strconv" + "testing" + + "github.com/gin-gonic/gin" + "github.com/labring/aiproxy/core/common/config" + "github.com/labring/aiproxy/core/common/reqlimit" + "github.com/labring/aiproxy/core/model" + "github.com/stretchr/testify/require" +) + +func TestMergeGroupChannelModelsBySetIncludesModelMappingKeys(t *testing.T) { + require.NoError(t, model.CacheSetGroupScopeModelConfigs(&model.GroupScopeModelConfigsCache{ + GroupID: "group-mapped", + Models: []string{"public-model"}, + Configs: map[string]model.ModelConfig{ + "public-model": {Model: "public-model"}, + }, + List: []model.GroupScopeModelConfig{ + {GroupID: "group-mapped", ModelConfig: model.ModelConfig{Model: "public-model"}}, + }, + })) + require.NoError(t, model.CacheSetGroupChannels(&model.GroupChannelsCache{ + GroupID: "group-mapped", + Channels: []*model.GroupChannel{ + { + ID: 1, + GroupID: "group-mapped", + Status: model.ChannelStatusEnabled, + Models: []string{"provider-model"}, + ModelMapping: map[string]string{"public-model": "provider-model"}, + Sets: []string{"default"}, + }, + }, + })) + t.Cleanup(func() { + require.NoError(t, model.CacheDeleteGroupScopeModelConfig("group-mapped")) + require.NoError(t, model.CacheDeleteGroupChannels("group-mapped")) + }) + + modelsBySet := mergeGroupChannelModelsBySet( + model.GroupCache{ + ID: "group-mapped", + Status: model.GroupStatusEnabled, + AvailableSets: []string{"default"}, + }, + []string{"default"}, + GroupChannelModeOwn, + map[string][]string{}, + ) + + require.ElementsMatch(t, []string{"public-model"}, modelsBySet["default"]) + + require.Equal( + t, + "public-model", + model.FindTokenModel( + model.TokenCache{}, + "public-model", + []string{"default"}, + modelsBySet, + ), + ) +} + +func TestMergeGroupChannelModelsBySetUsesEffectiveSets(t *testing.T) { + groupID := "group-set-intersection" + require.NoError(t, model.CacheSetGroupScopeModelConfigs(&model.GroupScopeModelConfigsCache{ + GroupID: groupID, + Models: []string{"default-model", "beta-model"}, + Configs: map[string]model.ModelConfig{ + "default-model": {Model: "default-model"}, + "beta-model": {Model: "beta-model"}, + }, + List: []model.GroupScopeModelConfig{ + {GroupID: groupID, ModelConfig: model.ModelConfig{Model: "default-model"}}, + {GroupID: groupID, ModelConfig: model.ModelConfig{Model: "beta-model"}}, + }, + })) + require.NoError(t, model.CacheSetGroupChannels(&model.GroupChannelsCache{ + GroupID: groupID, + Channels: []*model.GroupChannel{ + { + ID: 1, + GroupID: groupID, + Status: model.ChannelStatusEnabled, + Models: []string{"default-model"}, + }, + { + ID: 2, + GroupID: groupID, + Status: model.ChannelStatusEnabled, + Models: []string{"beta-model"}, + Sets: []string{"beta"}, + }, + }, + })) + t.Cleanup(func() { + require.NoError(t, model.CacheDeleteGroupScopeModelConfig(groupID)) + require.NoError(t, model.CacheDeleteGroupChannels(groupID)) + }) + + modelsBySet := mergeGroupChannelModelsBySet( + model.GroupCache{ + ID: groupID, + Status: model.GroupStatusEnabled, + AvailableSets: []string{model.ChannelDefaultSet, "beta"}, + }, + []string{"beta"}, + GroupChannelModeOwn, + map[string][]string{}, + ) + + require.NotContains(t, modelsBySet, model.ChannelDefaultSet) + require.ElementsMatch(t, []string{"beta-model"}, modelsBySet["beta"]) +} + +func TestMergeGroupChannelModelsBySetUsesChannelModelsWhenModelConfigDisabled(t *testing.T) { + oldDisableModelConfig := config.DisableModelConfig + config.DisableModelConfig = true + t.Cleanup(func() { + config.DisableModelConfig = oldDisableModelConfig + }) + + groupID := "group-disabled-model-config" + require.NoError(t, model.CacheSetGroupChannels(&model.GroupChannelsCache{ + GroupID: groupID, + Channels: []*model.GroupChannel{ + { + ID: 1, + GroupID: groupID, + Status: model.ChannelStatusEnabled, + Models: []string{"dynamic-model"}, + }, + }, + })) + t.Cleanup(func() { + require.NoError(t, model.CacheDeleteGroupChannels(groupID)) + }) + + modelsBySet := mergeGroupChannelModelsBySet( + model.GroupCache{ + ID: groupID, + Status: model.GroupStatusEnabled, + AvailableSets: []string{model.ChannelDefaultSet}, + }, + []string{model.ChannelDefaultSet}, + GroupChannelModeOwn, + map[string][]string{}, + ) + + require.ElementsMatch(t, []string{"dynamic-model"}, modelsBySet[model.ChannelDefaultSet]) +} + +func TestMergeGroupChannelModelsBySetGlobalModeUsesEffectiveSets(t *testing.T) { + global := map[string][]string{ + model.ChannelDefaultSet: {"default-model"}, + "beta": {"beta-model"}, + } + + modelsBySet := mergeGroupChannelModelsBySet( + model.GroupCache{ID: "group-global-sets", Status: model.GroupStatusEnabled}, + []string{"beta"}, + GroupChannelModeGlobal, + global, + ) + + require.Equal(t, map[string][]string{"beta": {"beta-model"}}, modelsBySet) +} + +func TestExternalTokenGroupChannelModeIgnoresCallerHeader(t *testing.T) { + require.Equal( + t, + GroupChannelModeOwn, + getTokenGroupChannelMode(model.TokenCache{Scope: model.ChannelScopeGroup}), + ) + require.Equal( + t, + GroupChannelModeGlobal, + getTokenGroupChannelMode(model.TokenCache{Scope: model.ChannelScopeGlobal}), + ) + require.Equal(t, GroupChannelModeGlobal, getTokenGroupChannelMode(model.TokenCache{})) +} + +func TestGroupChannelModelLimitUsesGroupChannelCounters(t *testing.T) { + t.Parallel() + gin.SetMode(gin.TestMode) + + groupID := "group-channel-limit-isolated" + modelName := "group-channel-limit-model" + tokenName := "group-channel-limit-token" + channelID := 77 + + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/", nil) + + err := CheckGroupModelRPMAndTPM( + c, + model.GroupCache{ID: groupID, Status: model.GroupStatusEnabled}, + model.ModelConfig{ + Model: modelName, + RPM: 100, + TPM: 100, + }, + tokenName, + model.ChannelScopeGroup, + channelID, + ) + require.NoError(t, err) + + globalRPM, _ := reqlimit.GetGroupModelRequest(t.Context(), groupID, modelName) + require.Zero(t, globalRPM) + globalTPM, _ := reqlimit.GetGroupModelTokensRequest(t.Context(), groupID, modelName) + require.Zero(t, globalTPM) + globalTokenRPM, _ := reqlimit.GetGroupModelTokennameRequest( + t.Context(), + groupID, + modelName, + tokenName, + ) + require.Zero(t, globalTokenRPM) + + channelRPM, _ := reqlimit.GetChannelModelRequest( + t.Context(), + strconv.Itoa(channelID), + modelName, + ) + require.Zero(t, channelRPM) + + groupChannelRPM, _ := reqlimit.GetGroupChannelModelRequest( + t.Context(), + groupID, + strconv.Itoa(channelID), + modelName, + ) + require.Equal(t, int64(1), groupChannelRPM) +} + +func TestParseGroupChannelModeDefaultsToGlobal(t *testing.T) { + t.Parallel() + + require.Equal(t, GroupChannelModeGlobal, parseGroupChannelMode("")) + require.Equal(t, GroupChannelModeGlobal, parseGroupChannelMode("mixed")) + require.Equal(t, GroupChannelModeGlobal, parseGroupChannelMode("unknown")) + require.Equal(t, GroupChannelModeGlobal, parseGroupChannelMode("global")) + require.Equal(t, GroupChannelModeOwn, parseGroupChannelMode("own")) + require.Equal(t, GroupChannelModeOwn, parseGroupChannelMode("group-only")) + require.Equal(t, GroupChannelModeOwn, parseGroupChannelMode("group_only")) + require.Equal(t, GroupChannelModeOwn, parseGroupChannelMode("group")) +} + +func TestGetTokenGroupChannelModeInternalDefaultsToGlobalAndHonorsHeader(t *testing.T) { + t.Parallel() + + require.Equal(t, GroupChannelModeGlobal, getInternalTokenGroupChannelMode("", "")) + require.Equal(t, GroupChannelModeGlobal, getInternalTokenGroupChannelMode("group", "")) + require.Equal(t, GroupChannelModeGlobal, getInternalTokenGroupChannelMode("", "group-1")) + require.Equal(t, GroupChannelModeGlobal, getInternalTokenGroupChannelMode("global", "group-1")) + require.Equal(t, GroupChannelModeOwn, getInternalTokenGroupChannelMode("group", "group-1")) + require.Equal(t, GroupChannelModeOwn, getInternalTokenGroupChannelMode("own", "group-1")) +} + +func TestMergeGroupChannelModelsBySetAllowsInternalGroupInOwnMode(t *testing.T) { + groupID := "internal-group-channel-mode" + require.NoError(t, model.CacheSetGroupScopeModelConfigs(&model.GroupScopeModelConfigsCache{ + GroupID: groupID, + Models: []string{"group-scope-model"}, + Configs: map[string]model.ModelConfig{ + "group-scope-model": {Model: "group-scope-model"}, + }, + List: []model.GroupScopeModelConfig{ + {GroupID: groupID, ModelConfig: model.ModelConfig{Model: "group-scope-model"}}, + }, + })) + require.NoError(t, model.CacheSetGroupChannels(&model.GroupChannelsCache{ + GroupID: groupID, + Channels: []*model.GroupChannel{ + { + ID: 1, + GroupID: groupID, + Status: model.ChannelStatusEnabled, + Models: []string{"group-scope-model"}, + Sets: []string{model.ChannelDefaultSet}, + }, + }, + })) + t.Cleanup(func() { + require.NoError(t, model.CacheDeleteGroupScopeModelConfig(groupID)) + require.NoError(t, model.CacheDeleteGroupChannels(groupID)) + }) + + modelsBySet := mergeGroupChannelModelsBySet( + model.GroupCache{ + ID: groupID, + Status: model.GroupStatusInternal, + AvailableSets: []string{model.ChannelDefaultSet}, + }, + []string{model.ChannelDefaultSet}, + GroupChannelModeOwn, + map[string][]string{model.ChannelDefaultSet: {"global-model"}}, + ) + + require.ElementsMatch(t, []string{"group-scope-model"}, modelsBySet[model.ChannelDefaultSet]) +} + +func TestMergeGroupChannelModelsBySetInternalOwnModeUsesDefaultSet(t *testing.T) { + groupID := "internal-group-channel-private-set" + require.NoError(t, model.CacheSetGroupScopeModelConfigs(&model.GroupScopeModelConfigsCache{ + GroupID: groupID, + Models: []string{"group-private-model"}, + Configs: map[string]model.ModelConfig{ + "group-private-model": {Model: "group-private-model"}, + }, + List: []model.GroupScopeModelConfig{ + {GroupID: groupID, ModelConfig: model.ModelConfig{Model: "group-private-model"}}, + }, + })) + require.NoError(t, model.CacheSetGroupChannels(&model.GroupChannelsCache{ + GroupID: groupID, + Channels: []*model.GroupChannel{ + { + ID: 1, + GroupID: groupID, + Status: model.ChannelStatusEnabled, + Models: []string{"group-private-model"}, + Sets: []string{"private-set"}, + }, + }, + })) + t.Cleanup(func() { + require.NoError(t, model.CacheDeleteGroupScopeModelConfig(groupID)) + require.NoError(t, model.CacheDeleteGroupChannels(groupID)) + }) + + modelsBySet := mergeGroupChannelModelsBySet( + model.GroupCache{ + ID: groupID, + Status: model.GroupStatusInternal, + AvailableSets: []string{model.ChannelDefaultSet}, + }, + []string{model.ChannelDefaultSet}, + GroupChannelModeOwn, + map[string][]string{model.ChannelDefaultSet: {"global-model"}}, + ) + + require.Empty(t, modelsBySet) +} + +func TestResolveRequestGroupChannelAvailableSetsInternalDefaultsToGroupSets(t *testing.T) { + groupID := "internal-expand-group-channel-set" + require.NoError(t, model.CacheSetGroupChannels(&model.GroupChannelsCache{ + GroupID: groupID, + Channels: []*model.GroupChannel{ + { + ID: 1, + GroupID: groupID, + Status: model.ChannelStatusEnabled, + Sets: []string{"private-set"}, + }, + { + ID: 2, + GroupID: groupID, + Status: model.ChannelStatusDisabled, + Sets: []string{"disabled-set"}, + }, + }, + })) + t.Cleanup(func() { + require.NoError(t, model.CacheDeleteGroupChannels(groupID)) + }) + + sets := resolveRequestGroupChannelAvailableSets( + model.GroupCache{ID: groupID, Status: model.GroupStatusInternal}, + model.TokenCache{}, + GroupChannelModeOwn, + ) + + require.Equal(t, []string{model.ChannelDefaultSet}, sets) +} + +func TestResolveRequestGroupChannelAvailableSetsAppliesTokenSets(t *testing.T) { + groupID := "token-filter-group-channel-set" + require.NoError(t, model.CacheSetGroupChannels(&model.GroupChannelsCache{ + GroupID: groupID, + Channels: []*model.GroupChannel{ + { + ID: 1, + GroupID: groupID, + Status: model.ChannelStatusEnabled, + Sets: []string{"private-set"}, + }, + { + ID: 2, + GroupID: groupID, + Status: model.ChannelStatusEnabled, + Sets: []string{"other-set"}, + }, + }, + })) + t.Cleanup(func() { + require.NoError(t, model.CacheDeleteGroupChannels(groupID)) + }) + + sets := resolveRequestGroupChannelAvailableSets( + model.GroupCache{ID: groupID, Status: model.GroupStatusEnabled}, + model.TokenCache{GroupChannelSets: []string{"private-set"}}, + GroupChannelModeOwn, + ) + + require.Equal(t, []string{"private-set"}, sets) +} + +func TestMergeGroupChannelModelsBySetGlobalModeReturnsGlobalOnly(t *testing.T) { + require.NoError(t, model.CacheSetGroupChannels(&model.GroupChannelsCache{ + GroupID: "group-global-mode", + Channels: []*model.GroupChannel{ + { + ID: 1, + GroupID: "group-global-mode", + Status: model.ChannelStatusEnabled, + Models: []string{"group-model"}, + Sets: []string{"default"}, + }, + }, + })) + t.Cleanup(func() { + require.NoError(t, model.CacheDeleteGroupChannels("group-global-mode")) + }) + + global := map[string][]string{ + model.ChannelDefaultSet: {"global-model"}, + } + modelsBySet := mergeGroupChannelModelsBySet( + model.GroupCache{ + ID: "group-global-mode", + Status: model.GroupStatusEnabled, + AvailableSets: []string{model.ChannelDefaultSet}, + }, + []string{model.ChannelDefaultSet}, + GroupChannelModeGlobal, + global, + ) + + require.Equal(t, global, modelsBySet) +} diff --git a/core/middleware/ctxkey.go b/core/middleware/ctxkey.go index c69f0b2f..86a06e5e 100644 --- a/core/middleware/ctxkey.go +++ b/core/middleware/ctxkey.go @@ -1,26 +1,32 @@ package middleware const ( - ChannelID = "channel_id" - Group = "group" - Token = "token" - GroupBalance = "group_balance" - RequestModel = "request_model" - RequestUser = "request_user" - RequestMetadata = "request_metadata" - PromptCacheKey = "prompt_cache_key" - RequestServiceTier = "request_service_tier" - RequestAt = "request_at" - RequestID = "request_id" - ModelCaches = "model_caches" - ModelConfig = "model_config" - Mode = "mode" - JobID = "job_id" - GenerationID = "generation_id" - OperationID = "operation_id" - ResponseID = "response_id" - VideoID = "video_id" - FileID = "file_id" + ChannelID = "channel_id" + ChannelScope = "channel_scope" + Group = "group" + Token = "token" + AvailableSets = "available_sets" + AvailableModels = "available_models" + GroupChannelAvailableSets = "group_channel_available_sets" + GroupChannelAvailableModels = "group_channel_available_models" + GroupBalance = "group_balance" + RequestModel = "request_model" + RequestUser = "request_user" + RequestMetadata = "request_metadata" + PromptCacheKey = "prompt_cache_key" + RequestServiceTier = "request_service_tier" + GroupChannelMode = "group_channel_mode" + RequestAt = "request_at" + RequestID = "request_id" + ModelCaches = "model_caches" + ModelConfig = "model_config" + Mode = "mode" + JobID = "job_id" + GenerationID = "generation_id" + OperationID = "operation_id" + ResponseID = "response_id" + VideoID = "video_id" + FileID = "file_id" requestBodyNode = "request_body_node" ) diff --git a/core/middleware/distributor.go b/core/middleware/distributor.go index b7c1279a..2d347976 100644 --- a/core/middleware/distributor.go +++ b/core/middleware/distributor.go @@ -15,7 +15,6 @@ import ( "github.com/labring/aiproxy/core/common" "github.com/labring/aiproxy/core/common/balance" "github.com/labring/aiproxy/core/common/config" - "github.com/labring/aiproxy/core/common/consume" "github.com/labring/aiproxy/core/common/notify" "github.com/labring/aiproxy/core/common/reqlimit" "github.com/labring/aiproxy/core/model" @@ -74,6 +73,17 @@ func GetGroupAdjustedModelConfig(group model.GroupCache, mc model.ModelConfig) m mc = mc.LoadFromGroupModelConfig(groupModelConfig) } + return ApplyGroupModelRatios(group, mc) +} + +func GetGroupScopeAdjustedModelConfig( + group model.GroupCache, + mc model.ModelConfig, +) model.ModelConfig { + return ApplyGroupModelRatios(group, mc) +} + +func ApplyGroupModelRatios(group model.GroupCache, mc model.ModelConfig) model.ModelConfig { rpmRatio, tpmRatio := getGroupPMRatio(group) groupConsumeLevelRatio := calculateGroupConsumeLevelRatio(group.UsedAmount) mc.RPM = int64(float64(mc.RPM) * rpmRatio * groupConsumeLevelRatio) @@ -82,6 +92,29 @@ func GetGroupAdjustedModelConfig(group model.GroupCache, mc model.ModelConfig) m return mc } +func ResolveModelConfig( + group model.GroupCache, + groupChannelMode string, + modelCaches *model.ModelCaches, + modelName string, +) (model.ModelConfig, bool) { + if groupChannelMode == GroupChannelModeOwn { + mc, ok := model.ResolveGroupScopeModelConfig(group.ID, modelName) + if !ok { + return model.ModelConfig{}, false + } + + return GetGroupScopeAdjustedModelConfig(group, mc), true + } + + mc, ok := modelCaches.ModelConfig.GetModelConfig(modelName) + if !ok { + return model.ModelConfig{}, false + } + + return GetGroupAdjustedModelConfig(group, mc), true +} + var ( ErrRequestRateLimitExceeded = errors.New("request rate limit exceeded, please try again later") ErrRequestTpmLimitExceeded = errors.New("request tpm limit exceeded, please try again later") @@ -111,12 +144,18 @@ func setTpmHeaders(c *gin.Context, tpm, remainingRequests int64) { c.Header(XRateLimitResetTokens, "1m0s") } -func checkGroupModelRPMAndTPM( +func CheckGroupModelRPMAndTPM( c *gin.Context, group model.GroupCache, mc model.ModelConfig, tokenName string, + channelScope model.ChannelScope, + channelID int, ) error { + if channelScope == model.ChannelScopeGroup { + return checkGroupChannelModelRPMAndTPM(c, group, mc, channelID) + } + log := common.GetLogger(c) groupModelCount, groupModelOverLimitCount, groupModelSecondCount := reqlimit.PushGroupModelRequest( @@ -188,6 +227,59 @@ func checkGroupModelRPMAndTPM( return nil } +func checkGroupChannelModelRPMAndTPM( + c *gin.Context, + group model.GroupCache, + mc model.ModelConfig, + channelID int, +) error { + log := common.GetLogger(c) + channelKey := strconv.Itoa(channelID) + + groupModelCount, groupModelOverLimitCount, groupModelSecondCount := reqlimit.PushGroupChannelModelRequest( + c.Request.Context(), + group.ID, + channelKey, + mc.Model, + ) + + if group.Status != model.GroupStatusInternal && mc.RPM > 0 { + totalRequests := groupModelCount + groupModelOverLimitCount + + log.Data["group_channel_rpm_limit"] = strconv.FormatInt(mc.RPM, 10) + if groupModelCount > mc.RPM { + setRpmHeaders(c, mc.RPM, 0) + return ErrRequestRateLimitExceeded + } + + setRpmHeaders(c, mc.RPM, mc.RPM-groupModelCount) + + log.Data["group_channel_rpm"] = strconv.FormatInt(totalRequests, 10) + log.Data["group_channel_rps"] = strconv.FormatInt(groupModelSecondCount, 10) + } + + groupModelCountTPM, groupModelCountTPS := reqlimit.GetGroupChannelModelTokensRequest( + c.Request.Context(), + group.ID, + channelKey, + mc.Model, + ) + + if group.Status != model.GroupStatusInternal && mc.TPM > 0 { + log.Data["group_channel_tpm_limit"] = strconv.FormatInt(mc.TPM, 10) + if groupModelCountTPM >= mc.TPM { + setTpmHeaders(c, mc.TPM, 0) + return ErrRequestTpmLimitExceeded + } + + setTpmHeaders(c, mc.TPM, mc.TPM-groupModelCountTPM) + log.Data["group_channel_tpm"] = strconv.FormatInt(groupModelCountTPM, 10) + log.Data["group_channel_tps"] = strconv.FormatInt(groupModelCountTPS, 10) + } + + return nil +} + type GroupBalanceConsumer struct { Group string balance float64 @@ -471,7 +563,12 @@ func distribute(c *gin.Context, mode mode.Mode) { return } - findModel := token.FindModel(requestModel) + findModel := model.FindModelWithAllowList( + GetActiveTokenModels(c), + requestModel, + GetActiveAvailableSets(c), + GetActiveAvailableModels(c), + ) if findModel == "" { AbortLogWithMessage( @@ -488,7 +585,7 @@ func distribute(c *gin.Context, mode mode.Mode) { SetLogModelFields(log.Data, findModel) - mc, ok := GetModelCaches(c).ModelConfig.GetModelConfig(findModel) + mc, ok := ResolveModelConfig(group, GetGroupChannelMode(c), GetModelCaches(c), findModel) if !ok { AbortLogWithMessage( c, @@ -502,24 +599,9 @@ func distribute(c *gin.Context, mode mode.Mode) { return } - mc = GetGroupAdjustedModelConfig(group, mc) - c.Set(RequestModel, findModel) c.Set(ModelConfig, mc) - if !CheckRelayMode(mode, mc.Type) { - AbortLogWithMessage( - c, - http.StatusNotFound, - fmt.Sprintf( - "The model `%s` does not exist on this endpoint.", - findModel, - ), - ) - - return - } - user, err := getRequestUser(c, mode) if err != nil { AbortLogWithMessage( @@ -575,23 +657,6 @@ func distribute(c *gin.Context, mode mode.Mode) { c.Set(RequestMetadata, metadata) - if err := checkGroupModelRPMAndTPM(c, group, mc, token.Name); err != nil { - errMsg := err.Error() - - consume.Summary( - http.StatusTooManyRequests, - time.Time{}, - NewMetaByContext(c, nil, mode), - model.Usage{}, - model.UsageContext{ServiceTier: requestServiceTier}, - model.Price{}, - true, - ) - AbortLogWithMessage(c, http.StatusTooManyRequests, errMsg) - - return - } - clearRequestBodyNode(c) c.Next() } @@ -612,6 +677,45 @@ func GetChannelID(c *gin.Context) int { return c.GetInt(ChannelID) } +func GetChannelScope(c *gin.Context) model.ChannelScope { + scope, _ := c.Get(ChannelScope) + if typedScope, ok := scope.(model.ChannelScope); ok { + return typedScope + } + + if stringScope, ok := scope.(string); ok { + return model.ChannelScope(stringScope) + } + + return "" +} + +func setStoreChannel(c *gin.Context, store *model.StoreCache, scope model.ChannelScope) { + if store == nil { + return + } + + c.Set(ChannelID, store.ChannelID) + c.Set(ChannelScope, scope) +} + +func getStoredRequestStore( + group string, + tokenID int, + storeID string, + scope model.ChannelScope, +) (*model.StoreCache, error) { + return model.CacheGetStoreByScope(group, tokenID, storeID, scope) +} + +func getStoredRequestStoreScope(c *gin.Context) model.ChannelScope { + if GetGroupChannelMode(c) == GroupChannelModeOwn { + return model.ChannelScopeGroup + } + + return model.ChannelScopeGlobal +} + func GetJobID(c *gin.Context) string { return c.GetString(JobID) } @@ -788,29 +892,39 @@ func getRequestModel(c *gin.Context, m mode.Mode, group string, tokenID int) (st case m == mode.VideoGenerationsGetJobs: jobID := c.Param("id") - store, err := model.CacheGetStore(group, tokenID, model.VideoJobStoreID(jobID)) + storeScope := getStoredRequestStoreScope(c) + + store, err := getStoredRequestStore( + group, + tokenID, + model.VideoJobStoreID(jobID), + storeScope, + ) if err != nil { return "", fmt.Errorf("get request model failed: %w", err) } c.Set(JobID, jobID) - c.Set(ChannelID, store.ChannelID) + setStoreChannel(c, store, storeScope) return store.Model, nil case m == mode.VideoGenerationsContent: generationID := c.Param("id") - store, err := model.CacheGetStore( + storeScope := getStoredRequestStoreScope(c) + + store, err := getStoredRequestStore( group, tokenID, model.VideoGenerationStoreID(generationID), + storeScope, ) if err != nil { return "", fmt.Errorf("get request model failed: %w", err) } c.Set(GenerationID, generationID) - c.Set(ChannelID, store.ChannelID) + setStoreChannel(c, store, storeScope) return store.Model, nil case isVideosStoredMode(m): @@ -838,17 +952,20 @@ func getRequestModel(c *gin.Context, m mode.Mode, group string, tokenID int) (st } if responseID != "" { - store, err := model.CacheGetStore( + storeScope := getStoredRequestStoreScope(c) + + store, err := getStoredRequestStore( group, tokenID, model.ResponseStoreID(responseID), + storeScope, ) if err != nil { return "", fmt.Errorf("get request model failed: %w", err) } c.Set(ResponseID, responseID) - c.Set(ChannelID, store.ChannelID) + setStoreChannel(c, store, storeScope) } return modelName, nil @@ -872,17 +989,20 @@ func getGeminiRequestModel(c *gin.Context, group string, tokenID int) (string, e modelName, operationID := getGeminiPathModelAndOperationID(c) if operationID != "" { - store, err := model.CacheGetStore( + storeScope := getStoredRequestStoreScope(c) + + store, err := getStoredRequestStore( group, tokenID, model.VideoJobStoreID(operationID), + storeScope, ) if err != nil { return "", fmt.Errorf("get request model failed: %w", err) } c.Set(OperationID, operationID) - c.Set(ChannelID, store.ChannelID) + setStoreChannel(c, store, storeScope) return store.Model, nil } @@ -902,13 +1022,20 @@ func isStoredResponseMode(m mode.Mode) bool { func getStoredResponseRequestModel(c *gin.Context, group string, tokenID int) (string, error) { responseID := c.Param("response_id") - store, err := model.CacheGetStore(group, tokenID, model.ResponseStoreID(responseID)) + storeScope := getStoredRequestStoreScope(c) + + store, err := getStoredRequestStore( + group, + tokenID, + model.ResponseStoreID(responseID), + storeScope, + ) if err != nil { return "", fmt.Errorf("get request model failed: %w", err) } c.Set(ResponseID, responseID) - c.Set(ChannelID, store.ChannelID) + setStoreChannel(c, store, storeScope) return store.Model, nil } @@ -947,17 +1074,20 @@ func getGeminiFileRequestModel(c *gin.Context, group string, tokenID int) (strin return "", errors.New("get request model failed: file id is empty") } - store, err := model.CacheGetStore( + storeScope := getStoredRequestStoreScope(c) + + store, err := getStoredRequestStore( group, tokenID, model.GeminiFileStoreID(fileID), + storeScope, ) if err != nil { return "", fmt.Errorf("get request model failed: %w", err) } c.Set(FileID, fileID) - c.Set(ChannelID, store.ChannelID) + setStoreChannel(c, store, storeScope) return store.Model, nil } @@ -1006,17 +1136,20 @@ func isVideosStoredMode(m mode.Mode) bool { func getVideosCreateRequestModel(c *gin.Context, group string, tokenID int) (string, error) { videoID := c.Param("video_id") if videoID != "" { - store, err := model.CacheGetStore( + storeScope := getStoredRequestStoreScope(c) + + store, err := getStoredRequestStore( group, tokenID, model.VideoGenerationStoreID(videoID), + storeScope, ) if err != nil { return "", fmt.Errorf("get request model failed: %w", err) } c.Set(VideoID, videoID) - c.Set(ChannelID, store.ChannelID) + setStoreChannel(c, store, storeScope) } if strings.HasPrefix(c.Request.Header.Get("Content-Type"), "multipart/form-data") { @@ -1086,10 +1219,13 @@ func getVideoCreateRequestModelFromReference( return "", nil } - store, err := model.CacheGetStore( + storeScope := getStoredRequestStoreScope(c) + + store, err := getStoredRequestStore( group, tokenID, model.VideoGenerationStoreID(videoID), + storeScope, ) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { @@ -1100,7 +1236,7 @@ func getVideoCreateRequestModelFromReference( } c.Set(VideoID, videoID) - c.Set(ChannelID, store.ChannelID) + setStoreChannel(c, store, storeScope) return store.Model, nil } @@ -1125,10 +1261,13 @@ func getLimitedMultipartFormValue(req *http.Request, key string) (string, error) func getStoredVideoRequestModel(c *gin.Context, group string, tokenID int) (string, error) { videoID := c.Param("video_id") - store, err := model.CacheGetStore( + storeScope := getStoredRequestStoreScope(c) + + store, err := getStoredRequestStore( group, tokenID, model.VideoGenerationStoreID(videoID), + storeScope, ) if err != nil { return "", fmt.Errorf("get request model failed: %w", err) @@ -1136,7 +1275,7 @@ func getStoredVideoRequestModel(c *gin.Context, group string, tokenID int) (stri c.Set(VideoID, videoID) c.Set(GenerationID, videoID) - c.Set(ChannelID, store.ChannelID) + setStoreChannel(c, store, storeScope) return store.Model, nil } @@ -1151,10 +1290,13 @@ func getNativeVideoTaskRequestModel(c *gin.Context, group string, tokenID int) ( return "", errors.New("get request model failed: task id is empty") } - store, err := model.CacheGetStore( + storeScope := getStoredRequestStoreScope(c) + + store, err := getStoredRequestStore( group, tokenID, model.VideoGenerationStoreID(taskID), + storeScope, ) if err != nil { return "", fmt.Errorf("get request model failed: %w", err) @@ -1162,7 +1304,7 @@ func getNativeVideoTaskRequestModel(c *gin.Context, group string, tokenID int) ( c.Set(VideoID, taskID) c.Set(GenerationID, taskID) - c.Set(ChannelID, store.ChannelID) + setStoreChannel(c, store, storeScope) return store.Model, nil } diff --git a/core/middleware/distributor_provider_video_test.go b/core/middleware/distributor_provider_video_test.go index 3ce9442d..51bebbd5 100644 --- a/core/middleware/distributor_provider_video_test.go +++ b/core/middleware/distributor_provider_video_test.go @@ -73,14 +73,14 @@ func TestGetRequestModelProviderVideoTaskPinsStoredChannel(t *testing.T) { gin.SetMode(gin.TestMode) withTestStoreDB(t, func() { - require.NoError(t, model.CacheSetStore(&model.StoreCache{ + require.NoError(t, model.CacheSetStoreByScope(&model.StoreCache{ ID: model.VideoGenerationStoreID("task-123"), GroupID: "group-1", TokenID: 7, ChannelID: 42, Model: "provider-video", ExpiresAt: time.Now().Add(time.Hour), - })) + }, model.ChannelScopeGroup)) for _, relayMode := range []mode.Mode{ mode.AliVideoTasks, @@ -98,12 +98,14 @@ func TestGetRequestModelProviderVideoTaskPinsStoredChannel(t *testing.T) { ctx, _ := gin.CreateTestContext(httptest.NewRecorder()) ctx.Request = req ctx.Params = gin.Params{{Key: "task_id", Value: "task-123"}} + ctx.Set(GroupChannelMode, GroupChannelModeOwn) modelName, err := getRequestModel(ctx, relayMode, "group-1", 7) require.NoError(t, err) assert.Equal(t, "provider-video", modelName) assert.Equal(t, "task-123", GetVideoID(ctx)) assert.Equal(t, 42, GetChannelID(ctx)) + assert.Equal(t, model.ChannelScopeGroup, GetChannelScope(ctx)) }) } }) diff --git a/core/middleware/distributor_video_test.go b/core/middleware/distributor_video_test.go index 086a6e46..a6d9f36a 100644 --- a/core/middleware/distributor_video_test.go +++ b/core/middleware/distributor_video_test.go @@ -180,23 +180,23 @@ func TestGetRequestModelVideosEditFallsBackToStoredVideoModel(t *testing.T) { gin.SetMode(gin.TestMode) withTestStoreDB(t, func() { - _, err := coremodel.SaveStore(&coremodel.StoreV2{ + _, err := coremodel.SaveStoreByScope(&coremodel.StoreV2{ ID: coremodel.VideoGenerationStoreID("video-123"), GroupID: "group-1", TokenID: 7, ChannelID: 42, Model: "sora-2-pro", ExpiresAt: time.Now().Add(time.Hour), - }) + }, coremodel.ChannelScopeGroup) require.NoError(t, err) - require.NoError(t, coremodel.CacheSetStore(&coremodel.StoreCache{ + require.NoError(t, coremodel.CacheSetStoreByScope(&coremodel.StoreCache{ ID: coremodel.VideoGenerationStoreID("video-123"), GroupID: "group-1", TokenID: 7, ChannelID: 42, Model: "sora-2-pro", ExpiresAt: time.Now().Add(time.Hour), - })) + }, coremodel.ChannelScopeGroup)) req := httptest.NewRequestWithContext( t.Context(), @@ -209,12 +209,14 @@ func TestGetRequestModelVideosEditFallsBackToStoredVideoModel(t *testing.T) { ctx, _ := gin.CreateTestContext(httptest.NewRecorder()) ctx.Request = req ctx.Set(Mode, mode.VideosEdits) + ctx.Set(GroupChannelMode, GroupChannelModeOwn) modelName, err := getRequestModel(ctx, mode.VideosEdits, "group-1", 7) require.NoError(t, err) assert.Equal(t, "sora-2-pro", modelName) assert.Equal(t, "video-123", GetVideoID(ctx)) assert.Equal(t, 42, GetChannelID(ctx)) + assert.Equal(t, coremodel.ChannelScopeGroup, GetChannelScope(ctx)) }) } @@ -398,7 +400,7 @@ func withTestStoreDB(t *testing.T, fn func()) { db, err := coremodel.OpenSQLite(filepath.Join(t.TempDir(), "middleware_store_test.db")) require.NoError(t, err) - require.NoError(t, db.AutoMigrate(&coremodel.StoreV2{})) + require.NoError(t, db.AutoMigrate(&coremodel.StoreV2{}, &coremodel.GroupChannelStoreV2{})) coremodel.LogDB = db coremodel.DB = db diff --git a/core/model/async_usage.go b/core/model/async_usage.go index 1181f673..04a91cd5 100644 --- a/core/model/async_usage.go +++ b/core/model/async_usage.go @@ -183,6 +183,28 @@ func MarkAsyncUsageBalanceConsumed(info *AsyncUsageInfo) error { }) } +func ClaimedAsyncUsageInfoExists(info *AsyncUsageInfo) (bool, error) { + if info == nil || info.ID == 0 || info.ProcessingToken == "" { + return false, nil + } + + var count int64 + + err := LogDB. + Model(&AsyncUsageInfo{}). + Where("id = ? AND status = ? AND processing_token = ?", + info.ID, + int(AsyncUsageStatusPending), + info.ProcessingToken, + ). + Count(&count).Error + if err != nil { + return false, err + } + + return count > 0, nil +} + func RetryClaimedAsyncUsageInfo(info *AsyncUsageInfo) error { return updateClaimedAsyncUsageInfo(info, map[string]any{ "retry_count": info.RetryCount, @@ -217,7 +239,7 @@ func FailClaimedAsyncUsageInfo(info *AsyncUsageInfo) (bool, error) { return tx.RowsAffected > 0, nil } -func CompleteClaimedAsyncUsageInfo( +func SaveClaimedAsyncUsageResult( info *AsyncUsageInfo, usage Usage, usageContext UsageContext, @@ -225,24 +247,51 @@ func CompleteClaimedAsyncUsageInfo( ) (bool, error) { now := time.Now() updatesModel := &AsyncUsageInfo{ - Status: AsyncUsageStatusCompleted, Usage: usage, UsageContext: usageContext, Amount: amount, Error: "", BalanceConsumed: info.BalanceConsumed, - ProcessingToken: "", UpdatedAt: now, } updates, err := asyncUsageUpdateValues( updatesModel, - "Status", "Usage", "UsageContext", "Amount", "Error", "BalanceConsumed", + "UpdatedAt", + ) + if err != nil { + return false, err + } + + tx := LogDB. + Model(&AsyncUsageInfo{}). + Where("id = ? AND processing_token = ?", info.ID, info.ProcessingToken). + Updates(updates) + if tx.Error != nil { + return false, tx.Error + } + + return tx.RowsAffected > 0, nil +} + +func CompleteClaimedAsyncUsageInfo(info *AsyncUsageInfo) (bool, error) { + now := time.Now() + updatesModel := &AsyncUsageInfo{ + Status: AsyncUsageStatusCompleted, + Error: "", + ProcessingToken: "", + UpdatedAt: now, + } + + updates, err := asyncUsageUpdateValues( + updatesModel, + "Status", + "Error", "ProcessingToken", "UpdatedAt", ) diff --git a/core/model/async_usage_postgres_integration_test.go b/core/model/async_usage_postgres_integration_test.go index 5f59361f..ca15f786 100644 --- a/core/model/async_usage_postgres_integration_test.go +++ b/core/model/async_usage_postgres_integration_test.go @@ -104,22 +104,27 @@ func TestAsyncUsageClaimPostgresLeaseExpiryAndTokenGuard(t *testing.T) { require.NoError(t, err) require.True(t, claimed) - completed, err := CompleteClaimedAsyncUsageInfo( + saved, err := SaveClaimedAsyncUsageResult( &AsyncUsageInfo{ID: info.ID, ProcessingToken: "token-old"}, Usage{InputTokens: 1, TotalTokens: 1}, UsageContext{}, Amount{UsedAmount: 1}, ) require.NoError(t, err) - require.False(t, completed) + require.False(t, saved) - completed, err = CompleteClaimedAsyncUsageInfo( - &AsyncUsageInfo{ID: info.ID, ProcessingToken: "token-new"}, + claimedInfo := &AsyncUsageInfo{ID: info.ID, ProcessingToken: "token-new"} + saved, err = SaveClaimedAsyncUsageResult( + claimedInfo, Usage{InputTokens: 2, TotalTokens: 2}, UsageContext{}, Amount{UsedAmount: 2}, ) require.NoError(t, err) + require.True(t, saved) + + completed, err := CompleteClaimedAsyncUsageInfo(claimedInfo) + require.NoError(t, err) require.True(t, completed) var got AsyncUsageInfo diff --git a/core/model/batch.go b/core/model/batch.go index b429a438..195228de 100644 --- a/core/model/batch.go +++ b/core/model/batch.go @@ -12,17 +12,25 @@ import ( "github.com/labring/aiproxy/core/common/notify" "github.com/labring/aiproxy/core/common/oncall" "github.com/shopspring/decimal" + log "github.com/sirupsen/logrus" "golang.org/x/sync/errgroup" ) type batchUpdateData struct { - Groups map[string]*GroupUpdate - Tokens map[int]*TokenUpdate - Channels map[int]*ChannelUpdate - Summaries map[SummaryUnique]*SummaryUpdate - GroupSummaries map[GroupSummaryUnique]*GroupSummaryUpdate - SummariesMinute map[SummaryMinuteUnique]*SummaryMinuteUpdate - GroupSummariesMinute map[GroupSummaryMinuteUnique]*GroupSummaryMinuteUpdate + Groups map[string]*GroupUpdate + Tokens map[int]*TokenUpdate + Channels map[int]*ChannelUpdate + GroupChannels map[groupChannelUpdateKey]*ChannelUpdate + GroupChannelGroups map[string]*GroupUpdate + GroupChannelTokens map[int]*TokenUpdate + Summaries map[SummaryUnique]*SummaryUpdate + GroupSummaries map[GroupSummaryUnique]*GroupSummaryUpdate + GroupChannelSummaries map[GroupChannelSummaryUnique]*GroupChannelSummaryUpdate + GroupChannelTokenSummaries map[GroupChannelTokenSummaryUnique]*GroupChannelTokenSummaryUpdate + SummariesMinute map[SummaryMinuteUnique]*SummaryMinuteUpdate + GroupSummariesMinute map[GroupSummaryMinuteUnique]*GroupSummaryMinuteUpdate + GroupChannelSummariesMinute map[GroupChannelSummaryMinuteUnique]*GroupChannelSummaryMinuteUpdate + GroupChannelTokenSummariesMinute map[GroupChannelTokenSummaryMinuteUnique]*GroupChannelTokenSummaryMinuteUpdate sync.Mutex } @@ -37,10 +45,17 @@ func (b *batchUpdateData) isCleanLocked() bool { return len(b.Groups) == 0 && len(b.Tokens) == 0 && len(b.Channels) == 0 && + len(b.GroupChannels) == 0 && + len(b.GroupChannelGroups) == 0 && + len(b.GroupChannelTokens) == 0 && len(b.Summaries) == 0 && len(b.GroupSummaries) == 0 && + len(b.GroupChannelSummaries) == 0 && + len(b.GroupChannelTokenSummaries) == 0 && len(b.SummariesMinute) == 0 && - len(b.GroupSummariesMinute) == 0 + len(b.GroupSummariesMinute) == 0 && + len(b.GroupChannelSummariesMinute) == 0 && + len(b.GroupChannelTokenSummariesMinute) == 0 } type GroupUpdate struct { @@ -59,6 +74,11 @@ type ChannelUpdate struct { RetryCount int } +type groupChannelUpdateKey struct { + GroupID string + ID int +} + type SummaryUpdate struct { SummaryUnique SummaryData @@ -79,17 +99,54 @@ type GroupSummaryMinuteUpdate struct { SummaryData } +type GroupChannelSummaryUpdate struct { + GroupChannelSummaryUnique + SummaryData +} + +type GroupChannelSummaryMinuteUpdate struct { + GroupChannelSummaryMinuteUnique + SummaryData +} + +type GroupChannelTokenSummaryUpdate struct { + GroupChannelTokenSummaryUnique + SummaryData +} + +type GroupChannelTokenSummaryMinuteUpdate struct { + GroupChannelTokenSummaryMinuteUnique + SummaryData +} + var batchData batchUpdateData func init() { batchData = batchUpdateData{ - Groups: make(map[string]*GroupUpdate), - Tokens: make(map[int]*TokenUpdate), - Channels: make(map[int]*ChannelUpdate), - Summaries: make(map[SummaryUnique]*SummaryUpdate), - GroupSummaries: make(map[GroupSummaryUnique]*GroupSummaryUpdate), - SummariesMinute: make(map[SummaryMinuteUnique]*SummaryMinuteUpdate), - GroupSummariesMinute: make(map[GroupSummaryMinuteUnique]*GroupSummaryMinuteUpdate), + Groups: make(map[string]*GroupUpdate), + Tokens: make(map[int]*TokenUpdate), + Channels: make(map[int]*ChannelUpdate), + GroupChannels: make(map[groupChannelUpdateKey]*ChannelUpdate), + GroupChannelGroups: make(map[string]*GroupUpdate), + GroupChannelTokens: make(map[int]*TokenUpdate), + Summaries: make(map[SummaryUnique]*SummaryUpdate), + GroupSummaries: make(map[GroupSummaryUnique]*GroupSummaryUpdate), + GroupChannelSummaries: make( + map[GroupChannelSummaryUnique]*GroupChannelSummaryUpdate, + ), + GroupChannelTokenSummaries: make( + map[GroupChannelTokenSummaryUnique]*GroupChannelTokenSummaryUpdate, + ), + SummariesMinute: make(map[SummaryMinuteUnique]*SummaryMinuteUpdate), + GroupSummariesMinute: make( + map[GroupSummaryMinuteUnique]*GroupSummaryMinuteUpdate, + ), + GroupChannelSummariesMinute: make( + map[GroupChannelSummaryMinuteUnique]*GroupChannelSummaryMinuteUpdate, + ), + GroupChannelTokenSummariesMinute: make( + map[GroupChannelTokenSummaryMinuteUnique]*GroupChannelTokenSummaryMinuteUpdate, + ), } } @@ -183,10 +240,30 @@ func ProcessBatchUpdatesSummary() { processChannelUpdates(errs) return nil }) + g.Go(func() error { + processGroupChannelUpdates(errs) + return nil + }) + g.Go(func() error { + processGroupChannelGroupUpdates(errs) + return nil + }) + g.Go(func() error { + processGroupChannelTokenUpdates(errs) + return nil + }) g.Go(func() error { processGroupSummaryUpdates(errs) return nil }) + g.Go(func() error { + processGroupChannelSummaryUpdates(errs) + return nil + }) + g.Go(func() error { + processGroupChannelTokenSummaryUpdates(errs) + return nil + }) g.Go(func() error { processSummaryUpdates(errs) return nil @@ -199,6 +276,14 @@ func ProcessBatchUpdatesSummary() { processGroupSummaryMinuteUpdates(errs) return nil }) + g.Go(func() error { + processGroupChannelSummaryMinuteUpdates(errs) + return nil + }) + g.Go(func() error { + processGroupChannelTokenSummaryMinuteUpdates(errs) + return nil + }) _ = g.Wait() @@ -270,6 +355,60 @@ func processChannelUpdates(errs *batchErrors) { } } +func logBatchGroupChannelUpdateError(message string, err error) { + log.WithError(err).Error(message) +} + +func processGroupChannelUpdates(errs *batchErrors) { + for key, data := range batchData.GroupChannels { + err := UpdateGroupChannelUsedAmount( + key.GroupID, + key.ID, + data.Amount.InexactFloat64(), + data.Count, + data.RetryCount, + ) + if IgnoreNotFound(err) != nil { + logBatchGroupChannelUpdateError("failed to batch update group channel", err) + errs.Add(err) + } else { + delete(batchData.GroupChannels, key) + } + } +} + +func processGroupChannelGroupUpdates(errs *batchErrors) { + for groupID, data := range batchData.GroupChannelGroups { + err := UpdateGroupChannelGroupUsedAmountAndRequestCount( + groupID, + data.Amount.InexactFloat64(), + data.Count, + ) + if IgnoreNotFound(err) != nil { + logBatchGroupChannelUpdateError("failed to batch update group channel group", err) + errs.Add(err) + } else { + delete(batchData.GroupChannelGroups, groupID) + } + } +} + +func processGroupChannelTokenUpdates(errs *batchErrors) { + for tokenID, data := range batchData.GroupChannelTokens { + err := UpdateGroupChannelTokenUsedAmount( + tokenID, + data.Amount.InexactFloat64(), + data.Count, + ) + if IgnoreNotFound(err) != nil { + logBatchGroupChannelUpdateError("failed to batch update group channel token", err) + errs.Add(err) + } else { + delete(batchData.GroupChannelTokens, tokenID) + } + } +} + func processGroupSummaryUpdates(errs *batchErrors) { for key, data := range batchData.GroupSummaries { err := UpsertGroupSummary(data.GroupSummaryUnique, data.SummaryData) @@ -287,6 +426,36 @@ func processGroupSummaryUpdates(errs *batchErrors) { } } +func processGroupChannelSummaryUpdates(errs *batchErrors) { + for key, data := range batchData.GroupChannelSummaries { + err := UpsertGroupChannelSummary(data.GroupChannelSummaryUnique, data.SummaryData) + if err != nil { + logBatchGroupChannelUpdateError("failed to batch update group channel summary", err) + errs.Add(err) + } else { + delete(batchData.GroupChannelSummaries, key) + } + } +} + +func processGroupChannelTokenSummaryUpdates(errs *batchErrors) { + for key, data := range batchData.GroupChannelTokenSummaries { + err := UpsertGroupChannelTokenSummary( + data.GroupChannelTokenSummaryUnique, + data.SummaryData, + ) + if err != nil { + logBatchGroupChannelUpdateError( + "failed to batch update group channel token summary", + err, + ) + errs.Add(err) + } else { + delete(batchData.GroupChannelTokenSummaries, key) + } + } +} + func processGroupSummaryMinuteUpdates(errs *batchErrors) { for key, data := range batchData.GroupSummariesMinute { err := UpsertGroupSummaryMinute(data.GroupSummaryMinuteUnique, data.SummaryData) @@ -304,6 +473,42 @@ func processGroupSummaryMinuteUpdates(errs *batchErrors) { } } +func processGroupChannelSummaryMinuteUpdates(errs *batchErrors) { + for key, data := range batchData.GroupChannelSummariesMinute { + err := UpsertGroupChannelSummaryMinute( + data.GroupChannelSummaryMinuteUnique, + data.SummaryData, + ) + if err != nil { + logBatchGroupChannelUpdateError( + "failed to batch update group channel summary minute", + err, + ) + errs.Add(err) + } else { + delete(batchData.GroupChannelSummariesMinute, key) + } + } +} + +func processGroupChannelTokenSummaryMinuteUpdates(errs *batchErrors) { + for key, data := range batchData.GroupChannelTokenSummariesMinute { + err := UpsertGroupChannelTokenSummaryMinute( + data.GroupChannelTokenSummaryMinuteUnique, + data.SummaryData, + ) + if err != nil { + logBatchGroupChannelUpdateError( + "failed to batch update group channel token summary minute", + err, + ) + errs.Add(err) + } else { + delete(batchData.GroupChannelTokenSummariesMinute, key) + } + } +} + func processSummaryUpdates(errs *batchErrors) { for key, data := range batchData.Summaries { err := UpsertSummary(data.SummaryUnique, data.SummaryData) @@ -346,6 +551,7 @@ func BatchRecordLogs( firstByteAt time.Time, group string, code int, + channelScope ChannelScope, channelID int, modelName string, tokenID int, @@ -373,6 +579,10 @@ func BatchRecordLogs( now = time.Now() } + if channelScope == ChannelScopeGroup { + asyncUsageStatus = AsyncUsageStatusNone + } + if code == http.StatusTooManyRequests || config.GetLogDetailStorageHours() < 0 || config.GetLogStorageHours() < 0 { @@ -381,52 +591,56 @@ func BatchRecordLogs( if downstreamResult { if config.GetLogStorageHours() >= 0 { - err = RecordConsumeLog( - requestID, - now, - requestAt, - retryAt, - firstByteAt, - group, - code, - channelID, - modelName, - tokenID, - tokenName, - endpoint, - content, - mode, - ip, - retryTimes, - requestDetail, - usage, - usageContext, - modelPrice, - amount, - user, - metadata, - promptCacheKey, - upstreamID, - asyncUsageStatus, - ) + if channelScope == ChannelScopeGroup { + err = RecordGroupChannelConsumeLog( + requestID, now, requestAt, retryAt, firstByteAt, group, code, channelID, + modelName, tokenID, tokenName, endpoint, content, mode, ip, retryTimes, + requestDetail, usage, usageContext, modelPrice, amount, user, metadata, + promptCacheKey, upstreamID, asyncUsageStatus, + ) + } else { + err = RecordConsumeLog( + requestID, now, requestAt, retryAt, firstByteAt, group, code, channelID, + modelName, tokenID, tokenName, endpoint, content, mode, ip, retryTimes, + requestDetail, usage, usageContext, modelPrice, amount, user, metadata, + promptCacheKey, upstreamID, asyncUsageStatus, + ) + } } } else { if code != http.StatusTooManyRequests && config.GetLogStorageHours() >= 0 && config.GetRetryLogStorageHours() > 0 { - err = RecordRetryLog( - requestID, - now, - requestAt, - retryAt, - firstByteAt, - code, - channelID, - modelName, - mode, - retryTimes, - requestDetail, - ) + if channelScope == ChannelScopeGroup { + err = RecordGroupChannelRetryLog( + requestID, + now, + requestAt, + retryAt, + firstByteAt, + group, + code, + channelID, + modelName, + mode, + retryTimes, + requestDetail, + ) + } else { + err = RecordRetryLog( + requestID, + now, + requestAt, + retryAt, + firstByteAt, + code, + channelID, + modelName, + mode, + retryTimes, + requestDetail, + ) + } } } @@ -436,6 +650,7 @@ func BatchRecordLogs( firstByteAt, group, code, + channelScope, channelID, modelName, tokenID, @@ -456,6 +671,7 @@ func BatchUpdateSummary( firstByteAt time.Time, group string, code int, + channelScope ChannelScope, channelID int, modelName string, tokenID int, @@ -475,9 +691,19 @@ func BatchUpdateSummary( batchData.Lock() defer batchData.Unlock() - updateChannelData(channelID, amount.UsedAmount, amountDecimal, !downstreamResult) + if channelScope == ChannelScopeGroup { + updateGroupChannelData( + group, + channelID, + amount.UsedAmount, + amountDecimal, + !downstreamResult, + ) + } else { + updateChannelData(channelID, amount.UsedAmount, amountDecimal, !downstreamResult) + } - if channelID != 0 { + if channelID != 0 && channelScope != ChannelScopeGroup { updateSummaryData( channelID, modelName, @@ -507,11 +733,82 @@ func BatchUpdateSummary( ) } + if channelID != 0 && channelScope == ChannelScopeGroup && group != "" { + updateGroupChannelSummaryData( + group, + channelID, + modelName, + now, + requestAt, + firstByteAt, + code, + amount, + usage, + !downstreamResult, + serviceTier, + summaryClaudeLongContext, + ) + + updateGroupChannelSummaryDataMinute( + group, + channelID, + modelName, + now, + requestAt, + firstByteAt, + code, + amount, + usage, + !downstreamResult, + serviceTier, + summaryClaudeLongContext, + ) + } + // group related data only records downstream result if !downstreamResult { return } + if channelScope == ChannelScopeGroup { + if group != "" && tokenID > 0 { + updateGroupChannelGroupData(group, amount.UsedAmount, amountDecimal) + updateGroupChannelTokenData(tokenID, amount.UsedAmount, amountDecimal) + } + + if group != "" && tokenName != "" { + updateGroupChannelTokenSummaryData( + group, + tokenName, + modelName, + now, + requestAt, + firstByteAt, + code, + amount, + usage, + serviceTier, + summaryClaudeLongContext, + ) + + updateGroupChannelTokenSummaryDataMinute( + group, + tokenName, + modelName, + now, + requestAt, + firstByteAt, + code, + amount, + usage, + serviceTier, + summaryClaudeLongContext, + ) + } + + return + } + updateGroupData(group, amount.UsedAmount, amountDecimal) updateTokenData(tokenID, amount.UsedAmount, amountDecimal) @@ -551,6 +848,7 @@ func BatchUpdateSummaryOnlyUsage( now time.Time, requestAt time.Time, group string, + channelScope ChannelScope, channelID int, modelName string, tokenID int, @@ -574,25 +872,75 @@ func BatchUpdateSummaryOnlyUsage( batchData.Lock() defer batchData.Unlock() - updateChannelAmountData(channelID, amount.UsedAmount, amountDecimal) - updateSummaryUsageData( - channelID, - modelName, - summaryAt, - usage, - amount, - serviceTier, - summaryClaudeLongContext, - ) - updateSummaryUsageDataMinute( - channelID, - modelName, - summaryAt, - usage, - amount, - serviceTier, - summaryClaudeLongContext, - ) + if channelScope == ChannelScopeGroup { + updateGroupChannelSummaryUsageData( + group, + channelID, + modelName, + summaryAt, + usage, + amount, + serviceTier, + summaryClaudeLongContext, + ) + updateGroupChannelSummaryUsageDataMinute( + group, + channelID, + modelName, + summaryAt, + usage, + amount, + serviceTier, + summaryClaudeLongContext, + ) + } else { + updateChannelAmountData(channelID, amount.UsedAmount, amountDecimal) + updateSummaryUsageData( + channelID, + modelName, + summaryAt, + usage, + amount, + serviceTier, + summaryClaudeLongContext, + ) + updateSummaryUsageDataMinute( + channelID, + modelName, + summaryAt, + usage, + amount, + serviceTier, + summaryClaudeLongContext, + ) + } + + if channelScope == ChannelScopeGroup { + if group != "" && tokenID > 0 && tokenName != "" { + updateGroupChannelTokenSummaryUsageData( + group, + tokenName, + modelName, + summaryAt, + usage, + amount, + serviceTier, + summaryClaudeLongContext, + ) + updateGroupChannelTokenSummaryUsageDataMinute( + group, + tokenName, + modelName, + summaryAt, + usage, + amount, + serviceTier, + summaryClaudeLongContext, + ) + } + + return + } updateGroupAmountData(group, amount.UsedAmount, amountDecimal) updateTokenAmountData(tokenID, amount.UsedAmount, amountDecimal) @@ -656,11 +1004,38 @@ func updateChannelAmountData(channelID int, amount float64, amountDecimal decima Add(batchData.Channels[channelID].Amount) } -func updateGroupData(group string, amount float64, amountDecimal decimal.Decimal) { - if group == "" { - return - } - +func updateGroupChannelData( + group string, + channelID int, + amount float64, + amountDecimal decimal.Decimal, + isRetry bool, +) { + if group == "" || channelID <= 0 { + return + } + + key := groupChannelUpdateKey{GroupID: group, ID: channelID} + if _, ok := batchData.GroupChannels[key]; !ok { + batchData.GroupChannels[key] = &ChannelUpdate{} + } + + if amount > 0 { + batchData.GroupChannels[key].Amount = amountDecimal. + Add(batchData.GroupChannels[key].Amount) + } + + batchData.GroupChannels[key].Count++ + if isRetry { + batchData.GroupChannels[key].RetryCount++ + } +} + +func updateGroupData(group string, amount float64, amountDecimal decimal.Decimal) { + if group == "" { + return + } + if _, ok := batchData.Groups[group]; !ok { batchData.Groups[group] = &GroupUpdate{} } @@ -686,6 +1061,23 @@ func updateGroupAmountData(group string, amount float64, amountDecimal decimal.D Add(batchData.Groups[group].Amount) } +func updateGroupChannelGroupData(group string, amount float64, amountDecimal decimal.Decimal) { + if group == "" { + return + } + + if _, ok := batchData.GroupChannelGroups[group]; !ok { + batchData.GroupChannelGroups[group] = &GroupUpdate{} + } + + if amount > 0 { + batchData.GroupChannelGroups[group].Amount = amountDecimal. + Add(batchData.GroupChannelGroups[group].Amount) + } + + batchData.GroupChannelGroups[group].Count++ +} + func updateTokenData(tokenID int, amount float64, amountDecimal decimal.Decimal) { if tokenID <= 0 { return @@ -716,6 +1108,23 @@ func updateTokenAmountData(tokenID int, amount float64, amountDecimal decimal.De Add(batchData.Tokens[tokenID].Amount) } +func updateGroupChannelTokenData(tokenID int, amount float64, amountDecimal decimal.Decimal) { + if tokenID <= 0 { + return + } + + if _, ok := batchData.GroupChannelTokens[tokenID]; !ok { + batchData.GroupChannelTokens[tokenID] = &TokenUpdate{} + } + + if amount > 0 { + batchData.GroupChannelTokens[tokenID].Amount = amountDecimal. + Add(batchData.GroupChannelTokens[tokenID].Amount) + } + + batchData.GroupChannelTokens[tokenID].Count++ +} + func updateGroupSummaryData( group, tokenName, modelName string, createAt time.Time, @@ -731,20 +1140,6 @@ func updateGroupSummaryData( createAt = time.Now() } - if requestAt.IsZero() { - requestAt = createAt - } - - if firstByteAt.IsZero() || firstByteAt.Before(requestAt) { - firstByteAt = requestAt - } - - totalTimeMilliseconds, totalTTFBMilliseconds := getSummaryLatencyMetrics( - createAt, - requestAt, - firstByteAt, - ) - groupUnique := GroupSummaryUnique{ GroupID: group, TokenName: tokenName, @@ -760,36 +1155,272 @@ func updateGroupSummaryData( batchData.GroupSummaries[groupUnique] = groupSummary } - groupSummary.Amount.Add(amount) + addRequestSummaryData( + &groupSummary.SummaryData, + createAt, + requestAt, + firstByteAt, + code, + amount, + usage, + false, + serviceTier, + summaryClaudeLongContext, + ) +} + +func addRequestSummaryData( + data *SummaryData, + createAt time.Time, + requestAt time.Time, + firstByteAt time.Time, + code int, + amount Amount, + usage Usage, + isRetry bool, + serviceTier string, + summaryClaudeLongContext bool, +) { + if createAt.IsZero() { + createAt = time.Now() + } + + if requestAt.IsZero() { + requestAt = createAt + } - groupSummary.TotalTimeMilliseconds += totalTimeMilliseconds - groupSummary.TotalTTFBMilliseconds += totalTTFBMilliseconds + if firstByteAt.IsZero() || firstByteAt.Before(requestAt) { + firstByteAt = requestAt + } - groupSummary.Usage.Add(usage) - groupSummary.AddRequest(code, false) - groupSummary.AddServiceTierBreakdown( + totalTimeMilliseconds, totalTTFBMilliseconds := getSummaryLatencyMetrics( + createAt, + requestAt, + firstByteAt, + ) + + data.Amount.Add(amount) + data.TotalTimeMilliseconds += totalTimeMilliseconds + data.TotalTTFBMilliseconds += totalTTFBMilliseconds + data.Usage.Add(usage) + data.AddRequest(code, isRetry) + data.AddServiceTierBreakdown( serviceTier, usage, amount, totalTimeMilliseconds, totalTTFBMilliseconds, - false, + isRetry, code, ) if summaryClaudeLongContext { - groupSummary.AddClaudeLongContextBreakdown(usage, amount, false, code) - groupSummary.ClaudeLongContext.TotalTimeMilliseconds += totalTimeMilliseconds - groupSummary.ClaudeLongContext.TotalTTFBMilliseconds += totalTTFBMilliseconds + data.AddClaudeLongContextBreakdown(usage, amount, isRetry, code) + data.ClaudeLongContext.TotalTimeMilliseconds += totalTimeMilliseconds + data.ClaudeLongContext.TotalTTFBMilliseconds += totalTTFBMilliseconds } if usage.CachedTokens > 0 { - groupSummary.CacheHitCount++ + data.CacheHitCount++ } if usage.CacheCreationTokens > 0 { - groupSummary.CacheCreationCount++ + data.CacheCreationCount++ + } +} + +func updateGroupChannelSummaryData( + group string, + groupChannelID int, + modelName string, + createAt time.Time, + requestAt time.Time, + firstByteAt time.Time, + code int, + amount Amount, + usage Usage, + isRetry bool, + serviceTier string, + summaryClaudeLongContext bool, +) { + if group == "" || groupChannelID <= 0 { + return + } + + if createAt.IsZero() { + createAt = time.Now() + } + + unique := GroupChannelSummaryUnique{ + GroupID: group, + GroupChannelID: groupChannelID, + Model: modelName, + HourTimestamp: createAt.Truncate(time.Hour).Unix(), } + + summary, ok := batchData.GroupChannelSummaries[unique] + if !ok { + summary = &GroupChannelSummaryUpdate{GroupChannelSummaryUnique: unique} + batchData.GroupChannelSummaries[unique] = summary + } + + addRequestSummaryData( + &summary.SummaryData, + createAt, + requestAt, + firstByteAt, + code, + amount, + usage, + isRetry, + serviceTier, + summaryClaudeLongContext, + ) +} + +func updateGroupChannelSummaryDataMinute( + group string, + groupChannelID int, + modelName string, + createAt time.Time, + requestAt time.Time, + firstByteAt time.Time, + code int, + amount Amount, + usage Usage, + isRetry bool, + serviceTier string, + summaryClaudeLongContext bool, +) { + if group == "" || groupChannelID <= 0 { + return + } + + if createAt.IsZero() { + createAt = time.Now() + } + + unique := GroupChannelSummaryMinuteUnique{ + GroupID: group, + GroupChannelID: groupChannelID, + Model: modelName, + MinuteTimestamp: createAt.Truncate(time.Minute).Unix(), + } + + summary, ok := batchData.GroupChannelSummariesMinute[unique] + if !ok { + summary = &GroupChannelSummaryMinuteUpdate{GroupChannelSummaryMinuteUnique: unique} + batchData.GroupChannelSummariesMinute[unique] = summary + } + + addRequestSummaryData( + &summary.SummaryData, + createAt, + requestAt, + firstByteAt, + code, + amount, + usage, + isRetry, + serviceTier, + summaryClaudeLongContext, + ) +} + +func updateGroupChannelTokenSummaryData( + group, tokenName, modelName string, + createAt time.Time, + requestAt time.Time, + firstByteAt time.Time, + code int, + amount Amount, + usage Usage, + serviceTier string, + summaryClaudeLongContext bool, +) { + if group == "" || tokenName == "" { + return + } + + if createAt.IsZero() { + createAt = time.Now() + } + + unique := GroupChannelTokenSummaryUnique{ + GroupID: group, + TokenName: tokenName, + Model: modelName, + HourTimestamp: createAt.Truncate(time.Hour).Unix(), + } + + summary, ok := batchData.GroupChannelTokenSummaries[unique] + if !ok { + summary = &GroupChannelTokenSummaryUpdate{ + GroupChannelTokenSummaryUnique: unique, + } + batchData.GroupChannelTokenSummaries[unique] = summary + } + + addRequestSummaryData( + &summary.SummaryData, + createAt, + requestAt, + firstByteAt, + code, + amount, + usage, + false, + serviceTier, + summaryClaudeLongContext, + ) +} + +func updateGroupChannelTokenSummaryDataMinute( + group, tokenName, modelName string, + createAt time.Time, + requestAt time.Time, + firstByteAt time.Time, + code int, + amount Amount, + usage Usage, + serviceTier string, + summaryClaudeLongContext bool, +) { + if group == "" || tokenName == "" { + return + } + + if createAt.IsZero() { + createAt = time.Now() + } + + unique := GroupChannelTokenSummaryMinuteUnique{ + GroupID: group, + TokenName: tokenName, + Model: modelName, + MinuteTimestamp: createAt.Truncate(time.Minute).Unix(), + } + + summary, ok := batchData.GroupChannelTokenSummariesMinute[unique] + if !ok { + summary = &GroupChannelTokenSummaryMinuteUpdate{ + GroupChannelTokenSummaryMinuteUnique: unique, + } + batchData.GroupChannelTokenSummariesMinute[unique] = summary + } + + addRequestSummaryData( + &summary.SummaryData, + createAt, + requestAt, + firstByteAt, + code, + amount, + usage, + false, + serviceTier, + summaryClaudeLongContext, + ) } func updateSummaryUsageData( @@ -860,6 +1491,154 @@ func updateSummaryUsageDataMinute( addSummaryUsageOnly(&summary.SummaryData, usage, amount, serviceTier, summaryClaudeLongContext) } +func updateGroupChannelSummaryUsageData( + group string, + groupChannelID int, + modelName string, + createAt time.Time, + usage Usage, + amount Amount, + serviceTier string, + summaryClaudeLongContext bool, +) { + if group == "" || groupChannelID <= 0 { + return + } + + if createAt.IsZero() { + createAt = time.Now() + } + + unique := GroupChannelSummaryUnique{ + GroupID: group, + GroupChannelID: groupChannelID, + Model: modelName, + HourTimestamp: createAt.Truncate(time.Hour).Unix(), + } + + summary, ok := batchData.GroupChannelSummaries[unique] + if !ok { + summary = &GroupChannelSummaryUpdate{GroupChannelSummaryUnique: unique} + batchData.GroupChannelSummaries[unique] = summary + } + + addSummaryUsageOnly(&summary.SummaryData, usage, amount, serviceTier, summaryClaudeLongContext) +} + +func updateGroupChannelSummaryUsageDataMinute( + group string, + groupChannelID int, + modelName string, + createAt time.Time, + usage Usage, + amount Amount, + serviceTier string, + summaryClaudeLongContext bool, +) { + if group == "" || groupChannelID <= 0 { + return + } + + if createAt.IsZero() { + createAt = time.Now() + } + + unique := GroupChannelSummaryMinuteUnique{ + GroupID: group, + GroupChannelID: groupChannelID, + Model: modelName, + MinuteTimestamp: createAt.Truncate(time.Minute).Unix(), + } + + summary, ok := batchData.GroupChannelSummariesMinute[unique] + if !ok { + summary = &GroupChannelSummaryMinuteUpdate{GroupChannelSummaryMinuteUnique: unique} + batchData.GroupChannelSummariesMinute[unique] = summary + } + + addSummaryUsageOnly(&summary.SummaryData, usage, amount, serviceTier, summaryClaudeLongContext) +} + +func updateGroupChannelTokenSummaryUsageData( + group, tokenName, modelName string, + createAt time.Time, + usage Usage, + amount Amount, + serviceTier string, + summaryClaudeLongContext bool, +) { + if group == "" || tokenName == "" { + return + } + + if createAt.IsZero() { + createAt = time.Now() + } + + unique := GroupChannelTokenSummaryUnique{ + GroupID: group, + TokenName: tokenName, + Model: modelName, + HourTimestamp: createAt.Truncate(time.Hour).Unix(), + } + + summary, ok := batchData.GroupChannelTokenSummaries[unique] + if !ok { + summary = &GroupChannelTokenSummaryUpdate{ + GroupChannelTokenSummaryUnique: unique, + } + batchData.GroupChannelTokenSummaries[unique] = summary + } + + addSummaryUsageOnly( + &summary.SummaryData, + usage, + amount, + serviceTier, + summaryClaudeLongContext, + ) +} + +func updateGroupChannelTokenSummaryUsageDataMinute( + group, tokenName, modelName string, + createAt time.Time, + usage Usage, + amount Amount, + serviceTier string, + summaryClaudeLongContext bool, +) { + if group == "" || tokenName == "" { + return + } + + if createAt.IsZero() { + createAt = time.Now() + } + + unique := GroupChannelTokenSummaryMinuteUnique{ + GroupID: group, + TokenName: tokenName, + Model: modelName, + MinuteTimestamp: createAt.Truncate(time.Minute).Unix(), + } + + summary, ok := batchData.GroupChannelTokenSummariesMinute[unique] + if !ok { + summary = &GroupChannelTokenSummaryMinuteUpdate{ + GroupChannelTokenSummaryMinuteUnique: unique, + } + batchData.GroupChannelTokenSummariesMinute[unique] = summary + } + + addSummaryUsageOnly( + &summary.SummaryData, + usage, + amount, + serviceTier, + summaryClaudeLongContext, + ) +} + func updateGroupSummaryUsageData( group, tokenName, modelName string, createAt time.Time, @@ -1012,20 +1791,6 @@ func updateGroupSummaryDataMinute( createAt = time.Now() } - if requestAt.IsZero() { - requestAt = createAt - } - - if firstByteAt.IsZero() || firstByteAt.Before(requestAt) { - firstByteAt = requestAt - } - - totalTimeMilliseconds, totalTTFBMilliseconds := getSummaryLatencyMetrics( - createAt, - requestAt, - firstByteAt, - ) - groupUnique := GroupSummaryMinuteUnique{ GroupID: group, TokenName: tokenName, @@ -1041,36 +1806,18 @@ func updateGroupSummaryDataMinute( batchData.GroupSummariesMinute[groupUnique] = groupSummary } - groupSummary.Amount.Add(amount) - - groupSummary.TotalTimeMilliseconds += totalTimeMilliseconds - groupSummary.TotalTTFBMilliseconds += totalTTFBMilliseconds - - groupSummary.Usage.Add(usage) - groupSummary.AddRequest(code, false) - groupSummary.AddServiceTierBreakdown( - serviceTier, - usage, + addRequestSummaryData( + &groupSummary.SummaryData, + createAt, + requestAt, + firstByteAt, + code, amount, - totalTimeMilliseconds, - totalTTFBMilliseconds, + usage, false, - code, + serviceTier, + summaryClaudeLongContext, ) - - if summaryClaudeLongContext { - groupSummary.AddClaudeLongContextBreakdown(usage, amount, false, code) - groupSummary.ClaudeLongContext.TotalTimeMilliseconds += totalTimeMilliseconds - groupSummary.ClaudeLongContext.TotalTTFBMilliseconds += totalTTFBMilliseconds - } - - if usage.CachedTokens > 0 { - groupSummary.CacheHitCount++ - } - - if usage.CacheCreationTokens > 0 { - groupSummary.CacheCreationCount++ - } } func updateSummaryData( @@ -1090,20 +1837,6 @@ func updateSummaryData( createAt = time.Now() } - if requestAt.IsZero() { - requestAt = createAt - } - - if firstByteAt.IsZero() || firstByteAt.Before(requestAt) { - firstByteAt = requestAt - } - - totalTimeMilliseconds, totalTTFBMilliseconds := getSummaryLatencyMetrics( - createAt, - requestAt, - firstByteAt, - ) - summaryUnique := SummaryUnique{ ChannelID: channelID, Model: modelName, @@ -1118,36 +1851,18 @@ func updateSummaryData( batchData.Summaries[summaryUnique] = summary } - summary.Amount.Add(amount) - - summary.TotalTimeMilliseconds += totalTimeMilliseconds - summary.TotalTTFBMilliseconds += totalTTFBMilliseconds - - summary.Usage.Add(usage) - summary.AddRequest(code, isRetry) - summary.AddServiceTierBreakdown( - serviceTier, - usage, + addRequestSummaryData( + &summary.SummaryData, + createAt, + requestAt, + firstByteAt, + code, amount, - totalTimeMilliseconds, - totalTTFBMilliseconds, + usage, isRetry, - code, + serviceTier, + summaryClaudeLongContext, ) - - if summaryClaudeLongContext { - summary.AddClaudeLongContextBreakdown(usage, amount, isRetry, code) - summary.ClaudeLongContext.TotalTimeMilliseconds += totalTimeMilliseconds - summary.ClaudeLongContext.TotalTTFBMilliseconds += totalTTFBMilliseconds - } - - if usage.CachedTokens > 0 { - summary.CacheHitCount++ - } - - if usage.CacheCreationTokens > 0 { - summary.CacheCreationCount++ - } } func updateSummaryDataMinute( @@ -1167,20 +1882,6 @@ func updateSummaryDataMinute( createAt = time.Now() } - if requestAt.IsZero() { - requestAt = createAt - } - - if firstByteAt.IsZero() || firstByteAt.Before(requestAt) { - firstByteAt = requestAt - } - - totalTimeMilliseconds, totalTTFBMilliseconds := getSummaryLatencyMetrics( - createAt, - requestAt, - firstByteAt, - ) - summaryUnique := SummaryMinuteUnique{ ChannelID: channelID, Model: modelName, @@ -1195,36 +1896,18 @@ func updateSummaryDataMinute( batchData.SummariesMinute[summaryUnique] = summary } - summary.Amount.Add(amount) - - summary.TotalTimeMilliseconds += totalTimeMilliseconds - summary.TotalTTFBMilliseconds += totalTTFBMilliseconds - - summary.Usage.Add(usage) - summary.AddRequest(code, isRetry) - summary.AddServiceTierBreakdown( - serviceTier, - usage, + addRequestSummaryData( + &summary.SummaryData, + createAt, + requestAt, + firstByteAt, + code, amount, - totalTimeMilliseconds, - totalTTFBMilliseconds, + usage, isRetry, - code, + serviceTier, + summaryClaudeLongContext, ) - - if summaryClaudeLongContext { - summary.AddClaudeLongContextBreakdown(usage, amount, isRetry, code) - summary.ClaudeLongContext.TotalTimeMilliseconds += totalTimeMilliseconds - summary.ClaudeLongContext.TotalTTFBMilliseconds += totalTTFBMilliseconds - } - - if usage.CachedTokens > 0 { - summary.CacheHitCount++ - } - - if usage.CacheCreationTokens > 0 { - summary.CacheCreationCount++ - } } func getSummaryLatencyMetrics( diff --git a/core/model/channel.go b/core/model/channel.go index 61133a1d..a3505a38 100644 --- a/core/model/channel.go +++ b/core/model/channel.go @@ -61,10 +61,26 @@ type Channel struct { } func (c *Channel) GetSets() []string { - if len(c.Sets) == 0 { - return []string{ChannelDefaultSet} + return NormalizeAvailableSets(c.Sets) +} + +func ChannelAccessModels(channel *Channel) []string { + if channel == nil { + return nil + } + + models := cloneStringSlice(channel.Models) + for publicModel := range channel.ModelMapping { + models = append(models, publicModel) } - return c.Sets + + return models +} + +func ChannelSupportsModel(channel *Channel, modelName string) bool { + return slices.ContainsFunc(ChannelAccessModels(channel), func(item string) bool { + return strings.EqualFold(item, modelName) + }) } func (c *Channel) BeforeDelete(tx *gorm.DB) (err error) { diff --git a/core/model/group.go b/core/model/group.go index 12e17e01..d4db18db 100644 --- a/core/model/group.go +++ b/core/model/group.go @@ -1,11 +1,13 @@ package model import ( + "context" "errors" "strings" "time" "github.com/labring/aiproxy/core/common" + "github.com/labring/aiproxy/core/monitor" log "github.com/sirupsen/logrus" "gorm.io/gorm" "gorm.io/gorm/clause" @@ -22,18 +24,20 @@ const ( ) type Group struct { - CreatedAt time.Time `json:"created_at"` - ID string `json:"id" gorm:"size:64;primaryKey"` - Tokens []Token `json:"-" gorm:"foreignKey:GroupID"` - GroupModelConfigs []GroupModelConfig `json:"-" gorm:"foreignKey:GroupID"` - PublicMCPReusingParams []PublicMCPReusingParam `json:"-" gorm:"foreignKey:GroupID"` - GroupMCPs []GroupMCP `json:"-" gorm:"foreignKey:GroupID"` - Status int `json:"status" gorm:"default:1;index"` - RPMRatio float64 `json:"rpm_ratio,omitempty" gorm:"index"` - TPMRatio float64 `json:"tpm_ratio,omitempty" gorm:"index"` - UsedAmount float64 `json:"used_amount" gorm:"index"` - RequestCount int `json:"request_count" gorm:"index"` - AvailableSets []string `json:"available_sets,omitempty" gorm:"serializer:fastjson;type:text"` + CreatedAt time.Time `json:"created_at"` + ID string `json:"id" gorm:"size:64;primaryKey"` + Tokens []Token `json:"-" gorm:"foreignKey:GroupID"` + GroupModelConfigs []GroupModelConfig `json:"-" gorm:"foreignKey:GroupID"` + PublicMCPReusingParams []PublicMCPReusingParam `json:"-" gorm:"foreignKey:GroupID"` + GroupMCPs []GroupMCP `json:"-" gorm:"foreignKey:GroupID"` + Status int `json:"status" gorm:"default:1;index"` + RPMRatio float64 `json:"rpm_ratio,omitempty" gorm:"index"` + TPMRatio float64 `json:"tpm_ratio,omitempty" gorm:"index"` + UsedAmount float64 `json:"used_amount" gorm:"index"` + RequestCount int `json:"request_count" gorm:"index"` + GroupChannelUsedAmount float64 `json:"group_channel_used_amount" gorm:"index"` + GroupChannelRequestCount int `json:"group_channel_request_count" gorm:"index"` + AvailableSets []string `json:"available_sets,omitempty" gorm:"serializer:fastjson;type:text"` BalanceAlertEnabled bool `gorm:"default:false" json:"balance_alert_enabled"` BalanceAlertThreshold float64 `gorm:"default:0" json:"balance_alert_threshold"` @@ -47,28 +51,117 @@ func (g *Group) BeforeSave(_ *gorm.DB) error { } func (g *Group) BeforeDelete(tx *gorm.DB) (err error) { - err = tx.Model(&Token{}).Where("group_id = ?", g.ID).Delete(&Token{}).Error + if g.ID == "" { + return nil + } + return deleteGroupsOwnedState(tx, []string{g.ID}) +} + +func deleteGroupsOwnedState(tx *gorm.DB, groupIDs []string) error { + if len(groupIDs) == 0 { + return nil + } + + err := tx.Model(&Token{}).Where("group_id IN ?", groupIDs).Delete(&Token{}).Error if err != nil { return err } err = tx.Model(&PublicMCPReusingParam{}). - Where("group_id = ?", g.ID). + Where("group_id IN ?", groupIDs). Delete(&PublicMCPReusingParam{}). Error if err != nil { return err } - err = tx.Model(&GroupMCP{}).Where("group_id = ?", g.ID).Delete(&GroupMCP{}).Error + err = tx.Model(&GroupMCP{}).Where("group_id IN ?", groupIDs).Delete(&GroupMCP{}).Error if err != nil { return err } - return tx.Model(&GroupModelConfig{}). - Where("group_id = ?", g.ID). + err = tx.Model(&GroupModelConfig{}). + Where("group_id IN ?", groupIDs). Delete(&GroupModelConfig{}). Error + if err != nil { + return err + } + + err = tx.Model(&GroupChannelTest{}). + Where("group_id IN ?", groupIDs). + Delete(&GroupChannelTest{}). + Error + if err != nil { + return err + } + + err = tx.Session(&gorm.Session{SkipHooks: true}). + Unscoped(). + Where("group_id IN ?", groupIDs). + Delete(&GroupChannel{}). + Error + if err != nil { + return err + } + + return tx.Model(&GroupScopeModelConfig{}). + Where("group_id IN ?", groupIDs). + Delete(&GroupScopeModelConfig{}). + Error +} + +type groupChannelIDRow struct { + GroupID string + ID int +} + +func getGroupChannelIDsByGroups(tx *gorm.DB, groupIDs []string) ([]int, error) { + rows, err := getGroupChannelIDRows(tx, groupIDs) + if err != nil { + return nil, err + } + + channelIDs := make([]int, 0, len(rows)) + for _, row := range rows { + channelIDs = append(channelIDs, row.ID) + } + + return channelIDs, nil +} + +func getGroupChannelIDsByGroupMap( + tx *gorm.DB, + groupIDs []string, +) (map[string][]int, error) { + rows, err := getGroupChannelIDRows(tx, groupIDs) + if err != nil { + return nil, err + } + + result := make(map[string][]int, len(groupIDs)) + for _, row := range rows { + result[row.GroupID] = append(result[row.GroupID], row.ID) + } + + return result, nil +} + +func getGroupChannelIDRows(tx *gorm.DB, groupIDs []string) ([]groupChannelIDRow, error) { + if len(groupIDs) == 0 { + return nil, nil + } + + var rows []groupChannelIDRow + + err := tx. + Model(&GroupChannel{}). + Select("group_id", "id"). + Where("group_id IN ?", groupIDs). + Find(&rows). + Error + + return rows, err } func getGroupOrder(order string) string { @@ -137,21 +230,64 @@ func DeleteGroupByID(id string) (err error) { if id == "" { return errors.New("group id is empty") } + + var channelIDs []int defer func() { if err == nil { if err := CacheDeleteGroup(id); err != nil { log.Error("cache delete group failed: " + err.Error()) } + if err := CacheDeleteGroupChannels(id); err != nil { + log.Error("cache delete group channels failed: " + err.Error()) + } + + if err := CacheDeleteGroupScopeModelConfig(id); err != nil { + log.Error("cache delete group scope model config failed: " + err.Error()) + } + + for _, channelID := range channelIDs { + _ = monitor.ClearGroupChannelAllModelErrorsByKey( + context.Background(), + GroupChannelMonitorKey(id, channelID), + ) + } + if _, err := DeleteGroupLogs(id); err != nil { log.Error("delete group logs failed: " + err.Error()) } + + if _, err := DeleteGroupChannelLogs(id); err != nil { + log.Error("delete group channel logs failed: " + err.Error()) + } } }() - result := DB.Delete(&Group{ID: id}) + return DB.Transaction(func(tx *gorm.DB) error { + if err := tx. + Model(&Group{}). + Select("id"). + Where("id = ?", id). + First(&Group{}). + Error; err != nil { + return HandleNotFound(err, ErrGroupNotFound) + } + + var err error - return HandleUpdateResult(result, ErrGroupNotFound) + channelIDs, err = getGroupChannelIDsByGroups(tx, []string{id}) + if err != nil { + return err + } + + if err := deleteGroupsOwnedState(tx, []string{id}); err != nil { + return err + } + + result := tx.Session(&gorm.Session{SkipHooks: true}).Delete(&Group{ID: id}) + + return HandleUpdateResult(result, ErrGroupNotFound) + }) } func DeleteGroupsByIDs(ids []string) (err error) { @@ -159,30 +295,77 @@ func DeleteGroupsByIDs(ids []string) (err error) { return nil } - groups := make([]Group, len(ids)) + var ( + groupIDs []string + channelIDsByGroup map[string][]int + ) defer func() { if err == nil { - for _, group := range groups { - if err := CacheDeleteGroup(group.ID); err != nil { + for _, groupID := range groupIDs { + if err := CacheDeleteGroup(groupID); err != nil { log.Error("cache delete group failed: " + err.Error()) } - if _, err := DeleteGroupLogs(group.ID); err != nil { + if err := CacheDeleteGroupChannels(groupID); err != nil { + log.Error("cache delete group channels failed: " + err.Error()) + } + + if err := CacheDeleteGroupScopeModelConfig(groupID); err != nil { + log.Error("cache delete group scope model config failed: " + err.Error()) + } + + for _, channelID := range channelIDsByGroup[groupID] { + _ = monitor.ClearGroupChannelAllModelErrorsByKey( + context.Background(), + GroupChannelMonitorKey(groupID, channelID), + ) + } + + if _, err := DeleteGroupLogs(groupID); err != nil { log.Error("delete group logs failed: " + err.Error()) } + + if _, err := DeleteGroupChannelLogs(groupID); err != nil { + log.Error("delete group channel logs failed: " + err.Error()) + } } } }() return DB.Transaction(func(tx *gorm.DB) error { - return tx. - Clauses(clause.Returning{ - Columns: []clause.Column{ - {Name: "id"}, - }, - }). - Where("id IN (?)", ids). - Delete(&groups). + var groups []Group + if err := tx. + Model(&Group{}). + Select("id"). + Where("id IN ?", ids). + Find(&groups). + Error; err != nil { + return err + } + + if len(groups) == 0 { + return nil + } + + groupIDs = make([]string, 0, len(groups)) + for _, group := range groups { + groupIDs = append(groupIDs, group.ID) + } + + var err error + + channelIDsByGroup, err = getGroupChannelIDsByGroupMap(tx, groupIDs) + if err != nil { + return err + } + + if err := deleteGroupsOwnedState(tx, groupIDs); err != nil { + return err + } + + return tx.Session(&gorm.Session{SkipHooks: true}). + Where("id IN ?", groupIDs). + Delete(&Group{}). Error }) } @@ -287,6 +470,22 @@ func UpdateGroupUsedAmountAndRequestCount(id string, amount float64, count int) return HandleUpdateResult(result, ErrGroupNotFound) } +func UpdateGroupChannelGroupUsedAmountAndRequestCount( + id string, + amount float64, + count int, +) error { + result := DB. + Model(&Group{}). + Where("id = ?", id). + Updates(map[string]any{ + "group_channel_used_amount": gorm.Expr("group_channel_used_amount + ?", amount), + "group_channel_request_count": gorm.Expr("group_channel_request_count + ?", count), + }) + + return HandleUpdateResult(result, ErrGroupNotFound) +} + func UpdateGroupRPMRatio(id string, rpmRatio float64) (err error) { defer func() { if err == nil { diff --git a/core/model/group_cache.go b/core/model/group_cache.go index a53a0f1f..3a897fed 100644 --- a/core/model/group_cache.go +++ b/core/model/group_cache.go @@ -38,10 +38,7 @@ type GroupCache struct { } func (g *GroupCache) GetAvailableSets() []string { - if len(g.AvailableSets) == 0 { - return []string{ChannelDefaultSet} - } - return g.AvailableSets + return NormalizeAvailableSets(g.AvailableSets) } func (g *Group) ToGroupCache() *GroupCache { diff --git a/core/model/group_scope_modelconfig.go b/core/model/group_scope_modelconfig.go new file mode 100644 index 00000000..dce6bf1d --- /dev/null +++ b/core/model/group_scope_modelconfig.go @@ -0,0 +1,228 @@ +package model + +import ( + "errors" + "slices" + + "github.com/labring/aiproxy/core/common" + log "github.com/sirupsen/logrus" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +const ErrGroupScopeModelConfigNotFound = "group_scope_model_config" + +type GroupScopeModelConfig struct { + ModelConfig + GroupID string `gorm:"size:64;primaryKey" json:"group_id" yaml:"group_id,omitempty"` + Group *Group `gorm:"foreignKey:GroupID" json:"-" yaml:"-"` +} + +func (c *GroupScopeModelConfig) BeforeSave(_ *gorm.DB) error { + if c.GroupID == "" { + return errors.New("group id is required") + } + return c.ModelConfig.BeforeSave(nil) +} + +func (c *GroupScopeModelConfig) ToModelConfig() ModelConfig { + return c.ModelConfig +} + +func SaveGroupScopeModelConfig(config GroupScopeModelConfig) (err error) { + defer func() { + if err == nil { + if err := CacheDeleteGroupScopeModelConfig(config.GroupID); err != nil { + log.Error("cache delete group scope model config failed: " + err.Error()) + } + } + }() + + return DB.Save(&config).Error +} + +func SaveGroupScopeModelConfigs(group string, configs []GroupScopeModelConfig) (err error) { + if group == "" { + return errors.New("group id is required") + } + + defer func() { + if err == nil { + if err := CacheDeleteGroupScopeModelConfig(group); err != nil { + log.Error("cache delete group scope model config failed: " + err.Error()) + } + } + }() + + for i := range configs { + configs[i].GroupID = group + } + + return DB.Save(&configs).Error +} + +func GetGroupScopeModelConfig(group, modelName string) (GroupScopeModelConfig, error) { + var config GroupScopeModelConfig + + err := DB.Where("group_id = ? AND model = ?", group, modelName).First(&config).Error + return config, HandleNotFound(err, ErrGroupScopeModelConfigNotFound) +} + +func GetAllGroupScopeModelConfigs(group string) ([]GroupScopeModelConfig, error) { + var configs []GroupScopeModelConfig + + err := DB.Where("group_id = ?", group).Find(&configs).Error + return configs, err +} + +func GetGroupScopeModelConfigs( + group string, + page, perPage int, + modelName string, +) (configs []GroupScopeModelConfig, total int64, err error) { + tx := DB.Model(&GroupScopeModelConfig{}).Where("group_id = ?", group) + if modelName != "" { + tx = tx.Where("model = ?", modelName) + } + + if err = tx.Count(&total).Error; err != nil { + return nil, 0, err + } + + if total <= 0 { + return nil, 0, nil + } + + limit, offset := toLimitOffset(page, perPage) + err = tx.Order("model asc").Limit(limit).Offset(offset).Find(&configs).Error + + return configs, total, err +} + +func GetGroupScopeModelConfigsByModels( + group string, + models []string, +) ([]GroupScopeModelConfig, error) { + if len(models) == 0 { + return nil, nil + } + + var configs []GroupScopeModelConfig + + err := DB.Where("group_id = ? AND model IN ?", group, models).Find(&configs).Error + + return configs, err +} + +func SearchGroupScopeModelConfigs( + group, keyword, modelName string, + page, perPage int, + owner ModelOwner, +) (configs []GroupScopeModelConfig, total int64, err error) { + tx := DB.Model(&GroupScopeModelConfig{}).Where("group_id = ?", group) + if modelName != "" { + tx = tx.Where("model = ?", modelName) + } + + if owner != "" { + tx = tx.Where("owner = ?", owner) + } + + if keyword != "" { + if !common.UsingSQLite { + tx = tx.Where("model ILIKE ?", "%"+keyword+"%") + } else { + tx = tx.Where("model LIKE ?", "%"+keyword+"%") + } + } + + if err = tx.Count(&total).Error; err != nil { + return nil, 0, err + } + + if total <= 0 { + return nil, 0, nil + } + + limit, offset := toLimitOffset(page, perPage) + err = tx.Order("model asc").Limit(limit).Offset(offset).Find(&configs).Error + + return configs, total, err +} + +func DeleteGroupScopeModelConfig(group, modelName string) error { + result := DB.Where("group_id = ? AND model = ?", group, modelName). + Delete(&GroupScopeModelConfig{}) + if result.Error != nil { + return result.Error + } + + if result.RowsAffected > 0 { + if err := CacheDeleteGroupScopeModelConfig(group); err != nil { + log.Error("cache delete group scope model config failed: " + err.Error()) + } + } + + return HandleUpdateResult(result, ErrGroupScopeModelConfigNotFound) +} + +func DeleteGroupScopeModelConfigsByModels(group string, models []string) error { + if len(models) == 0 { + return nil + } + + result := DB.Where("group_id = ? AND model IN ?", group, models). + Delete(&GroupScopeModelConfig{}) + if result.Error != nil { + return result.Error + } + + if result.RowsAffected > 0 { + if err := CacheDeleteGroupScopeModelConfig(group); err != nil { + log.Error("cache delete group scope model config failed: " + err.Error()) + } + } + + return nil +} + +func UpsertGroupScopeModelConfigs(group string, configs []GroupScopeModelConfig) (err error) { + if group == "" { + return errors.New("group id is required") + } + + if len(configs) == 0 { + return nil + } + + defer func() { + if err == nil { + if err := CacheDeleteGroupScopeModelConfig(group); err != nil { + log.Error("cache delete group scope model config failed: " + err.Error()) + } + } + }() + + for i := range configs { + configs[i].GroupID = group + } + + return DB.Clauses(clause.OnConflict{ + Columns: []clause.Column{ + {Name: "group_id"}, + {Name: "model"}, + }, + UpdateAll: true, + }).Create(&configs).Error +} + +func groupScopeModelConfigModels(configs []GroupScopeModelConfig) []string { + models := make([]string, 0, len(configs)) + for _, config := range configs { + models = append(models, config.Model) + } + + slices.Sort(models) + + return models +} diff --git a/core/model/group_scope_modelconfig_cache.go b/core/model/group_scope_modelconfig_cache.go new file mode 100644 index 00000000..2e28379b --- /dev/null +++ b/core/model/group_scope_modelconfig_cache.go @@ -0,0 +1,317 @@ +package model + +import ( + "context" + "errors" + "maps" + "math/rand/v2" + "time" + + "github.com/bytedance/sonic" + "github.com/labring/aiproxy/core/common" + "github.com/labring/aiproxy/core/common/config" + "github.com/redis/go-redis/v9" + log "github.com/sirupsen/logrus" +) + +const GroupScopeModelConfigsCacheKey = "group_scope_model_configs:%s" + +type GroupScopeModelConfigsCache struct { + GroupID string `json:"group_id" redis:"g"` + Models []string `json:"models" redis:"m"` + Configs map[string]ModelConfig `json:"configs" redis:"c"` + List []GroupScopeModelConfig `json:"list" redis:"l"` +} + +type ( + redisGroupScopeModelConfigMap map[string]ModelConfig + redisGroupScopeModelConfigList []GroupScopeModelConfig +) + +func (r *redisGroupScopeModelConfigMap) ScanRedis(value string) error { + return sonic.UnmarshalString(value, r) +} + +func (r redisGroupScopeModelConfigMap) MarshalBinary() ([]byte, error) { + return sonic.Marshal(r) +} + +func (r *redisGroupScopeModelConfigList) ScanRedis(value string) error { + return sonic.UnmarshalString(value, r) +} + +func (r redisGroupScopeModelConfigList) MarshalBinary() ([]byte, error) { + return sonic.Marshal(r) +} + +func getGroupScopeModelConfigsCacheKey(group string) string { + return common.RedisKeyf(GroupScopeModelConfigsCacheKey, group) +} + +func cloneModelConfig(config ModelConfig) ModelConfig { + cloned := config + cloned.Config = maps.Clone(config.Config) + cloned.Plugin = clonePluginConfig(config.Plugin) + cloned.Price = clonePrice(config.Price) + cloned.AllowedResolutions = cloneStringSlice(config.AllowedResolutions) + + return cloned +} + +func clonePluginConfig(values map[string]map[string]any) map[string]map[string]any { + if values == nil { + return nil + } + + cloned := make(map[string]map[string]any, len(values)) + for key, value := range values { + cloned[key] = maps.Clone(value) + } + + return cloned +} + +func cloneGroupScopeModelConfig(config GroupScopeModelConfig) GroupScopeModelConfig { + cloned := config + cloned.ModelConfig = cloneModelConfig(config.ModelConfig) + return cloned +} + +func cloneGroupScopeModelConfigs(configs []GroupScopeModelConfig) []GroupScopeModelConfig { + if configs == nil { + return nil + } + + cloned := make([]GroupScopeModelConfig, len(configs)) + for i, config := range configs { + cloned[i] = cloneGroupScopeModelConfig(config) + } + + return cloned +} + +func cloneModelConfigMap(values map[string]ModelConfig) map[string]ModelConfig { + if values == nil { + return nil + } + + cloned := make(map[string]ModelConfig, len(values)) + for key, value := range values { + cloned[key] = cloneModelConfig(value) + } + + return cloned +} + +func cloneGroupScopeModelConfigsCache( + cache *GroupScopeModelConfigsCache, +) *GroupScopeModelConfigsCache { + if cache == nil { + return nil + } + + return &GroupScopeModelConfigsCache{ + GroupID: cache.GroupID, + Models: cloneStringSlice(cache.Models), + Configs: cloneModelConfigMap(cache.Configs), + List: cloneGroupScopeModelConfigs(cache.List), + } +} + +func CacheDeleteGroupScopeModelConfig(group string) error { + cacheDeleteModelLocal(getGroupScopeModelConfigsCacheKey(group)) + + if !common.RedisEnabled { + return nil + } + + ctx, cancel := context.WithTimeout(context.Background(), redisTimeout) + defer cancel() + + return common.RDB.Del(ctx, getGroupScopeModelConfigsCacheKey(group)).Err() +} + +func CacheSetGroupScopeModelConfigs(cache *GroupScopeModelConfigsCache) error { + key := getGroupScopeModelConfigsCacheKey(cache.GroupID) + cacheSetModelLocal(key, cache, cloneGroupScopeModelConfigsCache) + return cacheSetGroupScopeModelConfigsRedis(cache) +} + +func CacheGetGroupScopeModelConfigs(group string) (*GroupScopeModelConfigsCache, error) { + if group == "" { + return nil, errors.New("group id is required") + } + + cacheKey := getGroupScopeModelConfigsCacheKey(group) + if cached, _, ok := cacheGetModelLocal(cacheKey, cloneGroupScopeModelConfigsCache); ok { + return cached, nil + } + + if common.RedisEnabled { + ctx, cancel := context.WithTimeout(context.Background(), redisTimeout) + defer cancel() + + cache := &GroupScopeModelConfigsCache{} + + err := scanGroupScopeModelConfigsCache(ctx, cacheKey, cache) + if err == nil && cache.GroupID != "" { + cacheSetModelLocal(cacheKey, cache, cloneGroupScopeModelConfigsCache) + return cache, nil + } else if err != nil && !errors.Is(err, redis.Nil) { + log.Errorf( + "get group scope model configs (%s) from redis error: %s", + group, + err.Error(), + ) + } + } + + cache, _, loaded, err := loadWithLocalKeyLock( + modelCacheLoadLocker, + cacheKey, + func() (*GroupScopeModelConfigsCache, bool, bool) { + return cacheGetModelLocal(cacheKey, cloneGroupScopeModelConfigsCache) + }, + func() (*GroupScopeModelConfigsCache, error) { + configs, err := GetAllGroupScopeModelConfigs(group) + if err != nil { + return nil, err + } + + cache := buildGroupScopeModelConfigsCache(group, configs) + cacheSetModelLocalUnlocked(cacheKey, cache, cloneGroupScopeModelConfigsCache) + + return cache, nil + }, + ) + if err != nil { + return nil, err + } + + if loaded { + if err := cacheSetGroupScopeModelConfigsRedis(cache); err != nil { + log.Error("redis set group scope model configs error: " + err.Error()) + } + } + + return cache, nil +} + +func CacheGetGroupScopeModelConfig(group, modelName string) (ModelConfig, bool) { + cache, err := CacheGetGroupScopeModelConfigs(group) + if err != nil { + return ModelConfig{}, false + } + + config, ok := cache.Configs[modelName] + + return config, ok +} + +func ResolveGroupScopeModelConfig(group, modelName string) (ModelConfig, bool) { + if config.DisableModelConfig { + return NewDefaultModelConfig(modelName), true + } + + modelConfig, ok := CacheGetGroupScopeModelConfig(group, modelName) + if ok { + return modelConfig, true + } + + return ModelConfig{}, false +} + +func buildGroupScopeModelConfigsCache( + group string, + configs []GroupScopeModelConfig, +) *GroupScopeModelConfigsCache { + configMap := make(map[string]ModelConfig, len(configs)) + for _, config := range configs { + configMap[config.Model] = config.ToModelConfig() + } + + return &GroupScopeModelConfigsCache{ + GroupID: group, + Models: groupScopeModelConfigModels(configs), + Configs: configMap, + List: cloneGroupScopeModelConfigs(configs), + } +} + +func cacheSetGroupScopeModelConfigsRedis(cache *GroupScopeModelConfigsCache) error { + if !common.RedisEnabled { + return nil + } + + ctx, cancel := context.WithTimeout(context.Background(), redisTimeout) + defer cancel() + + key := getGroupScopeModelConfigsCacheKey(cache.GroupID) + pipe := common.RDB.Pipeline() + pipe.HSet(ctx, key, "g", cache.GroupID) + pipe.HSet(ctx, key, "m", redisStringSlice(cache.Models)) + + configs, err := sonic.Marshal(redisGroupScopeModelConfigMap(cache.Configs)) + if err != nil { + cancel() + return err + } + + pipe.HSet(ctx, key, "c", configs) + + list, err := sonic.Marshal(redisGroupScopeModelConfigList(cache.List)) + if err != nil { + cancel() + return err + } + + pipe.HSet(ctx, key, "l", list) + + expireTime := SyncFrequency + time.Duration(rand.Int64N(60)-30)*time.Second + pipe.Expire(ctx, key, expireTime) + _, err = pipe.Exec(ctx) + + return err +} + +func scanGroupScopeModelConfigsCache( + ctx context.Context, + key string, + cache *GroupScopeModelConfigsCache, +) error { + values, err := common.RDB.HGetAll(ctx, key).Result() + if err != nil { + return err + } + + if len(values) == 0 { + return redis.Nil + } + + cache.GroupID = values["g"] + if raw := values["m"]; raw != "" { + if err := (*redisStringSlice)(&cache.Models).ScanRedis(raw); err != nil { + return err + } + } + + if raw := values["c"]; raw != "" { + var configs redisGroupScopeModelConfigMap + if err := configs.ScanRedis(raw); err != nil { + return err + } + + cache.Configs = map[string]ModelConfig(configs) + } + + if raw := values["l"]; raw != "" { + var list redisGroupScopeModelConfigList + if err := list.ScanRedis(raw); err != nil { + return err + } + + cache.List = []GroupScopeModelConfig(list) + } + + return nil +} diff --git a/core/model/groupchannel.go b/core/model/groupchannel.go new file mode 100644 index 00000000..a33beed0 --- /dev/null +++ b/core/model/groupchannel.go @@ -0,0 +1,898 @@ +package model + +import ( + "context" + "errors" + "fmt" + "slices" + "strings" + "time" + + "github.com/bytedance/sonic" + "github.com/labring/aiproxy/core/common" + "github.com/labring/aiproxy/core/monitor" + "github.com/labring/aiproxy/core/relay/mode" + log "github.com/sirupsen/logrus" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +const ErrGroupChannelNotFound = "group_channel" + +type ChannelScope string + +const ( + ChannelScopeGlobal ChannelScope = "global" + ChannelScopeGroup ChannelScope = "group" +) + +func ParseChannelScope(value string) ChannelScope { + switch strings.ToLower(strings.TrimSpace(value)) { + case string(ChannelScopeGlobal): + return ChannelScopeGlobal + case string(ChannelScopeGroup), "own", "group-only", "group_only": + return ChannelScopeGroup + default: + return "" + } +} + +func NormalizeChannelScope(scope ChannelScope) ChannelScope { + if scope == "" { + return ChannelScopeGlobal + } + + return scope +} + +func ValidTokenChannelScope(scope ChannelScope) bool { + return scope == "" || scope == ChannelScopeGlobal || scope == ChannelScopeGroup +} + +func GroupChannelMonitorKey(group string, id int) string { + return fmt.Sprintf("group_channel:%s:%d", group, id) +} + +func GroupChannelMonitorPrefix(group string) string { + return fmt.Sprintf("group_channel:%s:", group) +} + +type GroupChannel struct { + DeletedAt gorm.DeletedAt `gorm:"index" json:"-" yaml:"-"` + CreatedAt time.Time `gorm:"index" json:"created_at" yaml:"-"` + LastTestErrorAt time.Time ` json:"last_test_error_at" yaml:"-"` + GroupChannelTests []*GroupChannelTest `gorm:"foreignKey:GroupChannelID;references:ID" json:"group_channel_tests,omitempty" yaml:"-"` + ModelMapping map[string]string `gorm:"serializer:fastjson;type:text" json:"model_mapping" yaml:"model_mapping,omitempty"` + Key string `gorm:"type:text;index:,length:191" json:"key" yaml:"key,omitempty"` + GroupID string `gorm:"size:64;not null;index" json:"group_id" yaml:"group_id,omitempty"` + Name string `gorm:"size:64;index" json:"name" yaml:"name,omitempty"` + BaseURL string `gorm:"size:128;index" json:"base_url" yaml:"base_url,omitempty"` + ProxyURL string `gorm:"size:255" json:"proxy_url" yaml:"proxy_url,omitempty"` + Models []string `gorm:"serializer:fastjson;type:text" json:"models" yaml:"models,omitempty"` + ID int `gorm:"primaryKey" json:"id" yaml:"id,omitempty"` + UsedAmount float64 `gorm:"index" json:"used_amount" yaml:"-"` + RequestCount int `gorm:"index" json:"request_count" yaml:"-"` + RetryCount int `gorm:"index" json:"retry_count" yaml:"-"` + Status int `gorm:"default:1;index" json:"status" yaml:"status,omitempty"` + Type ChannelType `gorm:"default:0;index" json:"type" yaml:"type,omitempty"` + Priority int32 ` json:"priority" yaml:"priority,omitempty"` + SkipTLSVerify bool ` json:"skip_tls_verify" yaml:"skip_tls_verify,omitempty"` + EnabledNoPermissionBan bool ` json:"enabled_no_permission_ban" yaml:"enabled_no_permission_ban,omitempty"` + MaxErrorRate float64 ` json:"max_error_rate" yaml:"max_error_rate,omitempty"` + Configs ChannelConfigs `gorm:"serializer:fastjson;type:text" json:"configs,omitempty" yaml:"configs,omitempty"` + Sets []string `gorm:"serializer:fastjson;type:text" json:"sets,omitempty" yaml:"sets,omitempty"` +} + +func (c *GroupChannel) GetSets() []string { + return NormalizeAvailableSets(c.Sets) +} + +func GroupChannelAccessModels(channel *GroupChannel) []string { + if channel == nil { + return nil + } + + models := cloneStringSlice(channel.Models) + for publicModel := range channel.ModelMapping { + models = append(models, publicModel) + } + + return models +} + +func GroupChannelSupportsModel(channel *GroupChannel, modelName string) bool { + return slices.ContainsFunc(GroupChannelAccessModels(channel), func(item string) bool { + return strings.EqualFold(item, modelName) + }) +} + +func (c *GroupChannel) GetPriority() int32 { + if c.Priority == 0 { + return DefaultPriority + } + + if c.Priority > MaxPriority { + return MaxPriority + } + + return c.Priority +} + +func (c *GroupChannel) BeforeCreate(_ *gorm.DB) error { + if c.GroupID == "" { + return errors.New("group id is required") + } + return nil +} + +func (c *GroupChannel) BeforeUpdate(tx *gorm.DB) error { + if tx.Statement.Changed("GroupID") && c.GroupID == "" { + return errors.New("group id is required") + } + return nil +} + +func (c *GroupChannel) BeforeDelete(tx *gorm.DB) error { + return tx.Model(&GroupChannelTest{}). + Where("group_channel_id = ?", c.ID). + Delete(&GroupChannelTest{}). + Error +} + +func (c *GroupChannel) MarshalJSON() ([]byte, error) { + type Alias GroupChannel + + return sonic.Marshal(&struct { + *Alias + CreatedAt int64 `json:"created_at"` + LastTestErrorAt int64 `json:"last_test_error_at"` + }{ + Alias: (*Alias)(c), + CreatedAt: c.CreatedAt.UnixMilli(), + LastTestErrorAt: c.LastTestErrorAt.UnixMilli(), + }) +} + +func (c *GroupChannel) ToChannel() *Channel { + if c == nil { + return nil + } + + return &Channel{ + DeletedAt: c.DeletedAt, + CreatedAt: c.CreatedAt, + LastTestErrorAt: c.LastTestErrorAt, + ModelMapping: cloneStringStringMap(c.ModelMapping), + Key: c.Key, + Name: c.Name, + BaseURL: c.BaseURL, + ProxyURL: c.ProxyURL, + Models: cloneStringSlice(c.Models), + ID: c.ID, + UsedAmount: c.UsedAmount, + RequestCount: c.RequestCount, + RetryCount: c.RetryCount, + Status: c.Status, + Type: c.Type, + Priority: c.Priority, + SkipTLSVerify: c.SkipTLSVerify, + EnabledNoPermissionBan: c.EnabledNoPermissionBan, + MaxErrorRate: c.MaxErrorRate, + Configs: cloneChannelConfigs(c.Configs), + Sets: cloneStringSlice(c.Sets), + } +} + +func initializeGroupChannelModels(channel *GroupChannel) { + if len(channel.Models) == 0 { + return + } + + models := cloneStringSlice(channel.Models) + slices.Sort(models) + models = slices.Compact(models) + channel.Models = models +} + +func prepareGroupChannel(channel *GroupChannel) { + initializeGroupChannelModels(channel) +} + +func getGroupChannelOrder(order string) string { + prefix, suffix, _ := strings.Cut(order, "-") + switch prefix { + case "name", + "type", + "created_at", + "status", + "used_amount", + "request_count", + "priority", + "id": + switch suffix { + case "asc": + return prefix + " asc" + default: + return prefix + " desc" + } + default: + return "id desc" + } +} + +func buildGroupChannelsQuery( + group string, + id int, + name, key string, + channelType int, + baseURL string, +) *gorm.DB { + tx := DB.Model(&GroupChannel{}) + if group != "" { + tx = tx.Where("group_id = ?", group) + } + + if id != 0 { + tx = tx.Where("id = ?", id) + } + + if name != "" { + tx = tx.Where("name = ?", name) + } + + if key != "" { + tx = tx.Where("key = ?", key) + } + + if channelType != 0 { + tx = tx.Where("type = ?", channelType) + } + + if baseURL != "" { + tx = tx.Where("base_url = ?", baseURL) + } + + return tx +} + +func GetGlobalGroupChannels( + group string, + page, perPage, id int, + name, key string, + channelType int, + baseURL, order string, +) (channels []*GroupChannel, total int64, err error) { + tx := buildGroupChannelsQuery(group, id, name, key, channelType, baseURL) + + err = tx.Count(&total).Error + if err != nil || total <= 0 { + return channels, total, err + } + + limit, offset := toLimitOffset(page, perPage) + err = tx.Order(getGroupChannelOrder(order)).Limit(limit).Offset(offset).Find(&channels).Error + + return channels, total, err +} + +func GetGroupChannels( + group string, + page, perPage, id int, + name, key string, + channelType int, + baseURL, order string, +) (channels []*GroupChannel, total int64, err error) { + if group == "" { + return nil, 0, errors.New("group id is required") + } + + return GetGlobalGroupChannels( + group, + page, + perPage, + id, + name, + key, + channelType, + baseURL, + order, + ) +} + +func SearchGlobalGroupChannels( + group, keyword string, + page, perPage, id int, + name, key string, + channelType int, + baseURL, order string, +) (channels []*GroupChannel, total int64, err error) { + tx := buildGroupChannelsQuery(group, id, name, key, channelType, baseURL) + if keyword != "" { + var ( + conditions []string + values []any + ) + + keywordInt := String2Int(keyword) + if keywordInt != 0 && id == 0 { + conditions = append(conditions, "id = ?") + values = append(values, keywordInt) + } + + if name == "" { + if !common.UsingSQLite { + conditions = append(conditions, "name ILIKE ?") + } else { + conditions = append(conditions, "name LIKE ?") + } + + values = append(values, "%"+keyword+"%") + } + + if key == "" { + if !common.UsingSQLite { + conditions = append(conditions, "key ILIKE ?") + } else { + conditions = append(conditions, "key LIKE ?") + } + + values = append(values, "%"+keyword+"%") + } + + if baseURL == "" { + if !common.UsingSQLite { + conditions = append(conditions, "base_url ILIKE ?") + } else { + conditions = append(conditions, "base_url LIKE ?") + } + + values = append(values, "%"+keyword+"%") + } + + if !common.UsingSQLite { + conditions = append(conditions, "models ILIKE ?", "sets ILIKE ?") + } else { + conditions = append(conditions, "models LIKE ?", "sets LIKE ?") + } + + values = append(values, "%"+keyword+"%", "%"+keyword+"%") + if len(conditions) > 0 { + tx = tx.Where(fmt.Sprintf("(%s)", strings.Join(conditions, " OR ")), values...) + } + } + + err = tx.Count(&total).Error + if err != nil || total <= 0 { + return channels, total, err + } + + limit, offset := toLimitOffset(page, perPage) + err = tx.Order(getGroupChannelOrder(order)).Limit(limit).Offset(offset).Find(&channels).Error + + return channels, total, err +} + +func SearchGroupChannels( + group, keyword string, + page, perPage, id int, + name, key string, + channelType int, + baseURL, order string, +) (channels []*GroupChannel, total int64, err error) { + if group == "" { + return nil, 0, errors.New("group id is required") + } + + return SearchGlobalGroupChannels( + group, + keyword, + page, + perPage, + id, + name, + key, + channelType, + baseURL, + order, + ) +} + +func GetAllGlobalGroupChannels(group string) ([]*GroupChannel, error) { + var channels []*GroupChannel + + tx := DB.Model(&GroupChannel{}) + if group != "" { + tx = tx.Where("group_id = ?", group) + } + + err := tx.Order("id desc").Find(&channels).Error + + return channels, err +} + +func GetAllGroupChannels(group string) ([]*GroupChannel, error) { + if group == "" { + return nil, errors.New("group id is required") + } + + return GetAllGlobalGroupChannels(group) +} + +func prepareGroupChannels(channels []*GroupChannel) { + for _, channel := range channels { + prepareGroupChannel(channel) + } +} + +func LoadGlobalGroupChannels(group string) ([]*GroupChannel, error) { + channels, err := GetAllGlobalGroupChannels(group) + if err != nil { + return nil, err + } + + prepareGroupChannels(channels) + + return channels, nil +} + +func LoadGroupChannels(group string) ([]*GroupChannel, error) { + channels, err := GetAllGroupChannels(group) + if err != nil { + return nil, err + } + + prepareGroupChannels(channels) + + return channels, nil +} + +func LoadEnabledGlobalGroupChannels(group string) ([]*GroupChannel, error) { + var channels []*GroupChannel + + tx := DB.Where("status = ?", ChannelStatusEnabled) + if group != "" { + tx = tx.Where("group_id = ?", group) + } + + err := tx.Find(&channels).Error + if err != nil { + return nil, err + } + + prepareGroupChannels(channels) + + return channels, nil +} + +func LoadEnabledGroupChannels(group string) ([]*GroupChannel, error) { + if group == "" { + return nil, errors.New("group id is required") + } + + return LoadEnabledGlobalGroupChannels(group) +} + +func GetGroupChannelByID(group string, id int) (*GroupChannel, error) { + if group == "" { + return nil, errors.New("group id is required") + } + + channel := GroupChannel{} + err := DB.Where("group_id = ? AND id = ?", group, id).First(&channel).Error + + return &channel, HandleNotFound(err, ErrGroupChannelNotFound) +} + +func GetGlobalGroupChannelByID(id int) (*GroupChannel, error) { + channel := GroupChannel{} + err := DB.Where("id = ?", id).First(&channel).Error + return &channel, HandleNotFound(err, ErrGroupChannelNotFound) +} + +func LoadGroupChannelByID(group string, id int) (*GroupChannel, error) { + channel, err := GetGroupChannelByID(group, id) + if err != nil { + return nil, err + } + + prepareGroupChannel(channel) + + return channel, nil +} + +func LoadGlobalGroupChannelByID(id int) (*GroupChannel, error) { + channel, err := GetGlobalGroupChannelByID(id) + if err != nil { + return nil, err + } + + prepareGroupChannel(channel) + + return channel, nil +} + +type GroupChannelBasicInfo struct { + GroupID string `json:"group_id"` + Name string `json:"name"` + ID int `json:"id"` + Type ChannelType `json:"type"` +} + +func GetGlobalGroupChannelsBasicInfoByIDs(ids []int) ([]GroupChannelBasicInfo, error) { + if len(ids) == 0 { + return nil, nil + } + + var channels []GroupChannelBasicInfo + + err := DB.Model(&GroupChannel{}). + Where("id IN ?", ids). + Select("id", "group_id", "name", "type"). + Order("id desc"). + Find(&channels). + Error + + return channels, err +} + +func GetGroupChannelsBasicInfoByIDs( + group string, + ids []int, +) ([]GroupChannelBasicInfo, error) { + if group == "" { + return nil, errors.New("group id is required") + } + + if len(ids) == 0 { + return nil, nil + } + + var channels []GroupChannelBasicInfo + + err := DB.Model(&GroupChannel{}). + Where("group_id = ? AND id IN ?", group, ids). + Select("id", "group_id", "name", "type"). + Order("id desc"). + Find(&channels). + Error + + return channels, err +} + +func GetGroupChannelTests(group string, id int) ([]*GroupChannelTest, error) { + if group == "" { + return nil, errors.New("group id is required") + } + + var tests []*GroupChannelTest + + err := DB. + Where("group_id = ? AND group_channel_id = ?", group, id). + Order("test_at desc"). + Find(&tests). + Error + + return tests, err +} + +func BatchInsertGroupChannels(channels []*GroupChannel) (err error) { + defer func() { + if err == nil { + groups := map[string]struct{}{} + for _, channel := range channels { + groups[channel.GroupID] = struct{}{} + } + + for group := range groups { + if err := CacheDeleteGroupChannels(group); err != nil { + log.Error("cache delete group channels failed: " + err.Error()) + } + } + } + }() + + return DB.Transaction(func(tx *gorm.DB) error { + return tx.Create(&channels).Error + }) +} + +func UpdateGroupChannel(channel *GroupChannel) (err error) { + defer func() { + if err == nil { + if err := CacheDeleteGroupChannels(channel.GroupID); err != nil { + log.Error("cache delete group channels failed: " + err.Error()) + } + + _ = monitor.ClearGroupChannelAllModelErrorsByKey( + context.Background(), + GroupChannelMonitorKey(channel.GroupID, channel.ID), + ) + } + }() + + selects := []string{ + "model_mapping", + "key", + "base_url", + "proxy_url", + "models", + "priority", + "configs", + "skip_tls_verify", + "enabled_no_permission_ban", + "max_error_rate", + "sets", + } + if channel.Type != 0 { + selects = append(selects, "type") + } + + if channel.Name != "" { + selects = append(selects, "name") + } + + if channel.Status != 0 { + selects = append(selects, "status") + } + + result := DB. + Select(selects). + Clauses(clause.Returning{}). + Where("group_id = ? AND id = ?", channel.GroupID, channel.ID). + Updates(channel) + + return HandleUpdateResult(result, ErrGroupChannelNotFound) +} + +func UpdateGlobalGroupChannel(channel *GroupChannel) error { + if channel.ID == 0 { + return errors.New("group channel id is required") + } + + if channel.GroupID == "" { + existing, err := GetGlobalGroupChannelByID(channel.ID) + if err != nil { + return err + } + + channel.GroupID = existing.GroupID + } + + return UpdateGroupChannel(channel) +} + +func DeleteGroupChannelByID(group string, id int) (err error) { + defer func() { + if err == nil { + if err := CacheDeleteGroupChannels(group); err != nil { + log.Error("cache delete group channels failed: " + err.Error()) + } + + _ = monitor.ClearGroupChannelAllModelErrorsByKey( + context.Background(), + GroupChannelMonitorKey(group, id), + ) + } + }() + + result := DB.Where("group_id = ?", group).Delete(&GroupChannel{ID: id}) + + return HandleUpdateResult(result, ErrGroupChannelNotFound) +} + +func DeleteGlobalGroupChannelByID(id int) error { + channel, err := GetGlobalGroupChannelByID(id) + if err != nil { + return err + } + + return DeleteGroupChannelByID(channel.GroupID, id) +} + +func DeleteGroupChannelsByIDs(group string, ids []int) (err error) { + if len(ids) == 0 { + return nil + } + + defer func() { + if err == nil { + if err := CacheDeleteGroupChannels(group); err != nil { + log.Error("cache delete group channels failed: " + err.Error()) + } + + for _, id := range ids { + _ = monitor.ClearGroupChannelAllModelErrorsByKey( + context.Background(), + GroupChannelMonitorKey(group, id), + ) + } + } + }() + + return DB.Transaction(func(tx *gorm.DB) error { + if err := tx. + Where("group_id = ? AND group_channel_id IN ?", group, ids). + Delete(&GroupChannelTest{}).Error; err != nil { + return err + } + + return tx.Where("group_id = ? AND id IN ?", group, ids).Delete(&GroupChannel{}).Error + }) +} + +func DeleteGlobalGroupChannelsByIDs(ids []int) (err error) { + if len(ids) == 0 { + return nil + } + + channels, err := GetGlobalGroupChannelsBasicInfoByIDs(ids) + if err != nil { + return err + } + + idsByGroup := make(map[string][]int, len(channels)) + for _, channel := range channels { + idsByGroup[channel.GroupID] = append(idsByGroup[channel.GroupID], channel.ID) + } + + defer func() { + if err != nil { + return + } + + for group, groupIDs := range idsByGroup { + if err := CacheDeleteGroupChannels(group); err != nil { + log.Error("cache delete group channels failed: " + err.Error()) + } + + for _, id := range groupIDs { + _ = monitor.ClearGroupChannelAllModelErrorsByKey( + context.Background(), + GroupChannelMonitorKey(group, id), + ) + } + } + }() + + return DB.Transaction(func(tx *gorm.DB) error { + for group, groupIDs := range idsByGroup { + if err := tx. + Where("group_id = ? AND group_channel_id IN ?", group, groupIDs). + Delete(&GroupChannelTest{}).Error; err != nil { + return err + } + + if err := tx.Where("group_id = ? AND id IN ?", group, groupIDs). + Delete(&GroupChannel{}).Error; err != nil { + return err + } + } + + return nil + }) +} + +func UpdateGroupChannelStatusByID(group string, id, status int) (err error) { + defer func() { + if err == nil { + if err := CacheDeleteGroupChannels(group); err != nil { + log.Error("cache delete group channels failed: " + err.Error()) + } + + _ = monitor.ClearGroupChannelAllModelErrorsByKey( + context.Background(), + GroupChannelMonitorKey(group, id), + ) + } + }() + + result := DB.Model(&GroupChannel{}). + Where("group_id = ? AND id = ?", group, id). + Update("status", status) + + return HandleUpdateResult(result, ErrGroupChannelNotFound) +} + +func UpdateGlobalGroupChannelStatusByID(id, status int) error { + channel, err := GetGlobalGroupChannelByID(id) + if err != nil { + return err + } + + return UpdateGroupChannelStatusByID(channel.GroupID, id, status) +} + +func UpdateGroupChannelUsedAmount( + group string, + id int, + amount float64, + requestCount, retryCount int, +) (err error) { + defer func() { + if err == nil { + if cacheErr := CacheDeleteGroupChannels(group); cacheErr != nil { + log.Error("cache delete group channels failed: " + cacheErr.Error()) + } + } + }() + + result := DB.Model(&GroupChannel{}). + Where("group_id = ? AND id = ?", group, id). + Updates(func() map[string]any { + updates := map[string]any{ + "request_count": gorm.Expr("request_count + ?", requestCount), + "retry_count": gorm.Expr("retry_count + ?", retryCount), + } + if amount > 0 { + updates["used_amount"] = gorm.Expr("used_amount + ?", amount) + } + + return updates + }()) + + return HandleUpdateResult(result, ErrGroupChannelNotFound) +} + +func ClearGroupChannelLastTestErrorAt(group string, id int) error { + result := DB.Model(&GroupChannel{}). + Where("group_id = ? AND id = ?", group, id). + Update("last_test_error_at", gorm.Expr("NULL")) + return HandleUpdateResult(result, ErrGroupChannelNotFound) +} + +func (c *GroupChannel) UpdateModelTest( + testAt time.Time, + model, actualModel string, + mode mode.Mode, + took float64, + success bool, + response string, + code int, +) (*GroupChannelTest, error) { + var ct *GroupChannelTest + + err := DB.Transaction(func(tx *gorm.DB) error { + if !success { + result := tx.Model(&GroupChannel{}). + Where("group_id = ? AND id = ?", c.GroupID, c.ID). + Update("last_test_error_at", testAt) + if err := HandleUpdateResult(result, ErrGroupChannelNotFound); err != nil { + return err + } + } else if !c.LastTestErrorAt.IsZero() && time.Since(c.LastTestErrorAt) > time.Hour { + result := tx.Model(&GroupChannel{}). + Where("group_id = ? AND id = ?", c.GroupID, c.ID). + Update("last_test_error_at", gorm.Expr("NULL")) + if err := HandleUpdateResult(result, ErrGroupChannelNotFound); err != nil { + return err + } + } + + ct = &GroupChannelTest{ + GroupID: c.GroupID, + GroupChannelID: c.ID, + ChannelType: c.Type, + ChannelName: c.Name, + Model: model, + ActualModel: actualModel, + Mode: mode, + TestAt: testAt, + Took: took, + Success: success, + Response: response, + Code: code, + } + result := tx.Save(ct) + + return HandleUpdateResult(result, ErrGroupChannelNotFound) + }) + if err != nil { + return nil, err + } + + if err := CacheDeleteGroupChannels(c.GroupID); err != nil { + log.Error("cache delete group channels failed: " + err.Error()) + } + + return ct, nil +} diff --git a/core/model/groupchannel_cache.go b/core/model/groupchannel_cache.go new file mode 100644 index 00000000..a9c81193 --- /dev/null +++ b/core/model/groupchannel_cache.go @@ -0,0 +1,197 @@ +package model + +import ( + "context" + "errors" + "math/rand/v2" + "time" + + "github.com/bytedance/sonic" + "github.com/labring/aiproxy/core/common" + "github.com/redis/go-redis/v9" + log "github.com/sirupsen/logrus" +) + +const GroupChannelsCacheKey = "group_channels:%s" + +func getGroupChannelsCacheKey(group string) string { + return common.RedisKeyf(GroupChannelsCacheKey, group) +} + +type GroupChannelsCache struct { + GroupID string `json:"group_id" redis:"g"` + Channels []*GroupChannel `json:"channels" redis:"c"` +} + +type redisGroupChannels []*GroupChannel + +func (r *redisGroupChannels) ScanRedis(value string) error { + return sonic.UnmarshalString(value, r) +} + +func (r redisGroupChannels) MarshalBinary() ([]byte, error) { + return sonic.Marshal(r) +} + +func cloneGroupChannel(channel *GroupChannel) *GroupChannel { + if channel == nil { + return nil + } + + cloned := *channel + cloned.ModelMapping = cloneStringStringMap(channel.ModelMapping) + cloned.Models = cloneStringSlice(channel.Models) + cloned.Configs = cloneChannelConfigs(channel.Configs) + cloned.Sets = cloneStringSlice(channel.Sets) + + return &cloned +} + +func cloneGroupChannels(channels []*GroupChannel) []*GroupChannel { + if channels == nil { + return nil + } + + cloned := make([]*GroupChannel, len(channels)) + for i, channel := range channels { + cloned[i] = cloneGroupChannel(channel) + } + + return cloned +} + +func cloneGroupChannelsCache(cache *GroupChannelsCache) *GroupChannelsCache { + if cache == nil { + return nil + } + + return &GroupChannelsCache{ + GroupID: cache.GroupID, + Channels: cloneGroupChannels(cache.Channels), + } +} + +func CacheDeleteGroupChannels(group string) error { + cacheDeleteModelLocal(getGroupChannelsCacheKey(group)) + + if !common.RedisEnabled { + return nil + } + + ctx, cancel := context.WithTimeout(context.Background(), redisTimeout) + defer cancel() + + return common.RDB.Del(ctx, getGroupChannelsCacheKey(group)).Err() +} + +func CacheSetGroupChannels(cache *GroupChannelsCache) error { + key := getGroupChannelsCacheKey(cache.GroupID) + cacheSetModelLocal(key, cache, cloneGroupChannelsCache) + return cacheSetGroupChannelsRedis(cache) +} + +func CacheGetGroupChannels(group string) (*GroupChannelsCache, error) { + if group == "" { + return nil, errors.New("group id is required") + } + + cacheKey := getGroupChannelsCacheKey(group) + if cached, _, ok := cacheGetModelLocal(cacheKey, cloneGroupChannelsCache); ok { + return cached, nil + } + + if common.RedisEnabled { + ctx, cancel := context.WithTimeout(context.Background(), redisTimeout) + defer cancel() + + cache := &GroupChannelsCache{} + + err := scanGroupChannelsCache(ctx, cacheKey, cache) + if err == nil && cache.GroupID != "" { + cacheSetModelLocal(cacheKey, cache, cloneGroupChannelsCache) + return cache, nil + } else if err != nil && !errors.Is(err, redis.Nil) { + log.Errorf("get group channels (%s) from redis error: %s", group, err.Error()) + } + } + + cache, _, loaded, err := loadWithLocalKeyLock( + modelCacheLoadLocker, + cacheKey, + func() (*GroupChannelsCache, bool, bool) { + return cacheGetModelLocal(cacheKey, cloneGroupChannelsCache) + }, + func() (*GroupChannelsCache, error) { + channels, err := LoadEnabledGroupChannels(group) + if err != nil { + return nil, err + } + + gc := &GroupChannelsCache{GroupID: group, Channels: channels} + cacheSetModelLocalUnlocked(cacheKey, gc, cloneGroupChannelsCache) + + return gc, nil + }, + ) + if err != nil { + return nil, err + } + + if loaded { + if err := cacheSetGroupChannelsRedis(cache); err != nil { + log.Error("redis set group channels error: " + err.Error()) + } + } + + return cache, nil +} + +func cacheSetGroupChannelsRedis(cache *GroupChannelsCache) error { + if !common.RedisEnabled { + return nil + } + + ctx, cancel := context.WithTimeout(context.Background(), redisTimeout) + defer cancel() + + key := getGroupChannelsCacheKey(cache.GroupID) + pipe := common.RDB.Pipeline() + pipe.HSet(ctx, key, "g", cache.GroupID) + + channels, err := sonic.Marshal(redisGroupChannels(cache.Channels)) + if err != nil { + cancel() + return err + } + + pipe.HSet(ctx, key, "c", channels) + + expireTime := SyncFrequency + time.Duration(rand.Int64N(60)-30)*time.Second + pipe.Expire(ctx, key, expireTime) + _, err = pipe.Exec(ctx) + + return err +} + +func scanGroupChannelsCache(ctx context.Context, key string, cache *GroupChannelsCache) error { + values, err := common.RDB.HGetAll(ctx, key).Result() + if err != nil { + return err + } + + if len(values) == 0 { + return redis.Nil + } + + cache.GroupID = values["g"] + if raw := values["c"]; raw != "" { + var channels redisGroupChannels + if err := channels.ScanRedis(raw); err != nil { + return err + } + + cache.Channels = []*GroupChannel(channels) + } + + return nil +} diff --git a/core/model/groupchannel_log.go b/core/model/groupchannel_log.go new file mode 100644 index 00000000..87181f57 --- /dev/null +++ b/core/model/groupchannel_log.go @@ -0,0 +1,264 @@ +package model + +import ( + "time" + + "github.com/bytedance/sonic" + "github.com/labring/aiproxy/core/common" + "gorm.io/gorm" +) + +type GroupChannelLog struct { + RequestDetail *GroupChannelRequestDetail `gorm:"foreignKey:LogID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE;" json:"request_detail,omitempty"` + RequestAt time.Time ` json:"request_at"` + RetryAt time.Time ` json:"retry_at,omitempty"` + TTFBMilliseconds ZeroNullInt64 ` json:"ttfb_milliseconds,omitempty"` + CreatedAt time.Time `gorm:"autoCreateTime;index" json:"created_at"` + TokenName string `gorm:"size:32" json:"token_name,omitempty"` + Endpoint EmptyNullString `gorm:"size:64" json:"endpoint,omitempty"` + Content EmptyNullString `gorm:"type:text" json:"content,omitempty"` + GroupID string `gorm:"size:64;index" json:"group,omitempty"` + Model string `gorm:"size:128;index" json:"model"` + RequestID EmptyNullString `gorm:"type:char(16);index:,where:request_id is not null" json:"request_id"` + UpstreamID EmptyNullString `gorm:"type:varchar(256)" json:"upstream_id,omitempty"` + AsyncUsageStatus AsyncUsageStatus ` json:"async_usage_status,omitempty"` + ID int `gorm:"primaryKey" json:"id"` + TokenID int `gorm:"index" json:"token_id,omitempty"` + GroupChannelID int `gorm:"index" json:"group_channel_id,omitempty"` + Code int `gorm:"index" json:"code,omitempty"` + Mode int ` json:"mode,omitempty"` + IP EmptyNullString `gorm:"size:45;index:,where:ip is not null" json:"ip,omitempty"` + RetryTimes ZeroNullInt64 ` json:"retry_times,omitempty"` + Price Price `gorm:"embedded" json:"price,omitempty"` + Usage Usage `gorm:"embedded" json:"usage,omitempty"` + UsageContext UsageContext `gorm:"embedded" json:"usage_context,omitempty"` + Amount Amount `gorm:"embedded" json:"amount,omitempty"` + PromptCacheKey EmptyNullString `gorm:"type:text" json:"prompt_cache_key,omitempty"` + User EmptyNullString `gorm:"type:text" json:"user,omitempty"` + Metadata map[string]string `gorm:"serializer:fastjson;type:text" json:"metadata,omitempty"` +} + +type GroupChannelRequestDetail struct { + CreatedAt time.Time `gorm:"autoCreateTime;index" json:"-"` + RequestBody string `gorm:"type:text" json:"request_body,omitempty"` + ResponseBody string `gorm:"type:text" json:"response_body,omitempty"` + RequestBodyTruncated bool ` json:"request_body_truncated,omitempty"` + ResponseBodyTruncated bool ` json:"response_body_truncated,omitempty"` + ID int `gorm:"primaryKey" json:"id"` + LogID int `gorm:"index" json:"log_id"` +} + +type GroupChannelRetryLog struct { + RequestBody string `gorm:"type:text" json:"request_body,omitempty"` + ResponseBody string `gorm:"type:text" json:"response_body,omitempty"` + RequestBodyTruncated bool ` json:"request_body_truncated,omitempty"` + ResponseBodyTruncated bool ` json:"response_body_truncated,omitempty"` + RequestAt time.Time ` json:"request_at"` + RetryAt time.Time ` json:"retry_at,omitempty"` + TTFBMilliseconds ZeroNullInt64 ` json:"ttfb_milliseconds,omitempty"` + CreatedAt time.Time `gorm:"autoCreateTime;index" json:"created_at"` + GroupID string `gorm:"size:64;index" json:"group,omitempty"` + Model string `gorm:"size:128" json:"model"` + RequestID EmptyNullString `gorm:"type:char(16);index:,where:request_id is not null" json:"request_id"` + ID int `gorm:"primaryKey" json:"id"` + GroupChannelID int ` json:"group_channel_id,omitempty"` + Code int `gorm:"index" json:"code,omitempty"` + Mode int ` json:"mode,omitempty"` + RetryTimes ZeroNullInt64 ` json:"retry_times,omitempty"` +} + +func (l *GroupChannelLog) MarshalJSON() ([]byte, error) { + type Alias GroupChannelLog + + a := &struct { + *Alias + CreatedAt int64 `json:"created_at"` + RequestAt int64 `json:"request_at"` + RetryAt int64 `json:"retry_at,omitempty"` + UsedAmount float64 `json:"used_amount,omitempty"` + }{ + Alias: (*Alias)(l), + CreatedAt: l.CreatedAt.UnixMilli(), + RequestAt: l.RequestAt.UnixMilli(), + UsedAmount: l.Amount.UsedAmount, + } + if !l.RetryAt.IsZero() { + a.RetryAt = l.RetryAt.UnixMilli() + } + + return sonic.Marshal(a) +} + +func newGroupChannelRequestDetail(detail *RequestDetail) *GroupChannelRequestDetail { + if detail == nil { + return nil + } + + return &GroupChannelRequestDetail{ + CreatedAt: detail.CreatedAt, + RequestBody: detail.RequestBody, + ResponseBody: detail.ResponseBody, + RequestBodyTruncated: detail.RequestBodyTruncated, + ResponseBodyTruncated: detail.ResponseBodyTruncated, + } +} + +func CreateGroupChannelLogIndexes(db *gorm.DB) error { + indexes := []string{ + "CREATE INDEX IF NOT EXISTS idx_group_channel_logs_group_creat ON group_channel_logs (group_id, created_at DESC)", + "CREATE INDEX IF NOT EXISTS idx_group_channel_logs_channel_creat ON group_channel_logs (group_id, group_channel_id, created_at DESC)", + "CREATE INDEX IF NOT EXISTS idx_group_channel_logs_model_creat ON group_channel_logs (group_id, model, created_at DESC)", + } + + for _, index := range indexes { + if err := db.Exec(index).Error; err != nil { + return err + } + } + + return nil +} + +func (l *GroupChannelLog) BeforeCreate(_ *gorm.DB) error { + if len(l.Content) > contentMaxSize { + l.Content = EmptyNullString( + common.TruncateByRune(string(l.Content), contentMaxSize) + "...", + ) + } + + if l.CreatedAt.IsZero() { + l.CreatedAt = time.Now() + } + + if l.RequestAt.IsZero() { + l.RequestAt = l.CreatedAt + } + + return nil +} + +func RecordGroupChannelRetryLog( + requestID string, + createAt time.Time, + requestAt time.Time, + retryAt time.Time, + firstByteAt time.Time, + group string, + code int, + groupChannelID int, + modelName string, + mode int, + retryTimes int, + requestDetail *RequestDetail, +) error { + if createAt.IsZero() { + createAt = time.Now() + } + + if requestAt.IsZero() { + requestAt = createAt + } + + if firstByteAt.IsZero() || firstByteAt.Before(requestAt) { + firstByteAt = requestAt + } + + log := &GroupChannelRetryLog{ + RequestID: EmptyNullString(requestID), + RequestAt: requestAt, + CreatedAt: createAt, + RetryAt: retryAt, + TTFBMilliseconds: ZeroNullInt64(firstByteAt.Sub(requestAt).Milliseconds()), + GroupID: group, + Code: code, + Model: modelName, + Mode: mode, + GroupChannelID: groupChannelID, + RetryTimes: ZeroNullInt64(retryTimes), + } + if requestDetail != nil { + log.RequestBody = requestDetail.RequestBody + log.ResponseBody = requestDetail.ResponseBody + log.RequestBodyTruncated = requestDetail.RequestBodyTruncated + log.ResponseBodyTruncated = requestDetail.ResponseBodyTruncated + } + + return LogDB.Create(log).Error +} + +func RecordGroupChannelConsumeLog( + requestID string, + createAt time.Time, + requestAt time.Time, + retryAt time.Time, + firstByteAt time.Time, + group string, + code int, + groupChannelID int, + modelName string, + tokenID int, + tokenName string, + endpoint string, + content string, + mode int, + ip string, + retryTimes int, + requestDetail *RequestDetail, + usage Usage, + usageContext UsageContext, + modelPrice Price, + amountDetail Amount, + user string, + metadata map[string]string, + promptCacheKey string, + upstreamID string, + asyncUsageStatus AsyncUsageStatus, +) error { + if createAt.IsZero() { + createAt = time.Now() + } + + if requestAt.IsZero() { + requestAt = createAt + } + + if firstByteAt.IsZero() || firstByteAt.Before(requestAt) { + firstByteAt = requestAt + } + + const maxUpstreamIDLength = 256 + if len(upstreamID) > maxUpstreamIDLength { + upstreamID = upstreamID[:maxUpstreamIDLength] + } + + log := &GroupChannelLog{ + RequestID: EmptyNullString(requestID), + RequestAt: requestAt, + CreatedAt: createAt, + RetryAt: retryAt, + TTFBMilliseconds: ZeroNullInt64(firstByteAt.Sub(requestAt).Milliseconds()), + GroupID: group, + Code: code, + TokenID: tokenID, + TokenName: tokenName, + Model: modelName, + Mode: mode, + IP: EmptyNullString(ip), + GroupChannelID: groupChannelID, + Endpoint: EmptyNullString(endpoint), + Content: EmptyNullString(content), + RetryTimes: ZeroNullInt64(retryTimes), + RequestDetail: newGroupChannelRequestDetail(requestDetail), + Price: modelPrice, + Usage: usage, + UsageContext: usageContext, + Amount: amountDetail, + User: EmptyNullString(user), + Metadata: metadata, + PromptCacheKey: EmptyNullString(promptCacheKey), + UpstreamID: EmptyNullString(upstreamID), + AsyncUsageStatus: asyncUsageStatus, + } + + return LogDB.Create(log).Error +} diff --git a/core/model/groupchannel_summary.go b/core/model/groupchannel_summary.go new file mode 100644 index 00000000..6b3da91c --- /dev/null +++ b/core/model/groupchannel_summary.go @@ -0,0 +1,1121 @@ +package model + +import ( + "cmp" + "errors" + "slices" + "time" + + "golang.org/x/sync/errgroup" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +type GroupChannelSummary struct { + ID int `gorm:"primaryKey"` + Unique GroupChannelSummaryUnique `gorm:"embedded"` + Data SummaryData `gorm:"embedded"` +} + +type GroupChannelSummaryUnique struct { + GroupID string `gorm:"size:64;not null;uniqueIndex:idx_group_channel_summary_unique,priority:1"` + GroupChannelID int `gorm:"not null;uniqueIndex:idx_group_channel_summary_unique,priority:2"` + Model string `gorm:"size:128;not null;uniqueIndex:idx_group_channel_summary_unique,priority:3"` + HourTimestamp int64 `gorm:"not null;uniqueIndex:idx_group_channel_summary_unique,priority:4,sort:desc"` +} + +func (l *GroupChannelSummary) BeforeCreate(_ *gorm.DB) error { + if l.Unique.GroupID == "" { + return errors.New("group id is required") + } + + if l.Unique.GroupChannelID == 0 { + return errors.New("group channel id is required") + } + + if l.Unique.Model == "" { + return errors.New("model is required") + } + + if l.Unique.HourTimestamp == 0 { + return errors.New("hour timestamp is required") + } + + return validateHourTimestamp(l.Unique.HourTimestamp) +} + +func CreateGroupChannelSummaryIndexs(db *gorm.DB) error { + indexes := []string{ + "CREATE INDEX IF NOT EXISTS idx_group_channel_summary_group_hour ON group_channel_summaries (group_id, hour_timestamp DESC)", + "CREATE INDEX IF NOT EXISTS idx_group_channel_summary_channel_hour ON group_channel_summaries (group_id, group_channel_id, hour_timestamp DESC)", + "CREATE INDEX IF NOT EXISTS idx_group_channel_summary_model_hour ON group_channel_summaries (group_id, model, hour_timestamp DESC)", + } + + for _, index := range indexes { + if err := db.Exec(index).Error; err != nil { + return err + } + } + + return nil +} + +func UpsertGroupChannelSummary(unique GroupChannelSummaryUnique, data SummaryData) error { + if err := validateHourTimestamp(unique.HourTimestamp); err != nil { + return err + } + + var err error + for range 3 { + result := LogDB. + Model(&GroupChannelSummary{}). + Where( + "group_id = ? AND group_channel_id = ? AND model = ? AND hour_timestamp = ?", + unique.GroupID, + unique.GroupChannelID, + unique.Model, + unique.HourTimestamp, + ). + Updates(data.buildUpdateData("group_channel_summaries")) + + if result.Error != nil { + return result.Error + } + + if result.RowsAffected > 0 { + return nil + } + + err = createGroupChannelSummary(unique, data) + if err == nil { + return nil + } + + if !errors.Is(err, gorm.ErrDuplicatedKey) { + return err + } + } + + return err +} + +func createGroupChannelSummary(unique GroupChannelSummaryUnique, data SummaryData) error { + return LogDB. + Clauses(clause.OnConflict{ + Columns: []clause.Column{ + {Name: "group_id"}, + {Name: "group_channel_id"}, + {Name: "model"}, + {Name: "hour_timestamp"}, + }, + DoUpdates: clause.Assignments(data.buildUpdateData("group_channel_summaries")), + }). + Create(&GroupChannelSummary{Unique: unique, Data: data}).Error +} + +func GetGroupChannelLastRequestTimeMinute(group string, groupChannelID int) (time.Time, error) { + if group == "" { + return time.Time{}, errors.New("group id is required") + } + + if groupChannelID == 0 { + return time.Time{}, errors.New("group channel id is required") + } + + var summary GroupChannelSummaryMinute + + err := LogDB. + Model(&GroupChannelSummaryMinute{}). + Where("group_id = ? AND group_channel_id = ?", group, groupChannelID). + Order("minute_timestamp desc"). + First(&summary).Error + if summary.Unique.MinuteTimestamp == 0 { + return time.Time{}, nil + } + + return time.Unix(summary.Unique.MinuteTimestamp, 0), err +} + +type groupChannelLastRequestTimeRow struct { + GroupChannelID int + LastRequestAt int64 +} + +func GetGroupChannelLastRequestTimesMinute( + group string, + groupChannelIDs []int, +) (map[int]time.Time, error) { + if group == "" { + return nil, errors.New("group id is required") + } + + if len(groupChannelIDs) == 0 { + return map[int]time.Time{}, nil + } + + rows := make([]groupChannelLastRequestTimeRow, 0, len(groupChannelIDs)) + + err := LogDB. + Model(&GroupChannelSummaryMinute{}). + Select("group_channel_id, MAX(minute_timestamp) AS last_request_at"). + Where("group_id = ? AND group_channel_id IN ?", group, groupChannelIDs). + Group("group_channel_id"). + Find(&rows).Error + if err != nil { + return nil, err + } + + result := make(map[int]time.Time, len(rows)) + for _, row := range rows { + if row.LastRequestAt == 0 { + continue + } + + result[row.GroupChannelID] = time.Unix(row.LastRequestAt, 0) + } + + return result, nil +} + +func getGroupChannelChartData( + group string, + groupChannelID int, + modelName string, + start, end time.Time, + timeSpan TimeSpanType, + timezone *time.Location, + fields SummarySelectFields, +) ([]ChartData, error) { + modelName = normalizeSummaryModelFilter(modelName) + + query := LogDB.Model(&GroupChannelSummary{}).Where("group_id = ?", group) + if groupChannelID != 0 { + query = query.Where("group_channel_id = ?", groupChannelID) + } + + if modelName != "" { + query = query.Where("model = ?", modelName) + } + + switch { + case !start.IsZero() && !end.IsZero(): + query = query.Where("hour_timestamp BETWEEN ? AND ?", start.Unix(), end.Unix()) + case !start.IsZero(): + query = query.Where("hour_timestamp >= ?", start.Unix()) + case !end.IsZero(): + query = query.Where("hour_timestamp <= ?", end.Unix()) + } + + var chartData []ChartData + + err := query. + Select(fields.BuildSelectFields("hour_timestamp")). + Group("timestamp"). + Find(&chartData).Error + if err != nil { + return nil, err + } + + if len(chartData) > 0 && timeSpan != TimeSpanHour { + chartData = aggregateDataToSpan(chartData, timeSpan, timezone) + } + + slices.SortFunc(chartData, func(a, b ChartData) int { + return cmp.Compare(a.Timestamp, b.Timestamp) + }) + + return chartData, nil +} + +func GetGroupChannelUsedChannels( + group string, + groupChannelID int, + start, end time.Time, +) ([]int, error) { + if group == "" { + return nil, errors.New("group is required") + } + + return getGroupChannelGroupByValues[int]("group_channel_id", group, groupChannelID, start, end) +} + +func GetGlobalGroupChannelUsedChannels( + group string, + groupChannelID int, + start, end time.Time, +) ([]int, error) { + return getGroupChannelGroupByValues[int]("group_channel_id", group, groupChannelID, start, end) +} + +func GetGroupChannelUsedModels( + group string, + groupChannelID int, + start, end time.Time, +) ([]string, error) { + if group == "" { + return nil, errors.New("group is required") + } + + return getGroupChannelGroupByValues[string]("model", group, groupChannelID, start, end) +} + +func GetGlobalGroupChannelUsedModels( + group string, + groupChannelID int, + start, end time.Time, +) ([]string, error) { + return getGroupChannelGroupByValues[string]("model", group, groupChannelID, start, end) +} + +func getGroupChannelGroupByValues[T cmp.Ordered]( + field string, + group string, + groupChannelID int, + start, end time.Time, +) ([]T, error) { + type Result struct { + Value T + UsedAmount float64 + RequestCount int64 + } + + query := LogDB.Model(&GroupChannelSummary{}) + if group != "" { + query = query.Where("group_id = ?", group) + } + + if groupChannelID != 0 { + query = query.Where("group_channel_id = ?", groupChannelID) + } + + switch { + case !start.IsZero() && !end.IsZero(): + query = query.Where("hour_timestamp BETWEEN ? AND ?", start.Unix(), end.Unix()) + case !start.IsZero(): + query = query.Where("hour_timestamp >= ?", start.Unix()) + case !end.IsZero(): + query = query.Where("hour_timestamp <= ?", end.Unix()) + } + + var results []Result + + err := query. + Select( + field + " as value, SUM(request_count) as request_count, SUM(used_amount) as used_amount", + ). + Group(field). + Find(&results).Error + if err != nil { + return nil, err + } + + slices.SortFunc(results, func(a, b Result) int { + if a.UsedAmount != b.UsedAmount { + return cmp.Compare(b.UsedAmount, a.UsedAmount) + } + + if a.RequestCount != b.RequestCount { + return cmp.Compare(b.RequestCount, a.RequestCount) + } + + return cmp.Compare(a.Value, b.Value) + }) + + values := make([]T, len(results)) + for i, result := range results { + values[i] = result.Value + } + + return values, nil +} + +func GetGroupChannelDashboardData( + group string, + groupChannelID int, + modelName string, + start, end time.Time, + timeSpan TimeSpanType, + timezone *time.Location, + fields SummarySelectFields, +) (*DashboardResponse, error) { + if group == "" { + return nil, errors.New("group is required") + } + + if timeSpan == TimeSpanMinute { + return getGroupChannelDashboardDataMinute( + group, + groupChannelID, + modelName, + start, + end, + timeSpan, + timezone, + fields, + ) + } + + if end.IsZero() { + end = time.Now() + } else if end.Before(start) { + return nil, errors.New("end time is before start time") + } + + var ( + chartData []ChartData + channels []int + models []string + ) + + g := new(errgroup.Group) + + g.Go(func() error { + var err error + + chartData, err = getGroupChannelChartData( + group, + groupChannelID, + modelName, + start, + end, + timeSpan, + timezone, + fields, + ) + + return err + }) + + g.Go(func() error { + var err error + + channels, err = GetGroupChannelUsedChannels(group, groupChannelID, start, end) + return err + }) + + g.Go(func() error { + var err error + + models, err = GetGroupChannelUsedModels(group, groupChannelID, start, end) + return err + }) + + if err := g.Wait(); err != nil { + return nil, err + } + + dashboardResponse := sumDashboardResponse(chartData) + dashboardResponse.Channels = channels + dashboardResponse.Models = models + + return &dashboardResponse, nil +} + +func GetGlobalGroupChannelDashboardData( + group string, + groupChannelID int, + modelName string, + start, end time.Time, + timeSpan TimeSpanType, + timezone *time.Location, + fields SummarySelectFields, +) (*DashboardResponse, error) { + if timeSpan == TimeSpanMinute { + return getGlobalGroupChannelDashboardDataMinute( + group, + groupChannelID, + modelName, + start, + end, + timeSpan, + timezone, + fields, + ) + } + + if end.IsZero() { + end = time.Now() + } else if end.Before(start) { + return nil, errors.New("end time is before start time") + } + + var ( + chartData []ChartData + channels []int + models []string + ) + + g := new(errgroup.Group) + + g.Go(func() error { + var err error + + chartData, err = getGroupChannelChartData( + group, + groupChannelID, + modelName, + start, + end, + timeSpan, + timezone, + fields, + ) + + return err + }) + + g.Go(func() error { + var err error + + channels, err = GetGlobalGroupChannelUsedChannels(group, groupChannelID, start, end) + return err + }) + + g.Go(func() error { + var err error + + models, err = GetGlobalGroupChannelUsedModels(group, groupChannelID, start, end) + return err + }) + + if err := g.Wait(); err != nil { + return nil, err + } + + dashboardResponse := sumDashboardResponse(chartData) + dashboardResponse.Channels = channels + dashboardResponse.Models = models + + return &dashboardResponse, nil +} + +func GetGroupChannelTimeSeriesModelData( + group string, + groupChannelID int, + modelName string, + start, end time.Time, + timeSpan TimeSpanType, + timezone *time.Location, + fields SummarySelectFields, +) ([]TimeSummaryDataV2, error) { + modelName = normalizeSummaryModelFilter(modelName) + + if group == "" { + return nil, errors.New("group is required") + } + + if timeSpan == TimeSpanMinute { + return getGroupChannelTimeSeriesModelDataMinute( + group, + groupChannelID, + modelName, + start, + end, + timeSpan, + timezone, + fields, + ) + } + + if end.IsZero() { + end = time.Now() + } else if end.Before(start) { + return nil, errors.New("end time is before start time") + } + + query := LogDB.Model(&GroupChannelSummary{}).Where("group_id = ?", group) + if groupChannelID != 0 { + query = query.Where("group_channel_id = ?", groupChannelID) + } + + if modelName != "" { + query = query.Where("model = ?", modelName) + } + + switch { + case !start.IsZero() && !end.IsZero(): + query = query.Where("hour_timestamp BETWEEN ? AND ?", start.Unix(), end.Unix()) + case !start.IsZero(): + query = query.Where("hour_timestamp >= ?", start.Unix()) + case !end.IsZero(): + query = query.Where("hour_timestamp <= ?", end.Unix()) + } + + var rawData []SummaryDataV2 + + err := query. + Select(fields.BuildSelectFieldsV2("hour_timestamp", "group_id, group_channel_id, model")). + Group("timestamp, group_id, group_channel_id, model"). + Find(&rawData).Error + if err != nil { + return nil, err + } + + for i := range rawData { + rawData[i].ChannelID = rawData[i].GroupChannelID + } + + if len(rawData) > 0 { + if err := batchFillGroupChannelMaxValues( + rawData, + group, + groupChannelID, + modelName, + start, + end, + ); err != nil { + return nil, err + } + + if timeSpan != TimeSpanHour { + rawData = aggregateGroupChannelDataToSpan(rawData, timeSpan, timezone) + } + } + + result := convertToTimeModelData(rawData) + slices.SortFunc(result, func(a, b TimeSummaryDataV2) int { + return cmp.Compare(a.Timestamp, b.Timestamp) + }) + + return result, nil +} + +func GetGlobalGroupChannelTimeSeriesModelData( + group string, + groupChannelID int, + modelName string, + start, end time.Time, + timeSpan TimeSpanType, + timezone *time.Location, + fields SummarySelectFields, +) ([]TimeSummaryDataV2, error) { + modelName = normalizeSummaryModelFilter(modelName) + + if timeSpan == TimeSpanMinute { + return getGlobalGroupChannelTimeSeriesModelDataMinute( + group, + groupChannelID, + modelName, + start, + end, + timeSpan, + timezone, + fields, + ) + } + + if end.IsZero() { + end = time.Now() + } else if end.Before(start) { + return nil, errors.New("end time is before start time") + } + + query := LogDB.Model(&GroupChannelSummary{}) + if group != "" { + query = query.Where("group_id = ?", group) + } + + if groupChannelID != 0 { + query = query.Where("group_channel_id = ?", groupChannelID) + } + + if modelName != "" { + query = query.Where("model = ?", modelName) + } + + switch { + case !start.IsZero() && !end.IsZero(): + query = query.Where("hour_timestamp BETWEEN ? AND ?", start.Unix(), end.Unix()) + case !start.IsZero(): + query = query.Where("hour_timestamp >= ?", start.Unix()) + case !end.IsZero(): + query = query.Where("hour_timestamp <= ?", end.Unix()) + } + + var rawData []SummaryDataV2 + + err := query. + Select(fields.BuildSelectFieldsV2("hour_timestamp", "group_id, group_channel_id, model")). + Group("timestamp, group_id, group_channel_id, model"). + Find(&rawData).Error + if err != nil { + return nil, err + } + + for i := range rawData { + rawData[i].ChannelID = rawData[i].GroupChannelID + } + + if len(rawData) > 0 { + if err := batchFillGlobalGroupChannelMaxValues( + rawData, + group, + groupChannelID, + modelName, + start, + end, + ); err != nil { + return nil, err + } + + if timeSpan != TimeSpanHour { + rawData = aggregateGroupChannelDataToSpan(rawData, timeSpan, timezone) + } + } + + result := convertToTimeModelData(rawData) + slices.SortFunc(result, func(a, b TimeSummaryDataV2) int { + return cmp.Compare(a.Timestamp, b.Timestamp) + }) + + return result, nil +} + +func aggregateGroupChannelDataToSpan( + data []SummaryDataV2, + timeSpan TimeSpanType, + timezone *time.Location, +) []SummaryDataV2 { + if timezone == nil { + timezone = time.Local + } + + type AggKey struct { + Timestamp int64 + GroupID string + GroupChannelID int + Model string + } + + dataMap := make(map[AggKey]SummaryDataV2) + + for _, item := range data { + t := time.Unix(item.Timestamp, 0).In(timezone) + + key := AggKey{ + GroupID: item.GroupID, + GroupChannelID: item.GroupChannelID, + Model: item.Model, + } + + switch timeSpan { + case TimeSpanMonth: + key.Timestamp = time.Date(t.Year(), t.Month(), 1, 0, 0, 0, 0, timezone).Unix() + case TimeSpanDay: + key.Timestamp = time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, timezone).Unix() + case TimeSpanHour: + key.Timestamp = time.Date(t.Year(), t.Month(), t.Day(), t.Hour(), 0, 0, 0, timezone). + Unix() + case TimeSpanMinute: + fallthrough + default: + key.Timestamp = time.Date( + t.Year(), + t.Month(), + t.Day(), + t.Hour(), + t.Minute(), + 0, + 0, + timezone, + ).Unix() + } + + currentData, exists := dataMap[key] + if !exists { + currentData = SummaryDataV2{ + Timestamp: key.Timestamp, + ChannelID: item.GroupChannelID, + GroupChannelID: item.GroupChannelID, + GroupID: item.GroupID, + Model: item.Model, + } + } + + currentData.Add(item.SummaryDataSet) + currentData.ServiceTierFlex.Add(item.ServiceTierFlex) + currentData.ServiceTierPriority.Add(item.ServiceTierPriority) + currentData.ClaudeLongContext.Add(item.ClaudeLongContext) + + if item.MaxRPM > currentData.MaxRPM { + currentData.MaxRPM = item.MaxRPM + } + + if item.MaxTPM > currentData.MaxTPM { + currentData.MaxTPM = item.MaxTPM + } + + dataMap[key] = currentData + } + + result := make([]SummaryDataV2, 0, len(dataMap)) + for _, item := range dataMap { + result = append(result, item) + } + + return result +} + +func GetGroupChannelDashboardV2Data( + group string, + groupChannelID int, + modelName string, + start, end time.Time, + timeSpan TimeSpanType, + timezone *time.Location, + fields SummarySelectFields, +) (*DashboardV2Response, error) { + if group == "" { + return nil, errors.New("group is required") + } + + var ( + timeSeries []TimeSummaryDataV2 + channels []int + models []string + ) + + g := new(errgroup.Group) + + g.Go(func() error { + var err error + + timeSeries, err = GetGroupChannelTimeSeriesModelData( + group, + groupChannelID, + modelName, + start, + end, + timeSpan, + timezone, + fields, + ) + + return err + }) + + g.Go(func() error { + var err error + + channels, err = GetGroupChannelUsedChannels(group, groupChannelID, start, end) + return err + }) + + g.Go(func() error { + var err error + + models, err = GetGroupChannelUsedModels(group, groupChannelID, start, end) + return err + }) + + if err := g.Wait(); err != nil { + return nil, err + } + + return &DashboardV2Response{ + TimeSeries: timeSeries, + Channels: channels, + Models: models, + }, nil +} + +func GetGlobalGroupChannelDashboardV2Data( + group string, + groupChannelID int, + modelName string, + start, end time.Time, + timeSpan TimeSpanType, + timezone *time.Location, + fields SummarySelectFields, +) (*DashboardV2Response, error) { + var ( + timeSeries []TimeSummaryDataV2 + channels []int + models []string + ) + + g := new(errgroup.Group) + + g.Go(func() error { + var err error + + timeSeries, err = GetGlobalGroupChannelTimeSeriesModelData( + group, + groupChannelID, + modelName, + start, + end, + timeSpan, + timezone, + fields, + ) + + return err + }) + + g.Go(func() error { + var err error + + channels, err = GetGlobalGroupChannelUsedChannels(group, groupChannelID, start, end) + return err + }) + + g.Go(func() error { + var err error + + models, err = GetGlobalGroupChannelUsedModels(group, groupChannelID, start, end) + return err + }) + + if err := g.Wait(); err != nil { + return nil, err + } + + return &DashboardV2Response{ + TimeSeries: timeSeries, + Channels: channels, + Models: models, + }, nil +} + +func GetGroupChannelDashboardV3Data( + group string, + groupChannelID int, + modelName string, + start, end time.Time, + timeSpan TimeSpanType, + timezone *time.Location, + fields SummarySelectFields, +) (*DashboardV3Response, error) { + return GetGroupChannelDashboardV2Data( + group, + groupChannelID, + modelName, + start, + end, + timeSpan, + timezone, + fields, + ) +} + +func GetGlobalGroupChannelDashboardV3Data( + group string, + groupChannelID int, + modelName string, + start, end time.Time, + timeSpan TimeSpanType, + timezone *time.Location, + fields SummarySelectFields, +) (*DashboardV3Response, error) { + return GetGlobalGroupChannelDashboardV2Data( + group, + groupChannelID, + modelName, + start, + end, + timeSpan, + timezone, + fields, + ) +} + +func batchFillGroupChannelMaxValues( + rawData []SummaryDataV2, + group string, + groupChannelID int, + modelName string, + start, end time.Time, +) error { + modelName = normalizeSummaryModelFilter(modelName) + + minuteQuery := LogDB.Model(&GroupChannelSummaryMinute{}).Where("group_id = ?", group) + if groupChannelID != 0 { + minuteQuery = minuteQuery.Where("group_channel_id = ?", groupChannelID) + } + + if modelName != "" { + minuteQuery = minuteQuery.Where("model = ?", modelName) + } + + minuteStart := start.Unix() + + minuteEnd := end.Unix() + if end.IsZero() { + minuteEnd = time.Now().Unix() + } + + minuteQuery = minuteQuery.Where( + "minute_timestamp >= ? AND minute_timestamp <= ?", + minuteStart, + minuteEnd, + ) + + type MaxResult struct { + HourTimestamp int64 `json:"hour_timestamp"` + GroupID string `json:"group_id"` + GroupChannelID int `json:"group_channel_id"` + Model string `json:"model"` + MaxRPM int64 `json:"max_rpm"` + MaxTPM int64 `json:"max_tpm"` + } + + var maxResults []MaxResult + + err := minuteQuery. + Select(` + (minute_timestamp - minute_timestamp % 3600) as hour_timestamp, + group_id, + group_channel_id, + model, + MAX(request_count) as max_rpm, + MAX(total_tokens) as max_tpm + `). + Group("hour_timestamp, group_id, group_channel_id, model"). + Find(&maxResults).Error + if err != nil { + return err + } + + type Key struct { + HourTimestamp int64 + GroupID string + GroupChannelID int + Model string + } + + maxMap := make(map[Key]MaxResult) + for _, result := range maxResults { + key := Key{ + HourTimestamp: result.HourTimestamp, + GroupID: result.GroupID, + GroupChannelID: result.GroupChannelID, + Model: result.Model, + } + maxMap[key] = result + } + + for i := range rawData { + data := &rawData[i] + + groupChannelID := data.GroupChannelID + if groupChannelID == 0 { + groupChannelID = data.ChannelID + } + + key := Key{ + HourTimestamp: data.Timestamp, + GroupID: data.GroupID, + GroupChannelID: groupChannelID, + Model: data.Model, + } + if maxResult, exists := maxMap[key]; exists { + data.MaxRPM = maxResult.MaxRPM + data.MaxTPM = maxResult.MaxTPM + } + } + + return nil +} + +func batchFillGlobalGroupChannelMaxValues( + rawData []SummaryDataV2, + group string, + groupChannelID int, + modelName string, + start, end time.Time, +) error { + modelName = normalizeSummaryModelFilter(modelName) + + minuteQuery := LogDB.Model(&GroupChannelSummaryMinute{}) + if group != "" { + minuteQuery = minuteQuery.Where("group_id = ?", group) + } + + if groupChannelID != 0 { + minuteQuery = minuteQuery.Where("group_channel_id = ?", groupChannelID) + } + + if modelName != "" { + minuteQuery = minuteQuery.Where("model = ?", modelName) + } + + minuteStart := start.Unix() + + minuteEnd := end.Unix() + if end.IsZero() { + minuteEnd = time.Now().Unix() + } + + minuteQuery = minuteQuery.Where( + "minute_timestamp >= ? AND minute_timestamp <= ?", + minuteStart, + minuteEnd, + ) + + type MaxResult struct { + HourTimestamp int64 `json:"hour_timestamp"` + GroupID string `json:"group_id"` + GroupChannelID int `json:"group_channel_id"` + Model string `json:"model"` + MaxRPM int64 `json:"max_rpm"` + MaxTPM int64 `json:"max_tpm"` + } + + var maxResults []MaxResult + + err := minuteQuery. + Select(` + (minute_timestamp - minute_timestamp % 3600) as hour_timestamp, + group_id, + group_channel_id, + model, + MAX(request_count) as max_rpm, + MAX(total_tokens) as max_tpm + `). + Group("hour_timestamp, group_id, group_channel_id, model"). + Find(&maxResults).Error + if err != nil { + return err + } + + type Key struct { + HourTimestamp int64 + GroupID string + GroupChannelID int + Model string + } + + maxMap := make(map[Key]MaxResult) + for _, result := range maxResults { + key := Key{ + HourTimestamp: result.HourTimestamp, + GroupID: result.GroupID, + GroupChannelID: result.GroupChannelID, + Model: result.Model, + } + maxMap[key] = result + } + + for i := range rawData { + data := &rawData[i] + + groupChannelID := data.GroupChannelID + if groupChannelID == 0 { + groupChannelID = data.ChannelID + } + + key := Key{ + HourTimestamp: data.Timestamp, + GroupID: data.GroupID, + GroupChannelID: groupChannelID, + Model: data.Model, + } + if maxResult, exists := maxMap[key]; exists { + data.MaxRPM = maxResult.MaxRPM + data.MaxTPM = maxResult.MaxTPM + } + } + + return nil +} diff --git a/core/model/groupchannel_summary_minute.go b/core/model/groupchannel_summary_minute.go new file mode 100644 index 00000000..68c49315 --- /dev/null +++ b/core/model/groupchannel_summary_minute.go @@ -0,0 +1,523 @@ +package model + +import ( + "cmp" + "errors" + "slices" + "time" + + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +type GroupChannelSummaryMinute struct { + ID int `gorm:"primaryKey"` + Unique GroupChannelSummaryMinuteUnique `gorm:"embedded"` + Data SummaryData `gorm:"embedded"` +} + +type GroupChannelSummaryMinuteUnique struct { + GroupID string `gorm:"size:64;not null;uniqueIndex:idx_group_channel_summary_minute_unique,priority:1"` + GroupChannelID int `gorm:"not null;uniqueIndex:idx_group_channel_summary_minute_unique,priority:2"` + Model string `gorm:"size:128;not null;uniqueIndex:idx_group_channel_summary_minute_unique,priority:3"` + MinuteTimestamp int64 `gorm:"not null;uniqueIndex:idx_group_channel_summary_minute_unique,priority:4,sort:desc"` +} + +func (l *GroupChannelSummaryMinute) BeforeCreate(_ *gorm.DB) error { + if l.Unique.GroupID == "" { + return errors.New("group id is required") + } + + if l.Unique.GroupChannelID == 0 { + return errors.New("group channel id is required") + } + + if l.Unique.Model == "" { + return errors.New("model is required") + } + + if l.Unique.MinuteTimestamp == 0 { + return errors.New("minute timestamp is required") + } + + return validateMinuteTimestamp(l.Unique.MinuteTimestamp) +} + +func CreateGroupChannelSummaryMinuteIndexs(db *gorm.DB) error { + indexes := []string{ + "CREATE INDEX IF NOT EXISTS idx_group_channel_summary_minute_group_minute ON group_channel_summary_minutes (group_id, minute_timestamp DESC)", + "CREATE INDEX IF NOT EXISTS idx_group_channel_summary_minute_channel_minute ON group_channel_summary_minutes (group_id, group_channel_id, minute_timestamp DESC)", + "CREATE INDEX IF NOT EXISTS idx_group_channel_summary_minute_model_minute ON group_channel_summary_minutes (group_id, model, minute_timestamp DESC)", + } + + for _, index := range indexes { + if err := db.Exec(index).Error; err != nil { + return err + } + } + + return nil +} + +func UpsertGroupChannelSummaryMinute( + unique GroupChannelSummaryMinuteUnique, + data SummaryData, +) error { + if err := validateMinuteTimestamp(unique.MinuteTimestamp); err != nil { + return err + } + + var err error + for range 3 { + result := LogDB. + Model(&GroupChannelSummaryMinute{}). + Where( + "group_id = ? AND group_channel_id = ? AND model = ? AND minute_timestamp = ?", + unique.GroupID, + unique.GroupChannelID, + unique.Model, + unique.MinuteTimestamp, + ). + Updates(data.buildUpdateData("group_channel_summary_minutes")) + if result.Error != nil { + return result.Error + } + + if result.RowsAffected > 0 { + return nil + } + + err = createGroupChannelSummaryMinute(unique, data) + if err == nil { + return nil + } + + if !errors.Is(err, gorm.ErrDuplicatedKey) { + return err + } + } + + return err +} + +func createGroupChannelSummaryMinute( + unique GroupChannelSummaryMinuteUnique, + data SummaryData, +) error { + return LogDB. + Clauses(clause.OnConflict{ + Columns: []clause.Column{ + {Name: "group_id"}, + {Name: "group_channel_id"}, + {Name: "model"}, + {Name: "minute_timestamp"}, + }, + DoUpdates: clause.Assignments(data.buildUpdateData("group_channel_summary_minutes")), + }). + Create(&GroupChannelSummaryMinute{Unique: unique, Data: data}).Error +} + +func getGroupChannelChartDataMinute( + group string, + groupChannelID int, + modelName string, + start, end time.Time, + timeSpan TimeSpanType, + timezone *time.Location, + fields SummarySelectFields, +) ([]ChartData, error) { + modelName = normalizeSummaryModelFilter(modelName) + + query := LogDB.Model(&GroupChannelSummaryMinute{}).Where("group_id = ?", group) + if groupChannelID != 0 { + query = query.Where("group_channel_id = ?", groupChannelID) + } + + if modelName != "" { + query = query.Where("model = ?", modelName) + } + + switch { + case !start.IsZero() && !end.IsZero(): + query = query.Where("minute_timestamp BETWEEN ? AND ?", start.Unix(), end.Unix()) + case !start.IsZero(): + query = query.Where("minute_timestamp >= ?", start.Unix()) + case !end.IsZero(): + query = query.Where("minute_timestamp <= ?", end.Unix()) + } + + var chartData []ChartData + + err := query.Select(fields.BuildSelectFields("minute_timestamp")). + Group("timestamp"). + Find(&chartData).Error + if err != nil { + return nil, err + } + + if len(chartData) > 0 && timeSpan != TimeSpanMinute { + chartData = aggregateDataToSpan(chartData, timeSpan, timezone) + } + + slices.SortFunc(chartData, func(a, b ChartData) int { + return cmp.Compare(a.Timestamp, b.Timestamp) + }) + + return chartData, nil +} + +func GetGroupChannelUsedModelsMinute( + group string, + groupChannelID int, + start, end time.Time, +) ([]string, error) { + if group == "" { + return nil, errors.New("group is required") + } + + return getGroupChannelGroupByValuesMinute[string]("model", group, groupChannelID, start, end) +} + +func GetGlobalGroupChannelUsedModelsMinute( + group string, + groupChannelID int, + start, end time.Time, +) ([]string, error) { + return getGroupChannelGroupByValuesMinute[string]("model", group, groupChannelID, start, end) +} + +func GetGroupChannelUsedChannelsMinute( + group string, + groupChannelID int, + start, end time.Time, +) ([]int, error) { + if group == "" { + return nil, errors.New("group is required") + } + + return getGroupChannelGroupByValuesMinute[int]( + "group_channel_id", + group, + groupChannelID, + start, + end, + ) +} + +func GetGlobalGroupChannelUsedChannelsMinute( + group string, + groupChannelID int, + start, end time.Time, +) ([]int, error) { + return getGroupChannelGroupByValuesMinute[int]( + "group_channel_id", + group, + groupChannelID, + start, + end, + ) +} + +func getGroupChannelGroupByValuesMinute[T cmp.Ordered]( + field string, + group string, + groupChannelID int, + start, end time.Time, +) ([]T, error) { + type Result struct { + Value T + UsedAmount float64 + RequestCount int64 + } + + query := LogDB.Model(&GroupChannelSummaryMinute{}) + if group != "" { + query = query.Where("group_id = ?", group) + } + + if groupChannelID != 0 { + query = query.Where("group_channel_id = ?", groupChannelID) + } + + switch { + case !start.IsZero() && !end.IsZero(): + query = query.Where("minute_timestamp BETWEEN ? AND ?", start.Unix(), end.Unix()) + case !start.IsZero(): + query = query.Where("minute_timestamp >= ?", start.Unix()) + case !end.IsZero(): + query = query.Where("minute_timestamp <= ?", end.Unix()) + } + + var results []Result + + err := query. + Select( + field + " as value, SUM(request_count) as request_count, SUM(used_amount) as used_amount", + ). + Group(field). + Find(&results).Error + if err != nil { + return nil, err + } + + slices.SortFunc(results, func(a, b Result) int { + if a.UsedAmount != b.UsedAmount { + return cmp.Compare(b.UsedAmount, a.UsedAmount) + } + + if a.RequestCount != b.RequestCount { + return cmp.Compare(b.RequestCount, a.RequestCount) + } + + return cmp.Compare(a.Value, b.Value) + }) + + values := make([]T, len(results)) + for i, result := range results { + values[i] = result.Value + } + + return values, nil +} + +func getGroupChannelDashboardDataMinute( + group string, + groupChannelID int, + modelName string, + start, end time.Time, + timeSpan TimeSpanType, + timezone *time.Location, + fields SummarySelectFields, +) (*DashboardResponse, error) { + if group == "" { + return nil, errors.New("group is required") + } + + if end.IsZero() { + end = time.Now() + } else if end.Before(start) { + return nil, errors.New("end time is before start time") + } + + var ( + chartData []ChartData + channels []int + models []string + ) + + chartData, err := getGroupChannelChartDataMinute( + group, + groupChannelID, + modelName, + start, + end, + timeSpan, + timezone, + fields, + ) + if err != nil { + return nil, err + } + + channels, err = GetGroupChannelUsedChannelsMinute(group, groupChannelID, start, end) + if err != nil { + return nil, err + } + + models, err = GetGroupChannelUsedModelsMinute(group, groupChannelID, start, end) + if err != nil { + return nil, err + } + + dashboardResponse := sumDashboardResponse(chartData) + dashboardResponse.Channels = channels + dashboardResponse.Models = models + + return &dashboardResponse, nil +} + +func getGlobalGroupChannelDashboardDataMinute( + group string, + groupChannelID int, + modelName string, + start, end time.Time, + timeSpan TimeSpanType, + timezone *time.Location, + fields SummarySelectFields, +) (*DashboardResponse, error) { + if end.IsZero() { + end = time.Now() + } else if end.Before(start) { + return nil, errors.New("end time is before start time") + } + + var ( + chartData []ChartData + channels []int + models []string + ) + + chartData, err := getGroupChannelChartDataMinute( + group, + groupChannelID, + modelName, + start, + end, + timeSpan, + timezone, + fields, + ) + if err != nil { + return nil, err + } + + channels, err = GetGlobalGroupChannelUsedChannelsMinute(group, groupChannelID, start, end) + if err != nil { + return nil, err + } + + models, err = GetGlobalGroupChannelUsedModelsMinute(group, groupChannelID, start, end) + if err != nil { + return nil, err + } + + dashboardResponse := sumDashboardResponse(chartData) + dashboardResponse.Channels = channels + dashboardResponse.Models = models + + return &dashboardResponse, nil +} + +func getGroupChannelTimeSeriesModelDataMinute( + group string, + groupChannelID int, + modelName string, + start, end time.Time, + timeSpan TimeSpanType, + timezone *time.Location, + fields SummarySelectFields, +) ([]TimeSummaryDataV2, error) { + modelName = normalizeSummaryModelFilter(modelName) + + if group == "" { + return nil, errors.New("group is required") + } + + if end.IsZero() { + end = time.Now() + } else if end.Before(start) { + return nil, errors.New("end time is before start time") + } + + query := LogDB.Model(&GroupChannelSummaryMinute{}).Where("group_id = ?", group) + if groupChannelID != 0 { + query = query.Where("group_channel_id = ?", groupChannelID) + } + + if modelName != "" { + query = query.Where("model = ?", modelName) + } + + switch { + case !start.IsZero() && !end.IsZero(): + query = query.Where("minute_timestamp BETWEEN ? AND ?", start.Unix(), end.Unix()) + case !start.IsZero(): + query = query.Where("minute_timestamp >= ?", start.Unix()) + case !end.IsZero(): + query = query.Where("minute_timestamp <= ?", end.Unix()) + } + + var rawData []SummaryDataV2 + + err := query. + Select(fields.BuildSelectFieldsV2("minute_timestamp", "group_id, group_channel_id, model")). + Group("timestamp, group_id, group_channel_id, model"). + Find(&rawData).Error + if err != nil { + return nil, err + } + + for i, data := range rawData { + rawData[i].ChannelID = data.GroupChannelID + rawData[i].MaxRPM = data.RequestCount + rawData[i].MaxTPM = int64(data.TotalTokens) + } + + if len(rawData) > 0 && timeSpan != TimeSpanMinute { + rawData = aggregateGroupChannelDataToSpan(rawData, timeSpan, timezone) + } + + result := convertToTimeModelData(rawData) + slices.SortFunc(result, func(a, b TimeSummaryDataV2) int { + return cmp.Compare(a.Timestamp, b.Timestamp) + }) + + return result, nil +} + +func getGlobalGroupChannelTimeSeriesModelDataMinute( + group string, + groupChannelID int, + modelName string, + start, end time.Time, + timeSpan TimeSpanType, + timezone *time.Location, + fields SummarySelectFields, +) ([]TimeSummaryDataV2, error) { + modelName = normalizeSummaryModelFilter(modelName) + + if end.IsZero() { + end = time.Now() + } else if end.Before(start) { + return nil, errors.New("end time is before start time") + } + + query := LogDB.Model(&GroupChannelSummaryMinute{}) + if group != "" { + query = query.Where("group_id = ?", group) + } + + if groupChannelID != 0 { + query = query.Where("group_channel_id = ?", groupChannelID) + } + + if modelName != "" { + query = query.Where("model = ?", modelName) + } + + switch { + case !start.IsZero() && !end.IsZero(): + query = query.Where("minute_timestamp BETWEEN ? AND ?", start.Unix(), end.Unix()) + case !start.IsZero(): + query = query.Where("minute_timestamp >= ?", start.Unix()) + case !end.IsZero(): + query = query.Where("minute_timestamp <= ?", end.Unix()) + } + + var rawData []SummaryDataV2 + + err := query. + Select(fields.BuildSelectFieldsV2("minute_timestamp", "group_id, group_channel_id, model")). + Group("timestamp, group_id, group_channel_id, model"). + Find(&rawData).Error + if err != nil { + return nil, err + } + + for i, data := range rawData { + rawData[i].ChannelID = data.GroupChannelID + rawData[i].MaxRPM = data.RequestCount + rawData[i].MaxTPM = int64(data.TotalTokens) + } + + if len(rawData) > 0 && timeSpan != TimeSpanMinute { + rawData = aggregateGroupChannelDataToSpan(rawData, timeSpan, timezone) + } + + result := convertToTimeModelData(rawData) + slices.SortFunc(result, func(a, b TimeSummaryDataV2) int { + return cmp.Compare(a.Timestamp, b.Timestamp) + }) + + return result, nil +} diff --git a/core/model/groupchannel_test.go b/core/model/groupchannel_test.go new file mode 100644 index 00000000..76a0383f --- /dev/null +++ b/core/model/groupchannel_test.go @@ -0,0 +1,972 @@ +//nolint:testpackage +package model + +import ( + "path/filepath" + "testing" + "time" + + "github.com/labring/aiproxy/core/common" + "github.com/stretchr/testify/require" +) + +func TestGroupChannelPartialUpdatesPassHooks(t *testing.T) { + oldDB := DB + oldRedisEnabled := common.RedisEnabled + + db, err := OpenSQLite(filepath.Join(t.TempDir(), "groupchannel_test.db")) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&GroupChannel{}, &GroupChannelTest{})) + + DB = db + common.RedisEnabled = false + + t.Cleanup(func() { + DB = oldDB + common.RedisEnabled = oldRedisEnabled + + require.NoError(t, CacheDeleteGroupChannels("group-1")) + + sqlDB, err := db.DB() + require.NoError(t, err) + require.NoError(t, sqlDB.Close()) + }) + + require.NoError(t, DB.Create(&GroupChannel{ + ID: 7, + GroupID: "group-1", + Name: "test group channel", + Type: ChannelTypeOpenAI, + Status: ChannelStatusEnabled, + LastTestErrorAt: time.Now(), + }).Error) + + require.NoError(t, UpdateGroupChannelStatusByID("group-1", 7, ChannelStatusDisabled)) + require.NoError(t, UpdateGroupChannelUsedAmount("group-1", 7, 1.5, 2, 1)) + require.NoError(t, ClearGroupChannelLastTestErrorAt("group-1", 7)) + + var got GroupChannel + require.NoError(t, DB.First(&got, "group_id = ? AND id = ?", "group-1", 7).Error) + require.Equal(t, ChannelStatusDisabled, got.Status) + require.Equal(t, 1.5, got.UsedAmount) + require.Equal(t, 2, got.RequestCount) + require.Equal(t, 1, got.RetryCount) + require.True(t, got.LastTestErrorAt.IsZero()) +} + +func TestGetGroupChannelOrderRejectsTestAt(t *testing.T) { + require.Equal(t, "id desc", getGroupChannelOrder("test_at")) + require.Equal(t, "id desc", getGroupChannelOrder("test_at-asc")) + require.Equal(t, "created_at asc", getGroupChannelOrder("created_at-asc")) +} + +func TestGetGroupChannelTestsFiltersGroupChannelAndOrdersByTestAt(t *testing.T) { + oldDB := DB + oldRedisEnabled := common.RedisEnabled + + db, err := OpenSQLite(filepath.Join(t.TempDir(), "groupchannel_tests_read_test.db")) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&GroupChannelTest{})) + + DB = db + common.RedisEnabled = false + + t.Cleanup(func() { + DB = oldDB + common.RedisEnabled = oldRedisEnabled + + sqlDB, err := db.DB() + require.NoError(t, err) + require.NoError(t, sqlDB.Close()) + }) + + older := time.Now().Add(-time.Hour) + newer := time.Now() + require.NoError(t, DB.Create(&[]GroupChannelTest{ + { + GroupID: "group-1", + GroupChannelID: 7, + Model: "old-model", + TestAt: older, + }, + { + GroupID: "group-1", + GroupChannelID: 7, + Model: "new-model", + TestAt: newer, + }, + { + GroupID: "group-1", + GroupChannelID: 8, + Model: "other-channel", + TestAt: newer.Add(time.Hour), + }, + { + GroupID: "group-2", + GroupChannelID: 7, + Model: "other-group", + TestAt: newer.Add(2 * time.Hour), + }, + }).Error) + + tests, err := GetGroupChannelTests("group-1", 7) + require.NoError(t, err) + require.Len(t, tests, 2) + require.Equal(t, "new-model", tests[0].Model) + require.Equal(t, "old-model", tests[1].Model) +} + +func TestGroupChannelReadsRequireGroup(t *testing.T) { + _, err := LoadGroupChannels("") + require.Error(t, err) + + _, err = LoadEnabledGroupChannels("") + require.Error(t, err) + + _, err = GetGroupChannelByID("", 7) + require.Error(t, err) + + _, err = GetGroupChannelTests("", 7) + require.Error(t, err) +} + +func TestGetGroupChannelsBasicInfoByIDsFiltersGroup(t *testing.T) { + oldDB := DB + + db, err := OpenSQLite(filepath.Join(t.TempDir(), "groupchannel_basic_info.db")) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&GroupChannel{})) + + DB = db + t.Cleanup(func() { + DB = oldDB + + sqlDB, err := db.DB() + require.NoError(t, err) + require.NoError(t, sqlDB.Close()) + }) + + require.NoError(t, DB.Create(&[]GroupChannel{ + {ID: 7, GroupID: "group-1", Name: "one", Type: ChannelTypeOpenAI}, + {ID: 8, GroupID: "group-2", Name: "two", Type: ChannelTypeAnthropic}, + }).Error) + + groupInfos, err := GetGroupChannelsBasicInfoByIDs("group-1", []int{7, 8}) + require.NoError(t, err) + require.Len(t, groupInfos, 1) + require.Equal(t, "group-1", groupInfos[0].GroupID) + require.Equal(t, 7, groupInfos[0].ID) + + globalInfos, err := GetGlobalGroupChannelsBasicInfoByIDs([]int{7, 8}) + require.NoError(t, err) + require.Len(t, globalInfos, 2) + + _, err = GetGroupChannelsBasicInfoByIDs("", []int{7}) + require.Error(t, err) +} + +func TestGetGroupChannelLastRequestTimesMinute(t *testing.T) { + oldLogDB := LogDB + + db, err := OpenSQLite(filepath.Join(t.TempDir(), "groupchannel_summary_test.db")) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&GroupChannelSummaryMinute{})) + + LogDB = db + + t.Cleanup(func() { + LogDB = oldLogDB + + sqlDB, err := db.DB() + require.NoError(t, err) + require.NoError(t, sqlDB.Close()) + }) + + oldMinute := time.Now().Add(-2 * time.Minute).Truncate(time.Minute) + newMinute := time.Now().Add(-time.Minute).Truncate(time.Minute) + require.NoError(t, db.Create(&[]GroupChannelSummaryMinute{ + { + Unique: GroupChannelSummaryMinuteUnique{ + GroupID: "group-1", + GroupChannelID: 7, + Model: "gpt-5", + MinuteTimestamp: oldMinute.Unix(), + }, + }, + { + Unique: GroupChannelSummaryMinuteUnique{ + GroupID: "group-1", + GroupChannelID: 7, + Model: "gpt-5", + MinuteTimestamp: newMinute.Unix(), + }, + }, + { + Unique: GroupChannelSummaryMinuteUnique{ + GroupID: "group-1", + GroupChannelID: 8, + Model: "gpt-5", + MinuteTimestamp: oldMinute.Unix(), + }, + }, + }).Error) + + lastRequestTimes, err := GetGroupChannelLastRequestTimesMinute("group-1", []int{7, 8, 9}) + require.NoError(t, err) + require.Equal(t, newMinute.Unix(), lastRequestTimes[7].Unix()) + require.Equal(t, oldMinute.Unix(), lastRequestTimes[8].Unix()) + require.NotContains(t, lastRequestTimes, 9) +} + +func TestUpdateGroupChannelUsedAmountClearsGroupChannelCache(t *testing.T) { + oldDB := DB + oldRedisEnabled := common.RedisEnabled + + db, err := OpenSQLite(filepath.Join(t.TempDir(), "groupchannel_cache_test.db")) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&GroupChannel{})) + + DB = db + common.RedisEnabled = false + + t.Cleanup(func() { + DB = oldDB + common.RedisEnabled = oldRedisEnabled + + require.NoError(t, CacheDeleteGroupChannels("group-1")) + + sqlDB, err := db.DB() + require.NoError(t, err) + require.NoError(t, sqlDB.Close()) + }) + + require.NoError(t, DB.Create(&GroupChannel{ + ID: 7, + GroupID: "group-1", + Name: "cached", + Type: ChannelTypeOpenAI, + Status: ChannelStatusEnabled, + }).Error) + + cached, err := CacheGetGroupChannels("group-1") + require.NoError(t, err) + require.Len(t, cached.Channels, 1) + require.Zero(t, cached.Channels[0].UsedAmount) + + require.NoError(t, UpdateGroupChannelUsedAmount("group-1", 7, 1.5, 2, 1)) + + refreshed, err := CacheGetGroupChannels("group-1") + require.NoError(t, err) + require.Len(t, refreshed.Channels, 1) + require.Equal(t, 1.5, refreshed.Channels[0].UsedAmount) + require.Equal(t, 2, refreshed.Channels[0].RequestCount) + require.Equal(t, 1, refreshed.Channels[0].RetryCount) +} + +func TestDeleteGroupChannelsByIDsDeletesTests(t *testing.T) { + oldDB := DB + oldRedisEnabled := common.RedisEnabled + + db, err := OpenSQLite(filepath.Join(t.TempDir(), "groupchannel_delete_test.db")) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&GroupChannel{}, &GroupChannelTest{})) + + DB = db + common.RedisEnabled = false + + t.Cleanup(func() { + DB = oldDB + common.RedisEnabled = oldRedisEnabled + + require.NoError(t, CacheDeleteGroupChannels("group-1")) + + sqlDB, err := db.DB() + require.NoError(t, err) + require.NoError(t, sqlDB.Close()) + }) + + require.NoError(t, DB.Create(&[]GroupChannel{ + { + ID: 7, + GroupID: "group-1", + Name: "one", + Type: ChannelTypeOpenAI, + Status: ChannelStatusEnabled, + }, + { + ID: 8, + GroupID: "group-1", + Name: "two", + Type: ChannelTypeOpenAI, + Status: ChannelStatusEnabled, + }, + }).Error) + require.NoError(t, DB.Create(&[]GroupChannelTest{ + {GroupID: "group-1", GroupChannelID: 7, Model: "gpt-5", Mode: 1}, + {GroupID: "group-1", GroupChannelID: 8, Model: "gpt-5", Mode: 1}, + {GroupID: "group-2", GroupChannelID: 7, Model: "gpt-5", Mode: 1}, + }).Error) + + require.NoError(t, DeleteGroupChannelsByIDs("group-1", []int{7, 8})) + + var remainingTests []GroupChannelTest + require.NoError(t, DB.Order("group_id").Find(&remainingTests).Error) + require.Len(t, remainingTests, 1) + require.Equal(t, "group-2", remainingTests[0].GroupID) +} + +func TestDeleteGroupByIDDeletesGroupScopedChannelState(t *testing.T) { + oldDB := DB + oldLogDB := LogDB + oldRedisEnabled := common.RedisEnabled + + db, err := OpenSQLite(filepath.Join(t.TempDir(), "group_delete_test.db")) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate( + &Group{}, + &Token{}, + &PublicMCPReusingParam{}, + &GroupMCP{}, + &GroupModelConfig{}, + &GroupChannel{}, + &GroupChannelTest{}, + &GroupScopeModelConfig{}, + &Log{}, + &GroupChannelLog{}, + )) + + DB = db + LogDB = db + common.RedisEnabled = false + + t.Cleanup(func() { + DB = oldDB + LogDB = oldLogDB + common.RedisEnabled = oldRedisEnabled + + require.NoError(t, CacheDeleteGroup("group-delete")) + require.NoError(t, CacheDeleteGroupChannels("group-delete")) + require.NoError(t, CacheDeleteGroupScopeModelConfig("group-delete")) + + sqlDB, err := db.DB() + require.NoError(t, err) + require.NoError(t, sqlDB.Close()) + }) + + require.NoError(t, DB.Create(&Group{ID: "group-delete"}).Error) + require.NoError(t, DB.Create(&GroupChannel{ + ID: 7, + GroupID: "group-delete", + Name: "owned", + Type: ChannelTypeOpenAI, + Status: ChannelStatusEnabled, + }).Error) + require.NoError(t, DB.Create(&GroupChannelTest{ + GroupID: "group-delete", + GroupChannelID: 7, + Model: "gpt-5", + Mode: 1, + }).Error) + require.NoError(t, DB.Create(&GroupScopeModelConfig{ + GroupID: "group-delete", + ModelConfig: NewDefaultModelConfig("gpt-5"), + }).Error) + + require.NoError(t, DeleteGroupByID("group-delete")) + + var channelCount int64 + require.NoError(t, DB.Unscoped(). + Model(&GroupChannel{}). + Where("group_id = ?", "group-delete"). + Count(&channelCount).Error) + require.Zero(t, channelCount) + + var testCount int64 + require.NoError(t, DB.Model(&GroupChannelTest{}). + Where("group_id = ?", "group-delete"). + Count(&testCount).Error) + require.Zero(t, testCount) + + var configCount int64 + require.NoError(t, DB.Model(&GroupScopeModelConfig{}). + Where("group_id = ?", "group-delete"). + Count(&configCount).Error) + require.Zero(t, configCount) +} + +func TestDeleteGroupsByIDsDeletesGroupScopedChannelState(t *testing.T) { + oldDB := DB + oldLogDB := LogDB + oldRedisEnabled := common.RedisEnabled + + db, err := OpenSQLite(filepath.Join(t.TempDir(), "groups_delete_test.db")) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate( + &Group{}, + &Token{}, + &PublicMCPReusingParam{}, + &GroupMCP{}, + &GroupModelConfig{}, + &GroupChannel{}, + &GroupChannelTest{}, + &GroupScopeModelConfig{}, + &Log{}, + &GroupChannelLog{}, + )) + + DB = db + LogDB = db + common.RedisEnabled = false + + t.Cleanup(func() { + DB = oldDB + LogDB = oldLogDB + common.RedisEnabled = oldRedisEnabled + + for _, groupID := range []string{"group-bulk-1", "group-bulk-2", "group-keep"} { + require.NoError(t, CacheDeleteGroup(groupID)) + require.NoError(t, CacheDeleteGroupChannels(groupID)) + require.NoError(t, CacheDeleteGroupScopeModelConfig(groupID)) + } + + sqlDB, err := db.DB() + require.NoError(t, err) + require.NoError(t, sqlDB.Close()) + }) + + require.NoError(t, DB.Create(&[]Group{ + {ID: "group-bulk-1"}, + {ID: "group-bulk-2"}, + {ID: "group-keep"}, + }).Error) + require.NoError(t, DB.Create(&[]GroupChannel{ + { + ID: 7, + GroupID: "group-bulk-1", + Name: "owned-1", + Type: ChannelTypeOpenAI, + Status: ChannelStatusEnabled, + }, + { + ID: 8, + GroupID: "group-bulk-2", + Name: "owned-2", + Type: ChannelTypeOpenAI, + Status: ChannelStatusEnabled, + }, + { + ID: 9, + GroupID: "group-keep", + Name: "keep", + Type: ChannelTypeOpenAI, + Status: ChannelStatusEnabled, + }, + }).Error) + require.NoError(t, DB.Create(&[]GroupChannelTest{ + {GroupID: "group-bulk-1", GroupChannelID: 7, Model: "gpt-5", Mode: 1}, + {GroupID: "group-bulk-2", GroupChannelID: 8, Model: "gpt-5", Mode: 1}, + {GroupID: "group-keep", GroupChannelID: 9, Model: "gpt-5", Mode: 1}, + }).Error) + require.NoError(t, DB.Create(&[]GroupScopeModelConfig{ + {GroupID: "group-bulk-1", ModelConfig: NewDefaultModelConfig("gpt-5")}, + {GroupID: "group-bulk-2", ModelConfig: NewDefaultModelConfig("gpt-5")}, + {GroupID: "group-keep", ModelConfig: NewDefaultModelConfig("gpt-5")}, + }).Error) + + require.NoError(t, DeleteGroupsByIDs([]string{"group-bulk-1", "group-bulk-2"})) + + var deletedChannelCount int64 + require.NoError(t, DB.Unscoped(). + Model(&GroupChannel{}). + Where("group_id IN ?", []string{"group-bulk-1", "group-bulk-2"}). + Count(&deletedChannelCount).Error) + require.Zero(t, deletedChannelCount) + + var deletedTestCount int64 + require.NoError(t, DB.Model(&GroupChannelTest{}). + Where("group_id IN ?", []string{"group-bulk-1", "group-bulk-2"}). + Count(&deletedTestCount).Error) + require.Zero(t, deletedTestCount) + + var deletedConfigCount int64 + require.NoError(t, DB.Model(&GroupScopeModelConfig{}). + Where("group_id IN ?", []string{"group-bulk-1", "group-bulk-2"}). + Count(&deletedConfigCount).Error) + require.Zero(t, deletedConfigCount) + + var keptChannelCount int64 + require.NoError(t, DB.Model(&GroupChannel{}). + Where("group_id = ?", "group-keep"). + Count(&keptChannelCount).Error) + require.EqualValues(t, 1, keptChannelCount) +} + +func TestPrepareGroupChannelKeepsExplicitModelsOnly(t *testing.T) { + channel := &GroupChannel{ + Type: ChannelTypeOpenAI, + Models: []string{"z-model", "a-model", "z-model"}, + } + + prepareGroupChannel(channel) + + require.Equal(t, []string{"a-model", "z-model"}, channel.Models) + require.Empty(t, channel.ModelMapping) +} + +func TestPrepareGroupChannelKeepsEmptyModelsAndMapping(t *testing.T) { + channel := &GroupChannel{ + Type: ChannelTypeOpenAI, + } + + prepareGroupChannel(channel) + + require.Empty(t, channel.Models) + require.Empty(t, channel.ModelMapping) +} + +func TestGroupChannelDashboardChannelsRespectSelectedChannel(t *testing.T) { + oldLogDB := LogDB + + db, err := OpenSQLite(filepath.Join(t.TempDir(), "groupchannel_dashboard.db")) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&GroupChannelSummary{}, &GroupChannelSummaryMinute{})) + + LogDB = db + t.Cleanup(func() { + LogDB = oldLogDB + + sqlDB, err := db.DB() + require.NoError(t, err) + require.NoError(t, sqlDB.Close()) + }) + + hour := time.Now().Truncate(time.Hour).Unix() + + minute := time.Now().Truncate(time.Minute).Unix() + for _, channelID := range []int{7, 8} { + require.NoError(t, db.Create(&GroupChannelSummary{ + Unique: GroupChannelSummaryUnique{ + GroupID: "group-1", + GroupChannelID: channelID, + Model: "gpt-5", + HourTimestamp: hour, + }, + Data: SummaryData{ + SummaryDataSet: SummaryDataSet{ + Count: Count{RequestCount: 1}, + Amount: Amount{UsedAmount: float64(channelID)}, + }, + }, + }).Error) + require.NoError(t, db.Create(&GroupChannelSummaryMinute{ + Unique: GroupChannelSummaryMinuteUnique{ + GroupID: "group-1", + GroupChannelID: channelID, + Model: "gpt-5", + MinuteTimestamp: minute, + }, + Data: SummaryData{ + SummaryDataSet: SummaryDataSet{ + Count: Count{RequestCount: 1}, + Amount: Amount{UsedAmount: float64(channelID)}, + }, + }, + }).Error) + } + + start := time.Unix(hour, 0).Add(-time.Hour) + end := time.Unix(hour, 0).Add(time.Hour) + hourly, err := GetGroupChannelDashboardData( + "group-1", + 7, + "", + start, + end, + TimeSpanHour, + time.Local, + nil, + ) + require.NoError(t, err) + require.Equal(t, []int{7}, hourly.Channels) + + minutely, err := GetGroupChannelDashboardData( + "group-1", + 7, + "", + start, + end, + TimeSpanMinute, + time.Local, + nil, + ) + require.NoError(t, err) + require.Equal(t, []int{7}, minutely.Channels) + + v2, err := GetGroupChannelDashboardV2Data( + "group-1", + 7, + "", + start, + end, + TimeSpanHour, + time.Local, + nil, + ) + require.NoError(t, err) + require.Equal(t, []int{7}, v2.Channels) +} + +func TestGroupChannelSummaryReadsRequireGroup(t *testing.T) { + now := time.Now() + + _, err := GetGroupChannelUsedChannels("", 0, time.Time{}, now) + require.Error(t, err) + + _, err = GetGroupChannelUsedModels("", 0, time.Time{}, now) + require.Error(t, err) + + _, err = GetGroupChannelUsedChannelsMinute("", 0, time.Time{}, now) + require.Error(t, err) + + _, err = GetGroupChannelUsedModelsMinute("", 0, time.Time{}, now) + require.Error(t, err) + + _, err = GetGroupChannelDashboardData( + "", + 0, + "", + time.Time{}, + now, + TimeSpanMinute, + time.Local, + nil, + ) + require.Error(t, err) + + _, err = GetGroupChannelTimeSeriesModelData( + "", + 0, + "", + time.Time{}, + now, + TimeSpanMinute, + time.Local, + nil, + ) + require.Error(t, err) + + _, err = GetGroupChannelDashboardV2Data( + "", + 0, + "", + time.Time{}, + now, + TimeSpanHour, + time.Local, + nil, + ) + require.Error(t, err) +} + +func TestBatchUpdateSummaryRecordsGroupChannelTokenSummaryForToken(t *testing.T) { + oldLogDB := LogDB + oldDB := DB + + db, err := OpenSQLite(filepath.Join(t.TempDir(), "groupchannel_token_summary.db")) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate( + &Group{}, + &Token{}, + &GroupChannel{}, + &GroupSummary{}, + &GroupSummaryMinute{}, + &GroupChannelSummary{}, + &GroupChannelSummaryMinute{}, + &GroupChannelTokenSummary{}, + &GroupChannelTokenSummaryMinute{}, + )) + + LogDB = db + DB = db + t.Cleanup(func() { + ProcessBatchUpdatesSummary() + + LogDB = oldLogDB + DB = oldDB + + sqlDB, err := db.DB() + require.NoError(t, err) + require.NoError(t, sqlDB.Close()) + }) + + require.NoError(t, db.Create(&Group{ID: "group-1"}).Error) + require.NoError(t, db.Create(&Token{ID: 11, Name: "token-11", GroupID: "group-1"}).Error) + require.NoError(t, db.Create(&GroupChannel{ + ID: 7, + GroupID: "group-1", + Type: ChannelTypeOpenAI, + }).Error) + + now := time.Now().Truncate(time.Minute) + BatchUpdateSummary( + now, + now, + now, + "group-1", + 200, + ChannelScopeGroup, + 7, + "gpt-5", + 11, + "token-11", + true, + Usage{InputTokens: 2, OutputTokens: 3, TotalTokens: 5}, + Amount{UsedAmount: 1.5}, + "", + false, + ) + ProcessBatchUpdatesSummary() + + var oldGroupSummaryCount int64 + require.NoError(t, db.Model(&GroupSummary{}). + Where("group_id = ?", "group-1"). + Count(&oldGroupSummaryCount).Error) + require.Zero(t, oldGroupSummaryCount) + + var tokenSummaryCount int64 + require.NoError(t, db.Model(&GroupChannelTokenSummary{}).Count(&tokenSummaryCount).Error) + require.EqualValues(t, 1, tokenSummaryCount) + + var gotTokenSummary GroupChannelTokenSummary + require.NoError(t, db.Where( + "group_id = ? AND token_name = ? AND model = ?", + "group-1", + "token-11", + "gpt-5", + ).First(&gotTokenSummary).Error) + require.Equal(t, int64(1), gotTokenSummary.Data.RequestCount) + require.Equal(t, ZeroNullInt64(5), gotTokenSummary.Data.TotalTokens) + require.Equal(t, 1.5, gotTokenSummary.Data.UsedAmount) + + var gotChannelSummary GroupChannelSummary + require.NoError(t, db.Where( + "group_id = ? AND group_channel_id = ? AND model = ?", + "group-1", + 7, + "gpt-5", + ).First(&gotChannelSummary).Error) + require.Equal(t, int64(1), gotChannelSummary.Data.RequestCount) + require.Equal(t, ZeroNullInt64(5), gotChannelSummary.Data.TotalTokens) + require.Equal(t, 1.5, gotChannelSummary.Data.UsedAmount) + + var gotGroup Group + require.NoError(t, db.First(&gotGroup, "id = ?", "group-1").Error) + require.Zero(t, gotGroup.UsedAmount) + require.Zero(t, gotGroup.RequestCount) + require.Equal(t, 1.5, gotGroup.GroupChannelUsedAmount) + require.Equal(t, 1, gotGroup.GroupChannelRequestCount) + + var gotToken Token + require.NoError(t, db.First(&gotToken, "id = ?", 11).Error) + require.Zero(t, gotToken.UsedAmount) + require.Zero(t, gotToken.RequestCount) + require.Equal(t, 1.5, gotToken.GroupChannelUsedAmount) + require.Equal(t, 1, gotToken.GroupChannelRequestCount) + + var gotGroupChannel GroupChannel + require.NoError(t, db.First(&gotGroupChannel, "group_id = ? AND id = ?", "group-1", 7).Error) + require.Equal(t, 1.5, gotGroupChannel.UsedAmount) + require.Equal(t, 1, gotGroupChannel.RequestCount) +} + +func TestBatchUpdateSummaryRecordsGroupChannelAggregatesWithoutTokenName(t *testing.T) { + oldLogDB := LogDB + oldDB := DB + + db, err := OpenSQLite(filepath.Join(t.TempDir(), "groupchannel_admin_token_summary.db")) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate( + &Group{}, + &Token{}, + &GroupChannel{}, + &GroupChannelSummary{}, + &GroupChannelSummaryMinute{}, + &GroupChannelTokenSummary{}, + &GroupChannelTokenSummaryMinute{}, + )) + + LogDB = db + DB = db + t.Cleanup(func() { + ProcessBatchUpdatesSummary() + + LogDB = oldLogDB + DB = oldDB + + sqlDB, err := db.DB() + require.NoError(t, err) + require.NoError(t, sqlDB.Close()) + }) + + require.NoError(t, db.Create(&Group{ID: "group-1"}).Error) + require.NoError(t, db.Create(&Token{ID: 11, Name: "token-11", GroupID: "group-1"}).Error) + require.NoError(t, db.Create(&GroupChannel{ + ID: 7, + GroupID: "group-1", + Type: ChannelTypeOpenAI, + }).Error) + + now := time.Now().Truncate(time.Minute) + BatchUpdateSummary( + now, + now, + now, + "group-1", + 200, + ChannelScopeGroup, + 7, + "gpt-5", + 11, + "", + true, + Usage{InputTokens: 2, OutputTokens: 3, TotalTokens: 5}, + Amount{UsedAmount: 1.5}, + "", + false, + ) + ProcessBatchUpdatesSummary() + + var channelSummaryCount int64 + require.NoError(t, db.Model(&GroupChannelSummary{}).Count(&channelSummaryCount).Error) + require.EqualValues(t, 1, channelSummaryCount) + + var tokenSummaryCount int64 + require.NoError(t, db.Model(&GroupChannelTokenSummary{}).Count(&tokenSummaryCount).Error) + require.Zero(t, tokenSummaryCount) + + var tokenSummaryMinuteCount int64 + require.NoError(t, db.Model(&GroupChannelTokenSummaryMinute{}). + Count(&tokenSummaryMinuteCount).Error) + require.Zero(t, tokenSummaryMinuteCount) + + var gotGroup Group + require.NoError(t, db.First(&gotGroup, "id = ?", "group-1").Error) + require.Zero(t, gotGroup.UsedAmount) + require.Zero(t, gotGroup.RequestCount) + require.Equal(t, 1.5, gotGroup.GroupChannelUsedAmount) + require.Equal(t, 1, gotGroup.GroupChannelRequestCount) + + var gotToken Token + require.NoError(t, db.First(&gotToken, "id = ?", 11).Error) + require.Zero(t, gotToken.UsedAmount) + require.Zero(t, gotToken.RequestCount) + require.Equal(t, 1.5, gotToken.GroupChannelUsedAmount) + require.Equal(t, 1, gotToken.GroupChannelRequestCount) + + var gotGroupChannel GroupChannel + require.NoError(t, db.First(&gotGroupChannel, "group_id = ? AND id = ?", "group-1", 7).Error) + require.Equal(t, 1.5, gotGroupChannel.UsedAmount) + require.Equal(t, 1, gotGroupChannel.RequestCount) +} + +func TestGetGroupChannelTokenDashboardUsesScopedMinuteCounters(t *testing.T) { + oldLogDB := LogDB + + db, err := OpenSQLite(filepath.Join(t.TempDir(), "groupchannel_token_dashboard.db")) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate( + &GroupChannelTokenSummary{}, + &GroupChannelTokenSummaryMinute{}, + )) + + LogDB = db + t.Cleanup(func() { + LogDB = oldLogDB + + sqlDB, err := db.DB() + require.NoError(t, err) + require.NoError(t, sqlDB.Close()) + }) + + now := time.Now().Truncate(time.Minute) + require.NoError(t, db.Create(&GroupChannelTokenSummaryMinute{ + Unique: GroupChannelTokenSummaryMinuteUnique{ + GroupID: "group-1", + TokenName: "token-1", + Model: "gpt-5", + MinuteTimestamp: now.Unix(), + }, + Data: SummaryData{ + SummaryDataSet: SummaryDataSet{ + Count: Count{RequestCount: 3}, + Usage: Usage{TotalTokens: 17}, + }, + }, + }).Error) + + result, err := GetGroupChannelTokenDashboardData( + "group-1", + now.Add(-time.Hour), + now.Add(time.Hour), + "token-1", + "gpt-5", + TimeSpanMinute, + time.Local, + nil, + ) + require.NoError(t, err) + require.Equal(t, int64(3), result.RPM) + require.Equal(t, int64(17), result.TPM) +} + +func TestGroupChannelTokenSummaryReadsRequireGroup(t *testing.T) { + now := time.Now() + + _, err := GetGroupChannelTokenUsedModels("", "", time.Time{}, now) + require.Error(t, err) + + _, err = GetGroupChannelTokenUsedTokenNames("", time.Time{}, now) + require.Error(t, err) + + _, err = GetGroupChannelTokenUsedModelsMinute("", "", time.Time{}, now) + require.Error(t, err) + + _, err = GetGroupChannelTokenUsedTokenNamesMinute("", time.Time{}, now) + require.Error(t, err) + + _, err = GetGroupChannelTokenDashboardData( + "", + time.Time{}, + now, + "", + "", + TimeSpanMinute, + time.Local, + nil, + ) + require.Error(t, err) + + _, err = GetGroupChannelTokenTimeSeriesModelData( + "", + "", + "", + time.Time{}, + now, + TimeSpanMinute, + time.Local, + nil, + ) + require.Error(t, err) + + _, err = GetGroupChannelTokenDashboardV2Data( + "", + "", + "", + time.Time{}, + now, + TimeSpanHour, + time.Local, + nil, + ) + require.Error(t, err) +} diff --git a/core/model/groupchannel_token_summary.go b/core/model/groupchannel_token_summary.go new file mode 100644 index 00000000..4c5d7770 --- /dev/null +++ b/core/model/groupchannel_token_summary.go @@ -0,0 +1,965 @@ +package model + +import ( + "cmp" + "errors" + "slices" + "time" + + "golang.org/x/sync/errgroup" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +type GroupChannelTokenSummary struct { + ID int `gorm:"primaryKey"` + Unique GroupChannelTokenSummaryUnique `gorm:"embedded"` + Data SummaryData `gorm:"embedded"` +} + +type GroupChannelTokenSummaryUnique struct { + GroupID string `gorm:"size:64;not null;uniqueIndex:idx_group_channel_token_summary_unique,priority:1"` + TokenName string `gorm:"size:32;not null;uniqueIndex:idx_group_channel_token_summary_unique,priority:2"` + Model string `gorm:"size:128;not null;uniqueIndex:idx_group_channel_token_summary_unique,priority:3"` + HourTimestamp int64 `gorm:"not null;uniqueIndex:idx_group_channel_token_summary_unique,priority:4,sort:desc"` +} + +func (l *GroupChannelTokenSummary) BeforeCreate(_ *gorm.DB) error { + if l.Unique.GroupID == "" { + return errors.New("group id is required") + } + + if l.Unique.Model == "" { + return errors.New("model is required") + } + + if l.Unique.HourTimestamp == 0 { + return errors.New("hour timestamp is required") + } + + return validateHourTimestamp(l.Unique.HourTimestamp) +} + +func CreateGroupChannelTokenSummaryIndexs(db *gorm.DB) error { + indexes := []string{ + "CREATE INDEX IF NOT EXISTS idx_group_channel_token_summary_group_hour ON group_channel_token_summaries (group_id, hour_timestamp DESC)", + "CREATE INDEX IF NOT EXISTS idx_group_channel_token_summary_group_token_hour ON group_channel_token_summaries (group_id, token_name, hour_timestamp DESC)", + "CREATE INDEX IF NOT EXISTS idx_group_channel_token_summary_group_model_hour ON group_channel_token_summaries (group_id, model, hour_timestamp DESC)", + } + + for _, index := range indexes { + if err := db.Exec(index).Error; err != nil { + return err + } + } + + return nil +} + +func UpsertGroupChannelTokenSummary( + unique GroupChannelTokenSummaryUnique, + data SummaryData, +) error { + if err := validateHourTimestamp(unique.HourTimestamp); err != nil { + return err + } + + var err error + for range 3 { + result := LogDB. + Model(&GroupChannelTokenSummary{}). + Where( + "group_id = ? AND token_name = ? AND model = ? AND hour_timestamp = ?", + unique.GroupID, + unique.TokenName, + unique.Model, + unique.HourTimestamp, + ). + Updates(data.buildUpdateData("group_channel_token_summaries")) + + if result.Error != nil { + return result.Error + } + + if result.RowsAffected > 0 { + return nil + } + + err = createGroupChannelTokenSummary(unique, data) + if err == nil { + return nil + } + + if !errors.Is(err, gorm.ErrDuplicatedKey) { + return err + } + } + + return err +} + +func createGroupChannelTokenSummary( + unique GroupChannelTokenSummaryUnique, + data SummaryData, +) error { + return LogDB. + Clauses(clause.OnConflict{ + Columns: []clause.Column{ + {Name: "group_id"}, + {Name: "token_name"}, + {Name: "model"}, + {Name: "hour_timestamp"}, + }, + DoUpdates: clause.Assignments(data.buildUpdateData("group_channel_token_summaries")), + }). + Create(&GroupChannelTokenSummary{Unique: unique, Data: data}).Error +} + +func getGroupChannelTokenChartData( + group string, + start, end time.Time, + tokenName, modelName string, + timeSpan TimeSpanType, + timezone *time.Location, + fields SummarySelectFields, +) ([]ChartData, error) { + modelName = normalizeSummaryModelFilter(modelName) + + query := LogDB.Model(&GroupChannelTokenSummary{}).Where("group_id = ?", group) + if tokenName != "" { + query = query.Where("token_name = ?", tokenName) + } + + if modelName != "" { + query = query.Where("model = ?", modelName) + } + + switch { + case !start.IsZero() && !end.IsZero(): + query = query.Where("hour_timestamp BETWEEN ? AND ?", start.Unix(), end.Unix()) + case !start.IsZero(): + query = query.Where("hour_timestamp >= ?", start.Unix()) + case !end.IsZero(): + query = query.Where("hour_timestamp <= ?", end.Unix()) + } + + var chartData []ChartData + + err := query. + Select(fields.BuildSelectFields("hour_timestamp")). + Group("timestamp"). + Find(&chartData).Error + if err != nil { + return nil, err + } + + if len(chartData) > 0 && timeSpan != TimeSpanHour { + chartData = aggregateDataToSpan(chartData, timeSpan, timezone) + } + + slices.SortFunc(chartData, func(a, b ChartData) int { + return cmp.Compare(a.Timestamp, b.Timestamp) + }) + + return chartData, nil +} + +func GetGroupChannelTokenUsedModels( + group, tokenName string, + start, end time.Time, +) ([]string, error) { + if group == "" { + return nil, errors.New("group is required") + } + + return getGroupChannelTokenGroupByValues[string]("model", group, tokenName, start, end) +} + +func GetGlobalGroupChannelTokenUsedModels( + group, tokenName string, + start, end time.Time, +) ([]string, error) { + return getGroupChannelTokenGroupByValues[string]("model", group, tokenName, start, end) +} + +func GetGroupChannelTokenUsedTokenNames(group string, start, end time.Time) ([]string, error) { + if group == "" { + return nil, errors.New("group is required") + } + + return getGroupChannelTokenGroupByValues[string]("token_name", group, "", start, end) +} + +func GetGlobalGroupChannelTokenUsedTokenNames( + group string, + start, end time.Time, +) ([]string, error) { + return getGroupChannelTokenGroupByValues[string]("token_name", group, "", start, end) +} + +func getGroupChannelTokenGroupByValues[T cmp.Ordered]( + field, group, tokenName string, + start, end time.Time, +) ([]T, error) { + type Result struct { + Value T + UsedAmount float64 + RequestCount int64 + } + + query := LogDB.Model(&GroupChannelTokenSummary{}) + if group != "" { + query = query.Where("group_id = ?", group) + } + + if tokenName != "" { + query = query.Where("token_name = ?", tokenName) + } + + switch { + case !start.IsZero() && !end.IsZero(): + query = query.Where("hour_timestamp BETWEEN ? AND ?", start.Unix(), end.Unix()) + case !start.IsZero(): + query = query.Where("hour_timestamp >= ?", start.Unix()) + case !end.IsZero(): + query = query.Where("hour_timestamp <= ?", end.Unix()) + } + + var results []Result + + err := query. + Select( + field + " as value, SUM(request_count) as request_count, SUM(used_amount) as used_amount", + ). + Group(field). + Find(&results).Error + if err != nil { + return nil, err + } + + slices.SortFunc(results, func(a, b Result) int { + if a.UsedAmount != b.UsedAmount { + return cmp.Compare(b.UsedAmount, a.UsedAmount) + } + + if a.RequestCount != b.RequestCount { + return cmp.Compare(b.RequestCount, a.RequestCount) + } + + return cmp.Compare(a.Value, b.Value) + }) + + values := make([]T, len(results)) + for i, result := range results { + values[i] = result.Value + } + + return values, nil +} + +func GetGroupChannelTokenDashboardData( + group string, + start, end time.Time, + tokenName string, + modelName string, + timeSpan TimeSpanType, + timezone *time.Location, + fields SummarySelectFields, +) (*GroupDashboardResponse, error) { + if group == "" { + return nil, errors.New("group is required") + } + + if timeSpan == TimeSpanMinute { + return getGroupChannelTokenDashboardDataMinute( + group, + start, + end, + tokenName, + modelName, + timeSpan, + timezone, + fields, + ) + } + + if end.IsZero() { + end = time.Now() + } else if end.Before(start) { + return nil, errors.New("end time is before start time") + } + + var ( + chartData []ChartData + tokenNames []string + models []string + currentRPM int64 + currentTPM int64 + ) + + g := new(errgroup.Group) + g.Go(func() error { + var err error + + chartData, err = getGroupChannelTokenChartData( + group, + start, + end, + tokenName, + modelName, + timeSpan, + timezone, + fields, + ) + + return err + }) + g.Go(func() error { + var err error + + tokenNames, err = GetGroupChannelTokenUsedTokenNames(group, start, end) + return err + }) + g.Go(func() error { + var err error + + models, err = GetGroupChannelTokenUsedModels(group, tokenName, start, end) + return err + }) + g.Go(func() error { + currentRPM, currentTPM = getGroupChannelTokenCurrentRPM(group, tokenName, modelName) + return nil + }) + + if err := g.Wait(); err != nil { + return nil, err + } + + dashboardResponse := sumDashboardResponse(chartData) + dashboardResponse.Models = models + dashboardResponse.RPM = currentRPM + dashboardResponse.TPM = currentTPM + + return &GroupDashboardResponse{ + DashboardResponse: dashboardResponse, + TokenNames: tokenNames, + }, nil +} + +func GetGlobalGroupChannelTokenDashboardData( + group string, + start, end time.Time, + tokenName string, + modelName string, + timeSpan TimeSpanType, + timezone *time.Location, + fields SummarySelectFields, +) (*GroupDashboardResponse, error) { + if timeSpan == TimeSpanMinute { + return getGlobalGroupChannelTokenDashboardDataMinute( + group, + start, + end, + tokenName, + modelName, + timeSpan, + timezone, + fields, + ) + } + + if end.IsZero() { + end = time.Now() + } else if end.Before(start) { + return nil, errors.New("end time is before start time") + } + + var ( + chartData []ChartData + tokenNames []string + models []string + currentRPM int64 + currentTPM int64 + ) + + g := new(errgroup.Group) + g.Go(func() error { + var err error + + chartData, err = getGroupChannelTokenChartData( + group, + start, + end, + tokenName, + modelName, + timeSpan, + timezone, + fields, + ) + + return err + }) + g.Go(func() error { + var err error + + tokenNames, err = GetGlobalGroupChannelTokenUsedTokenNames(group, start, end) + return err + }) + g.Go(func() error { + var err error + + models, err = GetGlobalGroupChannelTokenUsedModels(group, tokenName, start, end) + return err + }) + g.Go(func() error { + currentRPM, currentTPM = getGroupChannelTokenCurrentRPM(group, tokenName, modelName) + return nil + }) + + if err := g.Wait(); err != nil { + return nil, err + } + + dashboardResponse := sumDashboardResponse(chartData) + dashboardResponse.Models = models + dashboardResponse.RPM = currentRPM + dashboardResponse.TPM = currentTPM + + return &GroupDashboardResponse{ + DashboardResponse: dashboardResponse, + TokenNames: tokenNames, + }, nil +} + +func GetGroupChannelTokenTimeSeriesModelData( + group string, + tokenName string, + modelName string, + start, end time.Time, + timeSpan TimeSpanType, + timezone *time.Location, + fields SummarySelectFields, +) ([]TimeSummaryDataV2, error) { + modelName = normalizeSummaryModelFilter(modelName) + + if group == "" { + return nil, errors.New("group is required") + } + + if timeSpan == TimeSpanMinute { + return getGroupChannelTokenTimeSeriesModelDataMinute( + group, + tokenName, + modelName, + start, + end, + timeSpan, + timezone, + fields, + ) + } + + if end.IsZero() { + end = time.Now() + } else if end.Before(start) { + return nil, errors.New("end time is before start time") + } + + query := LogDB.Model(&GroupChannelTokenSummary{}).Where("group_id = ?", group) + if tokenName != "" { + query = query.Where("token_name = ?", tokenName) + } + + if modelName != "" { + query = query.Where("model = ?", modelName) + } + + switch { + case !start.IsZero() && !end.IsZero(): + query = query.Where("hour_timestamp BETWEEN ? AND ?", start.Unix(), end.Unix()) + case !start.IsZero(): + query = query.Where("hour_timestamp >= ?", start.Unix()) + case !end.IsZero(): + query = query.Where("hour_timestamp <= ?", end.Unix()) + } + + var rawData []SummaryDataV2 + + err := query. + Select(fields.BuildSelectFieldsV2("hour_timestamp", "group_id, token_name, model")). + Group("timestamp, group_id, token_name, model"). + Find(&rawData).Error + if err != nil { + return nil, err + } + + if len(rawData) > 0 { + err = batchFillGroupChannelTokenMaxValues(rawData, group, tokenName, modelName, start, end) + if err != nil { + return nil, err + } + + if timeSpan != TimeSpanHour { + rawData = aggregatToSpanGroup(rawData, timeSpan, timezone) + } + } + + result := convertToTimeModelData(rawData) + slices.SortFunc(result, func(a, b TimeSummaryDataV2) int { + return cmp.Compare(a.Timestamp, b.Timestamp) + }) + + return result, nil +} + +func GetGlobalGroupChannelTokenTimeSeriesModelData( + group string, + tokenName string, + modelName string, + start, end time.Time, + timeSpan TimeSpanType, + timezone *time.Location, + fields SummarySelectFields, +) ([]TimeSummaryDataV2, error) { + modelName = normalizeSummaryModelFilter(modelName) + + if timeSpan == TimeSpanMinute { + return getGlobalGroupChannelTokenTimeSeriesModelDataMinute( + group, + tokenName, + modelName, + start, + end, + timeSpan, + timezone, + fields, + ) + } + + if end.IsZero() { + end = time.Now() + } else if end.Before(start) { + return nil, errors.New("end time is before start time") + } + + query := LogDB.Model(&GroupChannelTokenSummary{}) + if group != "" { + query = query.Where("group_id = ?", group) + } + + if tokenName != "" { + query = query.Where("token_name = ?", tokenName) + } + + if modelName != "" { + query = query.Where("model = ?", modelName) + } + + switch { + case !start.IsZero() && !end.IsZero(): + query = query.Where("hour_timestamp BETWEEN ? AND ?", start.Unix(), end.Unix()) + case !start.IsZero(): + query = query.Where("hour_timestamp >= ?", start.Unix()) + case !end.IsZero(): + query = query.Where("hour_timestamp <= ?", end.Unix()) + } + + var rawData []SummaryDataV2 + + err := query. + Select(fields.BuildSelectFieldsV2("hour_timestamp", "group_id, token_name, model")). + Group("timestamp, group_id, token_name, model"). + Find(&rawData).Error + if err != nil { + return nil, err + } + + if len(rawData) > 0 { + err = batchFillGlobalGroupChannelTokenMaxValues( + rawData, + group, + tokenName, + modelName, + start, + end, + ) + if err != nil { + return nil, err + } + + if timeSpan != TimeSpanHour { + rawData = aggregatToSpanGroup(rawData, timeSpan, timezone) + } + } + + result := convertToTimeModelData(rawData) + slices.SortFunc(result, func(a, b TimeSummaryDataV2) int { + return cmp.Compare(a.Timestamp, b.Timestamp) + }) + + return result, nil +} + +func GetGroupChannelTokenDashboardV2Data( + group string, + tokenName string, + modelName string, + start, end time.Time, + timeSpan TimeSpanType, + timezone *time.Location, + fields SummarySelectFields, +) (*GroupDashboardV2Response, error) { + if group == "" { + return nil, errors.New("group is required") + } + + var ( + timeSeries []TimeSummaryDataV2 + currentRPM int64 + currentTPM int64 + models []string + tokenNames []string + ) + + g := new(errgroup.Group) + g.Go(func() error { + var err error + + timeSeries, err = GetGroupChannelTokenTimeSeriesModelData( + group, + tokenName, + modelName, + start, + end, + timeSpan, + timezone, + fields, + ) + + return err + }) + g.Go(func() error { + currentRPM, currentTPM = getGroupChannelTokenCurrentRPM(group, tokenName, modelName) + return nil + }) + g.Go(func() error { + var err error + + models, err = GetGroupChannelTokenUsedModels(group, tokenName, start, end) + return err + }) + g.Go(func() error { + var err error + + tokenNames, err = GetGroupChannelTokenUsedTokenNames(group, start, end) + return err + }) + + if err := g.Wait(); err != nil { + return nil, err + } + + return &GroupDashboardV2Response{ + DashboardV2Response: DashboardV2Response{ + TimeSeries: timeSeries, + RPM: currentRPM, + TPM: currentTPM, + Models: models, + }, + TokenNames: tokenNames, + }, nil +} + +func GetGlobalGroupChannelTokenDashboardV2Data( + group string, + tokenName string, + modelName string, + start, end time.Time, + timeSpan TimeSpanType, + timezone *time.Location, + fields SummarySelectFields, +) (*GroupDashboardV2Response, error) { + var ( + timeSeries []TimeSummaryDataV2 + currentRPM int64 + currentTPM int64 + models []string + tokenNames []string + ) + + g := new(errgroup.Group) + g.Go(func() error { + var err error + + timeSeries, err = GetGlobalGroupChannelTokenTimeSeriesModelData( + group, + tokenName, + modelName, + start, + end, + timeSpan, + timezone, + fields, + ) + + return err + }) + g.Go(func() error { + currentRPM, currentTPM = getGroupChannelTokenCurrentRPM(group, tokenName, modelName) + return nil + }) + g.Go(func() error { + var err error + + models, err = GetGlobalGroupChannelTokenUsedModels(group, tokenName, start, end) + return err + }) + g.Go(func() error { + var err error + + tokenNames, err = GetGlobalGroupChannelTokenUsedTokenNames(group, start, end) + return err + }) + + if err := g.Wait(); err != nil { + return nil, err + } + + return &GroupDashboardV2Response{ + DashboardV2Response: DashboardV2Response{ + TimeSeries: timeSeries, + RPM: currentRPM, + TPM: currentTPM, + Models: models, + }, + TokenNames: tokenNames, + }, nil +} + +func GetGroupChannelTokenDashboardV3Data( + group string, + tokenName string, + modelName string, + start, end time.Time, + timeSpan TimeSpanType, + timezone *time.Location, + fields SummarySelectFields, +) (*GroupDashboardV3Response, error) { + return GetGroupChannelTokenDashboardV2Data( + group, + tokenName, + modelName, + start, + end, + timeSpan, + timezone, + fields, + ) +} + +func GetGlobalGroupChannelTokenDashboardV3Data( + group string, + tokenName string, + modelName string, + start, end time.Time, + timeSpan TimeSpanType, + timezone *time.Location, + fields SummarySelectFields, +) (*GroupDashboardV3Response, error) { + return GetGlobalGroupChannelTokenDashboardV2Data( + group, + tokenName, + modelName, + start, + end, + timeSpan, + timezone, + fields, + ) +} + +func batchFillGroupChannelTokenMaxValues( + rawData []SummaryDataV2, + group, tokenName, modelName string, + start, end time.Time, +) error { + modelName = normalizeSummaryModelFilter(modelName) + + minuteQuery := LogDB.Model(&GroupChannelTokenSummaryMinute{}).Where("group_id = ?", group) + if tokenName != "" { + minuteQuery = minuteQuery.Where("token_name = ?", tokenName) + } + + if modelName != "" { + minuteQuery = minuteQuery.Where("model = ?", modelName) + } + + minuteStart := start.Unix() + + minuteEnd := end.Unix() + if end.IsZero() { + minuteEnd = time.Now().Unix() + } + + minuteQuery = minuteQuery.Where( + "minute_timestamp >= ? AND minute_timestamp <= ?", + minuteStart, + minuteEnd, + ) + + type MaxResult struct { + HourTimestamp int64 `json:"hour_timestamp"` + GroupID string `json:"group_id"` + TokenName string `json:"token_name"` + Model string `json:"model"` + MaxRPM int64 `json:"max_rpm"` + MaxTPM int64 `json:"max_tpm"` + } + + var maxResults []MaxResult + + err := minuteQuery. + Select(` + (minute_timestamp - minute_timestamp % 3600) as hour_timestamp, + group_id, + token_name, + model, + MAX(request_count) as max_rpm, + MAX(total_tokens) as max_tpm + `). + Group("hour_timestamp, group_id, token_name, model"). + Find(&maxResults).Error + if err != nil { + return err + } + + type Key struct { + HourTimestamp int64 + GroupID string + TokenName string + Model string + } + + maxMap := make(map[Key]MaxResult) + for _, result := range maxResults { + key := Key{ + HourTimestamp: result.HourTimestamp, + GroupID: result.GroupID, + TokenName: result.TokenName, + Model: result.Model, + } + maxMap[key] = result + } + + for i := range rawData { + data := &rawData[i] + + key := Key{ + HourTimestamp: data.Timestamp, + GroupID: data.GroupID, + TokenName: data.TokenName, + Model: data.Model, + } + if maxResult, exists := maxMap[key]; exists { + data.MaxRPM = maxResult.MaxRPM + data.MaxTPM = maxResult.MaxTPM + } + } + + return nil +} + +func batchFillGlobalGroupChannelTokenMaxValues( + rawData []SummaryDataV2, + group, tokenName, modelName string, + start, end time.Time, +) error { + modelName = normalizeSummaryModelFilter(modelName) + + minuteQuery := LogDB.Model(&GroupChannelTokenSummaryMinute{}) + if group != "" { + minuteQuery = minuteQuery.Where("group_id = ?", group) + } + + if tokenName != "" { + minuteQuery = minuteQuery.Where("token_name = ?", tokenName) + } + + if modelName != "" { + minuteQuery = minuteQuery.Where("model = ?", modelName) + } + + minuteStart := start.Unix() + + minuteEnd := end.Unix() + if end.IsZero() { + minuteEnd = time.Now().Unix() + } + + minuteQuery = minuteQuery.Where( + "minute_timestamp >= ? AND minute_timestamp <= ?", + minuteStart, + minuteEnd, + ) + + type MaxResult struct { + HourTimestamp int64 `json:"hour_timestamp"` + GroupID string `json:"group_id"` + TokenName string `json:"token_name"` + Model string `json:"model"` + MaxRPM int64 `json:"max_rpm"` + MaxTPM int64 `json:"max_tpm"` + } + + var maxResults []MaxResult + + err := minuteQuery. + Select(` + (minute_timestamp - minute_timestamp % 3600) as hour_timestamp, + group_id, + token_name, + model, + MAX(request_count) as max_rpm, + MAX(total_tokens) as max_tpm + `). + Group("hour_timestamp, group_id, token_name, model"). + Find(&maxResults).Error + if err != nil { + return err + } + + type Key struct { + HourTimestamp int64 + GroupID string + TokenName string + Model string + } + + maxMap := make(map[Key]MaxResult) + for _, result := range maxResults { + key := Key{ + HourTimestamp: result.HourTimestamp, + GroupID: result.GroupID, + TokenName: result.TokenName, + Model: result.Model, + } + maxMap[key] = result + } + + for i := range rawData { + data := &rawData[i] + + key := Key{ + HourTimestamp: data.Timestamp, + GroupID: data.GroupID, + TokenName: data.TokenName, + Model: data.Model, + } + if maxResult, exists := maxMap[key]; exists { + data.MaxRPM = maxResult.MaxRPM + data.MaxTPM = maxResult.MaxTPM + } + } + + return nil +} diff --git a/core/model/groupchannel_token_summary_minute.go b/core/model/groupchannel_token_summary_minute.go new file mode 100644 index 00000000..dc19d0cd --- /dev/null +++ b/core/model/groupchannel_token_summary_minute.go @@ -0,0 +1,574 @@ +package model + +import ( + "cmp" + "errors" + "slices" + "time" + + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +type GroupChannelTokenSummaryMinute struct { + ID int `gorm:"primaryKey"` + Unique GroupChannelTokenSummaryMinuteUnique `gorm:"embedded"` + Data SummaryData `gorm:"embedded"` +} + +type GroupChannelTokenSummaryMinuteUnique struct { + GroupID string `gorm:"size:64;not null;uniqueIndex:idx_group_channel_token_summary_minute_unique,priority:1"` + TokenName string `gorm:"size:32;not null;uniqueIndex:idx_group_channel_token_summary_minute_unique,priority:2"` + Model string `gorm:"size:128;not null;uniqueIndex:idx_group_channel_token_summary_minute_unique,priority:3"` + MinuteTimestamp int64 `gorm:"not null;uniqueIndex:idx_group_channel_token_summary_minute_unique,priority:4,sort:desc"` +} + +func (l *GroupChannelTokenSummaryMinute) BeforeCreate(_ *gorm.DB) error { + if l.Unique.GroupID == "" { + return errors.New("group id is required") + } + + if l.Unique.Model == "" { + return errors.New("model is required") + } + + if l.Unique.MinuteTimestamp == 0 { + return errors.New("minute timestamp is required") + } + + return validateMinuteTimestamp(l.Unique.MinuteTimestamp) +} + +func CreateGroupChannelTokenSummaryMinuteIndexs(db *gorm.DB) error { + indexes := []string{ + "CREATE INDEX IF NOT EXISTS idx_group_channel_token_summary_minute_group_minute ON group_channel_token_summary_minutes (group_id, minute_timestamp DESC)", + "CREATE INDEX IF NOT EXISTS idx_group_channel_token_summary_minute_group_token_minute ON group_channel_token_summary_minutes (group_id, token_name, minute_timestamp DESC)", + "CREATE INDEX IF NOT EXISTS idx_group_channel_token_summary_minute_group_model_minute ON group_channel_token_summary_minutes (group_id, model, minute_timestamp DESC)", + } + + for _, index := range indexes { + if err := db.Exec(index).Error; err != nil { + return err + } + } + + return nil +} + +func UpsertGroupChannelTokenSummaryMinute( + unique GroupChannelTokenSummaryMinuteUnique, + data SummaryData, +) error { + if err := validateMinuteTimestamp(unique.MinuteTimestamp); err != nil { + return err + } + + var err error + for range 3 { + result := LogDB. + Model(&GroupChannelTokenSummaryMinute{}). + Where( + "group_id = ? AND token_name = ? AND model = ? AND minute_timestamp = ?", + unique.GroupID, + unique.TokenName, + unique.Model, + unique.MinuteTimestamp, + ). + Updates(data.buildUpdateData("group_channel_token_summary_minutes")) + if result.Error != nil { + return result.Error + } + + if result.RowsAffected > 0 { + return nil + } + + err = createGroupChannelTokenSummaryMinute(unique, data) + if err == nil { + return nil + } + + if !errors.Is(err, gorm.ErrDuplicatedKey) { + return err + } + } + + return err +} + +func createGroupChannelTokenSummaryMinute( + unique GroupChannelTokenSummaryMinuteUnique, + data SummaryData, +) error { + return LogDB. + Clauses(clause.OnConflict{ + Columns: []clause.Column{ + {Name: "group_id"}, + {Name: "token_name"}, + {Name: "model"}, + {Name: "minute_timestamp"}, + }, + DoUpdates: clause.Assignments( + data.buildUpdateData("group_channel_token_summary_minutes"), + ), + }). + Create(&GroupChannelTokenSummaryMinute{Unique: unique, Data: data}).Error +} + +func getGroupChannelTokenChartDataMinute( + group string, + start, end time.Time, + tokenName, modelName string, + timeSpan TimeSpanType, + timezone *time.Location, + fields SummarySelectFields, +) ([]ChartData, error) { + modelName = normalizeSummaryModelFilter(modelName) + + query := LogDB.Model(&GroupChannelTokenSummaryMinute{}).Where("group_id = ?", group) + if tokenName != "" { + query = query.Where("token_name = ?", tokenName) + } + + if modelName != "" { + query = query.Where("model = ?", modelName) + } + + switch { + case !start.IsZero() && !end.IsZero(): + query = query.Where("minute_timestamp BETWEEN ? AND ?", start.Unix(), end.Unix()) + case !start.IsZero(): + query = query.Where("minute_timestamp >= ?", start.Unix()) + case !end.IsZero(): + query = query.Where("minute_timestamp <= ?", end.Unix()) + } + + var chartData []ChartData + + err := query. + Select(fields.BuildSelectFields("minute_timestamp")). + Group("timestamp"). + Find(&chartData).Error + if err != nil { + return nil, err + } + + if len(chartData) > 0 && timeSpan != TimeSpanMinute { + chartData = aggregateDataToSpan(chartData, timeSpan, timezone) + } + + slices.SortFunc(chartData, func(a, b ChartData) int { + return cmp.Compare(a.Timestamp, b.Timestamp) + }) + + return chartData, nil +} + +func GetGroupChannelTokenUsedModelsMinute( + group, tokenName string, + start, end time.Time, +) ([]string, error) { + if group == "" { + return nil, errors.New("group is required") + } + + return getGroupChannelTokenGroupByValuesMinute[string]("model", group, tokenName, start, end) +} + +func GetGlobalGroupChannelTokenUsedModelsMinute( + group, tokenName string, + start, end time.Time, +) ([]string, error) { + return getGroupChannelTokenGroupByValuesMinute[string]("model", group, tokenName, start, end) +} + +func GetGroupChannelTokenUsedTokenNamesMinute( + group string, + start, end time.Time, +) ([]string, error) { + if group == "" { + return nil, errors.New("group is required") + } + + return getGroupChannelTokenGroupByValuesMinute[string]("token_name", group, "", start, end) +} + +func GetGlobalGroupChannelTokenUsedTokenNamesMinute( + group string, + start, end time.Time, +) ([]string, error) { + return getGroupChannelTokenGroupByValuesMinute[string]("token_name", group, "", start, end) +} + +func getGroupChannelTokenGroupByValuesMinute[T cmp.Ordered]( + field, group, tokenName string, + start, end time.Time, +) ([]T, error) { + type Result struct { + Value T + UsedAmount float64 + RequestCount int64 + } + + query := LogDB.Model(&GroupChannelTokenSummaryMinute{}) + if group != "" { + query = query.Where("group_id = ?", group) + } + + if tokenName != "" { + query = query.Where("token_name = ?", tokenName) + } + + switch { + case !start.IsZero() && !end.IsZero(): + query = query.Where("minute_timestamp BETWEEN ? AND ?", start.Unix(), end.Unix()) + case !start.IsZero(): + query = query.Where("minute_timestamp >= ?", start.Unix()) + case !end.IsZero(): + query = query.Where("minute_timestamp <= ?", end.Unix()) + } + + var results []Result + + err := query. + Select( + field + " as value, SUM(request_count) as request_count, SUM(used_amount) as used_amount", + ). + Group(field). + Find(&results).Error + if err != nil { + return nil, err + } + + slices.SortFunc(results, func(a, b Result) int { + if a.UsedAmount != b.UsedAmount { + return cmp.Compare(b.UsedAmount, a.UsedAmount) + } + + if a.RequestCount != b.RequestCount { + return cmp.Compare(b.RequestCount, a.RequestCount) + } + + return cmp.Compare(a.Value, b.Value) + }) + + values := make([]T, len(results)) + for i, result := range results { + values[i] = result.Value + } + + return values, nil +} + +func getGroupChannelTokenDashboardDataMinute( + group string, + start, end time.Time, + tokenName string, + modelName string, + timeSpan TimeSpanType, + timezone *time.Location, + fields SummarySelectFields, +) (*GroupDashboardResponse, error) { + if group == "" { + return nil, errors.New("group is required") + } + + if end.IsZero() { + end = time.Now() + } else if end.Before(start) { + return nil, errors.New("end time is before start time") + } + + var ( + chartData []ChartData + tokenNames []string + models []string + currentRPM int64 + currentTPM int64 + ) + + chartData, err := getGroupChannelTokenChartDataMinute( + group, + start, + end, + tokenName, + modelName, + timeSpan, + timezone, + fields, + ) + if err != nil { + return nil, err + } + + tokenNames, err = GetGroupChannelTokenUsedTokenNamesMinute(group, start, end) + if err != nil { + return nil, err + } + + models, err = GetGroupChannelTokenUsedModelsMinute(group, tokenName, start, end) + if err != nil { + return nil, err + } + + currentRPM, currentTPM = getGroupChannelTokenCurrentRPM(group, tokenName, modelName) + + dashboardResponse := sumDashboardResponse(chartData) + dashboardResponse.Models = models + dashboardResponse.RPM = currentRPM + dashboardResponse.TPM = currentTPM + + return &GroupDashboardResponse{ + DashboardResponse: dashboardResponse, + TokenNames: tokenNames, + }, nil +} + +func getGlobalGroupChannelTokenDashboardDataMinute( + group string, + start, end time.Time, + tokenName string, + modelName string, + timeSpan TimeSpanType, + timezone *time.Location, + fields SummarySelectFields, +) (*GroupDashboardResponse, error) { + if end.IsZero() { + end = time.Now() + } else if end.Before(start) { + return nil, errors.New("end time is before start time") + } + + var ( + chartData []ChartData + tokenNames []string + models []string + currentRPM int64 + currentTPM int64 + ) + + chartData, err := getGroupChannelTokenChartDataMinute( + group, + start, + end, + tokenName, + modelName, + timeSpan, + timezone, + fields, + ) + if err != nil { + return nil, err + } + + tokenNames, err = GetGlobalGroupChannelTokenUsedTokenNamesMinute(group, start, end) + if err != nil { + return nil, err + } + + models, err = GetGlobalGroupChannelTokenUsedModelsMinute(group, tokenName, start, end) + if err != nil { + return nil, err + } + + currentRPM, currentTPM = getGroupChannelTokenCurrentRPM(group, tokenName, modelName) + + dashboardResponse := sumDashboardResponse(chartData) + dashboardResponse.Models = models + dashboardResponse.RPM = currentRPM + dashboardResponse.TPM = currentTPM + + return &GroupDashboardResponse{ + DashboardResponse: dashboardResponse, + TokenNames: tokenNames, + }, nil +} + +func getGroupChannelTokenTimeSeriesModelDataMinute( + group string, + tokenName string, + modelName string, + start, end time.Time, + timeSpan TimeSpanType, + timezone *time.Location, + fields SummarySelectFields, +) ([]TimeSummaryDataV2, error) { + modelName = normalizeSummaryModelFilter(modelName) + + if group == "" { + return nil, errors.New("group is required") + } + + if end.IsZero() { + end = time.Now() + } else if end.Before(start) { + return nil, errors.New("end time is before start time") + } + + query := LogDB.Model(&GroupChannelTokenSummaryMinute{}).Where("group_id = ?", group) + if tokenName != "" { + query = query.Where("token_name = ?", tokenName) + } + + if modelName != "" { + query = query.Where("model = ?", modelName) + } + + switch { + case !start.IsZero() && !end.IsZero(): + query = query.Where("minute_timestamp BETWEEN ? AND ?", start.Unix(), end.Unix()) + case !start.IsZero(): + query = query.Where("minute_timestamp >= ?", start.Unix()) + case !end.IsZero(): + query = query.Where("minute_timestamp <= ?", end.Unix()) + } + + var rawData []SummaryDataV2 + + err := query. + Select(fields.BuildSelectFieldsV2("minute_timestamp", "group_id, token_name, model")). + Group("timestamp, group_id, token_name, model"). + Find(&rawData).Error + if err != nil { + return nil, err + } + + for i, data := range rawData { + rawData[i].MaxRPM = data.RequestCount + rawData[i].MaxTPM = int64(data.TotalTokens) + } + + if len(rawData) > 0 && timeSpan != TimeSpanMinute { + rawData = aggregatToSpanGroup(rawData, timeSpan, timezone) + } + + result := convertToTimeModelData(rawData) + slices.SortFunc(result, func(a, b TimeSummaryDataV2) int { + return cmp.Compare(a.Timestamp, b.Timestamp) + }) + + return result, nil +} + +func getGlobalGroupChannelTokenTimeSeriesModelDataMinute( + group string, + tokenName string, + modelName string, + start, end time.Time, + timeSpan TimeSpanType, + timezone *time.Location, + fields SummarySelectFields, +) ([]TimeSummaryDataV2, error) { + modelName = normalizeSummaryModelFilter(modelName) + + if end.IsZero() { + end = time.Now() + } else if end.Before(start) { + return nil, errors.New("end time is before start time") + } + + query := LogDB.Model(&GroupChannelTokenSummaryMinute{}) + if group != "" { + query = query.Where("group_id = ?", group) + } + + if tokenName != "" { + query = query.Where("token_name = ?", tokenName) + } + + if modelName != "" { + query = query.Where("model = ?", modelName) + } + + switch { + case !start.IsZero() && !end.IsZero(): + query = query.Where("minute_timestamp BETWEEN ? AND ?", start.Unix(), end.Unix()) + case !start.IsZero(): + query = query.Where("minute_timestamp >= ?", start.Unix()) + case !end.IsZero(): + query = query.Where("minute_timestamp <= ?", end.Unix()) + } + + var rawData []SummaryDataV2 + + err := query. + Select(fields.BuildSelectFieldsV2("minute_timestamp", "group_id, token_name, model")). + Group("timestamp, group_id, token_name, model"). + Find(&rawData).Error + if err != nil { + return nil, err + } + + for i, data := range rawData { + rawData[i].MaxRPM = data.RequestCount + rawData[i].MaxTPM = int64(data.TotalTokens) + } + + if len(rawData) > 0 && timeSpan != TimeSpanMinute { + rawData = aggregatToSpanGroup(rawData, timeSpan, timezone) + } + + result := convertToTimeModelData(rawData) + slices.SortFunc(result, func(a, b TimeSummaryDataV2) int { + return cmp.Compare(a.Timestamp, b.Timestamp) + }) + + return result, nil +} + +func getGroupChannelTokenCurrentRPM(group, tokenName, modelName string) (int64, int64) { + modelName = normalizeSummaryModelFilter(modelName) + now := time.Now() + recentStart := now.Add(-2 * time.Minute).Unix() + recentEnd := now.Unix() + + query := LogDB.Model(&GroupChannelTokenSummaryMinute{}). + Where("minute_timestamp >= ? AND minute_timestamp <= ?", recentStart, recentEnd) + if group != "" { + query = query.Where("group_id = ?", group) + } + + if tokenName != "" { + query = query.Where("token_name = ?", tokenName) + } + + if modelName != "" { + query = query.Where("model = ?", modelName) + } + + type Result struct { + RPM int64 `json:"rpm"` + TPM int64 `json:"tpm"` + } + + var result Result + + err := query. + Select("SUM(request_count) as rpm, SUM(total_tokens) as tpm"). + Where("minute_timestamp = (?)", + LogDB.Model(&GroupChannelTokenSummaryMinute{}). + Select("MAX(minute_timestamp)"). + Where("minute_timestamp >= ? AND minute_timestamp <= ?", recentStart, recentEnd). + Scopes(func(db *gorm.DB) *gorm.DB { + if group != "" { + db = db.Where("group_id = ?", group) + } + + if tokenName != "" { + db = db.Where("token_name = ?", tokenName) + } + + if modelName != "" { + db = db.Where("model = ?", modelName) + } + + return db + }), + ). + Find(&result).Error + if err != nil { + return 0, 0 + } + + return result.RPM, result.TPM +} diff --git a/core/model/groupchanneltest.go b/core/model/groupchanneltest.go new file mode 100644 index 00000000..85d57d91 --- /dev/null +++ b/core/model/groupchanneltest.go @@ -0,0 +1,35 @@ +package model + +import ( + "time" + + "github.com/bytedance/sonic" + "github.com/labring/aiproxy/core/relay/mode" +) + +type GroupChannelTest struct { + TestAt time.Time `json:"test_at"` + Model string `json:"model" gorm:"size:128;primaryKey"` + ActualModel string `json:"actual_model" gorm:"size:128"` + Response string `json:"response" gorm:"type:text"` + GroupID string `json:"group_id" gorm:"size:64;primaryKey"` + ChannelName string `json:"channel_name" gorm:"size:64"` + ChannelType ChannelType `json:"channel_type"` + GroupChannelID int `json:"group_channel_id" gorm:"primaryKey"` + Took float64 `json:"took"` + Success bool `json:"success"` + Mode mode.Mode `json:"mode"` + Code int `json:"code"` +} + +func (ct *GroupChannelTest) MarshalJSON() ([]byte, error) { + type Alias GroupChannelTest + + return sonic.Marshal(&struct { + *Alias + TestAt int64 `json:"test_at"` + }{ + Alias: (*Alias)(ct), + TestAt: ct.TestAt.UnixMilli(), + }) +} diff --git a/core/model/groupsummary-minute.go b/core/model/groupsummary-minute.go index 061e2f18..4308b485 100644 --- a/core/model/groupsummary-minute.go +++ b/core/model/groupsummary-minute.go @@ -144,3 +144,44 @@ func GetGroupTokenLastRequestTimeMinute(group, token string) (time.Time, error) return time.Unix(summary.Unique.MinuteTimestamp, 0), err } + +type groupTokenLastRequestTimeRow struct { + TokenName string + LastRequestAt int64 +} + +func GetGroupTokenLastRequestTimesMinute( + group string, + tokenNames []string, +) (map[string]time.Time, error) { + if group == "" { + return nil, errors.New("group is required") + } + + if len(tokenNames) == 0 { + return map[string]time.Time{}, nil + } + + rows := make([]groupTokenLastRequestTimeRow, 0, len(tokenNames)) + + err := LogDB. + Model(&GroupSummaryMinute{}). + Select("token_name, MAX(minute_timestamp) AS last_request_at"). + Where("group_id = ? AND token_name IN ?", group, tokenNames). + Group("token_name"). + Find(&rows).Error + if err != nil { + return nil, err + } + + result := make(map[string]time.Time, len(rows)) + for _, row := range rows { + if row.LastRequestAt == 0 { + continue + } + + result[row.TokenName] = time.Unix(row.LastRequestAt, 0) + } + + return result, nil +} diff --git a/core/model/groupsummary_minute_test.go b/core/model/groupsummary_minute_test.go new file mode 100644 index 00000000..93046c7a --- /dev/null +++ b/core/model/groupsummary_minute_test.go @@ -0,0 +1,78 @@ +//nolint:testpackage +package model + +import ( + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestGetGroupTokenLastRequestTimesMinute(t *testing.T) { + oldLogDB := LogDB + + db, err := OpenSQLite(filepath.Join(t.TempDir(), "groupsummary_minute_test.db")) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&GroupSummaryMinute{})) + + LogDB = db + + t.Cleanup(func() { + LogDB = oldLogDB + + sqlDB, err := db.DB() + require.NoError(t, err) + require.NoError(t, sqlDB.Close()) + }) + + oldMinute := time.Now().Add(-2 * time.Minute).Truncate(time.Minute) + newMinute := time.Now().Add(-time.Minute).Truncate(time.Minute) + require.NoError(t, db.Create(&[]GroupSummaryMinute{ + { + Unique: GroupSummaryMinuteUnique{ + GroupID: "group-1", + TokenName: "token-a", + Model: "gpt-5", + MinuteTimestamp: oldMinute.Unix(), + }, + }, + { + Unique: GroupSummaryMinuteUnique{ + GroupID: "group-1", + TokenName: "token-a", + Model: "gpt-5-mini", + MinuteTimestamp: newMinute.Unix(), + }, + }, + { + Unique: GroupSummaryMinuteUnique{ + GroupID: "group-1", + TokenName: "token-b", + Model: "gpt-5", + MinuteTimestamp: oldMinute.Unix(), + }, + }, + { + Unique: GroupSummaryMinuteUnique{ + GroupID: "group-2", + TokenName: "token-a", + Model: "gpt-5", + MinuteTimestamp: newMinute.Unix(), + }, + }, + }).Error) + + lastRequestTimes, err := GetGroupTokenLastRequestTimesMinute( + "group-1", + []string{"token-a", "token-b", "token-c"}, + ) + require.NoError(t, err) + require.Equal(t, newMinute.Unix(), lastRequestTimes["token-a"].Unix()) + require.Equal(t, oldMinute.Unix(), lastRequestTimes["token-b"].Unix()) + require.NotContains(t, lastRequestTimes, "token-c") + + empty, err := GetGroupTokenLastRequestTimesMinute("group-1", nil) + require.NoError(t, err) + require.Empty(t, empty) +} diff --git a/core/model/local_cache.go b/core/model/local_cache.go index 51749547..62aa7ba8 100644 --- a/core/model/local_cache.go +++ b/core/model/local_cache.go @@ -141,20 +141,15 @@ func cloneStringSlice(values []string) []string { return slices.Clone(values) } -func cloneStringSliceMap(values map[string][]string) map[string][]string { +func cloneStringStringMap(values map[string]string) map[string]string { if values == nil { return nil } - cloned := make(map[string][]string, len(values)) - for key, items := range values { - cloned[key] = cloneStringSlice(items) - } - - return cloned + return maps.Clone(values) } -func cloneStringStringMap(values map[string]string) map[string]string { +func cloneChannelConfigs(values ChannelConfigs) ChannelConfigs { if values == nil { return nil } @@ -218,8 +213,11 @@ func cloneTokenCache(token *TokenCache) *TokenCache { cloned := *token cloned.Subnets = redisStringSlice(cloneStringSlice([]string(token.Subnets))) cloned.Models = redisStringSlice(cloneStringSlice([]string(token.Models))) - cloned.availableSets = cloneStringSlice(token.availableSets) - cloned.modelsBySet = cloneStringSliceMap(token.modelsBySet) + cloned.Sets = redisStringSlice(cloneStringSlice([]string(token.Sets))) + cloned.GroupChannelModels = redisStringSlice( + cloneStringSlice([]string(token.GroupChannelModels)), + ) + cloned.GroupChannelSets = redisStringSlice(cloneStringSlice([]string(token.GroupChannelSets))) return &cloned } diff --git a/core/model/log.go b/core/model/log.go index d11aad26..30f0ad2c 100644 --- a/core/model/log.go +++ b/core/model/log.go @@ -182,6 +182,10 @@ func (l *Log) MarshalJSON() ([]byte, error) { } func GetLogDetail(logID int) (*RequestDetail, error) { + return getLogDetail(logID) +} + +func getLogDetail(logID int) (*RequestDetail, error) { var detail RequestDetail err := LogDB. @@ -200,6 +204,10 @@ func GetGroupLogDetail(logID int, group string) (*RequestDetail, error) { return nil, errors.New("invalid group parameter") } + return getLogDetailForGroup(logID, group) +} + +func getLogDetailForGroup(logID int, group string) (*RequestDetail, error) { var detail RequestDetail err := LogDB. @@ -215,6 +223,47 @@ func GetGroupLogDetail(logID int, group string) (*RequestDetail, error) { return &detail, nil } +func GetGroupChannelLogDetailForGroup(logID int, group string) (*RequestDetail, error) { + if group == "" { + return nil, errors.New("invalid group parameter") + } + + return getGroupChannelLogDetailByGroup(logID, group) +} + +func GetGroupChannelLogDetail(logID int) (*RequestDetail, error) { + return getGroupChannelLogDetailByGroup(logID, "") +} + +func getGroupChannelLogDetailByGroup(logID int, group string) (*RequestDetail, error) { + var detail GroupChannelRequestDetail + + query := LogDB. + Model(&GroupChannelRequestDetail{}). + Joins( + "JOIN group_channel_logs ON group_channel_logs.id = group_channel_request_details.log_id", + ). + Where("log_id = ?", logID) + if group != "" { + query = query.Where("group_channel_logs.group_id = ?", group) + } + + err := query.First(&detail).Error + if err != nil { + return nil, err + } + + return &RequestDetail{ + CreatedAt: detail.CreatedAt, + RequestBody: detail.RequestBody, + ResponseBody: detail.ResponseBody, + RequestBodyTruncated: detail.RequestBodyTruncated, + ResponseBodyTruncated: detail.ResponseBodyTruncated, + ID: detail.ID, + LogID: detail.LogID, + }, nil +} + const defaultCleanLogBatchSize = 10000 func CleanLog(batchSize int, optimize bool) (err error) { @@ -247,20 +296,12 @@ func cleanLog(batchSize int) error { logStorageHours := config.GetLogStorageHours() if logStorageHours != 0 { - subQuery := LogDB. - Model(&Log{}). - Where( - "created_at < ?", - time.Now().Add(-time.Duration(logStorageHours)*time.Hour), - ). - Limit(batchSize). - Select("id") - - err := LogDB. - Session(&gorm.Session{SkipDefaultTransaction: true}). - Where("id IN (?)", subQuery). - Delete(&Log{}).Error - if err != nil { + cutoff := time.Now().Add(-time.Duration(logStorageHours) * time.Hour) + if err := cleanLogTable[Log](cutoff, batchSize); err != nil { + return err + } + + if err := cleanLogTable[GroupChannelLog](cutoff, batchSize); err != nil { return err } } @@ -271,28 +312,42 @@ func cleanLog(batchSize int) error { } if retryLogStorageHours != 0 { - subQuery := LogDB. - Model(&RetryLog{}). - Where( - "created_at < ?", - time.Now().Add(-time.Duration(retryLogStorageHours)*time.Hour), - ). - Limit(batchSize). - Select("id") - - err := LogDB. - Session(&gorm.Session{SkipDefaultTransaction: true}). - Where("id IN (?)", subQuery). - Delete(&RetryLog{}).Error - if err != nil { + cutoff := time.Now().Add(-time.Duration(retryLogStorageHours) * time.Hour) + if err := cleanLogTable[RetryLog](cutoff, batchSize); err != nil { + return err + } + + if err := cleanLogTable[GroupChannelRetryLog](cutoff, batchSize); err != nil { return err } } - return LogDB. + if err := LogDB. Model(&StoreV2{}). Where("expires_at < ?", time.Now()). Delete(&StoreV2{}). + Error; err != nil { + return err + } + + return LogDB. + Model(&GroupChannelStoreV2{}). + Where("expires_at < ?", time.Now()). + Delete(&GroupChannelStoreV2{}). + Error +} + +func cleanLogTable[T any](cutoff time.Time, batchSize int) error { + subQuery := LogDB. + Model(new(T)). + Where("created_at < ?", cutoff). + Limit(batchSize). + Select("id") + + return LogDB. + Session(&gorm.Session{SkipDefaultTransaction: true}). + Where("id IN (?)", subQuery). + Delete(new(T)). Error } @@ -310,7 +365,7 @@ func optimizeLog() error { case common.UsingSQLite: return LogDB.Exec("VACUUM").Error default: - return LogDB.Exec("VACUUM ANALYZE logs").Error + return LogDB.Exec("VACUUM ANALYZE logs, group_channel_logs").Error } } @@ -328,24 +383,25 @@ func cleanLogDetail(batchSize int) error { batchSize = defaultCleanLogBatchSize } + cutoff := time.Now().Add(-time.Duration(detailStorageHours) * time.Hour) + if err := cleanLogDetailTable[RequestDetail](cutoff, batchSize); err != nil { + return err + } + + return cleanLogDetailTable[GroupChannelRequestDetail](cutoff, batchSize) +} + +func cleanLogDetailTable[T any](cutoff time.Time, batchSize int) error { subQuery := LogDB. - Model(&RequestDetail{}). - Where( - "created_at < ?", - time.Now().Add(-time.Duration(detailStorageHours)*time.Hour), - ). + Model(new(T)). + Where("created_at < ?", cutoff). Limit(batchSize). Select("id") - err := LogDB. + return LogDB. Session(&gorm.Session{SkipDefaultTransaction: true}). Where("id IN (?)", subQuery). - Delete(&RequestDetail{}).Error - if err != nil { - return err - } - - return nil + Delete(new(T)).Error } func RecordConsumeLog( @@ -461,6 +517,13 @@ type GetGroupLogsResult struct { TokenNames []string `json:"token_names"` } +type GetGroupChannelLogsResult struct { + Logs []*GroupChannelLog `json:"logs"` + Total int64 `json:"total"` + Models []string `json:"models,omitempty"` + TokenNames []string `json:"token_names"` +} + func buildGetLogsQuery( group string, startTimestamp time.Time, @@ -537,8 +600,204 @@ func buildGetLogsQuery( return tx } -func getLogs( +func buildGetGroupChannelLogsQuery( + group string, + startTimestamp time.Time, + endTimestamp time.Time, + modelName string, + requestID string, + upstreamID string, + tokenID int, + tokenName string, + channelID int, + codeType CodeType, + code int, + ip string, + user string, +) *gorm.DB { + return applyGroupChannelLogFilters( + LogDB.Model(&GroupChannelLog{}), + group, + true, + startTimestamp, + endTimestamp, + modelName, + requestID, + upstreamID, + tokenID, + tokenName, + channelID, + codeType, + code, + ip, + user, + ) +} + +func buildGetGlobalGroupChannelLogsQuery( + group string, + startTimestamp time.Time, + endTimestamp time.Time, + modelName string, + requestID string, + upstreamID string, + tokenID int, + tokenName string, + channelID int, + codeType CodeType, + code int, + ip string, + user string, +) *gorm.DB { + return applyGroupChannelLogFilters( + LogDB.Model(&GroupChannelLog{}), + group, + false, + startTimestamp, + endTimestamp, + modelName, + requestID, + upstreamID, + tokenID, + tokenName, + channelID, + codeType, + code, + ip, + user, + ) +} + +func applyGroupChannelLogFilters( + tx *gorm.DB, + group string, + requireGroup bool, + startTimestamp time.Time, + endTimestamp time.Time, + modelName string, + requestID string, + upstreamID string, + tokenID int, + tokenName string, + channelID int, + codeType CodeType, + code int, + ip string, + user string, +) *gorm.DB { + if requestID != "" { + tx = tx.Where("request_id = ?", requestID) + } + + if upstreamID != "" { + tx = tx.Where("upstream_id = ?", upstreamID) + } + + if ip != "" { + tx = tx.Where("ip = ?", ip) + } + + if requireGroup || group != "" { + tx = tx.Where("group_id = ?", group) + } + + if modelName != "" { + tx = tx.Where("model = ?", modelName) + } + + if tokenName != "" { + tx = tx.Where("token_name = ?", tokenName) + } + + if channelID != 0 { + tx = tx.Where("group_channel_id = ?", channelID) + } + + switch { + case !startTimestamp.IsZero() && !endTimestamp.IsZero(): + tx = tx.Where("created_at BETWEEN ? AND ?", startTimestamp, endTimestamp) + case !startTimestamp.IsZero(): + tx = tx.Where("created_at >= ?", startTimestamp) + case !endTimestamp.IsZero(): + tx = tx.Where("created_at <= ?", endTimestamp) + } + + switch codeType { + case CodeTypeSuccess: + tx = tx.Where("code = 200") + case CodeTypeError: + tx = tx.Where("code != 200") + default: + if code != 0 { + tx = tx.Where("code = ?", code) + } + } + + if tokenID != 0 { + tx = tx.Where("token_id = ?", tokenID) + } + + if user != "" { + tx = tx.Where("user = ?", user) + } + + return tx +} + +func buildGroupChannelLogsQuery( + group string, + requireGroup bool, + startTimestamp time.Time, + endTimestamp time.Time, + modelName string, + requestID string, + upstreamID string, + tokenID int, + tokenName string, + channelID int, + codeType CodeType, + code int, + ip string, + user string, +) *gorm.DB { + if requireGroup { + return buildGetGroupChannelLogsQuery( + group, + startTimestamp, + endTimestamp, + modelName, + requestID, + upstreamID, + tokenID, + tokenName, + channelID, + codeType, + code, + ip, + user, + ) + } + + return buildGetGlobalGroupChannelLogsQuery( + group, + startTimestamp, + endTimestamp, + modelName, + requestID, + upstreamID, + tokenID, + tokenName, + channelID, + codeType, + code, + ip, + user, + ) +} + +func getGroupChannelLogsByScope( group string, + requireGroup bool, startTimestamp time.Time, endTimestamp time.Time, modelName string, @@ -555,17 +814,17 @@ func getLogs( user string, page int, perPage int, -) (int64, []*Log, error) { +) (int64, []*GroupChannelLog, error) { var ( - total int64 - logs []*Log + total int64 + groupChannelLogs []*GroupChannelLog ) g := new(errgroup.Group) - g.Go(func() error { - return buildGetLogsQuery( + return buildGroupChannelLogsQuery( group, + requireGroup, startTimestamp, endTimestamp, modelName, @@ -582,8 +841,9 @@ func getLogs( }) g.Go(func() error { - query := buildGetLogsQuery( + query := buildGroupChannelLogsQuery( group, + requireGroup, startTimestamp, endTimestamp, modelName, @@ -605,28 +865,29 @@ func getLogs( }) } + query = query.Order(getLogOrder(order)) limit, offset := toLimitOffset(page, perPage) + query = query.Limit(limit).Offset(offset) - return query. - Order(getLogOrder(order)). - Limit(limit). - Offset(offset). - Find(&logs).Error + return query.Find(&groupChannelLogs).Error }) if err := g.Wait(); err != nil { return 0, nil, err } - return total, logs, nil + return total, groupChannelLogs, nil } -func GetLogs( +func getGlobalGroupChannelLogs( + group string, startTimestamp time.Time, endTimestamp time.Time, modelName string, requestID string, upstreamID string, + tokenID int, + tokenName string, channelID int, order string, codeType CodeType, @@ -636,9 +897,170 @@ func GetLogs( user string, page int, perPage int, -) (*GetLogsResult, error) { - var ( - total int64 +) (int64, []*GroupChannelLog, error) { + return getGroupChannelLogsByScope( + group, + false, + startTimestamp, + endTimestamp, + modelName, + requestID, + upstreamID, + tokenID, + tokenName, + channelID, + order, + codeType, + code, + withBody, + ip, + user, + page, + perPage, + ) +} + +func getGroupChannelLogs( + group string, + startTimestamp time.Time, + endTimestamp time.Time, + modelName string, + requestID string, + upstreamID string, + tokenID int, + tokenName string, + channelID int, + order string, + codeType CodeType, + code int, + withBody bool, + ip string, + user string, + page int, + perPage int, +) (int64, []*GroupChannelLog, error) { + return getGroupChannelLogsByScope( + group, + true, + startTimestamp, + endTimestamp, + modelName, + requestID, + upstreamID, + tokenID, + tokenName, + channelID, + order, + codeType, + code, + withBody, + ip, + user, + page, + perPage, + ) +} + +func getLogs( + group string, + startTimestamp time.Time, + endTimestamp time.Time, + modelName string, + requestID string, + upstreamID string, + tokenID int, + tokenName string, + channelID int, + order string, + codeType CodeType, + code int, + withBody bool, + ip string, + user string, + page int, + perPage int, +) (int64, []*Log, error) { + var ( + total int64 + logs []*Log + ) + + g := new(errgroup.Group) + + g.Go(func() error { + return buildGetLogsQuery( + group, + startTimestamp, + endTimestamp, + modelName, + requestID, + upstreamID, + tokenID, + tokenName, + channelID, + codeType, + code, + ip, + user, + ).Count(&total).Error + }) + + g.Go(func() error { + query := buildGetLogsQuery( + group, + startTimestamp, + endTimestamp, + modelName, + requestID, + upstreamID, + tokenID, + tokenName, + channelID, + codeType, + code, + ip, + user, + ) + if withBody { + query = query.Preload("RequestDetail") + } else { + query = query.Preload("RequestDetail", func(db *gorm.DB) *gorm.DB { + return db.Select("id", "log_id") + }) + } + + query = query.Order(getLogOrder(order)) + limit, offset := toLimitOffset(page, perPage) + query = query.Limit(limit).Offset(offset) + + return query.Find(&logs).Error + }) + + if err := g.Wait(); err != nil { + return 0, nil, err + } + + return total, logs, nil +} + +func GetLogs( + startTimestamp time.Time, + endTimestamp time.Time, + modelName string, + requestID string, + upstreamID string, + channelID int, + order string, + codeType CodeType, + code int, + withBody bool, + ip string, + user string, + page int, + perPage int, +) (*GetLogsResult, error) { + var ( + total int64 logs []*Log channels []int ) @@ -700,6 +1122,7 @@ func GetGroupLogs( upstreamID string, tokenID int, tokenName string, + channelID int, order string, codeType CodeType, code int, @@ -734,7 +1157,7 @@ func GetGroupLogs( upstreamID, tokenID, tokenName, - 0, + channelID, order, codeType, code, @@ -776,7 +1199,7 @@ func GetGroupLogs( }, nil } -func exportLogs( +func GetGroupChannelLogs( group string, startTimestamp time.Time, endTimestamp time.Time, @@ -792,42 +1215,78 @@ func exportLogs( withBody bool, ip string, user string, - maxEntries int, -) ([]*Log, error) { - var logs []*Log + page int, + perPage int, +) (*GetGroupChannelLogsResult, error) { + if group == "" { + return nil, errors.New("group is required") + } - query := buildGetLogsQuery( - group, - startTimestamp, - endTimestamp, - modelName, - requestID, - upstreamID, - tokenID, - tokenName, - channelID, - codeType, - code, - ip, - user, + var ( + total int64 + logs []*GroupChannelLog + tokenNames []string + models []string ) - if withBody { - query = query.Preload("RequestDetail") - } + g := new(errgroup.Group) - query = query.Order(getLogOrder(order)) - if maxEntries > 0 { - query = query.Limit(maxEntries) + g.Go(func() error { + var err error + + total, logs, err = getGroupChannelLogs( + group, + startTimestamp, + endTimestamp, + modelName, + requestID, + upstreamID, + tokenID, + tokenName, + channelID, + order, + codeType, + code, + withBody, + ip, + user, + page, + perPage, + ) + + return err + }) + + g.Go(func() error { + var err error + + tokenNames, err = GetGroupChannelTokenUsedTokenNames(group, startTimestamp, endTimestamp) + return err + }) + + g.Go(func() error { + var err error + + models, err = GetGroupChannelTokenUsedModels(group, tokenName, startTimestamp, endTimestamp) + return err + }) + + if err := g.Wait(); err != nil { + return nil, err } - return logs, query.Find(&logs).Error + return &GetGroupChannelLogsResult{ + Logs: logs, + Total: total, + Models: models, + TokenNames: tokenNames, + }, nil } -func exportLogsRange( +func GetGlobalGroupChannelLogs( group string, startTimestamp time.Time, - endExclusive time.Time, + endTimestamp time.Time, modelName string, requestID string, upstreamID string, @@ -840,24 +1299,163 @@ func exportLogsRange( withBody bool, ip string, user string, - maxEntries int, -) ([]*Log, error) { - var logs []*Log - - query := buildGetLogsQuery( - group, - time.Time{}, - time.Time{}, - modelName, - requestID, - upstreamID, - tokenID, - tokenName, - channelID, - codeType, - code, - ip, - user, + page int, + perPage int, +) (*GetGroupChannelLogsResult, error) { + var ( + total int64 + logs []*GroupChannelLog + tokenNames []string + models []string + ) + + g := new(errgroup.Group) + + g.Go(func() error { + var err error + + total, logs, err = getGlobalGroupChannelLogs( + group, + startTimestamp, + endTimestamp, + modelName, + requestID, + upstreamID, + tokenID, + tokenName, + channelID, + order, + codeType, + code, + withBody, + ip, + user, + page, + perPage, + ) + + return err + }) + + g.Go(func() error { + var err error + + tokenNames, err = GetGlobalGroupChannelTokenUsedTokenNames( + group, + startTimestamp, + endTimestamp, + ) + + return err + }) + + g.Go(func() error { + var err error + + models, err = GetGlobalGroupChannelTokenUsedModels( + group, + tokenName, + startTimestamp, + endTimestamp, + ) + + return err + }) + + if err := g.Wait(); err != nil { + return nil, err + } + + return &GetGroupChannelLogsResult{ + Logs: logs, + Total: total, + Models: models, + TokenNames: tokenNames, + }, nil +} + +func exportLogs( + group string, + startTimestamp time.Time, + endTimestamp time.Time, + modelName string, + requestID string, + upstreamID string, + tokenID int, + tokenName string, + channelID int, + order string, + codeType CodeType, + code int, + withBody bool, + ip string, + user string, + maxEntries int, +) ([]*Log, error) { + var logs []*Log + + query := buildGetLogsQuery( + group, + startTimestamp, + endTimestamp, + modelName, + requestID, + upstreamID, + tokenID, + tokenName, + channelID, + codeType, + code, + ip, + user, + ) + + if withBody { + query = query.Preload("RequestDetail") + } + + query = query.Order(getLogOrder(order)) + if maxEntries > 0 { + query = query.Limit(maxEntries) + } + + return logs, query.Find(&logs).Error +} + +func exportLogsRange( + group string, + startTimestamp time.Time, + endExclusive time.Time, + modelName string, + requestID string, + upstreamID string, + tokenID int, + tokenName string, + channelID int, + order string, + codeType CodeType, + code int, + withBody bool, + ip string, + user string, + maxEntries int, +) ([]*Log, error) { + var logs []*Log + + query := buildGetLogsQuery( + group, + time.Time{}, + time.Time{}, + modelName, + requestID, + upstreamID, + tokenID, + tokenName, + channelID, + codeType, + code, + ip, + user, ) if !startTimestamp.IsZero() { @@ -880,6 +1478,192 @@ func exportLogsRange( return logs, query.Find(&logs).Error } +func exportGroupChannelLogsByScope( + group string, + requireGroup bool, + startTimestamp time.Time, + endTimestamp time.Time, + modelName string, + requestID string, + upstreamID string, + tokenID int, + tokenName string, + channelID int, + order string, + codeType CodeType, + code int, + withBody bool, + ip string, + user string, + maxEntries int, +) ([]*GroupChannelLog, error) { + var groupChannelLogs []*GroupChannelLog + + query := buildGroupChannelLogsQuery( + group, + requireGroup, + startTimestamp, + endTimestamp, + modelName, + requestID, + upstreamID, + tokenID, + tokenName, + channelID, + codeType, + code, + ip, + user, + ) + + if withBody { + query = query.Preload("RequestDetail") + } + + query = query.Order(getLogOrder(order)) + if maxEntries > 0 { + query = query.Limit(maxEntries) + } + + return groupChannelLogs, query.Find(&groupChannelLogs).Error +} + +func exportGroupChannelLogs( + group string, + startTimestamp time.Time, + endTimestamp time.Time, + modelName string, + requestID string, + upstreamID string, + tokenID int, + tokenName string, + channelID int, + order string, + codeType CodeType, + code int, + withBody bool, + ip string, + user string, + maxEntries int, +) ([]*GroupChannelLog, error) { + return exportGroupChannelLogsByScope( + group, + true, + startTimestamp, + endTimestamp, + modelName, + requestID, + upstreamID, + tokenID, + tokenName, + channelID, + order, + codeType, + code, + withBody, + ip, + user, + maxEntries, + ) +} + +func exportGroupChannelLogsRangeByScope( + group string, + requireGroup bool, + startTimestamp time.Time, + endExclusive time.Time, + modelName string, + requestID string, + upstreamID string, + tokenID int, + tokenName string, + channelID int, + order string, + codeType CodeType, + code int, + withBody bool, + ip string, + user string, + maxEntries int, +) ([]*GroupChannelLog, error) { + var groupChannelLogs []*GroupChannelLog + + query := buildGroupChannelLogsQuery( + group, + requireGroup, + time.Time{}, + time.Time{}, + modelName, + requestID, + upstreamID, + tokenID, + tokenName, + channelID, + codeType, + code, + ip, + user, + ) + + if !startTimestamp.IsZero() { + query = query.Where("created_at >= ?", startTimestamp) + } + + if !endExclusive.IsZero() { + query = query.Where("created_at < ?", endExclusive) + } + + if withBody { + query = query.Preload("RequestDetail") + } + + query = query.Order(getLogOrder(order)) + if maxEntries > 0 { + query = query.Limit(maxEntries) + } + + return groupChannelLogs, query.Find(&groupChannelLogs).Error +} + +func exportGroupChannelLogsRange( + group string, + startTimestamp time.Time, + endExclusive time.Time, + modelName string, + requestID string, + upstreamID string, + tokenID int, + tokenName string, + channelID int, + order string, + codeType CodeType, + code int, + withBody bool, + ip string, + user string, + maxEntries int, +) ([]*GroupChannelLog, error) { + return exportGroupChannelLogsRangeByScope( + group, + true, + startTimestamp, + endExclusive, + modelName, + requestID, + upstreamID, + tokenID, + tokenName, + channelID, + order, + codeType, + code, + withBody, + ip, + user, + maxEntries, + ) +} + func ExportLogs( startTimestamp time.Time, endTimestamp time.Time, @@ -924,6 +1708,165 @@ func ExportGroupLogs( upstreamID string, tokenID int, tokenName string, + channelID int, + order string, + codeType CodeType, + code int, + withBody bool, + ip string, + user string, + maxEntries int, +) ([]*Log, error) { + if group == "" { + return nil, errors.New("group is required") + } + + return exportLogs( + group, + startTimestamp, + endTimestamp, + modelName, + requestID, + upstreamID, + tokenID, + tokenName, + channelID, + order, + codeType, + code, + withBody, + ip, + user, + maxEntries, + ) +} + +func ExportGroupChannelLogs( + group string, + startTimestamp time.Time, + endTimestamp time.Time, + modelName string, + requestID string, + upstreamID string, + tokenID int, + tokenName string, + channelID int, + order string, + codeType CodeType, + code int, + withBody bool, + ip string, + user string, + maxEntries int, +) ([]*GroupChannelLog, error) { + if group == "" { + return nil, errors.New("group is required") + } + + return exportGroupChannelLogs( + group, + startTimestamp, + endTimestamp, + modelName, + requestID, + upstreamID, + tokenID, + tokenName, + channelID, + order, + codeType, + code, + withBody, + ip, + user, + maxEntries, + ) +} + +func ExportGlobalGroupChannelLogs( + group string, + startTimestamp time.Time, + endTimestamp time.Time, + modelName string, + requestID string, + upstreamID string, + tokenID int, + tokenName string, + channelID int, + order string, + codeType CodeType, + code int, + withBody bool, + ip string, + user string, + maxEntries int, +) ([]*GroupChannelLog, error) { + return exportGroupChannelLogsByScope( + group, + false, + startTimestamp, + endTimestamp, + modelName, + requestID, + upstreamID, + tokenID, + tokenName, + channelID, + order, + codeType, + code, + withBody, + ip, + user, + maxEntries, + ) +} + +func ExportLogsRange( + startTimestamp time.Time, + endExclusive time.Time, + modelName string, + requestID string, + upstreamID string, + channelID int, + order string, + codeType CodeType, + code int, + withBody bool, + ip string, + user string, + maxEntries int, +) ([]*Log, error) { + return exportLogsRange( + "", + startTimestamp, + endExclusive, + modelName, + requestID, + upstreamID, + 0, + "", + channelID, + order, + codeType, + code, + withBody, + ip, + user, + maxEntries, + ) +} + +func ExportGroupLogsRange( + group string, + startTimestamp time.Time, + endExclusive time.Time, + modelName string, + requestID string, + upstreamID string, + tokenID int, + tokenName string, + channelID int, order string, codeType CodeType, code int, @@ -936,16 +1879,16 @@ func ExportGroupLogs( return nil, errors.New("group is required") } - return exportLogs( + return exportLogsRange( group, startTimestamp, - endTimestamp, + endExclusive, modelName, requestID, upstreamID, tokenID, tokenName, - 0, + channelID, order, codeType, code, @@ -956,12 +1899,15 @@ func ExportGroupLogs( ) } -func ExportLogsRange( +func ExportGroupChannelLogsRange( + group string, startTimestamp time.Time, endExclusive time.Time, modelName string, requestID string, upstreamID string, + tokenID int, + tokenName string, channelID int, order string, codeType CodeType, @@ -970,16 +1916,20 @@ func ExportLogsRange( ip string, user string, maxEntries int, -) ([]*Log, error) { - return exportLogsRange( - "", +) ([]*GroupChannelLog, error) { + if group == "" { + return nil, errors.New("group is required") + } + + return exportGroupChannelLogsRange( + group, startTimestamp, endExclusive, modelName, requestID, upstreamID, - 0, - "", + tokenID, + tokenName, channelID, order, codeType, @@ -991,7 +1941,7 @@ func ExportLogsRange( ) } -func ExportGroupLogsRange( +func ExportGlobalGroupChannelLogsRange( group string, startTimestamp time.Time, endExclusive time.Time, @@ -1000,6 +1950,7 @@ func ExportGroupLogsRange( upstreamID string, tokenID int, tokenName string, + channelID int, order string, codeType CodeType, code int, @@ -1007,13 +1958,10 @@ func ExportGroupLogsRange( ip string, user string, maxEntries int, -) ([]*Log, error) { - if group == "" { - return nil, errors.New("group is required") - } - - return exportLogsRange( +) ([]*GroupChannelLog, error) { + return exportGroupChannelLogsRangeByScope( group, + false, startTimestamp, endExclusive, modelName, @@ -1021,7 +1969,7 @@ func ExportGroupLogsRange( upstreamID, tokenID, tokenName, - 0, + channelID, order, codeType, code, @@ -1166,7 +2114,299 @@ func buildSearchLogsQuery( return tx } -func searchLogs( +func buildSearchGroupChannelLogsQueryByScope( + group string, + requireGroup bool, + keyword string, + startTimestamp time.Time, + endTimestamp time.Time, + modelName string, + requestID string, + upstreamID string, + tokenID int, + tokenName string, + channelID int, + codeType CodeType, + code int, + ip string, + user string, +) *gorm.DB { + tx := buildGroupChannelLogsQuery( + group, + requireGroup, + startTimestamp, + endTimestamp, + modelName, + requestID, + upstreamID, + tokenID, + tokenName, + channelID, + codeType, + code, + ip, + user, + ) + + if keyword == "" { + return tx + } + + var ( + conditions []string + values []any + ) + + if requestID == "" { + conditions = append(conditions, "request_id = ?") + values = append(values, keyword) + } + + if upstreamID == "" { + conditions = append(conditions, "upstream_id = ?") + values = append(values, keyword) + } + + if group == "" { + conditions = append(conditions, "group_id = ?") + values = append(values, keyword) + } + + if modelName == "" { + conditions = append(conditions, "model = ?") + values = append(values, keyword) + } + + if tokenName == "" { + conditions = append(conditions, "token_name = ?") + values = append(values, keyword) + } + + if len(conditions) > 0 { + tx = tx.Where(fmt.Sprintf("(%s)", strings.Join(conditions, " OR ")), values...) + } + + return tx +} + +func searchLogs( + group string, + keyword string, + requestID string, + upstreamID string, + tokenID int, + tokenName string, + modelName string, + startTimestamp time.Time, + endTimestamp time.Time, + channelID int, + order string, + codeType CodeType, + code int, + withBody bool, + ip string, + user string, + page int, + perPage int, +) (int64, []*Log, error) { + var ( + total int64 + logs []*Log + ) + + g := new(errgroup.Group) + + g.Go(func() error { + return buildSearchLogsQuery( + group, + keyword, + requestID, + upstreamID, + tokenID, + tokenName, + modelName, + startTimestamp, + endTimestamp, + channelID, + codeType, + code, + ip, + user, + ).Count(&total).Error + }) + + g.Go(func() error { + query := buildSearchLogsQuery( + group, + keyword, + requestID, + upstreamID, + tokenID, + tokenName, + modelName, + startTimestamp, + endTimestamp, + channelID, + codeType, + code, + ip, + user, + ) + + if withBody { + query = query.Preload("RequestDetail") + } else { + query = query.Preload("RequestDetail", func(db *gorm.DB) *gorm.DB { + return db.Select("id", "log_id") + }) + } + + query = query.Order(getLogOrder(order)) + limit, offset := toLimitOffset(page, perPage) + query = query.Limit(limit).Offset(offset) + + return query.Find(&logs).Error + }) + + if err := g.Wait(); err != nil { + return 0, nil, err + } + + return total, logs, nil +} + +func searchGroupChannelLogsByScope( + group string, + requireGroup bool, + keyword string, + requestID string, + upstreamID string, + tokenID int, + tokenName string, + modelName string, + startTimestamp time.Time, + endTimestamp time.Time, + channelID int, + order string, + codeType CodeType, + code int, + withBody bool, + ip string, + user string, + page int, + perPage int, +) (int64, []*GroupChannelLog, error) { + var ( + total int64 + groupChannelLogs []*GroupChannelLog + ) + + g := new(errgroup.Group) + g.Go(func() error { + return buildSearchGroupChannelLogsQueryByScope( + group, + requireGroup, + keyword, + startTimestamp, + endTimestamp, + modelName, + requestID, + upstreamID, + tokenID, + tokenName, + channelID, + codeType, + code, + ip, + user, + ).Count(&total).Error + }) + + g.Go(func() error { + query := buildSearchGroupChannelLogsQueryByScope( + group, + requireGroup, + keyword, + startTimestamp, + endTimestamp, + modelName, + requestID, + upstreamID, + tokenID, + tokenName, + channelID, + codeType, + code, + ip, + user, + ) + + if withBody { + query = query.Preload("RequestDetail") + } else { + query = query.Preload("RequestDetail", func(db *gorm.DB) *gorm.DB { + return db.Select("id", "log_id") + }) + } + + query = query.Order(getLogOrder(order)) + limit, offset := toLimitOffset(page, perPage) + query = query.Limit(limit).Offset(offset) + + return query.Find(&groupChannelLogs).Error + }) + + if err := g.Wait(); err != nil { + return 0, nil, err + } + + return total, groupChannelLogs, nil +} + +func searchGlobalGroupChannelLogs( + group string, + keyword string, + requestID string, + upstreamID string, + tokenID int, + tokenName string, + modelName string, + startTimestamp time.Time, + endTimestamp time.Time, + channelID int, + order string, + codeType CodeType, + code int, + withBody bool, + ip string, + user string, + page int, + perPage int, +) (int64, []*GroupChannelLog, error) { + return searchGroupChannelLogsByScope( + group, + false, + keyword, + requestID, + upstreamID, + tokenID, + tokenName, + modelName, + startTimestamp, + endTimestamp, + channelID, + order, + codeType, + code, + withBody, + ip, + user, + page, + perPage, + ) +} + +func searchGroupChannelLogs( group string, keyword string, requestID string, @@ -1185,16 +2425,63 @@ func searchLogs( user string, page int, perPage int, -) (int64, []*Log, error) { +) (int64, []*GroupChannelLog, error) { + return searchGroupChannelLogsByScope( + group, + true, + keyword, + requestID, + upstreamID, + tokenID, + tokenName, + modelName, + startTimestamp, + endTimestamp, + channelID, + order, + codeType, + code, + withBody, + ip, + user, + page, + perPage, + ) +} + +func SearchLogs( + keyword string, + requestID string, + upstreamID string, + group string, + tokenID int, + tokenName string, + modelName string, + startTimestamp time.Time, + endTimestamp time.Time, + channelID int, + order string, + codeType CodeType, + code int, + withBody bool, + ip string, + user string, + page int, + perPage int, +) (*GetLogsResult, error) { var ( - total int64 - logs []*Log + total int64 + logs []*Log + channels []int + models []string ) g := new(errgroup.Group) g.Go(func() error { - return buildSearchLogsQuery( + var err error + + total, logs, err = searchLogs( group, keyword, requestID, @@ -1205,15 +2492,84 @@ func searchLogs( startTimestamp, endTimestamp, channelID, + order, codeType, code, + withBody, ip, user, - ).Count(&total).Error + page, + perPage, + ) + + return err }) g.Go(func() error { - query := buildSearchLogsQuery( + var err error + + channels, err = GetUsedChannels(startTimestamp, endTimestamp) + return err + }) + + g.Go(func() error { + var err error + + models, err = GetUsedModels(channelID, startTimestamp, endTimestamp) + return err + }) + + if err := g.Wait(); err != nil { + return nil, err + } + + result := &GetLogsResult{ + Logs: logs, + Total: total, + Channels: channels, + Models: models, + } + + return result, nil +} + +func SearchGroupLogs( + group string, + keyword string, + requestID string, + upstreamID string, + tokenID int, + tokenName string, + modelName string, + startTimestamp time.Time, + endTimestamp time.Time, + channelID int, + order string, + codeType CodeType, + code int, + withBody bool, + ip string, + user string, + page int, + perPage int, +) (*GetGroupLogsResult, error) { + if group == "" { + return nil, errors.New("group is required") + } + + var ( + total int64 + logs []*Log + tokenNames []string + models []string + ) + + g := new(errgroup.Group) + + g.Go(func() error { + var err error + + total, logs, err = searchLogs( group, keyword, requestID, @@ -1224,41 +2580,54 @@ func searchLogs( startTimestamp, endTimestamp, channelID, + order, codeType, code, + withBody, ip, user, + page, + perPage, ) - if withBody { - query = query.Preload("RequestDetail") - } else { - query = query.Preload("RequestDetail", func(db *gorm.DB) *gorm.DB { - return db.Select("id", "log_id") - }) - } + return err + }) - limit, offset := toLimitOffset(page, perPage) + g.Go(func() error { + var err error + + tokenNames, err = GetGroupUsedTokenNames(group, startTimestamp, endTimestamp) + return err + }) + + g.Go(func() error { + var err error - return query. - Order(getLogOrder(order)). - Limit(limit). - Offset(offset). - Find(&logs).Error + models, err = GetGroupUsedModels(group, tokenName, startTimestamp, endTimestamp) + return err }) if err := g.Wait(); err != nil { - return 0, nil, err + return nil, err } - return total, logs, nil + result := &GetGroupLogsResult{ + GetLogsResult: GetLogsResult{ + Logs: logs, + Total: total, + Models: models, + }, + TokenNames: tokenNames, + } + + return result, nil } -func SearchLogs( +func SearchGroupChannelLogs( + group string, keyword string, requestID string, upstreamID string, - group string, tokenID int, tokenName string, modelName string, @@ -1273,12 +2642,16 @@ func SearchLogs( user string, page int, perPage int, -) (*GetLogsResult, error) { +) (*GetGroupChannelLogsResult, error) { + if group == "" { + return nil, errors.New("group is required") + } + var ( - total int64 - logs []*Log - channels []int - models []string + total int64 + logs []*GroupChannelLog + tokenNames []string + models []string ) g := new(errgroup.Group) @@ -1286,7 +2659,7 @@ func SearchLogs( g.Go(func() error { var err error - total, logs, err = searchLogs( + total, logs, err = searchGroupChannelLogs( group, keyword, requestID, @@ -1313,14 +2686,14 @@ func SearchLogs( g.Go(func() error { var err error - channels, err = GetUsedChannels(startTimestamp, endTimestamp) + tokenNames, err = GetGroupChannelTokenUsedTokenNames(group, startTimestamp, endTimestamp) return err }) g.Go(func() error { var err error - models, err = GetUsedModels(channelID, startTimestamp, endTimestamp) + models, err = GetGroupChannelTokenUsedModels(group, tokenName, startTimestamp, endTimestamp) return err }) @@ -1328,17 +2701,17 @@ func SearchLogs( return nil, err } - result := &GetLogsResult{ - Logs: logs, - Total: total, - Channels: channels, - Models: models, + result := &GetGroupChannelLogsResult{ + Logs: logs, + Total: total, + Models: models, + TokenNames: tokenNames, } return result, nil } -func SearchGroupLogs( +func SearchGlobalGroupChannelLogs( group string, keyword string, requestID string, @@ -1348,6 +2721,7 @@ func SearchGroupLogs( modelName string, startTimestamp time.Time, endTimestamp time.Time, + channelID int, order string, codeType CodeType, code int, @@ -1356,14 +2730,10 @@ func SearchGroupLogs( user string, page int, perPage int, -) (*GetGroupLogsResult, error) { - if group == "" { - return nil, errors.New("group is required") - } - +) (*GetGroupChannelLogsResult, error) { var ( total int64 - logs []*Log + logs []*GroupChannelLog tokenNames []string models []string ) @@ -1373,7 +2743,8 @@ func SearchGroupLogs( g.Go(func() error { var err error - total, logs, err = searchLogs(group, + total, logs, err = searchGlobalGroupChannelLogs( + group, keyword, requestID, upstreamID, @@ -1382,7 +2753,7 @@ func SearchGroupLogs( modelName, startTimestamp, endTimestamp, - 0, + channelID, order, codeType, code, @@ -1399,14 +2770,25 @@ func SearchGroupLogs( g.Go(func() error { var err error - tokenNames, err = GetGroupUsedTokenNames(group, startTimestamp, endTimestamp) + tokenNames, err = GetGlobalGroupChannelTokenUsedTokenNames( + group, + startTimestamp, + endTimestamp, + ) + return err }) g.Go(func() error { var err error - models, err = GetGroupUsedModels(group, tokenName, startTimestamp, endTimestamp) + models, err = GetGlobalGroupChannelTokenUsedModels( + group, + tokenName, + startTimestamp, + endTimestamp, + ) + return err }) @@ -1414,12 +2796,10 @@ func SearchGroupLogs( return nil, err } - result := &GetGroupLogsResult{ - GetLogsResult: GetLogsResult{ - Logs: logs, - Total: total, - Models: models, - }, + result := &GetGroupChannelLogsResult{ + Logs: logs, + Total: total, + Models: models, TokenNames: tokenNames, } @@ -1427,7 +2807,22 @@ func SearchGroupLogs( } func DeleteOldLog(timestamp time.Time) (int64, error) { - result := LogDB.Where("created_at < ?", timestamp).Delete(&Log{}) + return deleteOldLogsFromTable[Log](timestamp) +} + +func DeleteOldGroupChannelLog(timestamp time.Time) (int64, error) { + return deleteOldLogsFromTable[GroupChannelLog](timestamp) +} + +func DeleteOldGroupChannelLogForGroup(groupID string, timestamp time.Time) (int64, error) { + if groupID == "" { + return 0, errors.New("group is required") + } + + result := LogDB. + Where("group_id = ? AND created_at < ?", groupID, timestamp). + Delete(&GroupChannelLog{}) + return result.RowsAffected, result.Error } @@ -1436,8 +2831,24 @@ func DeleteGroupLogs(groupID string) (int64, error) { return 0, errors.New("group is required") } - result := LogDB.Where("group_id = ?", groupID).Delete(&Log{}) + return deleteGroupLogsFromTable[Log](groupID) +} + +func DeleteGroupChannelLogs(groupID string) (int64, error) { + if groupID == "" { + return 0, errors.New("group is required") + } + + return deleteGroupLogsFromTable[GroupChannelLog](groupID) +} + +func deleteOldLogsFromTable[T any](timestamp time.Time) (int64, error) { + result := LogDB.Where("created_at < ?", timestamp).Delete(new(T)) + return result.RowsAffected, result.Error +} +func deleteGroupLogsFromTable[T any](groupID string) (int64, error) { + result := LogDB.Where("group_id = ?", groupID).Delete(new(T)) return result.RowsAffected, result.Error } diff --git a/core/model/log_test.go b/core/model/log_test.go index 376fb072..a54e9cb4 100644 --- a/core/model/log_test.go +++ b/core/model/log_test.go @@ -5,6 +5,7 @@ import ( "testing" "time" + "github.com/labring/aiproxy/core/common/config" "github.com/labring/aiproxy/core/model" ) @@ -189,6 +190,74 @@ func TestRecordConsumeLogLoadsNullWebSearchCountAsZero(t *testing.T) { } } +func TestGetLogsAppliesDefaultPagination(t *testing.T) { + db, err := model.OpenSQLite(filepath.Join(t.TempDir(), "logs.db")) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + + prevLogDB := model.LogDB + model.LogDB = db + t.Cleanup(func() { + model.LogDB = prevLogDB + }) + + if err := db.AutoMigrate(&model.Log{}, &model.RequestDetail{}, &model.Summary{}); err != nil { + t.Fatalf("migrate log db: %v", err) + } + + rows := make([]model.Log, 11) + + baseTime := time.Unix(1777052048, 0) + for i := range rows { + rows[i] = model.Log{ + CreatedAt: baseTime.Add(time.Duration(i) * time.Second), + RequestAt: baseTime.Add(time.Duration(i) * time.Second), + GroupID: "test-group", + Model: "gpt-5.4", + RequestID: model.EmptyNullString("req_default_page"), + UpstreamID: model.EmptyNullString("resp_default_page"), + Code: 200, + Mode: 1, + ChannelID: 1, + TokenID: 1, + TokenName: "test-token", + } + } + + if err := db.Create(&rows).Error; err != nil { + t.Fatalf("seed logs: %v", err) + } + + result, err := model.GetLogs( + time.Time{}, + time.Time{}, + "", + "", + "", + 0, + "", + "", + 0, + false, + "", + "", + 0, + 0, + ) + if err != nil { + t.Fatalf("get logs: %v", err) + } + + if result.Total != 11 { + t.Fatalf("expected total=11, got %d", result.Total) + } + + if len(result.Logs) != 10 { + t.Fatalf("expected default page size 10, got %d", len(result.Logs)) + } +} + func TestCleanupFinishedAsyncUsagesKeepsPending(t *testing.T) { db, err := model.OpenSQLite(filepath.Join(t.TempDir(), "logs.db")) if err != nil { @@ -349,6 +418,1052 @@ func TestGetPendingAsyncUsagesSkipsFutureNextPollAt(t *testing.T) { } } +func TestCleanLogRemovesExpiredGroupChannelLogs(t *testing.T) { + db, err := model.OpenSQLite(filepath.Join(t.TempDir(), "logs.db")) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + + prevLogDB := model.LogDB + model.LogDB = db + t.Cleanup(func() { + model.LogDB = prevLogDB + }) + + if err := db.AutoMigrate( + &model.Log{}, + &model.GroupChannelLog{}, + &model.RequestDetail{}, + &model.GroupChannelRequestDetail{}, + &model.RetryLog{}, + &model.GroupChannelRetryLog{}, + &model.StoreV2{}, + &model.GroupChannelStoreV2{}, + &model.AsyncUsageInfo{}, + ); err != nil { + t.Fatalf("migrate logs: %v", err) + } + + oldLogStorageHours := config.GetLogStorageHours() + oldRetryLogStorageHours := config.GetRetryLogStorageHours() + t.Cleanup(func() { + config.SetLogStorageHours(oldLogStorageHours) + config.SetRetryLogStorageHours(oldRetryLogStorageHours) + }) + config.SetLogStorageHours(1) + config.SetRetryLogStorageHours(0) + + oldTime := time.Now().Add(-2 * time.Hour) + recentTime := time.Now() + + rows := []model.GroupChannelLog{ + { + GroupID: "group-1", + GroupChannelID: 1, + Model: "gpt-5", + RequestID: "old_group_log", + CreatedAt: oldTime, + RequestAt: oldTime, + }, + { + GroupID: "group-1", + GroupChannelID: 1, + Model: "gpt-5", + RequestID: "recent_group_log", + CreatedAt: recentTime, + RequestAt: recentTime, + }, + } + if err := db.Create(&rows).Error; err != nil { + t.Fatalf("seed group channel logs: %v", err) + } + + if err := model.CleanLog(100, false); err != nil { + t.Fatalf("clean log: %v", err) + } + + var ids []string + if err := db.Model(&model.GroupChannelLog{}). + Order("request_id"). + Pluck("request_id", &ids).Error; err != nil { + t.Fatalf("list group channel logs: %v", err) + } + + want := []string{"recent_group_log"} + if len(ids) != len(want) { + t.Fatalf("expected remaining group channel log ids %v, got %v", want, ids) + } + + for i := range want { + if ids[i] != want[i] { + t.Fatalf("expected remaining group channel log ids %v, got %v", want, ids) + } + } +} + +func TestDeleteOldLogPreservesGroupChannelLogs(t *testing.T) { + db, err := model.OpenSQLite(filepath.Join(t.TempDir(), "logs.db")) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + + prevLogDB := model.LogDB + model.LogDB = db + t.Cleanup(func() { + model.LogDB = prevLogDB + }) + + if err := db.AutoMigrate(&model.Log{}, &model.GroupChannelLog{}); err != nil { + t.Fatalf("migrate logs: %v", err) + } + + oldTime := time.Now().Add(-2 * time.Hour) + + recentTime := time.Now() + if err := db.Create(&[]model.GroupChannelLog{ + { + GroupID: "group-1", + GroupChannelID: 1, + Model: "gpt-5", + RequestID: "old_group_log", + CreatedAt: oldTime, + RequestAt: oldTime, + }, + { + GroupID: "group-1", + GroupChannelID: 1, + Model: "gpt-5", + RequestID: "recent_group_log", + CreatedAt: recentTime, + RequestAt: recentTime, + }, + }).Error; err != nil { + t.Fatalf("seed group channel logs: %v", err) + } + + deleted, err := model.DeleteOldLog(time.Now().Add(-time.Hour)) + if err != nil { + t.Fatalf("delete old logs: %v", err) + } + + if deleted != 0 { + t.Fatalf("expected no deleted normal logs, got %d", deleted) + } + + var ids []string + if err := db.Model(&model.GroupChannelLog{}). + Order("request_id"). + Pluck("request_id", &ids).Error; err != nil { + t.Fatalf("list group channel logs: %v", err) + } + + if len(ids) != 2 || ids[0] != "old_group_log" || ids[1] != "recent_group_log" { + t.Fatalf("expected group channel logs to remain, got %v", ids) + } + + deleted, err = model.DeleteOldGroupChannelLog(time.Now().Add(-time.Hour)) + if err != nil { + t.Fatalf("delete old group channel logs: %v", err) + } + + if deleted != 1 { + t.Fatalf("expected one deleted group channel log, got %d", deleted) + } + + ids = nil + if err := db.Model(&model.GroupChannelLog{}). + Order("request_id"). + Pluck("request_id", &ids).Error; err != nil { + t.Fatalf("list group channel logs after group channel delete: %v", err) + } + + if len(ids) != 1 || ids[0] != "recent_group_log" { + t.Fatalf("expected only recent group channel log to remain, got %v", ids) + } +} + +func TestDeleteOldGroupChannelLogForGroupIsScoped(t *testing.T) { + db, err := model.OpenSQLite(filepath.Join(t.TempDir(), "logs.db")) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + + prevLogDB := model.LogDB + model.LogDB = db + t.Cleanup(func() { + model.LogDB = prevLogDB + }) + + if err := db.AutoMigrate(&model.GroupChannelLog{}); err != nil { + t.Fatalf("migrate group channel logs: %v", err) + } + + oldTime := time.Now().Add(-2 * time.Hour) + + recentTime := time.Now() + if err := db.Create(&[]model.GroupChannelLog{ + { + GroupID: "group-1", + GroupChannelID: 1, + Model: "gpt-5", + RequestID: "group_1_old", + CreatedAt: oldTime, + RequestAt: oldTime, + }, + { + GroupID: "group-1", + GroupChannelID: 1, + Model: "gpt-5", + RequestID: "group_1_recent", + CreatedAt: recentTime, + RequestAt: recentTime, + }, + { + GroupID: "group-2", + GroupChannelID: 1, + Model: "gpt-5", + RequestID: "group_2_old", + CreatedAt: oldTime, + RequestAt: oldTime, + }, + }).Error; err != nil { + t.Fatalf("seed group channel logs: %v", err) + } + + deleted, err := model.DeleteOldGroupChannelLogForGroup("group-1", time.Now().Add(-time.Hour)) + if err != nil { + t.Fatalf("delete scoped old group channel logs: %v", err) + } + + if deleted != 1 { + t.Fatalf("expected one deleted group channel log, got %d", deleted) + } + + var ids []string + if err := db.Model(&model.GroupChannelLog{}). + Order("request_id"). + Pluck("request_id", &ids).Error; err != nil { + t.Fatalf("list group channel logs: %v", err) + } + + want := []string{"group_1_recent", "group_2_old"} + if len(ids) != len(want) { + t.Fatalf("expected remaining group channel logs %v, got %v", want, ids) + } + + for i := range want { + if ids[i] != want[i] { + t.Fatalf("expected remaining group channel logs %v, got %v", want, ids) + } + } +} + +func TestDeleteGroupLogsPreservesGroupChannelLogs(t *testing.T) { + db, err := model.OpenSQLite(filepath.Join(t.TempDir(), "logs.db")) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + + prevLogDB := model.LogDB + model.LogDB = db + t.Cleanup(func() { + model.LogDB = prevLogDB + }) + + if err := db.AutoMigrate(&model.Log{}, &model.GroupChannelLog{}); err != nil { + t.Fatalf("migrate logs: %v", err) + } + + now := time.Now() + if err := db.Create(&[]model.GroupChannelLog{ + { + GroupID: "group-1", + GroupChannelID: 1, + Model: "gpt-5", + RequestID: "group_1_log", + CreatedAt: now, + RequestAt: now, + }, + { + GroupID: "group-2", + GroupChannelID: 1, + Model: "gpt-5", + RequestID: "group_2_log", + CreatedAt: now, + RequestAt: now, + }, + }).Error; err != nil { + t.Fatalf("seed group channel logs: %v", err) + } + + deleted, err := model.DeleteGroupLogs("group-1") + if err != nil { + t.Fatalf("delete group logs: %v", err) + } + + if deleted != 0 { + t.Fatalf("expected no deleted normal logs, got %d", deleted) + } + + var ids []string + if err := db.Model(&model.GroupChannelLog{}). + Order("request_id"). + Pluck("request_id", &ids).Error; err != nil { + t.Fatalf("list group channel logs: %v", err) + } + + if len(ids) != 2 || ids[0] != "group_1_log" || ids[1] != "group_2_log" { + t.Fatalf("expected group channel logs to remain, got %v", ids) + } + + deleted, err = model.DeleteGroupChannelLogs("group-1") + if err != nil { + t.Fatalf("delete group channel logs: %v", err) + } + + if deleted != 1 { + t.Fatalf("expected one deleted group channel log, got %d", deleted) + } + + ids = nil + if err := db.Model(&model.GroupChannelLog{}). + Order("request_id"). + Pluck("request_id", &ids).Error; err != nil { + t.Fatalf("list group channel logs after group channel delete: %v", err) + } + + if len(ids) != 1 || ids[0] != "group_2_log" { + t.Fatalf("expected group-2 group channel log to remain, got %v", ids) + } +} + +func TestGetGroupLogsIsolatedFromGroupChannelLogs(t *testing.T) { + withGroupChannelLogDB(t, func(dbLogs groupChannelLogFixture) { + result, err := model.GetGroupLogs( + "group-1", + time.Time{}, + time.Now().Add(time.Hour), + "", + "", + "", + 0, + "", + 0, + "created_at-asc", + model.CodeTypeAll, + 0, + true, + "", + "", + 1, + 10, + ) + if err != nil { + t.Fatalf("get group logs: %v", err) + } + + if result.Total != 1 { + t.Fatalf("expected total=1, got %d", result.Total) + } + + if len(result.Logs) != 1 { + t.Fatalf("expected 1 log, got %d", len(result.Logs)) + } + + if result.Logs[0].RequestID != "normal_req" { + t.Fatalf("expected normal log, got %q", result.Logs[0].RequestID) + } + + filtered, err := model.GetGroupLogs( + "group-1", + time.Time{}, + time.Now().Add(time.Hour), + "", + "", + "", + 0, + "", + dbLogs.groupChannelID, + "created_at-asc", + model.CodeTypeAll, + 0, + false, + "", + "", + 1, + 10, + ) + if err != nil { + t.Fatalf("get filtered group logs: %v", err) + } + + if filtered.Total != 0 || len(filtered.Logs) != 0 { + t.Fatalf( + "expected no normal logs for group channel id, got total=%d logs=%#v", + filtered.Total, + filtered.Logs, + ) + } + + groupChannelResult, err := model.GetGroupChannelLogs( + "group-1", + time.Time{}, + time.Now().Add(time.Hour), + "", + "", + "", + 0, + "", + dbLogs.groupChannelID, + "created_at-asc", + model.CodeTypeAll, + 0, + true, + "", + "", + 1, + 10, + ) + if err != nil { + t.Fatalf("get group channel logs: %v", err) + } + + if groupChannelResult.Total != 1 || len(groupChannelResult.Logs) != 1 || + groupChannelResult.Logs[0].RequestID != "group_channel_req" { + t.Fatalf( + "expected group channel log from isolated endpoint, got total=%d logs=%#v", + groupChannelResult.Total, + groupChannelResult.Logs, + ) + } + + groupLog := groupChannelResult.Logs[0] + if groupLog.GroupChannelID != dbLogs.groupChannelID { + t.Fatalf( + "expected group channel id %d, got %d", + dbLogs.groupChannelID, + groupLog.GroupChannelID, + ) + } + + if groupLog.RequestDetail == nil || groupLog.RequestDetail.RequestBody != "group request" { + t.Fatalf("expected group channel request detail, got %#v", groupLog.RequestDetail) + } + + if len(groupChannelResult.TokenNames) != 1 || + groupChannelResult.TokenNames[0] != "group-token" { + t.Fatalf("expected group channel token facet, got %#v", groupChannelResult.TokenNames) + } + }) +} + +func TestGlobalLogsAreIsolatedFromGroupChannelLogs(t *testing.T) { + withGroupChannelLogDB(t, func(dbLogs groupChannelLogFixture) { + result, err := model.GetLogs( + time.Time{}, + time.Now().Add(time.Hour), + "", + "group_channel_req", + "", + dbLogs.groupChannelID, + "created_at-asc", + model.CodeTypeAll, + 0, + false, + "", + "", + 1, + 10, + ) + if err != nil { + t.Fatalf("get global logs: %v", err) + } + + if result.Total != 0 || len(result.Logs) != 0 { + t.Fatalf( + "expected no global group channel logs, got total=%d logs=%#v", + result.Total, + result.Logs, + ) + } + + searchResult, err := model.SearchLogs( + "group_channel_req", + "", + "", + "", + 0, + "", + "", + time.Time{}, + time.Now().Add(time.Hour), + 0, + "created_at-asc", + model.CodeTypeAll, + 0, + false, + "", + "", + 1, + 10, + ) + if err != nil { + t.Fatalf("search global logs: %v", err) + } + + if searchResult.Total != 0 || len(searchResult.Logs) != 0 { + t.Fatalf( + "expected no searched global group channel logs, got total=%d logs=%#v", + searchResult.Total, + searchResult.Logs, + ) + } + + logs, err := model.ExportLogsRange( + time.Time{}, + time.Now().Add(time.Hour), + "", + "group_channel_req", + "", + dbLogs.groupChannelID, + "created_at-asc", + model.CodeTypeAll, + 0, + true, + "", + "", + 10, + ) + if err != nil { + t.Fatalf("export global logs: %v", err) + } + + if len(logs) != 0 { + t.Fatalf("expected no exported global group channel logs, got %#v", logs) + } + + groupChannelLogs, err := model.ExportGroupChannelLogsRange( + "group-1", + time.Time{}, + time.Now().Add(time.Hour), + "", + "group_channel_req", + "", + 0, + "", + dbLogs.groupChannelID, + "created_at-asc", + model.CodeTypeAll, + 0, + true, + "", + "", + 10, + ) + if err != nil { + t.Fatalf("export isolated group channel logs: %v", err) + } + + if len(groupChannelLogs) != 1 || groupChannelLogs[0].RequestID != "group_channel_req" || + groupChannelLogs[0].RequestDetail == nil { + t.Fatalf("expected exported group channel log with detail, got %#v", groupChannelLogs) + } + + detail, err := model.GetGroupChannelLogDetailForGroup(dbLogs.groupChannelLogID, "group-1") + if err != nil { + t.Fatalf("get group channel detail: %v", err) + } + + if detail.RequestBody != "group request" { + t.Fatalf("unexpected global group channel detail: %#v", detail) + } + }) +} + +func TestGroupAndGroupChannelLogsPaginateIndependently(t *testing.T) { + db, err := model.OpenSQLite(filepath.Join(t.TempDir(), "logs.db")) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + + prevLogDB := model.LogDB + model.LogDB = db + t.Cleanup(func() { + model.LogDB = prevLogDB + }) + + if err := db.AutoMigrate( + &model.Log{}, + &model.RequestDetail{}, + &model.GroupChannelLog{}, + &model.GroupChannelRequestDetail{}, + &model.Summary{}, + &model.GroupSummary{}, + &model.GroupChannelTokenSummary{}, + ); err != nil { + t.Fatalf("migrate log db: %v", err) + } + + baseTime := time.Now().Add(-time.Hour) + for i := range 12 { + createdAt := baseTime.Add(time.Duration(i) * time.Second) + if err := db.Create(&model.Log{ + GroupID: "group-1", + ChannelID: 7, + Model: "gpt-5", + RequestID: model.EmptyNullString("normal_page_req"), + Code: 200, + CreatedAt: createdAt, + RequestAt: createdAt, + }).Error; err != nil { + t.Fatalf("seed normal log %d: %v", i, err) + } + } + + for i := range 12 { + createdAt := baseTime.Add(time.Duration(12+i) * time.Second) + if err := db.Create(&model.GroupChannelLog{ + GroupID: "group-1", + GroupChannelID: 11, + Model: "gpt-5", + RequestID: model.EmptyNullString("group_page_req"), + Code: 200, + CreatedAt: createdAt, + RequestAt: createdAt, + }).Error; err != nil { + t.Fatalf("seed group channel log %d: %v", i, err) + } + } + + result, err := model.GetGroupLogs( + "group-1", + time.Time{}, + time.Now().Add(time.Hour), + "", + "", + "", + 0, + "", + 0, + "created_at-asc", + model.CodeTypeAll, + 0, + false, + "", + "", + 2, + 10, + ) + if err != nil { + t.Fatalf("get paginated group logs: %v", err) + } + + if result.Total != 12 { + t.Fatalf("expected total=12, got %d", result.Total) + } + + if len(result.Logs) != 2 { + t.Fatalf("expected second page size 2, got %d", len(result.Logs)) + } + + if result.Logs[0].RequestID != "normal_page_req" { + t.Fatalf("expected normal log beyond first 10 rows, got %q", result.Logs[0].RequestID) + } + + searchResult, err := model.SearchGroupLogs( + "group-1", + "", + "", + "", + 0, + "", + "", + time.Time{}, + time.Now().Add(time.Hour), + 0, + "created_at-asc", + model.CodeTypeAll, + 0, + false, + "", + "", + 2, + 10, + ) + if err != nil { + t.Fatalf("search paginated group logs: %v", err) + } + + if searchResult.Total != 12 || len(searchResult.Logs) != 2 || + searchResult.Logs[0].RequestID != "normal_page_req" { + t.Fatalf( + "expected paginated search to include normal rows beyond first page, total=%d logs=%#v", + searchResult.Total, + searchResult.Logs, + ) + } + + groupChannelResult, err := model.GetGroupChannelLogs( + "group-1", + time.Time{}, + time.Now().Add(time.Hour), + "", + "", + "", + 0, + "", + 0, + "created_at-asc", + model.CodeTypeAll, + 0, + false, + "", + "", + 2, + 10, + ) + if err != nil { + t.Fatalf("get paginated group channel logs: %v", err) + } + + if groupChannelResult.Total != 12 || len(groupChannelResult.Logs) != 2 || + groupChannelResult.Logs[0].RequestID != "group_page_req" { + t.Fatalf( + "expected paginated group channel rows beyond first page, total=%d logs=%#v", + groupChannelResult.Total, + groupChannelResult.Logs, + ) + } + + groupChannelSearch, err := model.SearchGroupChannelLogs( + "group-1", + "", + "", + "", + 0, + "", + "", + time.Time{}, + time.Now().Add(time.Hour), + 0, + "created_at-asc", + model.CodeTypeAll, + 0, + false, + "", + "", + 2, + 10, + ) + if err != nil { + t.Fatalf("search paginated group channel logs: %v", err) + } + + if groupChannelSearch.Total != 12 || len(groupChannelSearch.Logs) != 2 || + groupChannelSearch.Logs[0].RequestID != "group_page_req" { + t.Fatalf( + "expected paginated group channel search beyond first page, total=%d logs=%#v", + groupChannelSearch.Total, + groupChannelSearch.Logs, + ) + } +} + +func TestSearchAndExportGroupChannelLogsAreIsolated(t *testing.T) { + withGroupChannelLogDB(t, func(dbLogs groupChannelLogFixture) { + result, err := model.SearchGroupLogs( + "group-1", + "group_channel_req", + "", + "", + 0, + "", + "", + time.Time{}, + time.Now().Add(time.Hour), + 0, + "created_at-asc", + model.CodeTypeAll, + 0, + false, + "", + "", + 1, + 10, + ) + if err != nil { + t.Fatalf("search group logs: %v", err) + } + + if result.Total != 0 || len(result.Logs) != 0 { + t.Fatalf( + "expected no normal logs from group channel search term, got total=%d logs=%#v", + result.Total, + result.Logs, + ) + } + + groupChannelResult, err := model.SearchGroupChannelLogs( + "group-1", + "group_channel_req", + "", + "", + 0, + "", + "", + time.Time{}, + time.Now().Add(time.Hour), + 0, + "created_at-asc", + model.CodeTypeAll, + 0, + false, + "", + "", + 1, + 10, + ) + if err != nil { + t.Fatalf("search group channel logs: %v", err) + } + + if groupChannelResult.Total != 1 || len(groupChannelResult.Logs) != 1 || + groupChannelResult.Logs[0].GroupChannelID != dbLogs.groupChannelID { + t.Fatalf( + "expected searched group channel log, got total=%d logs=%#v", + groupChannelResult.Total, + groupChannelResult.Logs, + ) + } + + logs, err := model.ExportGroupLogsRange( + "group-1", + time.Time{}, + time.Now().Add(time.Hour), + "", + "", + "", + 0, + "", + dbLogs.groupChannelID, + "created_at-asc", + model.CodeTypeAll, + 0, + true, + "", + "", + 10, + ) + if err != nil { + t.Fatalf("export group logs: %v", err) + } + + if len(logs) != 0 { + t.Fatalf( + "expected no exported group channel log from normal group export, got %#v", + logs, + ) + } + + groupChannelLogs, err := model.ExportGroupChannelLogsRange( + "group-1", + time.Time{}, + time.Now().Add(time.Hour), + "", + "", + "", + 0, + "", + dbLogs.groupChannelID, + "created_at-asc", + model.CodeTypeAll, + 0, + true, + "", + "", + 10, + ) + if err != nil { + t.Fatalf("export group channel logs: %v", err) + } + + if len(groupChannelLogs) != 1 || groupChannelLogs[0].RequestID != "group_channel_req" { + t.Fatalf("expected exported group channel log, got %#v", groupChannelLogs) + } + + if groupChannelLogs[0].RequestDetail == nil || + groupChannelLogs[0].RequestDetail.ResponseBody != "group response" { + t.Fatalf( + "expected exported group channel detail, got %#v", + groupChannelLogs[0].RequestDetail, + ) + } + }) +} + +func TestGetGroupChannelLogDetailForGroupReadsGroupChannelDetail(t *testing.T) { + withGroupChannelLogDB(t, func(dbLogs groupChannelLogFixture) { + detail, err := model.GetGroupChannelLogDetailForGroup( + dbLogs.groupChannelLogID, + "group-1", + ) + if err != nil { + t.Fatalf("get group channel detail: %v", err) + } + + if detail.RequestBody != "group request" || detail.ResponseBody != "group response" { + t.Fatalf("unexpected group channel detail: %#v", detail) + } + }) +} + +func TestGetGroupChannelLogsRequiresGroupFilter(t *testing.T) { + withGroupChannelLogDB(t, func(dbLogs groupChannelLogFixture) { + result, err := model.GetGroupChannelLogs( + "", + time.Time{}, + time.Now().Add(time.Hour), + "", + "", + "", + 0, + "", + dbLogs.groupChannelID, + "created_at-asc", + model.CodeTypeAll, + 0, + true, + "", + "", + 1, + 10, + ) + if err == nil { + t.Fatalf("expected missing group error, got result %#v", result) + } + + logs, err := model.ExportGroupChannelLogsRange( + "", + time.Time{}, + time.Now().Add(time.Hour), + "", + "", + "", + 0, + "", + dbLogs.groupChannelID, + "created_at-asc", + model.CodeTypeAll, + 0, + true, + "", + "", + 10, + ) + if err == nil { + t.Fatalf("expected missing group error, got logs %#v", logs) + } + }) +} + +type groupChannelLogFixture struct { + groupChannelID int + groupChannelLogID int +} + +func withGroupChannelLogDB(t *testing.T, fn func(groupChannelLogFixture)) { + t.Helper() + + db, err := model.OpenSQLite(filepath.Join(t.TempDir(), "logs.db")) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + + prevLogDB := model.LogDB + model.LogDB = db + t.Cleanup(func() { + model.LogDB = prevLogDB + }) + + if err := db.AutoMigrate( + &model.Log{}, + &model.RequestDetail{}, + &model.GroupChannelLog{}, + &model.GroupChannelRequestDetail{}, + &model.Summary{}, + &model.GroupSummary{}, + &model.GroupChannelTokenSummary{}, + ); err != nil { + t.Fatalf("migrate log db: %v", err) + } + + baseTime := time.Now().Add(-time.Minute) + + normalLog := model.Log{ + GroupID: "group-1", + ChannelID: 7, + Model: "gpt-5", + RequestID: "normal_req", + Code: 200, + CreatedAt: baseTime, + RequestAt: baseTime, + RequestDetail: &model.RequestDetail{ + RequestBody: "normal request", + ResponseBody: "normal response", + }, + } + if err := db.Create(&normalLog).Error; err != nil { + t.Fatalf("seed normal log: %v", err) + } + + groupChannelLog := model.GroupChannelLog{ + GroupID: "group-1", + GroupChannelID: 11, + TokenName: "group-token", + Model: "gpt-5", + RequestID: "group_channel_req", + Code: 200, + CreatedAt: baseTime.Add(time.Second), + RequestAt: baseTime.Add(time.Second), + RequestDetail: &model.GroupChannelRequestDetail{ + RequestBody: "group request", + ResponseBody: "group response", + }, + } + if err := db.Create(&groupChannelLog).Error; err != nil { + t.Fatalf("seed group channel log: %v", err) + } + + if err := db.Create(&model.GroupChannelTokenSummary{ + Unique: model.GroupChannelTokenSummaryUnique{ + GroupID: "group-1", + TokenName: groupChannelLog.TokenName, + Model: groupChannelLog.Model, + HourTimestamp: baseTime.Truncate(time.Hour).Unix(), + }, + }).Error; err != nil { + t.Fatalf("seed group channel token summary: %v", err) + } + + otherGroupLog := model.GroupChannelLog{ + GroupID: "group-2", + GroupChannelID: 11, + Model: "gpt-5", + RequestID: "other_group_channel_req", + Code: 200, + CreatedAt: baseTime.Add(2 * time.Second), + RequestAt: baseTime.Add(2 * time.Second), + } + if err := db.Create(&otherGroupLog).Error; err != nil { + t.Fatalf("seed other group channel log: %v", err) + } + + fn(groupChannelLogFixture{ + groupChannelID: groupChannelLog.GroupChannelID, + groupChannelLogID: groupChannelLog.ID, + }) +} + func TestTryClaimAsyncUsageInfoIsAtomic(t *testing.T) { db, err := model.OpenSQLite(filepath.Join(t.TempDir(), "logs.db")) if err != nil { diff --git a/core/model/main.go b/core/model/main.go index 226e65f4..449420b4 100644 --- a/core/model/main.go +++ b/core/model/main.go @@ -142,9 +142,12 @@ func migrateDB() error { err := DB.AutoMigrate( &Channel{}, &ChannelTest{}, + &GroupChannel{}, + &GroupChannelTest{}, &Token{}, &PublicMCP{}, &GroupModelConfig{}, + &GroupScopeModelConfig{}, &PublicMCPReusingParam{}, &GroupMCP{}, &Group{}, @@ -222,15 +225,23 @@ func migrateLogDB(batchSize int) error { err = LogDB.AutoMigrate( &Log{}, + &GroupChannelLog{}, &RequestDetail{}, + &GroupChannelRequestDetail{}, &RetryLog{}, + &GroupChannelRetryLog{}, &GroupSummary{}, + &GroupChannelSummary{}, + &GroupChannelTokenSummary{}, &Summary{}, &ConsumeError{}, &AsyncUsageInfo{}, &StoreV2{}, + &GroupChannelStoreV2{}, &SummaryMinute{}, &GroupSummaryMinute{}, + &GroupChannelSummaryMinute{}, + &GroupChannelTokenSummaryMinute{}, ) if err != nil { return err @@ -267,6 +278,26 @@ func migrateLogDB(batchSize int) error { ) } + err = CreateGroupChannelSummaryIndexs(LogDB) + if err != nil { + notify.ErrorThrottle( + "createGroupChannelSummaryIndexs", + time.Minute*10, + "failed to create group channel summary indexs", + err.Error(), + ) + } + + err = CreateGroupChannelTokenSummaryIndexs(LogDB) + if err != nil { + notify.ErrorThrottle( + "createGroupChannelTokenSummaryIndexs", + time.Minute*10, + "failed to create group channel token summary indexs", + err.Error(), + ) + } + err = CreateSummaryMinuteIndexs(LogDB) if err != nil { notify.ErrorThrottle( @@ -286,6 +317,36 @@ func migrateLogDB(batchSize int) error { err.Error(), ) } + + err = CreateGroupChannelSummaryMinuteIndexs(LogDB) + if err != nil { + notify.ErrorThrottle( + "createGroupChannelSummaryMinuteIndexs", + time.Minute*10, + "failed to create group channel summary minute indexs", + err.Error(), + ) + } + + err = CreateGroupChannelTokenSummaryMinuteIndexs(LogDB) + if err != nil { + notify.ErrorThrottle( + "createGroupChannelTokenSummaryMinuteIndexs", + time.Minute*10, + "failed to create group channel token summary minute indexs", + err.Error(), + ) + } + + err = CreateGroupChannelLogIndexes(LogDB) + if err != nil { + notify.ErrorThrottle( + "createGroupChannelLogIndexes", + time.Minute*10, + "failed to create group channel log indexs", + err.Error(), + ) + } }() return nil @@ -344,36 +405,49 @@ func preMigrationCleanup(batchSize int) error { err := preMigrationCleanupLogs(batchSize) if err != nil { if ignoreNoSuchTable(err) { - return nil + log.Warn("skip pre-migration logs cleanup: ", err.Error()) + } else { + return fmt.Errorf("failed to cleanup logs: %w", err) + } + } + + err = preMigrationCleanupGroupChannelLogs(batchSize) + if err != nil { + if ignoreNoSuchTable(err) { + log.Warn("skip pre-migration group channel logs cleanup: ", err.Error()) + } else { + return fmt.Errorf("failed to cleanup group channel logs: %w", err) } - return fmt.Errorf("failed to cleanup logs: %w", err) } // Clean up retry logs err = preMigrationCleanupRetryLogs(batchSize) if err != nil { if ignoreNoSuchTable(err) { - return nil + log.Warn("skip pre-migration retry logs cleanup: ", err.Error()) + } else { + return fmt.Errorf("failed to cleanup retry logs: %w", err) } - return fmt.Errorf("failed to cleanup retry logs: %w", err) } // Clean up request details err = preMigrationCleanupRequestDetails(batchSize) if err != nil { if ignoreNoSuchTable(err) { - return nil + log.Warn("skip pre-migration request details cleanup: ", err.Error()) + } else { + return fmt.Errorf("failed to cleanup request details: %w", err) } - return fmt.Errorf("failed to cleanup request details: %w", err) } // Clean up expired stores err = preMigrationCleanupStores() if err != nil { if ignoreNoSuchTable(err) { - return nil + log.Warn("skip pre-migration stores cleanup: ", err.Error()) + } else { + return fmt.Errorf("failed to cleanup stores: %w", err) } - return fmt.Errorf("failed to cleanup stores: %w", err) } log.Info("pre-migration cleanup completed") @@ -394,13 +468,31 @@ func preMigrationCleanupLogs(batchSize int) error { cutoffTime := time.Now().Add(-time.Duration(logStorageHours) * time.Hour) - // First, get the IDs to delete + return preMigrationCleanupLogTable[Log](cutoffTime, batchSize, "log") +} + +func preMigrationCleanupGroupChannelLogs(batchSize int) error { + logStorageHours := config.GetLogStorageHours() + if logStorageHours == 0 { + return nil + } + + if batchSize <= 0 { + batchSize = defaultCleanLogBatchSize + } + + cutoffTime := time.Now().Add(-time.Duration(logStorageHours) * time.Hour) + + return preMigrationCleanupLogTable[GroupChannelLog](cutoffTime, batchSize, "group channel log") +} + +func preMigrationCleanupLogTable[T any](cutoffTime time.Time, batchSize int, name string) error { ids := make([]int, 0, batchSize) for { ids = ids[:0] - err := LogDB.Model(&Log{}). + err := LogDB.Model(new(T)). Select("id"). Where("created_at < ?", cutoffTime). Limit(batchSize). @@ -417,12 +509,12 @@ func preMigrationCleanupLogs(batchSize int) error { // Delete by IDs err = LogDB.Where("id IN (?)", ids). Session(&gorm.Session{SkipDefaultTransaction: true}). - Delete(&Log{}).Error + Delete(new(T)).Error if err != nil { return err } - log.Infof("deleted %d expired log records", len(ids)) + log.Infof("deleted %d expired %s records", len(ids), name) // If we got less than batchSize, we're done if len(ids) < batchSize { @@ -450,40 +542,20 @@ func preMigrationCleanupRetryLogs(batchSize int) error { cutoffTime := time.Now().Add(-time.Duration(logStorageHours) * time.Hour) - // First, get the IDs to delete - ids := make([]int, 0, batchSize) - - for { - ids = ids[:0] - - err := LogDB.Model(&RetryLog{}). - Select("id"). - Where("created_at < ?", cutoffTime). - Limit(batchSize). - Find(&ids).Error - if err != nil { - return err - } - - // If no IDs found, we're done - if len(ids) == 0 { - break - } - - // Delete by IDs - err = LogDB.Where("id IN (?)", ids). - Session(&gorm.Session{SkipDefaultTransaction: true}). - Delete(&Log{}).Error - if err != nil { - return err - } - - log.Infof("deleted %d expired retry log records", len(ids)) + if err := preMigrationCleanupLogTable[RetryLog]( + cutoffTime, + batchSize, + "retry log", + ); err != nil { + return err + } - // If we got less than batchSize, we're done - if len(ids) < batchSize { - break - } + if err := preMigrationCleanupLogTable[GroupChannelRetryLog]( + cutoffTime, + batchSize, + "group channel retry log", + ); err != nil { + return err } return nil @@ -506,13 +578,32 @@ func preMigrationCleanupRequestDetails(batchSize int) error { cutoffTime := time.Now().Add(-time.Duration(detailStorageHours) * time.Hour) - // First, get the IDs to delete + if err := preMigrationCleanupRequestDetailTable[RequestDetail]( + cutoffTime, + batchSize, + "request detail", + ); err != nil { + return err + } + + return preMigrationCleanupRequestDetailTable[GroupChannelRequestDetail]( + cutoffTime, + batchSize, + "group channel request detail", + ) +} + +func preMigrationCleanupRequestDetailTable[T any]( + cutoffTime time.Time, + batchSize int, + name string, +) error { ids := make([]int, 0, batchSize) for { ids = ids[:0] - err := LogDB.Model(&RequestDetail{}). + err := LogDB.Model(new(T)). Select("id"). Where("created_at < ?", cutoffTime). Limit(batchSize). @@ -521,22 +612,19 @@ func preMigrationCleanupRequestDetails(batchSize int) error { return err } - // If no IDs found, we're done if len(ids) == 0 { break } - // Delete by IDs err = LogDB.Where("id IN (?)", ids). Session(&gorm.Session{SkipDefaultTransaction: true}). - Delete(&RequestDetail{}).Error + Delete(new(T)).Error if err != nil { return err } - log.Infof("deleted %d expired request detail records", len(ids)) + log.Infof("deleted %d expired %s records", len(ids), name) - // If we got less than batchSize, we're done if len(ids) < batchSize { break } @@ -546,9 +634,17 @@ func preMigrationCleanupRequestDetails(batchSize int) error { } func preMigrationCleanupStores() error { - return LogDB. + if err := LogDB. Session(&gorm.Session{SkipDefaultTransaction: true}). Where("expires_at < ?", time.Now()). Delete(&StoreV2{}). + Error; err != nil { + return err + } + + return LogDB. + Session(&gorm.Session{SkipDefaultTransaction: true}). + Where("expires_at < ?", time.Now()). + Delete(&GroupChannelStoreV2{}). Error } diff --git a/core/model/main_test.go b/core/model/main_test.go new file mode 100644 index 00000000..d88386ee --- /dev/null +++ b/core/model/main_test.go @@ -0,0 +1,107 @@ +//nolint:testpackage +package model + +import ( + "path/filepath" + "testing" + "time" + + "github.com/labring/aiproxy/core/common/config" + "github.com/stretchr/testify/require" +) + +func TestPreMigrationCleanupContinuesWhenGroupChannelLogsTableMissing(t *testing.T) { + oldLogDB := LogDB + + db, err := OpenSQLite(filepath.Join(t.TempDir(), "pre_migration_cleanup.db")) + require.NoError(t, err) + require.NoError( + t, + db.AutoMigrate(&Log{}, &RetryLog{}, &RequestDetail{}, &StoreV2{}, &GroupChannelStoreV2{}), + ) + + LogDB = db + + oldLogStorageHours := config.GetLogStorageHours() + oldRetryLogStorageHours := config.GetRetryLogStorageHours() + oldLogDetailStorageHours := config.GetLogDetailStorageHours() + t.Cleanup(func() { + LogDB = oldLogDB + + config.SetLogStorageHours(oldLogStorageHours) + config.SetRetryLogStorageHours(oldRetryLogStorageHours) + config.SetLogDetailStorageHours(oldLogDetailStorageHours) + + sqlDB, err := db.DB() + require.NoError(t, err) + require.NoError(t, sqlDB.Close()) + }) + + config.SetLogStorageHours(1) + config.SetRetryLogStorageHours(1) + config.SetLogDetailStorageHours(1) + + require.NoError(t, db.Create(&StoreV2{ + ID: ResponseStoreID("expired_pre_migration_store"), + GroupID: "group-1", + TokenID: 1, + ChannelID: 1, + Model: "gpt-5", + ExpiresAt: time.Now().Add(-time.Hour), + }).Error) + + require.NoError(t, preMigrationCleanup(100)) + + var count int64 + require.NoError(t, db.Model(&StoreV2{}).Count(&count).Error) + require.Zero(t, count) +} + +func TestPreMigrationCleanupRetryLogsIncludesGroupChannelRetryLogs(t *testing.T) { + oldLogDB := LogDB + + db, err := OpenSQLite(filepath.Join(t.TempDir(), "pre_migration_retry_cleanup.db")) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&RetryLog{}, &GroupChannelRetryLog{})) + + LogDB = db + + oldLogStorageHours := config.GetLogStorageHours() + oldRetryLogStorageHours := config.GetRetryLogStorageHours() + t.Cleanup(func() { + LogDB = oldLogDB + + config.SetLogStorageHours(oldLogStorageHours) + config.SetRetryLogStorageHours(oldRetryLogStorageHours) + + sqlDB, err := db.DB() + require.NoError(t, err) + require.NoError(t, sqlDB.Close()) + }) + + config.SetLogStorageHours(24) + config.SetRetryLogStorageHours(1) + + oldCreatedAt := time.Now().Add(-2 * time.Hour) + newCreatedAt := time.Now() + require.NoError(t, db.Create(&[]RetryLog{ + {CreatedAt: oldCreatedAt, Model: "old-global"}, + {CreatedAt: newCreatedAt, Model: "new-global"}, + }).Error) + require.NoError(t, db.Create(&[]GroupChannelRetryLog{ + {CreatedAt: oldCreatedAt, GroupID: "group-1", Model: "old-group-channel"}, + {CreatedAt: newCreatedAt, GroupID: "group-1", Model: "new-group-channel"}, + }).Error) + + require.NoError(t, preMigrationCleanupRetryLogs(1)) + + var globalRetryLogs []RetryLog + require.NoError(t, db.Order("model asc").Find(&globalRetryLogs).Error) + require.Len(t, globalRetryLogs, 1) + require.Equal(t, "new-global", globalRetryLogs[0].Model) + + var groupChannelRetryLogs []GroupChannelRetryLog + require.NoError(t, db.Order("model asc").Find(&groupChannelRetryLogs).Error) + require.Len(t, groupChannelRetryLogs, 1) + require.Equal(t, "new-group-channel", groupChannelRetryLogs[0].Model) +} diff --git a/core/model/retrylog.go b/core/model/retrylog.go index 43a20632..ba214001 100644 --- a/core/model/retrylog.go +++ b/core/model/retrylog.go @@ -80,8 +80,12 @@ func RecordRetryLog( Mode: mode, ChannelID: channelID, RetryTimes: ZeroNullInt64(retryTimes), - RequestBody: requestDetail.RequestBody, - ResponseBody: requestDetail.ResponseBody, + } + if requestDetail != nil { + log.RequestBody = requestDetail.RequestBody + log.ResponseBody = requestDetail.ResponseBody + log.RequestBodyTruncated = requestDetail.RequestBodyTruncated + log.ResponseBodyTruncated = requestDetail.ResponseBodyTruncated } return LogDB.Create(log).Error diff --git a/core/model/sets.go b/core/model/sets.go new file mode 100644 index 00000000..d7b73d9d --- /dev/null +++ b/core/model/sets.go @@ -0,0 +1,201 @@ +package model + +import ( + "slices" + "strings" +) + +func NormalizeAvailableSets(sets []string) []string { + normalized := cleanAvailableSets(sets) + if len(normalized) == 0 { + return []string{ChannelDefaultSet} + } + + return normalized +} + +func cleanAvailableSets(sets []string) []string { + normalized := make([]string, 0, len(sets)) + seen := make(map[string]struct{}, len(sets)) + + for _, set := range sets { + set = strings.TrimSpace(set) + if set == "" { + continue + } + + if _, ok := seen[set]; ok { + continue + } + + seen[set] = struct{}{} + normalized = append(normalized, set) + } + + return normalized +} + +func IntersectAvailableSets(groupSets, tokenSets []string) []string { + groupSets = NormalizeAvailableSets(groupSets) + tokenSets = cleanAvailableSets(tokenSets) + + if len(tokenSets) == 0 { + return groupSets + } + + tokenSetMap := make(map[string]struct{}, len(tokenSets)) + for _, set := range tokenSets { + tokenSetMap[set] = struct{}{} + } + + result := make([]string, 0, min(len(groupSets), len(tokenSets))) + for _, set := range groupSets { + if _, ok := tokenSetMap[set]; ok { + result = append(result, set) + } + } + + return result +} + +func ResolveTokenAvailableSets(groupSets, tokenSets []string) []string { + return IntersectAvailableSets(groupSets, tokenSets) +} + +func ResolveTokenGroupChannelAvailableSets(channelSets, tokenSets []string) []string { + return IntersectAvailableSets(channelSets, tokenSets) +} + +func SetsFromModelMap(modelsBySet map[string][]string) []string { + sets := make([]string, 0, len(modelsBySet)) + for set := range modelsBySet { + sets = append(sets, set) + } + + slices.Sort(sets) + + return NormalizeAvailableSets(sets) +} + +func FilterModelsBySet( + modelsBySet map[string][]string, + availableSets []string, +) map[string][]string { + availableSets = cleanAvailableSets(availableSets) + if len(modelsBySet) == 0 || len(availableSets) == 0 { + return map[string][]string{} + } + + result := make(map[string][]string, len(modelsBySet)) + for _, set := range availableSets { + if models, ok := modelsBySet[set]; ok { + result[set] = models + } + } + + return result +} + +func FindTokenModel( + token TokenCache, + modelName string, + availableSets []string, + modelsBySet map[string][]string, +) string { + return FindModelWithAllowList(token.Models, modelName, availableSets, modelsBySet) +} + +func FindModelWithAllowList( + allowedModels []string, + modelName string, + availableSets []string, + modelsBySet map[string][]string, +) string { + var findModel string + if len(allowedModels) != 0 { + if !slices.ContainsFunc(allowedModels, func(item string) bool { + ok := strings.EqualFold(item, modelName) + if ok { + findModel = item + } + + return ok + }) { + return findModel + } + } + + return findModelInSets(modelName, availableSets, modelsBySet) +} + +func findModelInSets( + modelName string, + availableSets []string, + modelsBySet map[string][]string, +) string { + var findModel string + for _, set := range availableSets { + if slices.ContainsFunc(modelsBySet[set], func(item string) bool { + ok := strings.EqualFold(item, modelName) + if ok { + findModel = item + } + + return ok + }) { + return findModel + } + } + + return findModel +} + +func RangeTokenModels( + token TokenCache, + availableSets []string, + modelsBySet map[string][]string, + fn func(model string) bool, +) { + RangeModelsWithAllowList(token.Models, availableSets, modelsBySet, fn) +} + +func RangeModelsWithAllowList( + allowedModels []string, + availableSets []string, + modelsBySet map[string][]string, + fn func(model string) bool, +) { + ranged := make(map[string]struct{}) + if len(allowedModels) != 0 { + for _, modelName := range allowedModels { + if _, ok := ranged[modelName]; ok { + continue + } + + modelName = findModelInSets(modelName, availableSets, modelsBySet) + if modelName == "" { + continue + } + + ranged[modelName] = struct{}{} + if !fn(modelName) { + return + } + } + + return + } + + for _, set := range availableSets { + for _, modelName := range modelsBySet[set] { + if _, ok := ranged[modelName]; ok { + continue + } + + ranged[modelName] = struct{}{} + if !fn(modelName) { + return + } + } + } +} diff --git a/core/model/sets_test.go b/core/model/sets_test.go new file mode 100644 index 00000000..0d32da07 --- /dev/null +++ b/core/model/sets_test.go @@ -0,0 +1,129 @@ +package model_test + +import ( + "testing" + + "github.com/labring/aiproxy/core/model" + "github.com/stretchr/testify/require" +) + +func TestNormalizeAvailableSetsDefaultsToDefault(t *testing.T) { + t.Parallel() + + require.Equal(t, []string{model.ChannelDefaultSet}, model.NormalizeAvailableSets(nil)) + require.Equal( + t, + []string{model.ChannelDefaultSet}, + model.NormalizeAvailableSets([]string{"", " "}), + ) +} + +func TestIntersectAvailableSetsUsesGroupOrderAndInheritsEmptyTokenSets(t *testing.T) { + t.Parallel() + + require.Equal( + t, + []string{model.ChannelDefaultSet}, + model.IntersectAvailableSets(nil, nil), + ) + require.Equal( + t, + []string{"beta", "default"}, + model.IntersectAvailableSets( + []string{"beta", "default", "beta", "internal"}, + []string{"default", "beta"}, + ), + ) + require.Equal(t, []string{"beta"}, model.IntersectAvailableSets([]string{"beta"}, nil)) +} + +func TestResolveTokenAvailableSetsUsesGroupDefault(t *testing.T) { + t.Parallel() + + require.Equal( + t, + []string{model.ChannelDefaultSet}, + model.ResolveTokenAvailableSets(nil, nil), + ) + require.Equal( + t, + []string{}, + model.ResolveTokenAvailableSets(nil, []string{"beta"}), + ) + require.Equal( + t, + []string{"beta"}, + model.ResolveTokenAvailableSets( + []string{"beta", model.ChannelDefaultSet}, + []string{"beta"}, + ), + ) +} + +func TestResolveTokenGroupChannelAvailableSetsUsesDedicatedSets(t *testing.T) { + t.Parallel() + + require.Equal( + t, + []string{"group-only"}, + model.ResolveTokenGroupChannelAvailableSets( + []string{"group-only"}, + nil, + ), + ) + require.Equal( + t, + []string{"group-only"}, + model.ResolveTokenGroupChannelAvailableSets( + []string{"group-only", model.ChannelDefaultSet}, + []string{"group-only"}, + ), + ) + require.Equal( + t, + []string{model.ChannelDefaultSet, "group-only"}, + model.ResolveTokenGroupChannelAvailableSets( + []string{model.ChannelDefaultSet, "group-only"}, + nil, + ), + ) +} + +func TestSetsFromModelMapReturnsStableSets(t *testing.T) { + t.Parallel() + + require.Equal( + t, + []string{"beta", model.ChannelDefaultSet}, + model.SetsFromModelMap(map[string][]string{ + model.ChannelDefaultSet: {"default-model"}, + "beta": {"beta-model"}, + }), + ) +} + +func TestFilterModelsBySet(t *testing.T) { + t.Parallel() + + modelsBySet := map[string][]string{ + model.ChannelDefaultSet: {"default-model"}, + "beta": {"beta-model"}, + } + + require.Empty(t, model.FilterModelsBySet(modelsBySet, nil)) + require.Equal( + t, + map[string][]string{"beta": {"beta-model"}}, + model.FilterModelsBySet(modelsBySet, []string{"beta"}), + ) +} + +func TestTokenModelAccessCanUseEmptyEffectiveSets(t *testing.T) { + t.Parallel() + + modelsBySet := map[string][]string{ + model.ChannelDefaultSet: {"default-model"}, + } + + require.Empty(t, model.FindTokenModel(model.TokenCache{}, "default-model", nil, modelsBySet)) +} diff --git a/core/model/store.go b/core/model/store.go index 19a84594..0d4879ef 100644 --- a/core/model/store.go +++ b/core/model/store.go @@ -5,6 +5,7 @@ import ( "encoding/hex" "errors" "fmt" + "strconv" "strings" "time" @@ -54,75 +55,139 @@ type StoreV2 struct { Metadata string `gorm:"type:text"` } +type GroupChannelStoreV2 struct { + ID string `gorm:"size:128;primaryKey:3"` + CreatedAt time.Time `gorm:"autoCreateTime"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` + ExpiresAt time.Time `gorm:"index"` + GroupID string `gorm:"size:64;primaryKey:1"` + TokenID int `gorm:"primaryKey:2"` + ChannelID int + Model string `gorm:"size:128"` + Metadata string `gorm:"type:text"` +} + func (s *StoreV2) BeforeSave(_ *gorm.DB) error { - if s.GroupID != "" { - if s.TokenID == 0 { + return prepareStoreForSave(s, time.Now()) +} + +func (s *GroupChannelStoreV2) BeforeSave(_ *gorm.DB) error { + now := time.Now() + + return prepareStoreBeforeSave( + &s.ID, + s.GroupID, + s.TokenID, + s.ChannelID, + &s.CreatedAt, + &s.UpdatedAt, + &s.ExpiresAt, + now, + ) +} + +func prepareStoreBeforeSave( + id *string, + groupID string, + tokenID int, + channelID int, + createdAt *time.Time, + updatedAt *time.Time, + expiresAt *time.Time, + now time.Time, +) error { + if groupID != "" { + if tokenID == 0 { return errors.New("token id is required") } } - if s.ChannelID == 0 { + if channelID == 0 { return errors.New("channel id is required") } - if s.ID == "" { - s.ID = common.ShortUUID() + if *id == "" { + *id = common.ShortUUID() } - if s.CreatedAt.IsZero() { - s.CreatedAt = time.Now() + if createdAt.IsZero() { + *createdAt = now } - if s.ExpiresAt.IsZero() { - s.ExpiresAt = s.CreatedAt.Add(time.Hour * 24 * 30) + if expiresAt.IsZero() { + *expiresAt = createdAt.Add(time.Hour * 24 * 30) } - if s.UpdatedAt.IsZero() { - s.UpdatedAt = s.CreatedAt + if updatedAt.IsZero() { + *updatedAt = *createdAt } return nil } func SaveStore(s *StoreV2) (*StoreV2, error) { - return SaveStoreWithOption(s, SaveStoreOption{}) + return SaveStoreWithOptionByScope(s, ChannelScopeGlobal, SaveStoreOption{}) } func SaveStoreWithOption(s *StoreV2, opt SaveStoreOption) (*StoreV2, error) { + return SaveStoreWithOptionByScope(s, ChannelScopeGlobal, opt) +} + +func SaveStoreByScope(s *StoreV2, scope ChannelScope) (*StoreV2, error) { + return SaveStoreWithOptionByScope(s, scope, SaveStoreOption{}) +} + +func SaveStoreWithOptionByScope( + s *StoreV2, + scope ChannelScope, + opt SaveStoreOption, +) (*StoreV2, error) { + scope = normalizeChannelScope(scope) + if opt.MinUpdateInterval > 0 { - if existing, ok := getStoreFastPath(s, opt); ok { + if existing, ok := getStoreFastPath(s, scope, opt); ok { return existing, nil } } - return upsertStore(s, opt) + return upsertStore(s, scope, opt) } func SaveIfNotExistStore(s *StoreV2) (*StoreV2, error) { - if existing, ok := getStoreFastPath(s, SaveStoreOption{}); ok { + return SaveIfNotExistStoreByScope(s, ChannelScopeGlobal) +} + +func SaveIfNotExistStoreByScope(s *StoreV2, scope ChannelScope) (*StoreV2, error) { + scope = normalizeChannelScope(scope) + + if existing, ok := getStoreFastPath(s, scope, SaveStoreOption{}); ok { return existing, nil } - tx := LogDB.Clauses(clause.OnConflict{DoNothing: true}).Create(s) + if err := prepareStoreForSave(s, time.Now()); err != nil { + return nil, err + } + + tx := LogDB.Clauses(clause.OnConflict{DoNothing: true}).Create(storeDBModel(s, scope)) if tx.Error != nil { return nil, tx.Error } if tx.RowsAffected > 0 { - if err := CacheSetStore(s.ToStoreCache()); err != nil { + if err := CacheSetStoreByScope(s.ToStoreCache(), scope); err != nil { return nil, err } return s, nil } - existing, err := getStore(s.GroupID, s.TokenID, s.ID, true) + existing, err := getStore(s.GroupID, s.TokenID, s.ID, scope, true) if err != nil { return nil, err } if existing.ExpiresAt.After(time.Now()) { - if err := CacheSetStore(existing.ToStoreCache()); err != nil { + if err := CacheSetStoreByScope(existing.ToStoreCache(), scope); err != nil { return nil, err } @@ -130,7 +195,7 @@ func SaveIfNotExistStore(s *StoreV2) (*StoreV2, error) { } tx = LogDB.Session(&gorm.Session{SkipHooks: true}). - Model(&StoreV2{}). + Model(storeDBModelForScope(scope)). Where( "group_id = ? and token_id = ? and id = ? and expires_at <= ?", s.GroupID, @@ -150,27 +215,27 @@ func SaveIfNotExistStore(s *StoreV2) (*StoreV2, error) { } if tx.RowsAffected > 0 { - if err := CacheSetStore(s.ToStoreCache()); err != nil { + if err := CacheSetStoreByScope(s.ToStoreCache(), scope); err != nil { return nil, err } return s, nil } - existing, err = GetStore(s.GroupID, s.TokenID, s.ID) + existing, err = GetStoreByScope(s.GroupID, s.TokenID, s.ID, scope) if err != nil { return nil, err } - if err := CacheSetStore(existing.ToStoreCache()); err != nil { + if err := CacheSetStoreByScope(existing.ToStoreCache(), scope); err != nil { return nil, err } return existing, nil } -func getStoreFastPath(s *StoreV2, opt SaveStoreOption) (*StoreV2, bool) { - sc, ok := cachePeekStore(s.GroupID, s.TokenID, s.ID) +func getStoreFastPath(s *StoreV2, scope ChannelScope, opt SaveStoreOption) (*StoreV2, bool) { + sc, ok := cachePeekStore(s.GroupID, s.TokenID, s.ID, scope) if !ok { return nil, false } @@ -181,12 +246,75 @@ func getStoreFastPath(s *StoreV2, opt SaveStoreOption) (*StoreV2, bool) { } } - return sc.ToStoreV2(), true + store := sc.ToStoreV2() + if store == nil || store.ID == "" { + return nil, false + } + + return store, true +} + +func normalizeChannelScope(scope ChannelScope) ChannelScope { + return NormalizeChannelScope(scope) } -func saveStoreWithMinUpdateInterval(s *StoreV2, opt SaveStoreOption) (*StoreV2, error) { +func storeDBModel(s *StoreV2, scope ChannelScope) any { + if normalizeChannelScope(scope) == ChannelScopeGroup { + return groupChannelStoreFromStore(s) + } + + return s +} + +func storeDBModelForScope(scope ChannelScope) any { + if normalizeChannelScope(scope) == ChannelScopeGroup { + return &GroupChannelStoreV2{} + } + + return &StoreV2{} +} + +func groupChannelStoreFromStore(s *StoreV2) *GroupChannelStoreV2 { + return &GroupChannelStoreV2{ + ID: s.ID, + CreatedAt: s.CreatedAt, + UpdatedAt: s.UpdatedAt, + ExpiresAt: s.ExpiresAt, + GroupID: s.GroupID, + TokenID: s.TokenID, + ChannelID: s.ChannelID, + Model: s.Model, + Metadata: s.Metadata, + } +} + +func (s *GroupChannelStoreV2) ToStoreV2() *StoreV2 { + if s == nil { + return nil + } + + return &StoreV2{ + ID: s.ID, + CreatedAt: s.CreatedAt, + UpdatedAt: s.UpdatedAt, + ExpiresAt: s.ExpiresAt, + GroupID: s.GroupID, + TokenID: s.TokenID, + ChannelID: s.ChannelID, + Model: s.Model, + Metadata: s.Metadata, + } +} + +func saveStoreWithMinUpdateInterval( + s *StoreV2, + scope ChannelScope, + opt SaveStoreOption, +) (*StoreV2, error) { now := time.Now() - prepareStoreForSave(s, now) + if err := prepareStoreForSave(s, now); err != nil { + return nil, err + } cutoff := now.Add(-opt.MinUpdateInterval) @@ -204,21 +332,23 @@ func saveStoreWithMinUpdateInterval(s *StoreV2, opt SaveStoreOption) (*StoreV2, "metadata": s.Metadata, }), Where: storeUpsertUpdateWhere(cutoff, now), - }).Create(s) + }).Create(storeDBModel(s, scope)) if tx.Error != nil { return nil, tx.Error } - return loadAndCacheStore(s.GroupID, s.TokenID, s.ID) + return loadAndCacheStore(s.GroupID, s.TokenID, s.ID, scope) } -func upsertStore(s *StoreV2, opt SaveStoreOption) (*StoreV2, error) { +func upsertStore(s *StoreV2, scope ChannelScope, opt SaveStoreOption) (*StoreV2, error) { if opt.MinUpdateInterval > 0 { - return saveStoreWithMinUpdateInterval(s, opt) + return saveStoreWithMinUpdateInterval(s, scope, opt) } now := time.Now() - prepareStoreForSave(s, now) + if err := prepareStoreForSave(s, now); err != nil { + return nil, err + } tx := LogDB.Clauses(clause.OnConflict{ Columns: []clause.Column{ @@ -233,26 +363,41 @@ func upsertStore(s *StoreV2, opt SaveStoreOption) (*StoreV2, error) { "model": s.Model, "metadata": s.Metadata, }), - }).Create(s) + }).Create(storeDBModel(s, scope)) if tx.Error != nil { return nil, tx.Error } - return loadAndCacheStore(s.GroupID, s.TokenID, s.ID) + return loadAndCacheStore(s.GroupID, s.TokenID, s.ID, scope) } -func prepareStoreForSave(s *StoreV2, now time.Time) { - if s.CreatedAt.IsZero() { - s.CreatedAt = now +func prepareStoreForSave(s *StoreV2, now time.Time) error { + if s == nil { + return errors.New("store is required") } - if s.UpdatedAt.IsZero() { - s.UpdatedAt = now + if s.GroupID != "" && s.TokenID == 0 { + return errors.New("token id is required") } - if s.ExpiresAt.IsZero() { - s.ExpiresAt = s.CreatedAt.Add(time.Hour * 24 * 30) + if s.ChannelID == 0 { + return errors.New("channel id is required") + } + + if s.ID == "" { + s.ID = common.ShortUUID() } + + return prepareStoreBeforeSave( + &s.ID, + s.GroupID, + s.TokenID, + s.ChannelID, + &s.CreatedAt, + &s.UpdatedAt, + &s.ExpiresAt, + now, + ) } func storeUpsertUpdateWhere(cutoff, now time.Time) clause.Where { @@ -272,13 +417,13 @@ func storeUpsertUpdateWhere(cutoff, now time.Time) clause.Where { } } -func loadAndCacheStore(group string, tokenID int, id string) (*StoreV2, error) { - current, err := getStore(group, tokenID, id, true) +func loadAndCacheStore(group string, tokenID int, id string, scope ChannelScope) (*StoreV2, error) { + current, err := getStore(group, tokenID, id, scope, true) if err != nil { return nil, err } - if err := CacheSetStore(current.ToStoreCache()); err != nil { + if err := CacheSetStoreByScope(current.ToStoreCache(), scope); err != nil { return nil, err } @@ -286,10 +431,34 @@ func loadAndCacheStore(group string, tokenID int, id string) (*StoreV2, error) { } func GetStore(group string, tokenID int, id string) (*StoreV2, error) { - return getStore(group, tokenID, id, false) + return GetStoreByScope(group, tokenID, id, ChannelScopeGlobal) } -func getStore(group string, tokenID int, id string, includeExpired bool) (*StoreV2, error) { +func GetStoreByScope(group string, tokenID int, id string, scope ChannelScope) (*StoreV2, error) { + return getStore(group, tokenID, id, scope, false) +} + +func getStore( + group string, + tokenID int, + id string, + scope ChannelScope, + includeExpired bool, +) (*StoreV2, error) { + scope = normalizeChannelScope(scope) + if scope == ChannelScopeGroup { + var s GroupChannelStoreV2 + + tx := LogDB.Where("group_id = ? and token_id = ? and id = ?", group, tokenID, id) + if !includeExpired { + tx = tx.Where("expires_at > ?", time.Now()) + } + + err := tx.First(&s).Error + + return s.ToStoreV2(), HandleNotFound(err, ErrStoreNotFound) + } + var s StoreV2 tx := LogDB.Where("group_id = ? and token_id = ? and id = ?", group, tokenID, id) @@ -352,3 +521,23 @@ func CacheFollowStoreID(modelName string, keyType CacheKeyType) string { func CacheFollowUserStoreID(modelName, user string, keyType CacheKeyType) string { return HashedStoreID(StorePrefixCacheFollowUser, string(keyType), modelName, user) } + +func StoreChannelKey(store *StoreCache, scope ChannelScope) string { + if store == nil { + return "" + } + + switch normalizeChannelScope(scope) { + case ChannelScopeGroup: + return GroupChannelMonitorKey(store.GroupID, store.ChannelID) + case ChannelScopeGlobal: + default: + return "" + } + + if store.ChannelID == 0 { + return "" + } + + return strconv.Itoa(store.ChannelID) +} diff --git a/core/model/store_cache.go b/core/model/store_cache.go index 80657265..b15c621e 100644 --- a/core/model/store_cache.go +++ b/core/model/store_cache.go @@ -17,8 +17,8 @@ const ( storeLocalMissTTL = 500 * time.Millisecond storeRedisMissTTL = 15 * time.Second - StoreCacheKey = "storev2:%s:%d:%s" - StoreCacheNotFoundKey = "storev2notfound:%s:%d:%s" + StoreCacheKey = "storev2:%s:%d:%s:%s" + StoreCacheNotFoundKey = "storev2notfound:%s:%d:%s:%s" ) var ( @@ -85,12 +85,12 @@ func (s *StoreCache) ToStoreV2() *StoreV2 { } } -func getStoreCacheKey(group string, tokenID int, id string) string { - return common.RedisKeyf(StoreCacheKey, group, tokenID, id) +func getStoreCacheKey(group string, tokenID int, id string, scope ChannelScope) string { + return common.RedisKeyf(StoreCacheKey, group, tokenID, normalizeChannelScope(scope), id) } -func getStoreCacheNotFoundKey(group string, tokenID int, id string) string { - return common.RedisKeyf(StoreCacheNotFoundKey, group, tokenID, id) +func getStoreCacheNotFoundKey(group string, tokenID int, id string, scope ChannelScope) string { + return common.RedisKeyf(StoreCacheNotFoundKey, group, tokenID, normalizeChannelScope(scope), id) } func getStoreLocalTTL(store *StoreCache) time.Duration { @@ -168,10 +168,29 @@ func cacheGetStoreLocal(key string) (*StoreCache, bool, bool) { return cloneStoreCache(item.Store), false, true } -func cachePeekStore(group string, tokenID int, id string) (*StoreCache, bool) { - cacheKey := getStoreCacheKey(group, tokenID, id) +func validStoreCache(store *StoreCache) bool { + return store != nil && store.ID != "" +} + +func cacheGetValidStoreLocal(key string) (*StoreCache, bool, bool) { + storeCache, notFound, ok := cacheGetStoreLocal(key) + if !ok || notFound { + return storeCache, notFound, ok + } + + if !validStoreCache(storeCache) { + storeLocalCache.Delete(key) + return nil, false, false + } + + return storeCache, false, true +} + +func cachePeekStore(group string, tokenID int, id string, scope ChannelScope) (*StoreCache, bool) { + scope = normalizeChannelScope(scope) + cacheKey := getStoreCacheKey(group, tokenID, id, scope) - if storeCache, notFound, ok := cacheGetStoreLocal(cacheKey); ok { + if storeCache, notFound, ok := cacheGetValidStoreLocal(cacheKey); ok { if notFound { return nil, false } @@ -216,8 +235,20 @@ func cacheSetStoreNotFound(ctx context.Context, key string) error { } func CacheSetStore(store *StoreCache) error { - key := getStoreCacheKey(store.GroupID, store.TokenID, store.ID) - cacheSetStoreLocal(key, store) + return CacheSetStoreByScope(store, ChannelScopeGlobal) +} + +func CacheSetStoreByScope(store *StoreCache, scope ChannelScope) error { + scope = normalizeChannelScope(scope) + cacheStore := cloneStoreCache(store) + + key := getStoreCacheKey( + cacheStore.GroupID, + cacheStore.TokenID, + cacheStore.ID, + scope, + ) + cacheSetStoreLocal(key, cacheStore) if !common.RedisEnabled { return nil @@ -229,8 +260,13 @@ func CacheSetStore(store *StoreCache) error { return cacheSetStore( ctx, key, - getStoreCacheNotFoundKey(store.GroupID, store.TokenID, store.ID), - store, + getStoreCacheNotFoundKey( + cacheStore.GroupID, + cacheStore.TokenID, + cacheStore.ID, + scope, + ), + cacheStore, ) } @@ -256,8 +292,23 @@ func cacheSetStore(ctx context.Context, key, notFoundKey string, store *StoreCac } func CacheGetStore(group string, tokenID int, id string) (*StoreCache, error) { - cacheKey := getStoreCacheKey(group, tokenID, id) - if storeCache, notFound, ok := cacheGetStoreLocal(cacheKey); ok { + return CacheGetStoreByScope(group, tokenID, id, ChannelScopeGlobal) +} + +func CacheGetStoreByScope( + group string, + tokenID int, + id string, + scope ChannelScope, +) (*StoreCache, error) { + return cacheGetStore(group, tokenID, id, scope) +} + +func cacheGetStore(group string, tokenID int, id string, scope ChannelScope) (*StoreCache, error) { + scope = normalizeChannelScope(scope) + + cacheKey := getStoreCacheKey(group, tokenID, id, scope) + if storeCache, notFound, ok := cacheGetValidStoreLocal(cacheKey); ok { if notFound { return nil, NotFoundError(ErrStoreNotFound) } @@ -281,7 +332,7 @@ func CacheGetStore(group string, tokenID int, id string) (*StoreCache, error) { } } - notFoundKey := getStoreCacheNotFoundKey(group, tokenID, id) + notFoundKey := getStoreCacheNotFoundKey(group, tokenID, id, scope) exists, err := common.RDB.Exists(ctx, notFoundKey).Result() if err == nil && exists > 0 { @@ -294,10 +345,10 @@ func CacheGetStore(group string, tokenID int, id string) (*StoreCache, error) { storeCacheLoadLocker, cacheKey, func() (*StoreCache, bool, bool) { - return cacheGetStoreLocal(cacheKey) + return cacheGetValidStoreLocal(cacheKey) }, func() (*StoreCache, error) { - store, err := GetStore(group, tokenID, id) + store, err := GetStoreByScope(group, tokenID, id, scope) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { cacheSetStoreNotFoundLocalUnlocked(cacheKey) @@ -319,7 +370,7 @@ func CacheGetStore(group string, tokenID int, id string) (*StoreCache, error) { if cacheErr := cacheSetStoreNotFound( ctx, - getStoreCacheNotFoundKey(group, tokenID, id), + getStoreCacheNotFoundKey(group, tokenID, id, scope), ); cacheErr != nil { log.Error("redis set store not found cache error: " + cacheErr.Error()) } @@ -339,7 +390,7 @@ func CacheGetStore(group string, tokenID int, id string) (*StoreCache, error) { if err := cacheSetStore( ctx, cacheKey, - getStoreCacheNotFoundKey(group, tokenID, id), + getStoreCacheNotFoundKey(group, tokenID, id, scope), storeCache, ); err != nil { log.Error("redis set store error: " + err.Error()) diff --git a/core/model/store_cache_redis_test.go b/core/model/store_cache_redis_test.go index d3315207..02ceed77 100644 --- a/core/model/store_cache_redis_test.go +++ b/core/model/store_cache_redis_test.go @@ -23,7 +23,7 @@ func TestCacheGetStoreWritesRedisNotFoundCache(t *testing.T) { exists, err := client.Exists( ctx, - getStoreCacheNotFoundKey("group-redis-miss", 1, storeID), + getStoreCacheNotFoundKey("group-redis-miss", 1, storeID, ChannelScopeGlobal), ).Result() require.NoError(t, err) require.EqualValues(t, 1, exists) @@ -43,7 +43,7 @@ func withTestStoreCacheRedisEnv(t *testing.T, fn func(context.Context, *redis.Cl db, err := OpenSQLite(filepath.Join(t.TempDir(), "store_cache_redis_test.db")) require.NoError(t, err) - require.NoError(t, db.AutoMigrate(&StoreV2{})) + require.NoError(t, db.AutoMigrate(&StoreV2{}, &GroupChannelStoreV2{})) req := testcontainers.ContainerRequest{ Image: "redis:7-alpine", diff --git a/core/model/store_postgres_integration_test.go b/core/model/store_postgres_integration_test.go index ebdda005..903bf3ff 100644 --- a/core/model/store_postgres_integration_test.go +++ b/core/model/store_postgres_integration_test.go @@ -183,7 +183,7 @@ func openTestPostgreSQLWithRetry(dsn string, timeout time.Duration) (*gorm.DB, e for time.Now().Before(deadline) { db, err := OpenPostgreSQL(dsn) if err == nil { - if migrateErr := db.AutoMigrate(&StoreV2{}); migrateErr == nil { + if migrateErr := db.AutoMigrate(&StoreV2{}, &GroupChannelStoreV2{}); migrateErr == nil { return db, nil } else { lastErr = migrateErr diff --git a/core/model/store_test.go b/core/model/store_test.go index a3e3e4f2..aba39308 100644 --- a/core/model/store_test.go +++ b/core/model/store_test.go @@ -22,8 +22,8 @@ func TestStoreIDNamespaces(t *testing.T) { assert.Contains(t, CacheFollowStoreID("gpt-5", CacheKeyTypeStable), "cachefollow:") assert.Contains( t, - getStoreCacheNotFoundKey("group-1", 7, ResponseStoreID("resp_123")), - "storev2notfound:group-1:7:response:resp_123", + getStoreCacheNotFoundKey("group-1", 7, ResponseStoreID("resp_123"), ChannelScopeGlobal), + "storev2notfound:group-1:7:global:response:resp_123", ) assert.Equal(t, "", StoreID(StorePrefixResponse, "")) } @@ -104,6 +104,36 @@ func TestSaveIfNotExistStoreReplacesExpiredStore(t *testing.T) { }) } +func TestSaveIfNotExistStoreLoadsExistingGroupScopedStore(t *testing.T) { + withTestStoreDB(t, func() { + storeID := PromptCacheStoreID("gpt-5", "cache-key", CacheKeyTypeStable) + + existing, err := SaveIfNotExistStoreByScope(&StoreV2{ + ID: storeID, + GroupID: "group-1", + TokenID: 1, + ChannelID: 10, + Model: "gpt-5", + ExpiresAt: time.Now().Add(time.Minute), + }, ChannelScopeGroup) + require.NoError(t, err) + + storeLocalCache.Flush() + + saved, err := SaveIfNotExistStoreByScope(&StoreV2{ + ID: storeID, + GroupID: "group-1", + TokenID: 1, + ChannelID: 20, + Model: "gpt-5", + ExpiresAt: time.Now().Add(2 * time.Minute), + }, ChannelScopeGroup) + require.NoError(t, err) + assert.Equal(t, existing.ChannelID, saved.ChannelID) + assert.Equal(t, storeID, saved.ID) + }) +} + func TestCacheGetStoreUsesLocalCache(t *testing.T) { withTestStoreDB(t, func() { storeID := ResponseStoreID("resp_local_hit") @@ -139,6 +169,154 @@ func TestCacheGetStoreUsesLocalCache(t *testing.T) { }) } +func TestSaveStorePersistsChannelScope(t *testing.T) { + withTestStoreDB(t, func() { + storeID := ResponseStoreID("resp_group_scope") + + _, err := SaveStoreByScope(&StoreV2{ + ID: storeID, + GroupID: "group-1", + TokenID: 1, + ChannelID: 10, + Model: "gpt-5", + ExpiresAt: time.Now().Add(time.Minute), + }, ChannelScopeGroup) + require.NoError(t, err) + + store, err := GetStoreByScope("group-1", 1, storeID, ChannelScopeGroup) + require.NoError(t, err) + assert.Equal(t, 10, store.ChannelID) + + storeLocalCache.Flush() + + cached, err := CacheGetStoreByScope("group-1", 1, storeID, ChannelScopeGroup) + require.NoError(t, err) + assert.Equal(t, storeID, cached.ID) + assert.Equal(t, 10, cached.ChannelID) + }) +} + +func TestSaveStoreByScopePreservesGeneratedID(t *testing.T) { + withTestStoreDB(t, func() { + input := &StoreV2{ + GroupID: "group-1", + TokenID: 1, + ChannelID: 20, + Model: "gpt-5", + ExpiresAt: time.Now().Add(time.Minute), + } + + saved, err := SaveStoreByScope(input, ChannelScopeGroup) + require.NoError(t, err) + require.NotEmpty(t, saved.ID) + assert.Equal(t, saved.ID, input.ID) + + current, err := GetStoreByScope("group-1", 1, saved.ID, ChannelScopeGroup) + require.NoError(t, err) + assert.Equal(t, saved.ID, current.ID) + assert.Equal(t, 20, current.ChannelID) + + cached, err := CacheGetStoreByScope("group-1", 1, saved.ID, ChannelScopeGroup) + require.NoError(t, err) + assert.Equal(t, saved.ID, cached.ID) + assert.Equal(t, 20, cached.ChannelID) + }) +} + +func TestSaveIfNotExistStoreByScopePreservesGeneratedID(t *testing.T) { + withTestStoreDB(t, func() { + input := &StoreV2{ + GroupID: "group-1", + TokenID: 1, + ChannelID: 20, + Model: "gpt-5", + ExpiresAt: time.Now().Add(time.Minute), + } + + saved, err := SaveIfNotExistStoreByScope(input, ChannelScopeGroup) + require.NoError(t, err) + require.NotEmpty(t, saved.ID) + assert.Equal(t, saved.ID, input.ID) + + current, err := GetStoreByScope("group-1", 1, saved.ID, ChannelScopeGroup) + require.NoError(t, err) + assert.Equal(t, saved.ID, current.ID) + assert.Equal(t, 20, current.ChannelID) + + cached, err := CacheGetStoreByScope("group-1", 1, saved.ID, ChannelScopeGroup) + require.NoError(t, err) + assert.Equal(t, saved.ID, cached.ID) + assert.Equal(t, 20, cached.ChannelID) + }) +} + +func TestSaveStoreDefaultsChannelScopeToGlobal(t *testing.T) { + withTestStoreDB(t, func() { + storeID := ResponseStoreID("resp_global_scope") + + _, err := SaveStore(&StoreV2{ + ID: storeID, + GroupID: "group-1", + TokenID: 1, + ChannelID: 10, + Model: "gpt-5", + ExpiresAt: time.Now().Add(time.Minute), + }) + require.NoError(t, err) + + store, err := GetStore("group-1", 1, storeID) + require.NoError(t, err) + assert.Equal(t, 10, store.ChannelID) + + cached, err := CacheGetStore("group-1", 1, storeID) + require.NoError(t, err) + assert.Equal(t, storeID, cached.ID) + }) +} + +func TestStoreScopesUseIndependentIdentities(t *testing.T) { + withTestStoreDB(t, func() { + storeID := CacheFollowStoreID("gpt-5", CacheKeyTypeStable) + + _, err := SaveStore(&StoreV2{ + ID: storeID, + GroupID: "group-1", + TokenID: 1, + ChannelID: 10, + Model: "gpt-5", + ExpiresAt: time.Now().Add(time.Minute), + }) + require.NoError(t, err) + + _, err = SaveStoreByScope(&StoreV2{ + ID: storeID, + GroupID: "group-1", + TokenID: 1, + ChannelID: 20, + Model: "gpt-5", + ExpiresAt: time.Now().Add(time.Minute), + }, ChannelScopeGroup) + require.NoError(t, err) + + globalStore, err := CacheGetStoreByScope("group-1", 1, storeID, ChannelScopeGlobal) + require.NoError(t, err) + assert.Equal(t, storeID, globalStore.ID) + assert.Equal(t, 10, globalStore.ChannelID) + + groupStore, err := CacheGetStoreByScope("group-1", 1, storeID, ChannelScopeGroup) + require.NoError(t, err) + assert.Equal(t, storeID, groupStore.ID) + assert.Equal(t, 20, groupStore.ChannelID) + + var count int64 + require.NoError(t, LogDB.Model(&StoreV2{}).Count(&count).Error) + assert.Equal(t, int64(1), count) + + require.NoError(t, LogDB.Model(&GroupChannelStoreV2{}).Count(&count).Error) + assert.Equal(t, int64(1), count) + }) +} + func TestCacheGetStoreCachesNotFoundLocally(t *testing.T) { withTestStoreDB(t, func() { storeID := ResponseStoreID("resp_local_miss") @@ -167,6 +345,29 @@ func TestCacheGetStoreCachesNotFoundLocally(t *testing.T) { }) } +func TestCacheGetStoreIgnoresInvalidLocalCache(t *testing.T) { + withTestStoreDB(t, func() { + storeID := ResponseStoreID("resp_invalid_local_cache") + cacheKey := getStoreCacheKey("group-1", 1, storeID, ChannelScopeGlobal) + + require.NoError(t, LogDB.Create(&StoreV2{ + ID: storeID, + GroupID: "group-1", + TokenID: 1, + ChannelID: 10, + Model: "gpt-5", + ExpiresAt: time.Now().Add(time.Minute), + }).Error) + + storeLocalCache.Set(cacheKey, localStoreCacheItem{}, storeLocalTTL) + + store, err := CacheGetStore("group-1", 1, storeID) + require.NoError(t, err) + assert.Equal(t, storeID, store.ID) + assert.Equal(t, 10, store.ChannelID) + }) +} + func TestSaveStoreWithOptionSkipsUpdateWithinMinInterval(t *testing.T) { withTestStoreDB(t, func() { storeID := CacheFollowStoreID("gpt-5", CacheKeyTypeRecent) @@ -244,6 +445,31 @@ func TestSaveIfNotExistStoreUsesCachedExistingFastPath(t *testing.T) { }) } +func TestSaveIfNotExistStoreIgnoresInvalidLocalCache(t *testing.T) { + withTestStoreDB(t, func() { + storeID := PromptCacheStoreID("gpt-5", "invalid-cache", CacheKeyTypeStable) + cacheKey := getStoreCacheKey("group-1", 1, storeID, ChannelScopeGlobal) + + storeLocalCache.Set(cacheKey, localStoreCacheItem{}, storeLocalTTL) + + saved, err := SaveIfNotExistStore(&StoreV2{ + ID: storeID, + GroupID: "group-1", + TokenID: 1, + ChannelID: 20, + Model: "gpt-5", + ExpiresAt: time.Now().Add(time.Minute), + }) + require.NoError(t, err) + assert.Equal(t, storeID, saved.ID) + assert.Equal(t, 20, saved.ChannelID) + + current, err := GetStore("group-1", 1, storeID) + require.NoError(t, err) + assert.Equal(t, 20, current.ChannelID) + }) +} + func TestSaveStoreWithOptionUsesCachedFastPathWithinMinInterval(t *testing.T) { withTestStoreDB(t, func() { storeID := CacheFollowStoreID("gpt-5", CacheKeyTypeRecent) @@ -407,7 +633,7 @@ func withTestStoreDB(t *testing.T, fn func()) { db, err := OpenSQLite(filepath.Join(t.TempDir(), "store_test.db")) require.NoError(t, err) - require.NoError(t, db.AutoMigrate(&StoreV2{})) + require.NoError(t, db.AutoMigrate(&StoreV2{}, &GroupChannelStoreV2{})) LogDB = db DB = db diff --git a/core/model/summary-minute.go b/core/model/summary-minute.go index bdbce66d..6e2e17f3 100644 --- a/core/model/summary-minute.go +++ b/core/model/summary-minute.go @@ -130,6 +130,7 @@ func getChartDataMinute( timezone *time.Location, fields SummarySelectFields, ) ([]ChartData, error) { + modelName = normalizeSummaryModelFilter(modelName) query := LogDB.Model(&SummaryMinute{}) if channelID != 0 { @@ -181,6 +182,8 @@ func getGroupChartDataMinute( timezone *time.Location, fields SummarySelectFields, ) ([]ChartData, error) { + modelName = normalizeSummaryModelFilter(modelName) + query := LogDB.Model(&GroupSummaryMinute{}) if group != "" { query = query.Where("group_id = ?", group) @@ -532,11 +535,12 @@ func getGroupDashboardDataMinute( } type SummaryDataV2 struct { - Timestamp int64 `json:"timestamp,omitempty"` - ChannelID int `json:"channel_id,omitempty"` - GroupID string `json:"group_id,omitempty"` - TokenName string `json:"token_name,omitempty"` - Model string `json:"model"` + Timestamp int64 `json:"timestamp,omitempty"` + ChannelID int `json:"channel_id,omitempty"` + GroupChannelID int `json:"group_channel_id,omitempty"` + GroupID string `json:"group_id,omitempty"` + TokenName string `json:"token_name,omitempty"` + Model string `json:"model"` SummaryDataSet ServiceTierFlex SummaryDataSet `json:"service_tier_flex,omitempty" gorm:"embedded;embeddedPrefix:service_tier_flex_"` @@ -727,6 +731,7 @@ func GetGroupDashboardV3Data( } func getCurrentRPM(channelID int, modelName string) (int64, int64) { + modelName = normalizeSummaryModelFilter(modelName) now := time.Now() recentStart := now.Add(-2 * time.Minute).Unix() recentEnd := now.Unix() @@ -776,6 +781,7 @@ func getCurrentRPM(channelID int, modelName string) (int64, int64) { } func getGroupCurrentRPM(group, tokenName, modelName string) (int64, int64) { + modelName = normalizeSummaryModelFilter(modelName) now := time.Now() recentStart := now.Add(-2 * time.Minute).Unix() recentEnd := now.Unix() @@ -834,6 +840,7 @@ func GetTimeSeriesModelData( timezone *time.Location, fields SummarySelectFields, ) ([]TimeSummaryDataV2, error) { + modelName = normalizeSummaryModelFilter(modelName) if timeSpan == TimeSpanMinute { return getTimeSeriesModelDataMinute( channelID, modelName, start, end, timeSpan, timezone, fields, @@ -906,6 +913,7 @@ func GetGroupTimeSeriesModelData( timezone *time.Location, fields SummarySelectFields, ) ([]TimeSummaryDataV2, error) { + modelName = normalizeSummaryModelFilter(modelName) if timeSpan == TimeSpanMinute { return getGroupTimeSeriesModelDataMinute( group, @@ -982,6 +990,7 @@ func batchFillMaxValues( modelName string, start, end time.Time, ) error { + modelName = normalizeSummaryModelFilter(modelName) minuteQuery := LogDB.Model(&SummaryMinute{}) if channelID != 0 { @@ -1067,6 +1076,7 @@ func batchFillGroupMaxValues( group, tokenName, modelName string, start, end time.Time, ) error { + modelName = normalizeSummaryModelFilter(modelName) minuteQuery := LogDB.Model(&GroupSummaryMinute{}). Where("group_id = ?", group) @@ -1161,6 +1171,8 @@ func getTimeSeriesModelDataMinute( timezone *time.Location, fields SummarySelectFields, ) ([]TimeSummaryDataV2, error) { + modelName = normalizeSummaryModelFilter(modelName) + if end.IsZero() { end = time.Now() } else if end.Before(start) { @@ -1225,6 +1237,8 @@ func getGroupTimeSeriesModelDataMinute( timezone *time.Location, fields SummarySelectFields, ) ([]TimeSummaryDataV2, error) { + modelName = normalizeSummaryModelFilter(modelName) + if end.IsZero() { end = time.Now() } else if end.Before(start) { @@ -1334,9 +1348,11 @@ func aggregatToSpan( currentData, exists := dataMap[key] if !exists { currentData = SummaryDataV2{ - Timestamp: key.Timestamp, - ChannelID: data.ChannelID, - Model: data.Model, + Timestamp: key.Timestamp, + ChannelID: data.ChannelID, + GroupChannelID: data.GroupChannelID, + GroupID: data.GroupID, + Model: data.Model, } } diff --git a/core/model/summary.go b/core/model/summary.go index 90a0e870..2bc9e4fe 100644 --- a/core/model/summary.go +++ b/core/model/summary.go @@ -17,6 +17,13 @@ import ( // SummarySelectFields is a list of field names to select when querying summary data type SummarySelectFields []string +func normalizeSummaryModelFilter(modelName string) string { + if strings.TrimSpace(modelName) == "*" { + return "" + } + return modelName +} + var ( baseCountSummaryFields = []string{ "request_count", @@ -830,6 +837,7 @@ func getChartData( timezone *time.Location, fields SummarySelectFields, ) ([]ChartData, error) { + modelName = normalizeSummaryModelFilter(modelName) query := LogDB.Model(&Summary{}) if channelID != 0 { @@ -881,6 +889,8 @@ func getGroupChartData( timezone *time.Location, fields SummarySelectFields, ) ([]ChartData, error) { + modelName = normalizeSummaryModelFilter(modelName) + query := LogDB.Model(&GroupSummary{}) if group != "" { query = query.Where("group_id = ?", group) diff --git a/core/model/token.go b/core/model/token.go index eb2fa021..18a9570c 100644 --- a/core/model/token.go +++ b/core/model/token.go @@ -31,18 +31,24 @@ const ( ) type Token struct { - CreatedAt time.Time `json:"created_at"` - Group *Group `json:"-" gorm:"foreignKey:GroupID"` - Key string `json:"key" gorm:"type:char(48);uniqueIndex"` - Name EmptyNullString `json:"name" gorm:"size:32;index;uniqueIndex:idx_group_name;not null"` - GroupID string `json:"group" gorm:"size:64;index;uniqueIndex:idx_group_name"` - Subnets []string `json:"subnets" gorm:"serializer:fastjson;type:text"` - Models []string `json:"models" gorm:"serializer:fastjson;type:text"` - Status int `json:"status" gorm:"default:1;index"` - ID int `json:"id" gorm:"primaryKey"` - - UsedAmount float64 `json:"used_amount" gorm:"index"` - RequestCount int `json:"request_count" gorm:"index"` + CreatedAt time.Time `json:"created_at"` + Group *Group `json:"-" gorm:"foreignKey:GroupID"` + Key string `json:"key" gorm:"type:char(48);uniqueIndex"` + Name EmptyNullString `json:"name" gorm:"size:32;index;uniqueIndex:idx_group_name;not null"` + GroupID string `json:"group" gorm:"size:64;index;uniqueIndex:idx_group_name"` + Scope ChannelScope `json:"scope" gorm:"size:16;index"` + Subnets []string `json:"subnets" gorm:"serializer:fastjson;type:text"` + Models []string `json:"models" gorm:"serializer:fastjson;type:text"` + Sets []string `json:"sets" gorm:"serializer:fastjson;type:text"` + GroupChannelModels []string `json:"group_channel_models" gorm:"serializer:fastjson;type:text"` + GroupChannelSets []string `json:"group_channel_sets" gorm:"serializer:fastjson;type:text"` + Status int `json:"status" gorm:"default:1;index"` + ID int `json:"id" gorm:"primaryKey"` + + UsedAmount float64 `json:"used_amount" gorm:"index"` + RequestCount int `json:"request_count" gorm:"index"` + GroupChannelUsedAmount float64 `json:"group_channel_used_amount" gorm:"index"` + GroupChannelRequestCount int `json:"group_channel_request_count" gorm:"index"` Quota float64 `json:"quota"` PeriodQuota float64 `json:"period_quota"` @@ -67,6 +73,15 @@ func (t *Token) BeforeSave(_ *gorm.DB) error { if len(t.Name) > 32 { return errors.New("token name is too long") } + + if t.Scope != "" { + t.Scope = ParseChannelScope(string(t.Scope)) + } + + if !ValidTokenChannelScope(t.Scope) { + return errors.New("invalid token scope") + } + return nil } @@ -431,9 +446,7 @@ func GetTokenByKey(key string) (*Token, error) { return &token, HandleNotFound(err, ErrTokenNotFound) } -// GetAndValidateToken validates a token and checks quota limits -// This function is safe for concurrent use and handles period resets atomically -func GetAndValidateToken(key string) (token *TokenCache, err error) { +func GetTokenByKeyForAuth(key string) (token *TokenCache, err error) { if key == "" { return nil, errors.New("no token provided") } @@ -453,6 +466,14 @@ func GetAndValidateToken(key string) (token *TokenCache, err error) { return nil, fmt.Errorf("token (%s[%d]) is disabled", token.Name, token.ID) } + return token, nil +} + +func ValidateTokenQuota(token *TokenCache) error { + if token == nil { + return errors.New("no token provided") + } + // Convert TokenCache to Token for quota checking tokenModel := Token{ ID: token.ID, @@ -466,15 +487,30 @@ func GetAndValidateToken(key string) (token *TokenCache, err error) { totalExceeded, periodExceeded, err := tokenModel.GetEffectiveQuotaStatus() if err != nil { - return nil, fmt.Errorf("token (%s[%d]) quota check failed: %w", token.Name, token.ID, err) + return fmt.Errorf("token (%s[%d]) quota check failed: %w", token.Name, token.ID, err) } if totalExceeded { - return nil, fmt.Errorf("token (%s[%d]) total quota is exhausted", token.Name, token.ID) + return fmt.Errorf("token (%s[%d]) total quota is exhausted", token.Name, token.ID) } if periodExceeded { - return nil, fmt.Errorf("token (%s[%d]) period quota is exhausted", token.Name, token.ID) + return fmt.Errorf("token (%s[%d]) period quota is exhausted", token.Name, token.ID) + } + + return nil +} + +// GetAndValidateToken validates a token and checks quota limits +// This function is safe for concurrent use and handles period resets atomically +func GetAndValidateToken(key string) (token *TokenCache, err error) { + token, err = GetTokenByKeyForAuth(key) + if err != nil { + return nil, err + } + + if err := ValidateTokenQuota(token); err != nil { + return nil, err } return token, nil @@ -678,10 +714,14 @@ func DeleteTokensByIDs(ids []int) (err error) { } type UpdateTokenRequest struct { - Name *string `json:"name"` - Subnets *[]string `json:"subnets"` - Models *[]string `json:"models"` - Status int `json:"status"` + Name *string `json:"name"` + Subnets *[]string `json:"subnets"` + Models *[]string `json:"models"` + Sets *[]string `json:"sets"` + GroupChannelModels *[]string `json:"group_channel_models"` + GroupChannelSets *[]string `json:"group_channel_sets"` + Scope *string `json:"scope"` + Status int `json:"status"` // Quota system Quota *float64 `json:"quota"` PeriodQuota *float64 `json:"period_quota"` @@ -765,6 +805,35 @@ func UpdateToken(id int, update UpdateTokenRequest) (token *Token, err error) { selects = append(selects, "models") } + if update.Sets != nil { + token.Sets = *update.Sets + + selects = append(selects, "sets") + } + + if update.GroupChannelModels != nil { + token.GroupChannelModels = *update.GroupChannelModels + + selects = append(selects, "group_channel_models") + } + + if update.GroupChannelSets != nil { + token.GroupChannelSets = *update.GroupChannelSets + + selects = append(selects, "group_channel_sets") + } + + if update.Scope != nil { + scope := ParseChannelScope(*update.Scope) + if *update.Scope != "" && scope == "" { + return nil, errors.New("invalid token scope") + } + + token.Scope = scope + + selects = append(selects, "scope") + } + if update.Status != 0 { selects = append(selects, "status") } @@ -868,6 +937,35 @@ func UpdateGroupToken( selects = append(selects, "models") } + if update.Sets != nil { + token.Sets = *update.Sets + + selects = append(selects, "sets") + } + + if update.GroupChannelModels != nil { + token.GroupChannelModels = *update.GroupChannelModels + + selects = append(selects, "group_channel_models") + } + + if update.GroupChannelSets != nil { + token.GroupChannelSets = *update.GroupChannelSets + + selects = append(selects, "group_channel_sets") + } + + if update.Scope != nil { + scope := ParseChannelScope(*update.Scope) + if *update.Scope != "" && scope == "" { + return nil, errors.New("invalid token scope") + } + + token.Scope = scope + + selects = append(selects, "scope") + } + if update.Status != 0 { selects = append(selects, "status") } @@ -924,6 +1022,23 @@ func UpdateTokenUsedAmount(id int, amount float64, requestCount int) (err error) return HandleUpdateResult(result, ErrTokenNotFound) } +func UpdateGroupChannelTokenUsedAmount(id int, amount float64, requestCount int) error { + result := DB. + Model(&Token{}). + Where("id = ?", id). + Updates( + map[string]any{ + "group_channel_used_amount": gorm.Expr("group_channel_used_amount + ?", amount), + "group_channel_request_count": gorm.Expr( + "group_channel_request_count + ?", + requestCount, + ), + }, + ) + + return HandleUpdateResult(result, ErrTokenNotFound) +} + // calculateNextPeriodStartTime calculates the next period start time based on the last update time and period type // This finds the most recent period boundary by incrementing from lastUpdateTime until we reach the current time // This maintains period continuity - e.g., if reset was on Jan 15, next periods are Feb 15, Mar 15, etc. diff --git a/core/model/token_cache.go b/core/model/token_cache.go index c6c71572..e5ad4d16 100644 --- a/core/model/token_cache.go +++ b/core/model/token_cache.go @@ -4,8 +4,6 @@ import ( "context" "errors" "math/rand/v2" - "slices" - "strings" "time" "github.com/labring/aiproxy/core/common" @@ -27,114 +25,48 @@ func updateTokenLocalCache(key string, update func(*TokenCache) bool) { } type TokenCache struct { - Group string `json:"group" redis:"g"` - Key string `json:"-" redis:"-"` - Name string `json:"name" redis:"n"` - Subnets redisStringSlice `json:"subnets" redis:"s"` - Models redisStringSlice `json:"models" redis:"m"` - ID int `json:"id" redis:"i"` - Status int `json:"status" redis:"st"` - UsedAmount float64 `json:"used_amount" redis:"u"` + Group string `json:"group" redis:"g"` + Key string `json:"-" redis:"-"` + Name string `json:"name" redis:"n"` + Scope ChannelScope `json:"scope" redis:"sc"` + Subnets redisStringSlice `json:"subnets" redis:"s"` + Models redisStringSlice `json:"models" redis:"m"` + Sets redisStringSlice `json:"sets" redis:"sets"` + GroupChannelModels redisStringSlice `json:"group_channel_models" redis:"gcm"` + GroupChannelSets redisStringSlice `json:"group_channel_sets" redis:"gcs"` + ID int `json:"id" redis:"i"` + Status int `json:"status" redis:"st"` + UsedAmount float64 `json:"used_amount" redis:"u"` Quota float64 `json:"quota" redis:"q"` PeriodQuota float64 `json:"period_quota" redis:"pq"` PeriodType string `json:"period_type" redis:"pt"` PeriodLastUpdateTime redisTime `json:"period_last_update_time" redis:"plut"` PeriodLastUpdateAmount float64 `json:"period_last_update_amount" redis:"plua"` - - availableSets []string - modelsBySet map[string][]string -} - -func (t *TokenCache) SetAvailableSets(availableSets []string) { - t.availableSets = availableSets -} - -func (t *TokenCache) SetModelsBySet(modelsBySet map[string][]string) { - t.modelsBySet = modelsBySet } -func (t *TokenCache) FindModel(model string) string { - var findModel string - if len(t.Models) != 0 { - if !slices.ContainsFunc(t.Models, func(e string) bool { - ok := strings.EqualFold(e, model) - if ok { - findModel = e - } - - return ok - }) { - return findModel - } - } - - return containsModel(model, t.availableSets, t.modelsBySet) +func (t *TokenCache) GetConfiguredSets() []string { + return cleanAvailableSets(t.Sets) } -func containsModel(model string, sets []string, modelsBySet map[string][]string) string { - var findModel string - for _, set := range sets { - if slices.ContainsFunc(modelsBySet[set], func(e string) bool { - ok := strings.EqualFold(e, model) - if ok { - findModel = e - } - - return ok - }) { - return findModel - } - } - - return findModel -} - -func (t *TokenCache) Range(fn func(model string) bool) { - ranged := make(map[string]struct{}) - if len(t.Models) != 0 { - for _, model := range t.Models { - if _, ok := ranged[model]; ok { - continue - } - - model = containsModel(model, t.availableSets, t.modelsBySet) - if model == "" { - continue - } - - ranged[model] = struct{}{} - if !fn(model) { - return - } - } - - return - } - - for _, set := range t.availableSets { - for _, model := range t.modelsBySet[set] { - if _, ok := ranged[model]; !ok { - if !fn(model) { - return - } - } - - ranged[model] = struct{}{} - } - } +func (t *TokenCache) GetConfiguredGroupChannelSets() []string { + return cleanAvailableSets(t.GroupChannelSets) } func (t *Token) ToTokenCache() *TokenCache { return &TokenCache{ - ID: t.ID, - Group: t.GroupID, - Key: t.Key, - Name: string(t.Name), - Models: t.Models, - Subnets: t.Subnets, - Status: t.Status, - UsedAmount: t.UsedAmount, + ID: t.ID, + Group: t.GroupID, + Key: t.Key, + Name: string(t.Name), + Scope: t.Scope, + Models: t.Models, + Sets: t.Sets, + GroupChannelModels: t.GroupChannelModels, + GroupChannelSets: t.GroupChannelSets, + Subnets: t.Subnets, + Status: t.Status, + UsedAmount: t.UsedAmount, Quota: t.Quota, PeriodQuota: t.PeriodQuota, diff --git a/core/model/token_update_test.go b/core/model/token_update_test.go new file mode 100644 index 00000000..b2ac7248 --- /dev/null +++ b/core/model/token_update_test.go @@ -0,0 +1,179 @@ +package model_test + +import ( + "path/filepath" + "testing" + + "github.com/labring/aiproxy/core/model" +) + +func TestUpdateTokenRejectsInvalidScope(t *testing.T) { + db, err := model.OpenSQLite(filepath.Join(t.TempDir(), "tokens.db")) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + + prevDB := model.DB + model.DB = db + t.Cleanup(func() { + model.DB = prevDB + }) + + if err := db.AutoMigrate(&model.Token{}); err != nil { + t.Fatalf("migrate tokens: %v", err) + } + + token := model.Token{ + GroupID: "test-group", + Name: model.EmptyNullString("test-token"), + Scope: model.ChannelScopeGlobal, + Status: model.TokenStatusEnabled, + } + if err := db.Create(&token).Error; err != nil { + t.Fatalf("create token: %v", err) + } + + invalidScope := "glboal" + if _, err := model.UpdateToken(token.ID, model.UpdateTokenRequest{ + Scope: &invalidScope, + }); err == nil { + t.Fatal("expected invalid token scope error") + } + + var got model.Token + if err := db.First(&got, token.ID).Error; err != nil { + t.Fatalf("reload token: %v", err) + } + + if got.Scope != model.ChannelScopeGlobal { + t.Fatalf("expected scope to remain global, got %q", got.Scope) + } +} + +func TestUpdateTokenUpdatesGroupChannelModels(t *testing.T) { + db, err := model.OpenSQLite(filepath.Join(t.TempDir(), "tokens.db")) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + + prevDB := model.DB + model.DB = db + t.Cleanup(func() { + model.DB = prevDB + }) + + if err := db.AutoMigrate(&model.Token{}); err != nil { + t.Fatalf("migrate tokens: %v", err) + } + + token := model.Token{ + GroupID: "test-group", + Name: model.EmptyNullString("test-token"), + Models: []string{"global-model"}, + Status: model.TokenStatusEnabled, + } + if err := db.Create(&token).Error; err != nil { + t.Fatalf("create token: %v", err) + } + + groupChannelModels := []string{"group-model"} + if _, err := model.UpdateToken(token.ID, model.UpdateTokenRequest{ + GroupChannelModels: &groupChannelModels, + }); err != nil { + t.Fatalf("update token: %v", err) + } + + var got model.Token + if err := db.First(&got, token.ID).Error; err != nil { + t.Fatalf("reload token: %v", err) + } + + if len(got.GroupChannelModels) != 1 || got.GroupChannelModels[0] != "group-model" { + t.Fatalf("expected group channel models to update, got %v", got.GroupChannelModels) + } +} + +func TestGetTokenByKeyForAuthSkipsQuotaCheck(t *testing.T) { + db, err := model.OpenSQLite(filepath.Join(t.TempDir(), "tokens.db")) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + + prevDB := model.DB + model.DB = db + t.Cleanup(func() { + model.DB = prevDB + }) + + if err := db.AutoMigrate(&model.Token{}); err != nil { + t.Fatalf("migrate tokens: %v", err) + } + + token := model.Token{ + GroupID: "test-group", + Name: model.EmptyNullString("test-token"), + Scope: model.ChannelScopeGroup, + Quota: 1, + UsedAmount: 1, + Status: model.TokenStatusEnabled, + } + if err := db.Create(&token).Error; err != nil { + t.Fatalf("create token: %v", err) + } + + got, err := model.GetTokenByKeyForAuth(token.Key) + if err != nil { + t.Fatalf("get token for auth: %v", err) + } + + if got.ID != token.ID { + t.Fatalf("expected token id %d, got %d", token.ID, got.ID) + } + + if err := model.ValidateTokenQuota(got); err == nil { + t.Fatal("expected quota validation error") + } +} + +func TestUpdateGroupTokenRejectsInvalidScope(t *testing.T) { + db, err := model.OpenSQLite(filepath.Join(t.TempDir(), "group_tokens.db")) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + + prevDB := model.DB + model.DB = db + t.Cleanup(func() { + model.DB = prevDB + }) + + if err := db.AutoMigrate(&model.Token{}); err != nil { + t.Fatalf("migrate tokens: %v", err) + } + + token := model.Token{ + GroupID: "test-group", + Name: model.EmptyNullString("test-token"), + Scope: model.ChannelScopeGlobal, + Status: model.TokenStatusEnabled, + } + if err := db.Create(&token).Error; err != nil { + t.Fatalf("create token: %v", err) + } + + invalidScope := "glboal" + if _, err := model.UpdateGroupToken(token.ID, token.GroupID, model.UpdateTokenRequest{ + Scope: &invalidScope, + }); err == nil { + t.Fatal("expected invalid token scope error") + } + + var got model.Token + if err := db.First(&got, token.ID).Error; err != nil { + t.Fatalf("reload token: %v", err) + } + + if got.Scope != model.ChannelScopeGlobal { + t.Fatalf("expected scope to remain global, got %q", got.Scope) + } +} diff --git a/core/monitor/local_cache.go b/core/monitor/local_cache.go index 55956236..f8acf82e 100644 --- a/core/monitor/local_cache.go +++ b/core/monitor/local_cache.go @@ -28,10 +28,18 @@ func bannedChannelsLocalCacheKey(model string) string { return "banned:" + model } +func bannedChannelsStringLocalCacheKey(model string) string { + return "banned:" + model + ":string" +} + func channelModelErrorRateLocalCacheKey(model string, channelID int64) string { return "rate:" + model + ":channel:" + strconv.FormatInt(channelID, 10) } +func channelModelErrorRateStringLocalCacheKey(model, channelKey string) string { + return "rate:" + model + ":channel:" + channelKey +} + func cloneChannelErrorRates(values map[int64]float64) map[int64]float64 { if values == nil { return nil @@ -43,6 +51,17 @@ func cloneChannelErrorRates(values map[int64]float64) map[int64]float64 { return cloned } +func cloneChannelStringErrorRates(values map[string]float64) map[string]float64 { + if values == nil { + return nil + } + + cloned := make(map[string]float64, len(values)) + maps.Copy(cloned, values) + + return cloned +} + func cloneBannedChannels(values map[int64]struct{}) map[int64]struct{} { if values == nil { return nil @@ -56,6 +75,19 @@ func cloneBannedChannels(values map[int64]struct{}) map[int64]struct{} { return cloned } +func cloneBannedChannelKeys(values map[string]struct{}) map[string]struct{} { + if values == nil { + return nil + } + + cloned := make(map[string]struct{}, len(values)) + for key := range values { + cloned[key] = struct{}{} + } + + return cloned +} + func getModelChannelErrorRateLocal(model string) (map[int64]float64, bool) { v, ok := modelChannelErrorRateLocalCache.Get(modelChannelErrorRateLocalCacheKey(model)) if !ok { @@ -70,6 +102,34 @@ func getModelChannelErrorRateLocal(model string) (map[int64]float64, bool) { return cloneChannelErrorRates(rates), true } +func getModelChannelStringErrorRateLocal(model string) (map[string]float64, bool) { + v, ok := modelChannelErrorRateLocalCache.Get( + modelChannelErrorRateLocalCacheKey(model) + ":string", + ) + if !ok { + return nil, false + } + + rates, ok := v.(map[string]float64) + if !ok { + panic("model channel string error rate local cache type mismatch") + } + + return cloneChannelStringErrorRates(rates), true +} + +func setModelChannelStringErrorRateLocalUnlocked(model string, values map[string]float64) { + modelChannelErrorRateLocalCache.Set( + modelChannelErrorRateLocalCacheKey(model)+":string", + cloneChannelStringErrorRates(values), + monitorLocalTTL, + ) + + for channelKey, rate := range values { + setChannelModelStringErrorRateLocalUnlocked(model, channelKey, rate) + } +} + func setModelChannelErrorRateLocalUnlocked(model string, values map[int64]float64) { modelChannelErrorRateLocalCache.Set( modelChannelErrorRateLocalCacheKey(model), @@ -85,6 +145,9 @@ func setModelChannelErrorRateLocalUnlocked(model string, values map[int64]float6 func deleteModelChannelErrorRateLocal(model string) { common.WithKeyLock(monitorLocalLoadLocker, modelChannelErrorRateLocalCacheKey(model), func() { modelChannelErrorRateLocalCache.Delete(modelChannelErrorRateLocalCacheKey(model)) + modelChannelErrorRateLocalCache.Delete( + modelChannelErrorRateLocalCacheKey(model) + ":string", + ) }) } @@ -104,6 +167,30 @@ func getChannelModelErrorRateLocal(model string, channelID int64) (float64, bool return rate, true } +func getChannelModelStringErrorRateLocal(model, channelKey string) (float64, bool) { + v, ok := channelModelErrorRateLocalCache.Get( + channelModelErrorRateStringLocalCacheKey(model, channelKey), + ) + if !ok { + return 0, false + } + + rate, ok := v.(float64) + if !ok { + panic("channel model string error rate local cache type mismatch") + } + + return rate, true +} + +func setChannelModelStringErrorRateLocalUnlocked(model, channelKey string, value float64) { + channelModelErrorRateLocalCache.Set( + channelModelErrorRateStringLocalCacheKey(model, channelKey), + value, + monitorLocalTTL, + ) +} + func setChannelModelErrorRateLocalUnlocked(model string, channelID int64, value float64) { channelModelErrorRateLocalCache.Set( channelModelErrorRateLocalCacheKey(model, channelID), @@ -112,14 +199,20 @@ func setChannelModelErrorRateLocalUnlocked(model string, channelID int64, value ) } -func deleteChannelModelErrorRateLocal(model string, channelID int64) { +func deleteChannelModelStringErrorRateLocal(model, channelKey string) { common.WithKeyLock( monitorLocalLoadLocker, - channelModelErrorRateLocalCacheKey(model, channelID), + channelModelErrorRateStringLocalCacheKey(model, channelKey), func() { channelModelErrorRateLocalCache.Delete( - channelModelErrorRateLocalCacheKey(model, channelID), + channelModelErrorRateStringLocalCacheKey(model, channelKey), ) + + if channelID, err := strconv.ParseInt(channelKey, 10, 64); err == nil { + channelModelErrorRateLocalCache.Delete( + channelModelErrorRateLocalCacheKey(model, channelID), + ) + } }, ) } @@ -146,9 +239,32 @@ func setBannedChannelsLocalUnlocked(model string, values map[int64]struct{}) { ) } +func getBannedChannelKeysLocal(model string) (map[string]struct{}, bool) { + v, ok := modelBannedChannelsLocalCache.Get(bannedChannelsStringLocalCacheKey(model)) + if !ok { + return nil, false + } + + banned, ok := v.(map[string]struct{}) + if !ok { + panic("banned channel keys local cache type mismatch") + } + + return cloneBannedChannelKeys(banned), true +} + +func setBannedChannelKeysLocalUnlocked(model string, values map[string]struct{}) { + modelBannedChannelsLocalCache.Set( + bannedChannelsStringLocalCacheKey(model), + cloneBannedChannelKeys(values), + monitorLocalTTL, + ) +} + func deleteBannedChannelsLocal(model string) { common.WithKeyLock(monitorLocalLoadLocker, bannedChannelsLocalCacheKey(model), func() { modelBannedChannelsLocalCache.Delete(bannedChannelsLocalCacheKey(model)) + modelBannedChannelsLocalCache.Delete(bannedChannelsStringLocalCacheKey(model)) }) } diff --git a/core/monitor/memmodel.go b/core/monitor/memmodel.go index 105148fe..7dccec4a 100644 --- a/core/monitor/memmodel.go +++ b/core/monitor/memmodel.go @@ -3,14 +3,19 @@ package monitor import ( "context" "math/rand/v2" + "strconv" "sync" "time" ) -var memModelMonitor *MemModelMonitor +var ( + memModelMonitor *MemModelMonitor + memGroupChannelModelMonitor *MemModelMonitor +) func init() { memModelMonitor = NewMemModelMonitor() + memGroupChannelModelMonitor = NewMemModelMonitor() } const ( @@ -36,7 +41,7 @@ type MemModelMonitor struct { } type ModelData struct { - channels map[int64]*ChannelStats + channels map[string]*ChannelStats totalStats *TimeWindowStats } @@ -117,6 +122,21 @@ func (m *MemModelMonitor) AddRequest( channelID int64, isError, tryBan bool, maxErrorRate float64, +) (errorRate float64, banExecution bool) { + return m.AddRequestByChannelKey( + model, + strconv.FormatInt(channelID, 10), + isError, + tryBan, + maxErrorRate, + ) +} + +func (m *MemModelMonitor) AddRequestByChannelKey( + model string, + channelKey string, + isError, tryBan bool, + maxErrorRate float64, ) (errorRate float64, banExecution bool) { m.mu.Lock() defer m.mu.Unlock() @@ -130,18 +150,18 @@ func (m *MemModelMonitor) AddRequest( if modelData, exists = m.models[model]; !exists { modelData = &ModelData{ - channels: make(map[int64]*ChannelStats), + channels: make(map[string]*ChannelStats), totalStats: NewTimeWindowStats(), } m.models[model] = modelData } var channel *ChannelStats - if channel, exists = modelData.channels[channelID]; !exists { + if channel, exists = modelData.channels[channelKey]; !exists { channel = &ChannelStats{ timeWindows: NewTimeWindowStats(), } - modelData.channels[channelID] = channel + modelData.channels[channelKey] = channel } modelData.totalStats.AddRequest(now, isError) @@ -212,7 +232,12 @@ func (m *MemModelMonitor) GetModelChannelErrorRate( result := make(map[int64]float64) if data, exists := m.models[model]; exists { - for channelID, channel := range data.channels { + for channelKey, channel := range data.channels { + channelID, err := strconv.ParseInt(channelKey, 10, 64) + if err != nil { + continue + } + result[channelID] = getErrorRateFromStats(channel.timeWindows) } } @@ -220,10 +245,39 @@ func (m *MemModelMonitor) GetModelChannelErrorRate( return result, nil } -func (m *MemModelMonitor) GetChannelModelErrorRate( +func (m *MemModelMonitor) GetModelChannelErrorRateByKey( _ context.Context, model string, +) (map[string]float64, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + result := make(map[string]float64) + if data, exists := m.models[model]; exists { + for channelKey, channel := range data.channels { + result[channelKey] = getErrorRateFromStats(channel.timeWindows) + } + } + + return result, nil +} + +func (m *MemModelMonitor) GetChannelModelErrorRate( + ctx context.Context, + model string, channelID int64, +) (float64, error) { + return m.GetChannelModelErrorRateByKey( + ctx, + model, + strconv.FormatInt(channelID, 10), + ) +} + +func (m *MemModelMonitor) GetChannelModelErrorRateByKey( + _ context.Context, + model string, + channelKey string, ) (float64, error) { m.mu.RLock() defer m.mu.RUnlock() @@ -233,7 +287,7 @@ func (m *MemModelMonitor) GetChannelModelErrorRate( return 0, nil } - channel, exists := data.channels[channelID] + channel, exists := data.channels[channelKey] if !exists { return 0, nil } @@ -250,7 +304,7 @@ func (m *MemModelMonitor) GetChannelModelErrorRates( result := make(map[string]float64) for model, data := range m.models { - if channel, exists := data.channels[channelID]; exists { + if channel, exists := data.channels[strconv.FormatInt(channelID, 10)]; exists { result[model] = getErrorRateFromStats(channel.timeWindows) } } @@ -266,7 +320,12 @@ func (m *MemModelMonitor) GetAllChannelModelErrorRates( result := make(map[int64]map[string]float64) for model, data := range m.models { - for channelID, channel := range data.channels { + for channelKey, channel := range data.channels { + channelID, err := strconv.ParseInt(channelKey, 10, 64) + if err != nil { + continue + } + if _, exists := result[channelID]; !exists { result[channelID] = make(map[string]float64) } @@ -292,11 +351,16 @@ func (m *MemModelMonitor) GetAllModelChannelStats( result[model] = make(map[int64]ModelChannelStatsSnapshot) } - for channelID, channel := range data.channels { - req, err := channel.timeWindows.GetStats() + for channelKey, channel := range data.channels { + channelID, err := strconv.ParseInt(channelKey, 10, 64) + if err != nil { + continue + } + + req, errCount := channel.timeWindows.GetStats() result[model][channelID] = ModelChannelStatsSnapshot{ Requests: int64(req), - Errors: int64(err), + Errors: int64(errCount), Banned: channel.bannedUntil.After(now), } } @@ -315,7 +379,12 @@ func (m *MemModelMonitor) GetBannedChannelsWithModel( var banned []int64 if data, exists := m.models[model]; exists { now := time.Now() - for channelID, channel := range data.channels { + for channelKey, channel := range data.channels { + channelID, err := strconv.ParseInt(channelKey, 10, 64) + if err != nil { + continue + } + if channel.bannedUntil.After(now) { banned = append(banned, channelID) } @@ -335,7 +404,12 @@ func (m *MemModelMonitor) GetBannedChannelsMapWithModel( banned := make(map[int64]struct{}) if data, exists := m.models[model]; exists { now := time.Now() - for channelID, channel := range data.channels { + for channelKey, channel := range data.channels { + channelID, err := strconv.ParseInt(channelKey, 10, 64) + if err != nil { + continue + } + if channel.bannedUntil.After(now) { banned[channelID] = struct{}{} } @@ -345,6 +419,26 @@ func (m *MemModelMonitor) GetBannedChannelsMapWithModel( return banned, nil } +func (m *MemModelMonitor) GetBannedChannelsMapWithModelByKey( + _ context.Context, + model string, +) (map[string]struct{}, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + banned := make(map[string]struct{}) + if data, exists := m.models[model]; exists { + now := time.Now() + for channelKey, channel := range data.channels { + if channel.bannedUntil.After(now) { + banned[channelKey] = struct{}{} + } + } + } + + return banned, nil +} + func (m *MemModelMonitor) GetAllBannedModelChannels(_ context.Context) (map[string][]int64, error) { m.mu.RLock() defer m.mu.RUnlock() @@ -353,7 +447,12 @@ func (m *MemModelMonitor) GetAllBannedModelChannels(_ context.Context) (map[stri now := time.Now() for model, data := range m.models { - for channelID, channel := range data.channels { + for channelKey, channel := range data.channels { + channelID, err := strconv.ParseInt(channelKey, 10, 64) + if err != nil { + continue + } + if channel.bannedUntil.After(now) { if _, exists := result[model]; !exists { result[model] = []int64{} @@ -368,26 +467,45 @@ func (m *MemModelMonitor) GetAllBannedModelChannels(_ context.Context) (map[stri } func (m *MemModelMonitor) ClearChannelModelErrors( - _ context.Context, + ctx context.Context, model string, channelID int, +) error { + return m.ClearChannelModelErrorsByKey( + ctx, + model, + strconv.Itoa(channelID), + ) +} + +func (m *MemModelMonitor) ClearChannelModelErrorsByKey( + _ context.Context, + model string, + channelKey string, ) error { m.mu.Lock() defer m.mu.Unlock() if data, exists := m.models[model]; exists { - delete(data.channels, int64(channelID)) + delete(data.channels, channelKey) } return nil } -func (m *MemModelMonitor) ClearChannelAllModelErrors(_ context.Context, channelID int) error { +func (m *MemModelMonitor) ClearChannelAllModelErrors(ctx context.Context, channelID int) error { + return m.ClearChannelAllModelErrorsByKey(ctx, strconv.Itoa(channelID)) +} + +func (m *MemModelMonitor) ClearChannelAllModelErrorsByKey( + _ context.Context, + channelKey string, +) error { m.mu.Lock() defer m.mu.Unlock() for _, data := range m.models { - delete(data.channels, int64(channelID)) + delete(data.channels, channelKey) } return nil diff --git a/core/monitor/memmodel_test.go b/core/monitor/memmodel_test.go index b9755e27..183727d9 100644 --- a/core/monitor/memmodel_test.go +++ b/core/monitor/memmodel_test.go @@ -74,8 +74,8 @@ func TestMemModelMonitorCleanupExpiredData(t *testing.T) { monitor := &MemModelMonitor{ models: map[string]*ModelData{ "model-a": { - channels: map[int64]*ChannelStats{ - 1: { + channels: map[string]*ChannelStats{ + "1": { timeWindows: &TimeWindowStats{ slices: []*timeSlice{ {windowStart: time.Now().Add(-13 * timeWindow), requests: 1}, @@ -179,3 +179,101 @@ func TestMemModelMonitorGetChannelModelErrorRate(t *testing.T) { require.NoError(t, err) require.Zero(t, rate) } + +func TestMemModelMonitorGroupChannelKeyIsolation(t *testing.T) { + monitor := &MemModelMonitor{ + models: make(map[string]*ModelData), + } + + groupKey := "group_channel:group-1:42" + for range minRequestCount { + _, _ = monitor.AddRequestByChannelKey("model-group", groupKey, true, false, 0) + } + + stringRates, err := monitor.GetModelChannelErrorRateByKey(context.Background(), "model-group") + require.NoError(t, err) + require.Contains(t, stringRates, groupKey) + + intRates, err := monitor.GetModelChannelErrorRate(context.Background(), "model-group") + require.NoError(t, err) + require.Empty(t, intRates) + + banned, err := monitor.GetBannedChannelsMapWithModelByKey(context.Background(), "model-group") + require.NoError(t, err) + require.Empty(t, banned) + + _, _ = monitor.AddRequestByChannelKey("model-group", groupKey, true, true, 0) + banned, err = monitor.GetBannedChannelsMapWithModelByKey(context.Background(), "model-group") + require.NoError(t, err) + require.Contains(t, banned, groupKey) + + require.NoError(t, monitor.ClearChannelAllModelErrorsByKey(context.Background(), groupKey)) + rate, err := monitor.GetChannelModelErrorRateByKey( + context.Background(), + "model-group", + groupKey, + ) + require.NoError(t, err) + require.Zero(t, rate) +} + +func TestGroupChannelMonitorNamespaceIsolatedFromGlobalMonitor(t *testing.T) { + oldGlobalMonitor := memModelMonitor + oldGroupChannelMonitor := memGroupChannelModelMonitor + memModelMonitor = &MemModelMonitor{models: make(map[string]*ModelData)} + memGroupChannelModelMonitor = &MemModelMonitor{models: make(map[string]*ModelData)} + t.Cleanup(func() { + memModelMonitor = oldGlobalMonitor + memGroupChannelModelMonitor = oldGroupChannelMonitor + }) + + ctx := context.Background() + + groupKey := "group_channel:group-1:42" + for range minRequestCount { + _, _, err := AddGroupChannelRequestByChannelKey( + ctx, + "model-group-scope", + groupKey, + true, + false, + 0, + ) + require.NoError(t, err) + } + + globalModelRates, err := GetModelsErrorRate(ctx) + require.NoError(t, err) + require.Empty(t, globalModelRates) + + globalStringRates, err := GetModelChannelErrorRateByKey(ctx, "model-group-scope") + require.NoError(t, err) + require.Empty(t, globalStringRates) + + groupStringRates, err := GetGroupChannelModelErrorRateByKey(ctx, "model-group-scope") + require.NoError(t, err) + require.Contains(t, groupStringRates, groupKey) + + _, _, err = AddGroupChannelRequestByChannelKey( + ctx, + "model-group-scope", + groupKey, + true, + true, + 0, + ) + require.NoError(t, err) + + globalBanned, err := GetBannedChannelKeysMapWithModel(ctx, "model-group-scope") + require.NoError(t, err) + require.Empty(t, globalBanned) + + groupBanned, err := GetGroupChannelBannedChannelKeysMapWithModel(ctx, "model-group-scope") + require.NoError(t, err) + require.Contains(t, groupBanned, groupKey) + + require.NoError(t, ClearGroupChannelAllModelErrorsByKey(ctx, groupKey)) + groupRate, err := GetGroupChannelChannelModelErrorRateByKey(ctx, "model-group-scope", groupKey) + require.NoError(t, err) + require.Zero(t, groupRate) +} diff --git a/core/monitor/model.go b/core/monitor/model.go index 3004d120..46e902e9 100644 --- a/core/monitor/model.go +++ b/core/monitor/model.go @@ -19,12 +19,21 @@ const ( statsKeySuffix = ":stats" modelTotalStatsSuffix = ":total_stats" channelKeyPart = ":channel:" + groupChannelKeyPrefix = "group_channel:" ) func modelKeyPrefix() string { return common.RedisKey("model:") } +func groupChannelModelKeyPrefix() string { + return common.RedisKey("group_channel", "model:") +} + +func groupChannelMonitorRedisPrefix() string { + return common.RedisKey("group_channel") +} + // Redis scripts var ( addRequestScript = redis.NewScript(addRequestLuaScript) @@ -34,18 +43,59 @@ var ( clearChannelAllModelErrorsScript = redis.NewScript(clearChannelAllModelErrorsLuaScript) clearAllModelErrorsScript = redis.NewScript(clearAllModelErrorsLuaScript) redisMonitorModel = newRedisModelMonitor( + modelKeyPrefix, + common.RedisKeyPrefix, + func() *redis.Client { + return common.RDB + }, + ) + redisGroupChannelMonitorModel = newRedisModelMonitor( + groupChannelModelKeyPrefix, + groupChannelMonitorRedisPrefix, func() *redis.Client { return common.RDB }, ) ) type redisModelMonitor struct { - getRDB func() *redis.Client + keyPrefix func() string + redisPrefix func() string + getRDB func() *redis.Client } -func newRedisModelMonitor(getRDB func() *redis.Client) *redisModelMonitor { +func newRedisModelMonitor( + keyPrefix func() string, + redisPrefix func() string, + getRDB func() *redis.Client, +) *redisModelMonitor { return &redisModelMonitor{ - getRDB: getRDB, + keyPrefix: keyPrefix, + redisPrefix: redisPrefix, + getRDB: getRDB, + } +} + +func (m *redisModelMonitor) modelKeyPrefix() string { + if m == nil || m.keyPrefix == nil { + return modelKeyPrefix() } + + return m.keyPrefix() +} + +func (m *redisModelMonitor) redisKeyPrefix() string { + if m == nil || m.redisPrefix == nil { + return common.RedisKeyPrefix() + } + + return m.redisPrefix() +} + +func (m *redisModelMonitor) cacheModel(model string) string { + if m.modelKeyPrefix() == modelKeyPrefix() { + return model + } + + return m.modelKeyPrefix() + model } func GetModelsErrorRate(ctx context.Context) (map[string]float64, error) { @@ -76,14 +126,15 @@ func (m *redisModelMonitor) GetModelsErrorRate(ctx context.Context) (map[string] } result := make(map[string]float64) - pattern := modelKeyPrefix() + "*" + modelTotalStatsSuffix + keyPrefix := m.modelKeyPrefix() + pattern := keyPrefix + "*" + modelTotalStatsSuffix now := time.Now().UnixMilli() iter := rdb.Scan(ctx, 0, pattern, 0).Iterator() for iter.Next(ctx) { key := iter.Val() - model := strings.TrimPrefix(key, modelKeyPrefix()) + model := strings.TrimPrefix(key, keyPrefix) model = strings.TrimSuffix(model, modelTotalStatsSuffix) rate, err := getErrorRateScript.Run( @@ -137,6 +188,83 @@ func AddRequest( ) } +func AddRequestByChannelKey( + ctx context.Context, + modelName string, + channelKey string, + isError, tryBan bool, + maxErrorRate float64, +) (errorRate float64, banExecution bool, err error) { + if strings.HasPrefix(channelKey, groupChannelKeyPrefix) { + return AddGroupChannelRequestByChannelKey( + ctx, + modelName, + channelKey, + isError, + tryBan, + maxErrorRate, + ) + } + + if channelKey == "" { + channelKey = "0" + } + + if !common.RedisEnabled { + errorRate, banExecution = memModelMonitor.AddRequestByChannelKey( + modelName, + channelKey, + isError, + tryBan, + maxErrorRate, + ) + + return errorRate, banExecution, nil + } + + return redisMonitorModel.AddRequestByChannelKey( + ctx, + modelName, + channelKey, + isError, + tryBan, + maxErrorRate, + ) +} + +func AddGroupChannelRequestByChannelKey( + ctx context.Context, + modelName string, + channelKey string, + isError, tryBan bool, + maxErrorRate float64, +) (errorRate float64, banExecution bool, err error) { + if channelKey == "" { + channelKey = "0" + } + + if !common.RedisEnabled { + errorRate, banExecution = memGroupChannelModelMonitor.AddRequestByChannelKey( + modelName, + channelKey, + isError, + tryBan, + maxErrorRate, + ) + + return errorRate, banExecution, nil + } + + return redisGroupChannelMonitorModel.AddRequestByChannelKey( + ctx, + modelName, + channelKey, + isError, + tryBan, + maxErrorRate, + ) +} + func (m *redisModelMonitor) AddRequest( ctx context.Context, model string, @@ -161,7 +289,7 @@ func (m *redisModelMonitor) AddRequest( val, err := addRequestScript.Run( ctx, rdb, - []string{common.RedisKeyPrefix(), model}, + []string{m.redisKeyPrefix(), model}, channelID, errorFlag, now, @@ -181,6 +309,45 @@ func (m *redisModelMonitor) AddRequest( return errorRate, banExecution, nil } +func (m *redisModelMonitor) AddRequestByChannelKey( + ctx context.Context, + modelName string, + channelKey string, + isError, tryBan bool, + maxErrorRate float64, +) (errorRate float64, banExecution bool, err error) { + rdb, err := m.rdb() + if err != nil { + return 0, false, err + } + + errorFlag := 0 + if isError { + errorFlag = 1 + } else { + tryBan = false + } + + val, err := addRequestScript.Run( + ctx, + rdb, + []string{m.redisKeyPrefix(), modelName}, + channelKey, + errorFlag, + time.Now().UnixMilli(), + maxErrorRate, + tryBan, + getBanDuration().Milliseconds(), + ).Slice() + if err != nil { + return 0, false, err + } + + banExecution, errorRate, err = parseAddRequestResult(val) + + return errorRate, banExecution, err +} + func parseAddRequestResult(result []any) (banExecution bool, errorRate float64, err error) { if len(result) != 2 { return false, 0, fmt.Errorf("unexpected add request result length: %d", len(result)) @@ -225,10 +392,10 @@ func parseLuaFloat(value any) (float64, error) { } } -func buildStatsKey(model, channelID string) string { +func buildStatsKey(keyPrefix, model, channelID string) string { return fmt.Sprintf( "%s%s%s%v%s", - modelKeyPrefix(), + keyPrefix, model, channelKeyPart, channelID, @@ -236,8 +403,8 @@ func buildStatsKey(model, channelID string) string { ) } -func getModelChannelID(key string) (string, int64, bool) { - content := strings.TrimPrefix(key, modelKeyPrefix()) +func getModelChannelID(keyPrefix, key string) (string, int64, bool) { + content := strings.TrimPrefix(key, keyPrefix) content = strings.TrimSuffix(content, statsKeySuffix) model, channelIDStr, ok := strings.Cut(content, channelKeyPart) @@ -253,6 +420,18 @@ func getModelChannelID(key string) (string, int64, bool) { return model, channelID, true } +func getModelChannelKey(keyPrefix, key string) (string, string, bool) { + content := strings.TrimPrefix(key, keyPrefix) + content = strings.TrimSuffix(content, statsKeySuffix) + + modelName, channelKey, ok := strings.Cut(content, channelKeyPart) + if !ok { + return "", "", false + } + + return modelName, channelKey, true +} + // GetChannelModelErrorRates gets error rates for a specific channel func GetChannelModelErrorRates(ctx context.Context, channelID int64) (map[string]float64, error) { if !common.RedisEnabled { @@ -284,14 +463,15 @@ func (m *redisModelMonitor) GetChannelModelErrorRates( } result := make(map[string]float64) - pattern := buildStatsKey("*", strconv.FormatInt(channelID, 10)) + keyPrefix := m.modelKeyPrefix() + pattern := buildStatsKey(keyPrefix, "*", strconv.FormatInt(channelID, 10)) now := time.Now().UnixMilli() iter := rdb.Scan(ctx, 0, pattern, 0).Iterator() for iter.Next(ctx) { key := iter.Val() - model, _, ok := getModelChannelID(key) + model, _, ok := getModelChannelID(keyPrefix, key) if !ok { continue } @@ -324,19 +504,91 @@ func GetModelChannelErrorRate(ctx context.Context, model string) (map[int64]floa return redisMonitorModel.GetModelChannelErrorRate(ctx, model) } +func GetModelChannelErrorRateByKey(ctx context.Context, model string) (map[string]float64, error) { + if !common.RedisEnabled { + return memModelMonitor.GetModelChannelErrorRateByKey(ctx, model) + } + return redisMonitorModel.GetModelChannelErrorRateByKey(ctx, model) +} + +func GetGroupChannelModelErrorRateByKey( + ctx context.Context, + model string, +) (map[string]float64, error) { + if !common.RedisEnabled { + return memGroupChannelModelMonitor.GetModelChannelErrorRateByKey(ctx, model) + } + return redisGroupChannelMonitorModel.GetModelChannelErrorRateByKey(ctx, model) +} + +func (m *redisModelMonitor) GetModelChannelErrorRateByKey( + ctx context.Context, + model string, +) (map[string]float64, error) { + cacheModel := m.cacheModel(model) + if result, ok := getModelChannelStringErrorRateLocal(cacheModel); ok { + return result, nil + } + + return loadWithLocalKeyLock( + monitorLocalLoadLocker, + modelChannelErrorRateLocalCacheKey(cacheModel)+":string", + func() (map[string]float64, bool) { + return getModelChannelStringErrorRateLocal(cacheModel) + }, + func() (map[string]float64, error) { + rdb, err := m.rdb() + if err != nil { + return nil, err + } + + result := make(map[string]float64) + keyPrefix := m.modelKeyPrefix() + pattern := buildStatsKey(keyPrefix, model, "*") + now := time.Now().UnixMilli() + + iter := rdb.Scan(ctx, 0, pattern, 0).Iterator() + for iter.Next(ctx) { + key := iter.Val() + + _, channelKey, ok := getModelChannelKey(keyPrefix, key) + if !ok { + continue + } + + rate, err := getErrorRateScript.Run(ctx, rdb, []string{key}, now).Float64() + if err != nil { + return nil, err + } + + result[channelKey] = rate + } + + if err := iter.Err(); err != nil { + return nil, err + } + + setModelChannelStringErrorRateLocalUnlocked(cacheModel, result) + + return result, nil + }, + ) +} + func (m *redisModelMonitor) GetModelChannelErrorRate( ctx context.Context, model string, ) (map[int64]float64, error) { - if result, ok := getModelChannelErrorRateLocal(model); ok { + cacheModel := m.cacheModel(model) + if result, ok := getModelChannelErrorRateLocal(cacheModel); ok { return result, nil } return loadWithLocalKeyLock( monitorLocalLoadLocker, - modelChannelErrorRateLocalCacheKey(model), + modelChannelErrorRateLocalCacheKey(cacheModel), func() (map[int64]float64, bool) { - return getModelChannelErrorRateLocal(model) + return getModelChannelErrorRateLocal(cacheModel) }, func() (map[int64]float64, error) { rdb, err := m.rdb() @@ -345,14 +597,15 @@ func (m *redisModelMonitor) GetModelChannelErrorRate( } result := make(map[int64]float64) - pattern := buildStatsKey(model, "*") + keyPrefix := m.modelKeyPrefix() + pattern := buildStatsKey(keyPrefix, model, "*") now := time.Now().UnixMilli() iter := rdb.Scan(ctx, 0, pattern, 0).Iterator() for iter.Next(ctx) { key := iter.Val() - _, channelID, ok := getModelChannelID(key) + _, channelID, ok := getModelChannelID(keyPrefix, key) if !ok { continue } @@ -374,7 +627,7 @@ func (m *redisModelMonitor) GetModelChannelErrorRate( return nil, err } - setModelChannelErrorRateLocalUnlocked(model, result) + setModelChannelErrorRateLocalUnlocked(cacheModel, result) return result, nil }, @@ -386,21 +639,94 @@ func (m *redisModelMonitor) GetChannelModelErrorRate( model string, channelID int64, ) (float64, error) { - if rate, ok := getChannelModelErrorRateLocal(model, channelID); ok { + cacheModel := m.cacheModel(model) + if rate, ok := getChannelModelErrorRateLocal(cacheModel, channelID); ok { return rate, nil } - if rates, ok := getModelChannelErrorRateLocal(model); ok { + if rates, ok := getModelChannelErrorRateLocal(cacheModel); ok { rate := rates[channelID] - setChannelModelErrorRateLocalUnlocked(model, channelID, rate) + setChannelModelErrorRateLocalUnlocked(cacheModel, channelID, rate) + return rate, nil + } + + return loadWithLocalKeyLock( + monitorLocalLoadLocker, + channelModelErrorRateLocalCacheKey(cacheModel, channelID), + func() (float64, bool) { + return getChannelModelErrorRateLocal(cacheModel, channelID) + }, + func() (float64, error) { + rdb, err := m.rdb() + if err != nil { + return 0, err + } + + rate, err := getErrorRateScript.Run( + ctx, + rdb, + []string{ + buildStatsKey(m.modelKeyPrefix(), model, strconv.FormatInt(channelID, 10)), + }, + time.Now().UnixMilli(), + ).Float64() + if err != nil { + return 0, err + } + + setChannelModelErrorRateLocalUnlocked(cacheModel, channelID, rate) + + return rate, nil + }, + ) +} + +func GetChannelModelErrorRateByKey( + ctx context.Context, + modelName, channelKey string, +) (float64, error) { + if strings.HasPrefix(channelKey, groupChannelKeyPrefix) { + return GetGroupChannelChannelModelErrorRateByKey(ctx, modelName, channelKey) + } + + if !common.RedisEnabled { + return memModelMonitor.GetChannelModelErrorRateByKey(ctx, modelName, channelKey) + } + + return redisMonitorModel.GetChannelModelErrorRateByKey(ctx, modelName, channelKey) +} + +func GetGroupChannelChannelModelErrorRateByKey( + ctx context.Context, + modelName, channelKey string, +) (float64, error) { + if !common.RedisEnabled { + return memGroupChannelModelMonitor.GetChannelModelErrorRateByKey(ctx, modelName, channelKey) + } + return redisGroupChannelMonitorModel.GetChannelModelErrorRateByKey(ctx, modelName, channelKey) +} + +func (m *redisModelMonitor) GetChannelModelErrorRateByKey( + ctx context.Context, + modelName string, + channelKey string, +) (float64, error) { + cacheModel := m.cacheModel(modelName) + if rate, ok := getChannelModelStringErrorRateLocal(cacheModel, channelKey); ok { + return rate, nil + } + + if rates, ok := getModelChannelStringErrorRateLocal(cacheModel); ok { + rate := rates[channelKey] + setChannelModelStringErrorRateLocalUnlocked(cacheModel, channelKey, rate) return rate, nil } return loadWithLocalKeyLock( monitorLocalLoadLocker, - channelModelErrorRateLocalCacheKey(model, channelID), + channelModelErrorRateStringLocalCacheKey(cacheModel, channelKey), func() (float64, bool) { - return getChannelModelErrorRateLocal(model, channelID) + return getChannelModelStringErrorRateLocal(cacheModel, channelKey) }, func() (float64, error) { rdb, err := m.rdb() @@ -411,14 +737,14 @@ func (m *redisModelMonitor) GetChannelModelErrorRate( rate, err := getErrorRateScript.Run( ctx, rdb, - []string{buildStatsKey(model, strconv.FormatInt(channelID, 10))}, + []string{buildStatsKey(m.modelKeyPrefix(), modelName, channelKey)}, time.Now().UnixMilli(), ).Float64() if err != nil { return 0, err } - setChannelModelErrorRateLocalUnlocked(model, channelID, rate) + setChannelModelStringErrorRateLocalUnlocked(cacheModel, channelKey, rate) return rate, nil }, @@ -444,7 +770,7 @@ func (m *redisModelMonitor) GetBannedChannelsWithModel( } result := []int64{} - prefix := modelKeyPrefix() + model + channelKeyPart + prefix := m.modelKeyPrefix() + model + channelKeyPart pattern := prefix + "*" + bannedKeySuffix iter := rdb.Scan(ctx, 0, pattern, 0).Iterator() @@ -476,19 +802,42 @@ func GetBannedChannelsMapWithModel(ctx context.Context, model string) (map[int64 return redisMonitorModel.GetBannedChannelsMapWithModel(ctx, model) } +func GetBannedChannelKeysMapWithModel( + ctx context.Context, + model string, +) (map[string]struct{}, error) { + if !common.RedisEnabled { + return memModelMonitor.GetBannedChannelsMapWithModelByKey(ctx, model) + } + + return redisMonitorModel.GetBannedChannelKeysMapWithModel(ctx, model) +} + +func GetGroupChannelBannedChannelKeysMapWithModel( + ctx context.Context, + model string, +) (map[string]struct{}, error) { + if !common.RedisEnabled { + return memGroupChannelModelMonitor.GetBannedChannelsMapWithModelByKey(ctx, model) + } + + return redisGroupChannelMonitorModel.GetBannedChannelKeysMapWithModel(ctx, model) +} + func (m *redisModelMonitor) GetBannedChannelsMapWithModel( ctx context.Context, model string, ) (map[int64]struct{}, error) { - if result, ok := getBannedChannelsLocal(model); ok { + cacheModel := m.cacheModel(model) + if result, ok := getBannedChannelsLocal(cacheModel); ok { return result, nil } return loadWithLocalKeyLock( monitorLocalLoadLocker, - bannedChannelsLocalCacheKey(model), + bannedChannelsLocalCacheKey(cacheModel), func() (map[int64]struct{}, bool) { - return getBannedChannelsLocal(model) + return getBannedChannelsLocal(cacheModel) }, func() (map[int64]struct{}, error) { rdb, err := m.rdb() @@ -497,7 +846,7 @@ func (m *redisModelMonitor) GetBannedChannelsMapWithModel( } result := make(map[int64]struct{}) - prefix := modelKeyPrefix() + model + channelKeyPart + prefix := m.modelKeyPrefix() + model + channelKeyPart pattern := prefix + "*" + bannedKeySuffix iter := rdb.Scan(ctx, 0, pattern, 0).Iterator() @@ -517,7 +866,50 @@ func (m *redisModelMonitor) GetBannedChannelsMapWithModel( return nil, err } - setBannedChannelsLocalUnlocked(model, result) + setBannedChannelsLocalUnlocked(cacheModel, result) + + return result, nil + }, + ) +} + +func (m *redisModelMonitor) GetBannedChannelKeysMapWithModel( + ctx context.Context, + model string, +) (map[string]struct{}, error) { + cacheModel := m.cacheModel(model) + if result, ok := getBannedChannelKeysLocal(cacheModel); ok { + return result, nil + } + + return loadWithLocalKeyLock( + monitorLocalLoadLocker, + bannedChannelsStringLocalCacheKey(cacheModel), + func() (map[string]struct{}, bool) { + return getBannedChannelKeysLocal(cacheModel) + }, + func() (map[string]struct{}, error) { + rdb, err := m.rdb() + if err != nil { + return nil, err + } + + result := make(map[string]struct{}) + prefix := m.modelKeyPrefix() + model + channelKeyPart + pattern := prefix + "*" + bannedKeySuffix + iter := rdb.Scan(ctx, 0, pattern, 0).Iterator() + + for iter.Next(ctx) { + key := iter.Val() + channelKey := strings.TrimSuffix(strings.TrimPrefix(key, prefix), bannedKeySuffix) + result[channelKey] = struct{}{} + } + + if err := iter.Err(); err != nil { + return nil, err + } + + setBannedChannelKeysLocalUnlocked(cacheModel, result) return result, nil }, @@ -537,6 +929,38 @@ func (m *redisModelMonitor) ClearChannelModelErrors( ctx context.Context, model string, channelID int, +) error { + return m.ClearChannelModelErrorsByKey(ctx, model, strconv.Itoa(channelID)) +} + +func ClearChannelModelErrorsByKey(ctx context.Context, model, channelKey string) error { + if strings.HasPrefix(channelKey, groupChannelKeyPrefix) { + return ClearGroupChannelModelErrorsByKey(ctx, model, channelKey) + } + + if !common.RedisEnabled { + return memModelMonitor.ClearChannelModelErrorsByKey(ctx, model, channelKey) + } + + return redisMonitorModel.ClearChannelModelErrorsByKey(ctx, model, channelKey) +} + +func ClearGroupChannelModelErrorsByKey( + ctx context.Context, + model string, + channelKey string, +) error { + if !common.RedisEnabled { + return memGroupChannelModelMonitor.ClearChannelModelErrorsByKey(ctx, model, channelKey) + } + + return redisGroupChannelMonitorModel.ClearChannelModelErrorsByKey(ctx, model, channelKey) +} + +func (m *redisModelMonitor) ClearChannelModelErrorsByKey( + ctx context.Context, + model string, + channelKey string, ) error { rdb, err := m.rdb() if err != nil { @@ -546,13 +970,14 @@ func (m *redisModelMonitor) ClearChannelModelErrors( err = clearChannelModelErrorsScript.Run( ctx, rdb, - []string{common.RedisKeyPrefix(), model}, - strconv.Itoa(channelID), + []string{m.redisKeyPrefix(), model}, + channelKey, ).Err() if err == nil { - deleteModelChannelErrorRateLocal(model) - deleteChannelModelErrorRateLocal(model, int64(channelID)) - deleteBannedChannelsLocal(model) + cacheModel := m.cacheModel(model) + deleteModelChannelErrorRateLocal(cacheModel) + deleteChannelModelStringErrorRateLocal(cacheModel, channelKey) + deleteBannedChannelsLocal(cacheModel) } return err @@ -568,6 +993,33 @@ func ClearChannelAllModelErrors(ctx context.Context, channelID int) error { } func (m *redisModelMonitor) ClearChannelAllModelErrors(ctx context.Context, channelID int) error { + return m.ClearChannelAllModelErrorsByKey(ctx, strconv.Itoa(channelID)) +} + +func ClearChannelAllModelErrorsByKey(ctx context.Context, channelKey string) error { + if strings.HasPrefix(channelKey, groupChannelKeyPrefix) { + return ClearGroupChannelAllModelErrorsByKey(ctx, channelKey) + } + + if !common.RedisEnabled { + return memModelMonitor.ClearChannelAllModelErrorsByKey(ctx, channelKey) + } + + return redisMonitorModel.ClearChannelAllModelErrorsByKey(ctx, channelKey) +} + +func ClearGroupChannelAllModelErrorsByKey(ctx context.Context, channelKey string) error { + if !common.RedisEnabled { + return memGroupChannelModelMonitor.ClearChannelAllModelErrorsByKey(ctx, channelKey) + } + + return redisGroupChannelMonitorModel.ClearChannelAllModelErrorsByKey(ctx, channelKey) +} + +func (m *redisModelMonitor) ClearChannelAllModelErrorsByKey( + ctx context.Context, + channelKey string, +) error { rdb, err := m.rdb() if err != nil { return err @@ -576,8 +1028,8 @@ func (m *redisModelMonitor) ClearChannelAllModelErrors(ctx context.Context, chan err = clearChannelAllModelErrorsScript.Run( ctx, rdb, - []string{common.RedisKeyPrefix()}, - strconv.Itoa(channelID), + []string{m.redisKeyPrefix()}, + channelKey, ).Err() if err == nil { flushMonitorLocalCache() @@ -601,7 +1053,7 @@ func (m *redisModelMonitor) ClearAllModelErrors(ctx context.Context) error { return err } - err = clearAllModelErrorsScript.Run(ctx, rdb, []string{common.RedisKeyPrefix()}).Err() + err = clearAllModelErrorsScript.Run(ctx, rdb, []string{m.redisKeyPrefix()}).Err() if err == nil { flushMonitorLocalCache() } @@ -627,12 +1079,13 @@ func (m *redisModelMonitor) GetAllBannedModelChannels( } result := make(map[string][]int64) - pattern := modelKeyPrefix() + "*" + channelKeyPart + "*" + bannedKeySuffix + keyPrefix := m.modelKeyPrefix() + pattern := keyPrefix + "*" + channelKeyPart + "*" + bannedKeySuffix iter := rdb.Scan(ctx, 0, pattern, 0).Iterator() for iter.Next(ctx) { key := iter.Val() - parts := strings.TrimPrefix(key, modelKeyPrefix()) + parts := strings.TrimPrefix(key, keyPrefix) parts = strings.TrimSuffix(parts, bannedKeySuffix) model, channelIDStr, ok := strings.Cut(parts, channelKeyPart) @@ -687,14 +1140,15 @@ func (m *redisModelMonitor) GetAllChannelModelErrorRates( } result := make(map[int64]map[string]float64) - pattern := buildStatsKey("*", "*") + keyPrefix := m.modelKeyPrefix() + pattern := buildStatsKey(keyPrefix, "*", "*") now := time.Now().UnixMilli() iter := rdb.Scan(ctx, 0, pattern, 0).Iterator() for iter.Next(ctx) { key := iter.Val() - model, channelID, ok := getModelChannelID(key) + model, channelID, ok := getModelChannelID(keyPrefix, key) if !ok { continue } @@ -732,14 +1186,15 @@ func (m *redisModelMonitor) GetAllModelChannelStats( } result := make(map[string]map[int64]ModelChannelStatsSnapshot) - pattern := buildStatsKey("*", "*") + keyPrefix := m.modelKeyPrefix() + pattern := buildStatsKey(keyPrefix, "*", "*") now := time.Now().UnixMilli() iter := rdb.Scan(ctx, 0, pattern, 0).Iterator() for iter.Next(ctx) { key := iter.Val() - model, channelID, ok := getModelChannelID(key) + model, channelID, ok := getModelChannelID(keyPrefix, key) if !ok { continue } @@ -755,7 +1210,7 @@ func (m *redisModelMonitor) GetAllModelChannelStats( bannedKey := fmt.Sprintf( "%s%s%s%d%s", - modelKeyPrefix(), + keyPrefix, model, channelKeyPart, channelID, diff --git a/core/monitor/model_integration_test.go b/core/monitor/model_integration_test.go index 4ed26185..636b3e1f 100644 --- a/core/monitor/model_integration_test.go +++ b/core/monitor/model_integration_test.go @@ -211,7 +211,7 @@ func TestRedisMonitorCleansExpiredSlicesWithCachedTotals(t *testing.T) { monitor := newTestRedisModelMonitor(redisClient) - statsKey := buildStatsKey("model-expired", "404") + statsKey := buildStatsKey(modelKeyPrefix(), "model-expired", "404") currentSlice := time.Now().UnixMilli() / 10000 expiredSlice := currentSlice - maxSliceCount - 1 validSlice := currentSlice @@ -256,7 +256,10 @@ func TestRedisMonitorGetModelChannelErrorRateUsesLocalCache(t *testing.T) { require.NoError(t, err) require.Contains(t, rates, int64(505)) - require.NoError(t, redisClient.Del(ctx, buildStatsKey("model-local-rate", "505")).Err()) + require.NoError( + t, + redisClient.Del(ctx, buildStatsKey(modelKeyPrefix(), "model-local-rate", "505")).Err(), + ) rates, err = monitor.GetModelChannelErrorRate(ctx, "model-local-rate") require.NoError(t, err) @@ -280,7 +283,11 @@ func TestRedisMonitorGetChannelModelErrorRateUsesLocalCache(t *testing.T) { require.NoError(t, err) require.InDelta(t, 0.5, rate, 0.01) - require.NoError(t, redisClient.Del(ctx, buildStatsKey("model-single-local-rate", "515")).Err()) + require.NoError( + t, + redisClient.Del(ctx, buildStatsKey(modelKeyPrefix(), "model-single-local-rate", "515")). + Err(), + ) rate, err = monitor.GetChannelModelErrorRate(ctx, "model-single-local-rate", 515) require.NoError(t, err) @@ -326,7 +333,8 @@ func TestRedisMonitorGetModelChannelErrorRateKeepsLocalCacheUntilTTL(t *testing. require.NoError( t, - redisClient.Del(ctx, buildStatsKey("model-local-rate-invalidate", "606")).Err(), + redisClient.Del(ctx, buildStatsKey(modelKeyPrefix(), "model-local-rate-invalidate", "606")). + Err(), ) rates, err = monitor.GetModelChannelErrorRate(ctx, "model-local-rate-invalidate") @@ -430,7 +438,7 @@ func TestRedisMonitorGetBannedChannelsMapWithModelKeepsLocalCacheUntilTTL(t *tes } func newTestRedisModelMonitor(client *redis.Client) *redisModelMonitor { - return newRedisModelMonitor(func() *redis.Client { + return newRedisModelMonitor(modelKeyPrefix, common.RedisKeyPrefix, func() *redis.Client { return client }) } diff --git a/core/relay/adaptor/ali/adaptor_test.go b/core/relay/adaptor/ali/adaptor_test.go index 65543fbe..8537df69 100644 --- a/core/relay/adaptor/ali/adaptor_test.go +++ b/core/relay/adaptor/ali/adaptor_test.go @@ -122,7 +122,7 @@ func TestOpenAIVideoErrorHandlerKeepsAliStatusNormalization(t *testing.T) { } } -func (s *aliTestStore) GetStore(_ string, _ int, id string) (adaptor.StoreCache, error) { +func (s *aliTestStore) getStore(id string) (adaptor.StoreCache, error) { for _, cache := range s.saved { if cache.ID == id { return cache, nil @@ -132,20 +132,36 @@ func (s *aliTestStore) GetStore(_ string, _ int, id string) (adaptor.StoreCache, return adaptor.StoreCache{}, nil } -func (s *aliTestStore) SaveStore(cache adaptor.StoreCache) error { +func (s *aliTestStore) GetStoreByScope( + group string, + tokenID int, + id string, + _ coremodel.ChannelScope, +) (adaptor.StoreCache, error) { + return s.getStore(id) +} + +func (s *aliTestStore) SaveStore( + cache adaptor.StoreCache, + _ coremodel.ChannelScope, +) error { s.saved = append(s.saved, cache) return nil } func (s *aliTestStore) SaveStoreWithOption( cache adaptor.StoreCache, + _ coremodel.ChannelScope, _ adaptor.SaveStoreOption, ) error { s.saved = append(s.saved, cache) return nil } -func (s *aliTestStore) SaveIfNotExistStore(cache adaptor.StoreCache) error { +func (s *aliTestStore) SaveIfNotExistStore( + cache adaptor.StoreCache, + _ coremodel.ChannelScope, +) error { s.saved = append(s.saved, cache) return nil } diff --git a/core/relay/adaptor/ali/ali_video.go b/core/relay/adaptor/ali/ali_video.go index 1cc2ccd5..d3da040a 100644 --- a/core/relay/adaptor/ali/ali_video.go +++ b/core/relay/adaptor/ali/ali_video.go @@ -295,7 +295,7 @@ func saveAliNativeVideoStore( Model: meta.OriginModel, Metadata: aliVideoStoreMetadataString(meta, taskID), ExpiresAt: expiresAt, - }) + }, meta.Channel.Scope) } func aliVideoTaskExpiresAt(response *relaymodel.AliVideoTaskResponse) time.Time { diff --git a/core/relay/adaptor/ali/async_usage.go b/core/relay/adaptor/ali/async_usage.go index e4662c6f..2201d913 100644 --- a/core/relay/adaptor/ali/async_usage.go +++ b/core/relay/adaptor/ali/async_usage.go @@ -172,7 +172,12 @@ func aliVideoAsyncUsageContextFromStore( } for _, storeID := range storeIDs { - cache, err := store.GetStore(info.GroupID, info.TokenID, storeID) + cache, err := store.GetStoreByScope( + info.GroupID, + info.TokenID, + storeID, + coremodel.ChannelScopeGlobal, + ) if err != nil || cache.Metadata == "" { continue } diff --git a/core/relay/adaptor/ali/video.go b/core/relay/adaptor/ali/video.go index 32a739a0..1ade718c 100644 --- a/core/relay/adaptor/ali/video.go +++ b/core/relay/adaptor/ali/video.go @@ -159,10 +159,11 @@ func aliVideoUpstreamTaskID(meta *meta.Meta, store adaptor.Store, videoID string return videoID, nil } - cache, err := store.GetStore( + cache, err := store.GetStoreByScope( meta.Group.ID, meta.Token.ID, coremodel.VideoGenerationStoreID(videoID), + meta.Channel.Scope, ) if err != nil || cache.ID == "" { return videoID, nil @@ -2151,7 +2152,7 @@ func saveAliVideoJobStore(meta *meta.Meta, store adaptor.Store, jobID string) er Model: meta.OriginModel, Metadata: aliVideoStoreMetadataString(meta), ExpiresAt: time.Now().Add(aliVideoTaskTTL), - }) + }, meta.Channel.Scope) } func saveAliVideoGenerationStore( @@ -2178,7 +2179,7 @@ func saveAliVideoGenerationStore( Model: meta.OriginModel, Metadata: aliVideoStoreMetadataString(meta, upstreamID), ExpiresAt: expiresAtTime, - }) + }, meta.Channel.Scope) } func aliVideoStoreMetadataString(meta *meta.Meta, upstreamID ...string) string { @@ -2208,7 +2209,7 @@ func applyStoredAliVideoRequestMetadata(meta *meta.Meta, store adaptor.Store, st return } - cache, err := store.GetStore(meta.Group.ID, meta.Token.ID, storeID) + cache, err := store.GetStoreByScope(meta.Group.ID, meta.Token.ID, storeID, meta.Channel.Scope) if err != nil || cache.Metadata == "" { return } diff --git a/core/relay/adaptor/doubao/async_usage.go b/core/relay/adaptor/doubao/async_usage.go index 12452397..ba17f21c 100644 --- a/core/relay/adaptor/doubao/async_usage.go +++ b/core/relay/adaptor/doubao/async_usage.go @@ -148,7 +148,12 @@ func doubaoVideoAsyncMetadataFromStore( } for _, storeID := range storeIDs { - cache, err := store.GetStore(info.GroupID, info.TokenID, storeID) + cache, err := store.GetStoreByScope( + info.GroupID, + info.TokenID, + storeID, + coremodel.ChannelScopeGlobal, + ) if err != nil || cache.Metadata == "" { continue } diff --git a/core/relay/adaptor/doubao/main_test.go b/core/relay/adaptor/doubao/main_test.go index 2fc4e48f..f2612d9b 100644 --- a/core/relay/adaptor/doubao/main_test.go +++ b/core/relay/adaptor/doubao/main_test.go @@ -26,7 +26,7 @@ type doubaoTestStore struct { saved []adaptor.StoreCache } -func (s *doubaoTestStore) GetStore(_ string, _ int, id string) (adaptor.StoreCache, error) { +func (s *doubaoTestStore) getStore(id string) (adaptor.StoreCache, error) { for _, cache := range s.saved { if cache.ID == id { return cache, nil @@ -36,20 +36,36 @@ func (s *doubaoTestStore) GetStore(_ string, _ int, id string) (adaptor.StoreCac return adaptor.StoreCache{}, nil } -func (s *doubaoTestStore) SaveStore(cache adaptor.StoreCache) error { +func (s *doubaoTestStore) GetStoreByScope( + group string, + tokenID int, + id string, + _ coremodel.ChannelScope, +) (adaptor.StoreCache, error) { + return s.getStore(id) +} + +func (s *doubaoTestStore) SaveStore( + cache adaptor.StoreCache, + _ coremodel.ChannelScope, +) error { s.saved = append(s.saved, cache) return nil } func (s *doubaoTestStore) SaveStoreWithOption( cache adaptor.StoreCache, + _ coremodel.ChannelScope, _ adaptor.SaveStoreOption, ) error { s.saved = append(s.saved, cache) return nil } -func (s *doubaoTestStore) SaveIfNotExistStore(cache adaptor.StoreCache) error { +func (s *doubaoTestStore) SaveIfNotExistStore( + cache adaptor.StoreCache, + _ coremodel.ChannelScope, +) error { s.saved = append(s.saved, cache) return nil } diff --git a/core/relay/adaptor/doubao/video.go b/core/relay/adaptor/doubao/video.go index 78373071..446f5048 100644 --- a/core/relay/adaptor/doubao/video.go +++ b/core/relay/adaptor/doubao/video.go @@ -1399,7 +1399,7 @@ func saveDoubaoVideoJobStore( Model: meta.OriginModel, Metadata: doubaoVideoStoreMetadataString(meta), ExpiresAt: expiresAt, - }) + }, meta.Channel.Scope) } func saveDoubaoVideoStore( @@ -1420,7 +1420,7 @@ func saveDoubaoVideoStore( Model: meta.OriginModel, Metadata: doubaoVideoStoreMetadataString(meta), ExpiresAt: expiresAt, - }) + }, meta.Channel.Scope) } func doubaoVideoStoreMetadataString(meta *meta.Meta) string { @@ -1444,7 +1444,7 @@ func applyStoredDoubaoVideoMetadata( return } - cache, err := store.GetStore(meta.Group.ID, meta.Token.ID, storeID) + cache, err := store.GetStoreByScope(meta.Group.ID, meta.Token.ID, storeID, meta.Channel.Scope) if err != nil || cache.Metadata == "" { return } diff --git a/core/relay/adaptor/fake/adaptor_test.go b/core/relay/adaptor/fake/adaptor_test.go index 69fa0537..97852c40 100644 --- a/core/relay/adaptor/fake/adaptor_test.go +++ b/core/relay/adaptor/fake/adaptor_test.go @@ -23,19 +23,28 @@ import ( type noopStore struct{} -func (noopStore) GetStore(string, int, string) (adaptor.StoreCache, error) { +func (noopStore) GetStoreByScope( + string, + int, + string, + model.ChannelScope, +) (adaptor.StoreCache, error) { return adaptor.StoreCache{}, nil } -func (noopStore) SaveStore(adaptor.StoreCache) error { +func (noopStore) SaveStore(adaptor.StoreCache, model.ChannelScope) error { return nil } -func (noopStore) SaveStoreWithOption(adaptor.StoreCache, adaptor.SaveStoreOption) error { +func (noopStore) SaveStoreWithOption( + adaptor.StoreCache, + model.ChannelScope, + adaptor.SaveStoreOption, +) error { return nil } -func (noopStore) SaveIfNotExistStore(adaptor.StoreCache) error { +func (noopStore) SaveIfNotExistStore(adaptor.StoreCache, model.ChannelScope) error { return nil } @@ -43,24 +52,36 @@ type recordingStore struct { saved []adaptor.StoreCache } -func (s *recordingStore) GetStore(string, int, string) (adaptor.StoreCache, error) { +func (s *recordingStore) GetStoreByScope( + string, + int, + string, + model.ChannelScope, +) (adaptor.StoreCache, error) { return adaptor.StoreCache{}, nil } -func (s *recordingStore) SaveStore(cache adaptor.StoreCache) error { +func (s *recordingStore) SaveStore( + cache adaptor.StoreCache, + _ model.ChannelScope, +) error { s.saved = append(s.saved, cache) return nil } func (s *recordingStore) SaveStoreWithOption( cache adaptor.StoreCache, + _ model.ChannelScope, _ adaptor.SaveStoreOption, ) error { s.saved = append(s.saved, cache) return nil } -func (s *recordingStore) SaveIfNotExistStore(cache adaptor.StoreCache) error { +func (s *recordingStore) SaveIfNotExistStore( + cache adaptor.StoreCache, + _ model.ChannelScope, +) error { s.saved = append(s.saved, cache) return nil } diff --git a/core/relay/adaptor/fake/helpers.go b/core/relay/adaptor/fake/helpers.go index 7f25a105..5303d45b 100644 --- a/core/relay/adaptor/fake/helpers.go +++ b/core/relay/adaptor/fake/helpers.go @@ -329,19 +329,28 @@ func (r *httptestRecorder) Write(b []byte) (int, error) { return r.body.Write(b) type discardStore struct{} -func (discardStore) GetStore(string, int, string) (adaptor.StoreCache, error) { +func (discardStore) GetStoreByScope( + string, + int, + string, + model.ChannelScope, +) (adaptor.StoreCache, error) { return adaptor.StoreCache{}, nil } -func (discardStore) SaveStore(adaptor.StoreCache) error { +func (discardStore) SaveStore(adaptor.StoreCache, model.ChannelScope) error { return nil } -func (discardStore) SaveStoreWithOption(adaptor.StoreCache, adaptor.SaveStoreOption) error { +func (discardStore) SaveStoreWithOption( + adaptor.StoreCache, + model.ChannelScope, + adaptor.SaveStoreOption, +) error { return nil } -func (discardStore) SaveIfNotExistStore(adaptor.StoreCache) error { +func (discardStore) SaveIfNotExistStore(adaptor.StoreCache, model.ChannelScope) error { return nil } diff --git a/core/relay/adaptor/fake/response.go b/core/relay/adaptor/fake/response.go index 598f9ff3..6224b478 100644 --- a/core/relay/adaptor/fake/response.go +++ b/core/relay/adaptor/fake/response.go @@ -435,7 +435,7 @@ func writeResponses( ChannelID: meta.Channel.ID, Model: meta.OriginModel, ExpiresAt: time.Now().Add(7 * 24 * time.Hour), - }) + }, meta.Channel.Scope) } if reqCtx.Stream { diff --git a/core/relay/adaptor/gemini/async_usage.go b/core/relay/adaptor/gemini/async_usage.go index 63989bd1..7333d2bd 100644 --- a/core/relay/adaptor/gemini/async_usage.go +++ b/core/relay/adaptor/gemini/async_usage.go @@ -240,7 +240,12 @@ func geminiVideoAsyncUsageMetadata( continue } - cache, err := store.GetStore(info.GroupID, info.TokenID, storeID) + cache, err := store.GetStoreByScope( + info.GroupID, + info.TokenID, + storeID, + model.ChannelScopeGlobal, + ) if err != nil { continue } diff --git a/core/relay/adaptor/gemini/video.go b/core/relay/adaptor/gemini/video.go index f2f9c342..e9844dba 100644 --- a/core/relay/adaptor/gemini/video.go +++ b/core/relay/adaptor/gemini/video.go @@ -2006,10 +2006,11 @@ func storedNativeGeminiVideoOperationName(meta *meta.Meta, store adaptor.Store) } if store != nil { - cache, err := store.GetStore( + cache, err := store.GetStoreByScope( meta.Group.ID, meta.Token.ID, model.VideoJobStoreID(meta.OperationID), + meta.Channel.Scope, ) if err != nil { return "" @@ -2017,10 +2018,11 @@ func storedNativeGeminiVideoOperationName(meta *meta.Meta, store adaptor.Store) return strings.TrimSpace(cache.Metadata) } else { - cache, err := model.CacheGetStore( + cache, err := model.CacheGetStoreByScope( meta.Group.ID, meta.Token.ID, model.VideoJobStoreID(meta.OperationID), + meta.Channel.Scope, ) if err != nil { return "" @@ -2271,7 +2273,7 @@ func geminiVideoStoredMetadata( return geminiVideoStoreMetadata{}, nil } - cache, err := store.GetStore(meta.Group.ID, meta.Token.ID, storeID) + cache, err := store.GetStoreByScope(meta.Group.ID, meta.Token.ID, storeID, meta.Channel.Scope) if err != nil { return geminiVideoStoreMetadata{}, err } @@ -2403,7 +2405,7 @@ func saveGeminiVideoJobStore( Model: meta.OriginModel, Metadata: geminiVideoStoreMetadataString(meta, operationName), ExpiresAt: expiresAt, - }) + }, meta.Channel.Scope) } func saveGeminiVideoStore( @@ -2425,7 +2427,7 @@ func saveGeminiVideoStore( Model: meta.OriginModel, Metadata: geminiVideoStoreMetadataString(meta, operationName), ExpiresAt: expiresAt, - }) + }, meta.Channel.Scope) } func saveGeminiFileStores( @@ -2451,7 +2453,7 @@ func saveGeminiFileStores( Model: meta.OriginModel, Metadata: geminiFileStoreMetadataString(uri), ExpiresAt: time.Now().Add(geminiVideoTTL), - }); err != nil { + }, meta.Channel.Scope); err != nil { return err } } @@ -2479,16 +2481,18 @@ func storedGeminiFileURL(meta *meta.Meta, store adaptor.Store) string { ) if store != nil { - cache, err = store.GetStore( + cache, err = store.GetStoreByScope( meta.Group.ID, meta.Token.ID, model.GeminiFileStoreID(meta.FileID), + meta.Channel.Scope, ) } else { - storeCache, getErr := model.CacheGetStore( + storeCache, getErr := model.CacheGetStoreByScope( meta.Group.ID, meta.Token.ID, model.GeminiFileStoreID(meta.FileID), + meta.Channel.Scope, ) if getErr == nil && storeCache != nil { cache = adaptor.StoreCache(*storeCache) diff --git a/core/relay/adaptor/gemini/video_test.go b/core/relay/adaptor/gemini/video_test.go index 55c78f56..77c4b154 100644 --- a/core/relay/adaptor/gemini/video_test.go +++ b/core/relay/adaptor/gemini/video_test.go @@ -42,9 +42,7 @@ func geminiVideoModelPriceForTest(t *testing.T, modelName string) coremodel.Pric return coremodel.Price{} } -func (s *geminiVideoTestStore) GetStore( - _ string, - _ int, +func (s *geminiVideoTestStore) getStore( id string, ) (adaptorapi.StoreCache, error) { for _, cache := range s.saved { @@ -56,20 +54,36 @@ func (s *geminiVideoTestStore) GetStore( return adaptorapi.StoreCache{}, coremodel.NotFoundError(coremodel.ErrStoreNotFound) } -func (s *geminiVideoTestStore) SaveStore(cache adaptorapi.StoreCache) error { +func (s *geminiVideoTestStore) GetStoreByScope( + group string, + tokenID int, + id string, + _ coremodel.ChannelScope, +) (adaptorapi.StoreCache, error) { + return s.getStore(id) +} + +func (s *geminiVideoTestStore) SaveStore( + cache adaptorapi.StoreCache, + _ coremodel.ChannelScope, +) error { s.saved = append(s.saved, cache) return nil } func (s *geminiVideoTestStore) SaveStoreWithOption( cache adaptorapi.StoreCache, + _ coremodel.ChannelScope, _ adaptorapi.SaveStoreOption, ) error { s.saved = append(s.saved, cache) return nil } -func (s *geminiVideoTestStore) SaveIfNotExistStore(cache adaptorapi.StoreCache) error { +func (s *geminiVideoTestStore) SaveIfNotExistStore( + cache adaptorapi.StoreCache, + _ coremodel.ChannelScope, +) error { s.saved = append(s.saved, cache) return nil } diff --git a/core/relay/adaptor/interface.go b/core/relay/adaptor/interface.go index 3be8d806..fed5ed2e 100644 --- a/core/relay/adaptor/interface.go +++ b/core/relay/adaptor/interface.go @@ -30,10 +30,15 @@ type SaveStoreOption struct { } type Store interface { - GetStore(group string, tokenID int, id string) (StoreCache, error) - SaveStore(store StoreCache) error - SaveStoreWithOption(store StoreCache, opt SaveStoreOption) error - SaveIfNotExistStore(store StoreCache) error + GetStoreByScope( + group string, + tokenID int, + id string, + scope model.ChannelScope, + ) (StoreCache, error) + SaveStore(store StoreCache, scope model.ChannelScope) error + SaveStoreWithOption(store StoreCache, scope model.ChannelScope, opt SaveStoreOption) error + SaveIfNotExistStore(store StoreCache, scope model.ChannelScope) error } type Metadata struct { diff --git a/core/relay/adaptor/openai/response.go b/core/relay/adaptor/openai/response.go index 07a2b94c..b02cd486 100644 --- a/core/relay/adaptor/openai/response.go +++ b/core/relay/adaptor/openai/response.go @@ -155,7 +155,7 @@ func ResponseHandler( ChannelID: meta.Channel.ID, Model: meta.OriginModel, ExpiresAt: time.Now().Add(time.Hour * 24 * 7), // Store for 7 days - }) + }, meta.Channel.Scope) if err != nil { log := common.GetLogger(c) log.Errorf("save response store failed: %v", err) @@ -272,7 +272,7 @@ readLoop: ChannelID: meta.Channel.ID, Model: meta.OriginModel, ExpiresAt: time.Now().Add(time.Hour * 24 * 7), - }) + }, meta.Channel.Scope) if saveErr != nil { log.Errorf("save response store failed: %v", saveErr) } diff --git a/core/relay/adaptor/openai/response_test.go b/core/relay/adaptor/openai/response_test.go index b8638e45..d01b2d7e 100644 --- a/core/relay/adaptor/openai/response_test.go +++ b/core/relay/adaptor/openai/response_test.go @@ -28,24 +28,36 @@ type responseTestStore struct { var responseStreamInitialBufferTimeoutTestMu sync.Mutex -func (s *responseTestStore) GetStore(string, int, string) (adaptor.StoreCache, error) { +func (s *responseTestStore) GetStoreByScope( + string, + int, + string, + model.ChannelScope, +) (adaptor.StoreCache, error) { return adaptor.StoreCache{}, nil } -func (s *responseTestStore) SaveStore(cache adaptor.StoreCache) error { +func (s *responseTestStore) SaveStore( + cache adaptor.StoreCache, + _ model.ChannelScope, +) error { s.saved = append(s.saved, cache) return nil } func (s *responseTestStore) SaveStoreWithOption( cache adaptor.StoreCache, + _ model.ChannelScope, _ adaptor.SaveStoreOption, ) error { s.saved = append(s.saved, cache) return nil } -func (s *responseTestStore) SaveIfNotExistStore(cache adaptor.StoreCache) error { +func (s *responseTestStore) SaveIfNotExistStore( + cache adaptor.StoreCache, + _ model.ChannelScope, +) error { s.savedIfNotExist = append(s.savedIfNotExist, cache) return nil } diff --git a/core/relay/adaptor/openai/video.go b/core/relay/adaptor/openai/video.go index 19269f3a..fe0c0be6 100644 --- a/core/relay/adaptor/openai/video.go +++ b/core/relay/adaptor/openai/video.go @@ -199,7 +199,7 @@ func VideoHandler( ChannelID: meta.Channel.ID, Model: meta.OriginModel, ExpiresAt: time.Now().Add(time.Hour * 24), - }) + }, meta.Channel.Scope) if err != nil { log := common.GetLogger(c) log.Errorf("save store failed: %v", err) @@ -332,7 +332,7 @@ func saveOpenAIVideoStore(meta *meta.Meta, store adaptor.Store, videoID string) ChannelID: meta.Channel.ID, Model: meta.OriginModel, ExpiresAt: time.Now().Add(time.Hour * 24 * 7), - }) + }, meta.Channel.Scope) } func VideoGetJobsHandler( @@ -391,7 +391,7 @@ func VideoGetJobsHandler( ChannelID: meta.Channel.ID, Model: meta.OriginModel, ExpiresAt: time.Unix(expiresAt, 0), - }) + }, meta.Channel.Scope) if err != nil { log := common.GetLogger(c) log.Errorf("save store failed: %v", err) diff --git a/core/relay/adaptor/siliconflow/adaptor_test.go b/core/relay/adaptor/siliconflow/adaptor_test.go index 11f766f8..9c99e112 100644 --- a/core/relay/adaptor/siliconflow/adaptor_test.go +++ b/core/relay/adaptor/siliconflow/adaptor_test.go @@ -22,10 +22,11 @@ type siliconflowTestStore struct { saved []adaptor.StoreCache } -func (s *siliconflowTestStore) GetStore( +func (s *siliconflowTestStore) GetStoreByScope( group string, tokenID int, id string, + _ coremodel.ChannelScope, ) (adaptor.StoreCache, error) { for _, cache := range s.saved { if cache.GroupID == group && cache.TokenID == tokenID && cache.ID == id { @@ -36,20 +37,27 @@ func (s *siliconflowTestStore) GetStore( return adaptor.StoreCache{}, nil } -func (s *siliconflowTestStore) SaveStore(cache adaptor.StoreCache) error { +func (s *siliconflowTestStore) SaveStore( + cache adaptor.StoreCache, + _ coremodel.ChannelScope, +) error { s.saved = append(s.saved, cache) return nil } func (s *siliconflowTestStore) SaveStoreWithOption( cache adaptor.StoreCache, + _ coremodel.ChannelScope, _ adaptor.SaveStoreOption, ) error { s.saved = append(s.saved, cache) return nil } -func (s *siliconflowTestStore) SaveIfNotExistStore(cache adaptor.StoreCache) error { +func (s *siliconflowTestStore) SaveIfNotExistStore( + cache adaptor.StoreCache, + _ coremodel.ChannelScope, +) error { s.saved = append(s.saved, cache) return nil } diff --git a/core/relay/adaptor/siliconflow/async_usage.go b/core/relay/adaptor/siliconflow/async_usage.go index 243c5844..e0d2de4c 100644 --- a/core/relay/adaptor/siliconflow/async_usage.go +++ b/core/relay/adaptor/siliconflow/async_usage.go @@ -97,7 +97,12 @@ func siliconFlowVideoAsyncMetadataFromStore( } for _, storeID := range storeIDs { - cache, err := store.GetStore(info.GroupID, info.TokenID, storeID) + cache, err := store.GetStoreByScope( + info.GroupID, + info.TokenID, + storeID, + model.ChannelScopeGlobal, + ) if err != nil || cache.Metadata == "" { continue } diff --git a/core/relay/adaptor/siliconflow/video.go b/core/relay/adaptor/siliconflow/video.go index bb4da327..77b9d643 100644 --- a/core/relay/adaptor/siliconflow/video.go +++ b/core/relay/adaptor/siliconflow/video.go @@ -972,7 +972,7 @@ func applyStoredVideoRequestMetadata(meta *meta.Meta, store adaptor.Store, store return } - cache, err := store.GetStore(meta.Group.ID, meta.Token.ID, storeID) + cache, err := store.GetStoreByScope(meta.Group.ID, meta.Token.ID, storeID, meta.Channel.Scope) if err != nil || cache.Metadata == "" { return } @@ -1018,7 +1018,7 @@ func saveVideoJobStore( Model: meta.OriginModel, Metadata: videoStoreMetadataString(meta), ExpiresAt: expiresAt, - }) + }, meta.Channel.Scope) } func saveVideoGenerationStore( @@ -1039,5 +1039,5 @@ func saveVideoGenerationStore( Model: meta.OriginModel, Metadata: videoStoreMetadataString(meta), ExpiresAt: expiresAt, - }) + }, meta.Channel.Scope) } diff --git a/core/relay/adaptor/vertexai/adaptor_test.go b/core/relay/adaptor/vertexai/adaptor_test.go index 970a7b16..e40c9986 100644 --- a/core/relay/adaptor/vertexai/adaptor_test.go +++ b/core/relay/adaptor/vertexai/adaptor_test.go @@ -25,9 +25,7 @@ type vertexVideoTestStore struct { items map[string]adaptorapi.StoreCache } -func (s *vertexVideoTestStore) GetStore( - _ string, - _ int, +func (s *vertexVideoTestStore) getStore( id string, ) (adaptorapi.StoreCache, error) { if item, ok := s.items[id]; ok { @@ -37,18 +35,34 @@ func (s *vertexVideoTestStore) GetStore( return adaptorapi.StoreCache{}, coremodel.NotFoundError(coremodel.ErrStoreNotFound) } -func (s *vertexVideoTestStore) SaveStore(adaptorapi.StoreCache) error { +func (s *vertexVideoTestStore) GetStoreByScope( + group string, + tokenID int, + id string, + _ coremodel.ChannelScope, +) (adaptorapi.StoreCache, error) { + return s.getStore(id) +} + +func (s *vertexVideoTestStore) SaveStore( + adaptorapi.StoreCache, + coremodel.ChannelScope, +) error { return nil } func (s *vertexVideoTestStore) SaveStoreWithOption( adaptorapi.StoreCache, + coremodel.ChannelScope, adaptorapi.SaveStoreOption, ) error { return nil } -func (s *vertexVideoTestStore) SaveIfNotExistStore(adaptorapi.StoreCache) error { +func (s *vertexVideoTestStore) SaveIfNotExistStore( + adaptorapi.StoreCache, + coremodel.ChannelScope, +) error { return nil } diff --git a/core/relay/meta/meta.go b/core/relay/meta/meta.go index 632ceb3a..4f16cd6a 100644 --- a/core/relay/meta/meta.go +++ b/core/relay/meta/meta.go @@ -2,6 +2,7 @@ package meta import ( "fmt" + "strconv" "time" "github.com/labring/aiproxy/core/model" @@ -13,8 +14,10 @@ type ChannelMeta struct { BaseURL string ProxyURL string Key string + GroupID string ID int Type model.ChannelType + Scope model.ChannelScope ModelMapping map[string]string EnabledAutoBalanceCheck bool SkipTLSVerify bool @@ -93,6 +96,12 @@ func WithToken(token model.TokenCache) Option { } } +func WithModelConfig(modelConfig model.ModelConfig) Option { + return func(meta *Meta) { + meta.ModelConfig = modelConfig + } +} + func WithRequestUsage(requestUsage model.Usage) Option { return func(meta *Meta) { meta.RequestUsage = requestUsage @@ -159,6 +168,13 @@ func WithUser(user string) Option { } } +func WithChannelScope(scope model.ChannelScope, groupID string) Option { + return func(meta *Meta) { + meta.Channel.Scope = scope + meta.Channel.GroupID = groupID + } +} + func NewMeta( channel *model.Channel, mode mode.Mode, @@ -195,7 +211,12 @@ func (m *Meta) SetChannel(channel *model.Channel) { m.Channel.ProxyURL = channel.ProxyURL m.Channel.Key = channel.Key m.Channel.ID = channel.ID + m.Channel.Type = channel.Type + if m.Channel.Scope == "" { + m.Channel.Scope = model.ChannelScopeGlobal + } + m.Channel.EnabledAutoBalanceCheck = channel.EnabledAutoBalanceCheck m.Channel.SkipTLSVerify = channel.SkipTLSVerify m.Channel.EnabledNoPermissionBan = channel.EnabledNoPermissionBan @@ -208,6 +229,18 @@ func (m *Meta) SetChannel(channel *model.Channel) { m.ActualModel, _ = GetMappedModelName(m.OriginModel, channel.ModelMapping) } +func (m *Meta) ChannelMonitorKey() string { + if m == nil { + return "0" + } + + if m.Channel.Scope == model.ChannelScopeGroup { + return model.GroupChannelMonitorKey(m.Channel.GroupID, m.Channel.ID) + } + + return strconv.Itoa(m.Channel.ID) +} + func (m *Meta) CopyChannelFromMeta(meta *Meta) { m.Channel = meta.Channel m.ChannelConfigs = meta.ChannelConfigs diff --git a/core/relay/plugin/cachefollow/cachefollow.go b/core/relay/plugin/cachefollow/cachefollow.go index ae398cf3..eaa4651b 100644 --- a/core/relay/plugin/cachefollow/cachefollow.go +++ b/core/relay/plugin/cachefollow/cachefollow.go @@ -169,7 +169,7 @@ func saveStableStoreMapping( CreatedAt: now, UpdatedAt: now, ExpiresAt: expiresAt, - }) + }, meta.Channel.Scope) } func saveRecentStoreMapping( @@ -185,18 +185,22 @@ func saveRecentStoreMapping( now := time.Now() - return store.SaveStoreWithOption(adaptor.StoreCache{ - ID: id, - GroupID: meta.Group.ID, - TokenID: meta.Token.ID, - ChannelID: meta.Channel.ID, - Model: meta.OriginModel, - CreatedAt: now, - UpdatedAt: now, - ExpiresAt: expiresAt, - }, adaptor.SaveStoreOption{ - MinUpdateInterval: minInterval, - }) + return store.SaveStoreWithOption( + adaptor.StoreCache{ + ID: id, + GroupID: meta.Group.ID, + TokenID: meta.Token.ID, + ChannelID: meta.Channel.ID, + Model: meta.OriginModel, + CreatedAt: now, + UpdatedAt: now, + ExpiresAt: expiresAt, + }, + meta.Channel.Scope, + adaptor.SaveStoreOption{ + MinUpdateInterval: minInterval, + }, + ) } func savePromptCacheMappings( diff --git a/core/relay/plugin/cachefollow/cachefollow_test.go b/core/relay/plugin/cachefollow/cachefollow_test.go index 9bcc8ee6..f551f23b 100644 --- a/core/relay/plugin/cachefollow/cachefollow_test.go +++ b/core/relay/plugin/cachefollow/cachefollow_test.go @@ -20,9 +20,16 @@ type recordingStore struct { stores map[string]adaptor.StoreCache saved []adaptor.StoreCache savedIfNotExist []adaptor.StoreCache + saveScope model.ChannelScope + saveIfScope model.ChannelScope } -func (s *recordingStore) GetStore(_ string, _ int, id string) (adaptor.StoreCache, error) { +func (s *recordingStore) GetStoreByScope( + _ string, + _ int, + id string, + _ model.ChannelScope, +) (adaptor.StoreCache, error) { if s.stores == nil { return adaptor.StoreCache{}, model.NotFoundError(model.ErrStoreNotFound) } @@ -35,7 +42,12 @@ func (s *recordingStore) GetStore(_ string, _ int, id string) (adaptor.StoreCach return store, nil } -func (s *recordingStore) SaveStore(cache adaptor.StoreCache) error { +func (s *recordingStore) SaveStore( + cache adaptor.StoreCache, + scope model.ChannelScope, +) error { + s.saveScope = scope + if s.stores == nil { s.stores = make(map[string]adaptor.StoreCache) } @@ -48,6 +60,7 @@ func (s *recordingStore) SaveStore(cache adaptor.StoreCache) error { func (s *recordingStore) SaveStoreWithOption( cache adaptor.StoreCache, + scope model.ChannelScope, opt adaptor.SaveStoreOption, ) error { if existing, ok := s.stores[cache.ID]; ok && @@ -57,10 +70,15 @@ func (s *recordingStore) SaveStoreWithOption( return nil } - return s.SaveStore(cache) + return s.SaveStore(cache, scope) } -func (s *recordingStore) SaveIfNotExistStore(cache adaptor.StoreCache) error { +func (s *recordingStore) SaveIfNotExistStore( + cache adaptor.StoreCache, + scope model.ChannelScope, +) error { + s.saveIfScope = scope + if s.stores == nil { s.stores = make(map[string]adaptor.StoreCache) } diff --git a/core/relay/plugin/monitor/group.go b/core/relay/plugin/monitor/group.go index cc06c130..aaeff318 100644 --- a/core/relay/plugin/monitor/group.go +++ b/core/relay/plugin/monitor/group.go @@ -42,6 +42,18 @@ func (m *GroupMonitor) DoResponse( result, relayErr := do.DoResponse(meta, store, c, resp) if result.Usage.TotalTokens > 0 { + if meta.Channel.Scope == model.ChannelScopeGroup { + count, secondCount := reqlimit.GetGroupChannelModelTokensRequest( + context.Background(), + meta.Group.ID, + strconv.Itoa(meta.Channel.ID), + meta.OriginModel, + ) + UpdateGroupChannelModelTokensRequest(c, meta.Group, count, secondCount) + + return result, relayErr + } + count, overLimitCount, secondCount := reqlimit.PushGroupModelTokensRequest( context.Background(), meta.Group.ID, @@ -84,6 +96,16 @@ func UpdateGroupModelTokensRequest(c *gin.Context, group model.GroupCache, tpm, log.Data["group_tps"] = strconv.FormatInt(tps, 10) } +func UpdateGroupChannelModelTokensRequest(c *gin.Context, group model.GroupCache, tpm, tps int64) { + if group.Status == model.GroupStatusInternal { + return + } + + log := common.GetLogger(c) + log.Data["group_channel_tpm"] = strconv.FormatInt(tpm, 10) + log.Data["group_channel_tps"] = strconv.FormatInt(tps, 10) +} + func UpdateGroupModelTokennameRequest(c *gin.Context, rpm, rps int64) { c.Set(GroupModelTokenRPM, rpm) c.Set(GroupModelTokenRPS, rps) diff --git a/core/relay/plugin/monitor/monitor.go b/core/relay/plugin/monitor/monitor.go index 981391ca..1f813946 100644 --- a/core/relay/plugin/monitor/monitor.go +++ b/core/relay/plugin/monitor/monitor.go @@ -14,6 +14,7 @@ import ( "github.com/labring/aiproxy/core/common/conv" "github.com/labring/aiproxy/core/common/notify" "github.com/labring/aiproxy/core/common/reqlimit" + "github.com/labring/aiproxy/core/model" "github.com/labring/aiproxy/core/monitor" "github.com/labring/aiproxy/core/relay/adaptor" "github.com/labring/aiproxy/core/relay/meta" @@ -31,6 +32,130 @@ func NewChannelMonitorPlugin() plugin.Plugin { return &ChannelMonitor{} } +func channelMonitorKey(meta *meta.Meta) string { + if meta == nil { + return "0" + } + return meta.ChannelMonitorKey() +} + +func channelRateKey(meta *meta.Meta) string { + if meta != nil && meta.Channel.Scope == model.ChannelScopeGroup { + return strconv.Itoa(meta.Channel.ID) + } + + return channelMonitorKey(meta) +} + +func channelRateScope(meta *meta.Meta) reqlimit.ChannelRateScope { + if meta != nil && meta.Channel.Scope == model.ChannelScopeGroup { + return reqlimit.ChannelRateScopeGroup + } + + return reqlimit.ChannelRateScopeGlobal +} + +func isGroupChannelMeta(meta *meta.Meta) bool { + return meta != nil && meta.Channel.Scope == model.ChannelScopeGroup +} + +func observeChannelModelRequestRate( + meta *meta.Meta, +) (int64, int64, int64) { + if isGroupChannelMeta(meta) { + count, secondCount := reqlimit.GetGroupChannelModelRequest( + context.Background(), + meta.Channel.GroupID, + channelRateKey(meta), + meta.OriginModel, + ) + + return count, 0, secondCount + } + + return reqlimit.PushScopedChannelModelRequest( + context.Background(), + channelRateScope(meta), + meta.Channel.GroupID, + channelRateKey(meta), + meta.OriginModel, + ) +} + +func observeChannelModelTokenRate( + meta *meta.Meta, + tokens int64, +) (int64, int64, int64) { + return reqlimit.PushScopedChannelModelTokensRequest( + context.Background(), + channelRateScope(meta), + meta.Channel.GroupID, + channelRateKey(meta), + meta.OriginModel, + tokens, + ) +} + +func addChannelMonitorRequest( + meta *meta.Meta, + isError, tryBan bool, + maxErrorRate float64, +) (float64, bool, error) { + if isGroupChannelMeta(meta) { + return monitor.AddGroupChannelRequestByChannelKey( + context.Background(), + meta.OriginModel, + meta.ChannelMonitorKey(), + isError, + tryBan, + maxErrorRate, + ) + } + + return monitor.AddRequestByChannelKey( + context.Background(), + meta.OriginModel, + meta.ChannelMonitorKey(), + isError, + tryBan, + maxErrorRate, + ) +} + +func getChannelRequestRate(meta *meta.Meta) (int64, int64) { + if isGroupChannelMeta(meta) { + return reqlimit.GetGroupChannelModelRequest( + context.Background(), + meta.Channel.GroupID, + channelRateKey(meta), + meta.OriginModel, + ) + } + + return reqlimit.GetChannelModelRequest( + context.Background(), + channelMonitorKey(meta), + meta.OriginModel, + ) +} + +func getChannelTokenRate(meta *meta.Meta) (int64, int64) { + if isGroupChannelMeta(meta) { + return reqlimit.GetGroupChannelModelTokensRequest( + context.Background(), + meta.Channel.GroupID, + channelRateKey(meta), + meta.OriginModel, + ) + } + + return reqlimit.GetChannelModelTokensRequest( + context.Background(), + channelMonitorKey(meta), + meta.OriginModel, + ) +} + var channelNoRetryStatusCodesMap = map[int]struct{}{ http.StatusBadRequest: {}, http.StatusRequestEntityTooLarge: {}, @@ -104,11 +229,7 @@ func (m *ChannelMonitor) DoRequest( req *http.Request, do adaptor.DoRequest, ) (*http.Response, error) { - count, overLimitCount, secondCount := reqlimit.PushChannelModelRequest( - context.Background(), - strconv.Itoa(meta.Channel.ID), - meta.OriginModel, - ) + count, overLimitCount, secondCount := observeChannelModelRequestRate(meta) updateChannelModelRequestRate(c, meta, count+overLimitCount, secondCount) requestAt := time.Now() @@ -144,18 +265,16 @@ func handleDoRequestError(meta *meta.Meta, c *gin.Context, err error, requestCos warnErrorRate := getChannelWarnErrorRate(meta) maxErrorRate := getChannelMaxErrorRate(meta) - errorRate, banExecution, _err := monitor.AddRequest( - context.Background(), - meta.OriginModel, - int64(meta.Channel.ID), - true, - false, - maxErrorRate, - ) + errorRate, banExecution, _err := addChannelMonitorRequest(meta, true, false, maxErrorRate) if _err != nil { common.GetLogger(c).Errorf("add request failed: %+v", _err) } + if isGroupChannelMeta(meta) { + common.GetLogger(c).WithError(err).Warn("group channel request failed") + return + } + switch { case banExecution: notifyChannelRequestIssue( @@ -185,6 +304,10 @@ func notifyChannelRequestIssue( requestCost time.Duration, interval time.Duration, ) { + if isGroupChannelMeta(meta) { + return + } + var notifyFunc func(title, message string) lockKey := fmt.Sprintf( @@ -234,10 +357,8 @@ func (m *ChannelMonitor) DoResponse( result, relayErr := do.DoResponse(meta, store, c, resp) if result.Usage.TotalTokens > 0 { - count, overLimitCount, secondCount := reqlimit.PushChannelModelTokensRequest( - context.Background(), - strconv.Itoa(meta.Channel.ID), - meta.OriginModel, + count, overLimitCount, secondCount := observeChannelModelTokenRate( + meta, int64(result.Usage.TotalTokens), ) updateChannelModelTokensRequestRate(c, meta, count+overLimitCount, secondCount) @@ -245,14 +366,7 @@ func (m *ChannelMonitor) DoResponse( if relayErr == nil { maxErrorRate := getChannelMaxErrorRate(meta) - if _, _, err := monitor.AddRequest( - context.Background(), - meta.OriginModel, - int64(meta.Channel.ID), - false, - false, - maxErrorRate, - ); err != nil { + if _, _, err := addChannelMonitorRequest(meta, false, false, maxErrorRate); err != nil { common.GetLogger(c).Errorf("add request failed: %+v", err) } @@ -274,10 +388,8 @@ func handleAdaptorError(meta *meta.Meta, c *gin.Context, relayErr adaptor.Error) maxErrorRate := getChannelMaxErrorRate(meta) tryBanNoPermission := shouldTryBanNoPermission(meta, hasPermission) - errorRate, banExecution, err := monitor.AddRequest( - context.Background(), - meta.OriginModel, - int64(meta.Channel.ID), + errorRate, banExecution, err := addChannelMonitorRequest( + meta, true, tryBanNoPermission, maxErrorRate, @@ -286,6 +398,11 @@ func handleAdaptorError(meta *meta.Meta, c *gin.Context, relayErr adaptor.Error) common.GetLogger(c).Errorf("add request failed: %+v", err) } + if isGroupChannelMeta(meta) { + common.GetLogger(c).WithError(relayErr).Warn("group channel response failed") + return + } + switch { case banExecution: notifyChannelResponseIssue(c, meta, "autoBanned", "Auto Banned", relayErr, time.Minute*15) @@ -341,6 +458,10 @@ func notifyChannelResponseIssue( err adaptor.Error, interval time.Duration, ) { + if isGroupChannelMeta(meta) { + return + } + var notifyFunc func(title, message string) lockKey := fmt.Sprintf( @@ -415,11 +536,7 @@ func GetChannelModelRequestRate(c *gin.Context, meta *meta.Meta) RequestRate { rate.RPM, _ = rpm.(int64) rate.RPS = meta.GetInt64(MetaChannelModelKeyRPS) } else { - rpm, rps := reqlimit.GetChannelModelRequest( - context.Background(), - strconv.Itoa(meta.Channel.ID), - meta.OriginModel, - ) + rpm, rps := getChannelRequestRate(meta) rate.RPM = rpm rate.RPS = rps updateChannelModelRequestRate(c, meta, rpm, rps) @@ -429,11 +546,7 @@ func GetChannelModelRequestRate(c *gin.Context, meta *meta.Meta) RequestRate { rate.TPM, _ = tpm.(int64) rate.TPS = meta.GetInt64(MetaChannelModelKeyTPS) } else { - tpm, tps := reqlimit.GetChannelModelTokensRequest( - context.Background(), - strconv.Itoa(meta.Channel.ID), - meta.OriginModel, - ) + tpm, tps := getChannelTokenRate(meta) rate.TPM = tpm rate.TPS = tps updateChannelModelTokensRequestRate(c, meta, tpm, tps) diff --git a/core/relay/plugin/monitor/monitor_test.go b/core/relay/plugin/monitor/monitor_test.go index 64bcf0fc..706401cc 100644 --- a/core/relay/plugin/monitor/monitor_test.go +++ b/core/relay/plugin/monitor/monitor_test.go @@ -3,14 +3,57 @@ package monitor import ( "net/http" + "net/http/httptest" + "strconv" + "sync/atomic" "testing" + "time" + "github.com/gin-gonic/gin" + "github.com/labring/aiproxy/core/common" "github.com/labring/aiproxy/core/common/config" + "github.com/labring/aiproxy/core/common/notify" + "github.com/labring/aiproxy/core/common/reqlimit" + "github.com/labring/aiproxy/core/model" + modelmonitor "github.com/labring/aiproxy/core/monitor" + "github.com/labring/aiproxy/core/relay/adaptor" relaymeta "github.com/labring/aiproxy/core/relay/meta" relaymodel "github.com/labring/aiproxy/core/relay/model" "github.com/stretchr/testify/require" ) +type doResponseFunc func( + meta *relaymeta.Meta, + store adaptor.Store, + c *gin.Context, + resp *http.Response, +) (adaptor.DoResponseResult, adaptor.Error) + +func (f doResponseFunc) DoResponse( + meta *relaymeta.Meta, + store adaptor.Store, + c *gin.Context, + resp *http.Response, +) (adaptor.DoResponseResult, adaptor.Error) { + return f(meta, store, c, resp) +} + +type doRequestFunc func( + meta *relaymeta.Meta, + store adaptor.Store, + c *gin.Context, + req *http.Request, +) (*http.Response, error) + +func (f doRequestFunc) DoRequest( + meta *relaymeta.Meta, + store adaptor.Store, + c *gin.Context, + req *http.Request, +) (*http.Response, error) { + return f(meta, store, c, req) +} + func TestGetChannelWarnErrorRateUsesChannelValueEvenWhenAutoBalanceDisabled(t *testing.T) { meta := &relaymeta.Meta{} meta.Channel.WarnErrorRate = 0.42 @@ -109,3 +152,281 @@ func TestChannelHasPermissionForForbiddenErrorCode(t *testing.T) { }) } } + +type countingNotifier struct { + count atomic.Int64 +} + +func (n *countingNotifier) Notify(notify.Level, string, string) { + n.count.Add(1) +} + +func (n *countingNotifier) NotifyThrottle( + notify.Level, + string, + time.Duration, + string, + string, +) { + n.count.Add(1) +} + +func TestChannelMonitorSkipsNotifyForGroupChannelErrors(t *testing.T) { + gin.SetMode(gin.TestMode) + + notifier := &countingNotifier{} + notify.SetDefaultNotifier(notifier) + t.Cleanup(func() { + notify.SetDefaultNotifier(¬ify.StdNotifier{}) + }) + + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/", nil) + common.SetLogger(c.Request, common.NewLogger()) + + meta := relaymeta.NewMeta(nil, 0, "group-channel-error-model", model.ModelConfig{}) + meta.Channel.Scope = model.ChannelScopeGroup + meta.Channel.GroupID = "group-channel-error" + meta.Channel.ID = 91 + + monitor := &ChannelMonitor{} + result, relayErr := monitor.DoResponse( + meta, + nil, + c, + nil, + doResponseFunc(func( + _ *relaymeta.Meta, + _ adaptor.Store, + _ *gin.Context, + _ *http.Response, + ) (adaptor.DoResponseResult, adaptor.Error) { + return adaptor.DoResponseResult{}, adaptor.NewError( + http.StatusInternalServerError, + "upstream failed", + ) + }), + ) + + require.Zero(t, result.Usage.TotalTokens) + require.NotNil(t, relayErr) + require.Zero(t, notifier.count.Load()) +} + +func TestGroupMonitorDoResponseUsesGroupChannelCounters(t *testing.T) { + t.Parallel() + gin.SetMode(gin.TestMode) + + groupID := "group-monitor-channel-isolated" + modelName := "group-monitor-channel-model" + + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/", nil) + common.SetLogger(c.Request, common.NewLogger()) + + meta := relaymeta.NewMeta(nil, 0, modelName, model.ModelConfig{}) + meta.Group = model.GroupCache{ID: groupID, Status: model.GroupStatusEnabled} + meta.Channel.Scope = model.ChannelScopeGroup + meta.Channel.GroupID = groupID + meta.Channel.ID = 88 + + groupMonitor := &GroupMonitor{} + channelMonitor := &ChannelMonitor{} + result, relayErr := groupMonitor.DoResponse( + meta, + nil, + c, + nil, + doResponseFunc(func( + _ *relaymeta.Meta, + _ adaptor.Store, + _ *gin.Context, + _ *http.Response, + ) (adaptor.DoResponseResult, adaptor.Error) { + return channelMonitor.DoResponse( + meta, + nil, + c, + nil, + doResponseFunc(func( + _ *relaymeta.Meta, + _ adaptor.Store, + _ *gin.Context, + _ *http.Response, + ) (adaptor.DoResponseResult, adaptor.Error) { + return adaptor.DoResponseResult{ + Usage: model.Usage{TotalTokens: 23}, + }, nil + }), + ) + }), + ) + require.Nil(t, relayErr) + require.Equal(t, model.Usage{TotalTokens: 23}, result.Usage) + + globalTPM, _ := reqlimit.GetGroupModelTokensRequest(t.Context(), groupID, modelName) + require.Zero(t, globalTPM) + + groupChannelTPM, _ := reqlimit.GetGroupChannelModelTokensRequest( + t.Context(), + groupID, + "88", + modelName, + ) + require.Equal(t, int64(23), groupChannelTPM) + + channelTPM, _ := reqlimit.GetChannelModelTokensRequest( + t.Context(), + meta.ChannelMonitorKey(), + modelName, + ) + require.Zero(t, channelTPM) + + log := common.GetLogger(c) + require.Equal(t, int64(23), log.Data["ch_tpm"]) + require.Equal(t, int64(23), log.Data["ch_tps"]) + require.Equal(t, "23", log.Data["group_channel_tpm"]) + require.Equal(t, "23", log.Data["group_channel_tps"]) +} + +func TestChannelMonitorUsesGroupChannelRealtimeNamespace(t *testing.T) { + gin.SetMode(gin.TestMode) + + groupID := "group-channel-monitor" + modelName := "group-channel-monitor-model" + + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/", nil) + common.SetLogger(c.Request, common.NewLogger()) + + meta := relaymeta.NewMeta(nil, 0, modelName, model.ModelConfig{}) + meta.Group = model.GroupCache{ID: groupID, Status: model.GroupStatusEnabled} + meta.Channel.Scope = model.ChannelScopeGroup + meta.Channel.GroupID = groupID + meta.Channel.ID = 89 + + _, _, _ = reqlimit.PushGroupChannelModelRequest( + t.Context(), + groupID, + strconv.Itoa(meta.Channel.ID), + modelName, + ) + + monitor := &ChannelMonitor{} + resp, err := monitor.DoRequest( + meta, + nil, + c, + c.Request, + doRequestFunc(func( + _ *relaymeta.Meta, + _ adaptor.Store, + _ *gin.Context, + _ *http.Request, + ) (*http.Response, error) { + return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}, nil + }), + ) + require.NoError(t, err) + require.NoError(t, resp.Body.Close()) + require.Equal(t, http.StatusOK, resp.StatusCode) + + groupChannelRPM, _ := reqlimit.GetGroupChannelModelRequest( + t.Context(), + groupID, + strconv.Itoa(meta.Channel.ID), + modelName, + ) + require.Equal(t, int64(1), groupChannelRPM) + + prefixedGroupChannelRPM, _ := reqlimit.GetGroupChannelModelRequest( + t.Context(), + groupID, + meta.ChannelMonitorKey(), + modelName, + ) + require.Zero(t, prefixedGroupChannelRPM) + + channelRPM, _ := reqlimit.GetChannelModelRequest( + t.Context(), + meta.ChannelMonitorKey(), + modelName, + ) + require.Zero(t, channelRPM) + + _, _, err = modelmonitor.AddGroupChannelRequestByChannelKey( + t.Context(), + modelName, + meta.ChannelMonitorKey(), + true, + true, + 0, + ) + require.NoError(t, err) + + globalBanned, err := modelmonitor.GetBannedChannelKeysMapWithModel(t.Context(), modelName) + require.NoError(t, err) + require.Empty(t, globalBanned) + + groupBanned, err := modelmonitor.GetGroupChannelBannedChannelKeysMapWithModel( + t.Context(), + modelName, + ) + require.NoError(t, err) + require.Contains(t, groupBanned, meta.ChannelMonitorKey()) +} + +func TestChannelMonitorReadsGroupChannelRetryCounter(t *testing.T) { + gin.SetMode(gin.TestMode) + + groupID := "group-channel-monitor-retry" + modelName := "group-channel-monitor-retry-model" + + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/", nil) + common.SetLogger(c.Request, common.NewLogger()) + + meta := relaymeta.NewMeta(nil, 0, modelName, model.ModelConfig{}) + meta.Group = model.GroupCache{ID: groupID, Status: model.GroupStatusEnabled} + meta.Channel.Scope = model.ChannelScopeGroup + meta.Channel.GroupID = groupID + meta.Channel.ID = 90 + meta.RetryAt = time.Now() + + _, _, _ = reqlimit.PushGroupChannelModelRequest( + t.Context(), + groupID, + strconv.Itoa(meta.Channel.ID), + modelName, + ) + + monitor := &ChannelMonitor{} + resp, err := monitor.DoRequest( + meta, + nil, + c, + c.Request, + doRequestFunc(func( + _ *relaymeta.Meta, + _ adaptor.Store, + _ *gin.Context, + _ *http.Request, + ) (*http.Response, error) { + return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}, nil + }), + ) + require.NoError(t, err) + require.NoError(t, resp.Body.Close()) + + groupChannelRPM, _ := reqlimit.GetGroupChannelModelRequest( + t.Context(), + groupID, + strconv.Itoa(meta.Channel.ID), + modelName, + ) + require.Equal(t, int64(1), groupChannelRPM) +} diff --git a/core/relay/utils/config_cache.go b/core/relay/utils/config_cache.go index 88167362..3dfbfd35 100644 --- a/core/relay/utils/config_cache.go +++ b/core/relay/utils/config_cache.go @@ -1,11 +1,12 @@ package utils import ( - "strconv" + "fmt" "sync" "time" "github.com/labring/aiproxy/core/common" + "github.com/labring/aiproxy/core/model" "github.com/labring/aiproxy/core/relay/meta" gcache "github.com/patrickmn/go-cache" ) @@ -28,7 +29,7 @@ func (c *ChannelConfigCache[T]) Load(meta *meta.Meta, defaults T) (T, error) { c.init() - key := strconv.Itoa(meta.Channel.ID) + key := meta.ChannelMonitorKey() return common.LoadWithKeyLock( c.locker, @@ -163,5 +164,14 @@ func pluginConfigCacheKey(meta *meta.Meta) string { return "" } + if meta.Channel.Scope == model.ChannelScopeGroup { + return fmt.Sprintf( + "%s:%s:%s", + meta.Channel.Scope, + meta.Channel.GroupID, + meta.ModelConfig.Model, + ) + } + return meta.ModelConfig.Model } diff --git a/core/relay/utils/config_cache_test.go b/core/relay/utils/config_cache_test.go index f55a4a69..a246ceb3 100644 --- a/core/relay/utils/config_cache_test.go +++ b/core/relay/utils/config_cache_test.go @@ -47,6 +47,48 @@ func TestChannelConfigCacheUsesChannelID(t *testing.T) { } } +func TestChannelConfigCacheScopesGroupChannelID(t *testing.T) { + cache := &utils.ChannelConfigCache[testChannelConfig]{} + globalMeta := meta.NewMeta( + &coremodel.Channel{ + ID: 7, + Configs: coremodel.ChannelConfigs{"enabled": true}, + }, + mode.ChatCompletions, + "test-model", + coremodel.ModelConfig{}, + ) + groupMeta := meta.NewMeta( + &coremodel.Channel{ + ID: 7, + Configs: coremodel.ChannelConfigs{"enabled": false}, + }, + mode.ChatCompletions, + "test-model", + coremodel.ModelConfig{}, + ) + groupMeta.Channel.Scope = coremodel.ChannelScopeGroup + groupMeta.Channel.GroupID = "group-1" + + globalConfig, err := cache.Load(globalMeta, testChannelConfig{}) + if err != nil { + t.Fatalf("load global config: %v", err) + } + + if !globalConfig.Enabled { + t.Fatal("expected global config to be enabled") + } + + groupConfig, err := cache.Load(groupMeta, testChannelConfig{Enabled: true}) + if err != nil { + t.Fatalf("load group config: %v", err) + } + + if groupConfig.Enabled { + t.Fatal("expected group config to use scoped channel configs") + } +} + func TestPluginConfigCacheUsesModelName(t *testing.T) { cache := &utils.PluginConfigCache[testPluginConfig]{} modelConfig := coremodel.ModelConfig{ @@ -79,6 +121,48 @@ func TestPluginConfigCacheUsesModelName(t *testing.T) { } } +func TestPluginConfigCacheScopesGroupModelConfig(t *testing.T) { + cache := &utils.PluginConfigCache[testPluginConfig]{} + groupAConfig := coremodel.ModelConfig{ + Model: "test-model", + Plugin: map[string]map[string]any{ + "test-plugin": {"enabled": true}, + }, + } + groupBConfig := coremodel.ModelConfig{ + Model: "test-model", + Plugin: map[string]map[string]any{ + "test-plugin": {"enabled": false}, + }, + } + + groupAMeta := meta.NewMeta(nil, mode.ChatCompletions, "test-model", groupAConfig) + groupAMeta.Channel.Scope = coremodel.ChannelScopeGroup + groupAMeta.Channel.GroupID = "group-a" + + groupBMeta := meta.NewMeta(nil, mode.ChatCompletions, "test-model", groupBConfig) + groupBMeta.Channel.Scope = coremodel.ChannelScopeGroup + groupBMeta.Channel.GroupID = "group-b" + + groupAPluginConfig, err := cache.Load(groupAMeta, "test-plugin", testPluginConfig{}) + if err != nil { + t.Fatalf("load group A plugin config: %v", err) + } + + if !groupAPluginConfig.Enabled { + t.Fatal("expected group A plugin config to be enabled") + } + + groupBPluginConfig, err := cache.Load(groupBMeta, "test-plugin", testPluginConfig{}) + if err != nil { + t.Fatalf("load group B plugin config: %v", err) + } + + if groupBPluginConfig.Enabled { + t.Fatal("expected group B plugin config to use its scoped model config") + } +} + func TestChannelConfigCacheBypassesZeroChannelID(t *testing.T) { cache := &utils.ChannelConfigCache[testChannelConfig]{} channel := &coremodel.Channel{ diff --git a/core/relay/utils/testreq.go b/core/relay/utils/testreq.go index 943712d3..86c4447b 100644 --- a/core/relay/utils/testreq.go +++ b/core/relay/utils/testreq.go @@ -26,102 +26,91 @@ func NewErrUnsupportedModelType(modelType string) *UnsupportedModelTypeError { func BuildRequest(modelConfig model.ModelConfig) (io.Reader, mode.Mode, error) { switch modelConfig.Type { case mode.ChatCompletions: - body, err := BuildChatCompletionRequest(modelConfig.Model) - if err != nil { - return nil, mode.Unknown, err - } - - return body, mode.ChatCompletions, nil + return buildModelRequest( + modelConfig.Model, + mode.ChatCompletions, + BuildChatCompletionRequest, + ) case mode.Completions: - body, err := BuildCompletionsRequest(modelConfig.Model) - if err != nil { - return nil, mode.Unknown, err - } - - return body, mode.Completions, nil + return buildModelRequest(modelConfig.Model, mode.Completions, BuildCompletionsRequest) case mode.Embeddings: - body, err := BuildEmbeddingsRequest(modelConfig.Model) - if err != nil { - return nil, mode.Unknown, err - } - - return body, mode.Embeddings, nil + return buildModelRequest(modelConfig.Model, mode.Embeddings, BuildEmbeddingsRequest) case mode.Moderations: - body, err := BuildModerationsRequest(modelConfig.Model) - if err != nil { - return nil, mode.Unknown, err - } - - return body, mode.Moderations, nil + return buildModelRequest(modelConfig.Model, mode.Moderations, BuildModerationsRequest) case mode.ImagesGenerations, mode.GeminiImage: - body, err := BuildImagesGenerationsRequest(modelConfig) - if err != nil { - return nil, mode.Unknown, err - } - - return body, mode.ImagesGenerations, nil + return buildModelConfigRequest( + modelConfig, + mode.ImagesGenerations, + BuildImagesGenerationsRequest, + ) case mode.ImagesEdits: return nil, mode.Unknown, NewErrUnsupportedModelType("edits") case mode.AudioSpeech, mode.GeminiTTS: - body, err := BuildAudioSpeechRequest(modelConfig.Model) - if err != nil { - return nil, mode.Unknown, err - } - - return body, mode.AudioSpeech, nil + return buildModelRequest(modelConfig.Model, mode.AudioSpeech, BuildAudioSpeechRequest) case mode.AudioTranscription: return nil, mode.Unknown, NewErrUnsupportedModelType("audio transcription") case mode.AudioTranslation: return nil, mode.Unknown, NewErrUnsupportedModelType("audio translation") case mode.Rerank: - body, err := BuildRerankRequest(modelConfig.Model) - if err != nil { - return nil, mode.Unknown, err - } - - return body, mode.Rerank, nil + return buildModelRequest(modelConfig.Model, mode.Rerank, BuildRerankRequest) + case mode.Anthropic: + return buildModelRequest(modelConfig.Model, mode.Anthropic, BuildAnthropicRequest) case mode.VideoGenerationsJobs: - body, err := BuildVideoGenerationJobRequest(modelConfig.Model) - if err != nil { - return nil, mode.Unknown, err - } - - return body, mode.VideoGenerationsJobs, nil + return buildModelRequest( + modelConfig.Model, + mode.VideoGenerationsJobs, + BuildVideoGenerationJobRequest, + ) case mode.Videos: - body, err := BuildVideosRequest(modelConfig.Model) - if err != nil { - return nil, mode.Unknown, err - } - - return body, mode.Videos, nil + return buildModelRequest(modelConfig.Model, mode.Videos, BuildVideosRequest) case mode.ParsePdf: return nil, mode.Unknown, NewErrUnsupportedModelType("parse pdf") case mode.GeminiVideo: - body, err := BuildGeminiVideoRequest(modelConfig.Model) - if err != nil { - return nil, mode.Unknown, err - } - - return body, mode.GeminiVideo, nil + return buildModelRequest(modelConfig.Model, mode.GeminiVideo, BuildGeminiVideoRequest) case mode.AliVideo: - body, err := BuildAliVideoRequest(modelConfig.Model) - if err != nil { - return nil, mode.Unknown, err - } - - return body, mode.AliVideo, nil + return buildModelRequest(modelConfig.Model, mode.AliVideo, BuildAliVideoRequest) case mode.DoubaoVideo: - body, err := BuildDoubaoVideoRequest(modelConfig.Model) - if err != nil { - return nil, mode.Unknown, err - } - - return body, mode.DoubaoVideo, nil + return buildModelRequest(modelConfig.Model, mode.DoubaoVideo, BuildDoubaoVideoRequest) default: return nil, mode.Unknown, NewErrUnsupportedModelType(modelConfig.Type.String()) } } +func buildModelRequest( + modelName string, + relayMode mode.Mode, + build func(string) (io.Reader, error), +) (io.Reader, mode.Mode, error) { + body, err := build(modelName) + if err != nil { + return nil, mode.Unknown, err + } + + return body, relayMode, nil +} + +func buildModelConfigRequest( + modelConfig model.ModelConfig, + relayMode mode.Mode, + build func(model.ModelConfig) (io.Reader, error), +) (io.Reader, mode.Mode, error) { + body, err := build(modelConfig) + if err != nil { + return nil, mode.Unknown, err + } + + return body, relayMode, nil +} + +func marshalRequestReader(request any) (io.Reader, error) { + jsonBytes, err := sonic.Marshal(request) + if err != nil { + return nil, err + } + + return bytes.NewReader(jsonBytes), nil +} + func BuildChatCompletionRequest(model string) (io.Reader, error) { testRequest := &relaymodel.GeneralOpenAIRequest{ Model: model, @@ -133,12 +122,7 @@ func BuildChatCompletionRequest(model string) (io.Reader, error) { }, } - jsonBytes, err := sonic.Marshal(testRequest) - if err != nil { - return nil, err - } - - return bytes.NewReader(jsonBytes), nil + return marshalRequestReader(testRequest) } func BuildCompletionsRequest(model string) (io.Reader, error) { @@ -147,12 +131,7 @@ func BuildCompletionsRequest(model string) (io.Reader, error) { Prompt: "hi", } - jsonBytes, err := sonic.Marshal(completionsRequest) - if err != nil { - return nil, err - } - - return bytes.NewReader(jsonBytes), nil + return marshalRequestReader(completionsRequest) } func BuildEmbeddingsRequest(model string) (io.Reader, error) { @@ -161,12 +140,7 @@ func BuildEmbeddingsRequest(model string) (io.Reader, error) { Input: "hi", } - jsonBytes, err := sonic.Marshal(embeddingsRequest) - if err != nil { - return nil, err - } - - return bytes.NewReader(jsonBytes), nil + return marshalRequestReader(embeddingsRequest) } func BuildModerationsRequest(model string) (io.Reader, error) { @@ -175,12 +149,7 @@ func BuildModerationsRequest(model string) (io.Reader, error) { Input: "hi", } - jsonBytes, err := sonic.Marshal(moderationsRequest) - if err != nil { - return nil, err - } - - return bytes.NewReader(jsonBytes), nil + return marshalRequestReader(moderationsRequest) } func BuildImagesGenerationsRequest(modelConfig model.ModelConfig) (io.Reader, error) { @@ -190,12 +159,7 @@ func BuildImagesGenerationsRequest(modelConfig model.ModelConfig) (io.Reader, er Size: "1024x1024", } - jsonBytes, err := sonic.Marshal(imagesGenerationsRequest) - if err != nil { - return nil, err - } - - return bytes.NewReader(jsonBytes), nil + return marshalRequestReader(imagesGenerationsRequest) } func BuildAudioSpeechRequest(model string) (io.Reader, error) { @@ -204,12 +168,7 @@ func BuildAudioSpeechRequest(model string) (io.Reader, error) { Input: "hi", } - jsonBytes, err := sonic.Marshal(audioSpeechRequest) - if err != nil { - return nil, err - } - - return bytes.NewReader(jsonBytes), nil + return marshalRequestReader(audioSpeechRequest) } func BuildRerankRequest(model string) (io.Reader, error) { @@ -219,12 +178,22 @@ func BuildRerankRequest(model string) (io.Reader, error) { Documents: []string{"hi"}, } - jsonBytes, err := sonic.Marshal(rerankRequest) - if err != nil { - return nil, err + return marshalRequestReader(rerankRequest) +} + +func BuildAnthropicRequest(model string) (io.Reader, error) { + anthropicRequest := map[string]any{ + "model": model, + "max_tokens": 16, + "messages": []relaymodel.Message{ + { + Role: relaymodel.RoleUser, + Content: "hi", + }, + }, } - return bytes.NewReader(jsonBytes), nil + return marshalRequestReader(anthropicRequest) } func BuildVideoGenerationJobRequest(model string) (io.Reader, error) { @@ -233,12 +202,7 @@ func BuildVideoGenerationJobRequest(model string) (io.Reader, error) { "prompt": "A calm cinematic shot of clouds moving over a mountain.", } - jsonBytes, err := sonic.Marshal(testRequest) - if err != nil { - return nil, err - } - - return bytes.NewReader(jsonBytes), nil + return marshalRequestReader(testRequest) } func BuildVideosRequest(model string) (io.Reader, error) { @@ -247,12 +211,7 @@ func BuildVideosRequest(model string) (io.Reader, error) { Prompt: "A calm cinematic shot of clouds moving over a mountain.", } - jsonBytes, err := sonic.Marshal(testRequest) - if err != nil { - return nil, err - } - - return bytes.NewReader(jsonBytes), nil + return marshalRequestReader(testRequest) } func BuildGeminiVideoRequest(_ string) (io.Reader, error) { @@ -264,12 +223,7 @@ func BuildGeminiVideoRequest(_ string) (io.Reader, error) { }, } - jsonBytes, err := sonic.Marshal(testRequest) - if err != nil { - return nil, err - } - - return bytes.NewReader(jsonBytes), nil + return marshalRequestReader(testRequest) } func BuildAliVideoRequest(model string) (io.Reader, error) { @@ -284,12 +238,7 @@ func BuildAliVideoRequest(model string) (io.Reader, error) { }, } - jsonBytes, err := sonic.Marshal(testRequest) - if err != nil { - return nil, err - } - - return bytes.NewReader(jsonBytes), nil + return marshalRequestReader(testRequest) } func BuildDoubaoVideoRequest(model string) (io.Reader, error) { @@ -306,10 +255,5 @@ func BuildDoubaoVideoRequest(model string) (io.Reader, error) { "ratio": "16:9", } - jsonBytes, err := sonic.Marshal(testRequest) - if err != nil { - return nil, err - } - - return bytes.NewReader(jsonBytes), nil + return marshalRequestReader(testRequest) } diff --git a/core/relay/utils/testreq_test.go b/core/relay/utils/testreq_test.go index 6a3d3116..f88cef4b 100644 --- a/core/relay/utils/testreq_test.go +++ b/core/relay/utils/testreq_test.go @@ -80,6 +80,32 @@ func TestBuildRequestDoubaoVideo(t *testing.T) { ) } +func TestBuildRequestAnthropic(t *testing.T) { + body, relayMode, err := utils.BuildRequest(model.ModelConfig{ + Model: "claude-sonnet-4-5", + Type: mode.Anthropic, + }) + require.NoError(t, err) + require.Equal(t, mode.Anthropic, relayMode) + + data, err := io.ReadAll(body) + require.NoError(t, err) + require.JSONEq( + t, + `{ + "model":"claude-sonnet-4-5", + "messages":[ + { + "role":"user", + "content":"hi" + } + ], + "max_tokens":16 + }`, + string(data), + ) +} + func TestBuildRequestVideoGenerationJob(t *testing.T) { body, relayMode, err := utils.BuildRequest(model.ModelConfig{ Model: "happyhorse-1.0-t2v", diff --git a/core/router/api.go b/core/router/api.go index efddd6db..7298d095 100644 --- a/core/router/api.go +++ b/core/router/api.go @@ -36,6 +36,7 @@ func SetAPIRouter(router *gin.Engine) { dashboardRoute := apiRouter.Group("/dashboard") { dashboardRoute.GET("/", controller.GetDashboard) + dashboardRoute.GET("/group_channel", controller.GetGlobalGroupChannelDashboard) dashboardRoute.GET("/:group", controller.GetGroupDashboard) dashboardRoute.GET("/:group/models", controller.GetGroupDashboardModels) } @@ -43,12 +44,20 @@ func SetAPIRouter(router *gin.Engine) { dashboardV2Route := apiRouter.Group("/dashboardv2") { dashboardV2Route.GET("/", controller.GetTimeSeriesModelData) + dashboardV2Route.GET( + "/group_channel", + controller.GetGlobalGroupChannelTimeSeriesModelData, + ) dashboardV2Route.GET("/:group", controller.GetGroupTimeSeriesModelData) } dashboardV3Route := apiRouter.Group("/dashboardv3") { dashboardV3Route.GET("/", controller.GetTimeSeriesModelDataV3) + dashboardV3Route.GET( + "/group_channel", + controller.GetGlobalGroupChannelTimeSeriesModelDataV3, + ) dashboardV3Route.GET("/:group", controller.GetGroupTimeSeriesModelDataV3) } @@ -72,6 +81,55 @@ func SetAPIRouter(router *gin.Engine) { groupRoute.POST("/:group/status", controller.UpdateGroupStatus) groupRoute.POST("/:group/rpm_ratio", controller.UpdateGroupRPMRatio) groupRoute.POST("/:group/tpm_ratio", controller.UpdateGroupTPMRatio) + groupRoute.GET("/:group/channel-dashboard", controller.GetGroupChannelDashboard) + groupRoute.GET( + "/:group/channel-dashboardv2", + controller.GetGroupChannelTimeSeriesModelData, + ) + groupRoute.GET( + "/:group/channel-dashboardv3", + controller.GetGroupChannelTimeSeriesModelDataV3, + ) + groupRoute.GET( + "/:group/channel-dashboard/models", + controller.GetGroupChannelDashboardModels, + ) + groupRoute.GET( + "/:group/channel-models/enabled", + controller.GetGroupChannelEnabledModels, + ) + groupRoute.GET( + "/:group/channel-models/enabled/:set", + controller.GetGroupChannelEnabledModelsSet, + ) + groupChannelsRoute := groupRoute.Group("/:group/channels") + { + groupChannelsRoute.GET("/", controller.GetGroupChannels) + groupChannelsRoute.GET("/search", controller.SearchGroupChannels) + groupChannelsRoute.GET("/type_metas", controller.ChannelTypeMetas) + groupChannelsRoute.POST("/", controller.AddGroupChannels) + groupChannelsRoute.POST("/batch_delete", controller.DeleteGroupChannels) + groupChannelsRoute.POST("/batch_info", controller.GetGroupChannelBatchInfo) + + importRoute := groupChannelsRoute.Group("/import") + { + importRoute.POST("/oneapi", controller.ImportGroupChannelFromOneAPI) + } + } + + groupChannelRoute := groupRoute.Group("/:group/channel") + { + groupChannelRoute.GET("/:id", controller.GetGroupChannel) + groupChannelRoute.POST("/", controller.AddGroupChannel) + groupChannelRoute.PUT("/:id", controller.UpdateGroupChannel) + groupChannelRoute.DELETE("/:id", controller.DeleteGroupChannel) + groupChannelRoute.POST("/:id/status", controller.UpdateGroupChannelStatus) + groupChannelRoute.POST("/test-preview", controller.TestGroupChannelPreview) + groupChannelRoute.POST("/test-preview-all", controller.TestGroupChannelPreviewAll) + groupChannelRoute.GET("/:id/tests", controller.GetGroupChannelTests) + groupChannelRoute.GET("/:id/test", controller.TestGroupChannelModels) + groupChannelRoute.GET("/:id/test/*model", controller.TestGroupChannel) + } groupModelConfigsRoute := groupRoute.Group("/:group/model_configs") { @@ -89,6 +147,29 @@ func SetAPIRouter(router *gin.Engine) { groupModelConfigRoute.GET("/*model", controller.GetGroupModelConfig) } + groupScopeModelConfigsRoute := groupRoute.Group("/:group/scope_model_configs") + { + groupScopeModelConfigsRoute.GET("/", controller.GetGroupScopeModelConfigs) + groupScopeModelConfigsRoute.GET("/search", controller.SearchGroupScopeModelConfigs) + groupScopeModelConfigsRoute.GET("/all", controller.GetAllGroupScopeModelConfigs) + groupScopeModelConfigsRoute.POST( + "/contains", + controller.GetGroupScopeModelConfigsByModelsContains, + ) + groupScopeModelConfigsRoute.POST("/", controller.SaveGroupScopeModelConfigs) + groupScopeModelConfigsRoute.POST( + "/batch_delete", + controller.DeleteGroupScopeModelConfigs, + ) + } + + groupScopeModelConfigRoute := groupRoute.Group("/:group/scope_model_config") + { + groupScopeModelConfigRoute.GET("/*model", controller.GetGroupScopeModelConfig) + groupScopeModelConfigRoute.POST("/*model", controller.SaveGroupScopeModelConfig) + groupScopeModelConfigRoute.DELETE("/*model", controller.DeleteGroupScopeModelConfig) + } + groupMcpRoute := groupRoute.Group("/:group/mcp") { groupMcpRoute.GET("/", mcp.GetGroupPublicMCPs) @@ -124,6 +205,35 @@ func SetAPIRouter(router *gin.Engine) { } } + groupChannelsRoute := apiRouter.Group("/group_channels") + { + groupChannelsRoute.GET("/", controller.GetGlobalGroupChannels) + groupChannelsRoute.GET("/search", controller.SearchGlobalGroupChannels) + groupChannelsRoute.GET("/type_metas", controller.ChannelTypeMetas) + groupChannelsRoute.POST("/", controller.AddGlobalGroupChannels) + groupChannelsRoute.POST("/batch_delete", controller.DeleteGlobalGroupChannels) + groupChannelsRoute.POST("/batch_info", controller.GetGlobalGroupChannelBatchInfo) + + importRoute := groupChannelsRoute.Group("/import") + { + importRoute.POST("/oneapi", controller.ImportGlobalGroupChannelFromOneAPI) + } + } + + groupChannelRoute := apiRouter.Group("/group_channel") + { + groupChannelRoute.POST("/", controller.AddGlobalGroupChannel) + groupChannelRoute.POST("/test-preview", controller.TestGlobalGroupChannelPreview) + groupChannelRoute.POST("/test-preview-all", controller.TestGlobalGroupChannelPreviewAll) + groupChannelRoute.GET("/:id", controller.GetGlobalGroupChannel) + groupChannelRoute.PUT("/:id", controller.UpdateGlobalGroupChannel) + groupChannelRoute.DELETE("/:id", controller.DeleteGlobalGroupChannel) + groupChannelRoute.POST("/:id/status", controller.UpdateGlobalGroupChannelStatus) + groupChannelRoute.GET("/:id/tests", controller.GetGlobalGroupChannelTests) + groupChannelRoute.GET("/:id/test", controller.TestGlobalGroupChannelModels) + groupChannelRoute.GET("/:id/test/*model", controller.TestGlobalGroupChannel) + } + channelRoute := apiRouter.Group("/channel") { channelRoute.GET("/:id", controller.GetChannel) @@ -172,6 +282,14 @@ func SetAPIRouter(router *gin.Engine) { logsRoute := apiRouter.Group("/logs") { logsRoute.GET("/export", controller.ExportLogs) + logsRoute.GET("/group_channel/export", controller.ExportGlobalGroupChannelLogs) + logsRoute.GET("/group_channel/search", controller.SearchGlobalGroupChannelLogs) + logsRoute.GET( + "/group_channel/detail/:log_id", + controller.GetGlobalGroupChannelLogDetail, + ) + logsRoute.GET("/group_channel", controller.GetGlobalGroupChannelLogs) + logsRoute.DELETE("/group_channel", controller.DeleteGlobalGroupChannelHistoryLogs) logsRoute.GET("/", controller.GetLogs) logsRoute.DELETE("/", controller.DeleteHistoryLogs) logsRoute.GET("/search", controller.SearchLogs) @@ -182,6 +300,14 @@ func SetAPIRouter(router *gin.Engine) { logRoute := apiRouter.Group("/log") { logRoute.GET("/:group/export", controller.ExportGroupLogs) + logRoute.GET("/:group/group_channel/export", controller.ExportGroupChannelLogs) + logRoute.GET("/:group/group_channel/search", controller.SearchGroupChannelLogs) + logRoute.GET( + "/:group/group_channel/detail/:log_id", + controller.GetGroupChannelLogDetailForGroup, + ) + logRoute.GET("/:group/group_channel", controller.GetGroupChannelLogs) + logRoute.DELETE("/:group/group_channel", controller.DeleteGroupChannelHistoryLogs) logRoute.GET("/:group", controller.GetGroupLogs) logRoute.GET("/:group/search", controller.SearchGroupLogs) logRoute.GET("/:group/detail/:log_id", controller.GetGroupLogDetail) diff --git a/core/task/async_usage_test.go b/core/task/async_usage_test.go index 0636678e..f63e6ec7 100644 --- a/core/task/async_usage_test.go +++ b/core/task/async_usage_test.go @@ -4,6 +4,8 @@ package task import ( "context" "errors" + "path/filepath" + "sync/atomic" "testing" "time" @@ -62,6 +64,37 @@ func (b preChargeFailingAsyncUsageBalance) GetGroupQuota( return nil, b.err } +type countingAsyncUsageBalance struct { + calls *atomic.Int64 +} + +func (b countingAsyncUsageBalance) GetGroupRemainBalance( + context.Context, + model.GroupCache, +) (float64, balance.PostGroupConsumer, error) { + return 100, countingAsyncUsageConsumer(b), nil +} + +func (b countingAsyncUsageBalance) GetGroupQuota( + context.Context, + model.GroupCache, +) (*balance.GroupQuota, error) { + return &balance.GroupQuota{Total: 100, Remain: 100}, nil +} + +type countingAsyncUsageConsumer struct { + calls *atomic.Int64 +} + +func (c countingAsyncUsageConsumer) PostGroupConsume( + context.Context, + string, + float64, +) (float64, error) { + c.calls.Add(1) + return 0, nil +} + func TestCompleteAsyncUsageIgnoresMissingLog(t *testing.T) { db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) require.NoError(t, err) @@ -123,6 +156,68 @@ func TestCompleteAsyncUsageReturnsLogUpdateError(t *testing.T) { require.Equal(t, model.ZeroNullInt64(0), info.Usage.InputTokens) } +func TestCompleteAsyncUsageKeepsClaimPendingWhenLogUpdateFails(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "async_usage_log_retry.db") + db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&model.Log{}, &model.AsyncUsageInfo{})) + + oldLogDB := model.LogDB + model.LogDB = db + t.Cleanup(func() { + model.LogDB = oldLogDB + }) + + requestID := "async_log_retry" + require.NoError(t, db.Create(&model.Log{ + RequestID: model.EmptyNullString(requestID), + AsyncUsageStatus: model.AsyncUsageStatusPending, + }).Error) + + info := &model.AsyncUsageInfo{ + RequestID: requestID, + RequestAt: time.Now(), + Status: model.AsyncUsageStatusPending, + Model: "gpt-5.4", + ProcessingToken: "claim-token", + } + require.NoError(t, model.CreateAsyncUsageInfo(info)) + require.NoError(t, db.Migrator().DropTable(&model.Log{})) + + usage := model.Usage{ + InputTokens: 10, + TotalTokens: 10, + } + err = completeAsyncUsage(context.Background(), info, usage, model.UsageContext{}) + require.ErrorContains(t, err, "update async usage log") + require.Equal(t, model.AsyncUsageStatusPending, info.Status) + require.Equal(t, usage.InputTokens, info.Usage.InputTokens) + + var got model.AsyncUsageInfo + require.NoError(t, db.First(&got, info.ID).Error) + require.Equal(t, model.AsyncUsageStatusPending, got.Status) + require.Equal(t, "claim-token", got.ProcessingToken) + require.Equal(t, model.ZeroNullInt64(10), got.Usage.InputTokens) + + require.NoError(t, db.AutoMigrate(&model.Log{})) + require.NoError(t, db.Create(&model.Log{ + RequestID: model.EmptyNullString(requestID), + AsyncUsageStatus: model.AsyncUsageStatusPending, + }).Error) + + require.NoError(t, completeAsyncUsage(context.Background(), info, usage, model.UsageContext{})) + require.Equal(t, model.AsyncUsageStatusCompleted, info.Status) + + require.NoError(t, db.First(&got, info.ID).Error) + require.Equal(t, model.AsyncUsageStatusCompleted, got.Status) + require.Empty(t, got.ProcessingToken) + + var gotLog model.Log + require.NoError(t, db.Where("request_id = ?", requestID).First(&gotLog).Error) + require.Equal(t, model.AsyncUsageStatusCompleted, gotLog.AsyncUsageStatus) + require.Equal(t, model.ZeroNullInt64(10), gotLog.Usage.InputTokens) +} + func TestCompleteAsyncUsageRecordsBalanceConsumeErrorWithoutRetry(t *testing.T) { db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) require.NoError(t, err) @@ -153,15 +248,16 @@ func TestCompleteAsyncUsageRecordsBalanceConsumeErrorWithoutRetry(t *testing.T) }) info := &model.AsyncUsageInfo{ - RequestID: "balance_error", - RequestAt: time.Now(), - Status: model.AsyncUsageStatusPending, - Model: "gpt-5.4", - GroupID: "group-async-balance", - TokenID: 1, - TokenName: "token-1", - Price: model.Price{InputPrice: 1, InputPriceUnit: 1}, - UpstreamID: "resp_balance_error", + RequestID: "balance_error", + RequestAt: time.Now(), + Status: model.AsyncUsageStatusPending, + Model: "gpt-5.4", + GroupID: "group-async-balance", + TokenID: 1, + TokenName: "token-1", + Price: model.Price{InputPrice: 1, InputPriceUnit: 1}, + UpstreamID: "resp_balance_error", + ProcessingToken: "claim-token", } require.NoError(t, model.CreateAsyncUsageInfo(info)) @@ -218,15 +314,16 @@ func TestCompleteAsyncUsageRetriesPreChargeBalanceError(t *testing.T) { }) info := &model.AsyncUsageInfo{ - RequestID: "pre_charge_balance_error", - RequestAt: time.Now(), - Status: model.AsyncUsageStatusPending, - Model: "gpt-5.4", - GroupID: "group-pre-charge-balance", - TokenID: 1, - TokenName: "token-1", - Price: model.Price{InputPrice: 1, InputPriceUnit: 1}, - UpstreamID: "resp_pre_charge_balance_error", + RequestID: "pre_charge_balance_error", + RequestAt: time.Now(), + Status: model.AsyncUsageStatusPending, + Model: "gpt-5.4", + GroupID: "group-pre-charge-balance", + TokenID: 1, + TokenName: "token-1", + Price: model.Price{InputPrice: 1, InputPriceUnit: 1}, + UpstreamID: "resp_pre_charge_balance_error", + ProcessingToken: "claim-token", } require.NoError(t, model.CreateAsyncUsageInfo(info)) @@ -347,6 +444,109 @@ func TestCompleteAsyncUsageChargesStoredPerRequestPrice(t *testing.T) { require.Equal(t, 0.25, got.Amount.UsedAmount) } +func TestCompleteAsyncUsageSkipsSideEffectsAfterClaimLost(t *testing.T) { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&model.Log{}, &model.AsyncUsageInfo{})) + + oldLogDB := model.LogDB + model.LogDB = db + t.Cleanup(func() { + model.LogDB = oldLogDB + }) + + requestID := "async_claim_lost" + require.NoError(t, db.Create(&model.Log{ + RequestID: model.EmptyNullString(requestID), + AsyncUsageStatus: model.AsyncUsageStatusPending, + }).Error) + + info := &model.AsyncUsageInfo{ + RequestID: requestID, + RequestAt: time.Now(), + Status: model.AsyncUsageStatusPending, + Model: "gpt-5.4", + ProcessingToken: "stale-token", + } + require.NoError(t, model.CreateAsyncUsageInfo(info)) + require.NoError(t, db.Model(&model.AsyncUsageInfo{}). + Where("id = ?", info.ID). + Update("processing_token", "other-token").Error) + + err = completeAsyncUsage(context.Background(), info, model.Usage{ + InputTokens: 3, + TotalTokens: 3, + }, model.UsageContext{}) + require.ErrorContains(t, err, "async usage claim lost") + + var gotLog model.Log + require.NoError(t, db.Where("request_id = ?", requestID).First(&gotLog).Error) + require.Equal(t, model.AsyncUsageStatusPending, gotLog.AsyncUsageStatus) + require.Equal(t, model.ZeroNullInt64(0), gotLog.Usage.TotalTokens) + + var gotInfo model.AsyncUsageInfo + require.NoError(t, db.First(&gotInfo, info.ID).Error) + require.Equal(t, model.AsyncUsageStatusPending, gotInfo.Status) + require.Equal(t, model.ZeroNullInt64(0), gotInfo.Usage.TotalTokens) +} + +func TestCompleteAsyncUsageDoesNotChargeAfterClaimLost(t *testing.T) { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&model.Log{}, &model.AsyncUsageInfo{})) + + oldLogDB := model.LogDB + model.LogDB = db + t.Cleanup(func() { + model.LogDB = oldLogDB + }) + + require.NoError(t, model.CacheSetGroup(&model.GroupCache{ + ID: "group-claim-lost-charge", + Status: model.GroupStatusEnabled, + })) + t.Cleanup(func() { + require.NoError(t, model.CacheDeleteGroup("group-claim-lost-charge")) + }) + + var chargeCalls atomic.Int64 + + oldBalance := balance.Default + balance.Default = countingAsyncUsageBalance{calls: &chargeCalls} + t.Cleanup(func() { + balance.Default = oldBalance + }) + + info := &model.AsyncUsageInfo{ + RequestID: "async_claim_lost_charge", + RequestAt: time.Now(), + Status: model.AsyncUsageStatusPending, + Model: "gpt-5.4", + GroupID: "group-claim-lost-charge", + TokenID: 1, + TokenName: "token-1", + Price: model.Price{InputPrice: 1, InputPriceUnit: 1}, + ProcessingToken: "stale-token", + } + require.NoError(t, model.CreateAsyncUsageInfo(info)) + require.NoError(t, db.Model(&model.AsyncUsageInfo{}). + Where("id = ?", info.ID). + Update("processing_token", "other-token").Error) + + err = completeAsyncUsage(context.Background(), info, model.Usage{ + InputTokens: 3, + TotalTokens: 3, + }, model.UsageContext{}) + require.ErrorContains(t, err, "async usage claim lost") + require.Zero(t, chargeCalls.Load()) + + var gotInfo model.AsyncUsageInfo + require.NoError(t, db.First(&gotInfo, info.ID).Error) + require.Equal(t, model.AsyncUsageStatusPending, gotInfo.Status) + require.False(t, gotInfo.BalanceConsumed) + require.Equal(t, model.ZeroNullInt64(0), gotInfo.Usage.TotalTokens) +} + func TestCompleteAsyncUsagePersistsBalanceConsumed(t *testing.T) { db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) require.NoError(t, err) diff --git a/core/task/task.go b/core/task/task.go index fc1125b6..a798a487 100644 --- a/core/task/task.go +++ b/core/task/task.go @@ -415,7 +415,7 @@ func processOneAsyncUsage(ctx context.Context, info *model.AsyncUsageInfo) { info.NextPollAt.Format(time.RFC3339), ) - channel, err := model.GetChannelByID(info.ChannelID) + channel, err := loadAsyncUsageChannel(info) if err != nil { if !errors.Is(err, gorm.ErrRecordNotFound) { log.Debugf( @@ -529,6 +529,10 @@ func processOneAsyncUsage(ctx context.Context, info *model.AsyncUsageInfo) { ) } +func loadAsyncUsageChannel(info *model.AsyncUsageInfo) (*model.Channel, error) { + return model.GetChannelByID(info.ChannelID) +} + func startAsyncUsageClaimRenewal( ctx context.Context, info *model.AsyncUsageInfo, @@ -615,6 +619,15 @@ func completeAsyncUsage( selectedPrice.ConditionalPrices = nil if amount.UsedAmount > 0 && !info.BalanceConsumed { + claimed, err := model.ClaimedAsyncUsageInfoExists(info) + if err != nil { + return fmt.Errorf("check async usage claim before charge: %w", err) + } + + if !claimed { + return errors.New("async usage claim lost") + } + charged, err := consumeAsyncUsageGroupBalance(ctx, info, amount.UsedAmount) if err != nil { notify.ErrorThrottle( @@ -645,29 +658,40 @@ func completeAsyncUsage( } } - if err := model.UpdateLogUsageByRequestID( - info.RequestID, - usage, - usageContext, - selectedPrice, - amount, - ); err != nil { - if !errors.Is(err, gorm.ErrRecordNotFound) { - notify.ErrorThrottle( - "asyncUsageUpdateLog", - time.Minute*5, - "update async usage log failed", - err.Error(), - ) + saved, err := model.SaveClaimedAsyncUsageResult(info, usage, usageContext, amount) + if err != nil { + return fmt.Errorf("update async usage info: %w", err) + } - return fmt.Errorf("update async usage log: %w", err) - } + if !saved { + return errors.New("async usage claim lost") } + info.Usage = usage + info.UsageContext = usageContext + info.Amount = amount + info.Error = "" + + if err := updateAsyncUsageLog(info, usage, usageContext, selectedPrice, amount); err != nil { + return err + } + + completed, err := model.CompleteClaimedAsyncUsageInfo(info) + if err != nil { + return fmt.Errorf("complete async usage info: %w", err) + } + + if !completed { + return errors.New("async usage claim lost") + } + + info.Status = model.AsyncUsageStatusCompleted + model.BatchUpdateSummaryOnlyUsage( time.Now(), info.RequestAt, info.GroupID, + model.ChannelScopeGlobal, info.ChannelID, info.Model, info.TokenID, @@ -678,19 +702,35 @@ func completeAsyncUsage( model.IsClaudeLongContextSummary(info.Model, usage), ) - info.Status = model.AsyncUsageStatusCompleted - info.Usage = usage - info.UsageContext = usageContext - info.Amount = amount - info.Error = "" + return nil +} - completed, err := model.CompleteClaimedAsyncUsageInfo(info, usage, usageContext, amount) - if err != nil { - return fmt.Errorf("update async usage info: %w", err) - } +func updateAsyncUsageLog( + info *model.AsyncUsageInfo, + usage model.Usage, + usageContext model.UsageContext, + selectedPrice model.Price, + amount model.Amount, +) error { + if err := model.UpdateLogUsageByRequestID( + info.RequestID, + usage, + usageContext, + selectedPrice, + amount, + ); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil + } - if !completed { - return errors.New("async usage claim lost") + notify.ErrorThrottle( + "asyncUsageUpdateLog", + time.Minute*5, + "update async usage log failed", + err.Error(), + ) + + return fmt.Errorf("update async usage log: %w", err) } return nil From 9c5a6d9a9ef040f6ec436b3db87d220bc823cd56 Mon Sep 17 00:00:00 2001 From: zijiren233 Date: Thu, 2 Jul 2026 11:56:47 +0800 Subject: [PATCH 2/6] fix: store selector --- core/model/store.go | 77 ++++++++++----------------------------------- 1 file changed, 17 insertions(+), 60 deletions(-) diff --git a/core/model/store.go b/core/model/store.go index 0d4879ef..fd553a6a 100644 --- a/core/model/store.go +++ b/core/model/store.go @@ -18,6 +18,11 @@ const ( ErrStoreNotFound = "store id" ) +const ( + storeV2Table = "store_v2" + groupChannelStoreV2Table = "group_channel_store_v2" +) + const ( StorePrefixResponse = "response" StorePrefixVideoJob = "video_job" @@ -168,7 +173,7 @@ func SaveIfNotExistStoreByScope(s *StoreV2, scope ChannelScope) (*StoreV2, error return nil, err } - tx := LogDB.Clauses(clause.OnConflict{DoNothing: true}).Create(storeDBModel(s, scope)) + tx := storeScopedDB(scope).Clauses(clause.OnConflict{DoNothing: true}).Create(s) if tx.Error != nil { return nil, tx.Error } @@ -195,7 +200,7 @@ func SaveIfNotExistStoreByScope(s *StoreV2, scope ChannelScope) (*StoreV2, error } tx = LogDB.Session(&gorm.Session{SkipHooks: true}). - Model(storeDBModelForScope(scope)). + Table(storeTable(scope)). Where( "group_id = ? and token_id = ? and id = ? and expires_at <= ?", s.GroupID, @@ -258,52 +263,16 @@ func normalizeChannelScope(scope ChannelScope) ChannelScope { return NormalizeChannelScope(scope) } -func storeDBModel(s *StoreV2, scope ChannelScope) any { - if normalizeChannelScope(scope) == ChannelScopeGroup { - return groupChannelStoreFromStore(s) - } - - return s -} - -func storeDBModelForScope(scope ChannelScope) any { +func storeTable(scope ChannelScope) string { if normalizeChannelScope(scope) == ChannelScopeGroup { - return &GroupChannelStoreV2{} + return groupChannelStoreV2Table } - return &StoreV2{} -} - -func groupChannelStoreFromStore(s *StoreV2) *GroupChannelStoreV2 { - return &GroupChannelStoreV2{ - ID: s.ID, - CreatedAt: s.CreatedAt, - UpdatedAt: s.UpdatedAt, - ExpiresAt: s.ExpiresAt, - GroupID: s.GroupID, - TokenID: s.TokenID, - ChannelID: s.ChannelID, - Model: s.Model, - Metadata: s.Metadata, - } + return storeV2Table } -func (s *GroupChannelStoreV2) ToStoreV2() *StoreV2 { - if s == nil { - return nil - } - - return &StoreV2{ - ID: s.ID, - CreatedAt: s.CreatedAt, - UpdatedAt: s.UpdatedAt, - ExpiresAt: s.ExpiresAt, - GroupID: s.GroupID, - TokenID: s.TokenID, - ChannelID: s.ChannelID, - Model: s.Model, - Metadata: s.Metadata, - } +func storeScopedDB(scope ChannelScope) *gorm.DB { + return LogDB.Table(storeTable(scope)) } func saveStoreWithMinUpdateInterval( @@ -318,7 +287,7 @@ func saveStoreWithMinUpdateInterval( cutoff := now.Add(-opt.MinUpdateInterval) - tx := LogDB.Clauses(clause.OnConflict{ + tx := storeScopedDB(scope).Clauses(clause.OnConflict{ Columns: []clause.Column{ {Name: "group_id"}, {Name: "token_id"}, @@ -332,7 +301,7 @@ func saveStoreWithMinUpdateInterval( "metadata": s.Metadata, }), Where: storeUpsertUpdateWhere(cutoff, now), - }).Create(storeDBModel(s, scope)) + }).Create(s) if tx.Error != nil { return nil, tx.Error } @@ -350,7 +319,7 @@ func upsertStore(s *StoreV2, scope ChannelScope, opt SaveStoreOption) (*StoreV2, return nil, err } - tx := LogDB.Clauses(clause.OnConflict{ + tx := storeScopedDB(scope).Clauses(clause.OnConflict{ Columns: []clause.Column{ {Name: "group_id"}, {Name: "token_id"}, @@ -363,7 +332,7 @@ func upsertStore(s *StoreV2, scope ChannelScope, opt SaveStoreOption) (*StoreV2, "model": s.Model, "metadata": s.Metadata, }), - }).Create(storeDBModel(s, scope)) + }).Create(s) if tx.Error != nil { return nil, tx.Error } @@ -446,22 +415,10 @@ func getStore( includeExpired bool, ) (*StoreV2, error) { scope = normalizeChannelScope(scope) - if scope == ChannelScopeGroup { - var s GroupChannelStoreV2 - - tx := LogDB.Where("group_id = ? and token_id = ? and id = ?", group, tokenID, id) - if !includeExpired { - tx = tx.Where("expires_at > ?", time.Now()) - } - - err := tx.First(&s).Error - - return s.ToStoreV2(), HandleNotFound(err, ErrStoreNotFound) - } var s StoreV2 - tx := LogDB.Where("group_id = ? and token_id = ? and id = ?", group, tokenID, id) + tx := storeScopedDB(scope).Where("group_id = ? and token_id = ? and id = ?", group, tokenID, id) if !includeExpired { tx = tx.Where("expires_at > ?", time.Now()) } From e5fd9473d0142ba619a0bec7fa3340a9a1264022 Mon Sep 17 00:00:00 2001 From: yy Date: Thu, 23 Jul 2026 15:24:10 +0800 Subject: [PATCH 3/6] fix: merge main --- .gitignore | 1 + go.work.sum | 16 +++++----------- 2 files changed, 6 insertions(+), 11 deletions(-) diff --git a/.gitignore b/.gitignore index 0c25fa82..24136d09 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ **/.vscode **/__debug_bin* **/.DS_Store +.agent/ diff --git a/go.work.sum b/go.work.sum index e5102d36..a3ea115d 100644 --- a/go.work.sum +++ b/go.work.sum @@ -124,7 +124,6 @@ github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhD github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk= github.com/chzyer/readline v1.5.1/go.mod h1:Eh+b79XXUwfKfcPLepksvw2tcLE/Ct21YObkaSkeBlk= github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg= -github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= github.com/cncf/xds/go v0.0.0-20241223141626-cff3c89139a3/go.mod h1:W+zGtBO5Y1IgJhy4+A9GOqVhqLpfZi+vwmdNXUehLA8= github.com/cncf/xds/go v0.0.0-20250121191232-2f005788dc42 h1:Om6kYQYDUk5wWbT0t0q6pvyM49i9XZAv9dDrkDA7gjk= github.com/cncf/xds/go v0.0.0-20250121191232-2f005788dc42/go.mod h1:W+zGtBO5Y1IgJhy4+A9GOqVhqLpfZi+vwmdNXUehLA8= @@ -138,8 +137,10 @@ github.com/dlclark/regexp2cg v0.2.0/go.mod h1:K2c4ctxtSQjzgeMKKgi1rEflZVVJWZWlUU github.com/envoyproxy/go-control-plane v0.13.4/go.mod h1:kDfuBlDVsSj2MjrLEtRWtHlsWIFcGyB2RMO44Dc5GZA= github.com/envoyproxy/go-control-plane v0.13.5-0.20251024222203-75eaa193e329/go.mod h1:Alz8LEClvR7xKsrq3qzoc4N0guvVNSS8KmSChGYr9hs= github.com/envoyproxy/go-control-plane v0.14.0/go.mod h1:NcS5X47pLl/hfqxU70yPwL9ZMkUlwlKxtAohpi2wBEU= +github.com/envoyproxy/go-control-plane/envoy v1.32.4/go.mod h1:Gzjc5k8JcJswLjAx1Zm+wSYE20UrLtt7JZMWiWQXQEw= github.com/envoyproxy/go-control-plane/ratelimit v0.1.0 h1:/G9QYbddjL25KvtKTv3an9lx6VBE2cnb8wp1vEGNYGI= github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4= +github.com/envoyproxy/protoc-gen-validate v1.2.1/go.mod h1:d/C80l/jxXLdfEIhX1W2TmLfsJ31lvEjwamM4DxlWXU= github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4= github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= @@ -188,7 +189,6 @@ github.com/jordanlewis/gcassert v0.0.0-20250430164644-389ef753e22e/go.mod h1:Zyb github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= github.com/klauspost/compress v1.17.6/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM= -github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8= github.com/knz/go-libedit v1.10.1 h1:0pHpWtx9vcvC0xGZqEQlQdfSQs7WRlAjuPvk3fOZDCo= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= @@ -197,13 +197,11 @@ github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/moby/sys/mount v0.3.4/go.mod h1:KcQJMbQdJHPlq5lcYT+/CjatWM4PuxKe+XLSVS4J6Os= github.com/moby/sys/mountinfo v0.7.2/go.mod h1:1YOa8w8Ih7uW0wALDUgT1dTTSBrZ+HiBLGws92L2RU4= github.com/moby/sys/reexec v0.1.0/go.mod h1:EqjBg8F3X7iZe5pU6nRZnYCMUTXoxsjiIfHup5wYIN8= -github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/neelance/astrewrite v0.0.0-20160511093645-99348263ae86 h1:D6paGObi5Wud7xg83MaEFyjxQB1W5bz5d0IFppr+ymk= github.com/neelance/astrewrite v0.0.0-20160511093645-99348263ae86/go.mod h1:kHJEU3ofeGjhHklVoIGuVj85JJwZ6kWPaJwCIxgnFmo= github.com/neelance/sourcemap v0.0.0-20200213170602-2833bce08e4c h1:bY6ktFuJkt+ZXkX0RChQch2FtHpWQLVS8Qo1YasiIVk= @@ -212,6 +210,7 @@ github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLA github.com/oasdiff/yaml3 v0.0.9/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o= github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58/go.mod h1:DXv8WO4yhMYhSNPKjeNKa5WY9YCIEBRbNzFFPJbWO6Y= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= +github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY= github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= @@ -232,7 +231,6 @@ github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5I github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= github.com/shurcooL/vfsgen v0.0.0-20200824052919-0d455de96546 h1:pXY9qYc/MP5zdvqWEUH6SjNiu7VhSjuVFTFiTcphaLU= github.com/shurcooL/vfsgen v0.0.0-20200824052919-0d455de96546/go.mod h1:TrYk7fJVaAttu97ZZKrO9UbRa8izdowaMIZcxYMbVaw= -github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= github.com/spf13/cobra v1.2.1 h1:+KmjbUw1hriSNMF55oPrkZcb27aECyrj8V2ytv7kWDw= github.com/spf13/cobra v1.2.1/go.mod h1:ExllRjgxM/piMAM+3tAZvg8fsklGAf3tPfi+i8t68Nk= github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= @@ -242,6 +240,7 @@ github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An github.com/spiffe/go-spiffe/v2 v2.5.0 h1:N2I01KCUkv1FAjZXJMwh95KK1ZIQLYbPfhaxw8WS0hE= github.com/spiffe/go-spiffe/v2 v2.5.0/go.mod h1:P+NxobPc6wXhVtINNtFjNWGBTreew1GBUCwT2wPmb7g= github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/urfave/cli/v2 v2.3.0 h1:qph92Y649prgesehzOrQjdWyxFOp/QVM+6imKHad91M= @@ -269,11 +268,9 @@ go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o= golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg= golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M= -golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8= -golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8= golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70= golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90/go.mod h1:xE1HEv6b+1SCZ5/uscMRjUBKtIxworgEcEi+/n9NQDQ= @@ -291,17 +288,14 @@ golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY= golang.org/x/net v0.28.0/go.mod h1:yqtgsTWOOnlGLG9GFRrK3++bGOUEkNBoHZc8MEDWPNg= golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k= golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= -golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20210420072515-93ed5bcd2bfe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= @@ -334,7 +328,6 @@ golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4= golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= -golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= @@ -380,6 +373,7 @@ gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8 gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= From 5bcb94a19fe28236837eeb4c20a214bc0b83607f Mon Sep 17 00:00:00 2001 From: yy Date: Wed, 29 Jul 2026 10:44:52 +0800 Subject: [PATCH 4/6] fix: ci lint --- core/controller/group-channel.go | 32 ++++++++++++--------- core/model/group_scope_modelconfig_cache.go | 12 ++++++-- core/model/groupchannel_cache.go | 12 ++++++-- 3 files changed, 39 insertions(+), 17 deletions(-) diff --git a/core/controller/group-channel.go b/core/controller/group-channel.go index b97bf1e8..44fc7f9e 100644 --- a/core/controller/group-channel.go +++ b/core/controller/group-channel.go @@ -1,10 +1,11 @@ package controller import ( + cryptorand "crypto/rand" "errors" "fmt" "maps" - "math/rand/v2" + "math/big" "net/http" "slices" "strconv" @@ -25,6 +26,19 @@ import ( log "github.com/sirupsen/logrus" ) +func secureShuffleStrings(values []string) { + for i := len(values) - 1; i > 0; i-- { + randomIndex, err := cryptorand.Int(cryptorand.Reader, big.NewInt(int64(i+1))) + if err != nil { + log.Warnf("failed to securely shuffle values: %s", err.Error()) + return + } + + j := int(randomIndex.Int64()) + values[i], values[j] = values[j], values[i] + } +} + type GroupChannelResponse struct { *model.GroupChannel AccessedAt time.Time `json:"accessed_at,omitempty"` @@ -1654,9 +1668,7 @@ func TestGroupChannelModels(c *gin.Context) { return } - rand.Shuffle(len(models), func(i, j int) { - models[i], models[j] = models[j], models[i] - }) + secureShuffleStrings(models) mc := model.LoadModelCaches() @@ -1776,9 +1788,7 @@ func TestGlobalGroupChannelModels(c *gin.Context) { return } - rand.Shuffle(len(models), func(i, j int) { - models[i], models[j] = models[j], models[i] - }) + secureShuffleStrings(models) mc := model.LoadModelCaches() @@ -1993,9 +2003,7 @@ func TestGroupChannelPreviewAll(c *gin.Context) { semaphore := make(chan struct{}, 5) - rand.Shuffle(len(models), func(i, j int) { - models[i], models[j] = models[j], models[i] - }) + secureShuffleStrings(models) mc := model.LoadModelCaches() @@ -2101,9 +2109,7 @@ func TestGlobalGroupChannelPreviewAll(c *gin.Context) { semaphore := make(chan struct{}, 5) - rand.Shuffle(len(models), func(i, j int) { - models[i], models[j] = models[j], models[i] - }) + secureShuffleStrings(models) mc := model.LoadModelCaches() diff --git a/core/model/group_scope_modelconfig_cache.go b/core/model/group_scope_modelconfig_cache.go index 2e28379b..91860e16 100644 --- a/core/model/group_scope_modelconfig_cache.go +++ b/core/model/group_scope_modelconfig_cache.go @@ -2,9 +2,10 @@ package model import ( "context" + cryptorand "crypto/rand" "errors" "maps" - "math/rand/v2" + "math/big" "time" "github.com/bytedance/sonic" @@ -267,7 +268,14 @@ func cacheSetGroupScopeModelConfigsRedis(cache *GroupScopeModelConfigsCache) err pipe.HSet(ctx, key, "l", list) - expireTime := SyncFrequency + time.Duration(rand.Int64N(60)-30)*time.Second + expireTime := SyncFrequency + randomJitter, randomErr := cryptorand.Int(cryptorand.Reader, big.NewInt(60)) + if randomErr != nil { + log.Warnf("failed to generate group scope model config cache expiry jitter: %s", randomErr) + } else { + expireTime += time.Duration(randomJitter.Int64()-30) * time.Second + } + pipe.Expire(ctx, key, expireTime) _, err = pipe.Exec(ctx) diff --git a/core/model/groupchannel_cache.go b/core/model/groupchannel_cache.go index a9c81193..61210799 100644 --- a/core/model/groupchannel_cache.go +++ b/core/model/groupchannel_cache.go @@ -2,8 +2,9 @@ package model import ( "context" + cryptorand "crypto/rand" "errors" - "math/rand/v2" + "math/big" "time" "github.com/bytedance/sonic" @@ -166,7 +167,14 @@ func cacheSetGroupChannelsRedis(cache *GroupChannelsCache) error { pipe.HSet(ctx, key, "c", channels) - expireTime := SyncFrequency + time.Duration(rand.Int64N(60)-30)*time.Second + expireTime := SyncFrequency + randomJitter, randomErr := cryptorand.Int(cryptorand.Reader, big.NewInt(60)) + if randomErr != nil { + log.Warnf("failed to generate group channel cache expiry jitter: %s", randomErr) + } else { + expireTime += time.Duration(randomJitter.Int64()-30) * time.Second + } + pipe.Expire(ctx, key, expireTime) _, err = pipe.Exec(ctx) From e923f45b3bd70c0d1b5a9daa2f7cce2e5644c360 Mon Sep 17 00:00:00 2001 From: yy Date: Wed, 29 Jul 2026 11:45:07 +0800 Subject: [PATCH 5/6] feat: ensure groups when creating group channels --- core/controller/group-channel.go | 8 +- core/controller/import.go | 4 +- core/docs/docs.go | 12 +- core/docs/swagger.json | 12 +- core/docs/swagger.yaml | 23 +- core/model/group.go | 24 +++ core/model/groupchannel.go | 35 ++- .../groupchannel_postgres_integration_test.go | 66 ++++++ core/model/groupchannel_test.go | 202 ++++++++++++++++++ 9 files changed, 354 insertions(+), 32 deletions(-) create mode 100644 core/model/groupchannel_postgres_integration_test.go diff --git a/core/controller/group-channel.go b/core/controller/group-channel.go index 44fc7f9e..9d3558b1 100644 --- a/core/controller/group-channel.go +++ b/core/controller/group-channel.go @@ -610,7 +610,7 @@ func (r *AddGroupChannelRequest) toGroupChannel(group string) (*model.GroupChann // AddGroupChannel godoc // // @Summary Add a group channel -// @Description Adds a group channel to a specific group +// @Description Adds a group channel to a specific group and atomically creates the group when it does not exist. Existing group configuration is preserved. // @Tags group-channel // @Accept json // @Produce json @@ -643,7 +643,7 @@ func AddGroupChannel(c *gin.Context) { // AddGlobalGroupChannel godoc // // @Summary Add a group channel -// @Description Adds a group channel from the global management view. The request body must include group_id. +// @Description Adds a group channel from the global management view and atomically creates the group when it does not exist. Existing group configuration is preserved. The request body must include group_id. // @Tags group_channel // @Accept json // @Produce json @@ -680,7 +680,7 @@ func AddGlobalGroupChannel(c *gin.Context) { // AddGroupChannels godoc // // @Summary Add multiple group channels -// @Description Adds group channels to a specific group +// @Description Adds group channels to a specific group and atomically creates the group when it does not exist. Existing group configuration is preserved. // @Tags group-channel // @Accept json // @Produce json @@ -718,7 +718,7 @@ func AddGroupChannels(c *gin.Context) { // AddGlobalGroupChannels godoc // // @Summary Add multiple group channels -// @Description Adds group channels from the global management view. Each request item must include group_id. +// @Description Adds group channels from the global management view and atomically creates missing groups. Existing group configuration is preserved. Each request item must include group_id. // @Tags group_channels // @Accept json // @Produce json diff --git a/core/controller/import.go b/core/controller/import.go index 68d6f816..52d8b3ad 100644 --- a/core/controller/import.go +++ b/core/controller/import.go @@ -310,7 +310,7 @@ func importGroupChannelsFromOneAPI( // ImportGlobalGroupChannelFromOneAPI godoc // // @Summary Import group channel from OneAPI -// @Description Imports group channels from OneAPI from the global management view. The request body must include group_id. +// @Description Imports group channels from OneAPI from the global management view and atomically creates the group when it does not exist. Existing group configuration is preserved. The request body must include group_id. // @Tags group_channels // @Produce json // @Security ApiKeyAuth @@ -330,7 +330,7 @@ func ImportGlobalGroupChannelFromOneAPI(c *gin.Context) { // ImportGroupChannelFromOneAPI godoc // // @Summary Import group channel from OneAPI -// @Description Imports group channels from OneAPI into a group +// @Description Imports group channels from OneAPI into a group and atomically creates the group when it does not exist. Existing group configuration is preserved. // @Tags group-channel // @Produce json // @Security ApiKeyAuth diff --git a/core/docs/docs.go b/core/docs/docs.go index c076058f..1bfae613 100644 --- a/core/docs/docs.go +++ b/core/docs/docs.go @@ -2638,7 +2638,7 @@ const docTemplate = `{ "ApiKeyAuth": [] } ], - "description": "Adds a group channel to a specific group", + "description": "Adds a group channel to a specific group and atomically creates the group when it does not exist. Existing group configuration is preserved.", "consumes": [ "application/json" ], @@ -3348,7 +3348,7 @@ const docTemplate = `{ "ApiKeyAuth": [] } ], - "description": "Adds group channels to a specific group", + "description": "Adds group channels to a specific group and atomically creates the group when it does not exist. Existing group configuration is preserved.", "consumes": [ "application/json" ], @@ -3510,7 +3510,7 @@ const docTemplate = `{ "ApiKeyAuth": [] } ], - "description": "Imports group channels from OneAPI into a group", + "description": "Imports group channels from OneAPI into a group and atomically creates the group when it does not exist. Existing group configuration is preserved.", "produces": [ "application/json" ], @@ -4898,7 +4898,7 @@ const docTemplate = `{ "ApiKeyAuth": [] } ], - "description": "Adds a group channel from the global management view. The request body must include group_id.", + "description": "Adds a group channel from the global management view and atomically creates the group when it does not exist. Existing group configuration is preserved. The request body must include group_id.", "consumes": [ "application/json" ], @@ -5537,7 +5537,7 @@ const docTemplate = `{ "ApiKeyAuth": [] } ], - "description": "Adds group channels from the global management view. Each request item must include group_id.", + "description": "Adds group channels from the global management view and atomically creates missing groups. Existing group configuration is preserved. Each request item must include group_id.", "consumes": [ "application/json" ], @@ -5678,7 +5678,7 @@ const docTemplate = `{ "ApiKeyAuth": [] } ], - "description": "Imports group channels from OneAPI from the global management view. The request body must include group_id.", + "description": "Imports group channels from OneAPI from the global management view and atomically creates the group when it does not exist. Existing group configuration is preserved. The request body must include group_id.", "produces": [ "application/json" ], diff --git a/core/docs/swagger.json b/core/docs/swagger.json index 0d73189a..48577bf5 100644 --- a/core/docs/swagger.json +++ b/core/docs/swagger.json @@ -2629,7 +2629,7 @@ "ApiKeyAuth": [] } ], - "description": "Adds a group channel to a specific group", + "description": "Adds a group channel to a specific group and atomically creates the group when it does not exist. Existing group configuration is preserved.", "consumes": [ "application/json" ], @@ -3339,7 +3339,7 @@ "ApiKeyAuth": [] } ], - "description": "Adds group channels to a specific group", + "description": "Adds group channels to a specific group and atomically creates the group when it does not exist. Existing group configuration is preserved.", "consumes": [ "application/json" ], @@ -3501,7 +3501,7 @@ "ApiKeyAuth": [] } ], - "description": "Imports group channels from OneAPI into a group", + "description": "Imports group channels from OneAPI into a group and atomically creates the group when it does not exist. Existing group configuration is preserved.", "produces": [ "application/json" ], @@ -4889,7 +4889,7 @@ "ApiKeyAuth": [] } ], - "description": "Adds a group channel from the global management view. The request body must include group_id.", + "description": "Adds a group channel from the global management view and atomically creates the group when it does not exist. Existing group configuration is preserved. The request body must include group_id.", "consumes": [ "application/json" ], @@ -5528,7 +5528,7 @@ "ApiKeyAuth": [] } ], - "description": "Adds group channels from the global management view. Each request item must include group_id.", + "description": "Adds group channels from the global management view and atomically creates missing groups. Existing group configuration is preserved. Each request item must include group_id.", "consumes": [ "application/json" ], @@ -5669,7 +5669,7 @@ "ApiKeyAuth": [] } ], - "description": "Imports group channels from OneAPI from the global management view. The request body must include group_id.", + "description": "Imports group channels from OneAPI from the global management view and atomically creates the group when it does not exist. Existing group configuration is preserved. The request body must include group_id.", "produces": [ "application/json" ], diff --git a/core/docs/swagger.yaml b/core/docs/swagger.yaml index 48ce6bf2..ba0f0ab9 100644 --- a/core/docs/swagger.yaml +++ b/core/docs/swagger.yaml @@ -5994,7 +5994,8 @@ paths: post: consumes: - application/json - description: Adds a group channel to a specific group + description: Adds a group channel to a specific group and atomically creates + the group when it does not exist. Existing group configuration is preserved. parameters: - description: Group ID in: path @@ -6428,7 +6429,8 @@ paths: post: consumes: - application/json - description: Adds group channels to a specific group + description: Adds group channels to a specific group and atomically creates + the group when it does not exist. Existing group configuration is preserved. parameters: - description: Group ID in: path @@ -6527,7 +6529,9 @@ paths: - group-channel /api/group/{group}/channels/import/oneapi: post: - description: Imports group channels from OneAPI into a group + description: Imports group channels from OneAPI into a group and atomically + creates the group when it does not exist. Existing group configuration is + preserved. parameters: - description: Group ID in: path @@ -7383,8 +7387,9 @@ paths: post: consumes: - application/json - description: Adds a group channel from the global management view. The request - body must include group_id. + description: Adds a group channel from the global management view and atomically + creates the group when it does not exist. Existing group configuration is + preserved. The request body must include group_id. parameters: - description: Group channel information in: body @@ -7773,7 +7778,8 @@ paths: post: consumes: - application/json - description: Adds group channels from the global management view. Each request + description: Adds group channels from the global management view and atomically + creates missing groups. Existing group configuration is preserved. Each request item must include group_id. parameters: - description: Group channel information @@ -7858,8 +7864,9 @@ paths: - group_channels /api/group_channels/import/oneapi: post: - description: Imports group channels from OneAPI from the global management view. - The request body must include group_id. + description: Imports group channels from OneAPI from the global management view + and atomically creates the group when it does not exist. Existing group configuration + is preserved. The request body must include group_id. parameters: - description: Import group channel from OneAPI request in: body diff --git a/core/model/group.go b/core/model/group.go index d4db18db..4cc93695 100644 --- a/core/model/group.go +++ b/core/model/group.go @@ -586,3 +586,27 @@ func SearchGroup( func CreateGroup(group *Group) error { return DB.Create(group).Error } + +func ensureGroups(tx *gorm.DB, groupIDs []string) error { + if len(groupIDs) == 0 { + return nil + } + + groups := make([]Group, 0, len(groupIDs)) + seen := make(map[string]struct{}, len(groupIDs)) + + for _, groupID := range groupIDs { + if groupID == "" { + return errors.New("group id is required") + } + + if _, ok := seen[groupID]; ok { + continue + } + + seen[groupID] = struct{}{} + groups = append(groups, Group{ID: groupID}) + } + + return tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&groups).Error +} diff --git a/core/model/groupchannel.go b/core/model/groupchannel.go index a33beed0..be0f44d1 100644 --- a/core/model/groupchannel.go +++ b/core/model/groupchannel.go @@ -576,15 +576,34 @@ func GetGroupChannelTests(group string, id int) ([]*GroupChannelTest, error) { } func BatchInsertGroupChannels(channels []*GroupChannel) (err error) { + groupIDs := make([]string, 0, len(channels)) + seenGroups := make(map[string]struct{}, len(channels)) + + for _, channel := range channels { + if channel == nil { + return errors.New("group channel is required") + } + + if channel.GroupID == "" { + return errors.New("group id is required") + } + + if _, ok := seenGroups[channel.GroupID]; ok { + continue + } + + seenGroups[channel.GroupID] = struct{}{} + groupIDs = append(groupIDs, channel.GroupID) + } + defer func() { if err == nil { - groups := map[string]struct{}{} - for _, channel := range channels { - groups[channel.GroupID] = struct{}{} - } + for _, groupID := range groupIDs { + if err := CacheDeleteGroup(groupID); err != nil { + log.Error("cache delete group failed: " + err.Error()) + } - for group := range groups { - if err := CacheDeleteGroupChannels(group); err != nil { + if err := CacheDeleteGroupChannels(groupID); err != nil { log.Error("cache delete group channels failed: " + err.Error()) } } @@ -592,6 +611,10 @@ func BatchInsertGroupChannels(channels []*GroupChannel) (err error) { }() return DB.Transaction(func(tx *gorm.DB) error { + if err := ensureGroups(tx, groupIDs); err != nil { + return err + } + return tx.Create(&channels).Error }) } diff --git a/core/model/groupchannel_postgres_integration_test.go b/core/model/groupchannel_postgres_integration_test.go new file mode 100644 index 00000000..ff5da5c1 --- /dev/null +++ b/core/model/groupchannel_postgres_integration_test.go @@ -0,0 +1,66 @@ +//nolint:testpackage +package model + +import ( + "sync" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestBatchInsertGroupChannelsPostgresConcurrentlyEnsuresOneGroup(t *testing.T) { + withTestPostgresStoreDB(t, func() { + require.NoError(t, DB.AutoMigrate(&Group{}, &GroupModelConfig{}, &GroupChannel{})) + + const ( + groupID = "group-postgres-concurrent" + workers = 8 + ) + + t.Cleanup(func() { + require.NoError(t, CacheDeleteGroup(groupID)) + require.NoError(t, CacheDeleteGroupChannels(groupID)) + }) + + start := make(chan struct{}) + errs := make(chan error, workers) + + var wg sync.WaitGroup + wg.Add(workers) + + for range workers { + go func() { + defer wg.Done() + + <-start + + errs <- BatchInsertGroupChannels([]*GroupChannel{ + { + GroupID: groupID, + Name: "concurrent", + Type: ChannelTypeOpenAI, + Status: ChannelStatusEnabled, + }, + }) + }() + } + + close(start) + wg.Wait() + close(errs) + + for err := range errs { + require.NoError(t, err) + } + + var groupCount int64 + require.NoError(t, DB.Model(&Group{}).Where("id = ?", groupID).Count(&groupCount).Error) + require.EqualValues(t, 1, groupCount) + + var channelCount int64 + require.NoError(t, DB.Model(&GroupChannel{}). + Where("group_id = ?", groupID). + Count(&channelCount).Error) + require.EqualValues(t, workers, channelCount) + }) +} diff --git a/core/model/groupchannel_test.go b/core/model/groupchannel_test.go index 76a0383f..4a10507c 100644 --- a/core/model/groupchannel_test.go +++ b/core/model/groupchannel_test.go @@ -3,6 +3,8 @@ package model import ( "path/filepath" + "strings" + "sync" "testing" "time" @@ -10,6 +12,206 @@ import ( "github.com/stretchr/testify/require" ) +func withGroupChannelInsertDB(t *testing.T, groupIDs []string, fn func()) { + t.Helper() + + oldDB := DB + oldRedisEnabled := common.RedisEnabled + + db, err := OpenSQLite(filepath.Join(t.TempDir(), "groupchannel_insert_test.db")) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&Group{}, &GroupModelConfig{}, &GroupChannel{})) + + DB = db + common.RedisEnabled = false + + t.Cleanup(func() { + DB = oldDB + common.RedisEnabled = oldRedisEnabled + + for _, groupID := range groupIDs { + require.NoError(t, CacheDeleteGroup(groupID)) + require.NoError(t, CacheDeleteGroupChannels(groupID)) + } + + sqlDB, err := db.DB() + require.NoError(t, err) + require.NoError(t, sqlDB.Close()) + }) + + fn() +} + +func TestBatchInsertGroupChannelsEnsuresGroups(t *testing.T) { + groupIDs := []string{"group-new", "group-existing"} + withGroupChannelInsertDB(t, groupIDs, func() { + _, err := CacheGetGroup("group-new") + require.Error(t, err) + + require.NoError(t, DB.Create(&Group{ + ID: "group-existing", + Status: GroupStatusDisabled, + RPMRatio: 1.5, + TPMRatio: 2.5, + AvailableSets: []string{"existing-set"}, + }).Error) + + require.NoError(t, BatchInsertGroupChannels([]*GroupChannel{ + { + ID: 1, + GroupID: "group-new", + Name: "new-1", + Type: ChannelTypeOpenAI, + Status: ChannelStatusEnabled, + }, + { + ID: 2, + GroupID: "group-new", + Name: "new-2", + Type: ChannelTypeOpenAI, + Status: ChannelStatusEnabled, + }, + { + ID: 3, + GroupID: "group-existing", + Name: "existing", + Type: ChannelTypeOpenAI, + Status: ChannelStatusEnabled, + }, + })) + + var newGroup Group + require.NoError(t, DB.First(&newGroup, "id = ?", "group-new").Error) + require.Equal(t, GroupStatusEnabled, newGroup.Status) + + var existingGroup Group + require.NoError(t, DB.First(&existingGroup, "id = ?", "group-existing").Error) + require.Equal(t, GroupStatusDisabled, existingGroup.Status) + require.Equal(t, 1.5, existingGroup.RPMRatio) + require.Equal(t, 2.5, existingGroup.TPMRatio) + require.Equal(t, []string{"existing-set"}, existingGroup.AvailableSets) + + var groupCount int64 + require.NoError(t, DB.Model(&Group{}).Count(&groupCount).Error) + require.EqualValues(t, 2, groupCount) + + var channelCount int64 + require.NoError(t, DB.Model(&GroupChannel{}).Count(&channelCount).Error) + require.EqualValues(t, 3, channelCount) + + cachedGroup, err := CacheGetGroup("group-new") + require.NoError(t, err) + require.Equal(t, "group-new", cachedGroup.ID) + }) +} + +func TestBatchInsertGroupChannelsRollsBackEnsuredGroup(t *testing.T) { + groupIDs := []string{"group-existing", "group-rollback"} + withGroupChannelInsertDB(t, groupIDs, func() { + require.NoError(t, DB.Create(&Group{ID: "group-existing"}).Error) + require.NoError(t, DB.Create(&GroupChannel{ + ID: 7, + GroupID: "group-existing", + Name: "existing", + Type: ChannelTypeOpenAI, + Status: ChannelStatusEnabled, + }).Error) + + err := BatchInsertGroupChannels([]*GroupChannel{ + { + ID: 7, + GroupID: "group-rollback", + Name: "duplicate-id", + Type: ChannelTypeOpenAI, + Status: ChannelStatusEnabled, + }, + }) + require.Error(t, err) + + var groupCount int64 + require.NoError(t, DB.Model(&Group{}). + Where("id = ?", "group-rollback"). + Count(&groupCount).Error) + require.Zero(t, groupCount) + }) +} + +func TestBatchInsertGroupChannelsRejectsInvalidGroups(t *testing.T) { + withGroupChannelInsertDB(t, nil, func() { + for name, channel := range map[string]*GroupChannel{ + "nil channel": nil, + "empty group": { + Name: "empty", + Type: ChannelTypeOpenAI, + }, + "long group": { + GroupID: strings.Repeat("g", 65), + Name: "long", + Type: ChannelTypeOpenAI, + }, + } { + t.Run(name, func(t *testing.T) { + require.Error(t, BatchInsertGroupChannels([]*GroupChannel{channel})) + }) + } + + var groupCount int64 + require.NoError(t, DB.Model(&Group{}).Count(&groupCount).Error) + require.Zero(t, groupCount) + + var channelCount int64 + require.NoError(t, DB.Model(&GroupChannel{}).Count(&channelCount).Error) + require.Zero(t, channelCount) + }) +} + +func TestBatchInsertGroupChannelsConcurrentlyEnsuresOneGroup(t *testing.T) { + const groupID = "group-concurrent" + withGroupChannelInsertDB(t, []string{groupID}, func() { + start := make(chan struct{}) + errs := make(chan error, 2) + + var wg sync.WaitGroup + for i := 1; i <= 2; i++ { + wg.Add(1) + + go func(id int) { + defer wg.Done() + + <-start + + errs <- BatchInsertGroupChannels([]*GroupChannel{ + { + ID: id, + GroupID: groupID, + Name: "concurrent", + Type: ChannelTypeOpenAI, + Status: ChannelStatusEnabled, + }, + }) + }(i) + } + + close(start) + wg.Wait() + close(errs) + + for err := range errs { + require.NoError(t, err) + } + + var groupCount int64 + require.NoError(t, DB.Model(&Group{}).Where("id = ?", groupID).Count(&groupCount).Error) + require.EqualValues(t, 1, groupCount) + + var channelCount int64 + require.NoError(t, DB.Model(&GroupChannel{}). + Where("group_id = ?", groupID). + Count(&channelCount).Error) + require.EqualValues(t, 2, channelCount) + }) +} + func TestGroupChannelPartialUpdatesPassHooks(t *testing.T) { oldDB := DB oldRedisEnabled := common.RedisEnabled From c70b376bcc2b0ce60539b1f92025411fc2f67635 Mon Sep 17 00:00:00 2001 From: yy Date: Wed, 29 Jul 2026 11:58:37 +0800 Subject: [PATCH 6/6] fix: satisfy latest golangci lint --- core/model/group_scope_modelconfig_cache.go | 1 + core/model/groupchannel_cache.go | 1 + 2 files changed, 2 insertions(+) diff --git a/core/model/group_scope_modelconfig_cache.go b/core/model/group_scope_modelconfig_cache.go index 91860e16..02662335 100644 --- a/core/model/group_scope_modelconfig_cache.go +++ b/core/model/group_scope_modelconfig_cache.go @@ -269,6 +269,7 @@ func cacheSetGroupScopeModelConfigsRedis(cache *GroupScopeModelConfigsCache) err pipe.HSet(ctx, key, "l", list) expireTime := SyncFrequency + randomJitter, randomErr := cryptorand.Int(cryptorand.Reader, big.NewInt(60)) if randomErr != nil { log.Warnf("failed to generate group scope model config cache expiry jitter: %s", randomErr) diff --git a/core/model/groupchannel_cache.go b/core/model/groupchannel_cache.go index 61210799..ec47349f 100644 --- a/core/model/groupchannel_cache.go +++ b/core/model/groupchannel_cache.go @@ -168,6 +168,7 @@ func cacheSetGroupChannelsRedis(cache *GroupChannelsCache) error { pipe.HSet(ctx, key, "c", channels) expireTime := SyncFrequency + randomJitter, randomErr := cryptorand.Int(cryptorand.Reader, big.NewInt(60)) if randomErr != nil { log.Warnf("failed to generate group channel cache expiry jitter: %s", randomErr)