From 1ee929e4b9b1ec9c5aba45363f9322c845d948a6 Mon Sep 17 00:00:00 2001 From: haruka <1628615876@qq.com> Date: Wed, 26 Aug 2026 11:16:12 +0800 Subject: [PATCH 001/145] fix: broadcast channel cache invalidations --- backend/cmd/server/wire_gen.go | 3 +- backend/go.sum | 2 + .../handler/openai_gateway_handler_test.go | 2 +- backend/internal/repository/channel_cache.go | 53 ++++++++++++++++ .../internal/repository/channel_cache_test.go | 44 +++++++++++++ backend/internal/repository/wire.go | 1 + .../billing_token_cost_request_test.go | 2 +- .../service/channel_available_test.go | 4 +- backend/internal/service/channel_service.go | 44 ++++++++++++- .../internal/service/channel_service_test.go | 62 ++++++++++++++++++- .../service/model_plaza_service_test.go | 2 +- .../service/model_pricing_resolver_test.go | 4 +- 12 files changed, 210 insertions(+), 13 deletions(-) create mode 100644 backend/internal/repository/channel_cache.go create mode 100644 backend/internal/repository/channel_cache_test.go diff --git a/backend/cmd/server/wire_gen.go b/backend/cmd/server/wire_gen.go index b2774d7232..675552de3e 100644 --- a/backend/cmd/server/wire_gen.go +++ b/backend/cmd/server/wire_gen.go @@ -137,7 +137,8 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { tlsFingerprintProfileCache := repository.NewTLSFingerprintProfileCache(redisClient) tlsFingerprintProfileService := service.NewTLSFingerprintProfileService(tlsFingerprintProfileRepository, tlsFingerprintProfileCache) channelRepository := repository.NewChannelRepository(db) - channelService := service.NewChannelService(channelRepository, groupRepository, apiKeyAuthCacheInvalidator, pricingService) + channelCachePubSub := repository.NewChannelCache(redisClient) + channelService := service.NewChannelService(channelRepository, groupRepository, apiKeyAuthCacheInvalidator, pricingService, channelCachePubSub) modelPricingResolver := service.NewModelPricingResolver(channelService, billingService) compositeModelRouteRepository := repository.NewCompositeModelRouteRepository(client) compositeRouteResolver := service.NewCompositeRouteResolver(compositeModelRouteRepository) diff --git a/backend/go.sum b/backend/go.sum index cbb257f12f..3c46484df8 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -255,6 +255,8 @@ github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba/go.mod h1:E github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE= +github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/wire v0.7.0 h1:JxUKI6+CVBgCO2WToKy/nQk0sS+amI9z9EjVmdaocj4= diff --git a/backend/internal/handler/openai_gateway_handler_test.go b/backend/internal/handler/openai_gateway_handler_test.go index 86c56d4901..00303e0ff2 100644 --- a/backend/internal/handler/openai_gateway_handler_test.go +++ b/backend/internal/handler/openai_gateway_handler_test.go @@ -2868,7 +2868,7 @@ func runOpenAIResponsesWebSocketUsageLogCase(t *testing.T, tc openAIResponsesWSU BillingModelSource: tc.billingModelSource, }}, groupPlatforms: map[int64]string{groupID: service.PlatformOpenAI}, - }, nil, nil, nil) + }, nil, nil, nil, nil) } billingCacheSvc := service.NewBillingCacheService(nil, nil, nil, nil, nil, nil, cfg, nil) diff --git a/backend/internal/repository/channel_cache.go b/backend/internal/repository/channel_cache.go new file mode 100644 index 0000000000..ff8e75b61e --- /dev/null +++ b/backend/internal/repository/channel_cache.go @@ -0,0 +1,53 @@ +package repository + +import ( + "context" + "log/slog" + + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/redis/go-redis/v9" +) + +const channelCachePubSubKey = "channel_cache_updated" + +type channelCache struct { + rdb *redis.Client +} + +// NewChannelCache creates the Redis-backed channel cache invalidation bus. +func NewChannelCache(rdb *redis.Client) service.ChannelCachePubSub { + return &channelCache{rdb: rdb} +} + +// NotifyUpdate notifies all instances to invalidate their local channel cache. +func (c *channelCache) NotifyUpdate(ctx context.Context) error { + return c.rdb.Publish(ctx, channelCachePubSubKey, "refresh").Err() +} + +// SubscribeUpdates subscribes to channel cache invalidation notifications. +func (c *channelCache) SubscribeUpdates(ctx context.Context, handler func()) { + go func() { + pubsub := c.rdb.Subscribe(ctx, channelCachePubSubKey) + defer func() { + if err := pubsub.Close(); err != nil { + slog.Warn("failed to close channel cache subscriber", "error", err) + } + }() + + messages := pubsub.Channel() + for { + select { + case <-ctx.Done(): + return + case message, ok := <-messages: + if !ok { + slog.Warn("channel cache subscriber stopped", "reason", "channel_closed") + return + } + if message != nil { + handler() + } + } + } + }() +} diff --git a/backend/internal/repository/channel_cache_test.go b/backend/internal/repository/channel_cache_test.go new file mode 100644 index 0000000000..89c45249ef --- /dev/null +++ b/backend/internal/repository/channel_cache_test.go @@ -0,0 +1,44 @@ +package repository + +import ( + "context" + "testing" + "time" + + "github.com/alicebob/miniredis/v2" + "github.com/redis/go-redis/v9" + "github.com/stretchr/testify/require" +) + +func TestChannelCachePubSub(t *testing.T) { + redisServer := miniredis.RunT(t) + publisherRedis := redis.NewClient(&redis.Options{Addr: redisServer.Addr()}) + subscriberRedis := redis.NewClient(&redis.Options{Addr: redisServer.Addr()}) + t.Cleanup(func() { + _ = publisherRedis.Close() + _ = subscriberRedis.Close() + }) + + publisher := NewChannelCache(publisherRedis) + subscriber := NewChannelCache(subscriberRedis) + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + received := make(chan struct{}, 1) + subscriber.SubscribeUpdates(ctx, func() { + select { + case received <- struct{}{}: + default: + } + }) + + require.Eventually(t, func() bool { + return redisServer.PubSubNumSub(channelCachePubSubKey)[channelCachePubSubKey] == 1 + }, time.Second, 10*time.Millisecond) + require.NoError(t, publisher.NotifyUpdate(context.Background())) + + select { + case <-received: + case <-time.After(time.Second): + t.Fatal("channel cache invalidation notification was not received") + } +} diff --git a/backend/internal/repository/wire.go b/backend/internal/repository/wire.go index e6904f833c..eae41bde41 100644 --- a/backend/internal/repository/wire.go +++ b/backend/internal/repository/wire.go @@ -137,6 +137,7 @@ var ProviderSet = wire.NewSet( NewRefreshTokenCache, NewErrorPassthroughCache, NewTLSFingerprintProfileCache, + NewChannelCache, NewContentModerationHashCache, // Encryptors diff --git a/backend/internal/service/billing_token_cost_request_test.go b/backend/internal/service/billing_token_cost_request_test.go index 8392b8cf49..76809514ab 100644 --- a/backend/internal/service/billing_token_cost_request_test.go +++ b/backend/internal/service/billing_token_cost_request_test.go @@ -23,7 +23,7 @@ func newTokenCostTestEnv(t *testing.T, groupPlatform string, pricing []ChannelMo return map[int64]string{100: groupPlatform}, nil }, } - cs := NewChannelService(repo, nil, nil, nil) + cs := NewChannelService(repo, nil, nil, nil, nil) bs := NewBillingService(&config.Config{}, catalog) return bs, NewModelPricingResolver(cs, bs) } diff --git a/backend/internal/service/channel_available_test.go b/backend/internal/service/channel_available_test.go index 2b7b23f7e5..d382c5fc2d 100644 --- a/backend/internal/service/channel_available_test.go +++ b/backend/internal/service/channel_available_test.go @@ -75,7 +75,7 @@ func newAvailableChannelService(channels []Channel, groupRepo GroupRepository) * repo := &mockChannelRepository{ listAllFn: func(ctx context.Context) ([]Channel, error) { return channels, nil }, } - return NewChannelService(repo, groupRepo, nil, nil) + return NewChannelService(repo, groupRepo, nil, nil, nil) } func TestListAvailable_EmptyActiveGroups_NoGroupsAttached(t *testing.T) { @@ -134,7 +134,7 @@ func TestListAvailable_ListAllErrorPropagates(t *testing.T) { listAllFn: func(ctx context.Context) ([]Channel, error) { return nil, sentinel }, } groupRepo := &stubGroupRepoForAvailable{} - svc := NewChannelService(repo, groupRepo, nil, nil) + svc := NewChannelService(repo, groupRepo, nil, nil, nil) out, err := svc.ListAvailable(context.Background()) require.Nil(t, out) require.ErrorIs(t, err, sentinel) diff --git a/backend/internal/service/channel_service.go b/backend/internal/service/channel_service.go index b3fe7dc63a..f32b297679 100644 --- a/backend/internal/service/channel_service.go +++ b/backend/internal/service/channel_service.go @@ -147,14 +147,22 @@ const ( channelCacheTTL = 10 * time.Minute channelErrorTTL = 5 * time.Second // DB 错误时的短缓存 channelCacheDBTimeout = 10 * time.Second + channelCacheNotifyTTL = 3 * time.Second ) +// ChannelCachePubSub broadcasts channel cache invalidations between instances. +type ChannelCachePubSub interface { + NotifyUpdate(ctx context.Context) error + SubscribeUpdates(ctx context.Context, handler func()) +} + // ChannelService 渠道管理服务 type ChannelService struct { repo ChannelRepository groupRepo GroupRepository authCacheInvalidator APIKeyAuthCacheInvalidator pricingService *PricingService // 用于「可用渠道」展示时回落到全局定价;可为 nil(测试场景) + cachePubSub ChannelCachePubSub cache atomic.Value // *channelCache cacheSF singleflight.Group @@ -163,13 +171,15 @@ type ChannelService struct { // NewChannelService 创建渠道服务实例。 // pricingService 仅供 ListAvailable 在渠道未配置定价时回落到全局 LiteLLM 数据; // 计费热路径走独立的 ModelPricingResolver,与此参数无关。可传 nil。 -func NewChannelService(repo ChannelRepository, groupRepo GroupRepository, authCacheInvalidator APIKeyAuthCacheInvalidator, pricingService *PricingService) *ChannelService { +func NewChannelService(repo ChannelRepository, groupRepo GroupRepository, authCacheInvalidator APIKeyAuthCacheInvalidator, pricingService *PricingService, cachePubSub ChannelCachePubSub) *ChannelService { s := &ChannelService{ repo: repo, groupRepo: groupRepo, authCacheInvalidator: authCacheInvalidator, pricingService: pricingService, + cachePubSub: cachePubSub, } + s.subscribeCacheUpdates(context.Background()) return s } @@ -383,13 +393,41 @@ func (s *ChannelService) InvalidateCache() { } func (s *ChannelService) invalidateCache() { - s.cache.Store((*channelCache)(nil)) - s.cacheSF.Forget("channel_cache") + s.clearCache() // 主动重建缓存,确保 CRUD 后立即生效 if _, err := s.buildCache(context.Background()); err != nil { slog.Warn("failed to rebuild channel cache after invalidation", "error", err) } + + s.notifyCacheUpdate() +} + +// clearCache clears only the in-process snapshot. Keeping this separate from +// invalidateCache prevents notifications received from Redis from being +// published again in a loop. +func (s *ChannelService) clearCache() { + s.cache.Store((*channelCache)(nil)) + s.cacheSF.Forget("channel_cache") +} + +func (s *ChannelService) notifyCacheUpdate() { + if s.cachePubSub == nil { + return + } + + ctx, cancel := context.WithTimeout(context.Background(), channelCacheNotifyTTL) + defer cancel() + if err := s.cachePubSub.NotifyUpdate(ctx); err != nil { + slog.Warn("failed to publish channel cache invalidation", "error", err) + } +} + +func (s *ChannelService) subscribeCacheUpdates(ctx context.Context) { + if s.cachePubSub == nil { + return + } + s.cachePubSub.SubscribeUpdates(ctx, s.clearCache) } // matchWildcard 在通配符定价中查找匹配项(最先匹配到优先) diff --git a/backend/internal/service/channel_service_test.go b/backend/internal/service/channel_service_test.go index e443b80b59..ec5613f009 100644 --- a/backend/internal/service/channel_service_test.go +++ b/backend/internal/service/channel_service_test.go @@ -6,6 +6,7 @@ import ( "context" "errors" "net/http" + "sync" "testing" infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" @@ -175,6 +176,27 @@ type mockChannelAuthCacheInvalidator struct { invalidatedUserIDs []int64 } +type mockChannelCachePubSub struct { + mu sync.Mutex + handlers []func() +} + +func (m *mockChannelCachePubSub) NotifyUpdate(context.Context) error { + m.mu.Lock() + handlers := append([]func(){}, m.handlers...) + m.mu.Unlock() + for _, handler := range handlers { + handler() + } + return nil +} + +func (m *mockChannelCachePubSub) SubscribeUpdates(_ context.Context, handler func()) { + m.mu.Lock() + m.handlers = append(m.handlers, handler) + m.mu.Unlock() +} + func (m *mockChannelAuthCacheInvalidator) InvalidateAuthCacheByKey(_ context.Context, key string) { m.invalidatedKeys = append(m.invalidatedKeys, key) } @@ -192,11 +214,11 @@ func (m *mockChannelAuthCacheInvalidator) InvalidateAuthCacheByGroupID(_ context // --------------------------------------------------------------------------- func newTestChannelService(repo *mockChannelRepository) *ChannelService { - return NewChannelService(repo, nil, nil, nil) + return NewChannelService(repo, nil, nil, nil, nil) } func newTestChannelServiceWithAuth(repo *mockChannelRepository, auth *mockChannelAuthCacheInvalidator) *ChannelService { - return NewChannelService(repo, nil, auth, nil) + return NewChannelService(repo, nil, auth, nil, nil) } // makeStandardRepo returns a repo that serves one active channel with anthropic pricing @@ -1384,6 +1406,42 @@ func TestInvalidateCache(t *testing.T) { require.Equal(t, 2, callCount) // rebuilt } +func TestInvalidateCachePublishesToOtherInstances(t *testing.T) { + cachePubSub := &mockChannelCachePubSub{} + publisher := NewChannelService(&mockChannelRepository{}, nil, nil, nil, cachePubSub) + updated := false + subscriberRepo := &mockChannelRepository{ + listAllFn: func(_ context.Context) ([]Channel, error) { + model := "old-model" + if updated { + model = "new-model" + } + return []Channel{{ + ID: 1, + Status: StatusActive, + GroupIDs: []int64{10}, + ModelPricing: []ChannelModelPricing{{ + ID: 100, + Platform: PlatformAnthropic, + Models: []string{model}, + }}, + }}, nil + }, + getGroupPlatformsFn: func(_ context.Context, _ []int64) (map[int64]string, error) { + return map[int64]string{10: PlatformAnthropic}, nil + }, + } + subscriber := NewChannelService(subscriberRepo, nil, nil, nil, cachePubSub) + + require.NotNil(t, subscriber.GetChannelModelPricing(context.Background(), 10, "old-model")) + require.Nil(t, subscriber.GetChannelModelPricing(context.Background(), 10, "new-model")) + + updated = true + publisher.invalidateCache() + + require.NotNil(t, subscriber.GetChannelModelPricing(context.Background(), 10, "new-model")) +} + // =========================================================================== // 5. CRUD Methods // =========================================================================== diff --git a/backend/internal/service/model_plaza_service_test.go b/backend/internal/service/model_plaza_service_test.go index d54aca70ad..bb9d87b4b9 100644 --- a/backend/internal/service/model_plaza_service_test.go +++ b/backend/internal/service/model_plaza_service_test.go @@ -339,7 +339,7 @@ func newPlazaServiceWithBilling(channels []Channel, groups []Group, groupPlatfor return groupPlatforms, nil }, } - cs := NewChannelService(repo, nil, nil, nil) + cs := NewChannelService(repo, nil, nil, nil, nil) bs := NewBillingService(&config.Config{}, catalog) return NewModelPlazaService(repo, &stubGroupRepoForAvailable{activeGroups: groups}, catalog, bs, NewModelPricingResolver(cs, bs)) } diff --git a/backend/internal/service/model_pricing_resolver_test.go b/backend/internal/service/model_pricing_resolver_test.go index 3fb3e1c63f..a0373a1247 100644 --- a/backend/internal/service/model_pricing_resolver_test.go +++ b/backend/internal/service/model_pricing_resolver_test.go @@ -230,7 +230,7 @@ func newResolverWithChannel(t *testing.T, pricing []ChannelModelPricing) *ModelP return map[int64]string{groupID: "anthropic"}, nil }, } - cs := NewChannelService(repo, nil, nil, nil) + cs := NewChannelService(repo, nil, nil, nil, nil) bs := newTestBillingServiceForResolver() return NewModelPricingResolver(cs, bs) } @@ -566,7 +566,7 @@ func TestResolve_WithChannelOverride_CacheError(t *testing.T) { return nil, errors.New("database unavailable") }, } - cs := NewChannelService(repo, nil, nil, nil) + cs := NewChannelService(repo, nil, nil, nil, nil) bs := newTestBillingServiceForResolver() r := NewModelPricingResolver(cs, bs) From 6d339ec93b35fb1e7b51388519c415c0ed068d54 Mon Sep 17 00:00:00 2001 From: spongehah <2635879218@qq.com> Date: Fri, 28 Aug 2026 14:38:15 +0800 Subject: [PATCH 002/145] fix: honor persisted cooldown when dropping in-process account blocks - Treat TempUnschedulableUntil/RateLimitResetAt/OverloadUntil as source of truth during account selection - Drop stale local account-level runtime blocks with generation+deadline CAS - Leave model-scoped transient and Grok quota blocks unchanged --- .../openai_account_runtime_block_fastpath.go | 88 ++++++++++++++++++- ...nai_account_runtime_block_fastpath_test.go | 39 ++++++++ .../openai_account_runtime_transient_test.go | 6 +- .../openai_upstream_transport_error.go | 5 +- 4 files changed, 133 insertions(+), 5 deletions(-) diff --git a/backend/internal/service/openai_account_runtime_block_fastpath.go b/backend/internal/service/openai_account_runtime_block_fastpath.go index 7b59c28fd4..482a7cad88 100644 --- a/backend/internal/service/openai_account_runtime_block_fastpath.go +++ b/backend/internal/service/openai_account_runtime_block_fastpath.go @@ -469,8 +469,94 @@ func (s *OpenAIGatewayService) isOpenAIAccountModelRuntimeBlocked(account *Accou return state.isBlocked(account.ID, openAIAccountModelTransientModel(canonicalModel), time.Now()) } +func accountPersistedSchedulingCooldownActive(account *Account) bool { + if account == nil { + return false + } + now := time.Now() + if account.TempUnschedulableUntil != nil && now.Before(*account.TempUnschedulableUntil) { + return true + } + if account.RateLimitResetAt != nil && now.Before(*account.RateLimitResetAt) { + return true + } + if account.OverloadUntil != nil && now.Before(*account.OverloadUntil) { + return true + } + return false +} + +type openAIAccountRuntimeBlockSnapshot struct { + until time.Time + generation uint64 + blocked bool +} + +func (s *OpenAIGatewayService) peekOpenAIAccountRuntimeBlock(account *Account) openAIAccountRuntimeBlockSnapshot { + if s == nil || !isOpenAIAccount(account) { + return openAIAccountRuntimeBlockSnapshot{} + } + mu := s.openAIAccountRuntimeBlockLock(account.ID) + mu.Lock() + defer mu.Unlock() + value, ok := s.openaiAccountRuntimeBlockUntil.Load(account.ID) + if !ok { + return openAIAccountRuntimeBlockSnapshot{} + } + until, isTime := value.(time.Time) + if !isTime || until.IsZero() || !time.Now().Before(until) { + s.openaiAccountRuntimeBlockUntil.Delete(account.ID) + s.openaiOAuth429RetryStartedAt.Delete(account.ID) + s.openaiAccountRuntimeBlockGeneration.Store(account.ID, s.openaiAccountRuntimeBlockSequence.Add(1)) + return openAIAccountRuntimeBlockSnapshot{} + } + generation, _ := s.openaiAccountRuntimeBlockGeneration.Load(account.ID) + gen, _ := generation.(uint64) + return openAIAccountRuntimeBlockSnapshot{until: until, generation: gen, blocked: true} +} + +// clearOpenAIAccountRuntimeBlockIfUnchanged deletes the in-process account block +// only when generation and deadline are unchanged. A newer block installed after +// peek must be kept even if its deadline happens to match. +func (s *OpenAIGatewayService) clearOpenAIAccountRuntimeBlockIfUnchanged(accountID int64, snapshot openAIAccountRuntimeBlockSnapshot) { + if s == nil || accountID <= 0 || !snapshot.blocked { + return + } + mu := s.openAIAccountRuntimeBlockLock(accountID) + mu.Lock() + defer mu.Unlock() + generation, ok := s.openaiAccountRuntimeBlockGeneration.Load(accountID) + if !ok || generation != snapshot.generation { + return + } + current, ok := s.openaiAccountRuntimeBlockUntil.Load(accountID) + currentUntil, isTime := current.(time.Time) + if !ok || !isTime || !currentUntil.Equal(snapshot.until) { + return + } + s.openaiAccountRuntimeBlockUntil.Delete(accountID) + s.openaiOAuth429RetryStartedAt.Delete(accountID) + s.openaiAccountRuntimeBlockGeneration.Store(accountID, s.openaiAccountRuntimeBlockSequence.Add(1)) +} + +// isOpenAIAccountRequestRuntimeBlocked treats persisted cooldown fields on the +// scheduling Account as source of truth. When TempUnschedulableUntil, +// RateLimitResetAt, and OverloadUntil are all inactive, a stale local account +// block is dropped with generation+deadline CAS. Model-scoped transient blocks +// are left alone. This is fail-open if a DB write failed or the snapshot has +// not caught up yet: empty cooldown fields drop the local account-level block. func (s *OpenAIGatewayService) isOpenAIAccountRequestRuntimeBlocked(account *Account, requestedModel string) bool { - return s != nil && (s.isOpenAIAccountRuntimeBlocked(account) || s.isOpenAIAccountModelRuntimeBlocked(account, requestedModel)) + if s == nil { + return false + } + snapshot := s.peekOpenAIAccountRuntimeBlock(account) + if snapshot.blocked { + if accountPersistedSchedulingCooldownActive(account) { + return true + } + s.clearOpenAIAccountRuntimeBlockIfUnchanged(account.ID, snapshot) + } + return s.isOpenAIAccountModelRuntimeBlocked(account, requestedModel) } func (s *OpenAIGatewayService) recordOpenAIOAuth429() { diff --git a/backend/internal/service/openai_account_runtime_block_fastpath_test.go b/backend/internal/service/openai_account_runtime_block_fastpath_test.go index 89d8c5b5ec..7446be26fd 100644 --- a/backend/internal/service/openai_account_runtime_block_fastpath_test.go +++ b/backend/internal/service/openai_account_runtime_block_fastpath_test.go @@ -545,6 +545,45 @@ func TestOpenAIRuntimeBlock_ClearAccountSchedulingBlock(t *testing.T) { require.False(t, svc.isOpenAIAccountRuntimeBlocked(account)) } +func TestRuntimeBlockHonorsClearedPersistedCooldown(t *testing.T) { + svc := &OpenAIGatewayService{} + account := &Account{ID: 92, Platform: PlatformGrok, Type: AccountTypeOAuth, Status: StatusActive, Schedulable: true} + svc.BlockAccountScheduling(account, time.Now().Add(30*time.Minute), "grok payment required") + require.False(t, svc.isOpenAIAccountRequestRuntimeBlocked(account, "grok-3")) + require.False(t, svc.isOpenAIAccountRuntimeBlocked(account)) +} + +func TestRuntimeBlockConditionalClearSkipsNewerGeneration(t *testing.T) { + svc := &OpenAIGatewayService{} + account := &Account{ID: 94, Platform: PlatformGrok, Type: AccountTypeOAuth, Status: StatusActive, Schedulable: true} + firstUntil := time.Now().Add(10 * time.Minute) + svc.BlockAccountScheduling(account, firstUntil, "stale") + snapshot := svc.peekOpenAIAccountRuntimeBlock(account) + require.True(t, snapshot.blocked) + newerUntil := time.Now().Add(30 * time.Minute) + svc.BlockAccountScheduling(account, newerUntil, "fresh") + svc.clearOpenAIAccountRuntimeBlockIfUnchanged(account.ID, snapshot) + require.True(t, svc.isOpenAIAccountRuntimeBlocked(account)) + require.False(t, svc.isOpenAIAccountRequestRuntimeBlocked(account, "grok-3")) + require.False(t, svc.isOpenAIAccountRuntimeBlocked(account)) +} + +func TestRuntimeBlockKeepsActivePersistedCooldown(t *testing.T) { + svc := &OpenAIGatewayService{} + until := time.Now().Add(30 * time.Minute) + account := &Account{ + ID: 93, + Platform: PlatformGrok, + Type: AccountTypeOAuth, + Status: StatusActive, + Schedulable: true, + TempUnschedulableUntil: &until, + } + svc.BlockAccountScheduling(account, until, "grok payment required") + require.True(t, svc.isOpenAIAccountRequestRuntimeBlocked(account, "grok-3")) + require.True(t, svc.isOpenAIAccountRuntimeBlocked(account)) +} + func TestShouldStopOpenAIOAuth429Failover_AfterBoundedFullWindows(t *testing.T) { svc := &OpenAIGatewayService{} account := &Account{ID: 42, Platform: PlatformOpenAI, Type: AccountTypeOAuth} diff --git a/backend/internal/service/openai_account_runtime_transient_test.go b/backend/internal/service/openai_account_runtime_transient_test.go index 4ba1f16a55..b25794fe77 100644 --- a/backend/internal/service/openai_account_runtime_transient_test.go +++ b/backend/internal/service/openai_account_runtime_transient_test.go @@ -111,6 +111,8 @@ func TestHandleOpenAITransientError_HardDisableStillBlocksWholeAccount(t *testin svc.BlockAccountScheduling(account, time.Now().Add(time.Minute), "upstream_disable") - require.True(t, svc.isOpenAIAccountRequestRuntimeBlocked(account, "gpt-5.5")) - require.True(t, svc.isOpenAIAccountRequestRuntimeBlocked(account, "gpt-5.6-sol")) + require.True(t, svc.isOpenAIAccountRuntimeBlocked(account)) + require.False(t, svc.isOpenAIAccountRequestRuntimeBlocked(account, "gpt-5.5")) + require.False(t, svc.isOpenAIAccountRequestRuntimeBlocked(account, "gpt-5.6-sol")) + require.False(t, svc.isOpenAIAccountRuntimeBlocked(account)) } diff --git a/backend/internal/service/openai_upstream_transport_error.go b/backend/internal/service/openai_upstream_transport_error.go index ed5c5f9c23..22a1f6e6a7 100644 --- a/backend/internal/service/openai_upstream_transport_error.go +++ b/backend/internal/service/openai_upstream_transport_error.go @@ -163,8 +163,9 @@ func (s *OpenAIGatewayService) tempUnscheduleOpenAITransportError(ctx context.Co until := time.Now().Add(openAITransportErrorTempUnschedDuration) reason := "upstream transport error (proxy/network): " + safeErr - // Immediate in-memory block (honoured by the scheduler at selection time), - // effective even if the DB write below fails or the account cache lags. + // Immediate in-memory block so this process skips the account until the + // persisted cooldown is visible on the scheduling Account. Selection is + // fail-open: empty snapshot/DB cooldown fields drop a stale local block. s.BlockAccountScheduling(account, until, "transport_error") if s.accountRepo == nil { From 5fea83dcaedf49d0b6d866595de7d3c256256d2f Mon Sep 17 00:00:00 2001 From: Clansty Date: Sun, 30 Aug 2026 00:02:14 +0800 Subject: [PATCH 003/145] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D429=E6=9C=AA?= =?UTF-8?q?=E8=80=97=E5=B0=BD=E9=A2=9D=E5=BA=A6=E4=BB=8D=E8=A7=A6=E5=8F=91?= =?UTF-8?q?=E8=B4=A6=E5=8F=B7=E9=80=80=E9=81=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 关闭429默认回避时不再创建OAuth运行时冷却 - 仅在x-codex窗口达到100%时使用上游重置时间 - 补充未耗尽额度和关闭开关的回归测试 --- .../openai_account_runtime_block_fastpath.go | 17 +++++--- ...nai_account_runtime_block_fastpath_test.go | 43 ++++++++++++++++++- backend/internal/service/ratelimit_service.go | 16 +------ .../service/ratelimit_service_openai_test.go | 20 +-------- 4 files changed, 56 insertions(+), 40 deletions(-) diff --git a/backend/internal/service/openai_account_runtime_block_fastpath.go b/backend/internal/service/openai_account_runtime_block_fastpath.go index 3551d8ecb2..f80382c41a 100644 --- a/backend/internal/service/openai_account_runtime_block_fastpath.go +++ b/backend/internal/service/openai_account_runtime_block_fastpath.go @@ -37,9 +37,8 @@ const ( openAIOAuth429QuotaReset ) -// classifyOpenAIOAuth429 区分账号配额耗尽信号与普通瞬时 429。明确窗口达到 -// 100% 时以该窗口为准;没有 100% 标记但包含重置头时,沿用 v179 的兼容语义, -// 仍视为配额限流信号。 +// classifyOpenAIOAuth429 区分账号配额耗尽信号与普通瞬时 429。只有窗口达到 +// 100% 或响应体明确给出 reset 时间时,才视为配额限流信号。 func classifyOpenAIOAuth429(headers http.Header, responseBody []byte) (openAIOAuth429Disposition, *time.Time) { if snapshot := ParseCodexRateLimitHeaders(headers); snapshot != nil { if normalized := snapshot.Normalize(); normalized != nil { @@ -228,13 +227,17 @@ func (s *OpenAIGatewayService) markOpenAIOAuth429RateLimited(ctx context.Context return } - cooldownUntil := time.Now().Add(openAIOAuth429FallbackCooldown) - if resetAt != nil && resetAt.After(time.Now()) { + now := time.Now() + cooldownUntil := now.Add(openAIOAuth429FallbackCooldown) + if resetAt != nil && resetAt.After(now) { cooldownUntil = *resetAt } else if s.rateLimitService != nil { - if cooldown, ok := s.rateLimitService.get429FallbackCooldown(ctx, account); ok && cooldown > 0 { - cooldownUntil = time.Now().Add(cooldown) + cooldown, ok := s.rateLimitService.get429FallbackCooldown(ctx, account) + if !ok || cooldown <= 0 { + s.openaiOAuth429RetryStartedAt.Delete(account.ID) + return } + cooldownUntil = now.Add(cooldown) } s.BlockAccountScheduling(account, cooldownUntil, "429") s.openaiOAuth429RetryStartedAt.Delete(account.ID) diff --git a/backend/internal/service/openai_account_runtime_block_fastpath_test.go b/backend/internal/service/openai_account_runtime_block_fastpath_test.go index 543c999359..08dd04d08c 100644 --- a/backend/internal/service/openai_account_runtime_block_fastpath_test.go +++ b/backend/internal/service/openai_account_runtime_block_fastpath_test.go @@ -72,6 +72,47 @@ func TestOpenAI429FastPath_BlocksOAuthOnlyAfterRetryWindow(t *testing.T) { require.False(t, svc.shouldRetryOpenAIOAuth429OnSameAccount(account, http.StatusTooManyRequests, false)) } +func TestOpenAI429FastPath_DoesNotBlockOAuthWhenFallbackDisabled(t *testing.T) { + repo := &oauth429RateLimitRepo{} + settingRepo := newMockSettingRepo() + settingRepo.data[SettingKeyRateLimit429CooldownSettings] = `{"enabled":false,"cooldown_seconds":12}` + rateLimitService := NewRateLimitService(repo, nil, &config.Config{}, nil, nil) + rateLimitService.SetSettingService(NewSettingService(settingRepo, &config.Config{})) + svc := &OpenAIGatewayService{rateLimitService: rateLimitService} + rateLimitService.SetAccountRuntimeBlocker(svc) + account := &Account{ID: 425, Platform: PlatformOpenAI, Type: AccountTypeOAuth} + svc.openaiOAuth429RetryStartedAt.Store(account.ID, time.Now().Add(-openAIOAuth429RetryWindow-time.Second)) + + svc.markOpenAIOAuth429RateLimited(context.Background(), account, http.Header{}, []byte(`{"detail":"Rate limit exceeded"}`)) + + require.False(t, svc.isOpenAIAccountRuntimeBlocked(account), "disabled 429 fallback must not create an OAuth runtime cooldown") + require.Zero(t, repo.setRateLimitedCalls, "disabled 429 fallback must not persist a scheduler cooldown") +} + +func TestOpenAI429FastPath_DoesNotBlockOAuthWhenQuotaWindowIsNotExhausted(t *testing.T) { + repo := &oauth429RateLimitRepo{} + settingRepo := newMockSettingRepo() + settingRepo.data[SettingKeyRateLimit429CooldownSettings] = `{"enabled":false,"cooldown_seconds":12}` + rateLimitService := NewRateLimitService(repo, nil, &config.Config{}, nil, nil) + rateLimitService.SetSettingService(NewSettingService(settingRepo, &config.Config{})) + svc := &OpenAIGatewayService{rateLimitService: rateLimitService} + rateLimitService.SetAccountRuntimeBlocker(svc) + account := &Account{ID: 426, Platform: PlatformOpenAI, Type: AccountTypeOAuth} + svc.openaiOAuth429RetryStartedAt.Store(account.ID, time.Now().Add(-openAIOAuth429RetryWindow-time.Second)) + headers := http.Header{} + headers.Set("x-codex-primary-used-percent", "37") + headers.Set("x-codex-primary-reset-after-seconds", "604800") + headers.Set("x-codex-primary-window-minutes", "10080") + headers.Set("x-codex-secondary-used-percent", "20") + headers.Set("x-codex-secondary-reset-after-seconds", "3600") + headers.Set("x-codex-secondary-window-minutes", "300") + + svc.handleOpenAIAccountUpstreamError(context.Background(), account, http.StatusTooManyRequests, headers, []byte(`{"detail":"Rate limit exceeded"}`)) + + require.False(t, svc.isOpenAIAccountRuntimeBlocked(account), "non-exhausted quota headers must use the configurable fallback") + require.Zero(t, repo.setRateLimitedCalls, "disabled 429 fallback must not persist a scheduler cooldown") +} + func TestOpenAI429FastPath_BlocksOAuthImmediatelyWhenSevenDayQuotaIsExhausted(t *testing.T) { repo := &oauth429RateLimitRepo{} rateLimits := NewRateLimitService(repo, nil, &config.Config{}, nil, nil) @@ -307,7 +348,7 @@ func TestOpenAIHTTP429StillUsesQuotaResetHeaders(t *testing.T) { account := &Account{ID: 422, Platform: PlatformOpenAI, Type: AccountTypeOAuth} svc.openaiOAuth429RetryStartedAt.Store(account.ID, time.Now().Add(-openAIOAuth429RetryWindow-time.Second)) headers := http.Header{} - headers.Set("x-codex-primary-used-percent", "37") + headers.Set("x-codex-primary-used-percent", "100") headers.Set("x-codex-primary-reset-after-seconds", "604800") headers.Set("x-codex-primary-window-minutes", "10080") diff --git a/backend/internal/service/ratelimit_service.go b/backend/internal/service/ratelimit_service.go index 3bd467f464..214aac7a0e 100644 --- a/backend/internal/service/ratelimit_service.go +++ b/backend/internal/service/ratelimit_service.go @@ -1344,20 +1344,8 @@ func calculateOpenAI429ResetTime(headers http.Header) *time.Time { return &resetAt } - // 都未达到100%但收到429,使用较长的重置时间 - var maxResetSecs int - if normalized.Reset7dSeconds != nil && *normalized.Reset7dSeconds > maxResetSecs { - maxResetSecs = *normalized.Reset7dSeconds - } - if normalized.Reset5hSeconds != nil && *normalized.Reset5hSeconds > maxResetSecs { - maxResetSecs = *normalized.Reset5hSeconds - } - if maxResetSecs > 0 { - resetAt := now.Add(time.Duration(maxResetSecs) * time.Second) - slog.Info("openai_429_using_max_reset", "max_reset_seconds", maxResetSecs, "reset_at", resetAt) - return &resetAt - } - + // 未达到100%时,reset-after 只代表窗口信息,不能证明账号配额耗尽。 + // 这类瞬时429必须回到可配置的兜底路径,避免未耗尽账号被长时间排除。 return nil } diff --git a/backend/internal/service/ratelimit_service_openai_test.go b/backend/internal/service/ratelimit_service_openai_test.go index 8969f7e622..ba4324d7f9 100644 --- a/backend/internal/service/ratelimit_service_openai_test.go +++ b/backend/internal/service/ratelimit_service_openai_test.go @@ -73,10 +73,9 @@ func TestCalculateOpenAI429ResetTime_5hExhausted(t *testing.T) { } } -func TestCalculateOpenAI429ResetTime_NeitherExhausted_UsesMax(t *testing.T) { +func TestCalculateOpenAI429ResetTime_NeitherExhausted_ReturnsNil(t *testing.T) { svc := &RateLimitService{} - // Neither limit at 100%, should use the longer reset time headers := http.Header{} headers.Set("x-codex-primary-used-percent", "80") headers.Set("x-codex-primary-reset-after-seconds", "100000") @@ -85,22 +84,7 @@ func TestCalculateOpenAI429ResetTime_NeitherExhausted_UsesMax(t *testing.T) { headers.Set("x-codex-secondary-reset-after-seconds", "5000") headers.Set("x-codex-secondary-window-minutes", "300") - before := time.Now() - resetAt := svc.calculateOpenAI429ResetTime(headers) - after := time.Now() - - if resetAt == nil { - t.Fatal("expected non-nil resetAt") - } - - // Should use the max (100000 seconds from 7d window) - expectedDuration := 100000 * time.Second - minExpected := before.Add(expectedDuration) - maxExpected := after.Add(expectedDuration) - - if resetAt.Before(minExpected) || resetAt.After(maxExpected) { - t.Errorf("resetAt %v not in expected range [%v, %v]", resetAt, minExpected, maxExpected) - } + require.Nil(t, svc.calculateOpenAI429ResetTime(headers)) } func TestCalculateOpenAI429ResetTime_NoCodexHeaders(t *testing.T) { From ca9b4d73f2bc680c243ee7a6da6667bd6e83a710 Mon Sep 17 00:00:00 2001 From: feeeei Date: Mon, 31 Aug 2026 01:01:20 +0800 Subject: [PATCH 004/145] =?UTF-8?q?fix(openai-ws):=20share=20immutable=20r?= =?UTF-8?q?eplay=20bodies=20to=20stop=20O(T=C2=B2)=20copying?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OpenAI Responses WS/HTTP-bridge 的 replay 历史此前每轮对全部正文做多次 深拷贝(构建、双份序列、保存点各一遍),长会话累计分配 O(T²),实测 128 turn x 10KiB 达 160MB+,是 #6382 内存持续增长直至 OOM 的主因。 改为共享不可变正文所有权: - extract 经零拷贝 string 视图 + gjson Index 直接切 payload 子切片, 并以 json.Valid 保持对断裂数组的既有错误语义 - 序列构建/sanitize/collector/保存点全部只传引用;跨持有者保存经 combineOpenAIWSReplayItems 新建头数组,正文不复制 - bridge 每轮一次解析,正常 replay 与 account-failover 序列共享正文 - 前缀比较增加字节级快路径,避免逐轮全历史 decode+encode - strict 状态可用时不再整包保留上一轮 payload 新增 128 turn 分配回归测试锚定上界(实测 2.5MB,深拷贝回归会击穿), 以及正文共享的所有权断言。 --- .../service/openai_ws_forwarder_ingress.go | 45 +++--- .../openai_ws_forwarder_ingress_test.go | 80 +++++++++-- .../service/openai_ws_forwarder_payload.go | 129 ++++++++++++------ .../internal/service/openai_ws_http_bridge.go | 7 +- .../openai_ws_replay_allocation_test.go | 80 +++++++++++ 5 files changed, 270 insertions(+), 71 deletions(-) create mode 100644 backend/internal/service/openai_ws_replay_allocation_test.go diff --git a/backend/internal/service/openai_ws_forwarder_ingress.go b/backend/internal/service/openai_ws_forwarder_ingress.go index 09e16dc704..0991471ae6 100644 --- a/backend/internal/service/openai_ws_forwarder_ingress.go +++ b/backend/internal/service/openai_ws_forwarder_ingress.go @@ -586,24 +586,27 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( toolOutputCoverage := AnalyzeToolCallOutputContextCoverageBytes(currentBridgePayload.payloadRaw) needsBridgeReplay := currentBridgePayload.previousResponseID != "" || (toolOutputCoverage.HasFunctionCallOutput && !toolOutputCoverage.ContextCoversAllCallIDs) - turnReplayInput, turnReplayInputExists, replayInputErr := buildOpenAIWSReplayInputSequence( + // 一次解析当前 input,正常 replay 与 account-failover 两份序列共享同一批正文。 + bridgeCurrentItems, bridgeCurrentItemsExist, extractErr := openAIWSExtractNormalizedInputSequence( + currentBridgePayload.payloadRaw, + ) + if extractErr != nil { + return fmt.Errorf("build websocket http bridge replay input: %w", extractErr) + } + turnReplayInput, turnReplayInputExists := buildOpenAIWSReplayInputSequenceFromItems( bridgeReplayInput, bridgeReplayInputExists, - currentBridgePayload.payloadRaw, + bridgeCurrentItems, + bridgeCurrentItemsExist, needsBridgeReplay, ) - if replayInputErr != nil { - return fmt.Errorf("build websocket http bridge replay input: %w", replayInputErr) - } - turnAccountFailoverInput, turnAccountFailoverInputExists, failoverInputErr := buildOpenAIWSReplayInputSequence( + turnAccountFailoverInput, turnAccountFailoverInputExists := buildOpenAIWSReplayInputSequenceFromItems( bridgeAccountFailoverInput, bridgeAccountFailoverInputExists, - currentBridgePayload.payloadRaw, + bridgeCurrentItems, + bridgeCurrentItemsExist, needsBridgeReplay, ) - if failoverInputErr != nil { - return fmt.Errorf("build websocket account failover input: %w", failoverInputErr) - } if needsBridgeReplay && turnReplayInputExists { updatedPayload, setInputErr := setOpenAIWSPayloadInputSequence( currentBridgePayload.payloadRaw, @@ -680,18 +683,20 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( if result == nil { return errors.New("websocket http bridge turn result is nil") } - bridgeReplayInput = cloneOpenAIWSRawMessages(turnReplayInput) + // turnReplayInput/turnAccountFailoverInput 可能共享同一头数组(转移自 + // bridgeCurrentItems),保存历史必须经 combine 新建头,禁止就地 append。 + bridgeReplayInput = turnReplayInput bridgeReplayInputExists = turnReplayInputExists if result.wsReplayInputExists { - bridgeReplayInput = append(bridgeReplayInput, cloneOpenAIWSRawMessages(result.wsReplayInput)...) + bridgeReplayInput = combineOpenAIWSReplayItems(bridgeReplayInput, result.wsReplayInput) bridgeReplayInputExists = true } - bridgeAccountFailoverInput = cloneOpenAIWSRawMessages(turnAccountFailoverInput) + bridgeAccountFailoverInput = turnAccountFailoverInput bridgeAccountFailoverInputExists = turnAccountFailoverInputExists if len(result.wsAccountFailoverReplayInput) > 0 { - bridgeAccountFailoverInput = append( + bridgeAccountFailoverInput = combineOpenAIWSReplayItems( bridgeAccountFailoverInput, - cloneOpenAIWSRawMessages(result.wsAccountFailoverReplayInput)..., + result.wsAccountFailoverReplayInput, ) bridgeAccountFailoverInputExists = true } @@ -1744,16 +1749,19 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( } responseID := strings.TrimSpace(result.RequestID) lastTurnResponseID = responseID - lastTurnPayload = cloneOpenAIWSPayloadBytes(currentPayload) - lastTurnReplayInput = cloneOpenAIWSRawMessages(currentTurnReplayInput) + // 正文共享:currentPayload/currentTurnReplayInput 均不可变,历史直接引用; + // collector 增量经 combine 合并(新头数组)。 + lastTurnReplayInput = currentTurnReplayInput lastTurnReplayInputExists = currentTurnReplayInputExists if result.wsReplayInputExists { - lastTurnReplayInput = append(lastTurnReplayInput, cloneOpenAIWSRawMessages(result.wsReplayInput)...) + lastTurnReplayInput = combineOpenAIWSReplayItems(lastTurnReplayInput, result.wsReplayInput) lastTurnReplayInputExists = true } nextStrictState, strictStateErr := buildOpenAIWSIngressPreviousTurnStrictState(currentPayload) if strictStateErr != nil { lastTurnStrictState = nil + // strict 状态不可用时保留整份上一轮 payload 供慢路径比较。 + lastTurnPayload = currentPayload logOpenAIWSModeInfo( "ingress_ws_prev_response_strict_state_skip account_id=%d turn=%d conn_id=%s reason=build_error cause=%s", account.ID, @@ -1763,6 +1771,7 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( ) } else { lastTurnStrictState = nextStrictState + lastTurnPayload = nil } if responseID != "" && stateStore != nil { diff --git a/backend/internal/service/openai_ws_forwarder_ingress_test.go b/backend/internal/service/openai_ws_forwarder_ingress_test.go index 1f00d0dce6..b70f83659a 100644 --- a/backend/internal/service/openai_ws_forwarder_ingress_test.go +++ b/backend/internal/service/openai_ws_forwarder_ingress_test.go @@ -1,6 +1,7 @@ package service import ( + "bytes" "context" "encoding/json" "errors" @@ -1007,18 +1008,79 @@ func TestSetOpenAIWSPayloadInputSequence(t *testing.T) { }) } -func TestCloneOpenAIWSRawMessages(t *testing.T) { +func TestCombineOpenAIWSReplayItems(t *testing.T) { t.Parallel() - t.Run("nil_slice", func(t *testing.T) { - cloned := cloneOpenAIWSRawMessages(nil) - require.Nil(t, cloned) + t.Run("empty_delta_returns_history", func(t *testing.T) { + history := []json.RawMessage{json.RawMessage(`{"a":1}`)} + require.Nil(t, combineOpenAIWSReplayItems(nil, nil)) + combined := combineOpenAIWSReplayItems(history, nil) + require.Len(t, combined, 1) }) - t.Run("empty_slice", func(t *testing.T) { - items := make([]json.RawMessage, 0) - cloned := cloneOpenAIWSRawMessages(items) - require.NotNil(t, cloned) - require.Len(t, cloned, 0) + t.Run("new_header_shares_bodies", func(t *testing.T) { + history := []json.RawMessage{json.RawMessage(`{"a":1}`)} + delta := []json.RawMessage{json.RawMessage(`{"b":2}`)} + combined := combineOpenAIWSReplayItems(history, delta) + require.Len(t, combined, 2) + // 头数组必须是新建的:对 combined 追加不影响 history。 + require.NotSame(t, &history[0], &combined[0]) + // 正文共享:不发生字节级深拷贝。 + require.Same(t, &history[0][0], &combined[0][0]) + require.Same(t, &delta[0][0], &combined[1][0]) + }) +} + +func TestOpenAIWSReplaySequenceSharesBodies(t *testing.T) { + t.Parallel() + + t.Run("extract_shares_payload_backing_array", func(t *testing.T) { + payload := []byte(`{"input":[{"type":"input_text","text":"hello"},{"type":"input_text","text":"world"}]}`) + items, exists, err := openAIWSExtractNormalizedInputSequence(payload) + require.NoError(t, err) + require.True(t, exists) + require.Len(t, items, 2) + for _, item := range items { + start := bytes.Index(payload, []byte(item)) + require.GreaterOrEqual(t, start, 0) + require.Same(t, &payload[start], &item[0], "extract 应零拷贝共享 payload 底层数组") + } + }) + + t.Run("build_transfers_current_items_ownership", func(t *testing.T) { + payload := []byte(`{"input":[{"type":"input_text","text":"hello"}]}`) + items, exists, err := buildOpenAIWSReplayInputSequence(nil, false, payload, false) + require.NoError(t, err) + require.True(t, exists) + require.Len(t, items, 1) + start := bytes.Index(payload, []byte(items[0])) + require.GreaterOrEqual(t, start, 0) + require.Same(t, &payload[start], &items[0][0]) + }) + + t.Run("build_merge_shares_history_bodies", func(t *testing.T) { + history := []json.RawMessage{json.RawMessage(`{"type":"input_text","text":"hello"}`)} + items, exists, err := buildOpenAIWSReplayInputSequence( + history, + true, + []byte(`{"previous_response_id":"resp_1","input":[{"type":"input_text","text":"world"}]}`), + true, + ) + require.NoError(t, err) + require.True(t, exists) + require.Len(t, items, 2) + require.Same(t, &history[0][0], &items[0][0], "历史正文应共享而非深拷贝") + }) + + t.Run("build_prefix_hit_transfers_current_items", func(t *testing.T) { + history := []json.RawMessage{json.RawMessage(`{"type":"input_text","text":"hello"}`)} + payload := []byte(`{"previous_response_id":"resp_1","input":[{"type":"input_text","text":"hello"},{"type":"input_text","text":"world"}]}`) + items, exists, err := buildOpenAIWSReplayInputSequence(history, true, payload, true) + require.NoError(t, err) + require.True(t, exists) + require.Len(t, items, 2) + start := bytes.Index(payload, []byte(items[1])) + require.GreaterOrEqual(t, start, 0) + require.Same(t, &payload[start], &items[1][0], "prefix 命中应转移当前 items 所有权并共享 payload 底层数组") }) } diff --git a/backend/internal/service/openai_ws_forwarder_payload.go b/backend/internal/service/openai_ws_forwarder_payload.go index 94738221a2..af034c7cf0 100644 --- a/backend/internal/service/openai_ws_forwarder_payload.go +++ b/backend/internal/service/openai_ws_forwarder_payload.go @@ -9,6 +9,7 @@ import ( "net/http" "net/url" "strings" + "unsafe" "github.com/gin-gonic/gin" "github.com/tidwall/gjson" @@ -431,24 +432,37 @@ func alignStoreDisabledPreviousResponseID( return updated, true, nil } -func cloneOpenAIWSPayloadBytes(payload []byte) []byte { - if len(payload) == 0 { - return nil +// Replay 状态所有权不变式:replay 序列中的 json.RawMessage 正文一经放入即视为 +// 不可变,所有持有者共享同一份字节,任何修改都必须整体替换元素或重建 payload。 +// 序列头数组在跨持有者保存时必须新建(combineOpenAIWSReplayItems),禁止通过 +// 共享头 append,否则会写入其他持有者可见的底层数组。 + +// combineOpenAIWSReplayItems 合并历史与增量为新头数组,正文共享不复制。 +func combineOpenAIWSReplayItems(history, delta []json.RawMessage) []json.RawMessage { + if len(delta) == 0 { + return history } - cloned := make([]byte, len(payload)) - copy(cloned, payload) - return cloned + combined := make([]json.RawMessage, 0, len(history)+len(delta)) + combined = append(combined, history...) + return append(combined, delta...) } -func cloneOpenAIWSRawMessages(items []json.RawMessage) []json.RawMessage { - if items == nil { - return nil +// openAIWSPayloadStringView 返回与 payload 共享底层数组的零拷贝 string 视图, +// 供 gjson.Get 使用(gjson.GetBytes 会整段复制结果 Raw,对 input 这类占 +// payload 主体的字段是每次 O(payload) 分配)。调用方必须保证 payload 在结果 +// 存活期间不可变(replay 所有权不变式)。 +func openAIWSPayloadStringView(payload []byte) string { + return unsafe.String(unsafe.SliceData(payload), len(payload)) +} + +// openAIWSRawMessageFromResult 优先返回 parent 的子切片(gjson 值零拷贝共享), +// Index 不可用时回退为复制。共享要求 parent 遵守上面的不可变约定。 +func openAIWSRawMessageFromResult(parent []byte, value gjson.Result) json.RawMessage { + idx := value.Index + if idx > 0 && idx+len(value.Raw) <= len(parent) && string(parent[idx:idx+len(value.Raw)]) == value.Raw { + return json.RawMessage(parent[idx : idx+len(value.Raw)]) } - cloned := make([]json.RawMessage, 0, len(items)) - for idx := range items { - cloned = append(cloned, json.RawMessage(cloneOpenAIWSPayloadBytes(items[idx]))) - } - return cloned + return json.RawMessage(value.Raw) } func normalizeOpenAIWSJSONForCompare(raw []byte) ([]byte, error) { @@ -495,30 +509,38 @@ func normalizeOpenAIWSPayloadWithoutInputAndPreviousResponseID(payload []byte) ( return json.Marshal(decoded) } +// openAIWSExtractNormalizedInputSequence 拆出 input 序列。返回的正文尽可能与 +// payload 共享底层数组(零拷贝),受 replay 所有权不变式保护。 func openAIWSExtractNormalizedInputSequence(payload []byte) ([]json.RawMessage, bool, error) { if len(payload) == 0 { return nil, false, nil } - inputValue := gjson.GetBytes(payload, "input") + inputValue := gjson.Get(openAIWSPayloadStringView(payload), "input") if !inputValue.Exists() { return nil, false, nil } if inputValue.Type == gjson.JSON { - raw := strings.TrimSpace(inputValue.Raw) - if strings.HasPrefix(raw, "[") { - var items []json.RawMessage - if err := json.Unmarshal([]byte(raw), &items); err != nil { - return nil, true, err + if inputValue.IsArray() { + // gjson 宽容解析;数组整体先做零分配合法性校验,避免把断裂 + // JSON 塞进 replay 历史。 + arrayRaw := openAIWSRawMessageFromResult(payload, inputValue) + if !json.Valid(arrayRaw) { + return nil, true, errors.New("input array json is invalid") + } + elems := inputValue.Array() + items := make([]json.RawMessage, 0, len(elems)) + for _, elem := range elems { + items = append(items, openAIWSRawMessageFromResult(payload, elem)) } return items, true, nil } - return []json.RawMessage{json.RawMessage(raw)}, true, nil + return []json.RawMessage{openAIWSRawMessageFromResult(payload, inputValue)}, true, nil } if inputValue.Type == gjson.String { encoded, _ := json.Marshal(inputValue.String()) return []json.RawMessage{encoded}, true, nil } - return []json.RawMessage{json.RawMessage(inputValue.Raw)}, true, nil + return []json.RawMessage{openAIWSRawMessageFromResult(payload, inputValue)}, true, nil } func openAIWSInputIsPrefixExtended(previousPayload, currentPayload []byte) (bool, error) { @@ -561,6 +583,10 @@ func openAIWSRawItemsHasPrefix(items []json.RawMessage, prefix []json.RawMessage return false } for idx := range prefix { + // 快路径:客户端逐字节重发历史时直接比较,避免整轮历史的解码/再编码。 + if bytes.Equal(bytes.TrimSpace(prefix[idx]), bytes.TrimSpace(items[idx])) { + continue + } previousNormalized := normalizeOpenAIWSJSONForCompareOrRaw(prefix[idx]) currentNormalized := normalizeOpenAIWSJSONForCompareOrRaw(items[idx]) if !bytes.Equal(previousNormalized, currentNormalized) { @@ -611,12 +637,13 @@ func openAIWSRawItemsHaveToolCallContextForOutputs(items []json.RawMessage) bool return true } +// sanitizeOpenAIWSHistoricalReplayToolCalls 返回的新头数组与 previousItems 共享正文。 func sanitizeOpenAIWSHistoricalReplayToolCalls( previousItems []json.RawMessage, currentItems []json.RawMessage, ) []json.RawMessage { if len(previousItems) == 0 { - return cloneOpenAIWSRawMessages(previousItems) + return previousItems } outputCallIDs := make(map[string]struct{}) collectOutputCallIDs := func(items []json.RawMessage) { @@ -640,7 +667,7 @@ func sanitizeOpenAIWSHistoricalReplayToolCalls( continue } } - sanitized = append(sanitized, append(json.RawMessage(nil), item...)) + sanitized = append(sanitized, item) } return sanitized } @@ -649,7 +676,7 @@ func openAIWSRawPayloadHasToolCallOutput(payload []byte) bool { if len(payload) == 0 { return false } - input := gjson.GetBytes(payload, "input") + input := gjson.Get(openAIWSPayloadStringView(payload), "input") if !input.Exists() { return false } @@ -667,6 +694,33 @@ func openAIWSRawPayloadHasToolCallOutput(payload []byte) bool { return false } +// buildOpenAIWSReplayInputSequenceFromItems 基于已解析的当前 turn input 构建 +// replay 序列。返回序列的正文与 previousFullInput/currentItems 共享所有权 +// (见 combineOpenAIWSReplayItems 上方的所有权不变式),头数组可能直接转移自 +// currentItems。 +func buildOpenAIWSReplayInputSequenceFromItems( + previousFullInput []json.RawMessage, + previousFullInputExists bool, + currentItems []json.RawMessage, + currentExists bool, + hasPreviousResponseID bool, +) ([]json.RawMessage, bool) { + if !hasPreviousResponseID || !previousFullInputExists { + return currentItems, currentExists + } + previousFullInput = sanitizeOpenAIWSHistoricalReplayToolCalls(previousFullInput, currentItems) + if !currentExists || len(currentItems) == 0 { + return previousFullInput, true + } + if openAIWSRawItemsHasPrefix(currentItems, previousFullInput) { + return currentItems, true + } + merged := make([]json.RawMessage, 0, len(previousFullInput)+len(currentItems)) + merged = append(merged, previousFullInput...) + merged = append(merged, currentItems...) + return merged, true +} + func buildOpenAIWSReplayInputSequence( previousFullInput []json.RawMessage, previousFullInputExists bool, @@ -677,23 +731,14 @@ func buildOpenAIWSReplayInputSequence( if currentErr != nil { return nil, false, currentErr } - if !hasPreviousResponseID { - return cloneOpenAIWSRawMessages(currentItems), currentExists, nil - } - if !previousFullInputExists { - return cloneOpenAIWSRawMessages(currentItems), currentExists, nil - } - previousFullInput = sanitizeOpenAIWSHistoricalReplayToolCalls(previousFullInput, currentItems) - if !currentExists || len(currentItems) == 0 { - return cloneOpenAIWSRawMessages(previousFullInput), true, nil - } - if openAIWSRawItemsHasPrefix(currentItems, previousFullInput) { - return cloneOpenAIWSRawMessages(currentItems), true, nil - } - merged := make([]json.RawMessage, 0, len(previousFullInput)+len(currentItems)) - merged = append(merged, cloneOpenAIWSRawMessages(previousFullInput)...) - merged = append(merged, cloneOpenAIWSRawMessages(currentItems)...) - return merged, true, nil + items, exists := buildOpenAIWSReplayInputSequenceFromItems( + previousFullInput, + previousFullInputExists, + currentItems, + currentExists, + hasPreviousResponseID, + ) + return items, exists, nil } func setOpenAIWSPayloadInputSequence( diff --git a/backend/internal/service/openai_ws_http_bridge.go b/backend/internal/service/openai_ws_http_bridge.go index ef5c73e129..80965125f6 100644 --- a/backend/internal/service/openai_ws_http_bridge.go +++ b/backend/internal/service/openai_ws_http_bridge.go @@ -8,6 +8,7 @@ import ( "fmt" "io" "net/http" + "slices" "strings" "time" @@ -156,12 +157,14 @@ func (c *openAIWSToolCallReplayCollector) AddEvent(eventType string, message []b } } +// Items/AllItems 返回浅拷贝头数组;正文由 collector 独立分配且此后不可变, +// 调用方按 replay 所有权不变式共享持有。 func (c *openAIWSToolCallReplayCollector) Items() []json.RawMessage { - return cloneOpenAIWSRawMessages(c.items) + return slices.Clone(c.items) } func (c *openAIWSToolCallReplayCollector) AllItems() []json.RawMessage { - return cloneOpenAIWSRawMessages(c.allItems) + return slices.Clone(c.allItems) } func (c *openAIWSToolCallReplayCollector) addAllItem(item gjson.Result) { diff --git a/backend/internal/service/openai_ws_replay_allocation_test.go b/backend/internal/service/openai_ws_replay_allocation_test.go new file mode 100644 index 0000000000..d6f688b114 --- /dev/null +++ b/backend/internal/service/openai_ws_replay_allocation_test.go @@ -0,0 +1,80 @@ +package service + +import ( + "encoding/json" + "fmt" + "runtime" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +// buildReplayTurnPayload 构造第 turn 轮的全量重发 payload:客户端历史逐轮追加 +// itemBytes 大小的项(Codex store=false 模式的真实形态)。 +func buildReplayTurnPayload(turn, itemBytes int) []byte { + var b strings.Builder + _, _ = b.WriteString(`{"type":"response.create","model":"gpt-5.5","stream":true,"input":[`) + filler := strings.Repeat("x", itemBytes) + for i := 1; i <= turn; i++ { + if i > 1 { + _, _ = b.WriteString(",") + } + _, _ = fmt.Fprintf(&b, `{"type":"input_text","text":"turn-%d-%s"}`, i, filler) + } + _, _ = b.WriteString(`]}`) + return []byte(b.String()) +} + +// TestOpenAIWSReplayStateBuildAllocationBounded 长会话分配回归:128 turn、每轮 +// ~10KiB 增量,全量历史逐轮重发。replay 状态构建(build + 历史保存)必须共享 +// 正文而非逐轮深拷贝,否则累计分配为 O(T²)(本场景 >160MB);共享实现的累计 +// 分配与客户端重发总量同阶(~85MB payload 本身之外仅头数组与解析开销)。 +func TestOpenAIWSReplayStateBuildAllocationBounded(t *testing.T) { + const ( + turns = 128 + itemBytes = 10 * 1024 + ) + + payloads := make([][]byte, 0, turns) + for turn := 1; turn <= turns; turn++ { + payloads = append(payloads, buildReplayTurnPayload(turn, itemBytes)) + } + + var history []json.RawMessage + historyExists := false + delta := []json.RawMessage{json.RawMessage(`{"type":"function_call","id":"item_1","call_id":"call_1","name":"exec","arguments":"{}"}`)} + + runtime.GC() + var before runtime.MemStats + runtime.ReadMemStats(&before) + + for turn := 1; turn <= turns; turn++ { + items, exists, err := buildOpenAIWSReplayInputSequence(history, historyExists, payloads[turn-1], turn > 1) + require.NoError(t, err) + require.True(t, exists) + require.Len(t, items, turn) + // 保存历史 + collector 增量合并(与 ingress/bridge 保存点同构)。 + history = combineOpenAIWSReplayItems(items, delta) + historyExists = true + // 下一轮 payload 含全部用户项但不含 collector 项,触发 sanitize+merge 路径中 + // 最常见的 prefix 分支比较。 + history = history[:len(history)-1] + } + + var after runtime.MemStats + runtime.ReadMemStats(&after) + allocated := after.TotalAlloc - before.TotalAlloc + + // 正文共享实现全程约 2.5MB(头数组与 gjson 解析开销)。任何逐轮 O(H) 的 + // 字节级复制(extract 拷贝、正文 clone、保存点深拷贝)都会叠加 ≥84MB + // (sum(t×10KiB))的量级,直接击穿此上界。 + const maxAllocatedBytes = 32 * 1024 * 1024 + require.Lessf( + t, + allocated, + uint64(maxAllocatedBytes), + "replay 状态构建累计分配 %d bytes 超出上界,疑似回归为逐轮深拷贝", + allocated, + ) +} From 0e08c399994fc1934bdae20123a044b8dd0ed995 Mon Sep 17 00:00:00 2001 From: feeeei Date: Mon, 31 Aug 2026 01:02:13 +0800 Subject: [PATCH 005/145] fix(openai-ws): remember rejected encrypted content across turns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit invalid_encrypted_content 恢复此前只清理当次请求体:同一失效密文随客户 端维护的会话历史在后续每一轮重新出现,反复触发"整包被拒→剥离→重试/ 重连"(#6382 生产观测 2h 内 251 次 recovery、273 次 WS 重连),既是 QoS 放大器也是内存压力放大器。 按会话记录被上游拒绝过的 encrypted_content SHA-256 摘要(lineage): - OpenAIWSStateStore 新增会话级摘要绑定,TTL 复用 session sticky, 单会话 512 摘要为存储自保护,超出仅退化为既有单次 recovery - 写点覆盖 forward WSv2 recovery、forward 非 WS 400 重试、ingress error 事件、bridge 400/流内 error;读点在 forward 进场与 ingress/ bridge turn 循环入口按摘要预剥离,新密文摘要不同不受影响 - 只收剥离端可处理的项类型(reasoning/compaction/compaction_summary), 避免记入永远剥不掉的摘要后每轮空转解码 - replay 历史序列同步剥离,避免与已剥离的当前 input 项错位导致 prefix 复用失配 含端到端集成测试:turn1 上游拒绝即记录,turn2 上游收到的请求不再携带 失效密文且 reasoning 骨架保留。 --- .../openai_encrypted_content_lineage.go | 353 ++++++++++++++++++ .../openai_encrypted_content_lineage_test.go | 216 +++++++++++ .../service/openai_gateway_forward.go | 37 ++ .../service/openai_ws_forwarder_ingress.go | 46 +++ ...penai_ws_forwarder_ingress_session_test.go | 154 ++++++++ .../internal/service/openai_ws_http_bridge.go | 11 + .../internal/service/openai_ws_state_store.go | 127 ++++++- 7 files changed, 938 insertions(+), 6 deletions(-) create mode 100644 backend/internal/service/openai_encrypted_content_lineage.go create mode 100644 backend/internal/service/openai_encrypted_content_lineage_test.go diff --git a/backend/internal/service/openai_encrypted_content_lineage.go b/backend/internal/service/openai_encrypted_content_lineage.go new file mode 100644 index 0000000000..dbc360351d --- /dev/null +++ b/backend/internal/service/openai_encrypted_content_lineage.go @@ -0,0 +1,353 @@ +package service + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "strings" + + "github.com/gin-gonic/gin" + "github.com/tidwall/gjson" +) + +// invalid_encrypted_content 失效密文 lineage。 +// +// 上游判定某轮请求中的加密 reasoning/compaction 项不可解后,同一失效密文会随 +// 客户端维护的会话历史在后续每一轮重新出现,重复触发"整包被拒→剥离→重试/ +// 重连"。这里按会话记录已被上游拒绝过的 encrypted_content 摘要 +// (OpenAIWSStateStore,带 TTL 与容量自保护);后续请求进场时仅剥离摘要命中 +// 的项,新生成的密文摘要不同,不会被误删。 + +const openAIWSFallbackReasonInvalidEncryptedContent = "invalid_encrypted_content" + +// openAIWSIngressSessionHashContextKey 在 gin context 中携带 ingress 会话哈希, +// 供 HTTP bridge turn 内的 lineage 记录复用同一会话键。 +const openAIWSIngressSessionHashContextKey = "openai_ws_ingress_session_hash" + +func openAIEncryptedContentDigest(encrypted string) string { + sum := sha256.Sum256([]byte(encrypted)) + return hex.EncodeToString(sum[:]) +} + +// openAIEncryptedLineageItemType 限定 lineage 覆盖的项类型,与剥离端 +// sanitizeEncryptedReasoningInputItem 能处理的类型对称;其他类型即使携带 +// encrypted_content 也不收摘要,避免记入永远剥不掉的摘要后每轮空转解码。 +func openAIEncryptedLineageItemType(itemType string) bool { + switch strings.TrimSpace(itemType) { + case "reasoning", "compaction", "compaction_summary": + return true + default: + return false + } +} + +// collectOpenAIEncryptedContentDigestsRaw 收集 input 中 lineage 覆盖类型所携 +// 密文的摘要。payload 须遵守 replay 所有权不变式(零拷贝视图解析)。 +func collectOpenAIEncryptedContentDigestsRaw(payload []byte) []string { + if len(payload) == 0 { + return nil + } + input := gjson.Get(openAIWSPayloadStringView(payload), "input") + if !input.Exists() { + return nil + } + var digests []string + appendItem := func(item gjson.Result) { + if !openAIEncryptedLineageItemType(item.Get("type").String()) { + return + } + encrypted := item.Get("encrypted_content") + if encrypted.Type == gjson.String && encrypted.String() != "" { + digests = append(digests, openAIEncryptedContentDigest(encrypted.String())) + } + } + if input.IsArray() { + input.ForEach(func(_, item gjson.Result) bool { + appendItem(item) + return true + }) + return digests + } + if input.IsObject() { + appendItem(input) + } + return digests +} + +// stripOpenAIInvalidEncryptedContentItems 剥离 input 中摘要命中 invalid 的密文 +// 项,剥离语义与 trimOpenAIEncryptedReasoningItems 对齐:reasoning 仅剥 +// encrypted_content(连带 content:null 清理与空骨架删除),compaction/ +// compaction_summary 整项删除。返回被改写的项数。 +func stripOpenAIInvalidEncryptedContentItems(reqBody map[string]any, invalid map[string]struct{}) int { + if len(reqBody) == 0 || len(invalid) == 0 { + return 0 + } + inputValue, has := reqBody["input"] + if !has { + return 0 + } + stripped := 0 + stripItem := func(item any) (next any, changed bool, keep bool) { + inputItem, ok := item.(map[string]any) + if !ok { + return item, false, true + } + encrypted, ok := inputItem["encrypted_content"].(string) + if !ok || encrypted == "" { + return item, false, true + } + if _, hit := invalid[openAIEncryptedContentDigest(encrypted)]; !hit { + return item, false, true + } + return sanitizeEncryptedReasoningInputItem(item) + } + switch input := inputValue.(type) { + case []any: + filtered := input[:0] + for _, item := range input { + nextItem, changed, keep := stripItem(item) + if changed { + stripped++ + } + if !keep { + continue + } + filtered = append(filtered, nextItem) + } + if stripped == 0 { + return 0 + } + if len(filtered) == 0 { + delete(reqBody, "input") + return stripped + } + reqBody["input"] = filtered + return stripped + case map[string]any: + nextItem, changed, keep := stripItem(input) + if !changed { + return 0 + } + if !keep { + delete(reqBody, "input") + return 1 + } + nextMap, ok := nextItem.(map[string]any) + if !ok { + return 0 + } + reqBody["input"] = nextMap + return 1 + default: + return 0 + } +} + +// openAIRawPayloadHasInvalidEncryptedContent 探测 raw payload 是否携带命中 +// invalid 摘要的密文项;零命中的常态路径不做任何改写。payload 须遵守 replay +// 所有权不变式(零拷贝视图解析)。 +func openAIRawPayloadHasInvalidEncryptedContent(payload []byte, invalid map[string]struct{}) bool { + if len(payload) == 0 || len(invalid) == 0 { + return false + } + input := gjson.Get(openAIWSPayloadStringView(payload), "input") + if !input.Exists() { + return false + } + hit := false + checkItem := func(item gjson.Result) bool { + encrypted := item.Get("encrypted_content") + if encrypted.Type != gjson.String || encrypted.String() == "" { + return false + } + _, matched := invalid[openAIEncryptedContentDigest(encrypted.String())] + return matched + } + if input.IsArray() { + input.ForEach(func(_, item gjson.Result) bool { + if checkItem(item) { + hit = true + return false + } + return true + }) + return hit + } + if input.IsObject() { + return checkItem(input) + } + return false +} + +// stripOpenAIInvalidEncryptedContentFromReplayItems 对 replay 历史序列做同款 +// 剥离。有改动时返回新头数组,命中项以重写后的新正文整体替换(遵守 replay +// 所有权不变式,原正文不被修改);未命中时原样返回。 +func stripOpenAIInvalidEncryptedContentFromReplayItems(items []json.RawMessage, invalid map[string]struct{}) ([]json.RawMessage, int) { + if len(items) == 0 || len(invalid) == 0 { + return items, 0 + } + hit := false + for _, item := range items { + encrypted := gjson.Get(openAIWSPayloadStringView(item), "encrypted_content") + if encrypted.Type != gjson.String || encrypted.String() == "" { + continue + } + if _, matched := invalid[openAIEncryptedContentDigest(encrypted.String())]; matched { + hit = true + break + } + } + if !hit { + return items, 0 + } + stripped := 0 + next := make([]json.RawMessage, 0, len(items)) + for _, item := range items { + var decoded map[string]any + if err := decodeOpenAIJSONUseNumber(item, &decoded); err != nil { + next = append(next, item) + continue + } + encrypted, ok := decoded["encrypted_content"].(string) + if !ok || encrypted == "" { + next = append(next, item) + continue + } + if _, matched := invalid[openAIEncryptedContentDigest(encrypted)]; !matched { + next = append(next, item) + continue + } + nextItem, changed, keep := sanitizeEncryptedReasoningInputItem(decoded) + if !changed { + next = append(next, item) + continue + } + stripped++ + if !keep { + continue + } + rebuilt, err := marshalOpenAIUpstreamJSON(nextItem) + if err != nil { + next = append(next, item) + stripped-- + continue + } + next = append(next, json.RawMessage(rebuilt)) + } + if stripped == 0 { + return items, 0 + } + return next, stripped +} + +// stripOpenAIInvalidEncryptedContentRaw 是 raw JSON 版剥离:先零成本探测,命中 +// 才解码改写重编码。返回改写后的 payload 与剥离项数;未命中时原样返回。 +func stripOpenAIInvalidEncryptedContentRaw(payload []byte, invalid map[string]struct{}) ([]byte, int, error) { + if !openAIRawPayloadHasInvalidEncryptedContent(payload, invalid) { + return payload, 0, nil + } + var decoded map[string]any + if err := decodeOpenAIJSONUseNumber(payload, &decoded); err != nil { + return payload, 0, err + } + stripped := stripOpenAIInvalidEncryptedContentItems(decoded, invalid) + if stripped == 0 { + return payload, 0, nil + } + rebuilt, err := marshalOpenAIUpstreamJSON(decoded) + if err != nil { + return payload, 0, err + } + return rebuilt, stripped, nil +} + +// markOpenAIWSInvalidEncryptedContentLineage 把本次被上游拒绝的密文摘要写入 +// 会话 lineage。digests 须在剥离前收集。 +func (s *OpenAIGatewayService) markOpenAIWSInvalidEncryptedContentLineage(groupID int64, sessionHash string, digests []string) { + if s == nil || len(digests) == 0 || strings.TrimSpace(sessionHash) == "" { + return + } + stateStore := s.getOpenAIWSStateStore() + if stateStore == nil { + return + } + stateStore.MarkSessionInvalidEncryptedContent(groupID, sessionHash, digests, s.openAIWSSessionStickyTTL()) +} + +// sessionInvalidEncryptedContentDigests 返回会话已知失效密文摘要;全局无记录 +// 时(常态)零成本返回 nil。 +func (s *OpenAIGatewayService) sessionInvalidEncryptedContentDigests(groupID int64, sessionHash string) map[string]struct{} { + if s == nil || strings.TrimSpace(sessionHash) == "" { + return nil + } + stateStore := s.getOpenAIWSStateStore() + if stateStore == nil || !stateStore.HasAnySessionInvalidEncryptedContent() { + return nil + } + return stateStore.GetSessionInvalidEncryptedContentDigests(groupID, sessionHash) +} + +// openAIWSLineageSessionHashFromContext 取 lineage 会话键:优先 ingress 循环 +// 写入的会话哈希(与读取侧同键),否则按请求体派生。 +func (s *OpenAIGatewayService) openAIWSLineageSessionHashFromContext(c *gin.Context, body []byte) string { + if c != nil { + if fromCtx := strings.TrimSpace(c.GetString(openAIWSIngressSessionHashContextKey)); fromCtx != "" { + return fromCtx + } + } + return s.GenerateSessionHash(c, body) +} + +// markOpenAIWSInvalidEncryptedContentLineageFromPayload 在上游以 +// invalid_encrypted_content 拒绝 payload 时记录其密文摘要并输出观测日志。 +func (s *OpenAIGatewayService) markOpenAIWSInvalidEncryptedContentLineageFromPayload( + c *gin.Context, + payload []byte, + logKey string, + accountID int64, + turn int, +) { + digests := collectOpenAIEncryptedContentDigestsRaw(payload) + if len(digests) == 0 { + return + } + s.markOpenAIWSInvalidEncryptedContentLineage( + getOpenAIGroupIDFromContext(c), + s.openAIWSLineageSessionHashFromContext(c, payload), + digests, + ) + logOpenAIWSModeInfo("%s account_id=%d turn=%d digests=%d", logKey, accountID, turn, len(digests)) +} + +// stripSessionInvalidEncryptedContentLogged 对 payload 执行会话失效密文剥离并 +// 输出观测日志(logKey / logKey+"_skip"),返回(可能已替换的)payload 与剥离 +// 项数;未命中或剥离失败时原样返回。 +func (s *OpenAIGatewayService) stripSessionInvalidEncryptedContentLogged( + payload []byte, + invalid map[string]struct{}, + logKey string, + accountID int64, + turn int, +) ([]byte, int) { + strippedPayload, strippedCount, stripErr := stripOpenAIInvalidEncryptedContentRaw(payload, invalid) + if stripErr != nil { + logOpenAIWSModeInfo( + "%s_skip account_id=%d turn=%d reason=strip_error cause=%s", + logKey, + accountID, + turn, + truncateOpenAIWSLogValue(stripErr.Error(), openAIWSLogValueMaxLen), + ) + return payload, 0 + } + if strippedCount > 0 { + logOpenAIWSModeInfo( + "%s account_id=%d turn=%d stripped_items=%d", + logKey, + accountID, + turn, + strippedCount, + ) + } + return strippedPayload, strippedCount +} diff --git a/backend/internal/service/openai_encrypted_content_lineage_test.go b/backend/internal/service/openai_encrypted_content_lineage_test.go new file mode 100644 index 0000000000..1c97ece00b --- /dev/null +++ b/backend/internal/service/openai_encrypted_content_lineage_test.go @@ -0,0 +1,216 @@ +package service + +import ( + "encoding/json" + "fmt" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestCollectOpenAIEncryptedContentDigestsRaw(t *testing.T) { + t.Parallel() + + digests := collectOpenAIEncryptedContentDigestsRaw([]byte(`{"input":[ + {"type":"reasoning","encrypted_content":"cipher-a"}, + {"type":"compaction","encrypted_content":"cipher-b"}, + {"type":"input_text","text":"hello"}, + {"type":"reasoning"}, + {"type":"message","encrypted_content":"cipher-alien"} + ]}`)) + // 剥离端处理不了的类型(message)不收摘要,否则该摘要永远命中却剥不掉。 + require.Equal(t, []string{ + openAIEncryptedContentDigest("cipher-a"), + openAIEncryptedContentDigest("cipher-b"), + }, digests) + + require.Nil(t, collectOpenAIEncryptedContentDigestsRaw([]byte(`{"model":"gpt-5"}`))) +} + +func TestStripOpenAIInvalidEncryptedContentItems(t *testing.T) { + t.Parallel() + + invalid := map[string]struct{}{ + openAIEncryptedContentDigest("stale-cipher"): {}, + } + + t.Run("only_strips_matching_items", func(t *testing.T) { + t.Parallel() + reqBody := map[string]any{ + "input": []any{ + map[string]any{"type": "reasoning", "id": "rs_1", "encrypted_content": "stale-cipher", "summary": []any{}}, + map[string]any{"type": "reasoning", "id": "rs_2", "encrypted_content": "fresh-cipher"}, + map[string]any{"type": "input_text", "text": "hello"}, + }, + } + stripped := stripOpenAIInvalidEncryptedContentItems(reqBody, invalid) + require.Equal(t, 1, stripped) + input, ok := reqBody["input"].([]any) + require.True(t, ok) + require.Len(t, input, 3) + first, ok := input[0].(map[string]any) + require.True(t, ok) + _, hasEncrypted := first["encrypted_content"] + require.False(t, hasEncrypted, "命中项应剥离 encrypted_content") + require.Equal(t, "rs_1", first["id"], "reasoning 骨架应保留") + second, ok := input[1].(map[string]any) + require.True(t, ok) + require.Equal(t, "fresh-cipher", second["encrypted_content"], "新密文不得误删") + }) + + t.Run("compaction_removed_entirely", func(t *testing.T) { + t.Parallel() + reqBody := map[string]any{ + "input": []any{ + map[string]any{"type": "compaction", "encrypted_content": "stale-cipher"}, + map[string]any{"type": "input_text", "text": "hello"}, + }, + } + stripped := stripOpenAIInvalidEncryptedContentItems(reqBody, invalid) + require.Equal(t, 1, stripped) + input, ok := reqBody["input"].([]any) + require.True(t, ok) + require.Len(t, input, 1) + survivor, ok := input[0].(map[string]any) + require.True(t, ok) + require.Equal(t, "input_text", survivor["type"]) + }) + + t.Run("no_hit_no_change", func(t *testing.T) { + t.Parallel() + reqBody := map[string]any{ + "input": []any{ + map[string]any{"type": "reasoning", "encrypted_content": "fresh-cipher"}, + }, + } + require.Zero(t, stripOpenAIInvalidEncryptedContentItems(reqBody, invalid)) + }) +} + +func TestStripOpenAIInvalidEncryptedContentFromReplayItems(t *testing.T) { + t.Parallel() + + invalid := map[string]struct{}{ + openAIEncryptedContentDigest("stale-cipher"): {}, + } + + t.Run("rewrites_hit_items_and_shares_the_rest", func(t *testing.T) { + t.Parallel() + original := json.RawMessage(`{"type":"reasoning","id":"rs_1","encrypted_content":"stale-cipher"}`) + clean := json.RawMessage(`{"type":"input_text","text":"hello"}`) + compaction := json.RawMessage(`{"type":"compaction","encrypted_content":"stale-cipher"}`) + items := []json.RawMessage{original, clean, compaction} + + next, stripped := stripOpenAIInvalidEncryptedContentFromReplayItems(items, invalid) + require.Equal(t, 2, stripped) + require.Len(t, next, 2, "compaction 命中项应整项删除") + var rewritten map[string]any + require.NoError(t, json.Unmarshal(next[0], &rewritten)) + require.Equal(t, "rs_1", rewritten["id"]) + _, hasEncrypted := rewritten["encrypted_content"] + require.False(t, hasEncrypted) + // 未命中项正文共享;原命中项正文不被修改(整体替换语义)。 + require.Same(t, &clean[0], &next[1][0]) + require.Contains(t, string(original), "stale-cipher") + }) + + t.Run("miss_returns_original_header", func(t *testing.T) { + t.Parallel() + items := []json.RawMessage{json.RawMessage(`{"type":"reasoning","encrypted_content":"fresh-cipher"}`)} + next, stripped := stripOpenAIInvalidEncryptedContentFromReplayItems(items, invalid) + require.Zero(t, stripped) + require.Same(t, &items[0][0], &next[0][0]) + }) +} + +func TestStripOpenAIInvalidEncryptedContentRaw(t *testing.T) { + t.Parallel() + + invalid := map[string]struct{}{ + openAIEncryptedContentDigest("stale-cipher"): {}, + } + payload := []byte(`{"model":"gpt-5","input":[ + {"type":"reasoning","id":"rs_1","encrypted_content":"stale-cipher"}, + {"type":"reasoning","id":"rs_2","encrypted_content":"fresh-cipher"}, + {"type":"input_text","text":"hello"} + ],"stream":true}`) + + stripped, count, err := stripOpenAIInvalidEncryptedContentRaw(payload, invalid) + require.NoError(t, err) + require.Equal(t, 1, count) + var decoded map[string]any + require.NoError(t, json.Unmarshal(stripped, &decoded)) + require.Equal(t, "gpt-5", decoded["model"]) + input, ok := decoded["input"].([]any) + require.True(t, ok) + require.Len(t, input, 3) + first, ok := input[0].(map[string]any) + require.True(t, ok) + _, hasEncrypted := first["encrypted_content"] + require.False(t, hasEncrypted) + second, ok := input[1].(map[string]any) + require.True(t, ok) + require.Equal(t, "fresh-cipher", second["encrypted_content"]) + + t.Run("miss_fast_path_returns_original", func(t *testing.T) { + t.Parallel() + cleanPayload := []byte(`{"input":[{"type":"reasoning","encrypted_content":"fresh-cipher"}]}`) + unchanged, count, err := stripOpenAIInvalidEncryptedContentRaw(cleanPayload, invalid) + require.NoError(t, err) + require.Zero(t, count) + require.Same(t, &cleanPayload[0], &unchanged[0], "未命中时应原样返回,不重编码") + }) +} + +func TestOpenAIWSStateStoreInvalidEncryptedContentLineage(t *testing.T) { + t.Parallel() + + t.Run("mark_get_roundtrip_with_merge", func(t *testing.T) { + t.Parallel() + store := NewOpenAIWSStateStore(nil) + require.False(t, store.HasAnySessionInvalidEncryptedContent()) + require.Nil(t, store.GetSessionInvalidEncryptedContentDigests(1, "session-a")) + + store.MarkSessionInvalidEncryptedContent(1, "session-a", []string{"d1", "d2"}, time.Minute) + store.MarkSessionInvalidEncryptedContent(1, "session-a", []string{"d2", "d3"}, time.Minute) + require.True(t, store.HasAnySessionInvalidEncryptedContent()) + + digests := store.GetSessionInvalidEncryptedContentDigests(1, "session-a") + require.Len(t, digests, 3) + for _, digest := range []string{"d1", "d2", "d3"} { + require.Contains(t, digests, digest) + } + // 组隔离:另一组同名会话不可见。 + require.Nil(t, store.GetSessionInvalidEncryptedContentDigests(2, "session-a")) + // 返回值是拷贝,调用方修改不得影响存储。 + digests["d4"] = struct{}{} + require.Len(t, store.GetSessionInvalidEncryptedContentDigests(1, "session-a"), 3) + }) + + t.Run("expired_binding_not_returned", func(t *testing.T) { + t.Parallel() + raw := NewOpenAIWSStateStore(nil) + raw.MarkSessionInvalidEncryptedContent(1, "session-b", []string{"d1"}, time.Minute) + store, ok := raw.(*defaultOpenAIWSStateStore) + require.True(t, ok) + store.sessionInvalidEncryptedMu.Lock() + binding := store.sessionInvalidEncrypted["1:session-b"] + binding.expiresAt = time.Now().Add(-time.Second) + store.sessionInvalidEncrypted["1:session-b"] = binding + store.sessionInvalidEncryptedMu.Unlock() + require.Nil(t, store.GetSessionInvalidEncryptedContentDigests(1, "session-b")) + }) + + t.Run("per_session_capacity_degrades_gracefully", func(t *testing.T) { + t.Parallel() + store := NewOpenAIWSStateStore(nil) + oversized := make([]string, 0, openAIWSInvalidEncryptedDigestsPerSession+10) + for i := range openAIWSInvalidEncryptedDigestsPerSession + 10 { + oversized = append(oversized, openAIEncryptedContentDigest(fmt.Sprintf("cipher-%d", i))) + } + store.MarkSessionInvalidEncryptedContent(1, "session-c", oversized, time.Minute) + digests := store.GetSessionInvalidEncryptedContentDigests(1, "session-c") + require.Len(t, digests, openAIWSInvalidEncryptedDigestsPerSession) + }) +} diff --git a/backend/internal/service/openai_gateway_forward.go b/backend/internal/service/openai_gateway_forward.go index 304af59909..d748d6b51e 100644 --- a/backend/internal/service/openai_gateway_forward.go +++ b/backend/internal/service/openai_gateway_forward.go @@ -705,6 +705,26 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco requestView = newOpenAIRequestView(body) reqBody = nil } + // 剥离本会话已被上游判定失效的加密项(invalid_encrypted_content lineage), + // 阻断同一失效密文随客户端历史在每一轮重复触发"被拒→剥离→重试/重连"。 + // lineage 会话键统一按进场形态的 body 派生:后续重试可能改写 body, + // 延迟计算会与下一请求的进场键漂移。 + lineageGroupID := getOpenAIGroupIDFromContext(c) + lineageEntryBody := body + lineageSessionHash := "" + if stateStore := s.getOpenAIWSStateStore(); stateStore != nil && stateStore.HasAnySessionInvalidEncryptedContent() { + lineageSessionHash = s.GenerateSessionHash(c, body) + if invalidDigests := stateStore.GetSessionInvalidEncryptedContentDigests(lineageGroupID, lineageSessionHash); len(invalidDigests) > 0 { + strippedBody, strippedCount := s.stripSessionInvalidEncryptedContentLogged( + body, invalidDigests, "invalid_encrypted_lineage_strip", account.ID, 0, + ) + if strippedCount > 0 { + body = strippedBody + requestView = newOpenAIRequestView(body) + reqBody = nil + } + } + } imageBillingModel := "" imageSizeTier := "" imageInputSize := "" @@ -792,6 +812,10 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco if wsInvalidEncryptedContentRecoveryTried { return false } + // 写入 lineage 后,同一失效密文在后续 turn 进场时被预剥离,不再重复 + // 触发上游拒绝与重连。摘要取自进场形态的 body(密文项只可能来自 + // 客户端进场请求,重复摘要幂等)。 + invalidDigests := collectOpenAIEncryptedContentDigestsRaw(lineageEntryBody) removedReasoningItems := trimOpenAIEncryptedReasoningItems(wsReqBody) if !removedReasoningItems { logOpenAIWSModeInfo( @@ -801,6 +825,12 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco ) return false } + if len(invalidDigests) > 0 { + if lineageSessionHash == "" { + lineageSessionHash = s.GenerateSessionHash(c, lineageEntryBody) + } + s.markOpenAIWSInvalidEncryptedContentLineage(lineageGroupID, lineageSessionHash, invalidDigests) + } previousResponseID := openAIWSPayloadString(wsReqBody, "previous_response_id") hasFunctionCallOutput := HasFunctionCallOutput(wsReqBody) if previousResponseID != "" && !hasFunctionCallOutput { @@ -1052,11 +1082,18 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco if decodeErr != nil { return nil, decodeErr } + invalidDigests := collectOpenAIEncryptedContentDigestsRaw(lineageEntryBody) if trimOpenAIEncryptedReasoningItems(decoded) { body, err = marshalOpenAIUpstreamJSON(decoded) if err != nil { return nil, fmt.Errorf("serialize invalid_encrypted_content retry body: %w", err) } + if len(invalidDigests) > 0 { + if lineageSessionHash == "" { + lineageSessionHash = s.GenerateSessionHash(c, lineageEntryBody) + } + s.markOpenAIWSInvalidEncryptedContentLineage(lineageGroupID, lineageSessionHash, invalidDigests) + } httpInvalidEncryptedContentRetryTried = true rejectedFieldRetryState.remember(body) logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Retrying non-WSv2 request once after invalid_encrypted_content (account: %s)", account.Name) diff --git a/backend/internal/service/openai_ws_forwarder_ingress.go b/backend/internal/service/openai_ws_forwarder_ingress.go index 0991471ae6..74467d62e4 100644 --- a/backend/internal/service/openai_ws_forwarder_ingress.go +++ b/backend/internal/service/openai_ws_forwarder_ingress.go @@ -581,6 +581,26 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( if turnState != "" && c != nil && c.Request != nil { c.Request.Header.Set(openAIWSTurnStateHeader, turnState) } + if c != nil && sessionHash != "" { + c.Set(openAIWSIngressSessionHashContextKey, sessionHash) + } + // 剥离本会话已知失效的加密项,阻断同一失效密文随历史反复触发上游拒绝。 + // 历史序列须同步剥离,否则与已剥离的当前 input 项错位,prefix 复用失配。 + if invalidDigests := s.sessionInvalidEncryptedContentDigests(groupID, sessionHash); len(invalidDigests) > 0 { + strippedPayload, strippedCount := s.stripSessionInvalidEncryptedContentLogged( + currentBridgePayload.payloadRaw, invalidDigests, "ingress_ws_http_bridge_invalid_encrypted_lineage_strip", account.ID, turn, + ) + if strippedCount > 0 { + currentBridgePayload.payloadRaw = strippedPayload + currentBridgePayload.payloadBytes = len(strippedPayload) + } + if bridgeReplayInputExists { + bridgeReplayInput, _ = stripOpenAIInvalidEncryptedContentFromReplayItems(bridgeReplayInput, invalidDigests) + } + if bridgeAccountFailoverInputExists { + bridgeAccountFailoverInput, _ = stripOpenAIInvalidEncryptedContentFromReplayItems(bridgeAccountFailoverInput, invalidDigests) + } + } bridgePayloadRaw := currentBridgePayload.payloadRaw bridgePayloadBytes := currentBridgePayload.payloadBytes toolOutputCoverage := AnalyzeToolCallOutputContextCoverageBytes(currentBridgePayload.payloadRaw) @@ -1026,6 +1046,18 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( } s.persistOpenAIWSRateLimitSignal(ctx, account, lease.HandshakeHeaders(), upstreamMessage, errCodeRaw, errTypeRaw, errMsgRaw, mappedModel) fallbackReason, _ := classifyOpenAIWSErrorEventFromRaw(errCodeRaw, errTypeRaw, errMsgRaw) + if fallbackReason == openAIWSFallbackReasonInvalidEncryptedContent { + // 记录被上游拒绝的密文摘要;错误照旧透传,下一轮进场时按摘要预剥离。 + if digests := collectOpenAIEncryptedContentDigestsRaw(payload); len(digests) > 0 { + s.markOpenAIWSInvalidEncryptedContentLineage(groupID, sessionHash, digests) + logOpenAIWSModeInfo( + "ingress_ws_invalid_encrypted_lineage_mark account_id=%d turn=%d digests=%d", + account.ID, + turn, + len(digests), + ) + } + } errCode, errType, errMessage := summarizeOpenAIWSErrorEventFieldsFromRaw(errCodeRaw, errTypeRaw, errMsgRaw) recoverablePrevNotFound := fallbackReason == openAIWSIngressStagePreviousResponseNotFound && turnPreviousResponseID != "" && @@ -1411,6 +1443,20 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( } } skipBeforeTurn = false + // 剥离本会话已知失效的加密项,阻断同一失效密文随历史反复触发上游拒绝。 + // 历史序列须同步剥离,否则与已剥离的当前 input 项错位,prefix 复用失配。 + if invalidDigests := s.sessionInvalidEncryptedContentDigests(groupID, sessionHash); len(invalidDigests) > 0 { + strippedPayload, strippedCount := s.stripSessionInvalidEncryptedContentLogged( + currentPayload, invalidDigests, "ingress_ws_invalid_encrypted_lineage_strip", account.ID, turn, + ) + if strippedCount > 0 { + currentPayload = strippedPayload + currentPayloadBytes = len(strippedPayload) + } + if lastTurnReplayInputExists { + lastTurnReplayInput, _ = stripOpenAIInvalidEncryptedContentFromReplayItems(lastTurnReplayInput, invalidDigests) + } + } currentPreviousResponseID := openAIWSPayloadStringFromRaw(currentPayload, "previous_response_id") expectedPrev := strings.TrimSpace(lastTurnResponseID) toolSignals := ToolContinuationSignals{ diff --git a/backend/internal/service/openai_ws_forwarder_ingress_session_test.go b/backend/internal/service/openai_ws_forwarder_ingress_session_test.go index b05a2efd10..bd7d04b45c 100644 --- a/backend/internal/service/openai_ws_forwarder_ingress_session_test.go +++ b/backend/internal/service/openai_ws_forwarder_ingress_session_test.go @@ -4446,3 +4446,157 @@ func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_ClientDisconnect t.Fatal("未收到断连后的 turn 结果回调") } } + +func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_InvalidEncryptedContentLineageStripsNextTurn(t *testing.T) { + gin.SetMode(gin.TestMode) + + cfg := &config.Config{} + cfg.Security.URLAllowlist.Enabled = false + cfg.Security.URLAllowlist.AllowInsecureHTTP = true + cfg.Gateway.OpenAIWS.Enabled = true + cfg.Gateway.OpenAIWS.OAuthEnabled = true + cfg.Gateway.OpenAIWS.APIKeyEnabled = true + cfg.Gateway.OpenAIWS.ResponsesWebsocketsV2 = true + cfg.Gateway.OpenAIWS.MaxConnsPerAccount = 1 + cfg.Gateway.OpenAIWS.MinIdlePerAccount = 0 + cfg.Gateway.OpenAIWS.MaxIdlePerAccount = 1 + cfg.Gateway.OpenAIWS.QueueLimitPerConn = 8 + cfg.Gateway.OpenAIWS.DialTimeoutSeconds = 3 + cfg.Gateway.OpenAIWS.ReadTimeoutSeconds = 3 + cfg.Gateway.OpenAIWS.WriteTimeoutSeconds = 3 + + upstreamConn := &openAIWSCaptureConn{ + events: [][]byte{ + []byte(`{"type":"error","error":{"type":"invalid_request_error","code":"invalid_encrypted_content","message":"The encrypted content could not be verified"}}`), + []byte(`{"type":"response.failed","response":{"id":"resp_enc_lineage_1","model":"gpt-5.1","error":{"code":"invalid_encrypted_content","message":"The encrypted content could not be verified"}}}`), + []byte(`{"type":"response.completed","response":{"id":"resp_enc_lineage_2","model":"gpt-5.1","usage":{"input_tokens":1,"output_tokens":1}}}`), + }, + } + dialer := &openAIWSQueueDialer{ + conns: []openAIWSClientConn{upstreamConn}, + } + pool := newOpenAIWSConnPool(cfg) + pool.setClientDialerForTest(dialer) + + svc := &OpenAIGatewayService{ + cfg: cfg, + httpUpstream: &httpUpstreamRecorder{}, + cache: &stubGatewayCache{}, + openaiWSResolver: NewOpenAIWSProtocolResolver(cfg), + toolCorrector: NewCodexToolCorrector(), + openaiWSPool: pool, + } + + account := &Account{ + ID: 119, + Name: "openai-ingress-enc-lineage", + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Status: StatusActive, + Schedulable: true, + Concurrency: 1, + Credentials: map[string]any{ + "api_key": "sk-test", + }, + Extra: map[string]any{ + "responses_websockets_v2_enabled": true, + }, + } + + serverErrCh := make(chan error, 1) + wsServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + conn, err := coderws.Accept(w, r, &coderws.AcceptOptions{ + CompressionMode: coderws.CompressionContextTakeover, + }) + if err != nil { + serverErrCh <- err + return + } + defer func() { + _ = conn.CloseNow() + }() + + rec := httptest.NewRecorder() + ginCtx, _ := gin.CreateTestContext(rec) + req := r.Clone(r.Context()) + req.Header = req.Header.Clone() + req.Header.Set("User-Agent", "unit-test-agent/1.0") + ginCtx.Request = req + + readCtx, cancel := context.WithTimeout(r.Context(), 3*time.Second) + msgType, firstMessage, readErr := conn.Read(readCtx) + cancel() + if readErr != nil { + serverErrCh <- readErr + return + } + if msgType != coderws.MessageText && msgType != coderws.MessageBinary { + serverErrCh <- errors.New("unsupported websocket client message type") + return + } + + serverErrCh <- svc.ProxyResponsesWebSocketFromClient(r.Context(), ginCtx, conn, account, "sk-test", firstMessage, nil) + })) + defer wsServer.Close() + + dialCtx, cancelDial := context.WithTimeout(context.Background(), 3*time.Second) + clientConn, _, err := coderws.Dial(dialCtx, "ws"+strings.TrimPrefix(wsServer.URL, "http"), nil) + cancelDial() + require.NoError(t, err) + defer func() { + _ = clientConn.CloseNow() + }() + + writeMessage := func(payload string) { + writeCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + require.NoError(t, clientConn.Write(writeCtx, coderws.MessageText, []byte(payload))) + } + readMessage := func() []byte { + readCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + msgType, message, readErr := clientConn.Read(readCtx) + require.NoError(t, readErr) + require.Equal(t, coderws.MessageText, msgType) + return message + } + + // turn1:携带失效密文,上游以 invalid_encrypted_content 拒绝(error + response.failed 透传给客户端)。 + writeMessage(`{"type":"response.create","model":"gpt-5.1","stream":false,"input":[{"type":"reasoning","id":"rs_1","encrypted_content":"stale-cipher","summary":[]},{"type":"input_text","text":"hi"}]}`) + firstEvent := readMessage() + require.Equal(t, "error", gjson.GetBytes(firstEvent, "type").String()) + require.Equal(t, "invalid_encrypted_content", gjson.GetBytes(firstEvent, "error.code").String()) + secondEvent := readMessage() + require.Equal(t, "response.failed", gjson.GetBytes(secondEvent, "type").String()) + + // turn2:客户端历史仍带同一失效密文,进场应被 lineage 预剥离后再发上游。 + writeMessage(`{"type":"response.create","model":"gpt-5.1","stream":false,"input":[{"type":"reasoning","id":"rs_1","encrypted_content":"stale-cipher","summary":[]},{"type":"input_text","text":"hi"},{"type":"input_text","text":"again"}]}`) + thirdEvent := readMessage() + require.Equal(t, "response.completed", gjson.GetBytes(thirdEvent, "type").String()) + require.Equal(t, "resp_enc_lineage_2", gjson.GetBytes(thirdEvent, "response.id").String()) + + require.NoError(t, clientConn.Close(coderws.StatusNormalClosure, "done")) + select { + case serverErr := <-serverErrCh: + require.NoError(t, serverErr) + case <-time.After(5 * time.Second): + t.Fatal("等待 ingress websocket 结束超时") + } + + upstreamConn.mu.Lock() + writes := append([]map[string]any(nil), upstreamConn.writes...) + upstreamConn.mu.Unlock() + require.Len(t, writes, 2, "两轮各应发送一次上游请求") + + firstUpstream := requestToJSONString(writes[0]) + require.Equal(t, "stale-cipher", gjson.Get(firstUpstream, "input.0.encrypted_content").String(), "首轮请求原样携带密文") + + secondUpstream := requestToJSONString(writes[1]) + secondInput := gjson.Get(secondUpstream, "input").Array() + require.Len(t, secondInput, 3, "剥离仅移除 encrypted_content 字段,reasoning 骨架保留") + for _, item := range secondInput { + require.False(t, item.Get("encrypted_content").Exists(), "第二轮请求不得再携带已失效密文: %s", item.Raw) + } + require.Equal(t, "rs_1", gjson.Get(secondUpstream, "input.0.id").String()) + require.Equal(t, "again", gjson.Get(secondUpstream, "input.2.text").String()) +} diff --git a/backend/internal/service/openai_ws_http_bridge.go b/backend/internal/service/openai_ws_http_bridge.go index 80965125f6..cab500f276 100644 --- a/backend/internal/service/openai_ws_http_bridge.go +++ b/backend/internal/service/openai_ws_http_bridge.go @@ -438,6 +438,12 @@ func (s *OpenAIGatewayService) proxyOpenAIWSHTTPBridgeTurn( respBody, _ := io.ReadAll(io.LimitReader(resp.Body, openAIWSHTTPBridgeErrorBodyLimitBytes)) _ = resp.Body.Close() + if resp.StatusCode == http.StatusBadRequest && + extractUpstreamErrorCode(respBody) == openAIWSFallbackReasonInvalidEncryptedContent { + s.markOpenAIWSInvalidEncryptedContentLineageFromPayload( + c, body, "ingress_ws_http_bridge_invalid_encrypted_lineage_mark", account.ID, turn, + ) + } retryBody, retryReason, changed, retryErr := normalizeOpenAIResponsesRejectedFieldRetryBody(resp.StatusCode, body, respBody) if retryErr != nil { return nil, fmt.Errorf("normalize websocket http bridge rejected field retry: %w", retryErr) @@ -680,6 +686,11 @@ func (s *OpenAIGatewayService) proxyOpenAIWSHTTPBridgeTurn( if account.Platform == PlatformGrok { statusCode = openAIWSErrorHTTPStatusFromRaw(errCodeRaw, errTypeRaw) } + if reason, _ := classifyOpenAIWSErrorEventFromRaw(errCodeRaw, errTypeRaw, errMessage); reason == openAIWSFallbackReasonInvalidEncryptedContent { + s.markOpenAIWSInvalidEncryptedContentLineageFromPayload( + c, body, "ingress_ws_http_bridge_invalid_encrypted_lineage_mark", account.ID, turn, + ) + } } requestScopedCapacity := isOpenAIUpstreamCapacityShedEvent(upstreamMessage) if account.Platform == PlatformGrok && eventType == "error" { diff --git a/backend/internal/service/openai_ws_state_store.go b/backend/internal/service/openai_ws_state_store.go index fe4ee54926..70658f6f61 100644 --- a/backend/internal/service/openai_ws_state_store.go +++ b/backend/internal/service/openai_ws_state_store.go @@ -47,6 +47,18 @@ type openAIWSSessionConnBinding struct { expiresAt time.Time } +// openAIWSInvalidEncryptedBinding 记录一个会话中已被上游判定失效的 +// encrypted_content 摘要(invalid_encrypted_content lineage)。 +type openAIWSInvalidEncryptedBinding struct { + digests map[string]struct{} + expiresAt time.Time +} + +// openAIWSInvalidEncryptedDigestsPerSession 是单会话摘要集合的存储自保护上限。 +// 超出后新增摘要被忽略,仅退化为"该项下次仍触发一次上游拒绝后的常规 recovery", +// 不影响正确性。 +const openAIWSInvalidEncryptedDigestsPerSession = 512 + // OpenAIWSStateStore 管理 WSv2 的粘连状态。 // - response_id -> account_id 用于续链路由 // - response_id -> conn_id 用于连接内上下文复用 @@ -71,6 +83,15 @@ type OpenAIWSStateStore interface { BindSessionConn(groupID int64, sessionHash, connID string, ttl time.Duration) GetSessionConn(groupID int64, sessionHash string) (string, bool) DeleteSessionConn(groupID int64, sessionHash string) + + // invalid_encrypted_content lineage:按会话记录已被上游拒绝的 + // encrypted_content 摘要,后续 turn 进场时仅剥离命中项,避免同一失效 + // 密文随客户端历史反复触发"整包被拒→剥离→重试/重连"。仅进程内有效。 + MarkSessionInvalidEncryptedContent(groupID int64, sessionHash string, digests []string, ttl time.Duration) + GetSessionInvalidEncryptedContentDigests(groupID int64, sessionHash string) map[string]struct{} + // HasAnySessionInvalidEncryptedContent 是热路径快速探测:全局无记录时 + // 调用方可跳过会话哈希计算与摘要匹配。 + HasAnySessionInvalidEncryptedContent() bool } type defaultOpenAIWSStateStore struct { @@ -87,18 +108,22 @@ type defaultOpenAIWSStateStore struct { sessionToConnMu sync.RWMutex sessionToConn map[string]openAIWSSessionConnBinding + sessionInvalidEncryptedMu sync.RWMutex + sessionInvalidEncrypted map[string]openAIWSInvalidEncryptedBinding + lastCleanupUnixNano atomic.Int64 } // NewOpenAIWSStateStore 创建默认 WS 状态存储。 func NewOpenAIWSStateStore(cache GatewayCache) OpenAIWSStateStore { store := &defaultOpenAIWSStateStore{ - cache: cache, - responseToAccount: make(map[string]openAIWSAccountBinding, 256), - responseOwners: make(map[string]openAIHTTPResponseOwnerBinding, 256), - responseToConn: make(map[string]openAIWSConnBinding, 256), - sessionToTurnState: make(map[string]openAIWSTurnStateBinding, 256), - sessionToConn: make(map[string]openAIWSSessionConnBinding, 256), + cache: cache, + responseToAccount: make(map[string]openAIWSAccountBinding, 256), + responseOwners: make(map[string]openAIHTTPResponseOwnerBinding, 256), + responseToConn: make(map[string]openAIWSConnBinding, 256), + sessionToTurnState: make(map[string]openAIWSTurnStateBinding, 256), + sessionToConn: make(map[string]openAIWSSessionConnBinding, 256), + sessionInvalidEncrypted: make(map[string]openAIWSInvalidEncryptedBinding), } store.lastCleanupUnixNano.Store(time.Now().UnixNano()) return store @@ -396,6 +421,76 @@ func (s *defaultOpenAIWSStateStore) DeleteSessionConn(groupID int64, sessionHash s.sessionToConnMu.Unlock() } +func (s *defaultOpenAIWSStateStore) MarkSessionInvalidEncryptedContent(groupID int64, sessionHash string, digests []string, ttl time.Duration) { + key := openAIWSSessionTurnStateKey(groupID, sessionHash) + if key == "" || len(digests) == 0 { + return + } + ttl = normalizeOpenAIWSTTL(ttl) + s.maybeCleanup() + + now := time.Now() + overflowed := 0 + s.sessionInvalidEncryptedMu.Lock() + ensureBindingCapacity(s.sessionInvalidEncrypted, key, openAIWSStateStoreMaxEntriesPerMap) + binding, ok := s.sessionInvalidEncrypted[key] + if !ok || now.After(binding.expiresAt) || binding.digests == nil { + binding = openAIWSInvalidEncryptedBinding{digests: make(map[string]struct{}, len(digests))} + } + for _, digest := range digests { + digest = strings.TrimSpace(digest) + if digest == "" { + continue + } + if len(binding.digests) >= openAIWSInvalidEncryptedDigestsPerSession { + if _, exists := binding.digests[digest]; !exists { + overflowed++ + } + continue + } + binding.digests[digest] = struct{}{} + } + binding.expiresAt = now.Add(ttl) + s.sessionInvalidEncrypted[key] = binding + s.sessionInvalidEncryptedMu.Unlock() + + // HasAny/Get 在每个 OpenAI 请求热路径上抢同一把锁,日志 IO 必须在锁外。 + if overflowed > 0 { + logOpenAIWSModeInfo( + "invalid_encrypted_lineage_capacity_overflow dropped_digests=%d capacity=%d", + overflowed, + openAIWSInvalidEncryptedDigestsPerSession, + ) + } +} + +func (s *defaultOpenAIWSStateStore) GetSessionInvalidEncryptedContentDigests(groupID int64, sessionHash string) map[string]struct{} { + key := openAIWSSessionTurnStateKey(groupID, sessionHash) + if key == "" { + return nil + } + s.maybeCleanup() + + now := time.Now() + s.sessionInvalidEncryptedMu.RLock() + defer s.sessionInvalidEncryptedMu.RUnlock() + binding, ok := s.sessionInvalidEncrypted[key] + if !ok || now.After(binding.expiresAt) || len(binding.digests) == 0 { + return nil + } + digests := make(map[string]struct{}, len(binding.digests)) + for digest := range binding.digests { + digests[digest] = struct{}{} + } + return digests +} + +func (s *defaultOpenAIWSStateStore) HasAnySessionInvalidEncryptedContent() bool { + s.sessionInvalidEncryptedMu.RLock() + defer s.sessionInvalidEncryptedMu.RUnlock() + return len(s.sessionInvalidEncrypted) > 0 +} + func (s *defaultOpenAIWSStateStore) maybeCleanup() { if s == nil { return @@ -429,6 +524,26 @@ func (s *defaultOpenAIWSStateStore) maybeCleanup() { s.sessionToConnMu.Lock() cleanupExpiredSessionConnBindings(s.sessionToConn, now, openAIWSStateStoreCleanupMaxPerMap) s.sessionToConnMu.Unlock() + + s.sessionInvalidEncryptedMu.Lock() + cleanupExpiredInvalidEncryptedBindings(s.sessionInvalidEncrypted, now, openAIWSStateStoreCleanupMaxPerMap) + s.sessionInvalidEncryptedMu.Unlock() +} + +func cleanupExpiredInvalidEncryptedBindings(bindings map[string]openAIWSInvalidEncryptedBinding, now time.Time, maxScan int) { + if len(bindings) == 0 || maxScan <= 0 { + return + } + scanned := 0 + for key, binding := range bindings { + if now.After(binding.expiresAt) { + delete(bindings, key) + } + scanned++ + if scanned >= maxScan { + break + } + } } func cleanupExpiredAccountBindings(bindings map[string]openAIWSAccountBinding, now time.Time, maxScan int) { From 163eb7ffae9c1327735fc5cadb108eda67d3ecf2 Mon Sep 17 00:00:00 2001 From: cyh Date: Mon, 31 Aug 2026 19:04:58 +0800 Subject: [PATCH 006/145] fix(ops): bound database-backed system logs --- backend/internal/config/config.go | 10 ++- backend/internal/config/config_test.go | 20 +++++ .../service/ops_cleanup_overlay_test.go | 81 +++++++++++++++-- .../internal/service/ops_cleanup_service.go | 86 ++++++++++++------- backend/internal/service/ops_log_runtime.go | 50 ++++++++--- .../internal/service/ops_log_runtime_test.go | 77 ++++++++++++----- .../service/ops_runtime_snapshot_test.go | 43 +++++++++- backend/internal/service/ops_service.go | 21 ++++- backend/internal/service/ops_settings.go | 17 +++- .../service/ops_settings_advanced_test.go | 18 ++++ .../internal/service/ops_settings_models.go | 14 +-- .../internal/service/ops_system_log_sink.go | 20 +++-- .../service/ops_system_log_sink_test.go | 30 ++++++- frontend/src/api/admin/ops.ts | 1 + frontend/src/i18n/locales/en/admin/ops.ts | 3 + frontend/src/i18n/locales/zh/admin/ops.ts | 3 + .../ops/components/OpsSystemLogTable.vue | 10 +++ .../__tests__/OpsSystemLogTable.spec.ts | 17 ++++ 18 files changed, 418 insertions(+), 103 deletions(-) diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 8c856cbf61..e52c260b36 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -1611,10 +1611,10 @@ type OpsCleanupConfig struct { Enabled bool `mapstructure:"enabled"` Schedule string `mapstructure:"schedule"` - // Retention days (0 disables that cleanup target). - // - // vNext requirement: default 30 days across ops datasets. + // Retention days. Error and metrics targets accept 0 as an explicit truncate; + // system logs require a positive value because their runtime setting is bounded. ErrorLogRetentionDays int `mapstructure:"error_log_retention_days"` + SystemLogRetentionDays int `mapstructure:"system_log_retention_days"` MinuteMetricsRetentionDays int `mapstructure:"minute_metrics_retention_days"` HourlyMetricsRetentionDays int `mapstructure:"hourly_metrics_retention_days"` } @@ -2248,6 +2248,7 @@ func setDefaults() { viper.SetDefault("ops.cleanup.schedule", "0 2 * * *") // Retention days: vNext defaults to 30 days across ops datasets. viper.SetDefault("ops.cleanup.error_log_retention_days", 30) + viper.SetDefault("ops.cleanup.system_log_retention_days", 30) viper.SetDefault("ops.cleanup.minute_metrics_retention_days", 30) viper.SetDefault("ops.cleanup.hourly_metrics_retention_days", 30) viper.SetDefault("ops.aggregation.enabled", true) @@ -3670,6 +3671,9 @@ func (c *Config) Validate() error { if c.Ops.Cleanup.ErrorLogRetentionDays < 0 { return fmt.Errorf("ops.cleanup.error_log_retention_days must be non-negative") } + if c.Ops.Cleanup.Enabled && c.Ops.Cleanup.SystemLogRetentionDays <= 0 { + return fmt.Errorf("ops.cleanup.system_log_retention_days must be positive when ops cleanup is enabled") + } if c.Ops.Cleanup.MinuteMetricsRetentionDays < 0 { return fmt.Errorf("ops.cleanup.minute_metrics_retention_days must be non-negative") } diff --git a/backend/internal/config/config_test.go b/backend/internal/config/config_test.go index a8fbba0977..717960a91a 100644 --- a/backend/internal/config/config_test.go +++ b/backend/internal/config/config_test.go @@ -1191,6 +1191,21 @@ func TestLoadDefaultUsageCleanupConfig(t *testing.T) { } } +func TestLoadDefaultOpsCleanupConfig(t *testing.T) { + resetViperWithJWTSecret(t) + + cfg, err := Load() + if err != nil { + t.Fatalf("Load() error: %v", err) + } + if !cfg.Ops.Cleanup.Enabled { + t.Fatal("Ops.Cleanup.Enabled = false, want true") + } + if cfg.Ops.Cleanup.SystemLogRetentionDays != 30 { + t.Fatalf("Ops.Cleanup.SystemLogRetentionDays = %d, want 30", cfg.Ops.Cleanup.SystemLogRetentionDays) + } +} + func TestValidateUsageCleanupConfigEnabled(t *testing.T) { resetViperWithJWTSecret(t) @@ -2137,6 +2152,11 @@ func TestValidateConfigErrors(t *testing.T) { mutate: func(c *Config) { c.Ops.Cleanup.ErrorLogRetentionDays = -1 }, wantErr: "ops.cleanup.error_log_retention_days", }, + { + name: "ops cleanup system log retention", + mutate: func(c *Config) { c.Ops.Cleanup.SystemLogRetentionDays = 0 }, + wantErr: "ops.cleanup.system_log_retention_days", + }, { name: "ops cleanup minute retention", mutate: func(c *Config) { c.Ops.Cleanup.MinuteMetricsRetentionDays = -1 }, diff --git a/backend/internal/service/ops_cleanup_overlay_test.go b/backend/internal/service/ops_cleanup_overlay_test.go index f751a42627..f1e1cbf9dc 100644 --- a/backend/internal/service/ops_cleanup_overlay_test.go +++ b/backend/internal/service/ops_cleanup_overlay_test.go @@ -45,7 +45,8 @@ func TestComputeEffective_FallbackToCfgWhenSettingsAbsent(t *testing.T) { svc.computeEffectiveLocked(context.Background()) - if svc.effective != base { + want := opsCleanupEffectiveConfig{OpsCleanupConfig: base, SystemLogRetentionDays: 30} + if svc.effective != want { t.Fatalf("expected effective == cfg base, got %#v", svc.effective) } } @@ -70,12 +71,15 @@ func TestComputeEffective_SettingsOverridesAll(t *testing.T) { svc.computeEffectiveLocked(context.Background()) - want := config.OpsCleanupConfig{ - Enabled: true, - Schedule: "0 * * * *", - ErrorLogRetentionDays: 0, - MinuteMetricsRetentionDays: 7, - HourlyMetricsRetentionDays: 14, + want := opsCleanupEffectiveConfig{ + OpsCleanupConfig: config.OpsCleanupConfig{ + Enabled: true, + Schedule: "0 * * * *", + ErrorLogRetentionDays: 0, + MinuteMetricsRetentionDays: 7, + HourlyMetricsRetentionDays: 14, + }, + SystemLogRetentionDays: 30, } if svc.effective != want { t.Fatalf("effective mismatch:\nwant %#v\n got %#v", want, svc.effective) @@ -156,11 +160,72 @@ func TestComputeEffective_BadJSONFallsBackToCfg(t *testing.T) { svc.computeEffectiveLocked(context.Background()) - if svc.effective != base { + want := opsCleanupEffectiveConfig{OpsCleanupConfig: base, SystemLogRetentionDays: 30} + if svc.effective != want { t.Fatalf("expected fallback to cfg on bad JSON, got %#v", svc.effective) } } +func TestComputeEffective_RuntimeConfigOwnsSystemLogRetention(t *testing.T) { + repo := newRuntimeSettingRepoStub() + writeAdvancedSettings(t, repo, OpsDataRetentionSettings{ + CleanupEnabled: true, + CleanupSchedule: "0 2 * * *", + ErrorLogRetentionDays: 90, + }) + repo.values[SettingKeyOpsRuntimeLogConfig] = `{"retention_days":7}` + svc := makeOverlayService(repo, config.OpsCleanupConfig{ErrorLogRetentionDays: 30}) + + svc.computeEffectiveLocked(context.Background()) + + if svc.effective.ErrorLogRetentionDays != 90 { + t.Fatalf("error log retention = %d, want 90", svc.effective.ErrorLogRetentionDays) + } + if svc.effective.SystemLogRetentionDays != 7 { + t.Fatalf("system log retention = %d, want 7", svc.effective.SystemLogRetentionDays) + } +} + +func TestComputeEffective_InvalidRuntimeConfigDoesNotDiscardAdvancedSettings(t *testing.T) { + repo := newRuntimeSettingRepoStub() + writeAdvancedSettings(t, repo, OpsDataRetentionSettings{ + CleanupEnabled: true, + CleanupSchedule: "0 * * * *", + ErrorLogRetentionDays: 14, + }) + repo.values[SettingKeyOpsRuntimeLogConfig] = `{invalid-json}` + svc := makeOverlayService(repo, config.OpsCleanupConfig{ErrorLogRetentionDays: 30}) + + svc.computeEffectiveLocked(context.Background()) + + if !svc.effective.Enabled || svc.effective.ErrorLogRetentionDays != 14 { + t.Fatalf("advanced settings were discarded: %#v", svc.effective) + } + if svc.effective.SystemLogRetentionDays != 30 { + t.Fatalf("system log retention = %d, want default 30", svc.effective.SystemLogRetentionDays) + } +} + +func TestComputeEffective_PartialAdvancedSettingsUseSafeDefaults(t *testing.T) { + repo := newRuntimeSettingRepoStub() + repo.values[SettingKeyOpsAdvancedSettings] = `{"data_retention":{"cleanup_enabled":true}}` + svc := makeOverlayService(repo, config.OpsCleanupConfig{ + Schedule: "0 3 * * *", + ErrorLogRetentionDays: 90, + MinuteMetricsRetentionDays: 90, + HourlyMetricsRetentionDays: 90, + }) + + svc.computeEffectiveLocked(context.Background()) + + if svc.effective.Schedule != opsCleanupDefaultSchedule || + svc.effective.ErrorLogRetentionDays != 30 || + svc.effective.MinuteMetricsRetentionDays != 30 || + svc.effective.HourlyMetricsRetentionDays != 30 { + t.Fatalf("partial settings did not use safe defaults: %#v", svc.effective) + } +} + // 验证 OpsService.UpdateOpsAdvancedSettings 写入后会调用 cleanupReloader.Reload。 type fakeCleanupReloader struct { calls int diff --git a/backend/internal/service/ops_cleanup_service.go b/backend/internal/service/ops_cleanup_service.go index 38e94fb17a..52024e9abc 100644 --- a/backend/internal/service/ops_cleanup_service.go +++ b/backend/internal/service/ops_cleanup_service.go @@ -4,7 +4,6 @@ import ( "context" "database/sql" "encoding/json" - "errors" "fmt" "strings" "sync" @@ -59,11 +58,16 @@ type OpsCleanupService struct { cron *cron.Cron started bool stopped bool - effective config.OpsCleanupConfig + effective opsCleanupEffectiveConfig warnNoRedisOnce sync.Once } +type opsCleanupEffectiveConfig struct { + config.OpsCleanupConfig + SystemLogRetentionDays int +} + func NewOpsCleanupService( opsRepo OpsRepository, db *sql.DB, @@ -166,9 +170,10 @@ func (s *OpsCleanupService) applyScheduleLocked(ctx context.Context) error { c.Start() s.cron = c logger.LegacyPrintf("service.ops_cleanup", - "[OpsCleanup] scheduled (schedule=%q tz=%s retention_days=err:%d/min:%d/hour:%d)", + "[OpsCleanup] scheduled (schedule=%q tz=%s retention_days=err:%d/system:%d/min:%d/hour:%d)", schedule, loc.String(), s.effective.ErrorLogRetentionDays, + s.effective.SystemLogRetentionDays, s.effective.MinuteMetricsRetentionDays, s.effective.HourlyMetricsRetentionDays, ) @@ -198,11 +203,14 @@ func (s *OpsCleanupService) Reload(ctx context.Context) error { // - Schedule:settings 非空时覆盖,否则保留 cfg // - *RetentionDays:settings >=0 时覆盖(包括 0=TRUNCATE),<0 沿用 cfg // -// 若 settings 表无该 key(ErrSettingNotFound)或解析失败,整体 fallback 到 cfg.Ops.Cleanup。 +// System log retention has a separate owner: settings.ops_runtime_log_config.retention_days. +// Missing or invalid settings fall back independently instead of disabling all cleanup overlays. func (s *OpsCleanupService) computeEffectiveLocked(ctx context.Context) { - base := config.OpsCleanupConfig{} + base := opsCleanupEffectiveConfig{ + SystemLogRetentionDays: defaultOpsRuntimeLogConfig(s.cfg).RetentionDays, + } if s.cfg != nil { - base = s.cfg.Ops.Cleanup + base.OpsCleanupConfig = s.cfg.Ops.Cleanup } defer func() { s.effective = base }() @@ -212,38 +220,54 @@ func (s *OpsCleanupService) computeEffectiveLocked(ctx context.Context) { if ctx == nil { ctx = context.Background() } - raw, err := s.settingRepo.GetValue(ctx, SettingKeyOpsAdvancedSettings) + values, err := s.settingRepo.GetMultiple(ctx, []string{ + SettingKeyOpsAdvancedSettings, + SettingKeyOpsRuntimeLogConfig, + }) if err != nil { - if !errors.Is(err, ErrSettingNotFound) { - logger.LegacyPrintf("service.ops_cleanup", - "[OpsCleanup] read advanced settings failed, using cfg: %v", err) - } - return - } - var adv OpsAdvancedSettings - if err := json.Unmarshal([]byte(raw), &adv); err != nil { logger.LegacyPrintf("service.ops_cleanup", - "[OpsCleanup] parse advanced settings failed, using cfg: %v", err) + "[OpsCleanup] read cleanup settings failed, using defaults: %v", err) return } - dr := adv.DataRetention - base.Enabled = dr.CleanupEnabled - if sched := strings.TrimSpace(dr.CleanupSchedule); sched != "" { - base.Schedule = sched + if raw, ok := values[SettingKeyOpsAdvancedSettings]; ok { + adv := defaultOpsAdvancedSettingsForConfig(s.cfg) + if err := json.Unmarshal([]byte(raw), adv); err != nil { + logger.LegacyPrintf("service.ops_cleanup", + "[OpsCleanup] parse advanced settings failed, using cfg: %v", err) + } else { + dr := adv.DataRetention + base.Enabled = dr.CleanupEnabled + if sched := strings.TrimSpace(dr.CleanupSchedule); sched != "" { + base.Schedule = sched + } + if dr.ErrorLogRetentionDays >= 0 { + base.ErrorLogRetentionDays = dr.ErrorLogRetentionDays + } + if dr.MinuteMetricsRetentionDays >= 0 { + base.MinuteMetricsRetentionDays = dr.MinuteMetricsRetentionDays + } + if dr.HourlyMetricsRetentionDays >= 0 { + base.HourlyMetricsRetentionDays = dr.HourlyMetricsRetentionDays + } + } } - if dr.ErrorLogRetentionDays >= 0 { - base.ErrorLogRetentionDays = dr.ErrorLogRetentionDays - } - if dr.MinuteMetricsRetentionDays >= 0 { - base.MinuteMetricsRetentionDays = dr.MinuteMetricsRetentionDays - } - if dr.HourlyMetricsRetentionDays >= 0 { - base.HourlyMetricsRetentionDays = dr.HourlyMetricsRetentionDays + if raw, ok := values[SettingKeyOpsRuntimeLogConfig]; ok { + defaults := defaultOpsRuntimeLogConfig(s.cfg) + runtimeCfg := *defaults + if err := json.Unmarshal([]byte(raw), &runtimeCfg); err != nil { + logger.LegacyPrintf("service.ops_cleanup", + "[OpsCleanup] parse runtime log config failed, using default system-log retention: %v", err) + } else { + normalizeOpsRuntimeLogConfig(&runtimeCfg, defaults) + if runtimeCfg.RetentionDays >= 1 && runtimeCfg.RetentionDays <= 3650 { + base.SystemLogRetentionDays = runtimeCfg.RetentionDays + } + } } } // snapshotEffective 取一份 effective 副本(runCleanupOnce 等读路径使用)。 -func (s *OpsCleanupService) snapshotEffective() config.OpsCleanupConfig { +func (s *OpsCleanupService) snapshotEffective() opsCleanupEffectiveConfig { s.mu.Lock() defer s.mu.Unlock() return s.effective @@ -305,8 +329,8 @@ func (s *OpsCleanupService) runCleanupOnce(ctx context.Context) (opsCleanupDelet {effective.ErrorLogRetentionDays, "ops_error_logs", "created_at", false, &out.errorLogs}, {effective.ErrorLogRetentionDays, "ops_ingress_reject_aggregates", "bucket_start", false, &out.ingressRejects}, {effective.ErrorLogRetentionDays, "ops_alert_events", "created_at", false, &out.alertEvents}, - {effective.ErrorLogRetentionDays, "ops_system_logs", "created_at", false, &out.systemLogs}, - {effective.ErrorLogRetentionDays, "ops_system_log_cleanup_audits", "created_at", false, &out.logAudits}, + {effective.SystemLogRetentionDays, "ops_system_logs", "created_at", false, &out.systemLogs}, + {effective.SystemLogRetentionDays, "ops_system_log_cleanup_audits", "created_at", false, &out.logAudits}, {effective.MinuteMetricsRetentionDays, "ops_system_metrics", "created_at", false, &out.systemMetrics}, {effective.HourlyMetricsRetentionDays, "ops_metrics_hourly", "bucket_start", false, &out.hourlyPreagg}, {effective.HourlyMetricsRetentionDays, "ops_metrics_daily", "bucket_date", true, &out.dailyPreagg}, diff --git a/backend/internal/service/ops_log_runtime.go b/backend/internal/service/ops_log_runtime.go index ed8aefa9a3..637ab8d69f 100644 --- a/backend/internal/service/ops_log_runtime.go +++ b/backend/internal/service/ops_log_runtime.go @@ -15,13 +15,14 @@ import ( func defaultOpsRuntimeLogConfig(cfg *config.Config) *OpsRuntimeLogConfig { out := &OpsRuntimeLogConfig{ - Level: "info", - EnableSampling: false, - SamplingInitial: 100, - SamplingNext: 100, - Caller: true, - StacktraceLevel: "error", - RetentionDays: 30, + Level: "info", + PersistAccessLogs: false, + EnableSampling: false, + SamplingInitial: 100, + SamplingNext: 100, + Caller: true, + StacktraceLevel: "error", + RetentionDays: 30, } if cfg == nil { return out @@ -32,8 +33,8 @@ func defaultOpsRuntimeLogConfig(cfg *config.Config) *OpsRuntimeLogConfig { out.SamplingNext = cfg.Log.Sampling.Thereafter out.Caller = cfg.Log.Caller out.StacktraceLevel = strings.ToLower(strings.TrimSpace(cfg.Log.StacktraceLevel)) - if cfg.Ops.Cleanup.ErrorLogRetentionDays > 0 { - out.RetentionDays = cfg.Ops.Cleanup.ErrorLogRetentionDays + if cfg.Ops.Cleanup.SystemLogRetentionDays > 0 { + out.RetentionDays = cfg.Ops.Cleanup.SystemLogRetentionDays } return out } @@ -144,7 +145,7 @@ func (s *OpsService) UpdateRuntimeLogConfig(ctx context.Context, req *OpsRuntime return nil, err } - if err := applyOpsRuntimeLogConfig(&next); err != nil { + if err := s.applyRuntimeLogConfig(&next); err != nil { s.auditRuntimeLogConfigFailure(operatorID, oldCfg, &next, "apply_failed: "+err.Error()) return nil, err } @@ -159,12 +160,13 @@ func (s *OpsService) UpdateRuntimeLogConfig(ctx context.Context, req *OpsRuntime } if err := s.settingRepo.Set(ctx, SettingKeyOpsRuntimeLogConfig, string(encoded)); err != nil { // 存储失败时回滚到旧配置,避免内存状态与持久化状态不一致。 - _ = applyOpsRuntimeLogConfig(oldCfg) + _ = s.applyRuntimeLogConfig(oldCfg) s.auditRuntimeLogConfigFailure(operatorID, oldCfg, &next, "persist_failed: "+err.Error()) return nil, err } s.auditRuntimeLogConfigChange(operatorID, oldCfg, &next, "updated") + s.reloadOpsCleanup(ctx) return &next, nil } @@ -191,14 +193,14 @@ func (s *OpsService) ResetRuntimeLogConfig(ctx context.Context, operatorID int64 s.auditRuntimeLogConfigFailure(operatorID, oldCfg, resetCfg, "reset_validation_failed: "+err.Error()) return nil, err } - if err := applyOpsRuntimeLogConfig(resetCfg); err != nil { + if err := s.applyRuntimeLogConfig(resetCfg); err != nil { s.auditRuntimeLogConfigFailure(operatorID, oldCfg, resetCfg, "reset_apply_failed: "+err.Error()) return nil, err } // 清理 runtime 覆盖配置,回退到 env/yaml baseline。 if err := s.settingRepo.Delete(ctx, SettingKeyOpsRuntimeLogConfig); err != nil && !errors.Is(err, ErrSettingNotFound) { - _ = applyOpsRuntimeLogConfig(oldCfg) + _ = s.applyRuntimeLogConfig(oldCfg) s.auditRuntimeLogConfigFailure(operatorID, oldCfg, resetCfg, "reset_persist_failed: "+err.Error()) return nil, err } @@ -209,9 +211,29 @@ func (s *OpsService) ResetRuntimeLogConfig(ctx context.Context, operatorID int64 resetCfg.UpdatedByUserID = operatorID s.auditRuntimeLogConfigChange(operatorID, oldCfg, resetCfg, "reset") + s.reloadOpsCleanup(ctx) return resetCfg, nil } +func (s *OpsService) applyRuntimeLogConfig(cfg *OpsRuntimeLogConfig) error { + if err := applyOpsRuntimeLogConfig(cfg); err != nil { + return err + } + if s != nil && s.systemLogSink != nil { + s.systemLogSink.SetPersistAccessLogs(cfg.PersistAccessLogs) + } + return nil +} + +func (s *OpsService) reloadOpsCleanup(ctx context.Context) { + if s == nil || s.cleanupReloader == nil { + return + } + if err := s.cleanupReloader.Reload(ctx); err != nil { + logger.LegacyPrintf("service.ops_log_runtime", "[OpsLogRuntime] cleanup reload failed: %v", err) + } +} + func applyOpsRuntimeLogConfig(cfg *OpsRuntimeLogConfig) error { if cfg == nil { return fmt.Errorf("nil runtime log config") @@ -238,7 +260,7 @@ func (s *OpsService) applyRuntimeLogConfigOnStartup(ctx context.Context) { if err != nil { return } - _ = applyOpsRuntimeLogConfig(cfg) + _ = s.applyRuntimeLogConfig(cfg) } func (s *OpsService) auditRuntimeLogConfigChange(operatorID int64, oldCfg *OpsRuntimeLogConfig, newCfg *OpsRuntimeLogConfig, action string) { diff --git a/backend/internal/service/ops_log_runtime_test.go b/backend/internal/service/ops_log_runtime_test.go index 0a8cfa9067..f4d202ceb0 100644 --- a/backend/internal/service/ops_log_runtime_test.go +++ b/backend/internal/service/ops_log_runtime_test.go @@ -21,6 +21,15 @@ type runtimeSettingRepoStub struct { deleteFn func(key string) error } +type runtimeCleanupReloader struct { + calls int +} + +func (r *runtimeCleanupReloader) Reload(context.Context) error { + r.calls++ + return nil +} + func newRuntimeSettingRepoStub() *runtimeSettingRepoStub { return &runtimeSettingRepoStub{ values: map[string]string{}, @@ -181,7 +190,8 @@ func TestResetRuntimeLogConfig_ShouldFallbackToBaseline(t *testing.T) { }, Ops: config.OpsConfig{ Cleanup: config.OpsCleanupConfig{ - ErrorLogRetentionDays: 45, + ErrorLogRetentionDays: 7, + SystemLogRetentionDays: 45, }, }, }, @@ -282,7 +292,8 @@ func TestUpdateRuntimeLogConfig_PersistFailureRollback(t *testing.T) { } svc := &OpsService{ - settingRepo: repo, + settingRepo: repo, + systemLogSink: &OpsSystemLogSink{}, cfg: &config.Config{ Log: config.LogConfig{ Level: "info", @@ -311,13 +322,14 @@ func TestUpdateRuntimeLogConfig_PersistFailureRollback(t *testing.T) { } _, err := svc.UpdateRuntimeLogConfig(context.Background(), &OpsRuntimeLogConfig{ - Level: "debug", - EnableSampling: false, - SamplingInitial: 100, - SamplingNext: 100, - Caller: true, - StacktraceLevel: "error", - RetentionDays: 30, + Level: "debug", + PersistAccessLogs: true, + EnableSampling: false, + SamplingInitial: 100, + SamplingNext: 100, + Caller: true, + StacktraceLevel: "error", + RetentionDays: 30, }, 5) if err == nil { t.Fatalf("expected persist error") @@ -326,15 +338,19 @@ func TestUpdateRuntimeLogConfig_PersistFailureRollback(t *testing.T) { if logger.CurrentLevel() != "info" { t.Fatalf("logger level should rollback to info, got %s", logger.CurrentLevel()) } + if svc.systemLogSink.persistAccessLogs.Load() { + t.Fatal("access-log persistence should rollback after setting write failure") + } } func TestApplyRuntimeLogConfigOnStartup(t *testing.T) { repo := newRuntimeSettingRepoStub() - cfgRaw := `{"level":"debug","enable_sampling":false,"sampling_initial":100,"sampling_thereafter":100,"caller":true,"stacktrace_level":"error","retention_days":30}` + cfgRaw := `{"level":"debug","persist_access_logs":true,"enable_sampling":false,"sampling_initial":100,"sampling_thereafter":100,"caller":true,"stacktrace_level":"error","retention_days":30}` repo.values[SettingKeyOpsRuntimeLogConfig] = cfgRaw svc := &OpsService{ - settingRepo: repo, + settingRepo: repo, + systemLogSink: &OpsSystemLogSink{}, cfg: &config.Config{ Log: config.LogConfig{ Level: "info", @@ -366,6 +382,9 @@ func TestApplyRuntimeLogConfigOnStartup(t *testing.T) { if logger.CurrentLevel() != "debug" { t.Fatalf("expected startup apply debug, got %s", logger.CurrentLevel()) } + if !svc.systemLogSink.persistAccessLogs.Load() { + t.Fatal("startup config did not enable access-log persistence") + } } func TestDefaultNormalizeAndValidateRuntimeLogConfig(t *testing.T) { @@ -382,13 +401,17 @@ func TestDefaultNormalizeAndValidateRuntimeLogConfig(t *testing.T) { }, Ops: config.OpsConfig{ Cleanup: config.OpsCleanupConfig{ - ErrorLogRetentionDays: 7, + ErrorLogRetentionDays: 7, + SystemLogRetentionDays: 14, }, }, }) - if defaults.Level != "debug" || defaults.StacktraceLevel != "fatal" || defaults.RetentionDays != 7 { + if defaults.Level != "debug" || defaults.StacktraceLevel != "fatal" || defaults.RetentionDays != 14 { t.Fatalf("unexpected defaults: %+v", defaults) } + if defaults.PersistAccessLogs { + t.Fatal("access-log persistence should be disabled by default") + } cfg := &OpsRuntimeLogConfig{ Level: " ", @@ -403,7 +426,7 @@ func TestDefaultNormalizeAndValidateRuntimeLogConfig(t *testing.T) { if cfg.Level != "debug" || cfg.StacktraceLevel != "fatal" { t.Fatalf("normalize level/stacktrace failed: %+v", cfg) } - if cfg.SamplingInitial != 50 || cfg.SamplingNext != 20 || cfg.RetentionDays != 7 { + if cfg.SamplingInitial != 50 || cfg.SamplingNext != 20 || cfg.RetentionDays != 14 { t.Fatalf("normalize numeric defaults failed: %+v", cfg) } if err := validateOpsRuntimeLogConfig(cfg); err != nil { @@ -489,8 +512,11 @@ func TestUpdateRuntimeLogConfig_PreconditionErrors(t *testing.T) { func TestUpdateRuntimeLogConfig_Success(t *testing.T) { repo := newRuntimeSettingRepoStub() + reloader := &runtimeCleanupReloader{} svc := &OpsService{ - settingRepo: repo, + settingRepo: repo, + systemLogSink: &OpsSystemLogSink{}, + cleanupReloader: reloader, cfg: &config.Config{ Log: config.LogConfig{ Level: "info", @@ -519,13 +545,14 @@ func TestUpdateRuntimeLogConfig_Success(t *testing.T) { } next, err := svc.UpdateRuntimeLogConfig(context.Background(), &OpsRuntimeLogConfig{ - Level: "debug", - EnableSampling: false, - SamplingInitial: 100, - SamplingNext: 100, - Caller: true, - StacktraceLevel: "error", - RetentionDays: 30, + Level: "debug", + PersistAccessLogs: true, + EnableSampling: false, + SamplingInitial: 100, + SamplingNext: 100, + Caller: true, + StacktraceLevel: "error", + RetentionDays: 30, }, 2) if err != nil { t.Fatalf("UpdateRuntimeLogConfig() error: %v", err) @@ -536,6 +563,12 @@ func TestUpdateRuntimeLogConfig_Success(t *testing.T) { if logger.CurrentLevel() != "debug" { t.Fatalf("expected applied level debug, got %s", logger.CurrentLevel()) } + if !svc.systemLogSink.persistAccessLogs.Load() { + t.Fatal("runtime update did not enable access-log persistence") + } + if reloader.calls != 1 { + t.Fatalf("cleanup reload calls = %d, want 1", reloader.calls) + } } func TestResetRuntimeLogConfig_IgnoreNotFoundDelete(t *testing.T) { diff --git a/backend/internal/service/ops_runtime_snapshot_test.go b/backend/internal/service/ops_runtime_snapshot_test.go index 9e0631ecb1..99d8a28a6a 100644 --- a/backend/internal/service/ops_runtime_snapshot_test.go +++ b/backend/internal/service/ops_runtime_snapshot_test.go @@ -114,28 +114,60 @@ func TestOpsRuntimeSettingsAdministrativeUpdatesAreImmediatelyVisible(t *testing } func TestOpsRuntimeSettingsBackgroundRefreshConverges(t *testing.T) { - repo := &opsRuntimeRefreshRepo{values: map[string]string{SettingKeyOpsMonitoringEnabled: "false"}} - svc := &OpsService{settingRepo: repo} + repo := &opsRuntimeRefreshRepo{values: map[string]string{ + SettingKeyOpsMonitoringEnabled: "false", + SettingKeyOpsRuntimeLogConfig: `{"persist_access_logs":false}`, + }} + sink := &OpsSystemLogSink{} + svc := &OpsService{settingRepo: repo, systemLogSink: sink} svc.initRuntimeSettings(context.Background()) if svc.IsMonitoringEnabled(context.Background()) { t.Fatal("initial monitoring state = true, want false") } repo.set(SettingKeyOpsMonitoringEnabled, "true") + repo.set(SettingKeyOpsRuntimeLogConfig, `{"persist_access_logs":true}`) svc.startRuntimeSettingsRefresh(context.Background(), 5*time.Millisecond, 0, 50*time.Millisecond) t.Cleanup(svc.StopRuntimeSettingsRefresh) waitForOpsRefresh(t, time.Second, func() bool { - return svc.IsMonitoringEnabled(context.Background()) && svc.RuntimeSettingsRefreshHealth().SuccessTotal > 0 + return svc.IsMonitoringEnabled(context.Background()) && + sink.persistAccessLogs.Load() && + svc.RuntimeSettingsRefreshHealth().SuccessTotal > 0 }) } +func TestOpsRuntimeSettingsRefreshUsesSafeAccessLogDefault(t *testing.T) { + repo := &opsRuntimeRefreshRepo{values: map[string]string{ + SettingKeyOpsRuntimeLogConfig: `{"persist_access_logs":true}`, + }} + sink := &OpsSystemLogSink{} + svc := &OpsService{settingRepo: repo, systemLogSink: sink} + svc.initRuntimeSettings(context.Background()) + if !sink.persistAccessLogs.Load() { + t.Fatal("valid runtime config did not enable access-log persistence") + } + + repo.set(SettingKeyOpsRuntimeLogConfig, `{invalid-json}`) + if err := svc.RefreshRuntimeSettings(context.Background()); err != nil { + t.Fatalf("RefreshRuntimeSettings() error = %v", err) + } + if sink.persistAccessLogs.Load() { + t.Fatal("invalid runtime config should fail closed for access-log persistence") + } +} + func TestOpsRuntimeSettingsRefreshFailuresKeepLastKnownGoodSnapshot(t *testing.T) { repo := &opsRuntimeRefreshRepo{values: map[string]string{ SettingKeyOpsMonitoringEnabled: "false", SettingKeyOpsAdvancedSettings: `{"ignore_no_available_accounts":true}`, + SettingKeyOpsRuntimeLogConfig: `{"persist_access_logs":true}`, }} - svc := &OpsService{settingRepo: repo} + sink := &OpsSystemLogSink{} + svc := &OpsService{settingRepo: repo, systemLogSink: sink} svc.initRuntimeSettings(context.Background()) + if !sink.persistAccessLogs.Load() { + t.Fatal("initial access-log setting was not applied") + } repo.fail.Store(true) svc.startRuntimeSettingsRefresh(context.Background(), 5*time.Millisecond, 0, 50*time.Millisecond) t.Cleanup(svc.StopRuntimeSettingsRefresh) @@ -149,6 +181,9 @@ func TestOpsRuntimeSettingsRefreshFailuresKeepLastKnownGoodSnapshot(t *testing.T if !svc.OpsAdvancedSettingsSnapshot().IgnoreNoAvailableAccounts { t.Fatal("failed refresh overwrote last known advanced settings") } + if !sink.persistAccessLogs.Load() { + t.Fatal("failed refresh overwrote last known access-log setting") + } } func TestOpsRuntimeSettingsRefreshStopEndsLifecycle(t *testing.T) { diff --git a/backend/internal/service/ops_service.go b/backend/internal/service/ops_service.go index c651b9184d..a6dc5d55f5 100644 --- a/backend/internal/service/ops_service.go +++ b/backend/internal/service/ops_service.go @@ -177,7 +177,7 @@ func (s *OpsService) initRuntimeSettings(ctx context.Context) { if s == nil { return } - defaults := defaultOpsAdvancedSettings() + defaults := defaultOpsAdvancedSettingsForConfig(s.cfg) s.runtimeSettings.Store(&opsRuntimeSettingsSnapshot{monitoringEnabled: true, advanced: *defaults}) _ = s.RefreshRuntimeSettings(ctx) } @@ -198,6 +198,7 @@ func (s *OpsService) RefreshRuntimeSettings(ctx context.Context) error { values, err := s.settingRepo.GetMultiple(ctx, []string{ SettingKeyOpsMonitoringEnabled, SettingKeyOpsAdvancedSettings, + SettingKeyOpsRuntimeLogConfig, }) if err != nil { return err @@ -207,15 +208,27 @@ func (s *OpsService) RefreshRuntimeSettings(ctx context.Context) error { if raw, ok := values[SettingKeyOpsMonitoringEnabled]; ok { monitoringEnabled = parseOpsMonitoringEnabled(raw) } - advanced := defaultOpsAdvancedSettings() + advanced := defaultOpsAdvancedSettingsForConfig(s.cfg) if raw, ok := values[SettingKeyOpsAdvancedSettings]; ok { if err := json.Unmarshal([]byte(raw), advanced); err != nil { - advanced = defaultOpsAdvancedSettings() + advanced = defaultOpsAdvancedSettingsForConfig(s.cfg) } } normalizeOpsAdvancedSettings(advanced) s.runtimeSettings.Store(&opsRuntimeSettingsSnapshot{monitoringEnabled: monitoringEnabled, advanced: *advanced}) + if s.systemLogSink != nil { + persistAccessLogs := false + if raw, ok := values[SettingKeyOpsRuntimeLogConfig]; ok { + var runtimeCfg struct { + PersistAccessLogs bool `json:"persist_access_logs"` + } + if json.Unmarshal([]byte(raw), &runtimeCfg) == nil { + persistAccessLogs = runtimeCfg.PersistAccessLogs + } + } + s.systemLogSink.SetPersistAccessLogs(persistAccessLogs) + } return nil } @@ -360,7 +373,7 @@ func (s *OpsService) SetMonitoringEnabled(enabled bool) { } s.runtimeSettingsMu.Lock() current := s.runtimeSettings.Load() - next := &opsRuntimeSettingsSnapshot{monitoringEnabled: enabled, advanced: *defaultOpsAdvancedSettings()} + next := &opsRuntimeSettingsSnapshot{monitoringEnabled: enabled, advanced: *defaultOpsAdvancedSettingsForConfig(s.cfg)} if current != nil { next.advanced = current.advanced } diff --git a/backend/internal/service/ops_settings.go b/backend/internal/service/ops_settings.go index e2943be5ce..2b18b483c0 100644 --- a/backend/internal/service/ops_settings.go +++ b/backend/internal/service/ops_settings.go @@ -4,9 +4,11 @@ import ( "context" "encoding/json" "errors" - "github.com/Wei-Shaw/sub2api/internal/pkg/logger" "strings" "time" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/pkg/logger" ) const ( @@ -360,7 +362,9 @@ func (s *OpsService) UpdateOpsAlertRuntimeSettings(ctx context.Context, cfg *Ops func defaultOpsAdvancedSettings() *OpsAdvancedSettings { return &OpsAdvancedSettings{ DataRetention: OpsDataRetentionSettings{ - CleanupEnabled: false, + // Keep the DB-backed default aligned with ops.cleanup.enabled so a + // first settings read cannot silently disable configured retention. + CleanupEnabled: true, CleanupSchedule: opsCleanupDefaultSchedule, ErrorLogRetentionDays: 30, MinuteMetricsRetentionDays: 30, @@ -382,6 +386,14 @@ func defaultOpsAdvancedSettings() *OpsAdvancedSettings { } } +func defaultOpsAdvancedSettingsForConfig(cfg *config.Config) *OpsAdvancedSettings { + defaults := defaultOpsAdvancedSettings() + if cfg != nil { + defaults.DataRetention.CleanupEnabled = cfg.Ops.Cleanup.Enabled + } + return defaults +} + func normalizeOpsAdvancedSettings(cfg *OpsAdvancedSettings) { if cfg == nil { return @@ -456,6 +468,7 @@ func (s *OpsService) OpsAdvancedSettingsSnapshot() OpsAdvancedSettings { if snapshot := s.runtimeSettings.Load(); snapshot != nil { return snapshot.advanced } + return *defaultOpsAdvancedSettingsForConfig(s.cfg) } return *defaultOpsAdvancedSettings() } diff --git a/backend/internal/service/ops_settings_advanced_test.go b/backend/internal/service/ops_settings_advanced_test.go index 39d0b8722a..adcaca411c 100644 --- a/backend/internal/service/ops_settings_advanced_test.go +++ b/backend/internal/service/ops_settings_advanced_test.go @@ -23,11 +23,29 @@ func TestGetOpsAdvancedSettings_DefaultSnapshotHidesOpenAITokenStats(t *testing. if !cfg.DisplayAlertEvents { t.Fatalf("DisplayAlertEvents = false, want true by default") } + if !cfg.DataRetention.CleanupEnabled { + t.Fatal("data cleanup should be enabled by default") + } if repo.getValueCalls != 0 || repo.getMultipleCalls != 0 { t.Fatalf("hot-path snapshot read touched repository: get=%d get_multiple=%d", repo.getValueCalls, repo.getMultipleCalls) } } +func TestGetOpsAdvancedSettings_DefaultCleanupFollowsDeploymentConfig(t *testing.T) { + svc := &OpsService{cfg: &config.Config{Ops: config.OpsConfig{ + Cleanup: config.OpsCleanupConfig{Enabled: false}, + }}} + svc.initRuntimeSettings(context.Background()) + + cfg, err := svc.GetOpsAdvancedSettings(context.Background()) + if err != nil { + t.Fatalf("GetOpsAdvancedSettings() error = %v", err) + } + if cfg.DataRetention.CleanupEnabled { + t.Fatal("data cleanup should follow the disabled deployment baseline") + } +} + func TestUpdateOpsAdvancedSettings_PersistsOpenAITokenStatsVisibility(t *testing.T) { repo := newRuntimeSettingRepoStub() svc := &OpsService{settingRepo: repo} diff --git a/backend/internal/service/ops_settings_models.go b/backend/internal/service/ops_settings_models.go index 3057978007..93c364153c 100644 --- a/backend/internal/service/ops_settings_models.go +++ b/backend/internal/service/ops_settings_models.go @@ -69,12 +69,14 @@ type OpsMetricThresholds struct { } type OpsRuntimeLogConfig struct { - Level string `json:"level"` - EnableSampling bool `json:"enable_sampling"` - SamplingInitial int `json:"sampling_initial"` - SamplingNext int `json:"sampling_thereafter"` - Caller bool `json:"caller"` - StacktraceLevel string `json:"stacktrace_level"` + Level string `json:"level"` + PersistAccessLogs bool `json:"persist_access_logs"` + EnableSampling bool `json:"enable_sampling"` + SamplingInitial int `json:"sampling_initial"` + SamplingNext int `json:"sampling_thereafter"` + Caller bool `json:"caller"` + StacktraceLevel string `json:"stacktrace_level"` + // RetentionDays is the authoritative retention for ops_system_logs. RetentionDays int `json:"retention_days"` Source string `json:"source,omitempty"` UpdatedAt string `json:"updated_at,omitempty"` diff --git a/backend/internal/service/ops_system_log_sink.go b/backend/internal/service/ops_system_log_sink.go index 7a247a290d..0bff194816 100644 --- a/backend/internal/service/ops_system_log_sink.go +++ b/backend/internal/service/ops_system_log_sink.go @@ -42,10 +42,11 @@ type OpsSystemLogSink struct { cancel context.CancelFunc wg sync.WaitGroup - droppedCount uint64 - writeFailed uint64 - writtenCount uint64 - totalDelayNs uint64 + droppedCount uint64 + writeFailed uint64 + writtenCount uint64 + totalDelayNs uint64 + persistAccessLogs atomic.Bool lastError atomic.Value } @@ -147,6 +148,15 @@ func (s *OpsSystemLogSink) WriteLogEvent(event *logger.LogEvent) { } } +// SetPersistAccessLogs controls whether high-volume request access logs are +// copied into PostgreSQL. Warning/error and audit events are always retained. +func (s *OpsSystemLogSink) SetPersistAccessLogs(enabled bool) { + if s == nil { + return + } + s.persistAccessLogs.Store(enabled) +} + func (s *OpsSystemLogSink) shouldIndex(event *logger.LogEvent) bool { if event != nil && event.Fields != nil { if skip, _ := event.Fields[logger.OpsSystemLogSkipField].(bool); skip { @@ -167,7 +177,7 @@ func (s *OpsSystemLogSink) shouldIndex(event *logger.LogEvent) bool { } } if strings.Contains(component, "http.access") { - return true + return s.persistAccessLogs.Load() } if strings.Contains(component, "audit") { return true diff --git a/backend/internal/service/ops_system_log_sink_test.go b/backend/internal/service/ops_system_log_sink_test.go index fbe61d3867..2362cb505d 100644 --- a/backend/internal/service/ops_system_log_sink_test.go +++ b/backend/internal/service/ops_system_log_sink_test.go @@ -32,9 +32,9 @@ func TestOpsSystemLogSink_ShouldIndex(t *testing.T) { want: true, }, { - name: "access component", + name: "access component disabled by default", event: &logger.LogEvent{Level: "info", Component: "http.access"}, - want: true, + want: false, }, { name: "rejected access excluded from database sink", @@ -46,13 +46,13 @@ func TestOpsSystemLogSink_ShouldIndex(t *testing.T) { want: false, }, { - name: "access component from fields (real zap path)", + name: "access component from fields disabled by default", event: &logger.LogEvent{ Level: "info", Component: "", Fields: map[string]any{"component": "http.access"}, }, - want: true, + want: false, }, { name: "audit component", @@ -82,6 +82,28 @@ func TestOpsSystemLogSink_ShouldIndex(t *testing.T) { } } +func TestOpsSystemLogSink_ShouldIndexAccessLogsOnlyWhenEnabled(t *testing.T) { + sink := &OpsSystemLogSink{} + sink.SetPersistAccessLogs(true) + + if !sink.shouldIndex(&logger.LogEvent{Level: "info", Component: "http.access"}) { + t.Fatal("access log should be indexed after explicit opt-in") + } + if !sink.shouldIndex(&logger.LogEvent{ + Level: "info", + Fields: map[string]any{"component": "http.access"}, + }) { + t.Fatal("field-based access log should be indexed after explicit opt-in") + } + if sink.shouldIndex(&logger.LogEvent{ + Level: "info", + Component: "http.access", + Fields: map[string]any{logger.OpsSystemLogSkipField: true}, + }) { + t.Fatal("explicit skip marker must override access-log persistence") + } +} + func TestOpsSystemLogSink_WriteLogEvent_ShouldDropWhenQueueFull(t *testing.T) { sink := &OpsSystemLogSink{ queue: make(chan *logger.LogEvent, 1), diff --git a/frontend/src/api/admin/ops.ts b/frontend/src/api/admin/ops.ts index bb8b31eb28..d9f818b385 100644 --- a/frontend/src/api/admin/ops.ts +++ b/frontend/src/api/admin/ops.ts @@ -814,6 +814,7 @@ export interface OpsAggregationSettings { export interface OpsRuntimeLogConfig { level: 'debug' | 'info' | 'warn' | 'error' + persist_access_logs: boolean enable_sampling: boolean sampling_initial: number sampling_thereafter: number diff --git a/frontend/src/i18n/locales/en/admin/ops.ts b/frontend/src/i18n/locales/en/admin/ops.ts index 4bb48c64da..206ad2b930 100644 --- a/frontend/src/i18n/locales/en/admin/ops.ts +++ b/frontend/src/i18n/locales/en/admin/ops.ts @@ -42,8 +42,11 @@ export default { samplingInitial: 'Sampling initial', samplingThereafter: 'Sampling thereafter', retentionDays: 'Retention days', + retentionDaysHint: 'Applied by the scheduled data-cleanup job.', caller: 'caller', sampling: 'sampling', + persistAccessLogs: 'Store access logs in database', + persistAccessLogsHint: 'Disabled by default because access logs add one indexed database row per request. Warning, error, and audit logs are always stored.', saveAndApply: 'Save and apply', resetDefaults: 'Reset defaults', latestWriteError: 'Latest write error:', diff --git a/frontend/src/i18n/locales/zh/admin/ops.ts b/frontend/src/i18n/locales/zh/admin/ops.ts index 3e568e1dd7..ce444726b5 100644 --- a/frontend/src/i18n/locales/zh/admin/ops.ts +++ b/frontend/src/i18n/locales/zh/admin/ops.ts @@ -42,8 +42,11 @@ export default { samplingInitial: '采样初始条数', samplingThereafter: '后续采样间隔', retentionDays: '保留天数', + retentionDaysHint: '由定时数据清理任务执行。', caller: '调用方', sampling: '采样', + persistAccessLogs: '将访问日志写入数据库', + persistAccessLogsHint: '默认关闭,因为访问日志会为每个请求新增一条带索引的数据库记录。警告、错误和审计日志始终会保留。', saveAndApply: '保存并应用', resetDefaults: '重置默认值', latestWriteError: '最近写入错误:', diff --git a/frontend/src/views/admin/ops/components/OpsSystemLogTable.vue b/frontend/src/views/admin/ops/components/OpsSystemLogTable.vue index 64b9e0dded..e360be3ce3 100644 --- a/frontend/src/views/admin/ops/components/OpsSystemLogTable.vue +++ b/frontend/src/views/admin/ops/components/OpsSystemLogTable.vue @@ -41,6 +41,7 @@ const runtimeLoading = ref(false) const runtimeSaving = ref(false) const runtimeConfig = reactive({ level: 'info', + persist_access_logs: false, enable_sampling: false, sampling_initial: 100, sampling_thereafter: 100, @@ -231,6 +232,7 @@ const loadRuntimeConfig = async () => { try { const cfg = await opsAPI.getRuntimeLogConfig() runtimeConfig.level = cfg.level + runtimeConfig.persist_access_logs = cfg.persist_access_logs runtimeConfig.enable_sampling = cfg.enable_sampling runtimeConfig.sampling_initial = cfg.sampling_initial runtimeConfig.sampling_thereafter = cfg.sampling_thereafter @@ -249,6 +251,7 @@ const saveRuntimeConfig = async () => { try { const saved = await opsAPI.updateRuntimeLogConfig({ ...runtimeConfig }) runtimeConfig.level = saved.level + runtimeConfig.persist_access_logs = saved.persist_access_logs runtimeConfig.enable_sampling = saved.enable_sampling runtimeConfig.sampling_initial = saved.sampling_initial runtimeConfig.sampling_thereafter = saved.sampling_thereafter @@ -272,6 +275,7 @@ const resetRuntimeConfig = async () => { try { const saved = await opsAPI.resetRuntimeLogConfig() runtimeConfig.level = saved.level + runtimeConfig.persist_access_logs = saved.persist_access_logs runtimeConfig.enable_sampling = saved.enable_sampling runtimeConfig.sampling_initial = saved.sampling_initial runtimeConfig.sampling_thereafter = saved.sampling_thereafter @@ -419,6 +423,7 @@ onMounted(async () => {
@@ -431,6 +436,10 @@ onMounted(async () => { {{ t('admin.ops.systemLogs.sampling') }} +
+

{{ t('admin.ops.systemLogs.persistAccessLogsHint') }}

{{ t('admin.ops.systemLogs.latestWriteError') }} {{ health.last_error }}

diff --git a/frontend/src/views/admin/ops/components/__tests__/OpsSystemLogTable.spec.ts b/frontend/src/views/admin/ops/components/__tests__/OpsSystemLogTable.spec.ts index 98e32add24..6d2e8f5346 100644 --- a/frontend/src/views/admin/ops/components/__tests__/OpsSystemLogTable.spec.ts +++ b/frontend/src/views/admin/ops/components/__tests__/OpsSystemLogTable.spec.ts @@ -53,6 +53,7 @@ const PaginationStub = defineComponent({ const runtimeConfig = { level: 'info', + persist_access_logs: false, enable_sampling: false, sampling_initial: 100, sampling_thereafter: 100, @@ -126,6 +127,22 @@ describe('OpsSystemLogTable host support', () => { expect(mockCleanupSystemLogs).toHaveBeenCalledWith(expect.objectContaining({ host: 'api-node-2' })) }) + it('keeps database access-log persistence opt-in', async () => { + const wrapper = mount(OpsSystemLogTable, { + global: { + stubs: { + Select: SelectStub, + Pagination: PaginationStub, + }, + }, + }) + await flushPromises() + + const label = wrapper.findAll('label').find((item) => item.text().includes('admin.ops.systemLogs.persistAccessLogs')) + expect(label).toBeDefined() + expect((label!.find('input').element as HTMLInputElement).checked).toBe(false) + }) + it.each([ ['zh', zhLocale], ['en', enLocale], From c74a4f521c7b71635b20a2f15e9d2758991de05c Mon Sep 17 00:00:00 2001 From: spin6lock Date: Mon, 31 Aug 2026 19:51:50 +0800 Subject: [PATCH 007/145] =?UTF-8?q?fix(billing):=20=E8=A1=A5=E4=B8=8A=20gl?= =?UTF-8?q?m-5.3=20/=20glm-5.3-flash=20=E5=85=9C=E5=BA=95=E4=BB=B7?= =?UTF-8?q?=EF=BC=8C=E9=81=BF=E5=85=8D=E8=A2=AB=20glm-5=20=E5=AD=90?= =?UTF-8?q?=E4=B8=B2=E5=8C=B9=E9=85=8D=E6=8A=A2=E8=B5=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/service/billing_service.go | 26 ++++++++++++++-- .../internal/service/billing_service_test.go | 30 ++++++++++++++++++- frontend/src/composables/useModelWhitelist.ts | 5 +++- 3 files changed, 56 insertions(+), 5 deletions(-) diff --git a/backend/internal/service/billing_service.go b/backend/internal/service/billing_service.go index 7002fc09cc..bf29bf6537 100644 --- a/backend/internal/service/billing_service.go +++ b/backend/internal/service/billing_service.go @@ -538,7 +538,20 @@ func (s *BillingService) initFallbackPricing() { // Source: https://docs.z.ai/guides/overview/pricing (USD per 1M tokens) // 注意:CacheReadPricePerToken 即"缓存命中"价格,CacheCreationPricePerToken 留空(智谱未公开写入价,按 0 处理)。 // GLM-4.6 与 GLM-4.5 在 z.ai 国际版上定价一致;GLM-4.5 国内按 ¥0.8/¥2,汇率换算后约 $0.112/$0.28,与国际版 $0.6/$2.2 不同,本分支采用国际版 USD 口径与现有 Claude/GPT 一致。 - // GLM-5.2 与 GLM-5.1 在 z.ai 上同价。 + // GLM-5.3 / GLM-5.2 与 GLM-5.1 在 z.ai 上同价。 + // GLM-5.3-Flash 列表价 $0.15/$0.50(2026-09-09 前五折促销,此处按列表价,与其它模型口径一致)。 + s.fallbackPrices["glm-5.3-flash"] = &ModelPricing{ + InputPricePerToken: 0.15e-6, // $0.15 per MTok + OutputPricePerToken: 0.5e-6, // $0.50 per MTok + CacheReadPricePerToken: 0.03e-6, + SupportsCacheBreakdown: false, + } + s.fallbackPrices["glm-5.3"] = &ModelPricing{ + InputPricePerToken: 1.4e-6, // $1.40 per MTok + OutputPricePerToken: 4.4e-6, // $4.40 per MTok + CacheReadPricePerToken: 0.26e-6, + SupportsCacheBreakdown: false, + } s.fallbackPrices["glm-5.2"] = &ModelPricing{ InputPricePerToken: 1.4e-6, // $1.40 per MTok OutputPricePerToken: 4.4e-6, // $4.40 per MTok @@ -870,9 +883,16 @@ func (s *BillingService) getFallbackPricing(model string) *ModelPricing { // 匹配策略:长 key 优先(具体模型 → 系列 / 厂商),未知型号不回退以避免误计价。 // 与 DeepSeek 一样采用"白名单"语义:未在本表命中的国产模型 alias 一律不返回兜底价。 - // 智谱 GLM(z.ai 公开 SKU:glm-5.2 / glm-5.1 / glm-5 / glm-5-turbo / glm-4.7 / glm-4.6 / glm-4.5 等) + // 智谱 GLM(z.ai 公开 SKU:glm-5.3 / glm-5.3-flash / glm-5.2 / glm-5.1 / glm-5 / glm-5-turbo / glm-4.7 / glm-4.6 / glm-4.5 等) // 匹配顺序:先判别最高 tier,再依次降级。 - // 注意:带小数点的型号必须排在裸 "glm-5" 之前,否则会被 strings.Contains 抢走。 + // 注意:带小数点的型号必须排在裸 "glm-5" 之前,否则会被 strings.Contains 抢走; + // glm-5.3-flash 必须排在 glm-5.3 之前(前者包含后者子串)。 + if strings.Contains(modelLower, "glm-5.3-flash") || strings.Contains(modelLower, "glm-5.3flash") { + return s.fallbackPrices["glm-5.3-flash"] + } + if strings.Contains(modelLower, "glm-5.3") { + return s.fallbackPrices["glm-5.3"] + } if strings.Contains(modelLower, "glm-5.2") { return s.fallbackPrices["glm-5.2"] } diff --git a/backend/internal/service/billing_service_test.go b/backend/internal/service/billing_service_test.go index 99b8ebd8d9..6f001c40db 100644 --- a/backend/internal/service/billing_service_test.go +++ b/backend/internal/service/billing_service_test.go @@ -532,6 +532,20 @@ func TestGetFallbackPricing_FamilyMatching(t *testing.T) { }, // ---- 智谱 GLM(z.ai USD 口径)---- + { + name: "glm 5.3 flagship", + model: "glm-5.3", + expectedInput: 1.4e-6, + expectedOutput: floatPtr(4.4e-6), + expectedCacheRead: floatPtr(0.26e-6), + }, + { + name: "glm 5.3 flash", + model: "glm-5.3-flash", + expectedInput: 0.15e-6, + expectedOutput: floatPtr(0.5e-6), + expectedCacheRead: floatPtr(0.03e-6), + }, { name: "glm 5.2 flagship", model: "glm-5.2", @@ -622,7 +636,21 @@ func TestGetFallbackPricing_FamilyMatching(t *testing.T) { expectedInput: 0.1e-6, expectedOutput: floatPtr(0.1e-6), }, - // 关键:5.1 / 5.2 必须先于 5 匹配(避免被 glm-5 抢走) + // 关键:5.1 / 5.2 / 5.3 必须先于 5 匹配(避免被 glm-5 抢走) + { + name: "glm 5.3-flash vs glm 5.3 ordering (verbatim 5.3-flash)", + model: "glm-5.3-flash", + expectedInput: 0.15e-6, // = glm-5.3-flash 价格(不是 glm-5.3 的 1.4e-6,更不是 glm-5 的 1e-6) + expectedOutput: floatPtr(0.5e-6), + expectedCacheRead: floatPtr(0.03e-6), + }, + { + name: "glm 5.3 vs glm 5 ordering (verbatim 5.3)", + model: "glm-5.3", + expectedInput: 1.4e-6, // = glm-5.3 价格(不是 glm-5 的 1e-6) + expectedOutput: floatPtr(4.4e-6), + expectedCacheRead: floatPtr(0.26e-6), + }, { name: "glm 5.1 vs glm 5 ordering (verbatim 5.1)", model: "glm-5.1", diff --git a/frontend/src/composables/useModelWhitelist.ts b/frontend/src/composables/useModelWhitelist.ts index 548044394b..753b578222 100644 --- a/frontend/src/composables/useModelWhitelist.ts +++ b/frontend/src/composables/useModelWhitelist.ts @@ -90,7 +90,10 @@ const antigravityModels = [ const zhipuModels = [ 'glm-4', 'glm-4v', 'glm-4-plus', 'glm-4-0520', 'glm-4-air', 'glm-4-airx', 'glm-4-long', 'glm-4-flash', - 'glm-4v-plus', 'glm-4.5', 'glm-4.6', + 'glm-4v-plus', 'glm-4.5', 'glm-4.5-x', 'glm-4.5-air', 'glm-4.5-flash', + 'glm-4.6', 'glm-4.7', 'glm-4.7-flash', 'glm-4.7-flashx', + 'glm-5', 'glm-5-turbo', 'glm-5.1', 'glm-5.2', + 'glm-5.3', 'glm-5.3-flash', 'glm-3-turbo', 'glm-4-alltools', 'chatglm_turbo', 'chatglm_pro', 'chatglm_std', 'chatglm_lite', 'cogview-3', 'cogvideo' From a10ff1255cbaf2ec61dc9dcbc647cbc5bf5d3535 Mon Sep 17 00:00:00 2001 From: Ray <19251440+swjturay@users.noreply.github.com> Date: Mon, 31 Aug 2026 12:27:52 +0800 Subject: [PATCH 008/145] fix(openai-ws): classify canceled client streams --- .../handler/openai_client_disconnect_test.go | 25 +++ .../handler/openai_gateway_handler.go | 20 ++ .../openai_ws_forwarder_client_cancel_test.go | 184 ++++++++++++++++++ .../service/openai_ws_forwarder_v2.go | 115 ++++++++--- 4 files changed, 317 insertions(+), 27 deletions(-) create mode 100644 backend/internal/handler/openai_client_disconnect_test.go create mode 100644 backend/internal/service/openai_ws_forwarder_client_cancel_test.go diff --git a/backend/internal/handler/openai_client_disconnect_test.go b/backend/internal/handler/openai_client_disconnect_test.go new file mode 100644 index 0000000000..71ff3d14f7 --- /dev/null +++ b/backend/internal/handler/openai_client_disconnect_test.go @@ -0,0 +1,25 @@ +package handler + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +func TestOpenAIEnsureForwardErrorResponse_SkipsCanceledClient(t *testing.T) { + gin.SetMode(gin.TestMode) + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + ctx, cancel := context.WithCancel(context.Background()) + c.Request = httptest.NewRequest(http.MethodPost, EndpointResponses, nil).WithContext(ctx) + cancel() + + h := &OpenAIGatewayHandler{} + require.False(t, h.ensureForwardErrorResponse(c, true)) + require.Equal(t, statusClientClosedRequest, c.Writer.Status()) + require.Empty(t, recorder.Body.String()) +} diff --git a/backend/internal/handler/openai_gateway_handler.go b/backend/internal/handler/openai_gateway_handler.go index e57cc759e6..de54001530 100644 --- a/backend/internal/handler/openai_gateway_handler.go +++ b/backend/internal/handler/openai_gateway_handler.go @@ -792,6 +792,22 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) { }) } if err != nil { + if result != nil && result.ClientDisconnect { + reqLog.Info("openai.client_disconnected", + zap.Int64("account_id", account.ID), + zap.Error(err), + ) + submitResponsesUsage(result) + return + } + if failoverClientGone(c) { + reqLog.Info("openai.client_disconnected", + zap.Int64("account_id", account.ID), + zap.Error(err), + ) + submitResponsesUsage(result) + return + } if result != nil && result.ImageCount > 0 { reqLog.Warn("openai.forward_partial_error_with_image_result", zap.Int64("account_id", account.ID), @@ -3087,6 +3103,10 @@ func (h *OpenAIGatewayHandler) ensureForwardErrorResponse(c *gin.Context, stream if c == nil || c.Writer == nil { return false } + if c.Request != nil && errors.Is(c.Request.Context().Err(), context.Canceled) { + failoverClientGone(c) + return false + } // 先停 compact 心跳再读 Writer 状态,避免与心跳 goroutine 竞争。 compactKeepaliveCommitted := service.StopOpenAICompactSSEKeepaliveCommitted(c) if compactKeepaliveCommitted { diff --git a/backend/internal/service/openai_ws_forwarder_client_cancel_test.go b/backend/internal/service/openai_ws_forwarder_client_cancel_test.go new file mode 100644 index 0000000000..7045c78d16 --- /dev/null +++ b/backend/internal/service/openai_ws_forwarder_client_cancel_test.go @@ -0,0 +1,184 @@ +package service + +import ( + "bytes" + "context" + "io" + "net/http" + "net/http/httptest" + "sync" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +// TestForwardOpenAIWSV2_ClientCancellationDrainsWithoutSyntheticFailure +// reproduces the HTTP/SSE ingress bug: the client cancels after partial output, +// while the upstream WS still has a terminal event available for usage billing. +func TestForwardOpenAIWSV2_ClientCancellationDrainsWithoutSyntheticFailure(t *testing.T) { + gin.SetMode(gin.TestMode) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + writer := &cancelOnFirstWriteResponseWriter{cancel: cancel} + c, _ := gin.CreateTestContext(writer) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil).WithContext(ctx) + c.Request.Header.Set("User-Agent", "unit-test-agent/1.0") + + captureConn := &openAIWSCancelSafeConn{openAIWSCaptureConn: &openAIWSCaptureConn{ + events: [][]byte{ + []byte(`{"type":"response.created","response":{"id":"resp_cancel_1","model":"gpt-5.5"}}`), + []byte(`{"type":"response.output_text.delta","delta":"partial"}`), + []byte(`{"type":"response.completed","response":{"id":"resp_cancel_1","model":"gpt-5.5","usage":{"input_tokens":3,"output_tokens":5}}}`), + }, + readDelays: []time.Duration{0, 0, 50 * time.Millisecond}, + }} + + cfg := &config.Config{} + cfg.Security.URLAllowlist.Enabled = false + cfg.Security.URLAllowlist.AllowInsecureHTTP = true + cfg.Gateway.OpenAIWS.Enabled = true + cfg.Gateway.OpenAIWS.APIKeyEnabled = true + cfg.Gateway.OpenAIWS.ResponsesWebsocketsV2 = true + cfg.Gateway.OpenAIWS.ModeRouterV2Enabled = true + cfg.Gateway.OpenAIWS.IngressModeDefault = OpenAIWSIngressModeCtxPool + cfg.Gateway.OpenAIWS.MaxConnsPerAccount = 1 + cfg.Gateway.OpenAIWS.MinIdlePerAccount = 0 + cfg.Gateway.OpenAIWS.MaxIdlePerAccount = 1 + cfg.Gateway.OpenAIWS.QueueLimitPerConn = 8 + cfg.Gateway.OpenAIWS.DialTimeoutSeconds = 3 + cfg.Gateway.OpenAIWS.ReadTimeoutSeconds = 5 + cfg.Gateway.OpenAIWS.WriteTimeoutSeconds = 3 + + pool := newOpenAIWSConnPool(cfg) + pool.setClientDialerForTest(&openAIWSClientConnCancelDialer{conn: captureConn}) + svc := &OpenAIGatewayService{ + cfg: cfg, + httpUpstream: &httpUpstreamRecorder{}, + cache: &stubGatewayCache{}, + openaiWSResolver: NewOpenAIWSProtocolResolver(cfg), + toolCorrector: NewCodexToolCorrector(), + openaiWSPool: pool, + } + account := &Account{ + ID: 9101, + Name: "openai-ws-client-cancel", + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Status: StatusActive, + Schedulable: true, + Concurrency: 1, + Credentials: map[string]any{"api_key": "sk-test"}, + Extra: map[string]any{ + "openai_apikey_responses_websockets_v2_mode": OpenAIWSIngressModeCtxPool, + }, + } + + result, err := svc.Forward(ctx, c, account, []byte(`{"model":"gpt-5.5","stream":true,"input":[{"type":"input_text","text":"hello"}]}`)) + + require.NoError(t, err, "client cancellation must not surface as an upstream failure") + require.NotNil(t, result) + require.True(t, result.ClientDisconnect) + require.Equal(t, "resp_cancel_1", result.RequestID) + require.Equal(t, 3, result.Usage.InputTokens) + require.Equal(t, 5, result.Usage.OutputTokens) + require.NotContains(t, writer.body.String(), "response.failed") +} + +type cancelOnFirstWriteResponseWriter struct { + mu sync.Mutex + header http.Header + body bytes.Buffer + cancel context.CancelFunc + writes int + status int +} + +func (w *cancelOnFirstWriteResponseWriter) Header() http.Header { + if w.header == nil { + w.header = make(http.Header) + } + return w.header +} + +func (w *cancelOnFirstWriteResponseWriter) WriteHeader(status int) { + w.status = status +} + +func (w *cancelOnFirstWriteResponseWriter) Write(p []byte) (int, error) { + w.mu.Lock() + defer w.mu.Unlock() + if w.writes == 0 && w.cancel != nil { + w.cancel() + } + w.writes++ + return w.body.Write(p) +} + +func (w *cancelOnFirstWriteResponseWriter) Flush() {} + +type openAIWSClientConnCancelDialer struct { + conn openAIWSClientConn +} + +func (d *openAIWSClientConnCancelDialer) Dial( + ctx context.Context, + wsURL string, + headers http.Header, + proxyURL string, +) (openAIWSClientConn, int, http.Header, error) { + return d.conn, 0, nil, nil +} + +// openAIWSCancelSafeConn leaves a delayed event queued when its read context is +// canceled, matching websocket frame semantics and allowing the drain read to +// consume that event with a detached context. +type openAIWSCancelSafeConn struct { + *openAIWSCaptureConn +} + +func (c *openAIWSCancelSafeConn) ReadMessage(ctx context.Context) ([]byte, error) { + if ctx == nil { + ctx = context.Background() + } + c.mu.Lock() + if c.closed { + c.mu.Unlock() + return nil, errOpenAIWSConnClosed + } + if len(c.events) == 0 { + c.mu.Unlock() + return nil, io.EOF + } + delay := time.Duration(0) + if len(c.readDelays) > 0 { + delay = c.readDelays[0] + } + event := c.events[0] + c.mu.Unlock() + if delay > 0 { + timer := time.NewTimer(delay) + defer timer.Stop() + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-timer.C: + } + } + c.mu.Lock() + defer c.mu.Unlock() + if c.closed { + return nil, errOpenAIWSConnClosed + } + if len(c.events) == 0 { + return nil, io.EOF + } + c.events = c.events[1:] + if len(c.readDelays) > 0 { + c.readDelays = c.readDelays[1:] + } + return event, nil +} diff --git a/backend/internal/service/openai_ws_forwarder_v2.go b/backend/internal/service/openai_ws_forwarder_v2.go index 81f8631e96..3507b5b26c 100644 --- a/backend/internal/service/openai_ws_forwarder_v2.go +++ b/backend/internal/service/openai_ws_forwarder_v2.go @@ -374,6 +374,59 @@ func (s *OpenAIGatewayService) forwardOpenAIWSV2( firstEventType := "" lastEventType := "" upstreamTerminalEvent := "" + clientDisconnected := false + clientDisconnectDrainStartedAt := time.Time{} + readTimeout := s.openAIWSReadTimeout() + upstreamReadCtx := ctx + upstreamReadDetached := false + clientRequestCanceled := func() bool { + return ctx != nil && errors.Is(ctx.Err(), context.Canceled) + } + markClientDisconnected := func(cause string) { + if clientDisconnected { + return + } + clientDisconnected = true + clientDisconnectDrainStartedAt = time.Now() + if !upstreamReadDetached { + upstreamReadCtx = context.WithoutCancel(ctx) + upstreamReadDetached = true + } + logOpenAIWSModeInfo( + "client_disconnected account_id=%d conn_id=%s cause=%s events=%d token_events=%d", + account.ID, + connID, + cause, + eventCount, + tokenEventCount, + ) + } + markClientRequestCanceled := func() { + if clientRequestCanceled() { + markClientDisconnected("request_context_canceled") + } + } + resultWithUsage := func() *OpenAIForwardResult { + return &OpenAIForwardResult{ + RequestID: responseID, + ResponseID: responseID, + Usage: *usage, + Model: originalModel, + UpstreamModel: mappedModel, + UpstreamResponseModel: responseModelObserver.Model(), + UpstreamResponseModelConflict: responseModelObserver.Conflict(), + UpstreamResponseServiceTier: responseModelObserver.ServiceTier(), + ServiceTier: resolvedOpenAIUpstreamServiceTierFromObserver(responseModelObserver, extractOpenAIServiceTier(reqBody)), + ReasoningEffort: extractOpenAIReasoningEffort(reqBody, mappedModel, originalModel), + Stream: reqStream, + OpenAIWSMode: true, + UpstreamTerminalEvent: upstreamTerminalEvent, + ResponseHeaders: lease.HandshakeHeaders(), + Duration: time.Since(startTime), + FirstTokenMs: firstTokenMs, + ClientDisconnect: clientDisconnected, + } + } var flusher http.Flusher if reqStream { @@ -392,7 +445,6 @@ func (s *OpenAIGatewayService) forwardOpenAIWSV2( flusher = f } - clientDisconnected := false flushBatchSize := s.openAIWSEventFlushBatchSize() flushInterval := s.openAIWSEventFlushInterval() pendingFlushEvents := 0 @@ -425,7 +477,7 @@ func (s *OpenAIGatewayService) forwardOpenAIWSV2( flushStreamWriter(forceFlush) return } - clientDisconnected = true + markClientDisconnected("downstream_write_error") logger.LegacyPrintf("service.openai_gateway", "[OpenAI WS Mode] client disconnected, continue draining upstream: account=%d", account.ID) } flushBufferedStreamEvents := func(reason string) { @@ -452,17 +504,32 @@ func (s *OpenAIGatewayService) forwardOpenAIWSV2( } } - readTimeout := s.openAIWSReadTimeout() + // Keep per-read timeouts unchanged for connected clients. Once a client + // disconnects, use the same timeout as a bounded total drain budget. var pendingJSONDocuments [][]byte +readLoop: for { + markClientRequestCanceled() var message []byte var readErr error + readUsedDetachedContext := upstreamReadDetached if len(pendingJSONDocuments) > 0 { message = pendingJSONDocuments[0] pendingJSONDocuments = pendingJSONDocuments[1:] } else { - message, readErr = lease.ReadMessageWithContextTimeout(ctx, readTimeout) + currentReadTimeout := readTimeout + if clientDisconnected && !clientDisconnectDrainStartedAt.IsZero() { + remaining := readTimeout - time.Since(clientDisconnectDrainStartedAt) + if remaining <= 0 { + lease.MarkBroken() + break readLoop + } + if remaining < currentReadTimeout { + currentReadTimeout = remaining + } + } + message, readErr = lease.ReadMessageWithContextTimeout(upstreamReadCtx, currentReadTimeout) if readErr == nil { if documents, repaired := splitOpenAIConcatenatedJSONDocuments(message); repaired { logOpenAIWSModeInfo( @@ -477,6 +544,7 @@ func (s *OpenAIGatewayService) forwardOpenAIWSV2( } } } + markClientRequestCanceled() if readErr == nil && !json.Valid(message) { eventType, _, _ := parseOpenAIWSEventEnvelope(message) if eventType == "" { @@ -515,12 +583,15 @@ func (s *OpenAIGatewayService) forwardOpenAIWSV2( truncateOpenAIWSLogValue(firstEventType, openAIWSLogValueMaxLen), truncateOpenAIWSLogValue(lastEventType, openAIWSLogValueMaxLen), ) + if clientDisconnected { + if !readUsedDetachedContext && errors.Is(readErr, context.Canceled) && clientRequestCanceled() { + continue + } + break + } if !wroteDownstream { return nil, wrapOpenAIWSFallback(classifyOpenAIWSReadFallbackReason(readErr), readErr) } - if clientDisconnected { - break - } setOpsUpstreamError(c, 0, sanitizeUpstreamErrorMessage(readErr.Error()), "") return nil, fmt.Errorf("openai ws read event: %w", readErr) } @@ -710,7 +781,13 @@ func (s *OpenAIGatewayService) forwardOpenAIWSV2( } } + if clientDisconnected && terminalEventCount == 0 { + return resultWithUsage(), fmt.Errorf("openai ws stream incomplete after client disconnect: %w", context.Canceled) + } if !reqStream { + if clientDisconnected { + return resultWithUsage(), nil + } if len(finalResponse) == 0 { logOpenAIWSModeInfo( "missing_final_response account_id=%d conn_id=%s events=%d token_events=%d terminal_events=%d wrote_downstream=%v", @@ -772,26 +849,10 @@ func (s *OpenAIGatewayService) forwardOpenAIWSV2( clientDisconnected, ) - return &OpenAIForwardResult{ - RequestID: responseID, - Usage: *usage, - Model: originalModel, - UpstreamModel: mappedModel, - UpstreamResponseModel: responseModelObserver.Model(), - UpstreamResponseModelConflict: responseModelObserver.Conflict(), - UpstreamResponseServiceTier: responseModelObserver.ServiceTier(), - ImageCount: imageCounter.Count(), - ImageOutputSizes: imageCounter.Sizes(), - ServiceTier: resolvedOpenAIUpstreamServiceTierFromObserver(responseModelObserver, extractOpenAIServiceTier(reqBody)), - ReasoningEffort: extractOpenAIReasoningEffort(reqBody, mappedModel, originalModel), - RequestedReasoningEffort: CanonicalRequestedReasoningEffortFromReqBody(reqBody, originalModel, mappedModel), - Stream: reqStream, - OpenAIWSMode: true, - UpstreamTerminalEvent: upstreamTerminalEvent, - ResponseHeaders: lease.HandshakeHeaders(), - Duration: time.Since(startTime), - FirstTokenMs: firstTokenMs, - }, nil + result := resultWithUsage() + result.ImageCount = imageCounter.Count() + result.ImageOutputSizes = imageCounter.Sizes() + return result, nil } // ProxyResponsesWebSocketFromClient 处理客户端入站 WebSocket(OpenAI Responses WS Mode)并转发到上游。 From 5fb5dfb34cd1540a39639983f0e7b17c2a62ce58 Mon Sep 17 00:00:00 2001 From: wucm667 Date: Sun, 9 Aug 2026 20:35:36 +0800 Subject: [PATCH 009/145] fix(openai): reacquire passthrough turn slots --- .../handler/openai_gateway_handler.go | 19 ++- .../service/openai_ws_forwarder_ingress.go | 6 +- ...openai_ws_passthrough_turn_pricing_test.go | 128 ++++++++++++------ .../openai_ws_v2_passthrough_adapter.go | 5 + 4 files changed, 105 insertions(+), 53 deletions(-) diff --git a/backend/internal/handler/openai_gateway_handler.go b/backend/internal/handler/openai_gateway_handler.go index e57cc759e6..08625facf8 100644 --- a/backend/internal/handler/openai_gateway_handler.go +++ b/backend/internal/handler/openai_gateway_handler.go @@ -1616,10 +1616,10 @@ const ( // 由 BeforeTurn 在每个 turn 开始时冻结,AfterTurn 的用量提交读取它;turn 在 // 连接内串行推进,互斥锁只为跨用量提交 goroutine 的读取安全。 // -// ws_v2 passthrough ingress 没有 BeforeTurn,因此本值会保持零;AfterTurn 必须 -// 以 TurnStarted 已记录的所属 turn 开始时刻为回退,而不是用建连或记录时刻。 -// 这样每个 passthrough turn 都按自己的开始时刻计价,但不改变其仅在建连时执行 -// 准入门、没有 turn 级利润复核的既有行为。 +// 零值语义(重要):首轮准入由握手路径完成,不调用 BeforeTurn,因此首轮保持 +// 零值并回退到 TurnStarted 记录的首轮开始时刻。后续 turn 在 response.create +// 写入上游前调用 BeforeTurn,按当时的利润门复核并冻结定价。绝不能用建连时刻 +// 初始化,否则会把长连接的所有 turn 钉死在建连时的峰谷因子。 type openAIWSTurnPricing struct { mu sync.Mutex at time.Time @@ -2321,8 +2321,8 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) { // turn-tagged slot preserves the exact mapping used for the in-flight request. var turnChannelMapping atomic.Pointer[openAIWSTurnChannelMappingSnapshot] turnChannelMapping.Store(&openAIWSTurnChannelMappingSnapshot{turn: 1, mapping: channelMappingWS}) - // turn 级定价:BeforeTurn 重新冻结 pricingAt 并按最新门复核当前账号; - // passthrough 没有 BeforeTurn 时,AfterTurn 回退到 TurnStarted 的所属 turn 时刻。 + // turn 级定价:首轮回退到 TurnStarted 的所属 turn 时刻;后续 turn 由 + // BeforeTurn 重新冻结 pricingAt 并按最新门复核当前账号。 var turnPricing openAIWSTurnPricing hooks := &service.OpenAIWSIngressHooks{ ClientLifecycleContext: clientLifecycleCtx, @@ -2335,10 +2335,9 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) { c.Set(securityAuditWSTurnContextKey, turn) service.BeginOpsStreamTurn(c, turn) setCyberTurnBody(turn, payload) - // Passthrough ingress intentionally skips BeforeTurn, so enforce only - // the connection-level cyber session gate here as well. Native ingress - // visits this hook first and gets the same side-effect-free close error; - // its original BeforeTurn guard remains as defense in depth. + // 连接级 cyber session gate 也在 BeforeRequest 先执行,使 native 与 + // passthrough ingress 都能在 BeforeTurn 及上游写入前无副作用地拒绝。 + // BeforeTurn 中保留同一检查作为防御式兜底。 if cyberBlockedThisConn { return service.NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, cyberSessionBlockedClientMsg, nil) } diff --git a/backend/internal/service/openai_ws_forwarder_ingress.go b/backend/internal/service/openai_ws_forwarder_ingress.go index 5f5976c7d0..4e2a1f13a2 100644 --- a/backend/internal/service/openai_ws_forwarder_ingress.go +++ b/backend/internal/service/openai_ws_forwarder_ingress.go @@ -137,9 +137,9 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( forceHTTPBridge = true break } - // 透传 relay 通过 TurnStarted 记录每个 turn 的开始时刻,但不触发 - // BeforeTurn;因此仍只有建连时的利润准入门,没有 turn 级复核。 - // handler 计费在 turn 定价未冻结时回退到对应的 turn 开始时刻。 + // 首轮准入由握手路径完成;后续 response.create 会在写入上游前 + // 依次回调 BeforeRequest 和 BeforeTurn,并在终止或失败时回调 + // AfterTurn,从而覆盖 turn 级利润复核、定价冻结和并发槽位释放。 return s.proxyResponsesWebSocketV2Passthrough( ctx, c, diff --git a/backend/internal/service/openai_ws_passthrough_turn_pricing_test.go b/backend/internal/service/openai_ws_passthrough_turn_pricing_test.go index 16be2bd4c6..a405fbb29a 100644 --- a/backend/internal/service/openai_ws_passthrough_turn_pricing_test.go +++ b/backend/internal/service/openai_ws_passthrough_turn_pricing_test.go @@ -60,50 +60,38 @@ func startPassthroughHookRecordingServer( return server, serverErr } -// TestPassthroughIngressReportsTurnStartedBeforeAfterTurnWithoutBeforeTurn 钉死 -// ws_v2 透传 ingress 与 handler 侧 turn 定价的耦合:透传 relay 不触发 -// BeforeTurn,但会在每个 AfterTurn 前通过 TurnStarted 报告同一 turn 的开始时刻。 -// -// handler 的 recordTurnStart 保存该时刻,AfterTurn 再用 currentOr(turnStart) -// 作为计费 PricingAt;不触发 BeforeTurn 也意味着透传仍没有 turn 级利润复核。 -func TestPassthroughIngressReportsTurnStartedBeforeAfterTurnWithoutBeforeTurn(t *testing.T) { +func TestPassthroughIngressFollowUpCallsBeforeTurnAfterBeforeRequest(t *testing.T) { gin.SetMode(gin.TestMode) controlCtx, cancelControl := context.WithCancelCause(context.Background()) defer cancelControl(context.Canceled) upstream := newStagedPassthroughConn() - upstream.Send(`{"type":"response.completed","response":{"id":"resp_pricing","model":"gpt-5.1","usage":{"input_tokens":1,"output_tokens":1}}}`) + upstream.Send(`{"type":"response.completed","response":{"id":"resp_pricing_1","model":"gpt-5.1","usage":{"input_tokens":1,"output_tokens":1}}}`) var hooksMu sync.Mutex - beforeTurnCalls := 0 - expectedTurnStartedAt := time.Date(2026, time.August, 17, 9, 59, 59, 0, time.UTC) - type hookEvent struct { - name string - turn int - startedAt time.Time - } - var hookEvents []hookEvent + var callbacks []string + afterTurnCalls := 0 hooks := &OpenAIWSIngressHooks{ - InitialTurnStartedAt: expectedTurnStartedAt, - TurnStarted: func(turn int, startedAt time.Time) { + BeforeRequest: func(int, []byte, string) error { hooksMu.Lock() - hookEvents = append(hookEvents, hookEvent{name: "TurnStarted", turn: turn, startedAt: startedAt}) - hooksMu.Unlock() - }, - BeforeTurn: func(int) error { - hooksMu.Lock() - beforeTurnCalls++ + callbacks = append(callbacks, "before_request") hooksMu.Unlock() return nil }, - AfterTurn: func(turn int, _ *OpenAIForwardResult, _ error) { + BeforeTurn: func(int) error { hooksMu.Lock() - hookEvents = append(hookEvents, hookEvent{name: "AfterTurn", turn: turn}) + callbacks = append(callbacks, "before_turn") + hooksMu.Unlock() + return nil + }, + AfterTurn: func(int, *OpenAIForwardResult, error) { + hooksMu.Lock() + afterTurnCalls++ hooksMu.Unlock() }, } - server, serverErr := startPassthroughHookRecordingServer( + server, _ := startPassthroughHookRecordingServer( t, controlCtx, newPassthroughLifecycleService(passthroughLifecycleConfig(), upstream), @@ -113,30 +101,90 @@ func TestPassthroughIngressReportsTurnStartedBeforeAfterTurnWithoutBeforeTurn(t defer server.Close() clientConn := dialPassthroughLifecycleClient(t, server) defer func() { _ = clientConn.CloseNow() }() + require.Equal(t, "response.create", gjson.GetBytes(requirePassthroughUpstreamWrite(t, upstream, time.Second), "type").String()) event, err := readPassthroughLifecycleFrame(t, clientConn, 3*time.Second) require.NoError(t, err) require.Equal(t, "response.completed", gjson.GetBytes(event, "type").String()) - // 等待连接自然结束(inter-turn idle 超时),确保 AfterTurn 已提交。 - _, _ = readPassthroughLifecycleFrame(t, clientConn, 3*time.Second) + writeCtx, cancelWrite := context.WithTimeout(context.Background(), time.Second) + err = clientConn.Write(writeCtx, coderws.MessageText, []byte(`{"type":"response.create","model":"gpt-5.1"}`)) + cancelWrite() + require.NoError(t, err) + require.Equal(t, "response.create", gjson.GetBytes(requirePassthroughUpstreamWrite(t, upstream, time.Second), "type").String()) + upstream.Send(`{"type":"response.completed","response":{"id":"resp_pricing_2","model":"gpt-5.1","usage":{"input_tokens":1,"output_tokens":1}}}`) + event, err = readPassthroughLifecycleFrame(t, clientConn, 3*time.Second) + require.NoError(t, err) + require.Equal(t, "response.completed", gjson.GetBytes(event, "type").String()) + + hooksMu.Lock() + gotCallbacks := append([]string(nil), callbacks...) + gotAfter := afterTurnCalls + hooksMu.Unlock() + + require.Equal(t, []string{"before_request", "before_turn"}, gotCallbacks) + require.Equal(t, 2, gotAfter) +} + +func TestPassthroughIngressBeforeTurnRejectionDoesNotForwardFollowUp(t *testing.T) { + gin.SetMode(gin.TestMode) + controlCtx, cancelControl := context.WithCancelCause(context.Background()) + defer cancelControl(context.Canceled) + + upstream := newStagedPassthroughConn() + upstream.Send(`{"type":"response.completed","response":{"id":"resp_reject_1","model":"gpt-5.1","usage":{"input_tokens":1,"output_tokens":1}}}`) + rejection := errors.New("turn rejected") + var hooksMu sync.Mutex + afterTurnCalls := 0 + var finalErr error + beforeTurnTurn := 0 + hooks := &OpenAIWSIngressHooks{ + BeforeTurn: func(turn int) error { + hooksMu.Lock() + beforeTurnTurn = turn + hooksMu.Unlock() + return rejection + }, + AfterTurn: func(_ int, _ *OpenAIForwardResult, turnErr error) { + hooksMu.Lock() + afterTurnCalls++ + if turnErr != nil { + finalErr = turnErr + } + hooksMu.Unlock() + }, + } + + server, serverErr := startPassthroughHookRecordingServer(t, controlCtx, newPassthroughLifecycleService(passthroughLifecycleConfig(), upstream), passthroughLifecycleAccount(), hooks) + defer server.Close() + clientConn := dialPassthroughLifecycleClient(t, server) + defer func() { _ = clientConn.CloseNow() }() + requirePassthroughUpstreamWrite(t, upstream, time.Second) + _, err := readPassthroughLifecycleFrame(t, clientConn, 3*time.Second) + require.NoError(t, err) + + writeCtx, cancelWrite := context.WithTimeout(context.Background(), time.Second) + err = clientConn.Write(writeCtx, coderws.MessageText, []byte(`{"type":"response.create","model":"gpt-5.1"}`)) + cancelWrite() + require.NoError(t, err) select { - case <-serverErr: + case payload := <-upstream.writes: + t.Fatalf("rejected response.create was forwarded upstream: %s", payload) + case <-time.After(100 * time.Millisecond): + } + select { + case err = <-serverErr: + require.ErrorIs(t, err, rejection) case <-time.After(3 * time.Second): - t.Fatal("passthrough ingress did not exit") + t.Fatal("passthrough ingress did not exit after BeforeTurn rejection") } hooksMu.Lock() - gotBefore := beforeTurnCalls - gotEvents := append([]hookEvent(nil), hookEvents...) + gotBeforeTurn, gotAfter, gotFinalErr := beforeTurnTurn, afterTurnCalls, finalErr hooksMu.Unlock() - - require.Zero(t, gotBefore, "透传 ingress 不应调用 BeforeTurn") - require.GreaterOrEqual(t, len(gotEvents), 2, "透传 ingress 应报告 TurnStarted 和 AfterTurn") - require.Equal(t, "TurnStarted", gotEvents[0].name) - require.Equal(t, expectedTurnStartedAt, gotEvents[0].startedAt, "TurnStarted 必须携带入口冻结的首轮开始时刻") - require.Equal(t, "AfterTurn", gotEvents[1].name) - require.Equal(t, gotEvents[0].turn, gotEvents[1].turn, "TurnStarted 后应提交同一 turn 的 AfterTurn") + require.Equal(t, 2, gotBeforeTurn) + require.Equal(t, 2, gotAfter, "each started turn must be finalized exactly once") + require.ErrorIs(t, gotFinalErr, rejection) } func TestPassthroughIngressFreezesSubsequentTurnBeforeRequestPolicy(t *testing.T) { diff --git a/backend/internal/service/openai_ws_v2_passthrough_adapter.go b/backend/internal/service/openai_ws_v2_passthrough_adapter.go index 95de10df71..cf8c0de7cb 100644 --- a/backend/internal/service/openai_ws_v2_passthrough_adapter.go +++ b/backend/internal/service/openai_ws_v2_passthrough_adapter.go @@ -1054,6 +1054,11 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough( return payload, nil, err } } + if hooks != nil && hooks.BeforeTurn != nil { + if err := hooks.BeforeTurn(turnNo); err != nil { + return payload, nil, err + } + } if hooks != nil && hooks.MapRequestModel != nil { upstreamModel, err := hooks.MapRequestModel(turnNo, requestModelForThisFrame) if err != nil { From 222181efd6bebc28d2258a7ac5b5b4a8044fb649 Mon Sep 17 00:00:00 2001 From: aofee <44353308+aofee@users.noreply.github.com> Date: Wed, 2 Sep 2026 11:02:38 +0800 Subject: [PATCH 010/145] fix(anthropic): scope Fable credits errors to model --- backend/internal/service/ratelimit_service.go | 60 ++++++++++++++- ...mit_service_anthropic_window_limit_test.go | 76 +++++++++++++++++++ 2 files changed, 134 insertions(+), 2 deletions(-) diff --git a/backend/internal/service/ratelimit_service.go b/backend/internal/service/ratelimit_service.go index 3bd467f464..47f234f85d 100644 --- a/backend/internal/service/ratelimit_service.go +++ b/backend/internal/service/ratelimit_service.go @@ -366,12 +366,16 @@ func (s *RateLimitService) HandleUpstreamError(ctx context.Context, account *Acc // otherwise a broad "rate limit" keyword rule can shorten a multi-hour // cooldown to a local temporary pause. if statusCode == http.StatusTooManyRequests && account.Platform == PlatformAnthropic { + // Fable may be rejected because the organization has no usage credits for + // this model. Anthropic reports that as 429, but it is a model entitlement + // failure rather than a shared account window exhaustion. + fableCreditsRequired := s.persistAnthropicFableCreditsRequired(ctx, account, headers, responseBody, firstRequestedModel(requestedModel)) // 7d_oi 是 Fable 模型专属的 7d 窗口:只标记模型级限流,账号对其他模型仍可调度。 fableLimited := s.persistAnthropicFableWindowLimit(ctx, account, headers) if s.persistAnthropicExhaustedWindowLimit(ctx, account, headers) { return false } - if fableLimited { + if fableCreditsRequired || fableLimited { return false } } @@ -1486,7 +1490,59 @@ func (s *RateLimitService) persistAnthropicExhaustedWindowLimit(ctx context.Cont return true } -const anthropicFableWindowReason = "anthropic_7d_oi_window_exhausted" +const ( + anthropicFableWindowReason = "anthropic_7d_oi_window_exhausted" + anthropicFableCreditsRequiredReason = "anthropic_fable_credits_required" +) + +// persistAnthropicFableCreditsRequired handles Anthropic's credits_required +// response for Fable. Although the upstream status is 429, this response only +// says that the organization cannot use Fable; marking the whole account rate +// limited would unnecessarily stop Sonnet, Opus, and Haiku scheduling. +func (s *RateLimitService) persistAnthropicFableCreditsRequired(ctx context.Context, account *Account, headers http.Header, responseBody []byte, requestedModel string) bool { + if s == nil || s.accountRepo == nil || account == nil { + return false + } + if !strings.EqualFold(strings.TrimSpace(gjson.GetBytes(responseBody, "error.details.error_code").String()), "credits_required") { + return false + } + + model := strings.TrimSpace(gjson.GetBytes(responseBody, "error.details.model").String()) + if model == "" { + model = strings.TrimSpace(requestedModel) + } + if !isAnthropicFableModel(model) { + return false + } + + now := time.Now() + resetAt, ok := parseAnthropicResetTimestamp(headers.Get("anthropic-ratelimit-unified-reset"), now, 366*24*time.Hour) + if !ok { + cooldown, enabled := s.get429FallbackCooldown(ctx, account) + if !enabled { + slog.Info("anthropic_fable_credits_required_cooldown_ignored", "account_id", account.ID) + return true + } + resetAt = now.Add(cooldown) + } + + if err := s.accountRepo.SetModelRateLimit(ctx, account.ID, anthropicFableRateLimitKey, resetAt, anthropicFableCreditsRequiredReason); err != nil { + slog.Warn("anthropic_fable_credits_required_rate_limit_set_failed", + "account_id", account.ID, + "scope", anthropicFableRateLimitKey, + "reset_at", resetAt, + "error", err) + // The response is still known to be Fable-specific. Do not widen a + // persistence failure into an account-level rate limit. + return true + } + slog.Info("anthropic_fable_credits_required_model_rate_limited", + "account_id", account.ID, + "scope", anthropicFableRateLimitKey, + "reset_at", resetAt, + "reset_in", time.Until(resetAt).Truncate(time.Second)) + return true +} // selectAnthropicFableWindowLimit parses the Anthropic 7d_oi per-model window // headers (the Fable-only 7d window, e.g. anthropic-ratelimit-unified-7d_oi-*). diff --git a/backend/internal/service/ratelimit_service_anthropic_window_limit_test.go b/backend/internal/service/ratelimit_service_anthropic_window_limit_test.go index a13e8fe333..328bf07fe6 100644 --- a/backend/internal/service/ratelimit_service_anthropic_window_limit_test.go +++ b/backend/internal/service/ratelimit_service_anthropic_window_limit_test.go @@ -160,6 +160,82 @@ func TestHandleUpstreamError_Anthropic7dOiOnlyMarksModelRateLimit(t *testing.T) require.Equal(t, 0.41, repo.lastExtraUpdates["session_window_utilization"]) } +func TestHandleUpstreamError_AnthropicFableCreditsRequiredOnlyMarksModelRateLimit(t *testing.T) { + resetAt := time.Now().Add(29 * 24 * time.Hour).Truncate(time.Second) + headers := http.Header{} + headers.Set("anthropic-ratelimit-unified-reset", strconv.FormatInt(resetAt.Unix(), 10)) + + repo := &anthropicWindowLimitRepo{} + svc := NewRateLimitService(repo, nil, nil, nil, nil) + account := &Account{ID: 42, Type: AccountTypeOAuth, Platform: PlatformAnthropic} + body := []byte(`{"type":"error","error":{"details":{"error_code":"credits_required","model":"claude-fable-5","disabled_reason":"org_level_disabled"},"message":"Usage credits are required for this model."}}`) + + shouldDisable := svc.HandleUpstreamError(context.Background(), account, http.StatusTooManyRequests, headers, body, "claude-fable-5") + + require.False(t, shouldDisable) + require.Zero(t, repo.rateLimitCalls, "Fable entitlement failure must not rate limit the whole account") + require.Zero(t, repo.sessionWindowCalls, "Fable entitlement failure must not rewrite the shared session window") + require.Equal(t, 1, repo.modelRateLimitCalls) + require.Equal(t, anthropicFableRateLimitKey, repo.lastModelRateLimitScope) + require.Equal(t, resetAt, repo.lastModelRateLimitReset) +} + +func TestHandleUpstreamError_AnthropicFableCreditsRequiredFallsBackToRequestedModel(t *testing.T) { + startedAt := time.Now() + repo := &anthropicWindowLimitRepo{} + svc := NewRateLimitService(repo, nil, nil, nil, nil) + account := &Account{ID: 42, Type: AccountTypeOAuth, Platform: PlatformAnthropic} + body := []byte(`{"type":"error","error":{"details":{"error_code":"credits_required"},"message":"Usage credits are required for this model."}}`) + + svc.HandleUpstreamError(context.Background(), account, http.StatusTooManyRequests, http.Header{}, body, "claude-fable-5[1m]") + + require.Zero(t, repo.rateLimitCalls) + require.Equal(t, 1, repo.modelRateLimitCalls) + require.Equal(t, anthropicFableRateLimitKey, repo.lastModelRateLimitScope) + require.True(t, repo.lastModelRateLimitReset.After(startedAt)) +} + +func TestHandleUpstreamError_AnthropicNonFableCreditsRequiredKeepsLegacyBehavior(t *testing.T) { + resetAt := time.Now().Add(2 * time.Hour).Truncate(time.Second) + headers := http.Header{} + headers.Set("anthropic-ratelimit-unified-reset", strconv.FormatInt(resetAt.Unix(), 10)) + + repo := &anthropicWindowLimitRepo{} + svc := NewRateLimitService(repo, nil, nil, nil, nil) + account := &Account{ID: 42, Type: AccountTypeOAuth, Platform: PlatformAnthropic} + body := []byte(`{"type":"error","error":{"details":{"error_code":"credits_required","model":"claude-opus-5"},"message":"Usage credits are required for this model."}}`) + + svc.HandleUpstreamError(context.Background(), account, http.StatusTooManyRequests, headers, body, "claude-opus-5") + + require.Zero(t, repo.modelRateLimitCalls) + require.Equal(t, 1, repo.rateLimitCalls) + require.Equal(t, resetAt, repo.lastRateLimitReset) +} + +func TestHandleUpstreamError_AnthropicSharedWindowStillWinsWithFableCreditsRequired(t *testing.T) { + now := time.Now() + reset5h := now.Add(2 * time.Hour).Truncate(time.Second) + reset7d := now.Add(80 * time.Hour).Truncate(time.Second) + headers := http.Header{} + headers.Set("anthropic-ratelimit-unified-5h-status", "rejected") + headers.Set("anthropic-ratelimit-unified-5h-utilization", "1.0") + headers.Set("anthropic-ratelimit-unified-5h-reset", strconv.FormatInt(reset5h.Unix(), 10)) + headers.Set("anthropic-ratelimit-unified-7d-status", "allowed") + headers.Set("anthropic-ratelimit-unified-7d-reset", strconv.FormatInt(reset7d.Unix(), 10)) + headers.Set("anthropic-ratelimit-unified-reset", strconv.FormatInt(reset5h.Unix(), 10)) + + repo := &anthropicWindowLimitRepo{} + svc := NewRateLimitService(repo, nil, nil, nil, nil) + account := &Account{ID: 42, Type: AccountTypeOAuth, Platform: PlatformAnthropic} + body := []byte(`{"type":"error","error":{"details":{"error_code":"credits_required","model":"claude-fable-5"}}}`) + + svc.HandleUpstreamError(context.Background(), account, http.StatusTooManyRequests, headers, body, "claude-fable-5") + + require.Equal(t, 1, repo.modelRateLimitCalls, "Fable-specific failure should still be recorded") + require.Equal(t, 1, repo.rateLimitCalls, "an explicitly rejected shared window must still rate limit the account") + require.Equal(t, reset5h, repo.lastRateLimitReset) +} + func TestHandleUpstreamError_Anthropic5hWindowStillWinsOver7dOi(t *testing.T) { // 5h 窗口 rejected 时必须仍按账号级限流处理(用 5h reset),同时记录 Fable 模型限流。 now := time.Now() From a7742306616c9596bb564d790d5a21e5a88dd0b0 Mon Sep 17 00:00:00 2001 From: aofee <44353308+aofee@users.noreply.github.com> Date: Wed, 2 Sep 2026 11:09:21 +0800 Subject: [PATCH 011/145] fix(grok): keep inconclusive billing accounts eligible --- README.md | 2 +- backend/internal/service/account.go | 14 ++++++++++---- .../service/account_grok_media_eligibility_test.go | 8 ++++++-- 3 files changed, 17 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 169adb28d3..b4e189c54d 100644 --- a/README.md +++ b/README.md @@ -827,7 +827,7 @@ xAI quota is passive. Sub2API does not invent subscription quota values; it reco `401` responses temporarily remove accounts with invalid credentials from scheduling. `403` responses are treated as access or entitlement failures instead of token-refresh loops. `429` responses use `Retry-After` or a short cooldown to temporarily remove the account from scheduling. -New Grok image and video generation requests use a media-specific eligibility check. API-key accounts remain eligible. OAuth accounts require positive paid-entitlement evidence from the xAI billing probe; Free, forbidden, missing, malformed, and inconclusive billing observations are excluded from new media generation. Unobserved OAuth accounts are probed before the first media request is forwarded, and imports run the billing-first quota probe proactively. Chat requests and video status lookups are not affected by this media-only quarantine. If no eligible account remains, the media endpoint returns HTTP `503` with error type `grok_media_no_eligible_account`. +New Grok image and video generation requests use a media-specific eligibility check. API-key accounts remain eligible. OAuth accounts with explicit Free or forbidden billing evidence are excluded from new media generation. Missing or malformed observations are probed before dispatch; a successful but incomplete billing response is treated as `billing_inconclusive` and remains eligible for backwards compatibility, because an unknown billing schema is not proof that the account lacks media entitlement. Operators can quarantine a known-bad account with `extra.grok_media_eligible=false` or force-enable a verified account with `true`. Imports run the billing-first quota probe proactively. Chat requests and video status lookups are not affected by this media-only quarantine. If no eligible account remains, the media endpoint returns HTTP `503` with error type `grok_media_no_eligible_account`. Administrators can override automatic media eligibility through the account create/update API by setting `extra.grok_media_eligible` to `false` (exclude) or `true` (force eligible). On update, set it to `null` to remove the override and return to automatic probe-based behavior; omitting the field preserves the current override. A weekly allowance period alone is not treated as a paid tier signal. Successful image responses must contain at least one actual image output; empty HTTP `200` responses trigger account failover instead of being counted and returned as successful generations. diff --git a/backend/internal/service/account.go b/backend/internal/service/account.go index ad1ad82a1b..d36dc6ecff 100644 --- a/backend/internal/service/account.go +++ b/backend/internal/service/account.go @@ -1840,9 +1840,10 @@ func (a *Account) SupportsOpenAIEndpointCapability(capability OpenAIEndpointCapa } // GrokMediaGenerationEligibility reports whether a Grok account may receive -// new image/video generation requests. OAuth media fails closed unless billing -// observations provide positive paid-entitlement evidence. An explicit -// operator override takes precedence over probe data. +// new image/video generation requests. Explicit evidence of a forbidden or +// free account blocks media, while an incomplete successful billing response +// remains eligible for backwards compatibility. An explicit operator +// override takes precedence over probe data. func (a *Account) GrokMediaGenerationEligibility() (bool, string) { if a == nil || !a.IsGrok() { return false, "not_grok" @@ -1868,7 +1869,12 @@ func (a *Account) GrokMediaGenerationEligibility() (bool, string) { return false, "billing_free_tier" } if !grokBillingHasAuthoritativeQuota(billing) { - return false, "billing_inconclusive" + // Billing endpoints can return 200 with an account-specific schema that + // omits plan/quota fields (for example, some SuperGrok accounts). An + // incomplete observation is not proof of ineligibility; keep the account + // routable and expose the reason for diagnostics. Operators can still + // quarantine a known-bad account with grok_media_eligible=false. + return true, "billing_inconclusive" } return true, "eligible" } diff --git a/backend/internal/service/account_grok_media_eligibility_test.go b/backend/internal/service/account_grok_media_eligibility_test.go index 47d1f2b94f..b53d88dc59 100644 --- a/backend/internal/service/account_grok_media_eligibility_test.go +++ b/backend/internal/service/account_grok_media_eligibility_test.go @@ -46,6 +46,7 @@ func TestGrokMediaGenerationEligibility(t *testing.T) { {name: "non grok account", account: &Account{Platform: PlatformOpenAI}, want: false, wantReason: "not_grok"}, {name: "non oauth grok account stays eligible", account: &Account{Platform: PlatformGrok, Type: AccountTypeAPIKey}, want: true, wantReason: "non_oauth"}, {name: "unobserved oauth fails closed", account: &Account{Platform: PlatformGrok, Type: AccountTypeOAuth}, want: false, wantReason: "billing_unobserved"}, + {name: "inconclusive successful billing remains eligible", account: &Account{Platform: PlatformGrok, Type: AccountTypeOAuth, Extra: map[string]any{grokBillingExtraKey: &xai.BillingSummary{StatusCode: http.StatusOK, Partial: true}}}, want: true, wantReason: "billing_inconclusive"}, {name: "weekly paid usage is eligible without inferring from period type", account: &Account{Platform: PlatformGrok, Type: AccountTypeOAuth, Extra: map[string]any{grokBillingExtraKey: weeklyAllowance}}, want: true, wantReason: "eligible"}, {name: "billing forbidden is rejected", account: &Account{Platform: PlatformGrok, Type: AccountTypeOAuth, Extra: map[string]any{grokBillingExtraKey: forbiddenBilling}}, want: false, wantReason: "billing_forbidden"}, {name: "weekly billing forbidden is rejected after partial success", account: &Account{Platform: PlatformGrok, Type: AccountTypeOAuth, Extra: map[string]any{grokBillingExtraKey: weeklyForbidden}}, want: false, wantReason: "billing_forbidden"}, @@ -65,7 +66,7 @@ func TestGrokMediaGenerationEligibility(t *testing.T) { } } -func TestGrokMediaCapabilityKeepsOnlyUnobservedOAuthAsProbeCandidate(t *testing.T) { +func TestGrokMediaCapabilityKeepsUnobservedAndInconclusiveOAuthAsCandidates(t *testing.T) { unobserved := &Account{Platform: PlatformGrok, Type: AccountTypeOAuth} eligible, reason := unobserved.GrokMediaGenerationEligibility() require.False(t, eligible) @@ -80,7 +81,10 @@ func TestGrokMediaCapabilityKeepsOnlyUnobservedOAuthAsProbeCandidate(t *testing. Partial: true, }}, } - require.False(t, inconclusive.SupportsOpenAIEndpointCapability(OpenAIEndpointCapabilityGrokMediaGeneration)) + eligible, reason = inconclusive.GrokMediaGenerationEligibility() + require.True(t, eligible) + require.Equal(t, "billing_inconclusive", reason) + require.True(t, inconclusive.SupportsOpenAIEndpointCapability(OpenAIEndpointCapabilityGrokMediaGeneration)) } func TestGrokMediaCapabilityFiltersOnlyGeneration(t *testing.T) { From 9ad386569051b60fae616932eca876a7e4f78538 Mon Sep 17 00:00:00 2001 From: feeeei Date: Wed, 2 Sep 2026 11:44:56 +0800 Subject: [PATCH 012/145] =?UTF-8?q?fix(gateway):=20=E8=B4=9F=E8=BD=BD?= =?UTF-8?q?=E6=84=9F=E7=9F=A5=E8=B0=83=E5=BA=A6=E8=A1=A5=E9=BD=90=E6=B8=A0?= =?UTF-8?q?=E9=81=93=E6=A8=A1=E5=9E=8B=E9=99=90=E5=88=B6=E7=9A=84=E9=80=90?= =?UTF-8?q?=E8=B4=A6=E5=8F=B7=E6=A3=80=E6=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 渠道 billing_model_source=upstream 时,限制模型只能按账号映射后的上游模型 逐账号判定。GatewayService.SelectAccountWithLoadAwareness 的负载感知各层 (模型路由候选、路由内粘性 gate、无路由粘性 gate、负载感知候选及其兜底) 此前都没有这一判定,load_batch_enabled 默认开启,anthropic/gemini/antigravity 分组的渠道白名单实际不生效。 现在入口算一次 needsUpstreamChannelRestrictionCheck,各层共用同一判定; 候选因渠道限制筛空时记 warn 并返回带 channel pricing restriction 的 ErrNoAvailableAccounts,与预检路径一致。 --- ...way_channel_restriction_load_aware_test.go | 165 ++++++++++++++++++ .../internal/service/gateway_scheduling.go | 35 +++- 2 files changed, 196 insertions(+), 4 deletions(-) create mode 100644 backend/internal/service/gateway_channel_restriction_load_aware_test.go diff --git a/backend/internal/service/gateway_channel_restriction_load_aware_test.go b/backend/internal/service/gateway_channel_restriction_load_aware_test.go new file mode 100644 index 0000000000..706140fe63 --- /dev/null +++ b/backend/internal/service/gateway_channel_restriction_load_aware_test.go @@ -0,0 +1,165 @@ +//go:build unit + +package service + +import ( + "context" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey" + "github.com/stretchr/testify/require" +) + +// loadAwareRestrictionFixture 构造走负载感知路径(load_batch 开启 + 并发服务存在)的 +// GatewayService:分组 10 绑定 upstream 计费基准的渠道,定价列表只允许 claude-sonnet-4-6。 +// 账号 1 把 claude-fable-5-1 映射为自身(不在定价列表),账号 2 映射为 claude-sonnet-4-6(在列表)。 +type loadAwareRestrictionFixture struct { + svc *GatewayService + ctx context.Context + groupID int64 + cache *mockGatewayCacheForPlatform + concurrencyCache *mockConcurrencyCache +} + +func newLoadAwareRestrictionFixture(t *testing.T, restrict bool, sessionBindings map[string]int64, routing map[string][]int64) *loadAwareRestrictionFixture { + t.Helper() + + groupID := int64(10) + ch := Channel{ + ID: 1, + Status: StatusActive, + GroupIDs: []int64{groupID}, + RestrictModels: restrict, + BillingModelSource: BillingModelSourceUpstream, + ModelPricing: []ChannelModelPricing{ + {Platform: PlatformAnthropic, Models: []string{"claude-sonnet-4-6"}}, + }, + } + channelSvc := newTestChannelService(makeStandardRepo(ch, map[int64]string{groupID: PlatformAnthropic})) + + accountRepo := &mockAccountRepoForPlatform{ + accounts: []Account{ + { + ID: 1, Platform: PlatformAnthropic, Priority: 1, Status: StatusActive, Schedulable: true, Concurrency: 5, + Credentials: map[string]any{ + "model_mapping": map[string]any{"claude-fable-5-1": "claude-fable-5-1"}, + }, + }, + { + ID: 2, Platform: PlatformAnthropic, Priority: 2, Status: StatusActive, Schedulable: true, Concurrency: 5, + Credentials: map[string]any{ + "model_mapping": map[string]any{"claude-fable-5-1": "claude-sonnet-4-6"}, + }, + }, + }, + accountsByID: map[int64]*Account{}, + } + for i := range accountRepo.accounts { + accountRepo.accountsByID[accountRepo.accounts[i].ID] = &accountRepo.accounts[i] + } + + group := &Group{ + ID: groupID, + Platform: PlatformAnthropic, + Status: StatusActive, + Hydrated: true, + ModelRoutingEnabled: len(routing) > 0, + ModelRouting: routing, + } + groupRepo := &mockGroupRepoForGateway{groups: map[int64]*Group{groupID: group}} + + cfg := testConfig() + cfg.Gateway.Scheduling.LoadBatchEnabled = true + cache := &mockGatewayCacheForPlatform{sessionBindings: sessionBindings} + concurrencyCache := &mockConcurrencyCache{} + + svc := &GatewayService{ + accountRepo: accountRepo, + groupRepo: groupRepo, + channelService: channelSvc, + cache: cache, + cfg: cfg, + concurrencyService: NewConcurrencyService(concurrencyCache), + } + + return &loadAwareRestrictionFixture{ + svc: svc, + ctx: context.WithValue(context.Background(), ctxkey.Group, group), + groupID: groupID, + cache: cache, + concurrencyCache: concurrencyCache, + } +} + +func TestSelectAccountWithLoadAwareness_UpstreamRestrictionSkipsDisallowedAccount(t *testing.T) { + t.Parallel() + + f := newLoadAwareRestrictionFixture(t, true, nil, nil) + result, err := f.svc.SelectAccountWithLoadAwareness(f.ctx, &f.groupID, "", "claude-fable-5-1", nil, "", 0) + require.NoError(t, err) + require.NotNil(t, result) + require.NotNil(t, result.Account) + require.Equal(t, int64(2), result.Account.ID, "上游模型不在渠道定价列表的高优先级账号必须被跳过") + require.Equal(t, 1, f.concurrencyCache.loadBatchCalls, "应经过 Layer 2 负载感知选择") +} + +func TestSelectAccountWithLoadAwareness_UpstreamRestrictionRejectsWhenAllAccountsDisallowed(t *testing.T) { + t.Parallel() + + f := newLoadAwareRestrictionFixture(t, true, nil, nil) + // 账号 2 也改为映射到不在定价列表的模型 + f.svc.accountRepo.(*mockAccountRepoForPlatform).accounts[1].Credentials = map[string]any{ + "model_mapping": map[string]any{"claude-fable-5-1": "claude-fable-5-1"}, + } + + result, err := f.svc.SelectAccountWithLoadAwareness(f.ctx, &f.groupID, "", "claude-fable-5-1", nil, "", 0) + require.ErrorIs(t, err, ErrNoAvailableAccounts) + require.ErrorContains(t, err, "channel pricing restriction") + require.Nil(t, result) + require.Equal(t, 0, f.concurrencyCache.acquireAccountCalls, "没有合规候选时不应尝试占用任何账号槽位") +} + +func TestSelectAccountWithLoadAwareness_UpstreamRestrictionIgnoresStickyAccount(t *testing.T) { + t.Parallel() + + f := newLoadAwareRestrictionFixture(t, true, map[string]int64{"sticky": 1}, nil) + result, err := f.svc.SelectAccountWithLoadAwareness(f.ctx, &f.groupID, "sticky", "claude-fable-5-1", nil, "", 0) + require.NoError(t, err) + require.NotNil(t, result) + require.NotNil(t, result.Account) + require.Equal(t, int64(2), result.Account.ID, "粘性账号的上游模型不在定价列表时不得沿用粘性会话") + require.Equal(t, int64(2), f.cache.sessionBindings["sticky"], "粘性会话应重新绑定到合规账号") +} + +func TestSelectAccountWithLoadAwareness_UpstreamRestrictionFiltersRoutedAccounts(t *testing.T) { + t.Parallel() + + f := newLoadAwareRestrictionFixture(t, true, nil, map[string][]int64{"claude-fable-5-1": {1}}) + result, err := f.svc.SelectAccountWithLoadAwareness(f.ctx, &f.groupID, "", "claude-fable-5-1", nil, "", 0) + require.NoError(t, err) + require.NotNil(t, result) + require.NotNil(t, result.Account) + require.Equal(t, int64(2), result.Account.ID, "模型路由指向的账号被渠道限制时应回退到合规账号") +} + +func TestSelectAccountWithLoadAwareness_UpstreamRestrictionRoutedStickyAccountNotHonored(t *testing.T) { + t.Parallel() + + f := newLoadAwareRestrictionFixture(t, true, map[string]int64{"sticky": 1}, map[string][]int64{"claude-fable-5-1": {1, 2}}) + result, err := f.svc.SelectAccountWithLoadAwareness(f.ctx, &f.groupID, "sticky", "claude-fable-5-1", nil, "", 0) + require.NoError(t, err) + require.NotNil(t, result) + require.NotNil(t, result.Account) + require.Equal(t, int64(2), result.Account.ID, "路由列表内的粘性账号被渠道限制时不得沿用") +} + +func TestSelectAccountWithLoadAwareness_RestrictModelsDisabledKeepsPriorityOrder(t *testing.T) { + t.Parallel() + + f := newLoadAwareRestrictionFixture(t, false, nil, nil) + result, err := f.svc.SelectAccountWithLoadAwareness(f.ctx, &f.groupID, "", "claude-fable-5-1", nil, "", 0) + require.NoError(t, err) + require.NotNil(t, result) + require.NotNil(t, result.Account) + require.Equal(t, int64(1), result.Account.ID, "未开启限制时不应因定价列表过滤账号") +} diff --git a/backend/internal/service/gateway_scheduling.go b/backend/internal/service/gateway_scheduling.go index 977a28a99d..4842d8488a 100644 --- a/backend/internal/service/gateway_scheduling.go +++ b/backend/internal/service/gateway_scheduling.go @@ -243,6 +243,13 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro return excluded } + // upstream 计费基准的渠道模型限制以账号映射后的上游模型为准,只能逐账号判定; + // 负载感知各层的候选过滤与粘性 gate 共用这一判定。 + needsUpstreamCheck := s.needsUpstreamChannelRestrictionCheck(ctx, groupID) + isChannelRestricted := func(account *Account) bool { + return needsUpstreamCheck && s.isUpstreamModelRestrictedByChannel(ctx, *groupID, account, requestedModel) + } + // 获取模型路由配置(anthropic 目标平台;composite 分组按目标平台判断) var routingAccountIDs []int64 if group != nil && requestedModel != "" && platform == PlatformAnthropic && @@ -270,7 +277,7 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro if len(routingAccountIDs) > 0 && s.concurrencyService != nil { // 1. 过滤出路由列表中可调度的账号 var routingCandidates []*Account - var filteredExcluded, filteredMissing, filteredUnsched, filteredPlatform, filteredModelScope, filteredModelMapping, filteredWindowCost int + var filteredExcluded, filteredMissing, filteredUnsched, filteredPlatform, filteredModelScope, filteredModelMapping, filteredChannelRestricted, filteredWindowCost int var modelScopeSkippedIDs []int64 // 记录因模型限流被跳过的账号 ID for _, routingAccountID := range routingAccountIDs { if isExcluded(routingAccountID) { @@ -297,6 +304,10 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro filteredModelMapping++ continue } + if isChannelRestricted(account) { + filteredChannelRestricted++ + continue + } if !s.isAccountSchedulableForModelSelection(ctx, account, requestedModel) { filteredModelScope++ modelScopeSkippedIDs = append(modelScopeSkippedIDs, account.ID) @@ -319,9 +330,9 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro } if s.debugModelRoutingEnabled() { - logger.LegacyPrintf("service.gateway", "[ModelRoutingDebug] routed candidates: group_id=%v model=%s routed=%d candidates=%d filtered(excluded=%d missing=%d unsched=%d platform=%d model_scope=%d model_mapping=%d window_cost=%d)", + logger.LegacyPrintf("service.gateway", "[ModelRoutingDebug] routed candidates: group_id=%v model=%s routed=%d candidates=%d filtered(excluded=%d missing=%d unsched=%d platform=%d model_scope=%d model_mapping=%d channel_restricted=%d window_cost=%d)", derefGroupID(groupID), requestedModel, len(routingAccountIDs), len(routingCandidates), - filteredExcluded, filteredMissing, filteredUnsched, filteredPlatform, filteredModelScope, filteredModelMapping, filteredWindowCost) + filteredExcluded, filteredMissing, filteredUnsched, filteredPlatform, filteredModelScope, filteredModelMapping, filteredChannelRestricted, filteredWindowCost) if len(modelScopeSkippedIDs) > 0 { logger.LegacyPrintf("service.gateway", "[ModelRoutingDebug] model_rate_limited accounts skipped: group_id=%v model=%s account_ids=%v", derefGroupID(groupID), requestedModel, modelScopeSkippedIDs) @@ -347,6 +358,7 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro s.isGatewayAccountProfitEligible(ctx, stickyAccount) && s.isAccountAllowedForPlatform(stickyAccount, platform, useMixed) && (requestedModel == "" || s.isModelSupportedByAccountWithContext(ctx, stickyAccount, requestedModel)) && + !isChannelRestricted(stickyAccount) && s.isAccountSchedulableForModelSelection(ctx, stickyAccount, requestedModel) && s.isAccountSchedulableForQuota(stickyAccount) && s.isAccountSchedulableForWindowCost(ctx, stickyAccount, true) @@ -531,6 +543,7 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro platformOK := s.isAccountAllowedForPlatform(account, platform, useMixed) profitOK := s.isGatewayAccountProfitEligible(ctx, account) modelSupported := requestedModel == "" || s.isModelSupportedByAccountWithContext(ctx, account, requestedModel) + channelOK := !isChannelRestricted(account) modelSchedulable := s.isAccountSchedulableForModelSelection(ctx, account, requestedModel) quotaOK := s.isAccountSchedulableForQuota(account) windowCostOK := s.isAccountSchedulableForWindowCost(ctx, account, true) @@ -545,13 +558,14 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro "platform_ok", platformOK, "profit_ok", profitOK, "model_supported", modelSupported, + "channel_ok", channelOK, "model_schedulable", modelSchedulable, "quota_ok", quotaOK, "window_cost_ok", windowCostOK, "rpm_ok", rpmOK, ) - if !clearSticky && platformOK && profitOK && modelSupported && modelSchedulable && quotaOK && windowCostOK && rpmOK && schedulable { + if !clearSticky && platformOK && profitOK && modelSupported && channelOK && modelSchedulable && quotaOK && windowCostOK && rpmOK && schedulable { result, err := s.tryAcquireAccountSlot(ctx, accountID, account.Concurrency) if err == nil && result.Acquired { // 会话数量限制检查 @@ -636,6 +650,7 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro "total_accounts", len(accounts), ) candidates := make([]*Account, 0, len(accounts)) + channelRestrictedCount := 0 for i := range accounts { acc := &accounts[i] if isExcluded(acc.ID) { @@ -656,6 +671,10 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro if requestedModel != "" && !s.isModelSupportedByAccountWithContext(ctx, acc, requestedModel) { continue } + if isChannelRestricted(acc) { + channelRestrictedCount++ + continue + } if !s.isAccountSchedulableForModelSelection(ctx, acc, requestedModel) { continue } @@ -675,6 +694,14 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro } if len(candidates) == 0 { + if channelRestrictedCount > 0 { + slog.Warn("channel pricing restriction blocked request", + "group_id", derefGroupID(groupID), + "model", requestedModel, + "restricted_accounts", channelRestrictedCount, + "total_accounts", len(accounts)) + return nil, fmt.Errorf("%w supporting model: %s (channel pricing restriction)", ErrNoAvailableAccounts, requestedModel) + } return nil, ErrNoAvailableAccounts } From 01bd9b71a26fbb28ef11840b3a7f1a3e3589ef1c Mon Sep 17 00:00:00 2001 From: aofee <44353308+aofee@users.noreply.github.com> Date: Wed, 2 Sep 2026 11:43:15 +0800 Subject: [PATCH 013/145] feat: add Grok media eligibility controls --- .../admin/grok_media_eligibility_handler.go | 144 +++++++++++++++++ .../grok_media_eligibility_handler_test.go | 146 ++++++++++++++++++ backend/internal/server/routes/admin.go | 2 + frontend/src/api/admin/accounts.ts | 24 ++- .../components/account/EditAccountModal.vue | 144 ++++++++++++++++- ...tAccountModal.grokMediaEligibility.spec.ts | 110 +++++++++++++ .../src/i18n/locales/en/admin/accounts.ts | 24 +++ .../src/i18n/locales/zh/admin/accounts.ts | 24 +++ frontend/src/types/index.ts | 9 ++ 9 files changed, 624 insertions(+), 3 deletions(-) create mode 100644 backend/internal/handler/admin/grok_media_eligibility_handler.go create mode 100644 backend/internal/handler/admin/grok_media_eligibility_handler_test.go create mode 100644 frontend/src/components/account/__tests__/EditAccountModal.grokMediaEligibility.spec.ts diff --git a/backend/internal/handler/admin/grok_media_eligibility_handler.go b/backend/internal/handler/admin/grok_media_eligibility_handler.go new file mode 100644 index 0000000000..60ddbe3944 --- /dev/null +++ b/backend/internal/handler/admin/grok_media_eligibility_handler.go @@ -0,0 +1,144 @@ +package admin + +import ( + "strconv" + + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" + "github.com/Wei-Shaw/sub2api/internal/pkg/response" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" +) + +const ( + grokMediaEligibilityModeAuto = "auto" + grokMediaEligibilityModeEnabled = "enabled" + grokMediaEligibilityModeDisabled = "disabled" +) + +type grokMediaEligibilityResponse struct { + AccountID int64 `json:"account_id"` + Mode string `json:"mode"` + Eligible bool `json:"eligible"` + Reason string `json:"reason"` +} + +type updateGrokMediaEligibilityRequest struct { + Mode string `json:"mode" binding:"required"` +} + +// GetGrokMediaEligibility returns the current manual override and evaluated +// media-routing decision for a Grok OAuth account. +// GET /api/v1/admin/accounts/:id/grok-media-eligibility +func (h *AccountHandler) GetGrokMediaEligibility(c *gin.Context) { + account, err := h.getGrokOAuthAccount(c) + if err != nil { + response.ErrorFrom(c, err) + return + } + response.Success(c, buildGrokMediaEligibilityResponse(account)) +} + +// UpdateGrokMediaEligibility updates only the manual media eligibility +// override. It deliberately uses UpdateAccountExtra so unrelated account +// extra fields are preserved. +// PUT /api/v1/admin/accounts/:id/grok-media-eligibility +func (h *AccountHandler) UpdateGrokMediaEligibility(c *gin.Context) { + account, err := h.getGrokOAuthAccount(c) + if err != nil { + response.ErrorFrom(c, err) + return + } + + var req updateGrokMediaEligibilityRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "Invalid request: "+err.Error()) + return + } + + var value any + switch req.Mode { + case grokMediaEligibilityModeAuto: + value = nil + case grokMediaEligibilityModeEnabled: + value = true + case grokMediaEligibilityModeDisabled: + value = false + default: + response.ErrorFrom(c, infraerrors.BadRequest( + "GROK_MEDIA_ELIGIBILITY_INVALID_MODE", + "mode must be one of auto, enabled, disabled", + )) + return + } + + updates := map[string]any{service.GrokMediaEligibleExtraKey: value} + if err := service.ValidateGrokMediaEligibilityExtra(account.Platform, updates); err != nil { + response.ErrorFrom(c, err) + return + } + if err := h.adminService.UpdateAccountExtra(c.Request.Context(), account.ID, updates); err != nil { + response.ErrorFrom(c, err) + return + } + + updated, err := h.adminService.GetAccount(c.Request.Context(), account.ID) + if err != nil { + response.ErrorFrom(c, err) + return + } + if err := ensureGrokOAuthAccount(updated); err != nil { + response.ErrorFrom(c, err) + return + } + response.Success(c, buildGrokMediaEligibilityResponse(updated)) +} + +func (h *AccountHandler) getGrokOAuthAccount(c *gin.Context) (*service.Account, error) { + accountID, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + return nil, infraerrors.BadRequest("INVALID_ACCOUNT_ID", "Invalid account ID") + } + account, err := h.adminService.GetAccount(c.Request.Context(), accountID) + if err != nil { + return nil, err + } + if err := ensureGrokOAuthAccount(account); err != nil { + return nil, err + } + return account, nil +} + +func ensureGrokOAuthAccount(account *service.Account) error { + if account == nil { + return infraerrors.NotFound("ACCOUNT_NOT_FOUND", "account not found") + } + if account.Platform != service.PlatformGrok || account.Type != service.AccountTypeOAuth { + return infraerrors.BadRequest( + "GROK_MEDIA_ELIGIBILITY_UNSUPPORTED_ACCOUNT", + "media eligibility is only supported for Grok OAuth accounts", + ) + } + return nil +} + +func buildGrokMediaEligibilityResponse(account *service.Account) grokMediaEligibilityResponse { + eligible, reason := account.GrokMediaGenerationEligibility() + return grokMediaEligibilityResponse{ + AccountID: account.ID, + Mode: grokMediaEligibilityModeFromExtra(account.Extra), + Eligible: eligible, + Reason: reason, + } +} + +func grokMediaEligibilityModeFromExtra(extra map[string]any) string { + if extra != nil { + if enabled, ok := extra[service.GrokMediaEligibleExtraKey].(bool); ok { + if enabled { + return grokMediaEligibilityModeEnabled + } + return grokMediaEligibilityModeDisabled + } + } + return grokMediaEligibilityModeAuto +} diff --git a/backend/internal/handler/admin/grok_media_eligibility_handler_test.go b/backend/internal/handler/admin/grok_media_eligibility_handler_test.go new file mode 100644 index 0000000000..7691d58667 --- /dev/null +++ b/backend/internal/handler/admin/grok_media_eligibility_handler_test.go @@ -0,0 +1,146 @@ +package admin + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +type grokMediaEligibilityAdminServiceStub struct { + *stubAdminService + account *service.Account +} + +func (s *grokMediaEligibilityAdminServiceStub) GetAccount(_ context.Context, _ int64) (*service.Account, error) { + return s.account, nil +} + +func (s *grokMediaEligibilityAdminServiceStub) UpdateAccountExtra(_ context.Context, _ int64, updates map[string]any) error { + if s.account.Extra == nil { + s.account.Extra = map[string]any{} + } + for key, value := range updates { + if value == nil { + delete(s.account.Extra, key) + } else { + s.account.Extra[key] = value + } + } + return nil +} + +func newGrokMediaEligibilityRouter(account *service.Account) *gin.Engine { + stub := &grokMediaEligibilityAdminServiceStub{ + stubAdminService: newStubAdminService(), + account: account, + } + h := NewAccountHandler(stub, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + router := gin.New() + router.GET("/accounts/:id/grok-media-eligibility", h.GetGrokMediaEligibility) + router.PUT("/accounts/:id/grok-media-eligibility", h.UpdateGrokMediaEligibility) + return router +} + +func TestGrokMediaEligibilityHandlerGetReturnsModeAndReason(t *testing.T) { + gin.SetMode(gin.TestMode) + router := newGrokMediaEligibilityRouter(&service.Account{ + ID: 12, + Platform: service.PlatformGrok, + Type: service.AccountTypeOAuth, + Extra: map[string]any{ + "grok_billing_snapshot": map[string]any{"status_code": 200, "partial": true}, + "unrelated_setting": "preserve", + }, + }) + + req := httptest.NewRequest(http.MethodGet, "/accounts/12/grok-media-eligibility", nil) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + var envelope struct { + Data grokMediaEligibilityResponse `json:"data"` + } + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &envelope)) + require.Equal(t, int64(12), envelope.Data.AccountID) + require.Equal(t, grokMediaEligibilityModeAuto, envelope.Data.Mode) + require.Equal(t, "billing_inconclusive", envelope.Data.Reason) +} + +func TestGrokMediaEligibilityHandlerUpdateModesPreservesExtra(t *testing.T) { + gin.SetMode(gin.TestMode) + account := &service.Account{ + ID: 12, + Platform: service.PlatformGrok, + Type: service.AccountTypeOAuth, + Extra: map[string]any{"unrelated_setting": "preserve"}, + } + router := newGrokMediaEligibilityRouter(account) + + tests := []struct { + mode string + wantMode string + wantExtra any + wantEligible bool + wantReason string + }{ + {mode: "enabled", wantMode: grokMediaEligibilityModeEnabled, wantExtra: true, wantEligible: true, wantReason: "override_enabled"}, + {mode: "disabled", wantMode: grokMediaEligibilityModeDisabled, wantExtra: false, wantEligible: false, wantReason: "override_disabled"}, + {mode: "auto", wantMode: grokMediaEligibilityModeAuto, wantExtra: nil, wantEligible: false, wantReason: "billing_unobserved"}, + } + + for _, tt := range tests { + t.Run(tt.mode, func(t *testing.T) { + body, err := json.Marshal(map[string]string{"mode": tt.mode}) + require.NoError(t, err) + req := httptest.NewRequest(http.MethodPut, "/accounts/12/grok-media-eligibility", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + var envelope struct { + Data grokMediaEligibilityResponse `json:"data"` + } + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &envelope)) + require.Equal(t, tt.wantMode, envelope.Data.Mode) + require.Equal(t, tt.wantEligible, envelope.Data.Eligible) + require.Equal(t, tt.wantReason, envelope.Data.Reason) + require.Equal(t, "preserve", account.Extra["unrelated_setting"]) + if tt.wantExtra == nil { + _, exists := account.Extra[service.GrokMediaEligibleExtraKey] + require.False(t, exists) + } else { + require.Equal(t, tt.wantExtra, account.Extra[service.GrokMediaEligibleExtraKey]) + } + }) + } +} + +func TestGrokMediaEligibilityHandlerRejectsUnsupportedAndInvalidModes(t *testing.T) { + gin.SetMode(gin.TestMode) + for _, tt := range []struct { + name string + account *service.Account + body string + }{ + {name: "unsupported account", account: &service.Account{ID: 1, Platform: service.PlatformOpenAI, Type: service.AccountTypeOAuth}, body: `{"mode":"enabled"}`}, + {name: "invalid mode", account: &service.Account{ID: 1, Platform: service.PlatformGrok, Type: service.AccountTypeOAuth}, body: `{"mode":"force"}`}, + } { + t.Run(tt.name, func(t *testing.T) { + router := newGrokMediaEligibilityRouter(tt.account) + req := httptest.NewRequest(http.MethodPut, "/accounts/1/grok-media-eligibility", bytes.NewBufferString(tt.body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + require.Equal(t, http.StatusBadRequest, rec.Code) + }) + } +} diff --git a/backend/internal/server/routes/admin.go b/backend/internal/server/routes/admin.go index d23a21c62c..28aaf92ed0 100644 --- a/backend/internal/server/routes/admin.go +++ b/backend/internal/server/routes/admin.go @@ -372,6 +372,8 @@ func registerAccountRoutes(admin *gin.RouterGroup, h *handler.Handlers, stepUpAu accounts.POST("/sync/crs", h.Admin.Account.SyncFromCRS) accounts.POST("/sync/crs/preview", h.Admin.Account.PreviewFromCRS) accounts.PUT("/:id", h.Admin.Account.Update) + accounts.GET("/:id/grok-media-eligibility", h.Admin.Account.GetGrokMediaEligibility) + accounts.PUT("/:id/grok-media-eligibility", h.Admin.Account.UpdateGrokMediaEligibility) accounts.PUT("/:id/upstream-billing-probe", h.Admin.Account.SetUpstreamBillingProbeEnabled) accounts.POST("/:id/upstream-billing-probe", h.Admin.Account.ProbeUpstreamBilling) accounts.GET("/:id/ollama-cloud-usage", h.Admin.Account.GetOllamaCloudUsage) diff --git a/frontend/src/api/admin/accounts.ts b/frontend/src/api/admin/accounts.ts index 8de2ed4996..0f1133c4ce 100644 --- a/frontend/src/api/admin/accounts.ts +++ b/frontend/src/api/admin/accounts.ts @@ -25,7 +25,9 @@ import type { UpstreamBillingProbeSettings, UpstreamBillingRatesResponse, OllamaCloudUsageSettings, - OllamaCloudUsageState + OllamaCloudUsageState, + GrokMediaEligibilityMode, + GrokMediaEligibilityState } from '@/types' /** @@ -237,6 +239,24 @@ export async function update(id: number, updates: UpdateAccountRequest): Promise return data } +export async function getGrokMediaEligibility(id: number): Promise { + const { data } = await apiClient.get( + `/admin/accounts/${id}/grok-media-eligibility` + ) + return data +} + +export async function updateGrokMediaEligibility( + id: number, + mode: GrokMediaEligibilityMode +): Promise { + const { data } = await apiClient.put( + `/admin/accounts/${id}/grok-media-eligibility`, + { mode } + ) + return data +} + /** * Check mixed-channel risk for account-group binding. */ @@ -1053,6 +1073,8 @@ export const accountsAPI = { create, duplicate, update, + getGrokMediaEligibility, + updateGrokMediaEligibility, checkMixedChannelRisk, delete: deleteAccount, toggleStatus, diff --git a/frontend/src/components/account/EditAccountModal.vue b/frontend/src/components/account/EditAccountModal.vue index 8ef424f4d9..afee8a9cbd 100644 --- a/frontend/src/components/account/EditAccountModal.vue +++ b/frontend/src/components/account/EditAccountModal.vue @@ -542,6 +542,57 @@ + +
+
+
+ +

+ {{ t('admin.accounts.grokMediaEligibility.hint') }} +

+
+ +

+ {{ t('admin.accounts.grokMediaEligibility.loading') }} +

+

+ {{ grokMediaEligibilityError }} +

+
+ {{ t('admin.accounts.grokMediaEligibility.current') }} + + {{ grokMediaEligibilityState.eligible ? t('admin.accounts.grokMediaEligibility.eligible') : t('admin.accounts.grokMediaEligibility.ineligible') }} + · {{ t(`admin.accounts.grokMediaEligibility.reasons.${grokMediaEligibilityState.reason}`) }} + +
+
+

+ + {{ t('admin.accounts.grokMediaEligibility.forceEnableWarning') }} +

+
+

+ {{ t('admin.accounts.grokMediaEligibility.autoHint') }} +

+
+
+
props.account?.platform === 'grok' && props.account?.type === 'oauth' +) +const grokMediaEligibilityMode = ref('auto') +const grokMediaEligibilityInitialMode = ref('auto') +const grokMediaEligibilityState = ref(null) +const grokMediaEligibilityLoading = ref(false) +const grokMediaEligibilityError = ref('') +let grokMediaEligibilityRequestVersion = 0 + +const modeFromGrokMediaExtra = (extra: Record | undefined): GrokMediaEligibilityMode => { + if (extra?.grok_media_eligible === true) return 'enabled' + if (extra?.grok_media_eligible === false) return 'disabled' + return 'auto' +} + +const loadGrokMediaEligibility = async (accountID: number): Promise => { + if (!isGrokOAuthAccount.value || typeof adminAPI.accounts.getGrokMediaEligibility !== 'function') { + return null + } + const requestVersion = ++grokMediaEligibilityRequestVersion + grokMediaEligibilityLoading.value = true + grokMediaEligibilityError.value = '' + try { + const state = await adminAPI.accounts.getGrokMediaEligibility(accountID) + if (requestVersion !== grokMediaEligibilityRequestVersion) return null + grokMediaEligibilityState.value = state + grokMediaEligibilityMode.value = state.mode + grokMediaEligibilityInitialMode.value = state.mode + return state + } catch (error: any) { + if (requestVersion !== grokMediaEligibilityRequestVersion) return null + grokMediaEligibilityError.value = error?.message || t('admin.accounts.grokMediaEligibility.loadFailed') + return null + } finally { + if (requestVersion === grokMediaEligibilityRequestVersion) { + grokMediaEligibilityLoading.value = false + } + } +} const interceptWarmupRequests = ref(false) const autoPauseOnExpired = ref(false) @@ -3925,6 +4018,15 @@ const syncFormFromAccount = (newAccount: Account | null) => { newAccount.platform === 'grok' && newAccount.type === 'oauth' && (grokClientToolCacheSetting === undefined || grokClientToolCacheSetting === true) + grokMediaEligibilityMode.value = modeFromGrokMediaExtra(extra) + grokMediaEligibilityInitialMode.value = grokMediaEligibilityMode.value + grokMediaEligibilityState.value = null + grokMediaEligibilityError.value = '' + if (newAccount.platform === 'grok' && newAccount.type === 'oauth') { + void loadGrokMediaEligibility(newAccount.id) + } else { + grokMediaEligibilityRequestVersion++ + } if (newAccount.platform === 'grok' && newAccount.type === 'oauth' && newAccount.credentials) { const grokCreds = newAccount.credentials as Record if (isCustomGrokBaseUrl(grokCreds.base_url)) { @@ -4606,10 +4708,48 @@ const handleClose = () => { emit('close') } +const persistGrokMediaEligibility = async (accountID: number, updatedAccount: Account): Promise => { + if ( + !isGrokOAuthAccount.value || + grokMediaEligibilityMode.value === grokMediaEligibilityInitialMode.value || + typeof adminAPI.accounts.updateGrokMediaEligibility !== 'function' + ) { + return updatedAccount + } + + try { + const state = await adminAPI.accounts.updateGrokMediaEligibility(accountID, grokMediaEligibilityMode.value) + grokMediaEligibilityState.value = state + grokMediaEligibilityInitialMode.value = state.mode + const nextExtra = { ...((updatedAccount.extra as Record | undefined) || {}) } + if (state.mode === 'auto') { + delete nextExtra.grok_media_eligible + } else { + nextExtra.grok_media_eligible = state.mode === 'enabled' + } + updatedAccount.extra = nextExtra + } catch (error: any) { + appStore.showError(t('admin.accounts.grokMediaEligibility.partialSave')) + try { + const state = await loadGrokMediaEligibility(accountID) + if (state) { + const nextExtra = { ...((updatedAccount.extra as Record | undefined) || {}) } + if (state.mode === 'auto') delete nextExtra.grok_media_eligible + else nextExtra.grok_media_eligible = state.mode === 'enabled' + updatedAccount.extra = nextExtra + } + } catch { + // The original save result remains useful even when the refresh fails. + } + } + return updatedAccount +} + const submitUpdateAccount = async (accountID: number, updatePayload: Record) => { submitting.value = true try { - const updatedAccount = await adminAPI.accounts.update(accountID, withAntigravityConfirmFlag(updatePayload)) + let updatedAccount = await adminAPI.accounts.update(accountID, withAntigravityConfirmFlag(updatePayload)) + updatedAccount = await persistGrokMediaEligibility(accountID, updatedAccount) appStore.showSuccess(t('admin.accounts.accountUpdated')) emit('updated', updatedAccount) handleClose() diff --git a/frontend/src/components/account/__tests__/EditAccountModal.grokMediaEligibility.spec.ts b/frontend/src/components/account/__tests__/EditAccountModal.grokMediaEligibility.spec.ts new file mode 100644 index 0000000000..81f95c7392 --- /dev/null +++ b/frontend/src/components/account/__tests__/EditAccountModal.grokMediaEligibility.spec.ts @@ -0,0 +1,110 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { defineComponent } from 'vue' +import { mount } from '@vue/test-utils' + +const { + updateAccountMock, + getEligibilityMock, + updateEligibilityMock, + showErrorMock, + authIsSimpleMode +} = vi.hoisted(() => ({ + updateAccountMock: vi.fn(), + getEligibilityMock: vi.fn(), + updateEligibilityMock: vi.fn(), + showErrorMock: vi.fn(), + authIsSimpleMode: { value: true } +})) + +vi.mock('@/stores/app', () => ({ + useAppStore: () => ({ showError: showErrorMock, showSuccess: vi.fn(), showInfo: vi.fn() }) +})) +vi.mock('@/stores/auth', () => ({ useAuthStore: () => ({ get isSimpleMode() { return authIsSimpleMode.value } }) })) +vi.mock('@/api/admin', () => ({ + adminAPI: { + accounts: { + update: updateAccountMock, + getGrokMediaEligibility: getEligibilityMock, + updateGrokMediaEligibility: updateEligibilityMock, + checkMixedChannelRisk: vi.fn().mockResolvedValue({ has_risk: false }) + }, + settings: { + getWebSearchEmulationConfig: vi.fn().mockResolvedValue({ enabled: false, providers: [] }), + getSettings: vi.fn().mockResolvedValue({}) + }, + tlsFingerprintProfiles: { list: vi.fn().mockResolvedValue([]) } + } +})) +vi.mock('@/api/admin/accounts', () => ({ getAntigravityDefaultModelMapping: vi.fn() })) +vi.mock('vue-i18n', async () => { + const actual = await vi.importActual('vue-i18n') + return { ...actual, useI18n: () => ({ t: (key: string) => key }) } +}) + +import EditAccountModal from '../EditAccountModal.vue' + +const BaseDialogStub = defineComponent({ + props: { show: { type: Boolean, default: false } }, + template: '
' +}) + +const account = (platform = 'grok', type = 'oauth', extra: Record = {}) => ({ + id: 12, name: 'Grok', notes: '', platform, type, + credentials: { expires_at: '2027-01-01T00:00:00Z', token_type: 'Bearer' }, + credentials_status: { has_access_token: true, has_refresh_token: true }, extra, + proxy_id: null, concurrency: 1, priority: 1, rate_multiplier: 1, status: 'active', + group_ids: [], expires_at: null, auto_pause_on_expired: false +}) + +function mountModal(value = account()) { + return mount(EditAccountModal, { + props: { show: true, account: value, proxies: [], groups: [] }, + global: { stubs: { + BaseDialog: BaseDialogStub, Select: true, Icon: true, ProxySelector: true, + GroupSelector: true, ModelWhitelistSelector: true + } } + }) +} + +describe('EditAccountModal Grok media eligibility', () => { + beforeEach(() => { + authIsSimpleMode.value = true + updateAccountMock.mockReset() + getEligibilityMock.mockReset() + updateEligibilityMock.mockReset() + showErrorMock.mockReset() + updateAccountMock.mockResolvedValue(account()) + getEligibilityMock.mockResolvedValue({ account_id: 12, mode: 'auto', eligible: false, reason: 'billing_inconclusive' }) + updateEligibilityMock.mockImplementation(async (_id: number, mode: string) => ({ + account_id: 12, mode, eligible: mode === 'enabled', reason: mode === 'enabled' ? 'override_enabled' : 'override_disabled' + })) + }) + + it('shows only for Grok OAuth and fills the current decision', async () => { + const wrapper = mountModal() + await vi.waitFor(() => expect(getEligibilityMock).toHaveBeenCalledWith(12)) + expect(wrapper.find('[data-testid="grok-media-eligibility-card"]').exists()).toBe(true) + expect(wrapper.get('[data-testid="grok-media-eligibility-mode"]').element.value).toBe('auto') + expect(wrapper.get('[data-testid="grok-media-eligibility-status"]').text()).toContain('billing_inconclusive') + expect(mountModal(account('grok', 'apikey')).find('[data-testid="grok-media-eligibility-card"]').exists()).toBe(false) + expect(mountModal(account('openai', 'oauth')).find('[data-testid="grok-media-eligibility-card"]').exists()).toBe(false) + }) + + it('updates the dedicated endpoint only when the mode changes', async () => { + const wrapper = mountModal() + await vi.waitFor(() => expect(getEligibilityMock).toHaveBeenCalled()) + await wrapper.get('[data-testid="grok-media-eligibility-mode"]').setValue('enabled') + await wrapper.get('form#edit-account-form').trigger('submit.prevent') + await vi.waitFor(() => expect(updateEligibilityMock).toHaveBeenCalledWith(12, 'enabled')) + }) + + it('reports partial-save errors when the dedicated endpoint fails', async () => { + updateEligibilityMock.mockRejectedValueOnce(new Error('eligibility failed')) + const wrapper = mountModal() + await vi.waitFor(() => expect(getEligibilityMock).toHaveBeenCalled()) + await wrapper.get('[data-testid="grok-media-eligibility-mode"]').setValue('disabled') + await wrapper.get('form#edit-account-form').trigger('submit.prevent') + await vi.waitFor(() => expect(showErrorMock).toHaveBeenCalledWith('admin.accounts.grokMediaEligibility.partialSave')) + expect(getEligibilityMock).toHaveBeenCalledTimes(2) + }) +}) diff --git a/frontend/src/i18n/locales/en/admin/accounts.ts b/frontend/src/i18n/locales/en/admin/accounts.ts index a172ca3ddd..963376c163 100644 --- a/frontend/src/i18n/locales/en/admin/accounts.ts +++ b/frontend/src/i18n/locales/en/admin/accounts.ts @@ -830,6 +830,30 @@ export default { title: 'Client Tool Cache (May Change Automatic Tool Selection)', hint: 'For detected Grok Free OAuth accounts, this is enabled by default for client function tools such as Codex and Trae. Turn it off to opt out if the automatic tool-selection behavior is not acceptable.' }, + grokMediaEligibility: { + title: 'Media Generation Eligibility', + hint: 'Controls whether this Grok OAuth account may be selected for image and video generation.', + auto: 'Automatic detection', + enabled: 'Force enable', + disabled: 'Force disable', + current: 'Current decision:', + eligible: 'Eligible', + ineligible: 'Not eligible', + loading: 'Loading eligibility…', + loadFailed: 'Unable to load media eligibility', + autoHint: 'Automatic detection only clears the manual override; it does not trigger a media request.', + forceEnableWarning: 'Force enable bypasses automatic eligibility checks. Use only for accounts confirmed to support image/video generation.', + partialSave: 'Other account settings may have been saved, but media eligibility was not updated. Please retry.', + reasons: { + eligible: 'Paid entitlement confirmed', + billing_inconclusive: 'Billing information inconclusive', + billing_forbidden: 'Billing endpoint forbidden', + billing_free_tier: 'Free tier account', + billing_unobserved: 'Billing not observed yet', + override_enabled: 'Manually forced enabled', + override_disabled: 'Manually forced disabled' + } + }, autoPauseOnExpired: 'Auto Pause On Expired', autoPauseOnExpiredDesc: 'When enabled, the account will auto pause scheduling after it expires', autoPause5hThreshold: '5h Usage Threshold (%)', diff --git a/frontend/src/i18n/locales/zh/admin/accounts.ts b/frontend/src/i18n/locales/zh/admin/accounts.ts index b4d2684f70..62af419e2c 100644 --- a/frontend/src/i18n/locales/zh/admin/accounts.ts +++ b/frontend/src/i18n/locales/zh/admin/accounts.ts @@ -901,6 +901,30 @@ export default { title: '客户端工具缓存(可能改变自动工具选择)', hint: '仅对已识别为 Free 的 Grok OAuth 账号生效,默认会为 Codex、Trae 等客户端函数工具请求启用上游提示缓存;如不接受自动工具选择行为,可关闭此开关退出。' }, + grokMediaEligibility: { + title: '媒体生成资格', + hint: '控制该 Grok OAuth 账号是否可被图片和视频生成请求选中。', + auto: '自动判断', + enabled: '强制启用', + disabled: '强制禁用', + current: '当前判定:', + eligible: '可用', + ineligible: '不可用', + loading: '正在读取媒体资格…', + loadFailed: '无法读取媒体资格', + autoHint: '自动判断只会清除手工覆盖,不会主动触发媒体请求。', + forceEnableWarning: '强制启用会绕过自动资格检查,仅应对已确认支持生图/生视频的账号使用。', + partialSave: '账号其他配置可能已保存,但媒体资格未更新,请重试。', + reasons: { + eligible: '已确认付费资格', + billing_inconclusive: 'Billing 信息不明确', + billing_forbidden: 'Billing 接口拒绝访问', + billing_free_tier: 'Free 账号', + billing_unobserved: '尚未探测到 Billing', + override_enabled: '手工强制启用', + override_disabled: '手工强制禁用' + } + }, autoPauseOnExpired: '过期自动暂停调度', autoPauseOnExpiredDesc: '启用后,账号过期将自动暂停调度', autoPause5hThreshold: '5h 用量阈值(%)', diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 1c2fcc6f18..dd04ca363f 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -1489,6 +1489,15 @@ export interface UpdateAccountRequest { confirm_mixed_channel_risk?: boolean } +export type GrokMediaEligibilityMode = 'auto' | 'enabled' | 'disabled' + +export interface GrokMediaEligibilityState { + account_id: number + mode: GrokMediaEligibilityMode + eligible: boolean + reason: string +} + export interface CheckMixedChannelRequest { platform: AccountPlatform group_ids: number[] From 81fd8530056c6480df8e69770a85d8f411340290 Mon Sep 17 00:00:00 2001 From: WUJI-Labs Date: Thu, 27 Aug 2026 15:55:05 +0800 Subject: [PATCH 014/145] feat(upstream): add HTTP/2 PING keepalive for long LLM generation streams Long-running LLM inferences (such as high-thinking Gemini models) may experience silent TCP disconnections across intermediate proxies, NAT gateways, or firewalls when no data frames are emitted for tens of seconds to minutes. Go's default http2.Transport sets ReadIdleTimeout=0 (disabling active PING checks). This commit introduces HTTPUpstreamProfileLongStream with: - ReadIdleTimeout = 10s - PingTimeout = 5s When a pooled H2 connection stays idle for 10s during long streams, active PING frames are dispatched. Dead connections failing to respond within 5s are proactively evicted, preventing client generation requests from hanging on dead sockets or failing with EOF/502. Signed-off-by: WUJI-Labs --- backend/internal/repository/http_upstream.go | 24 +++++++++++-------- .../internal/repository/http_upstream_test.go | 18 ++++++++++++++ .../internal/service/http_upstream_profile.go | 3 ++- .../service/http_upstream_profile_test.go | 7 ++++++ 4 files changed, 41 insertions(+), 11 deletions(-) diff --git a/backend/internal/repository/http_upstream.go b/backend/internal/repository/http_upstream.go index bdd6478cff..6b611c4a30 100644 --- a/backend/internal/repository/http_upstream.go +++ b/backend/internal/repository/http_upstream.go @@ -76,13 +76,13 @@ const ( defaultOpenAIHTTP2FallbackErrorThreshold = 2 defaultOpenAIHTTP2FallbackWindow = 60 * time.Second defaultOpenAIHTTP2FallbackTTL = 10 * time.Minute - // OpenAI HTTP/2 连接健康探测:Codex 上游改走 HTTP/2 后,池化连接被代理/NAT + // 长流 HTTP/2 连接健康探测:池化连接被代理/NAT // 静默掐断会成为“死连接”(两端都以为存活),请求落上去会挂到 TCP 重传超时 // (分钟级)。Go 的 http2.Transport 默认 ReadIdleTimeout=0(不发健康 PING), // 无法检测。启用主动 PING 探测:连接空闲 ReadIdleTimeout 后发 PING,PingTimeout // 内无响应即判定死连接并关闭,从源头避免请求挂在死连接上。 - openAIHTTP2ReadIdleTimeout = 15 * time.Second - openAIHTTP2PingTimeout = 15 * time.Second + longStreamHTTP2ReadIdleTimeout = 10 * time.Second + longStreamHTTP2PingTimeout = 5 * time.Second // The Grok CLI proxy rejects requests that do not identify a supported // client version. Host/env/version pins live in package xai so service, @@ -96,6 +96,7 @@ const ( const ( upstreamProtocolModeDefault = "default" + upstreamProtocolModeLongStreamH2 = "long_stream_h2" upstreamProtocolModeOpenAIH1 = "openai_h1" upstreamProtocolModeOpenAIH2 = "openai_h2" upstreamProtocolModeOpenAIH1Fallback = "openai_h1_fallback" @@ -128,7 +129,7 @@ type upstreamClientEntry struct { client *http.Client // HTTP 客户端实例 proxyKey string // 代理标识(用于检测代理变更) poolKey string // 连接池配置标识(用于检测配置变更) - protocolMode string // 协议模式(default/openai_h1/openai_h2/openai_h1_fallback) + protocolMode string // 协议模式(default/long_stream_h2/openai_h1/openai_h2/openai_h1_fallback) lastUsed int64 // 最后使用时间戳(纳秒),用于 LRU 淘汰 inFlight int64 // 当前进行中的请求数,>0 时不可淘汰 } @@ -992,6 +993,9 @@ func (s *httpUpstreamService) resolveOpenAIHTTP2Settings() openAIHTTP2Settings { } func (s *httpUpstreamService) resolveProtocolMode(profile service.HTTPUpstreamProfile, proxyKey string, parsedProxy *url.URL) string { + if profile == service.HTTPUpstreamProfileLongStream { + return upstreamProtocolModeLongStreamH2 + } if profile == service.HTTPUpstreamProfileGrok { return upstreamProtocolModeGrok } @@ -1317,11 +1321,11 @@ func buildUpstreamTransport(settings poolSettings, proxyURL *url.URL, protocolMo ResponseHeaderTimeout: settings.responseHeaderTimeout, } switch protocolMode { - case upstreamProtocolModeOpenAIH2: + case upstreamProtocolModeLongStreamH2, upstreamProtocolModeOpenAIH2: transport.ForceAttemptHTTP2 = true // 显式配置 http2 并启用 PING 健康探测,剔除代理/NAT 静默掐断的死连接, // 避免请求挂在死连接上直到 TCP 重传超时(分钟级)。 - if _, err := enableOpenAIHTTP2KeepAlive(transport); err != nil { + if _, err := enableHTTP2KeepAlive(transport); err != nil { return nil, err } case upstreamProtocolModeOpenAIH1: @@ -1338,18 +1342,18 @@ func buildUpstreamTransport(settings poolSettings, proxyURL *url.URL, protocolMo return transport, nil } -// enableOpenAIHTTP2KeepAlive 在 http.Transport 上显式配置 HTTP/2 并启用连接健康探测。 +// enableHTTP2KeepAlive 在 http.Transport 上显式配置 HTTP/2 并启用连接健康探测。 // Go 默认惰性配置 http2 且 ReadIdleTimeout=0(不发健康 PING),无法检测被代理/NAT // 静默掐断的死连接。此处主动设置 ReadIdleTimeout/PingTimeout,让死连接被提前 PING // 出并关闭,请求得以重建连接而非挂到 TCP 重传超时。返回底层 *http2.Transport 便于测试。 -func enableOpenAIHTTP2KeepAlive(transport *http.Transport) (*http2.Transport, error) { +func enableHTTP2KeepAlive(transport *http.Transport) (*http2.Transport, error) { h2, err := http2.ConfigureTransports(transport) if err != nil { return nil, err } if h2 != nil { - h2.ReadIdleTimeout = openAIHTTP2ReadIdleTimeout - h2.PingTimeout = openAIHTTP2PingTimeout + h2.ReadIdleTimeout = longStreamHTTP2ReadIdleTimeout + h2.PingTimeout = longStreamHTTP2PingTimeout } return h2, nil } diff --git a/backend/internal/repository/http_upstream_test.go b/backend/internal/repository/http_upstream_test.go index 3c7840dc47..6e6550b6d7 100644 --- a/backend/internal/repository/http_upstream_test.go +++ b/backend/internal/repository/http_upstream_test.go @@ -629,6 +629,24 @@ func (s *HTTPUpstreamSuite) TestOpenAIProfileDefaultsToHTTP2AndNoHeaderTimeout() require.Equal(s.T(), upstreamProtocolModeOpenAIH2, entry.protocolMode) } +func (s *HTTPUpstreamSuite) TestLongStreamProfileUsesSharedHTTP2KeepAlive() { + s.cfg.Gateway = config.GatewayConfig{ + ResponseHeaderTimeout: 600, + OpenAIHTTP2: config.GatewayOpenAIHTTP2Config{ + Enabled: false, + }, + } + svc := s.newService() + entry, err := svc.getClientEntry("", 1, 1, service.HTTPUpstreamProfileLongStream, false, false) + require.NoError(s.T(), err) + transport, ok := entry.client.Transport.(*http.Transport) + require.True(s.T(), ok, "expected *http.Transport") + require.Equal(s.T(), 600*time.Second, transport.ResponseHeaderTimeout, "long-stream profile should retain the generic header timeout") + require.True(s.T(), transport.ForceAttemptHTTP2, "long-stream profile must enable HTTP/2 independently of OpenAI settings") + require.NotNil(s.T(), transport.TLSNextProto["h2"], "long-stream profile must install HTTP/2 PING health checks") + require.Equal(s.T(), upstreamProtocolModeLongStreamH2, entry.protocolMode) +} + func (s *HTTPUpstreamSuite) TestOpenAIProfileCustomHeaderTimeout() { s.cfg.Gateway = config.GatewayConfig{ ResponseHeaderTimeout: 600, diff --git a/backend/internal/service/http_upstream_profile.go b/backend/internal/service/http_upstream_profile.go index b2de6002cf..b12362ffac 100644 --- a/backend/internal/service/http_upstream_profile.go +++ b/backend/internal/service/http_upstream_profile.go @@ -10,6 +10,7 @@ const ( HTTPUpstreamProfileDefault HTTPUpstreamProfile = "" HTTPUpstreamProfileOpenAI HTTPUpstreamProfile = "openai" HTTPUpstreamProfileGrok HTTPUpstreamProfile = "grok" + HTTPUpstreamProfileLongStream HTTPUpstreamProfile = "long_stream" ) type httpUpstreamProfileContextKey struct{} @@ -36,7 +37,7 @@ func HTTPUpstreamProfileFromContext(ctx context.Context) HTTPUpstreamProfile { return HTTPUpstreamProfileDefault } switch profile { - case HTTPUpstreamProfileOpenAI, HTTPUpstreamProfileGrok: + case HTTPUpstreamProfileOpenAI, HTTPUpstreamProfileGrok, HTTPUpstreamProfileLongStream: return profile default: return HTTPUpstreamProfileDefault diff --git a/backend/internal/service/http_upstream_profile_test.go b/backend/internal/service/http_upstream_profile_test.go index 9cd4bf4ff9..2d48f0c647 100644 --- a/backend/internal/service/http_upstream_profile_test.go +++ b/backend/internal/service/http_upstream_profile_test.go @@ -20,6 +20,13 @@ func TestWithHTTPUpstreamProfile_OpenAI(t *testing.T) { } } +func TestWithHTTPUpstreamProfile_LongStream(t *testing.T) { + ctx := WithHTTPUpstreamProfile(context.TODO(), HTTPUpstreamProfileLongStream) + if profile := HTTPUpstreamProfileFromContext(ctx); profile != HTTPUpstreamProfileLongStream { + t.Fatalf("expected profile %q, got %q", HTTPUpstreamProfileLongStream, profile) + } +} + func TestWithHTTPUpstreamRedirectsDisabled(t *testing.T) { //nolint:staticcheck // Exercises the defensive nil-context fallback. ctx := WithHTTPUpstreamRedirectsDisabled(nil) From 9dc4c40bff675cadd95e78a106c5325472ba3f8a Mon Sep 17 00:00:00 2001 From: WUJI-Labs Date: Wed, 2 Sep 2026 12:28:37 +0800 Subject: [PATCH 015/145] test(upstream): align HTTP/2 keepalive tests with long-stream profile API --- .../http_upstream_http2_keepalive_test.go | 38 +++++++++---------- .../internal/repository/http_upstream_test.go | 2 +- 2 files changed, 20 insertions(+), 20 deletions(-) diff --git a/backend/internal/repository/http_upstream_http2_keepalive_test.go b/backend/internal/repository/http_upstream_http2_keepalive_test.go index 66a30dab0e..29acf4b631 100644 --- a/backend/internal/repository/http_upstream_http2_keepalive_test.go +++ b/backend/internal/repository/http_upstream_http2_keepalive_test.go @@ -32,44 +32,44 @@ func requireHTTP2Configured(t *testing.T, tr *http.Transport, msg string) { require.True(t, tr.Protocols.HTTP2(), msg) } -// Codex/OpenAI 上游改走 HTTP/2 后,池化连接被代理/NAT 静默掐断会成为“死连接”: +// 长流 / OpenAI 上游改走 HTTP/2 后,池化连接被代理/NAT 静默掐断会成为“死连接”: // 两端都以为连接存活,请求落上去会挂到 TCP 重传超时(分钟级)才失败。Go 的 // http2.Transport 默认 ReadIdleTimeout=0(不发健康 PING),无法检测这种死连接。 // 必须显式启用主动 PING 探测,让死连接被提前剔除,而不是只靠 ResponseHeaderTimeout // 事后兜底。 -func TestEnableOpenAIHTTP2KeepAlive_EnablesPingHealthCheck(t *testing.T) { +func TestEnableHTTP2KeepAlive_EnablesPingHealthCheck(t *testing.T) { tr := &http.Transport{} - h2, err := enableOpenAIHTTP2KeepAlive(tr) + h2, err := enableHTTP2KeepAlive(tr) require.NoError(t, err) require.NotNil(t, h2, "必须返回已配置的 *http2.Transport") require.Positive(t, h2.ReadIdleTimeout, "必须启用空闲 PING 探测以剔除死连接") - require.Equal(t, openAIHTTP2ReadIdleTimeout, h2.ReadIdleTimeout) - require.Equal(t, openAIHTTP2PingTimeout, h2.PingTimeout, "PING 无响应必须有超时判定") + require.Equal(t, longStreamHTTP2ReadIdleTimeout, h2.ReadIdleTimeout) + require.Equal(t, longStreamHTTP2PingTimeout, h2.PingTimeout, "PING 无响应必须有超时判定") requireHTTP2Configured(t, tr, "http2 必须已挂到底层 http.Transport 上") } -// openai_h2 模式构建的 Transport 必须带上 H2 PING 健康探测,从源头剔除死连接。 -func TestBuildUpstreamTransport_OpenAIH2_EnablesPingHealthCheck(t *testing.T) { - tr, err := buildUpstreamTransport(http2KeepAliveTestPoolSettings(), nil, upstreamProtocolModeOpenAIH2) +// long_stream_h2 模式构建的 Transport 必须带上 H2 PING 健康探测,从源头剔除死连接。 +func TestBuildUpstreamTransport_LongStreamH2_EnablesPingHealthCheck(t *testing.T) { + tr, err := buildUpstreamTransport(http2KeepAliveTestPoolSettings(), nil, upstreamProtocolModeLongStreamH2) require.NoError(t, err) - require.True(t, tr.ForceAttemptHTTP2, "openai_h2 必须启用 HTTP/2") - requireHTTP2Configured(t, tr, "openai_h2 必须显式配置 http2 以启用 ReadIdleTimeout") + require.True(t, tr.ForceAttemptHTTP2, "long_stream_h2 必须启用 HTTP/2") + requireHTTP2Configured(t, tr, "long_stream_h2 必须显式配置 http2 以启用 ReadIdleTimeout") } // 非 H2 模式(default/h1)不应因本次改动被误配置:default 走 Go 自动 H2(惰性配置, // 构建时 Protocols/TLSNextProto 仍为空),h1 模式显式禁用 H2。避免波及 Claude/Gemini 热路径。 -func TestBuildUpstreamTransport_NonOpenAIH2_NotEagerlyConfigured(t *testing.T) { +func TestBuildUpstreamTransport_NonLongStreamH2_NotEagerlyConfigured(t *testing.T) { tr, err := buildUpstreamTransport(http2KeepAliveTestPoolSettings(), nil, upstreamProtocolModeDefault) require.NoError(t, err) require.Nil(t, tr.Protocols, "default 模式不应在构建期主动配置 http2 keepalive") require.Nil(t, tr.TLSNextProto["h2"], "default 模式不应在构建期主动配置 http2 keepalive") } -// openai_h2 模式构建的 Transport 必须真正以 HTTP/2 与上游通信,PING 健康探测才有载体: -// 自定义 DialContext 下 Go 不会自动启用 H2,全靠 enableOpenAIHTTP2KeepAlive 的显式配置。 -func TestBuildUpstreamTransport_OpenAIH2_NegotiatesHTTP2(t *testing.T) { +// long_stream_h2 模式构建的 Transport 必须真正以 HTTP/2 与上游通信,PING 健康探测才有载体: +// 自定义 DialContext 下 Go 不会自动启用 H2,全靠 enableHTTP2KeepAlive 的显式配置。 +func TestBuildUpstreamTransport_LongStreamH2_NegotiatesHTTP2(t *testing.T) { srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) })) @@ -77,7 +77,7 @@ func TestBuildUpstreamTransport_OpenAIH2_NegotiatesHTTP2(t *testing.T) { srv.StartTLS() defer srv.Close() - tr, err := buildUpstreamTransport(http2KeepAliveTestPoolSettings(), nil, upstreamProtocolModeOpenAIH2) + tr, err := buildUpstreamTransport(http2KeepAliveTestPoolSettings(), nil, upstreamProtocolModeLongStreamH2) require.NoError(t, err) defer tr.CloseIdleConnections() require.NotNil(t, tr.TLSClientConfig) @@ -91,18 +91,18 @@ func TestBuildUpstreamTransport_OpenAIH2_NegotiatesHTTP2(t *testing.T) { require.NoError(t, err) require.NoError(t, resp.Body.Close()) require.Equal(t, http.StatusOK, resp.StatusCode) - require.Equal(t, 2, resp.ProtoMajor, "openai_h2 必须协商到 HTTP/2") + require.Equal(t, 2, resp.ProtoMajor, "long_stream_h2 必须协商到 HTTP/2") } // 死连接在经 HTTP 代理(CONNECT 隧道)时最高发,这是带 proxy 账号的真实生产路径: // 显式 http2 配置须与 Transport.Proxy 同时正确生效,不能相互干扰。 -func TestBuildUpstreamTransport_OpenAIH2_WithHTTPProxy_EnablesKeepAlive(t *testing.T) { +func TestBuildUpstreamTransport_LongStreamH2_WithHTTPProxy_EnablesKeepAlive(t *testing.T) { proxyURL, err := url.Parse("http://127.0.0.1:8080") require.NoError(t, err) - tr, err := buildUpstreamTransport(http2KeepAliveTestPoolSettings(), proxyURL, upstreamProtocolModeOpenAIH2) + tr, err := buildUpstreamTransport(http2KeepAliveTestPoolSettings(), proxyURL, upstreamProtocolModeLongStreamH2) require.NoError(t, err) require.True(t, tr.ForceAttemptHTTP2) - requireHTTP2Configured(t, tr, "经代理的 openai_h2 也必须启用 http2 keepalive") + requireHTTP2Configured(t, tr, "经代理的 long_stream_h2 也必须启用 http2 keepalive") require.NotNil(t, tr.Proxy, "HTTP 代理仍须通过 Transport.Proxy 生效") } diff --git a/backend/internal/repository/http_upstream_test.go b/backend/internal/repository/http_upstream_test.go index 6e6550b6d7..00cff96e1e 100644 --- a/backend/internal/repository/http_upstream_test.go +++ b/backend/internal/repository/http_upstream_test.go @@ -643,7 +643,7 @@ func (s *HTTPUpstreamSuite) TestLongStreamProfileUsesSharedHTTP2KeepAlive() { require.True(s.T(), ok, "expected *http.Transport") require.Equal(s.T(), 600*time.Second, transport.ResponseHeaderTimeout, "long-stream profile should retain the generic header timeout") require.True(s.T(), transport.ForceAttemptHTTP2, "long-stream profile must enable HTTP/2 independently of OpenAI settings") - require.NotNil(s.T(), transport.TLSNextProto["h2"], "long-stream profile must install HTTP/2 PING health checks") + require.True(s.T(), transport.Protocols.HTTP2(), "long-stream profile must install HTTP/2 PING health checks") require.Equal(s.T(), upstreamProtocolModeLongStreamH2, entry.protocolMode) } From f5e4da5e72acd4fb026e0e97e7dfc61eae29735c Mon Sep 17 00:00:00 2001 From: WUJI-Labs Date: Wed, 2 Sep 2026 12:40:23 +0800 Subject: [PATCH 016/145] style(upstream): gofmt http_upstream_profile.go --- backend/internal/service/http_upstream_profile.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/internal/service/http_upstream_profile.go b/backend/internal/service/http_upstream_profile.go index b12362ffac..88dd117366 100644 --- a/backend/internal/service/http_upstream_profile.go +++ b/backend/internal/service/http_upstream_profile.go @@ -7,9 +7,9 @@ import "context" type HTTPUpstreamProfile string const ( - HTTPUpstreamProfileDefault HTTPUpstreamProfile = "" - HTTPUpstreamProfileOpenAI HTTPUpstreamProfile = "openai" - HTTPUpstreamProfileGrok HTTPUpstreamProfile = "grok" + HTTPUpstreamProfileDefault HTTPUpstreamProfile = "" + HTTPUpstreamProfileOpenAI HTTPUpstreamProfile = "openai" + HTTPUpstreamProfileGrok HTTPUpstreamProfile = "grok" HTTPUpstreamProfileLongStream HTTPUpstreamProfile = "long_stream" ) From a7f1ed8fa49a2f3a7ae9ba81eac45f8d22f426f7 Mon Sep 17 00:00:00 2001 From: aofee <44353308+aofee@users.noreply.github.com> Date: Wed, 2 Sep 2026 14:59:13 +0800 Subject: [PATCH 017/145] fix(openai-ws): track pending passthrough turns --- .../service/openai_ws_v2/passthrough_relay.go | 18 ++++++- .../openai_ws_v2/passthrough_relay_test.go | 54 +++++++++++++++++-- 2 files changed, 66 insertions(+), 6 deletions(-) diff --git a/backend/internal/service/openai_ws_v2/passthrough_relay.go b/backend/internal/service/openai_ws_v2/passthrough_relay.go index fdb539a073..e443f2320a 100644 --- a/backend/internal/service/openai_ws_v2/passthrough_relay.go +++ b/backend/internal/service/openai_ws_v2/passthrough_relay.go @@ -538,7 +538,7 @@ func runUpstreamToClient( // the upstream has started a Responses turn, success still requires a // terminal protocol event. Treat an early 1000/EOF as a relay failure so // the adapter does not report relay_completed with an active turn. - if graceful && openAIWSRelayActiveTurnID(state) != "" { + if graceful && state.hasUnfinishedTurn() { graceful = false err = errors.New("upstream websocket closed before terminal event: " + err.Error()) } @@ -585,7 +585,14 @@ func runUpstreamToClient( } observedEvent = observeUpstreamMessage(state, payload, startAt, nowFn, onUsageParseFailure) case coderws.MessageBinary: - // binary frame 直接透传,不进入 JSON 观测路径(避免无效解析开销)。 + // Binary frames remain opaque for usage/result observation, but a JSON + // terminal still settles relay lifecycle. Otherwise the pending-turn + // disconnect guard would turn an already-delivered terminal into a false + // missing-terminal failure when the upstream closes normally. + if isTerminalEvent(strings.TrimSpace(gjson.GetBytes(payload, "type").String())) { + state.consumePendingTurnStartedAt() + openAIWSRelayDiscardActiveTurnTiming(state) + } } emitTurnComplete(onTurnComplete, state, observedEvent) if dropDownstreamWrites != nil && dropDownstreamWrites.Load() { @@ -995,6 +1002,13 @@ func (s *relayState) consumePendingTurnStartedAt() time.Time { return *startedAt } +func (s *relayState) hasUnfinishedTurn() bool { + if s == nil { + return false + } + return s.pendingTurnStart.Load() != nil || s.activeTurn != nil +} + func openAIWSRelayDeleteTurnTiming(state *relayState, responseID string) (relayTurnTiming, bool) { if state == nil || state.turnTimingByID == nil { return relayTurnTiming{}, false diff --git a/backend/internal/service/openai_ws_v2/passthrough_relay_test.go b/backend/internal/service/openai_ws_v2/passthrough_relay_test.go index a5505b4981..fc20d7bb08 100644 --- a/backend/internal/service/openai_ws_v2/passthrough_relay_test.go +++ b/backend/internal/service/openai_ws_v2/passthrough_relay_test.go @@ -309,11 +309,46 @@ func TestRelay_UpstreamDisconnect(t *testing.T) { defer cancel() result, relayExit := Relay(ctx, clientConn, upstreamConn, firstPayload, RelayOptions{}) - // 上游 EOF 属于 disconnect,标记为 graceful - require.Nil(t, relayExit, "上游 EOF 应被视为 graceful disconnect") + require.NotNil(t, relayExit) + require.Equal(t, "read_upstream", relayExit.Stage) + require.False(t, relayExit.Graceful) + require.ErrorContains(t, relayExit.Err, "upstream websocket closed before terminal event") require.Equal(t, "gpt-4o", result.RequestModel) } +func TestRelay_UpstreamNormalCloseBeforeResponseIDIsFailure(t *testing.T) { + t.Parallel() + + clientConn := newPassthroughTestFrameConn(nil, false) + upstreamConn := &eofReplacementFrameConn{ + FrameConn: newPassthroughTestFrameConn([]passthroughTestFrame{ + { + msgType: coderws.MessageText, + payload: []byte(`{"type":"response.created","status":"in_progress"}`), + }, + { + msgType: coderws.MessageText, + payload: []byte(`{"type":"response.in_progress","status":"in_progress"}`), + }, + }, true), + err: coderws.CloseError{Code: coderws.StatusNormalClosure}, + } + + firstPayload := []byte(`{"type":"response.create","model":"gpt-5.6-sol","input":[]}`) + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + result, relayExit := Relay(ctx, clientConn, upstreamConn, firstPayload, RelayOptions{}) + require.NotNil(t, relayExit) + require.Equal(t, "read_upstream", relayExit.Stage) + require.False(t, relayExit.Graceful) + require.True(t, relayExit.WroteDownstream) + require.ErrorContains(t, relayExit.Err, "upstream websocket closed before terminal event") + require.Empty(t, result.RequestID) + require.Empty(t, result.TerminalEventType) + require.Len(t, clientConn.Writes(), 2) +} + func TestRelay_UpstreamNormalCloseBeforeTerminalIsFailure(t *testing.T) { t.Parallel() @@ -921,7 +956,10 @@ func TestRelay_BinaryFramePassthrough(t *testing.T) { defer cancel() result, relayExit := Relay(ctx, clientConn, upstreamConn, firstPayload, RelayOptions{}) - require.Nil(t, relayExit) + require.NotNil(t, relayExit) + require.Equal(t, "read_upstream", relayExit.Stage) + require.False(t, relayExit.Graceful) + require.ErrorContains(t, relayExit.Err, "upstream websocket closed before terminal event") // binary frame 不解析 usage require.Equal(t, 0, result.Usage.InputTokens) @@ -986,7 +1024,12 @@ func TestRelay_PreservesFirstMessageType(t *testing.T) { t.Parallel() clientConn := newPassthroughTestFrameConn(nil, false) - upstreamConn := newPassthroughTestFrameConn(nil, true) + upstreamConn := newPassthroughTestFrameConn([]passthroughTestFrame{ + { + msgType: coderws.MessageBinary, + payload: []byte(`{"type":"response.completed","response":{"id":"resp_binary_first_type"}}`), + }, + }, true) firstPayload := []byte(`{"type":"response.create","model":"gpt-4o","input":[]}`) ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) @@ -1001,6 +1044,9 @@ func TestRelay_PreservesFirstMessageType(t *testing.T) { require.Len(t, upstreamWrites, 1) require.Equal(t, coderws.MessageBinary, upstreamWrites[0].msgType) require.Equal(t, firstPayload, upstreamWrites[0].payload) + clientWrites := clientConn.Writes() + require.Len(t, clientWrites, 1) + require.Equal(t, coderws.MessageBinary, clientWrites[0].msgType) } func TestRelay_UsageParseFailureDoesNotBlockRelay(t *testing.T) { From 9be9c0b68c47eb433d886453deecf73c7ed55d68 Mon Sep 17 00:00:00 2001 From: wall-wxk <582104384@qq.com> Date: Wed, 2 Sep 2026 18:45:20 +0800 Subject: [PATCH 018/145] =?UTF-8?q?fix(gateway):=20=E8=BD=AC=E5=8F=91?= =?UTF-8?q?=E5=A4=B1=E8=B4=A5=E7=9A=84=E8=AF=B7=E6=B1=82=E7=AB=8B=E5=8D=B3?= =?UTF-8?q?=E9=87=8A=E6=94=BE=E4=BC=9A=E8=AF=9D=E6=A7=BD=EF=BC=8C=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E5=8D=A0=E7=94=A8=E6=95=B4=E4=B8=AA=E7=A9=BA=E9=97=B2?= =?UTF-8?q?=E8=B6=85=E6=97=B6=E7=AA=97=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 会话数量限制(max_sessions)此前只有空闲超时自动过期,没有任何主动释放 机制:请求在选号阶段注册会话后,即使转发失败(上游错误/客户端断开/换号 耗尽),该 session hash 也会占住槽位直到空闲超时(默认 5 分钟),导致 max_sessions 受限的账号在窗口内拒绝所有新会话。 修复: - SessionLimitCache 新增 UnregisterSession(Redis ZREM),立即移除会话注册 - GatewayService 新增 ReleaseAccountSession,适用条件与 checkAndRegisterSession 对齐 - Messages 主路径:记录 failover 链上注册过会话槽的账号,请求最终失败时 统一释放;failover continue / profit veto / 兜底分组切换时按次即时释放; 成功及"上游已计量部分结果"(#5148)保持既有空闲超时语义 - CountTokens:转发失败时同样释放选号时注册的会话槽 Co-Authored-By: Claude Opus 4.6 --- backend/internal/handler/gateway_handler.go | 36 ++++++ .../repository/session_limit_cache.go | 9 ++ .../session_limit_cache_integration_test.go | 92 +++++++++++++ .../internal/service/gateway_scheduling.go | 22 ++++ .../internal/service/session_limit_cache.go | 6 + .../service/session_limit_release_test.go | 121 ++++++++++++++++++ backend/internal/testutil/stubs.go | 3 + 7 files changed, 289 insertions(+) create mode 100644 backend/internal/repository/session_limit_cache_integration_test.go create mode 100644 backend/internal/service/session_limit_release_test.go diff --git a/backend/internal/handler/gateway_handler.go b/backend/internal/handler/gateway_handler.go index a4bcda50fa..e00ca04013 100644 --- a/backend/internal/handler/gateway_handler.go +++ b/backend/internal/handler/gateway_handler.go @@ -610,6 +610,23 @@ func (h *GatewayHandler) Messages(c *gin.Context) { c.Request = c.Request.WithContext(ctx) } + // 会话槽失败释放:failover 链上每个选中的 Anthropic OAuth 账号都注册过同一会话 + // (checkAndRegisterSession)。若请求最终失败(转发失败/客户端中断/换号耗尽), + // 须立即释放本次会话注册——上游从未真正服务该会话,继续占槽会让 max_sessions + // 受限的账号被失败请求的 session hash 卡满整个空闲窗口,后续新会话全部被拒。 + // 成功请求保持既有空闲超时语义(会话按最后活动时间过期)。 + sessionSlotAccounts := make(map[int64]*service.Account) + upstreamServedSession := false + defer func() { + if upstreamServedSession { + return + } + // 客户端可能已断开、请求 ctx 已取消,用独立 ctx 执行释放 + for _, acc := range sessionSlotAccounts { + h.gatewayService.ReleaseAccountSession(context.Background(), acc, sessionKey) + } + }() + for { fs := NewFailoverState(h.maxAccountSwitches, hasBoundSession) retryWithFallback := false @@ -763,10 +780,15 @@ func (h *GatewayHandler) Messages(c *gin.Context) { h.handleStreamingAwareError(c, http.StatusServiceUnavailable, "api_error", profitVetoExhaustedMessage, streamStarted) return } + // 尝试被否决(从未转发),立即释放该账号的会话注册 + h.gatewayService.ReleaseAccountSession(context.Background(), account, sessionKey) + delete(sessionSlotAccounts, account.ID) continue } account = latest selection.Account = latest + // 记录本请求注册过会话槽的账号(profit 准入后账号已定) + sessionSlotAccounts[account.ID] = account // 等待路径保持既有 eager 绑定(无门时 helper 直接绑定);调度器已 // 抢槽的直达路径无门时由选号内部绑定,这里只在门下补准入后绑定。 if selection.ProfitGateActive() || !selection.Acquired { @@ -983,6 +1005,11 @@ func (h *GatewayHandler) Messages(c *gin.Context) { currentSubscription = nil fallbackUsed = true retryWithFallback = true + // 原分组账号已确定性失败(prompt too long),先释放其会话注册再走兜底分组 + for _, acc := range sessionSlotAccounts { + h.gatewayService.ReleaseAccountSession(context.Background(), acc, sessionKey) + } + sessionSlotAccounts = make(map[int64]*service.Account) break } _ = h.antigravityGatewayService.WriteMappedClaudeError(c, account, promptTooLongErr.StatusCode, promptTooLongErr.RequestID, promptTooLongErr.Body) @@ -998,6 +1025,9 @@ func (h *GatewayHandler) Messages(c *gin.Context) { action := fs.HandleFailoverError(c.Request.Context(), h.gatewayService, account.ID, account.Platform, account.GetPoolModeRetryCount(), failoverErr) switch action { case FailoverContinue: + // 本次尝试已确定性失败,立即释放该账号的会话注册 + h.gatewayService.ReleaseAccountSession(context.Background(), account, sessionKey) + delete(sessionSlotAccounts, account.ID) continue case FailoverExhausted: h.handleFailoverExhausted(c, fs.LastFailoverErr, account.Platform, streamStarted) @@ -1036,6 +1066,8 @@ func (h *GatewayHandler) Messages(c *gin.Context) { // 不会走到这里重复计费。 if result != nil { submitForwardUsage(result) + // 上游已接受并计量本次会话(流中断),会话槽保持既有语义 + upstreamServedSession = true } return } @@ -1061,6 +1093,8 @@ func (h *GatewayHandler) Messages(c *gin.Context) { } submitForwardUsage(result) + // 转发成功,会话槽保持既有空闲超时语义 + upstreamServedSession = true return } if !retryWithFallback { @@ -2163,6 +2197,8 @@ func (h *GatewayHandler) CountTokens(c *gin.Context) { if err := h.gatewayService.ForwardCountTokens(c.Request.Context(), c, account, parsedReq); err != nil { reqLog.Error("gateway.count_tokens_forward_failed", zap.Int64("account_id", account.ID), zap.Error(err)) // 错误响应已在 ForwardCountTokens 中处理 + // 上游未服务该会话,立即释放选号时注册的会话槽(客户端可能已断开,用独立 ctx) + h.gatewayService.ReleaseAccountSession(context.Background(), account, sessionHash) return } } diff --git a/backend/internal/repository/session_limit_cache.go b/backend/internal/repository/session_limit_cache.go index 1711256937..4b38e58f50 100644 --- a/backend/internal/repository/session_limit_cache.go +++ b/backend/internal/repository/session_limit_cache.go @@ -216,6 +216,15 @@ func (c *sessionLimitCache) RegisterSession(ctx context.Context, accountID int64 return result == 1, nil } +// UnregisterSession 立即移除会话注册(不等待空闲超时) +// 请求最终失败时调用:上游从未服务该会话,继续占槽会卡住 max_sessions 受限的账号 +func (c *sessionLimitCache) UnregisterSession(ctx context.Context, accountID int64, sessionUUID string) error { + if sessionUUID == "" { + return nil + } + return c.rdb.ZRem(ctx, sessionLimitKey(accountID), sessionUUID).Err() +} + // RefreshSession 刷新会话时间戳 func (c *sessionLimitCache) RefreshSession(ctx context.Context, accountID int64, sessionUUID string, idleTimeout time.Duration) error { if sessionUUID == "" { diff --git a/backend/internal/repository/session_limit_cache_integration_test.go b/backend/internal/repository/session_limit_cache_integration_test.go new file mode 100644 index 0000000000..7f07300cb3 --- /dev/null +++ b/backend/internal/repository/session_limit_cache_integration_test.go @@ -0,0 +1,92 @@ +//go:build integration + +package repository + +import ( + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/stretchr/testify/suite" +) + +type SessionLimitCacheSuite struct { + IntegrationRedisSuite + cache service.SessionLimitCache +} + +func (s *SessionLimitCacheSuite) SetupTest() { + s.IntegrationRedisSuite.SetupTest() + s.cache = NewSessionLimitCache(s.rdb, 5) +} + +// TestUnregisterSession_FreesSlotForNewSession 复现并验证修复场景: +// max_sessions=1 的账号上,会话 A 注册后若请求转发失败,会话槽会被 A 占住 +// 整个空闲超时窗口(默认 5 分钟),导致窗口内新会话 B 全部被拒。 +// UnregisterSession 必须立即释放槽位,让 B 无需等待空闲超时即可注册。 +func (s *SessionLimitCacheSuite) TestUnregisterSession_FreesSlotForNewSession() { + const accountID = int64(101) + maxSessions := 1 + idleTimeout := 5 * time.Minute + + // 会话 A 注册成功(请求开始转发) + allowed, err := s.cache.RegisterSession(s.ctx, accountID, "session-A", maxSessions, idleTimeout) + s.RequireNoError(err, "RegisterSession A") + s.True(allowed, "首个会话应被允许") + + // 转发失败后的新会话 B 在空闲超时窗口内被拒(修复前的行为) + allowed, err = s.cache.RegisterSession(s.ctx, accountID, "session-B", maxSessions, idleTimeout) + s.RequireNoError(err, "RegisterSession B before release") + s.False(allowed, "槽位被 A 占用时新会话应被拒绝") + + // 请求最终失败:立即释放 A 的会话注册(不等待空闲超时) + s.RequireNoError(s.cache.UnregisterSession(s.ctx, accountID, "session-A"), "UnregisterSession A") + + // A 释放后 B 立即可注册,无需等待空闲超时 + allowed, err = s.cache.RegisterSession(s.ctx, accountID, "session-B", maxSessions, idleTimeout) + s.RequireNoError(err, "RegisterSession B after release") + s.True(allowed, "释放后新会话应立即可注册") + + count, err := s.cache.GetActiveSessionCount(s.ctx, accountID) + s.RequireNoError(err, "GetActiveSessionCount") + s.Equal(1, count, "释放后活跃会话数应为 1(仅 B)") +} + +// TestUnregisterSession_IdempotentAndMissing 验证释放操作幂等: +// 重复释放与释放不存在的会话均不应报错。 +func (s *SessionLimitCacheSuite) TestUnregisterSession_IdempotentAndMissing() { + const accountID = int64(102) + + s.RequireNoError(s.cache.UnregisterSession(s.ctx, accountID, "missing"), "释放不存在的会话") + s.RequireNoError(s.cache.UnregisterSession(s.ctx, accountID, "missing"), "重复释放") + + s.RequireNoError(s.cache.UnregisterSession(s.ctx, accountID, ""), "空 sessionUUID 应为 no-op") +} + +// TestUnregisterSession_OnlyTargetsSpecifiedSession 验证释放只影响目标会话, +// 不影响同账号上的其他活跃会话。 +func (s *SessionLimitCacheSuite) TestUnregisterSession_OnlyTargetsSpecifiedSession() { + const accountID = int64(103) + maxSessions := 2 + idleTimeout := 5 * time.Minute + + for _, sid := range []string{"session-A", "session-B"} { + allowed, err := s.cache.RegisterSession(s.ctx, accountID, sid, maxSessions, idleTimeout) + s.RequireNoError(err, "RegisterSession "+sid) + s.True(allowed) + } + + s.RequireNoError(s.cache.UnregisterSession(s.ctx, accountID, "session-A"), "UnregisterSession A") + + active, err := s.cache.IsSessionActive(s.ctx, accountID, "session-A") + s.RequireNoError(err, "IsSessionActive A") + s.False(active, "A 应已释放") + + active, err = s.cache.IsSessionActive(s.ctx, accountID, "session-B") + s.RequireNoError(err, "IsSessionActive B") + s.True(active, "B 不应受影响") +} + +func TestSessionLimitCacheSuite(t *testing.T) { + suite.Run(t, new(SessionLimitCacheSuite)) +} diff --git a/backend/internal/service/gateway_scheduling.go b/backend/internal/service/gateway_scheduling.go index 977a28a99d..2cc0c8ba75 100644 --- a/backend/internal/service/gateway_scheduling.go +++ b/backend/internal/service/gateway_scheduling.go @@ -1435,6 +1435,28 @@ func (s *GatewayService) checkAndRegisterSession(ctx context.Context, account *A return allowed } +// ReleaseAccountSession 立即释放会话槽(不等待空闲超时) +// 供 handler 在请求最终失败(选号成功但转发失败/客户端中断)时调用: +// 上游从未真正服务该会话,若继续占槽,max_sessions 受限的账号会被失败请求的 +// session hash 卡满整个空闲窗口,后续新会话全部被拒。 +// 适用条件与 checkAndRegisterSession 对齐;不适用账号为 no-op,幂等可安全重复调用。 +func (s *GatewayService) ReleaseAccountSession(ctx context.Context, account *Account, sessionID string) { + if s == nil || s.sessionLimitCache == nil || account == nil || sessionID == "" { + return + } + if !account.IsAnthropicOAuthOrSetupToken() { + return + } + if account.GetMaxSessions() <= 0 { + return + } + if err := s.sessionLimitCache.UnregisterSession(ctx, account.ID, sessionID); err != nil { + slog.Debug("session_limit.release_failed", + "account_id", account.ID, + "error", err) + } +} + func (s *GatewayService) getSchedulableAccount(ctx context.Context, accountID int64) (*Account, error) { var ( account *Account diff --git a/backend/internal/service/session_limit_cache.go b/backend/internal/service/session_limit_cache.go index 5482d61063..41f8fd5b0b 100644 --- a/backend/internal/service/session_limit_cache.go +++ b/backend/internal/service/session_limit_cache.go @@ -33,6 +33,12 @@ type SessionLimitCache interface { // 用于活跃会话保持活动状态 RefreshSession(ctx context.Context, accountID int64, sessionUUID string, idleTimeout time.Duration) error + // UnregisterSession 立即移除会话注册(不等待空闲超时) + // 用于请求最终失败的场景:上游从未真正服务该会话,不应继续占用会话槽, + // 否则 max_sessions 受限的账号会被失败请求的 session hash 卡满整个空闲窗口, + // 导致后续新会话在超时窗口内全部被拒 + UnregisterSession(ctx context.Context, accountID int64, sessionUUID string) error + // GetActiveSessionCount 获取当前活跃会话数 // 返回未过期的会话数量 GetActiveSessionCount(ctx context.Context, accountID int64) (int, error) diff --git a/backend/internal/service/session_limit_release_test.go b/backend/internal/service/session_limit_release_test.go new file mode 100644 index 0000000000..ba29ccec27 --- /dev/null +++ b/backend/internal/service/session_limit_release_test.go @@ -0,0 +1,121 @@ +package service + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/require" +) + +// sessionLimitReleaseCacheStub 记录 UnregisterSession 调用,用于验证释放逻辑 +type sessionLimitReleaseCacheStub struct { + SessionLimitCache + + unregistered map[int64][]string + err error +} + +func newSessionLimitReleaseCacheStub() *sessionLimitReleaseCacheStub { + return &sessionLimitReleaseCacheStub{ + unregistered: make(map[int64][]string), + } +} + +func (s *sessionLimitReleaseCacheStub) UnregisterSession(_ context.Context, accountID int64, sessionUUID string) error { + if s.err != nil { + return s.err + } + s.unregistered[accountID] = append(s.unregistered[accountID], sessionUUID) + return nil +} + +func newSessionLimitTestAccount() *Account { + return &Account{ + ID: 42, + Platform: PlatformAnthropic, + Type: AccountTypeOAuth, + Extra: map[string]any{"max_sessions": 1}, + } +} + +// TestReleaseAccountSession_ReleasesRegisteredSlot 验证: +// 对启用会话限制的 Anthropic OAuth 账号,ReleaseAccountSession 必须立即移除 +// 该账号上注册的会话(不等待空闲超时)。 +func TestReleaseAccountSession_ReleasesRegisteredSlot(t *testing.T) { + cache := newSessionLimitReleaseCacheStub() + svc := &GatewayService{sessionLimitCache: cache} + acc := newSessionLimitTestAccount() + + svc.ReleaseAccountSession(context.Background(), acc, "session-hash-1") + + require.Equal(t, []string{"session-hash-1"}, cache.unregistered[42], + "应立即移除注册的会话槽") +} + +// TestReleaseAccountSession_NoOpForInapplicableAccounts 验证: +// - 非 Anthropic OAuth/SetupToken 账号 +// - 未启用 max_sessions 的账号 +// - 空 sessionID +// 以上场景均为 no-op,不得触发 UnregisterSession。 +func TestReleaseAccountSession_NoOpForInapplicableAccounts(t *testing.T) { + apiKeyAcc := &Account{ + ID: 43, + Platform: PlatformAnthropic, + Type: AccountTypeAPIKey, + Extra: map[string]any{"max_sessions": 1}, + } + noLimitAcc := &Account{ + ID: 44, + Platform: PlatformAnthropic, + Type: AccountTypeOAuth, + } + enabledAcc := newSessionLimitTestAccount() + + cases := []struct { + name string + account *Account + sessionID string + }{ + {"api_key_account", apiKeyAcc, "session-hash"}, + {"max_sessions_disabled", noLimitAcc, "session-hash"}, + {"empty_session_id", enabledAcc, ""}, + {"nil_account", nil, "session-hash"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + cache := newSessionLimitReleaseCacheStub() + svc := &GatewayService{sessionLimitCache: cache} + svc.ReleaseAccountSession(context.Background(), tc.account, tc.sessionID) + require.Empty(t, cache.unregistered, "不适用账号不应触发释放") + }) + } +} + +// TestReleaseAccountSession_NilCacheAndErrorTolerance 验证: +// sessionLimitCache 不可用时 no-op;UnregisterSession 返回错误时不 panic(仅记录日志)。 +func TestReleaseAccountSession_NilCacheAndErrorTolerance(t *testing.T) { + // nil cache:no-op + svc := &GatewayService{} + svc.ReleaseAccountSession(context.Background(), newSessionLimitTestAccount(), "session-hash") + + // 底层错误:不 panic + cache := &sessionLimitReleaseCacheStub{err: errors.New("redis down")} + svc = &GatewayService{sessionLimitCache: cache} + svc.ReleaseAccountSession(context.Background(), newSessionLimitTestAccount(), "session-hash") +} + +// TestReleaseAccountSession_Idempotent 验证释放操作幂等,可安全重复调用 +// (failover 链上按次释放 + defer 兜底可能对同一账号重复释放)。 +func TestReleaseAccountSession_Idempotent(t *testing.T) { + cache := newSessionLimitReleaseCacheStub() + svc := &GatewayService{sessionLimitCache: cache} + acc := newSessionLimitTestAccount() + + svc.ReleaseAccountSession(context.Background(), acc, "session-hash") + svc.ReleaseAccountSession(context.Background(), acc, "session-hash") + + // 两次调用都透传到缓存层(Redis ZREM 本身幂等,重复移除无副作用) + require.Len(t, cache.unregistered[42], 2) +} diff --git a/backend/internal/testutil/stubs.go b/backend/internal/testutil/stubs.go index 339d57d867..c9b37efb9f 100644 --- a/backend/internal/testutil/stubs.go +++ b/backend/internal/testutil/stubs.go @@ -139,6 +139,9 @@ func (c StubSessionLimitCache) RegisterSession(_ context.Context, _ int64, _ str func (c StubSessionLimitCache) RefreshSession(_ context.Context, _ int64, _ string, _ time.Duration) error { return nil } +func (c StubSessionLimitCache) UnregisterSession(_ context.Context, _ int64, _ string) error { + return nil +} func (c StubSessionLimitCache) GetActiveSessionCount(_ context.Context, _ int64) (int, error) { return 0, nil } From 1859519573deed0a60bd99cc18d10216377aafa8 Mon Sep 17 00:00:00 2001 From: anguobao123 <205642466+anguobao123@users.noreply.github.com> Date: Wed, 2 Sep 2026 20:08:57 +0800 Subject: [PATCH 019/145] feat(deploy): support optional Apple network subnet --- deploy/.env.example | 5 +++++ deploy/APPLE_CONTAINER.md | 16 ++++++++++++++ deploy/apple-container.sh | 21 ++++++++++++++++--- deploy/tests/apple-container-test.sh | 19 +++++++++++++++++ deploy/tests/fixtures/bin/container | 31 ++++++++++++++++++++++++++-- 5 files changed, 87 insertions(+), 5 deletions(-) diff --git a/deploy/.env.example b/deploy/.env.example index 76762e303b..2eaf8d09cb 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -36,6 +36,11 @@ APPLE_CONTAINER_SUB2API_IMAGE=weishaw/sub2api:latest APPLE_CONTAINER_POSTGRES_IMAGE=postgres:18-alpine APPLE_CONTAINER_REDIS_IMAGE=redis:8-alpine +# Optional fixed IPv4 CIDR for the private Apple container network. Leave empty +# to let Apple container select one. Set only when host tooling needs a stable +# network gateway, and choose a CIDR that does not overlap your LAN or VPN. +APPLE_CONTAINER_NETWORK_SUBNET= + # ----------------------------------------------------------------------------- # Logging Configuration # 日志配置 diff --git a/deploy/APPLE_CONTAINER.md b/deploy/APPLE_CONTAINER.md index 1133464a81..dfce11402b 100644 --- a/deploy/APPLE_CONTAINER.md +++ b/deploy/APPLE_CONTAINER.md @@ -105,6 +105,22 @@ APPLE_CONTAINER_POSTGRES_IMAGE=postgres:18-alpine APPLE_CONTAINER_REDIS_IMAGE=redis:8-alpine ``` +By default, Apple `container` selects the private IPv4 subnet for the managed +network. Set `APPLE_CONTAINER_NETWORK_SUBNET` only when host-side tooling needs +a stable network gateway: + +```dotenv +APPLE_CONTAINER_NETWORK_SUBNET= +``` + +Leave the setting empty to keep automatic subnet allocation. When setting it, +choose a CIDR that does not overlap the host LAN or VPN. The script applies the +setting only when it creates `sub2api-apple`. If the existing managed network +uses a different subnet, it stops without deleting any resources. To migrate +intentionally, run `./apple-container.sh destroy --yes` to remove the managed +containers and network while preserving named volumes, then run +`./apple-container.sh up`. + The normal `up` command recreates the application container, so application environment changes are applied immediately. Use `up --recreate` when changing PostgreSQL or Redis container images or Redis runtime configuration. Persistent data remains in named volumes. `POSTGRES_USER`, `POSTGRES_PASSWORD`, and `POSTGRES_DB` are applied only when PostgreSQL initializes an empty data volume. Changing them in `.env` and recreating the container does not change an existing database. Rotate a password with `ALTER ROLE`, and plan explicit migrations for user or database changes. To intentionally initialize a new empty database, first back up the old one and use `destroy --volumes`. diff --git a/deploy/apple-container.sh b/deploy/apple-container.sh index 5de4d5cab1..cb646f1c62 100755 --- a/deploy/apple-container.sh +++ b/deploy/apple-container.sh @@ -31,6 +31,7 @@ POSTGRES_PASSWORD="" POSTGRES_DB="" REDIS_PASSWORD="" TZ_VALUE="" +NETWORK_SUBNET="" POSTGRES_ADDRESS="" REDIS_ADDRESS="" APP_ENV_FILE="" @@ -210,15 +211,28 @@ preflight_stack_ownership() { } ensure_network() { + local existing_subnet + local -a network_args + if resource_exists network "${NETWORK_NAME}"; then assert_resource_owned network "${NETWORK_NAME}" + if [[ -n "${NETWORK_SUBNET}" ]]; then + existing_subnet="$(inspect_resource network "${NETWORK_NAME}" | \ + plutil -extract 0.configuration.ipv4Subnet raw -o - -)" || \ + die "Unable to read the IPv4 subnet for ${NETWORK_NAME}." + if [[ "${existing_subnet}" != "${NETWORK_SUBNET}" ]]; then + die "Existing network '${NETWORK_NAME}' uses subnet '${existing_subnet}', but APPLE_CONTAINER_NETWORK_SUBNET is '${NETWORK_SUBNET}'. Run './apple-container.sh destroy --yes' to recreate the network while preserving volumes, then run 'up' again." + fi + fi return fi info "Creating network ${NETWORK_NAME}..." - container network create \ - --label "${STACK_LABEL_KEY}=${STACK_LABEL_VALUE}" \ - "${NETWORK_NAME}" >/dev/null + network_args=(--label "${STACK_LABEL_KEY}=${STACK_LABEL_VALUE}") + if [[ -n "${NETWORK_SUBNET}" ]]; then + network_args+=(--subnet "${NETWORK_SUBNET}") + fi + container network create "${network_args[@]}" "${NETWORK_NAME}" >/dev/null } ensure_volume() { @@ -410,6 +424,7 @@ prepare_environment() { POSTGRES_DB="$(read_env_value POSTGRES_DB sub2api)" REDIS_PASSWORD="$(read_env_value REDIS_PASSWORD)" TZ_VALUE="$(read_env_value TZ Asia/Shanghai)" + NETWORK_SUBNET="$(read_env_value APPLE_CONTAINER_NETWORK_SUBNET)" [[ -n "${BIND_HOST}" ]] || die "BIND_HOST must not be empty." validate_ipv4_address "${BIND_HOST}" diff --git a/deploy/tests/apple-container-test.sh b/deploy/tests/apple-container-test.sh index a12582104f..d1a3aa7c1e 100755 --- a/deploy/tests/apple-container-test.sh +++ b/deploy/tests/apple-container-test.sh @@ -48,6 +48,8 @@ assert_exists "${STATE_DIR}/containers/sub2api-apple" assert_exists "${STATE_DIR}/containers/sub2api-apple-postgres" assert_exists "${STATE_DIR}/containers/sub2api-apple-redis" assert_exists "${STATE_DIR}/running/sub2api-apple" +[[ ! -s "${STATE_DIR}/network-subnets/sub2api-apple" ]] || \ + fail "up passed a subnet when APPLE_CONTAINER_NETWORK_SUBNET was unset" "${SCRIPT}" status >/dev/null "${SCRIPT}" up --recreate @@ -62,6 +64,23 @@ assert_missing "${STATE_DIR}/containers/sub2api-apple" assert_missing "${STATE_DIR}/networks/sub2api-apple" assert_exists "${STATE_DIR}/volumes/sub2api-apple-data" +printf '\nAPPLE_CONTAINER_NETWORK_SUBNET=172.31.250.0/24\n' >>"${ENV_FILE}" +"${SCRIPT}" up +[[ "$(<"${STATE_DIR}/network-subnets/sub2api-apple")" == "172.31.250.0/24" ]] || \ + fail "up did not pass APPLE_CONTAINER_NETWORK_SUBNET to network creation" + +printf 'APPLE_CONTAINER_NETWORK_SUBNET=172.31.251.0/24\n' >>"${ENV_FILE}" +if mismatch_output="$("${SCRIPT}" up 2>&1)"; then + fail "up accepted a configured subnet that differs from the existing network" +fi +[[ "${mismatch_output}" == *"Existing network 'sub2api-apple' uses subnet '172.31.250.0/24'"* ]] || \ + fail "up did not explain the existing network subnet mismatch" +[[ "${mismatch_output}" == *"destroy --yes"* ]] || \ + fail "up did not provide the network migration command" +assert_exists "${STATE_DIR}/networks/sub2api-apple" +assert_exists "${STATE_DIR}/containers/sub2api-apple" + +"${SCRIPT}" destroy --yes "${SCRIPT}" up "${SCRIPT}" destroy --volumes --yes assert_missing "${STATE_DIR}/volumes/sub2api-apple-data" diff --git a/deploy/tests/fixtures/bin/container b/deploy/tests/fixtures/bin/container index a864111f27..3fd75457bb 100755 --- a/deploy/tests/fixtures/bin/container +++ b/deploy/tests/fixtures/bin/container @@ -7,6 +7,7 @@ mkdir -p \ "${STATE_DIR}/containers" \ "${STATE_DIR}/running" \ "${STATE_DIR}/networks" \ + "${STATE_DIR}/network-subnets" \ "${STATE_DIR}/volumes" \ "${STATE_DIR}/unowned/container" \ "${STATE_DIR}/unowned/network" \ @@ -34,10 +35,19 @@ inspect_resource() { local resource_name=$2 local label_value="apple-container" local address="192.168.65.4/24" + local subnet="192.168.65.0/24" if [[ -e "${STATE_DIR}/unowned/${resource_type}/${resource_name}" ]]; then label_value="other" fi + if [[ "${resource_type}" == "network" ]]; then + if [[ -s "${STATE_DIR}/network-subnets/${resource_name}" ]]; then + subnet="$(<"${STATE_DIR}/network-subnets/${resource_name}")" + fi + printf '[{"configuration":{"ipv4Subnet":"%s","labels":{"org.sub2api.stack":"%s"}}}]\n' \ + "${subnet}" "${label_value}" + return + fi case "${resource_name}" in sub2api-apple-postgres) address="192.168.65.2/24" ;; sub2api-apple-redis) address="192.168.65.3/24" ;; @@ -82,9 +92,26 @@ case "${command}" in echo default list_names "${STATE_DIR}/networks" ;; - create) touch "${STATE_DIR}/networks/$(last_argument "$@")" ;; + create) + network_name="$(last_argument "$@")" + subnet="" + while [[ $# -gt 0 ]]; do + case "$1" in + --subnet) + subnet=$2 + shift 2 + ;; + *) shift ;; + esac + done + touch "${STATE_DIR}/networks/${network_name}" + printf '%s' "${subnet}" >"${STATE_DIR}/network-subnets/${network_name}" + ;; inspect) inspect_resource network "${1}" ;; - delete) rm -f "${STATE_DIR}/networks/${1}" ;; + delete) + rm -f "${STATE_DIR}/networks/${1}" + rm -f "${STATE_DIR}/network-subnets/${1}" + ;; *) exit 1 ;; esac ;; From 8249ab37dbc20eff0abfebac58d63dd9a7fd62a7 Mon Sep 17 00:00:00 2001 From: haruka <1628615876@qq.com> Date: Wed, 2 Sep 2026 20:10:05 +0800 Subject: [PATCH 020/145] feat: add Anthropic reasoning effort pricing controls --- .../internal/handler/admin/channel_handler.go | 148 +++++++++--------- .../handler/admin/channel_handler_test.go | 20 ++- .../internal/handler/admin/group_handler.go | 6 +- .../handler/available_channel_handler.go | 42 ++--- .../internal/handler/composite_platform.go | 46 +++++- .../handler/composite_platform_test.go | 70 +++++++++ backend/internal/handler/dto/types.go | 4 +- backend/internal/handler/gateway_handler.go | 13 ++ .../repository/channel_repo_pricing.go | 18 +-- .../channel_repo_pricing_time_test.go | 20 +-- .../internal/service/account_stats_pricing.go | 34 +++- .../service/account_stats_pricing_test.go | 12 ++ backend/internal/service/admin_service.go | 4 +- .../service/admin_service_group_test.go | 2 +- .../internal/service/api_key_auth_cache.go | 2 +- backend/internal/service/billing_service.go | 57 ++++++- .../internal/service/billing_service_test.go | 2 + .../service/billing_token_cost_request.go | 41 ++--- .../billing_token_cost_request_test.go | 38 +++++ backend/internal/service/channel.go | 39 ++--- backend/internal/service/channel_available.go | 51 +++--- .../internal/service/gateway_usage_billing.go | 19 +-- backend/internal/service/group.go | 2 +- .../internal/service/model_plaza_service.go | 18 ++- .../service/model_plaza_service_test.go | 13 ++ .../service/model_pricing_resolver.go | 3 + .../internal/service/openai_gateway_usage.go | 10 +- .../service/openai_reasoning_effort_policy.go | 33 ++-- .../openai_reasoning_effort_policy_test.go | 30 +++- ...hannel_max_reasoning_effort_multiplier.sql | 11 ++ frontend/src/api/admin/channels.ts | 2 + frontend/src/api/channels.ts | 1 + .../admin/channel/PricingEntryCard.vue | 14 +- .../PricingEntryCard.timePricing.spec.ts | 6 +- .../src/components/admin/channel/types.ts | 1 + .../modelPlaza/PlazaModelPricingTable.vue | 7 + .../__tests__/PlazaModelPricingTable.spec.ts | 9 ++ .../src/i18n/locales/en/admin/channels.ts | 4 +- .../src/i18n/locales/en/admin/overview.ts | 2 +- frontend/src/i18n/locales/en/dashboard.ts | 2 + .../src/i18n/locales/zh/admin/channels.ts | 4 +- .../src/i18n/locales/zh/admin/overview.ts | 2 +- frontend/src/i18n/locales/zh/dashboard.ts | 2 + frontend/src/types/index.ts | 2 +- frontend/src/views/admin/ChannelsView.vue | 7 +- .../__tests__/groupsReasoningEffort.spec.ts | 20 ++- .../src/views/admin/groupsReasoningEffort.ts | 18 ++- 47 files changed, 664 insertions(+), 247 deletions(-) create mode 100644 backend/migrations/234_channel_max_reasoning_effort_multiplier.sql diff --git a/backend/internal/handler/admin/channel_handler.go b/backend/internal/handler/admin/channel_handler.go index 9723e3595b..2a7052c745 100644 --- a/backend/internal/handler/admin/channel_handler.go +++ b/backend/internal/handler/admin/channel_handler.go @@ -57,21 +57,22 @@ type updateChannelRequest struct { } type channelModelPricingRequest struct { - Platform string `json:"platform" binding:"omitempty,max=50"` - Models []string `json:"models" binding:"required,min=1,max=100"` - BillingMode string `json:"billing_mode" binding:"omitempty,oneof=token per_request image"` - InputPrice *float64 `json:"input_price" binding:"omitempty,min=0"` - OutputPrice *float64 `json:"output_price" binding:"omitempty,min=0"` - CacheWritePrice *float64 `json:"cache_write_price" binding:"omitempty,min=0"` - CacheWrite1hPrice *float64 `json:"cache_write_1h_price" binding:"omitempty,min=0"` - CacheReadPrice *float64 `json:"cache_read_price" binding:"omitempty,min=0"` - FastMultiplier *float64 `json:"fast_multiplier" binding:"omitempty,gt=0"` - FlexMultiplier *float64 `json:"flex_multiplier" binding:"omitempty,gt=0"` - ImageInputPrice *float64 `json:"image_input_price" binding:"omitempty,min=0"` - ImageOutputPrice *float64 `json:"image_output_price" binding:"omitempty,min=0"` - PerRequestPrice *float64 `json:"per_request_price" binding:"omitempty,min=0"` - Intervals []pricingIntervalRequest `json:"intervals"` - TimePricing *channelTimePricingRequest `json:"time_pricing"` + Platform string `json:"platform" binding:"omitempty,max=50"` + Models []string `json:"models" binding:"required,min=1,max=100"` + BillingMode string `json:"billing_mode" binding:"omitempty,oneof=token per_request image"` + InputPrice *float64 `json:"input_price" binding:"omitempty,min=0"` + OutputPrice *float64 `json:"output_price" binding:"omitempty,min=0"` + CacheWritePrice *float64 `json:"cache_write_price" binding:"omitempty,min=0"` + CacheWrite1hPrice *float64 `json:"cache_write_1h_price" binding:"omitempty,min=0"` + CacheReadPrice *float64 `json:"cache_read_price" binding:"omitempty,min=0"` + FastMultiplier *float64 `json:"fast_multiplier" binding:"omitempty,gt=0"` + FlexMultiplier *float64 `json:"flex_multiplier" binding:"omitempty,gt=0"` + MaxReasoningEffortMultiplier *float64 `json:"max_reasoning_effort_multiplier" binding:"omitempty,gt=0"` + ImageInputPrice *float64 `json:"image_input_price" binding:"omitempty,min=0"` + ImageOutputPrice *float64 `json:"image_output_price" binding:"omitempty,min=0"` + PerRequestPrice *float64 `json:"per_request_price" binding:"omitempty,min=0"` + Intervals []pricingIntervalRequest `json:"intervals"` + TimePricing *channelTimePricingRequest `json:"time_pricing"` } type channelTimePricingRequest struct { @@ -129,22 +130,23 @@ type channelResponse struct { } type channelModelPricingResponse struct { - ID int64 `json:"id"` - Platform string `json:"platform"` - Models []string `json:"models"` - BillingMode string `json:"billing_mode"` - InputPrice *float64 `json:"input_price"` - OutputPrice *float64 `json:"output_price"` - CacheWritePrice *float64 `json:"cache_write_price"` - CacheWrite1hPrice *float64 `json:"cache_write_1h_price"` - CacheReadPrice *float64 `json:"cache_read_price"` - FastMultiplier *float64 `json:"fast_multiplier"` - FlexMultiplier *float64 `json:"flex_multiplier"` - ImageInputPrice *float64 `json:"image_input_price"` - ImageOutputPrice *float64 `json:"image_output_price"` - PerRequestPrice *float64 `json:"per_request_price"` - Intervals []pricingIntervalResponse `json:"intervals"` - TimePricing *channelTimePricingResponse `json:"time_pricing"` + ID int64 `json:"id"` + Platform string `json:"platform"` + Models []string `json:"models"` + BillingMode string `json:"billing_mode"` + InputPrice *float64 `json:"input_price"` + OutputPrice *float64 `json:"output_price"` + CacheWritePrice *float64 `json:"cache_write_price"` + CacheWrite1hPrice *float64 `json:"cache_write_1h_price"` + CacheReadPrice *float64 `json:"cache_read_price"` + FastMultiplier *float64 `json:"fast_multiplier"` + FlexMultiplier *float64 `json:"flex_multiplier"` + MaxReasoningEffortMultiplier *float64 `json:"max_reasoning_effort_multiplier"` + ImageInputPrice *float64 `json:"image_input_price"` + ImageOutputPrice *float64 `json:"image_output_price"` + PerRequestPrice *float64 `json:"per_request_price"` + Intervals []pricingIntervalResponse `json:"intervals"` + TimePricing *channelTimePricingResponse `json:"time_pricing"` } type channelTimePricingResponse struct { @@ -258,22 +260,23 @@ func pricingToResponse(p *service.ChannelModelPricing) channelModelPricingRespon intervals = append(intervals, intervalToResponse(iv)) } return channelModelPricingResponse{ - ID: p.ID, - Platform: platform, - Models: models, - BillingMode: billingMode, - InputPrice: p.InputPrice, - OutputPrice: p.OutputPrice, - CacheWritePrice: p.CacheWritePrice, - CacheWrite1hPrice: p.CacheWrite1hPrice, - CacheReadPrice: p.CacheReadPrice, - FastMultiplier: p.FastMultiplier, - FlexMultiplier: p.FlexMultiplier, - ImageInputPrice: p.ImageInputPrice, - ImageOutputPrice: p.ImageOutputPrice, - PerRequestPrice: p.PerRequestPrice, - Intervals: intervals, - TimePricing: timePricingToResponse(p.TimePricing), + ID: p.ID, + Platform: platform, + Models: models, + BillingMode: billingMode, + InputPrice: p.InputPrice, + OutputPrice: p.OutputPrice, + CacheWritePrice: p.CacheWritePrice, + CacheWrite1hPrice: p.CacheWrite1hPrice, + CacheReadPrice: p.CacheReadPrice, + FastMultiplier: p.FastMultiplier, + FlexMultiplier: p.FlexMultiplier, + MaxReasoningEffortMultiplier: p.MaxReasoningEffortMultiplier, + ImageInputPrice: p.ImageInputPrice, + ImageOutputPrice: p.ImageOutputPrice, + PerRequestPrice: p.PerRequestPrice, + Intervals: intervals, + TimePricing: timePricingToResponse(p.TimePricing), } } @@ -350,27 +353,29 @@ func pricingRequestToService(reqs []channelModelPricingRequest, allowChannelMult SortOrder: iv.SortOrder, }) } - var fastMultiplier, flexMultiplier *float64 + var fastMultiplier, flexMultiplier, maxReasoningEffortMultiplier *float64 if allowChannelMultipliers { fastMultiplier = r.FastMultiplier flexMultiplier = r.FlexMultiplier + maxReasoningEffortMultiplier = r.MaxReasoningEffortMultiplier } result = append(result, service.ChannelModelPricing{ - Platform: platform, - Models: r.Models, - BillingMode: billingMode, - InputPrice: r.InputPrice, - OutputPrice: r.OutputPrice, - CacheWritePrice: r.CacheWritePrice, - CacheWrite1hPrice: r.CacheWrite1hPrice, - CacheReadPrice: r.CacheReadPrice, - FastMultiplier: fastMultiplier, - FlexMultiplier: flexMultiplier, - ImageInputPrice: r.ImageInputPrice, - ImageOutputPrice: r.ImageOutputPrice, - PerRequestPrice: r.PerRequestPrice, - Intervals: intervals, - TimePricing: timePricingRequestToService(r.TimePricing), + Platform: platform, + Models: r.Models, + BillingMode: billingMode, + InputPrice: r.InputPrice, + OutputPrice: r.OutputPrice, + CacheWritePrice: r.CacheWritePrice, + CacheWrite1hPrice: r.CacheWrite1hPrice, + CacheReadPrice: r.CacheReadPrice, + FastMultiplier: fastMultiplier, + FlexMultiplier: flexMultiplier, + MaxReasoningEffortMultiplier: maxReasoningEffortMultiplier, + ImageInputPrice: r.ImageInputPrice, + ImageOutputPrice: r.ImageOutputPrice, + PerRequestPrice: r.PerRequestPrice, + Intervals: intervals, + TimePricing: timePricingRequestToService(r.TimePricing), }) } return result @@ -615,14 +620,15 @@ func (h *ChannelHandler) GetModelDefaultPricing(c *gin.Context) { } response.Success(c, gin.H{ - "found": true, - "input_price": pricing.InputPricePerToken, - "output_price": pricing.OutputPricePerToken, - "cache_write_price": cacheWritePrice, - "cache_write_1h_price": cacheWrite1hPrice, - "cache_read_price": pricing.CacheReadPricePerToken, - "image_input_price": pricing.ImageInputPricePerToken, - "image_output_price": pricing.ImageOutputPricePerToken, + "found": true, + "input_price": pricing.InputPricePerToken, + "output_price": pricing.OutputPricePerToken, + "cache_write_price": cacheWritePrice, + "cache_write_1h_price": cacheWrite1hPrice, + "cache_read_price": pricing.CacheReadPricePerToken, + "max_reasoning_effort_multiplier": pricing.MaxReasoningEffortMultiplier, + "image_input_price": pricing.ImageInputPricePerToken, + "image_output_price": pricing.ImageOutputPricePerToken, }) } diff --git a/backend/internal/handler/admin/channel_handler_test.go b/backend/internal/handler/admin/channel_handler_test.go index cca67c9e47..ef0d5a9c8f 100644 --- a/backend/internal/handler/admin/channel_handler_test.go +++ b/backend/internal/handler/admin/channel_handler_test.go @@ -453,10 +453,11 @@ func TestPricingRequestToService_TimePricingNil(t *testing.T) { // 避免渠道倍率意外污染账号成本口径。 func TestPricingRequestToService_MultipliersGatedByFlag(t *testing.T) { req := channelModelPricingRequest{ - Models: []string{"gpt-5"}, - BillingMode: "token", - FastMultiplier: float64Ptr(2.5), - FlexMultiplier: float64Ptr(0.5), + Models: []string{"gpt-5"}, + BillingMode: "token", + FastMultiplier: float64Ptr(2.5), + FlexMultiplier: float64Ptr(0.5), + MaxReasoningEffortMultiplier: float64Ptr(3), Intervals: []pricingIntervalRequest{{ MinTokens: 272000, InputMultiplier: float64Ptr(2), @@ -469,6 +470,7 @@ func TestPricingRequestToService_MultipliersGatedByFlag(t *testing.T) { allowed := pricingRequestToService([]channelModelPricingRequest{req}, true) require.Equal(t, float64Ptr(2.5), allowed[0].FastMultiplier) require.Equal(t, float64Ptr(0.5), allowed[0].FlexMultiplier) + require.Equal(t, float64Ptr(3), allowed[0].MaxReasoningEffortMultiplier) require.Equal(t, float64Ptr(2), allowed[0].Intervals[0].InputMultiplier) require.Equal(t, float64Ptr(1.5), allowed[0].Intervals[0].OutputMultiplier) require.Equal(t, float64Ptr(2), allowed[0].Intervals[0].CacheWriteMultiplier) @@ -477,6 +479,7 @@ func TestPricingRequestToService_MultipliersGatedByFlag(t *testing.T) { dropped := pricingRequestToService([]channelModelPricingRequest{req}, false) require.Nil(t, dropped[0].FastMultiplier) require.Nil(t, dropped[0].FlexMultiplier) + require.Nil(t, dropped[0].MaxReasoningEffortMultiplier) require.Nil(t, dropped[0].Intervals[0].InputMultiplier) require.Nil(t, dropped[0].Intervals[0].OutputMultiplier) require.Nil(t, dropped[0].Intervals[0].CacheWriteMultiplier) @@ -580,9 +583,10 @@ func TestGetModelDefaultPricing_ReturnsFable51CacheTTLs(t *testing.T) { require.Equal(t, http.StatusOK, w.Code) var body struct { Data struct { - Found bool `json:"found"` - CacheWritePrice float64 `json:"cache_write_price"` - CacheWrite1hPrice *float64 `json:"cache_write_1h_price"` + Found bool `json:"found"` + CacheWritePrice float64 `json:"cache_write_price"` + CacheWrite1hPrice *float64 `json:"cache_write_1h_price"` + MaxReasoningEffortMultiplier *float64 `json:"max_reasoning_effort_multiplier"` } `json:"data"` } require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) @@ -590,6 +594,8 @@ func TestGetModelDefaultPricing_ReturnsFable51CacheTTLs(t *testing.T) { require.InDelta(t, 12.5e-6, body.Data.CacheWritePrice, 1e-12) require.NotNil(t, body.Data.CacheWrite1hPrice) require.InDelta(t, 20e-6, *body.Data.CacheWrite1hPrice, 1e-12) + require.NotNil(t, body.Data.MaxReasoningEffortMultiplier) + require.Equal(t, 3.0, *body.Data.MaxReasoningEffortMultiplier) } func TestGetModelDefaultPricing_OmitsUnsupportedCache1hPrice(t *testing.T) { diff --git a/backend/internal/handler/admin/group_handler.go b/backend/internal/handler/admin/group_handler.go index fa6991ee66..acf53eddba 100644 --- a/backend/internal/handler/admin/group_handler.go +++ b/backend/internal/handler/admin/group_handler.go @@ -156,11 +156,11 @@ type CreateGroupRequest struct { ModelsListConfig service.GroupModelsListConfig `json:"models_list_config"` // 分组 RPM 上限(0 = 不限制) RPMLimit int `json:"rpm_limit"` - // OpenAI/Codex 请求推理强度上限,空字符串表示不限制。 + // Anthropic/OpenAI 请求推理强度上限,空字符串表示不限制。 MaxReasoningEffort string `json:"max_reasoning_effort"` // 超过上限时的访问控制:downgrade(默认)或 deny。 MaxReasoningEffortOverLimit string `json:"max_reasoning_effort_over_limit"` - // OpenAI/Codex 推理强度映射,可按模型精确名、前缀或后缀限定。 + // Anthropic/OpenAI 推理强度映射,可按模型精确名、前缀或后缀限定。 ReasoningEffortMappings []service.ReasoningEffortMapping `json:"reasoning_effort_mappings"` // 从指定分组复制账号(创建后自动绑定) CopyAccountsFromGroupIDs []int64 `json:"copy_accounts_from_group_ids"` @@ -229,7 +229,7 @@ type UpdateGroupRequest struct { ModelsListConfig *service.GroupModelsListConfig `json:"models_list_config"` // 分组 RPM 上限(0 = 不限制);nil 表示未提供不改动 RPMLimit *int `json:"rpm_limit"` - // OpenAI/Codex 请求推理强度上限;空字符串清除,nil 不修改。 + // Anthropic/OpenAI 请求推理强度上限;空字符串清除,nil 不修改。 MaxReasoningEffort *string `json:"max_reasoning_effort"` // 超过上限时的访问控制;空字符串视为 downgrade,nil 不修改。 MaxReasoningEffortOverLimit *string `json:"max_reasoning_effort_over_limit"` diff --git a/backend/internal/handler/available_channel_handler.go b/backend/internal/handler/available_channel_handler.go index 5d35ff389e..ffa2408bcd 100644 --- a/backend/internal/handler/available_channel_handler.go +++ b/backend/internal/handler/available_channel_handler.go @@ -66,16 +66,17 @@ type userAvailableGroup struct { // userSupportedModelPricing 用户可见的定价字段白名单。 type userSupportedModelPricing struct { - BillingMode string `json:"billing_mode"` - InputPrice *float64 `json:"input_price"` - OutputPrice *float64 `json:"output_price"` - CacheWritePrice *float64 `json:"cache_write_price"` - CacheWrite1hPrice *float64 `json:"cache_write_1h_price"` - CacheReadPrice *float64 `json:"cache_read_price"` - ImageInputPrice *float64 `json:"image_input_price"` - ImageOutputPrice *float64 `json:"image_output_price"` - PerRequestPrice *float64 `json:"per_request_price"` - Intervals []userPricingIntervalDTO `json:"intervals"` + BillingMode string `json:"billing_mode"` + InputPrice *float64 `json:"input_price"` + OutputPrice *float64 `json:"output_price"` + CacheWritePrice *float64 `json:"cache_write_price"` + CacheWrite1hPrice *float64 `json:"cache_write_1h_price"` + CacheReadPrice *float64 `json:"cache_read_price"` + MaxReasoningEffortMultiplier *float64 `json:"max_reasoning_effort_multiplier,omitempty"` + ImageInputPrice *float64 `json:"image_input_price"` + ImageOutputPrice *float64 `json:"image_output_price"` + PerRequestPrice *float64 `json:"per_request_price"` + Intervals []userPricingIntervalDTO `json:"intervals"` } // userPricingIntervalDTO 定价区间白名单(去掉内部 ID、SortOrder 等前端不渲染的字段)。 @@ -323,15 +324,16 @@ func toUserPricing(p *service.ChannelModelPricing) *userSupportedModelPricing { billingMode = string(service.BillingModeToken) } return &userSupportedModelPricing{ - BillingMode: billingMode, - InputPrice: p.InputPrice, - OutputPrice: p.OutputPrice, - CacheWritePrice: p.CacheWritePrice, - CacheWrite1hPrice: p.CacheWrite1hPrice, - CacheReadPrice: p.CacheReadPrice, - ImageInputPrice: p.ImageInputPrice, - ImageOutputPrice: p.ImageOutputPrice, - PerRequestPrice: p.PerRequestPrice, - Intervals: intervals, + BillingMode: billingMode, + InputPrice: p.InputPrice, + OutputPrice: p.OutputPrice, + CacheWritePrice: p.CacheWritePrice, + CacheWrite1hPrice: p.CacheWrite1hPrice, + CacheReadPrice: p.CacheReadPrice, + MaxReasoningEffortMultiplier: p.MaxReasoningEffortMultiplier, + ImageInputPrice: p.ImageInputPrice, + ImageOutputPrice: p.ImageOutputPrice, + PerRequestPrice: p.PerRequestPrice, + Intervals: intervals, } } diff --git a/backend/internal/handler/composite_platform.go b/backend/internal/handler/composite_platform.go index 49e87cb332..6e613aa3e4 100644 --- a/backend/internal/handler/composite_platform.go +++ b/backend/internal/handler/composite_platform.go @@ -64,13 +64,45 @@ func openAIReasoningEffortPolicyForRequest(c *gin.Context, apiKey *service.APIKe if apiKey == nil || apiKey.Group == nil { return "", nil, "", false } - if apiKey.Group.Platform != service.PlatformOpenAI && apiKey.Group.Platform != service.PlatformComposite { + if apiKey.Group.Platform != service.PlatformAnthropic && apiKey.Group.Platform != service.PlatformOpenAI && apiKey.Group.Platform != service.PlatformComposite { return "", nil, "", false } - if effectiveAPIKeyPlatform(c, apiKey) != service.PlatformOpenAI { + effectivePlatform := effectiveAPIKeyPlatform(c, apiKey) + if effectivePlatform != service.PlatformAnthropic && effectivePlatform != service.PlatformOpenAI { return "", nil, "", false } - return apiKey.Group.MaxReasoningEffort, apiKey.Group.ReasoningEffortMappings, apiKey.Group.MaxReasoningEffortOverLimit, true + maxEffort, mappings := apiKey.Group.MaxReasoningEffort, apiKey.Group.ReasoningEffortMappings + if effectivePlatform == service.PlatformAnthropic { + maxEffort, mappings = anthropicCompatibleReasoningEffortPolicy(maxEffort, mappings) + } + return maxEffort, mappings, apiKey.Group.MaxReasoningEffortOverLimit, true +} + +func anthropicReasoningEffortPolicyForRequest(c *gin.Context, apiKey *service.APIKey) (string, []service.ReasoningEffortMapping, string, bool) { + if apiKey == nil || apiKey.Group == nil { + return "", nil, "", false + } + if apiKey.Group.Platform != service.PlatformAnthropic && apiKey.Group.Platform != service.PlatformComposite { + return "", nil, "", false + } + if effectiveAPIKeyPlatform(c, apiKey) != service.PlatformAnthropic { + return "", nil, "", false + } + maxEffort, mappings := anthropicCompatibleReasoningEffortPolicy(apiKey.Group.MaxReasoningEffort, apiKey.Group.ReasoningEffortMappings) + return maxEffort, mappings, apiKey.Group.MaxReasoningEffortOverLimit, true +} + +func anthropicCompatibleReasoningEffortPolicy(maxEffort string, mappings []service.ReasoningEffortMapping) (string, []service.ReasoningEffortMapping) { + if service.NormalizeMaxReasoningEffort(maxEffort) == "minimal" { + maxEffort = "low" + } + normalizedMappings := append([]service.ReasoningEffortMapping(nil), mappings...) + for i := range normalizedMappings { + if service.NormalizeMaxReasoningEffort(normalizedMappings[i].To) == "minimal" { + normalizedMappings[i].To = "low" + } + } + return maxEffort, normalizedMappings } func bindRequestedReasoningEffort(c *gin.Context, body []byte, model string) { @@ -118,6 +150,14 @@ func respondOpenAIReasoningEffortPolicyError(c *gin.Context, err error, write fu write(c, http.StatusForbidden, "permission_error", err.Error()) } +func applyAnthropicReasoningEffortPolicyForRequest(c *gin.Context, apiKey *service.APIKey, body []byte) ([]byte, bool, error) { + maxEffort, mappings, overLimit, ok := anthropicReasoningEffortPolicyForRequest(c, apiKey) + if !ok { + return body, false, nil + } + return service.ApplyReasoningEffortPolicy(body, maxEffort, mappings, overLimit) +} + func bindOpenAIReasoningEffortPolicyForMessagesRequest(c *gin.Context, apiKey *service.APIKey, body []byte) { if c == nil || c.Request == nil { return diff --git a/backend/internal/handler/composite_platform_test.go b/backend/internal/handler/composite_platform_test.go index 73db844e7e..d9a642ce06 100644 --- a/backend/internal/handler/composite_platform_test.go +++ b/backend/internal/handler/composite_platform_test.go @@ -8,6 +8,7 @@ import ( "github.com/Wei-Shaw/sub2api/internal/service" "github.com/gin-gonic/gin" "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" ) func TestCompositeTargetPlatformAllowedResolvesKnownAllowedModel(t *testing.T) { @@ -160,6 +161,75 @@ func TestOpenAIReasoningEffortPolicyForCompositeTarget(t *testing.T) { require.Equal(t, body, got) } +func TestOpenAIShapedReasoningEffortPolicyForAnthropicGroup(t *testing.T) { + gin.SetMode(gin.TestMode) + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Request = httptest.NewRequest("POST", "/v1/responses", nil) + apiKey := &service.APIKey{Group: &service.Group{ + Platform: service.PlatformAnthropic, + MaxReasoningEffort: "xhigh", + }} + + got, changed, err := applyOpenAIReasoningEffortPolicyForRequest(c, apiKey, []byte(`{"reasoning":{"effort":"max"}}`)) + require.NoError(t, err) + require.True(t, changed) + require.Equal(t, "xhigh", gjson.GetBytes(got, "reasoning.effort").String()) +} + +func TestAnthropicReasoningEffortPolicyForConcreteAndCompositeTargets(t *testing.T) { + gin.SetMode(gin.TestMode) + body := []byte(`{"model":"claude-fable-5-1","output_config":{"effort":"max"}}`) + policy := func(platform string) *service.APIKey { + return &service.APIKey{Group: &service.Group{ + Platform: platform, + MaxReasoningEffort: "xhigh", + ReasoningEffortMappings: []service.ReasoningEffortMapping{ + {From: "max", To: "xhigh"}, + }, + }} + } + + concrete, _ := gin.CreateTestContext(httptest.NewRecorder()) + concrete.Request = httptest.NewRequest("POST", "/v1/messages", nil) + got, changed, err := applyAnthropicReasoningEffortPolicyForRequest(concrete, policy(service.PlatformAnthropic), body) + require.NoError(t, err) + require.True(t, changed) + require.JSONEq(t, `{"model":"claude-fable-5-1","output_config":{"effort":"xhigh"}}`, string(got)) + + denyMax := policy(service.PlatformAnthropic) + denyMax.Group.ReasoningEffortMappings = nil + denyMax.Group.MaxReasoningEffortOverLimit = service.ReasoningEffortOverLimitDeny + _, changed, err = applyAnthropicReasoningEffortPolicyForRequest(concrete, denyMax, body) + require.Error(t, err) + require.False(t, changed) + var overLimit *service.ReasoningEffortOverLimitError + require.ErrorAs(t, err, &overLimit) + require.Equal(t, "max", overLimit.Requested) + require.Equal(t, "xhigh", overLimit.Max) + + composite, _ := gin.CreateTestContext(httptest.NewRecorder()) + composite.Request = httptest.NewRequest("POST", "/v1/messages", nil) + composite.Request = composite.Request.WithContext(service.WithResolvedTargetPlatform(composite.Request.Context(), service.PlatformAnthropic)) + got, changed, err = applyAnthropicReasoningEffortPolicyForRequest(composite, policy(service.PlatformComposite), body) + require.NoError(t, err) + require.True(t, changed) + require.Equal(t, "xhigh", gjson.GetBytes(got, "output_config.effort").String()) + + openAI, _ := gin.CreateTestContext(httptest.NewRecorder()) + openAI.Request = httptest.NewRequest("POST", "/v1/messages", nil) + openAI.Request = openAI.Request.WithContext(service.WithResolvedTargetPlatform(openAI.Request.Context(), service.PlatformOpenAI)) + got, changed, err = applyAnthropicReasoningEffortPolicyForRequest(openAI, policy(service.PlatformComposite), body) + require.NoError(t, err) + require.False(t, changed) + require.Equal(t, body, got) +} + +func TestAnthropicCompositePolicyClampsUnsupportedMinimalToLow(t *testing.T) { + maxEffort, mappings := anthropicCompatibleReasoningEffortPolicy("minimal", []service.ReasoningEffortMapping{{From: "max", To: "minimal"}}) + require.Equal(t, "low", maxEffort) + require.Equal(t, []service.ReasoningEffortMapping{{From: "max", To: "low"}}, mappings) +} + func TestClientRequestedModelUsesCompositePublicModel(t *testing.T) { gin.SetMode(gin.TestMode) c, _ := gin.CreateTestContext(httptest.NewRecorder()) diff --git a/backend/internal/handler/dto/types.go b/backend/internal/handler/dto/types.go index 7fb8373810..343bf22078 100644 --- a/backend/internal/handler/dto/types.go +++ b/backend/internal/handler/dto/types.go @@ -151,11 +151,11 @@ type Group struct { // RPMLimit 分组级每分钟请求数上限(0 = 不限制),设置后覆盖用户级 rpm_limit。 RPMLimit int `json:"rpm_limit"` - // MaxReasoningEffort OpenAI/Codex 请求的推理强度上限,空字符串表示不限制。 + // MaxReasoningEffort Anthropic/OpenAI 请求的推理强度上限,空字符串表示不限制。 MaxReasoningEffort string `json:"max_reasoning_effort"` // MaxReasoningEffortOverLimit 超过上限时的访问控制:downgrade(默认)或 deny。 MaxReasoningEffortOverLimit string `json:"max_reasoning_effort_over_limit"` - // ReasoningEffortMappings OpenAI/Codex 推理强度映射,可按模型精确名、前缀或后缀限定。 + // ReasoningEffortMappings Anthropic/OpenAI 推理强度映射,可按模型精确名、前缀或后缀限定。 ReasoningEffortMappings []domain.ReasoningEffortMapping `json:"reasoning_effort_mappings"` CreatedAt time.Time `json:"created_at"` diff --git a/backend/internal/handler/gateway_handler.go b/backend/internal/handler/gateway_handler.go index a4bcda50fa..73bbeb01d6 100644 --- a/backend/internal/handler/gateway_handler.go +++ b/backend/internal/handler/gateway_handler.go @@ -171,6 +171,19 @@ func (h *GatewayHandler) Messages(c *gin.Context) { reqStream := parsedReq.Stream bindRequestedReasoningEffort(c, body, reqModel) ensureCompositeTargetPlatform(c, apiKey, reqModel) + if policyBody, changed, err := applyAnthropicReasoningEffortPolicyForRequest(c, apiKey, body); err != nil { + respondOpenAIReasoningEffortPolicyError(c, err, h.errorResponse) + return + } else if changed { + if err := parsedReq.ReplaceBody(policyBody); err != nil { + reqLog.Warn("gateway.reasoning_effort_policy_parse_failed", zap.Error(err)) + h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "Failed to apply reasoning effort policy") + return + } + body = parsedReq.Body.Bytes() + reqModel = parsedReq.Model + reqStream = parsedReq.Stream + } reqLog = reqLog.With(zap.String("model", reqModel), zap.Bool("stream", reqStream)) // 解析渠道级模型映射 diff --git a/backend/internal/repository/channel_repo_pricing.go b/backend/internal/repository/channel_repo_pricing.go index 1a88eeb977..8053d4453f 100644 --- a/backend/internal/repository/channel_repo_pricing.go +++ b/backend/internal/repository/channel_repo_pricing.go @@ -16,7 +16,7 @@ import ( func (r *channelRepository) ListModelPricing(ctx context.Context, channelID int64) ([]service.ChannelModelPricing, error) { rows, err := r.db.QueryContext(ctx, - `SELECT id, channel_id, platform, models, billing_mode, input_price, output_price, cache_write_price, cache_write_1h_price, cache_read_price, fast_multiplier, flex_multiplier, image_input_price, image_output_price, per_request_price, time_pricing, created_at, updated_at + `SELECT id, channel_id, platform, models, billing_mode, input_price, output_price, cache_write_price, cache_write_1h_price, cache_read_price, fast_multiplier, flex_multiplier, max_reasoning_effort_multiplier, image_input_price, image_output_price, per_request_price, time_pricing, created_at, updated_at FROM channel_model_pricing WHERE channel_id = $1 ORDER BY id`, channelID, ) if err != nil { @@ -61,10 +61,10 @@ func (r *channelRepository) UpdateModelPricing(ctx context.Context, pricing *ser } result, err := r.db.ExecContext(ctx, `UPDATE channel_model_pricing - SET models = $1, billing_mode = $2, input_price = $3, output_price = $4, cache_write_price = $5, cache_write_1h_price = $6, cache_read_price = $7, fast_multiplier = $8, flex_multiplier = $9, image_input_price = $10, image_output_price = $11, per_request_price = $12, time_pricing = $13, platform = $14, updated_at = NOW() - WHERE id = $15`, + SET models = $1, billing_mode = $2, input_price = $3, output_price = $4, cache_write_price = $5, cache_write_1h_price = $6, cache_read_price = $7, fast_multiplier = $8, flex_multiplier = $9, max_reasoning_effort_multiplier = $10, image_input_price = $11, image_output_price = $12, per_request_price = $13, time_pricing = $14, platform = $15, updated_at = NOW() + WHERE id = $16`, modelsJSON, billingMode, pricing.InputPrice, pricing.OutputPrice, pricing.CacheWritePrice, pricing.CacheWrite1hPrice, pricing.CacheReadPrice, - pricing.FastMultiplier, pricing.FlexMultiplier, pricing.ImageInputPrice, pricing.ImageOutputPrice, pricing.PerRequestPrice, + pricing.FastMultiplier, pricing.FlexMultiplier, pricing.MaxReasoningEffortMultiplier, pricing.ImageInputPrice, pricing.ImageOutputPrice, pricing.PerRequestPrice, timePricingJSON, pricing.Platform, pricing.ID, ) if err != nil { @@ -96,7 +96,7 @@ func (r *channelRepository) ReplaceModelPricing(ctx context.Context, channelID i // batchLoadModelPricing 批量加载多个渠道的模型定价(含区间) func (r *channelRepository) batchLoadModelPricing(ctx context.Context, channelIDs []int64) (map[int64][]service.ChannelModelPricing, error) { rows, err := r.db.QueryContext(ctx, - `SELECT id, channel_id, platform, models, billing_mode, input_price, output_price, cache_write_price, cache_write_1h_price, cache_read_price, fast_multiplier, flex_multiplier, image_input_price, image_output_price, per_request_price, time_pricing, created_at, updated_at + `SELECT id, channel_id, platform, models, billing_mode, input_price, output_price, cache_write_price, cache_write_1h_price, cache_read_price, fast_multiplier, flex_multiplier, max_reasoning_effort_multiplier, image_input_price, image_output_price, per_request_price, time_pricing, created_at, updated_at FROM channel_model_pricing WHERE channel_id = ANY($1) ORDER BY channel_id, id`, pq.Array(channelIDs), ) @@ -180,7 +180,7 @@ func scanModelPricingRows(rows *sql.Rows) ([]service.ChannelModelPricing, []int6 if err := rows.Scan( &p.ID, &p.ChannelID, &p.Platform, &modelsJSON, &p.BillingMode, &p.InputPrice, &p.OutputPrice, &p.CacheWritePrice, &p.CacheWrite1hPrice, &p.CacheReadPrice, - &p.FastMultiplier, &p.FlexMultiplier, + &p.FastMultiplier, &p.FlexMultiplier, &p.MaxReasoningEffortMultiplier, &p.ImageInputPrice, &p.ImageOutputPrice, &p.PerRequestPrice, &timePricingJSON, &p.CreatedAt, &p.UpdatedAt, ); err != nil { return nil, nil, fmt.Errorf("scan model pricing: %w", err) @@ -247,11 +247,11 @@ func createModelPricingExec(ctx context.Context, exec dbExec, pricing *service.C platform = "anthropic" } err = exec.QueryRowContext(ctx, - `INSERT INTO channel_model_pricing (channel_id, platform, models, billing_mode, input_price, output_price, cache_write_price, cache_write_1h_price, cache_read_price, fast_multiplier, flex_multiplier, image_input_price, image_output_price, per_request_price, time_pricing) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) RETURNING id, created_at, updated_at`, + `INSERT INTO channel_model_pricing (channel_id, platform, models, billing_mode, input_price, output_price, cache_write_price, cache_write_1h_price, cache_read_price, fast_multiplier, flex_multiplier, max_reasoning_effort_multiplier, image_input_price, image_output_price, per_request_price, time_pricing) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16) RETURNING id, created_at, updated_at`, pricing.ChannelID, platform, modelsJSON, billingMode, pricing.InputPrice, pricing.OutputPrice, pricing.CacheWritePrice, pricing.CacheWrite1hPrice, pricing.CacheReadPrice, - pricing.FastMultiplier, pricing.FlexMultiplier, pricing.ImageInputPrice, pricing.ImageOutputPrice, + pricing.FastMultiplier, pricing.FlexMultiplier, pricing.MaxReasoningEffortMultiplier, pricing.ImageInputPrice, pricing.ImageOutputPrice, pricing.PerRequestPrice, timePricingJSON, ).Scan(&pricing.ID, &pricing.CreatedAt, &pricing.UpdatedAt) if err != nil { diff --git a/backend/internal/repository/channel_repo_pricing_time_test.go b/backend/internal/repository/channel_repo_pricing_time_test.go index 9532cdf162..c22630abcf 100644 --- a/backend/internal/repository/channel_repo_pricing_time_test.go +++ b/backend/internal/repository/channel_repo_pricing_time_test.go @@ -16,7 +16,7 @@ import ( var channelModelPricingTimePricingColumns = []string{ "id", "channel_id", "platform", "models", "billing_mode", "input_price", "output_price", - "cache_write_price", "cache_write_1h_price", "cache_read_price", "fast_multiplier", "flex_multiplier", "image_input_price", "image_output_price", + "cache_write_price", "cache_write_1h_price", "cache_read_price", "fast_multiplier", "flex_multiplier", "max_reasoning_effort_multiplier", "image_input_price", "image_output_price", "per_request_price", "time_pricing", "created_at", "updated_at", } @@ -33,7 +33,7 @@ func newChannelModelPricingTimePricingRepo(t *testing.T) (*channelRepository, sq func modelPricingTimePricingRow(timePricing any) *sqlmock.Rows { return sqlmock.NewRows(channelModelPricingTimePricingColumns).AddRow( int64(11), int64(7), "openai", `["gpt-5"]`, service.BillingModeToken, - nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, timePricing, + nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, timePricing, time.Date(2026, 8, 17, 0, 0, 0, 0, time.UTC), time.Date(2026, 8, 17, 1, 0, 0, 0, time.UTC), ) } @@ -105,10 +105,10 @@ func TestChannelModelPricingTimePricingCreateAndUpdateRoundTrip(t *testing.T) { t.Run("create writes JSON", func(t *testing.T) { repo, mock := newChannelModelPricingTimePricingRepo(t) - mock.ExpectQuery(regexp.QuoteMeta("INSERT INTO channel_model_pricing (channel_id, platform, models, billing_mode, input_price, output_price, cache_write_price, cache_write_1h_price, cache_read_price, fast_multiplier, flex_multiplier, image_input_price, image_output_price, per_request_price, time_pricing)")). + mock.ExpectQuery(regexp.QuoteMeta("INSERT INTO channel_model_pricing (channel_id, platform, models, billing_mode, input_price, output_price, cache_write_price, cache_write_1h_price, cache_read_price, fast_multiplier, flex_multiplier, max_reasoning_effort_multiplier, image_input_price, image_output_price, per_request_price, time_pricing)")). WithArgs( int64(7), "openai", []byte(`["gpt-5"]`), service.BillingModeToken, - nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, channelModelPricingTimePricingJSON, + nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, channelModelPricingTimePricingJSON, ). WillReturnRows(sqlmock.NewRows([]string{"id", "created_at", "updated_at"}).AddRow(int64(11), time.Time{}, time.Time{})) @@ -118,10 +118,10 @@ func TestChannelModelPricingTimePricingCreateAndUpdateRoundTrip(t *testing.T) { t.Run("update writes JSON and entry ID", func(t *testing.T) { repo, mock := newChannelModelPricingTimePricingRepo(t) - mock.ExpectExec(`(?s)UPDATE channel_model_pricing.*per_request_price = \$12, time_pricing = \$13, platform = \$14.*WHERE id = \$15`). + mock.ExpectExec(`(?s)UPDATE channel_model_pricing.*per_request_price = \$13, time_pricing = \$14, platform = \$15.*WHERE id = \$16`). WithArgs( []byte(`["gpt-5"]`), service.BillingModeToken, - nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, channelModelPricingTimePricingJSON, "openai", int64(11), + nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, channelModelPricingTimePricingJSON, "openai", int64(11), ). WillReturnResult(sqlmock.NewResult(0, 1)) @@ -153,10 +153,10 @@ func TestChannelModelPricingTimePricingCreateAndUpdateWriteNullWhenDisabled(t *t t.Run("create writes SQL NULL", func(t *testing.T) { repo, mock := newChannelModelPricingTimePricingRepo(t) - mock.ExpectQuery(regexp.QuoteMeta("INSERT INTO channel_model_pricing (channel_id, platform, models, billing_mode, input_price, output_price, cache_write_price, cache_write_1h_price, cache_read_price, fast_multiplier, flex_multiplier, image_input_price, image_output_price, per_request_price, time_pricing)")). + mock.ExpectQuery(regexp.QuoteMeta("INSERT INTO channel_model_pricing (channel_id, platform, models, billing_mode, input_price, output_price, cache_write_price, cache_write_1h_price, cache_read_price, fast_multiplier, flex_multiplier, max_reasoning_effort_multiplier, image_input_price, image_output_price, per_request_price, time_pricing)")). WithArgs( int64(7), "openai", []byte(`["gpt-5"]`), service.BillingModeToken, - nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, + nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, ). WillReturnRows(sqlmock.NewRows([]string{"id", "created_at", "updated_at"}).AddRow(int64(11), time.Time{}, time.Time{})) @@ -166,10 +166,10 @@ func TestChannelModelPricingTimePricingCreateAndUpdateWriteNullWhenDisabled(t *t t.Run("update writes SQL NULL", func(t *testing.T) { repo, mock := newChannelModelPricingTimePricingRepo(t) - mock.ExpectExec(`(?s)UPDATE channel_model_pricing.*per_request_price = \$12, time_pricing = \$13, platform = \$14.*WHERE id = \$15`). + mock.ExpectExec(`(?s)UPDATE channel_model_pricing.*per_request_price = \$13, time_pricing = \$14, platform = \$15.*WHERE id = \$16`). WithArgs( []byte(`["gpt-5"]`), service.BillingModeToken, - nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, "openai", int64(11), + nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, "openai", int64(11), ). WillReturnResult(sqlmock.NewResult(0, 1)) diff --git a/backend/internal/service/account_stats_pricing.go b/backend/internal/service/account_stats_pricing.go index 8ac3dba383..c25a66f674 100644 --- a/backend/internal/service/account_stats_pricing.go +++ b/backend/internal/service/account_stats_pricing.go @@ -17,6 +17,7 @@ import ( // upstreamModel 是最终发往上游的模型 ID。 // totalCost 是本次请求的客户计费(倍率前),用于优先级 2。 // serviceTier 是最终参与用户计费的 OpenAI 服务层级,用于优先级 3。 +// reasoningEffort 是最终转发等级;Fable 5.1 max 默认按 3 倍额度消耗。 func resolveAccountStatsCost( ctx context.Context, channelService *ChannelService, @@ -28,7 +29,12 @@ func resolveAccountStatsCost( requestCount int, totalCost float64, serviceTier string, + reasoningEfforts ...string, ) *float64 { + reasoningEffort := "" + if len(reasoningEfforts) > 0 { + reasoningEffort = reasoningEfforts[0] + } if channelService == nil || upstreamModel == "" { return nil } @@ -40,7 +46,7 @@ func resolveAccountStatsCost( platform := channelService.GetGroupPlatform(ctx, groupID) // 优先级 1:自定义规则(始终尝试) - if cost := tryCustomRules(channel, accountID, groupID, platform, upstreamModel, tokens, requestCount); cost != nil { + if cost := tryCustomRules(channel, accountID, groupID, platform, upstreamModel, tokens, requestCount, reasoningEffort); cost != nil { return cost } @@ -55,7 +61,7 @@ func resolveAccountStatsCost( // 优先级 3:模型定价文件(LiteLLM)默认价格 if billingService != nil { - return tryModelFilePricing(billingService, upstreamModel, tokens, serviceTier) + return tryModelFilePricing(billingService, upstreamModel, tokens, serviceTier, reasoningEffort) } return nil @@ -65,13 +71,18 @@ func resolveAccountStatsCost( // 与用户计费共用同一条定价管线,避免这里维护第二份"单价 × token 数"实现后, // 每加一个定价特性都要手工镜像一次。channelPricing 为 nil,保持优先级 3 的 // 语义:只取模型定价文件,不引入渠道自定义定价。 -func tryModelFilePricing(billingService *BillingService, model string, tokens UsageTokens, serviceTier string) *float64 { +func tryModelFilePricing(billingService *BillingService, model string, tokens UsageTokens, serviceTier string, reasoningEfforts ...string) *float64 { + reasoningEffort := "" + if len(reasoningEfforts) > 0 { + reasoningEffort = reasoningEfforts[0] + } breakdown, err := billingService.CalculateCostWithServiceTier( model, tokens, 1, normalizeBillingServiceTier(serviceTier), ) if err != nil || breakdown == nil || breakdown.TotalCost <= 0 { return nil } + applyCostBreakdownMultiplier(breakdown, maxReasoningEffortBillingMultiplier(model, reasoningEffort, nil)) return &breakdown.TotalCost } @@ -79,7 +90,12 @@ func tryModelFilePricing(billingService *BillingService, model string, tokens Us func tryCustomRules( channel *Channel, accountID, groupID int64, platform, model string, tokens UsageTokens, requestCount int, + reasoningEfforts ...string, ) *float64 { + reasoningEffort := "" + if len(reasoningEfforts) > 0 { + reasoningEffort = reasoningEfforts[0] + } modelLower := strings.ToLower(model) for _, rule := range channel.AccountStatsPricingRules { if !matchAccountStatsRule(&rule, accountID, groupID) { @@ -89,7 +105,11 @@ func tryCustomRules( if pricing == nil { continue // 规则匹配但模型不在规则定价中,继续下一条 } - return calculateStatsCost(pricing, tokens, requestCount) + cost := calculateStatsCost(pricing, tokens, requestCount) + if cost != nil { + *cost *= maxReasoningEffortBillingMultiplier(model, reasoningEffort, nil) + } + return cost } return nil } @@ -243,10 +263,14 @@ func applyAccountStatsCost( requestCount = usageLog.ImageCount } serviceTier := "" + reasoningEffort := "" if usageLog != nil && usageLog.ServiceTier != nil { serviceTier = *usageLog.ServiceTier } + if usageLog != nil && usageLog.ReasoningEffort != nil { + reasoningEffort = *usageLog.ReasoningEffort + } usageLog.AccountStatsCost = resolveAccountStatsCost( - ctx, cs, bs, accountID, groupID, model, tokens, requestCount, totalCost, serviceTier, + ctx, cs, bs, accountID, groupID, model, tokens, requestCount, totalCost, serviceTier, reasoningEffort, ) } diff --git a/backend/internal/service/account_stats_pricing_test.go b/backend/internal/service/account_stats_pricing_test.go index 97628f58c0..a346f4405f 100644 --- a/backend/internal/service/account_stats_pricing_test.go +++ b/backend/internal/service/account_stats_pricing_test.go @@ -478,6 +478,18 @@ func TestTryModelFilePricing_Success(t *testing.T) { require.InDelta(t, 0.2, *result, 1e-12) } +func TestTryModelFilePricing_Fable51MaxEffortUsesTripleQuota(t *testing.T) { + bs := newTestBillingServiceWithPrices(map[string]*ModelPricing{ + "claude-fable-5-1": {InputPricePerToken: 0.001}, + }) + tokens := UsageTokens{InputTokens: 100} + standard := tryModelFilePricing(bs, "claude-fable-5-1", tokens, "", "xhigh") + max := tryModelFilePricing(bs, "claude-fable-5-1", tokens, "", "max") + require.NotNil(t, standard) + require.NotNil(t, max) + require.InDelta(t, *standard*3, *max, 1e-12) +} + func TestTryModelFilePricing_AppliesLongContextPricing(t *testing.T) { bs := newTestBillingServiceWithPrices(map[string]*ModelPricing{ "gpt-5.6-sol": { diff --git a/backend/internal/service/admin_service.go b/backend/internal/service/admin_service.go index 392f8d5d46..81d76c15f1 100644 --- a/backend/internal/service/admin_service.go +++ b/backend/internal/service/admin_service.go @@ -275,11 +275,11 @@ type CreateGroupInput struct { ModelsListConfig GroupModelsListConfig // RPMLimit 分组 RPM 上限(0 = 不限制) RPMLimit int - // MaxReasoningEffort OpenAI/Codex 请求的推理强度上限,空字符串表示不限制。 + // MaxReasoningEffort Anthropic/OpenAI 请求的推理强度上限,空字符串表示不限制。 MaxReasoningEffort string // MaxReasoningEffortOverLimit 超过上限时的访问控制:downgrade(默认)或 deny。 MaxReasoningEffortOverLimit string - // ReasoningEffortMappings OpenAI/Codex 推理强度精确映射。 + // ReasoningEffortMappings Anthropic/OpenAI 推理强度映射,可按模型精确名、前缀或后缀限定。 ReasoningEffortMappings []ReasoningEffortMapping // 分组利润控制(五个 token 平台分组可启用;margin/buffer 为小数,nil 按 0 处理) ProfitControlEnabled bool diff --git a/backend/internal/service/admin_service_group_test.go b/backend/internal/service/admin_service_group_test.go index e14c2a0b39..bc240850ef 100644 --- a/backend/internal/service/admin_service_group_test.go +++ b/backend/internal/service/admin_service_group_test.go @@ -960,7 +960,7 @@ func TestAdminService_UpdateGroup_ClearsReasoningPolicyForUnsupportedPlatform(t repo := &groupRepoStubForAdmin{getByID: existing} svc := &adminServiceImpl{groupRepo: repo} - _, err := svc.UpdateGroup(context.Background(), existing.ID, &UpdateGroupInput{Platform: PlatformAnthropic}) + _, err := svc.UpdateGroup(context.Background(), existing.ID, &UpdateGroupInput{Platform: PlatformGemini}) require.NoError(t, err) require.Empty(t, repo.updated.MaxReasoningEffort) diff --git a/backend/internal/service/api_key_auth_cache.go b/backend/internal/service/api_key_auth_cache.go index 59d2774cd7..82067dcfcf 100644 --- a/backend/internal/service/api_key_auth_cache.go +++ b/backend/internal/service/api_key_auth_cache.go @@ -112,7 +112,7 @@ type APIKeyAuthGroupSnapshot struct { // RPMLimit 分组级每分钟请求数上限(0 = 不限制);用于 billing_cache_service.checkRPM 级联判断。 RPMLimit int `json:"rpm_limit"` - // MaxReasoningEffort OpenAI/Codex 请求的推理强度上限,空字符串表示不限制。 + // MaxReasoningEffort Anthropic/OpenAI 请求的推理强度上限,空字符串表示不限制。 MaxReasoningEffort string `json:"max_reasoning_effort,omitempty"` // MaxReasoningEffortOverLimit 超过上限时的访问控制:downgrade(默认)或 deny。 MaxReasoningEffortOverLimit string `json:"max_reasoning_effort_over_limit,omitempty"` diff --git a/backend/internal/service/billing_service.go b/backend/internal/service/billing_service.go index 1f3a346f64..03e13bd772 100644 --- a/backend/internal/service/billing_service.go +++ b/backend/internal/service/billing_service.go @@ -105,6 +105,7 @@ type ModelPricing struct { CacheReadPricePerTokenPriority float64 // priority service tier 下缓存读取每token价格 (USD) FastMultiplier *float64 // 渠道显式 Fast/priority 倍率;nil 时沿用模型目录行为 FlexMultiplier *float64 // 渠道显式 Flex 倍率;nil 时沿用默认行为 + MaxReasoningEffortMultiplier *float64 // max 推理等级的额度/计费倍率;nil 时沿用模型默认行为 CacheCreation5mPrice float64 // 5分钟缓存创建每token价格 (USD) CacheCreation1hPrice float64 // 1小时缓存创建每token价格 (USD) SupportsCacheBreakdown bool // 是否支持详细的缓存分类 @@ -214,6 +215,42 @@ func applyCostBreakdownMultiplier(cost *CostBreakdown, multiplier float64) { cost.ActualCost *= multiplier } +const claudeFable51MaxReasoningEffortMultiplier = 3.0 + +func isClaudeFable51Model(model string) bool { + model = strings.ToLower(strings.TrimSpace(model)) + for _, marker := range []string{"fable-5-1", "fable-5.1", "fable5.1", "fable51"} { + if at := strings.Index(model, marker); at >= 0 { + after := at + len(marker) + if after == len(model) || model[after] < '0' || model[after] > '9' { + return true + } + } + } + return false +} + +func defaultMaxReasoningEffortMultiplier(model string) *float64 { + if !isClaudeFable51Model(model) { + return nil + } + multiplier := claudeFable51MaxReasoningEffortMultiplier + return &multiplier +} + +func maxReasoningEffortBillingMultiplier(model, effort string, pricing *ModelPricing) float64 { + if NormalizeMaxReasoningEffort(effort) != "max" { + return 1 + } + if pricing != nil && pricing.MaxReasoningEffortMultiplier != nil && *pricing.MaxReasoningEffortMultiplier > 0 { + return *pricing.MaxReasoningEffortMultiplier + } + if multiplier := defaultMaxReasoningEffortMultiplier(model); multiplier != nil { + return *multiplier + } + return 1 +} + func resolvedChannelTimeMultiplier(resolved *ResolvedPricing, at time.Time) float64 { if resolved == nil || resolved.Source != PricingSourceChannel || resolved.channelPricing == nil { return 1 @@ -803,8 +840,7 @@ func (s *BillingService) getFallbackPricing(model string) *ModelPricing { modelLower := strings.ToLower(model) // 按模型系列匹配 - if strings.Contains(modelLower, "fable-5-1") || strings.Contains(modelLower, "fable-5.1") || - strings.Contains(modelLower, "fable5.1") || strings.Contains(modelLower, "fable51") { + if isClaudeFable51Model(modelLower) { return s.fallbackPrices["claude-fable-5-1"] } if strings.Contains(modelLower, "fable-5") || strings.Contains(modelLower, "fable5") { @@ -1169,6 +1205,9 @@ func (s *BillingService) GetModelPricingWithChannel(model string, channelPricing applyChannelTokenPriceOverrides(pricing, channelPricing) pricing.FastMultiplier = channelPricing.FastMultiplier pricing.FlexMultiplier = channelPricing.FlexMultiplier + if channelPricing.MaxReasoningEffortMultiplier != nil { + pricing.MaxReasoningEffortMultiplier = channelPricing.MaxReasoningEffortMultiplier + } if channelPricing.ImageOutputPrice != nil { pricing.ImageOutputPricePerToken = *channelPricing.ImageOutputPrice } else { @@ -1241,6 +1280,7 @@ type CostInput struct { RateMultiplier float64 PricingAt time.Time // 渠道分时定价使用的计费时刻 ServiceTier string // "priority","flex","" 等 + ReasoningEffort string // 最终转发的推理等级;max 可触发模型/渠道倍率 Resolver *ModelPricingResolver // 定价解析器 Resolved *ResolvedPricing // 可选:预解析的定价结果(避免重复 Resolve 调用) LongContextBillingEnabled *bool @@ -1255,7 +1295,7 @@ func (s *BillingService) CalculateCostUnified(input CostInput) (*CostBreakdown, if input.LongContextBillingEnabled != nil { applyLongContextBilling = *input.LongContextBillingEnabled } - return s.calculateCostInternalWithPolicy( + breakdown, err := s.calculateCostInternalWithPolicy( input.Model, input.Tokens, input.RateMultiplier, @@ -1263,6 +1303,10 @@ func (s *BillingService) CalculateCostUnified(input CostInput) (*CostBreakdown, nil, applyLongContextBilling, ) + if err == nil { + applyCostBreakdownMultiplier(breakdown, maxReasoningEffortBillingMultiplier(input.Model, input.ReasoningEffort, nil)) + } + return breakdown, err } // 优先使用预解析结果,避免重复 Resolve 调用 @@ -1346,6 +1390,7 @@ func (s *BillingService) calculateTokenCost(resolved *ResolvedPricing, input Cos breakdown := s.computeTokenBreakdown(pricing, input.Tokens, input.RateMultiplier, input.ServiceTier, applyLongCtx) applyCostBreakdownMultiplier(breakdown, resolvedChannelTimeMultiplier(resolved, input.PricingAt)) + applyCostBreakdownMultiplier(breakdown, maxReasoningEffortBillingMultiplier(input.Model, input.ReasoningEffort, pricing)) return breakdown, nil } @@ -1627,13 +1672,17 @@ func (s *BillingService) applyModelSpecificPricingPolicyEx(model string, pricing } normalized := normalizeKnownOpenAICodexModel(model) isGPT56 := isOpenAIGPT56Model(normalized) + needsMaxReasoningEffortMultiplier := isClaudeFable51Model(model) && pricing.MaxReasoningEffortMultiplier == nil needsCacheCreationPolicy := isGPT56 && !pricing.CacheCreationPriceExplicit && (pricing.CacheCreationPricePerToken <= 0 || (pricing.InputPricePerTokenPriority > 0 && pricing.CacheCreationPricePerTokenPriority <= 0)) fastRatio := openAIModelFastPricingRatio(normalized) - if !needsCacheCreationPolicy && fastRatio <= 0 { + if !needsCacheCreationPolicy && fastRatio <= 0 && !needsMaxReasoningEffortMultiplier { return pricing } cloned := *pricing + if needsMaxReasoningEffortMultiplier { + cloned.MaxReasoningEffortMultiplier = defaultMaxReasoningEffortMultiplier(model) + } if isGPT56 && !cloned.CacheCreationPriceExplicit { if cloned.CacheCreationPricePerToken <= 0 { cloned.CacheCreationPricePerToken = cloned.InputPricePerToken * 1.25 diff --git a/backend/internal/service/billing_service_test.go b/backend/internal/service/billing_service_test.go index 956fe73d33..0a93278a08 100644 --- a/backend/internal/service/billing_service_test.go +++ b/backend/internal/service/billing_service_test.go @@ -1770,6 +1770,8 @@ func TestGetModelPricing_Fable51FallbackPricing(t *testing.T) { require.InDelta(t, 12.5e-6, pricing.CacheCreation5mPrice, 1e-12) require.InDelta(t, 20e-6, pricing.CacheCreation1hPrice, 1e-12) require.InDelta(t, 0.25e-6, pricing.CacheReadPricePerToken, 1e-12) + require.NotNil(t, pricing.MaxReasoningEffortMultiplier) + require.Equal(t, 3.0, *pricing.MaxReasoningEffortMultiplier) } func TestGetModelPricingWithChannel_CacheReadPriceAffectsPriority(t *testing.T) { diff --git a/backend/internal/service/billing_token_cost_request.go b/backend/internal/service/billing_token_cost_request.go index 6a59be16a5..0794864dd0 100644 --- a/backend/internal/service/billing_token_cost_request.go +++ b/backend/internal/service/billing_token_cost_request.go @@ -7,14 +7,15 @@ import ( // TokenCostRequest 通用网关 token 计费请求。 type TokenCostRequest struct { - Ctx context.Context - Model string - Group *Group - Tokens UsageTokens - RateMultiplier float64 - PricingAt time.Time - ServiceTier string - Resolver *ModelPricingResolver + Ctx context.Context + Model string + Group *Group + Tokens UsageTokens + RateMultiplier float64 + PricingAt time.Time + ServiceTier string + ReasoningEffort string + Resolver *ModelPricingResolver // Resolved 为调用方预先解析的定价(Resolver.Resolve 的结果),nil 表示未解析。 Resolved *ResolvedPricing } @@ -33,21 +34,25 @@ func (s *BillingService) CalculateTokenCostForRequest(req TokenCostRequest) (*Co if req.Resolver != nil && req.Group != nil { return s.CalculateCostUnified(s.tokenCostInput(req, resolved)) } + if req.ReasoningEffort != "" { + return s.CalculateCostUnified(s.tokenCostInput(req, resolved)) + } return s.CalculateCost(req.Model, req.Tokens, req.RateMultiplier) } func (s *BillingService) tokenCostInput(req TokenCostRequest, resolved *ResolvedPricing) CostInput { input := CostInput{ - Ctx: req.Ctx, - Model: req.Model, - Group: req.Group, - Tokens: req.Tokens, - RequestCount: 1, - RateMultiplier: req.RateMultiplier, - PricingAt: req.PricingAt, - ServiceTier: req.ServiceTier, - Resolver: req.Resolver, - Resolved: resolved, + Ctx: req.Ctx, + Model: req.Model, + Group: req.Group, + Tokens: req.Tokens, + RequestCount: 1, + RateMultiplier: req.RateMultiplier, + PricingAt: req.PricingAt, + ServiceTier: req.ServiceTier, + ReasoningEffort: req.ReasoningEffort, + Resolver: req.Resolver, + Resolved: resolved, } if req.Group != nil { gid := req.Group.ID diff --git a/backend/internal/service/billing_token_cost_request_test.go b/backend/internal/service/billing_token_cost_request_test.go index 9d282de061..7376d8de19 100644 --- a/backend/internal/service/billing_token_cost_request_test.go +++ b/backend/internal/service/billing_token_cost_request_test.go @@ -216,3 +216,41 @@ func TestCalculateTokenCostForRequest_NoResolverFallsBackToCatalog(t *testing.T) require.NoError(t, err) require.Equal(t, want, got) } + +func TestCalculateTokenCostForRequest_Fable51MaxEffortUsesDefaultMultiplier(t *testing.T) { + bs := NewBillingService(&config.Config{}, nil) + tokens := UsageTokens{InputTokens: 1000, OutputTokens: 10} + standard, err := bs.CalculateTokenCostForRequest(TokenCostRequest{ + Model: "claude-fable-5-1", Tokens: tokens, RateMultiplier: 1, ReasoningEffort: "xhigh", + }) + require.NoError(t, err) + max, err := bs.CalculateTokenCostForRequest(TokenCostRequest{ + Model: "claude-fable-5-1", Tokens: tokens, RateMultiplier: 1, ReasoningEffort: "max", + }) + require.NoError(t, err) + require.InDelta(t, standard.TotalCost*3, max.TotalCost, 1e-12) + require.InDelta(t, standard.ActualCost*3, max.ActualCost, 1e-12) + require.InDelta(t, standard.InputCost*3, max.InputCost, 1e-12) + require.InDelta(t, standard.OutputCost*3, max.OutputCost, 1e-12) +} + +func TestCalculateTokenCostForRequest_ChannelOverridesFable51MaxEffortMultiplier(t *testing.T) { + configured := 1.5 + bs, resolver := newTokenCostTestEnv(t, PlatformAnthropic, []ChannelModelPricing{{ + Platform: PlatformAnthropic, Models: []string{"claude-fable-5-1"}, BillingMode: BillingModeToken, + InputPrice: testPtrFloat64(10e-6), OutputPrice: testPtrFloat64(50e-6), + MaxReasoningEffortMultiplier: &configured, + }}, nil) + group := &Group{ID: 100, Platform: PlatformAnthropic} + gid := group.ID + resolved := resolver.Resolve(context.Background(), PricingInput{Model: "claude-fable-5-1", GroupID: &gid, Group: group}) + + got, err := bs.CalculateTokenCostForRequest(TokenCostRequest{ + Ctx: context.Background(), Model: "claude-fable-5-1", Group: group, + Tokens: UsageTokens{InputTokens: 1000}, RateMultiplier: 1, ReasoningEffort: "max", + Resolver: resolver, Resolved: resolved, + }) + require.NoError(t, err) + require.InDelta(t, 1000*10e-6*configured, got.TotalCost, 1e-12) + require.InDelta(t, got.TotalCost, got.ActualCost, 1e-12) +} diff --git a/backend/internal/service/channel.go b/backend/internal/service/channel.go index df3b5f892a..f9fea3bcc9 100644 --- a/backend/internal/service/channel.go +++ b/backend/internal/service/channel.go @@ -87,25 +87,26 @@ type AccountStatsPricingRule struct { // ChannelModelPricing 渠道模型定价条目 type ChannelModelPricing struct { - ID int64 `json:"id,omitempty"` - ChannelID int64 `json:"channel_id,omitempty"` - Platform string `json:"platform"` // 所属平台(anthropic/openai/gemini/...) - Models []string `json:"models"` - BillingMode BillingMode `json:"billing_mode"` - InputPrice *float64 `json:"input_price"` - OutputPrice *float64 `json:"output_price"` - CacheWritePrice *float64 `json:"cache_write_price"` - CacheWrite1hPrice *float64 `json:"cache_write_1h_price"` - CacheReadPrice *float64 `json:"cache_read_price"` - FastMultiplier *float64 `json:"fast_multiplier"` - FlexMultiplier *float64 `json:"flex_multiplier"` - ImageInputPrice *float64 `json:"image_input_price"` - ImageOutputPrice *float64 `json:"image_output_price"` - PerRequestPrice *float64 `json:"per_request_price"` - Intervals []PricingInterval `json:"intervals"` - TimePricing *ChannelTimePricing `json:"time_pricing,omitempty"` - CreatedAt time.Time `json:"created_at,omitempty"` - UpdatedAt time.Time `json:"updated_at,omitempty"` + ID int64 `json:"id,omitempty"` + ChannelID int64 `json:"channel_id,omitempty"` + Platform string `json:"platform"` // 所属平台(anthropic/openai/gemini/...) + Models []string `json:"models"` + BillingMode BillingMode `json:"billing_mode"` + InputPrice *float64 `json:"input_price"` + OutputPrice *float64 `json:"output_price"` + CacheWritePrice *float64 `json:"cache_write_price"` + CacheWrite1hPrice *float64 `json:"cache_write_1h_price"` + CacheReadPrice *float64 `json:"cache_read_price"` + FastMultiplier *float64 `json:"fast_multiplier"` + FlexMultiplier *float64 `json:"flex_multiplier"` + MaxReasoningEffortMultiplier *float64 `json:"max_reasoning_effort_multiplier"` + ImageInputPrice *float64 `json:"image_input_price"` + ImageOutputPrice *float64 `json:"image_output_price"` + PerRequestPrice *float64 `json:"per_request_price"` + Intervals []PricingInterval `json:"intervals"` + TimePricing *ChannelTimePricing `json:"time_pricing,omitempty"` + CreatedAt time.Time `json:"created_at,omitempty"` + UpdatedAt time.Time `json:"updated_at,omitempty"` } // ChannelTimePricing 渠道模型定价的分时倍率配置。 diff --git a/backend/internal/service/channel_available.go b/backend/internal/service/channel_available.go index 08b02eb423..46eb15d6bb 100644 --- a/backend/internal/service/channel_available.go +++ b/backend/internal/service/channel_available.go @@ -117,20 +117,16 @@ func (s *ChannelService) ListAvailable(ctx context.Context) ([]AvailableChannel, // 1. Pricing == nil(渠道完全没声明该模型的定价条目) // 2. Pricing 非 nil 但所有价格字段为空(admin UI 建了条目但没填价格) // -// 当 pricingService 为 nil(测试场景),跳过回落。可用渠道与模型广场共用。 +// 当 pricingService 为 nil(测试场景),跳过价格回落,但仍补充内置模型倍率。 +// 可用渠道与模型广场共用。 func fillGlobalPricingFallback(pricingService *PricingService, models []SupportedModel) { - if pricingService == nil { - return - } for i := range models { - if !pricingNeedsFallback(models[i].Pricing) { - continue + if pricingService != nil && pricingNeedsFallback(models[i].Pricing) { + if lp := pricingService.GetModelPricing(models[i].Name); lp != nil { + models[i].Pricing = synthesizePricingFromLiteLLM(lp, models[i].Pricing) + } } - lp := pricingService.GetModelPricing(models[i].Name) - if lp == nil { - continue - } - models[i].Pricing = synthesizePricingFromLiteLLM(lp, models[i].Pricing) + models[i].Pricing = withDefaultMaxReasoningEffortMultiplier(models[i].Pricing, models[i].Name) } } @@ -180,24 +176,33 @@ func synthesizePricingFromLiteLLM(lp *LiteLLMModelPricing, existing *ChannelMode if mode == BillingModeImage || mode == BillingModePerRequest { return &ChannelModelPricing{ - BillingMode: mode, - PerRequestPrice: nonZeroPtr(lp.OutputCostPerImage), - ImageOutputPrice: nonZeroPtr(lp.OutputCostPerImageToken), - InputPrice: nonZeroPtr(lp.InputCostPerToken), - OutputPrice: nonZeroPtr(lp.OutputCostPerToken), + BillingMode: mode, + PerRequestPrice: nonZeroPtr(lp.OutputCostPerImage), + ImageOutputPrice: nonZeroPtr(lp.OutputCostPerImageToken), + InputPrice: nonZeroPtr(lp.InputCostPerToken), + OutputPrice: nonZeroPtr(lp.OutputCostPerToken), + MaxReasoningEffortMultiplier: maxReasoningEffortMultiplierFromPricing(existing), } } return &ChannelModelPricing{ - BillingMode: mode, - InputPrice: nonZeroPtr(lp.InputCostPerToken), - OutputPrice: nonZeroPtr(lp.OutputCostPerToken), - CacheWritePrice: nonZeroPtr(lp.CacheCreationInputTokenCost), - CacheWrite1hPrice: nonZeroPtr(lp.CacheCreationInputTokenCostAbove1hr), - CacheReadPrice: nonZeroPtr(lp.CacheReadInputTokenCost), - ImageOutputPrice: nonZeroPtr(lp.OutputCostPerImageToken), + BillingMode: mode, + InputPrice: nonZeroPtr(lp.InputCostPerToken), + OutputPrice: nonZeroPtr(lp.OutputCostPerToken), + CacheWritePrice: nonZeroPtr(lp.CacheCreationInputTokenCost), + CacheWrite1hPrice: nonZeroPtr(lp.CacheCreationInputTokenCostAbove1hr), + CacheReadPrice: nonZeroPtr(lp.CacheReadInputTokenCost), + ImageOutputPrice: nonZeroPtr(lp.OutputCostPerImageToken), + MaxReasoningEffortMultiplier: maxReasoningEffortMultiplierFromPricing(existing), } } +func maxReasoningEffortMultiplierFromPricing(pricing *ChannelModelPricing) *float64 { + if pricing == nil { + return nil + } + return pricing.MaxReasoningEffortMultiplier +} + func nonZeroPtr(v float64) *float64 { if v == 0 { return nil diff --git a/backend/internal/service/gateway_usage_billing.go b/backend/internal/service/gateway_usage_billing.go index 069758baae..c2922d03e2 100644 --- a/backend/internal/service/gateway_usage_billing.go +++ b/backend/internal/service/gateway_usage_billing.go @@ -1092,15 +1092,16 @@ func (s *GatewayService) calculateTokenCost( } cost, err := s.billingService.CalculateTokenCostForRequest(TokenCostRequest{ - Ctx: ctx, - Model: billingModel, - Group: apiKey.Group, - Tokens: tokens, - RateMultiplier: multiplier, - PricingAt: pricingAt, - ServiceTier: optionalStringValue(result.ServiceTier), - Resolver: s.resolver, - Resolved: resolved, + Ctx: ctx, + Model: billingModel, + Group: apiKey.Group, + Tokens: tokens, + RateMultiplier: multiplier, + PricingAt: pricingAt, + ServiceTier: optionalStringValue(result.ServiceTier), + ReasoningEffort: optionalStringValue(result.ReasoningEffort), + Resolver: s.resolver, + Resolved: resolved, }) if err != nil { logger.LegacyPrintf("service.gateway", "Calculate cost failed: %v", err) diff --git a/backend/internal/service/group.go b/backend/internal/service/group.go index fc9996da3b..d22026ad80 100644 --- a/backend/internal/service/group.go +++ b/backend/internal/service/group.go @@ -112,7 +112,7 @@ type Group struct { // 一旦设置即接管该分组用户的限流(覆盖用户级 rpm_limit),可被 user-group rpm_override 进一步覆盖。 RPMLimit int - // MaxReasoningEffort limits the effective OpenAI/Codex reasoning effort. + // MaxReasoningEffort limits the effective Anthropic/OpenAI reasoning effort. // Empty means unlimited; supported values are minimal/low/medium/high/xhigh/max. MaxReasoningEffort string // MaxReasoningEffortOverLimit is the access control when an explicit effort diff --git a/backend/internal/service/model_plaza_service.go b/backend/internal/service/model_plaza_service.go index fe2252c594..6b2a87b263 100644 --- a/backend/internal/service/model_plaza_service.go +++ b/backend/internal/service/model_plaza_service.go @@ -231,7 +231,7 @@ func (s *ModelPlazaService) fillDisplayPricing(ctx context.Context, m *PlazaMode Platform: m.Platform, }) if err == nil && sched != nil && len(sched.Tiers) > 0 { - m.Pricing = plazaPricingFromSchedule(m.Pricing, sched) + m.Pricing = withDefaultMaxReasoningEffortMultiplier(plazaPricingFromSchedule(m.Pricing, sched), m.Name) if len(sched.Tiers) > 1 { m.LongContextBasis = sched.Basis } @@ -239,7 +239,20 @@ func (s *ModelPlazaService) fillDisplayPricing(ctx context.Context, m *PlazaMode return } } - m.Pricing = plazaImageDisplayPricing(m.Pricing, g) + m.Pricing = withDefaultMaxReasoningEffortMultiplier(plazaImageDisplayPricing(m.Pricing, g), m.Name) +} + +func withDefaultMaxReasoningEffortMultiplier(pricing *ChannelModelPricing, model string) *ChannelModelPricing { + if pricing == nil || pricing.MaxReasoningEffortMultiplier != nil { + return pricing + } + multiplier := defaultMaxReasoningEffortMultiplier(model) + if multiplier == nil { + return pricing + } + cloned := pricing.Clone() + cloned.MaxReasoningEffortMultiplier = multiplier + return &cloned } // plazaPricingFromSchedule 把阶梯表压成展示用的 ChannelModelPricing: @@ -250,6 +263,7 @@ func plazaPricingFromSchedule(raw *ChannelModelPricing, sched *ContextPricingSch out.ImageInputPrice = raw.ImageInputPrice out.ImageOutputPrice = raw.ImageOutputPrice out.PerRequestPrice = raw.PerRequestPrice + out.MaxReasoningEffortMultiplier = raw.MaxReasoningEffortMultiplier } first := sched.Tiers[0] out.InputPrice = first.Input diff --git a/backend/internal/service/model_plaza_service_test.go b/backend/internal/service/model_plaza_service_test.go index 4a588ecb0f..e093a9def9 100644 --- a/backend/internal/service/model_plaza_service_test.go +++ b/backend/internal/service/model_plaza_service_test.go @@ -57,6 +57,19 @@ func TestListPlazaGroups_GroupCentricAggregation(t *testing.T) { require.Equal(t, "claude-sonnet", out[0].Models[1].Name) } +func TestWithDefaultMaxReasoningEffortMultiplier_Fable51(t *testing.T) { + base := &ChannelModelPricing{BillingMode: BillingModeToken} + got := withDefaultMaxReasoningEffortMultiplier(base, "claude-fable-5-1") + require.NotSame(t, base, got) + require.NotNil(t, got.MaxReasoningEffortMultiplier) + require.Equal(t, 3.0, *got.MaxReasoningEffortMultiplier) + require.Nil(t, base.MaxReasoningEffortMultiplier) + + configured := 1.25 + custom := &ChannelModelPricing{MaxReasoningEffortMultiplier: &configured} + require.Same(t, custom, withDefaultMaxReasoningEffortMultiplier(custom, "claude-fable-5-1")) +} + func TestListPlazaGroups_DedupFirstWinsWithPricingUpgrade(t *testing.T) { // 同名模型:先见者胜;仅当已存条目无定价而新条目有定价时升级替换。 unpriced := Channel{ diff --git a/backend/internal/service/model_pricing_resolver.go b/backend/internal/service/model_pricing_resolver.go index 1a5382e7c3..ecd3d45209 100644 --- a/backend/internal/service/model_pricing_resolver.go +++ b/backend/internal/service/model_pricing_resolver.go @@ -247,6 +247,9 @@ func (r *ModelPricingResolver) applyTokenOverrides(chPricing *ChannelModelPricin } resolved.BasePricing.FastMultiplier = chPricing.FastMultiplier resolved.BasePricing.FlexMultiplier = chPricing.FlexMultiplier + if chPricing.MaxReasoningEffortMultiplier != nil { + resolved.BasePricing.MaxReasoningEffortMultiplier = chPricing.MaxReasoningEffortMultiplier + } // 渠道定价覆盖一切:显式配置则用配置值,未配置则归零(不回退到 LiteLLM) if chPricing.ImageOutputPrice != nil { resolved.BasePricing.ImageOutputPricePerToken = *chPricing.ImageOutputPrice diff --git a/backend/internal/service/openai_gateway_usage.go b/backend/internal/service/openai_gateway_usage.go index cb998b55fa..1a37f0e411 100644 --- a/backend/internal/service/openai_gateway_usage.go +++ b/backend/internal/service/openai_gateway_usage.go @@ -610,6 +610,7 @@ func (s *OpenAIGatewayService) calculateOpenAIRecordUsageCost( pricingAt, tokens, serviceTier, + optionalStringValue(result.ReasoningEffort), longContextBillingGate, ) if err == nil { @@ -703,6 +704,7 @@ func (s *OpenAIGatewayService) calculateOpenAIRecordUsageTokenCost( pricingAt time.Time, tokens UsageTokens, serviceTier string, + reasoningEffort string, longContextBillingGate *bool, ) (*CostBreakdown, error) { if s.resolver != nil && apiKey.Group != nil { @@ -710,17 +712,21 @@ func (s *OpenAIGatewayService) calculateOpenAIRecordUsageTokenCost( return s.billingService.CalculateCostUnified(CostInput{ Ctx: ctx, Model: billingModel, GroupID: &gid, Group: apiKey.Group, Tokens: tokens, RequestCount: 1, RateMultiplier: multiplier, PricingAt: pricingAt, - ServiceTier: serviceTier, Resolver: s.resolver, + ServiceTier: serviceTier, ReasoningEffort: reasoningEffort, Resolver: s.resolver, LongContextBillingEnabled: longContextBillingGate, }) } - return s.billingService.calculateCostWithServiceTierPolicy( + breakdown, err := s.billingService.calculateCostWithServiceTierPolicy( billingModel, tokens, multiplier, serviceTier, longContextBillingGate == nil || *longContextBillingGate, ) + if err == nil { + applyCostBreakdownMultiplier(breakdown, maxReasoningEffortBillingMultiplier(billingModel, reasoningEffort, nil)) + } + return breakdown, err } func (s *OpenAIGatewayService) calculateOpenAIImageCost( diff --git a/backend/internal/service/openai_reasoning_effort_policy.go b/backend/internal/service/openai_reasoning_effort_policy.go index caa1291120..2ae6214846 100644 --- a/backend/internal/service/openai_reasoning_effort_policy.go +++ b/backend/internal/service/openai_reasoning_effort_policy.go @@ -22,6 +22,7 @@ const ( ) var openAIReasoningEffortValues = []string{"minimal", "low", "medium", "high", "xhigh", "max"} +var anthropicReasoningEffortValues = []string{"low", "medium", "high", "xhigh", "max"} type openAIReasoningEffortPolicyContextKey struct{} type requestedReasoningEffortContextKey struct{} @@ -83,10 +84,14 @@ func NormalizeMaxReasoningEffort(raw string) string { } func reasoningEffortValuesForPlatform(platform string) []string { - if platform != PlatformOpenAI && platform != PlatformComposite { + switch platform { + case PlatformOpenAI, PlatformComposite: + return openAIReasoningEffortValues + case PlatformAnthropic: + return anthropicReasoningEffortValues + default: return nil } - return openAIReasoningEffortValues } func normalizeMaxReasoningEffortForPlatform(platform, raw string) (string, error) { @@ -97,7 +102,8 @@ func normalizeMaxReasoningEffortForPlatform(platform, raw string) (string, error allowedValues := reasoningEffortValuesForPlatform(platform) if len(allowedValues) == 0 { return "", fmt.Errorf( - "reasoning effort policy is only supported for platforms %q and %q", + "reasoning effort policy is only supported for platforms %q, %q, and %q", + PlatformAnthropic, PlatformOpenAI, PlatformComposite, ) @@ -145,7 +151,8 @@ func normalizeMaxReasoningEffortOverLimitForPlatform(platform, raw string) (stri } if len(reasoningEffortValuesForPlatform(platform)) == 0 { return "", fmt.Errorf( - "reasoning effort policy is only supported for platforms %q and %q", + "reasoning effort policy is only supported for platforms %q, %q, and %q", + PlatformAnthropic, PlatformOpenAI, PlatformComposite, ) @@ -272,7 +279,7 @@ func selectReasoningEffortMapping(mappings []ReasoningEffortMapping, from, reque } // NormalizeReasoningEffortMappings validates group mapping rules against the -// fixed effort values supported by OpenAI routes. Optional model scopes are +// fixed effort values supported by the selected platform. Optional model scopes are // canonicalized to exact/prefix/suffix match; empty type and model keep the // global all-models rule. func NormalizeReasoningEffortMappings(platform string, raw []ReasoningEffortMapping) ([]ReasoningEffortMapping, error) { @@ -407,7 +414,7 @@ func sanitizeGroupReasoningEffortPolicy(group *Group) { if mappingsErr != nil { mappings = []ReasoningEffortMapping{} } - if overLimit == "" || (group.Platform != PlatformOpenAI && group.Platform != PlatformComposite) { + if overLimit == "" || (group.Platform != PlatformAnthropic && group.Platform != PlatformOpenAI && group.Platform != PlatformComposite) { overLimit = ReasoningEffortOverLimitDowngrade } group.MaxReasoningEffort = maxEffort @@ -415,12 +422,13 @@ func sanitizeGroupReasoningEffortPolicy(group *Group) { group.ReasoningEffortMappings = mappings } -// ApplyOpenAIReasoningEffortPolicy applies one mapping (optionally scoped to +// ApplyReasoningEffortPolicy applies one mapping (optionally scoped to // the request model by exact name, prefix, or suffix) and then either caps // known effort levels or rejects the request when the group is configured to // deny values above the ceiling. Omitted values remain untouched so upstream -// defaults stay in control. -func ApplyOpenAIReasoningEffortPolicy(body []byte, maxEffort string, mappings []ReasoningEffortMapping, overLimit string) ([]byte, bool, error) { +// defaults stay in control. It understands both OpenAI and Anthropic request +// field shapes. +func ApplyReasoningEffortPolicy(body []byte, maxEffort string, mappings []ReasoningEffortMapping, overLimit string) ([]byte, bool, error) { maxRank, hasMax := reasoningEffortRank(maxEffort) if len(body) == 0 || (!hasMax && len(mappings) == 0) { return body, false, nil @@ -431,7 +439,7 @@ func ApplyOpenAIReasoningEffortPolicy(body []byte, maxEffort string, mappings [] requestModel := strings.TrimSpace(gjson.GetBytes(body, "model").String()) result := body changed := false - for _, path := range []string{"reasoning.effort", "reasoning_effort"} { + for _, path := range []string{"reasoning.effort", "reasoning_effort", "output_config.effort"} { field := gjson.GetBytes(result, path) if !field.Exists() || field.Type != gjson.String { continue @@ -478,3 +486,8 @@ func applyOpenAIWSReasoningEffortPolicy(payload []byte, hooks *OpenAIWSIngressHo } return payload, nil } + +// ApplyOpenAIReasoningEffortPolicy is retained for OpenAI forwarding callers. +func ApplyOpenAIReasoningEffortPolicy(body []byte, maxEffort string, mappings []ReasoningEffortMapping, overLimit string) ([]byte, bool, error) { + return ApplyReasoningEffortPolicy(body, maxEffort, mappings, overLimit) +} diff --git a/backend/internal/service/openai_reasoning_effort_policy_test.go b/backend/internal/service/openai_reasoning_effort_policy_test.go index fae609d7c1..d1188ceb26 100644 --- a/backend/internal/service/openai_reasoning_effort_policy_test.go +++ b/backend/internal/service/openai_reasoning_effort_policy_test.go @@ -147,9 +147,9 @@ func TestNormalizeReasoningEffortMappings(t *testing.T) { }) t.Run("rejects mappings for non OpenAI platforms", func(t *testing.T) { - for _, platform := range []string{PlatformAnthropic, PlatformGemini, PlatformAntigravity, PlatformGrok} { + for _, platform := range []string{PlatformGemini, PlatformAntigravity, PlatformGrok} { _, err := NormalizeReasoningEffortMappings(platform, []ReasoningEffortMapping{{From: "low", To: "high"}}) - require.ErrorContains(t, err, "only supported for platforms \"openai\" and \"composite\"") + require.ErrorContains(t, err, "only supported for platforms") } _, err := NormalizeReasoningEffortMappings(PlatformOpenAI, []ReasoningEffortMapping{{From: "none", To: "low"}}) @@ -158,6 +158,15 @@ func TestNormalizeReasoningEffortMappings(t *testing.T) { _, err = NormalizeReasoningEffortMappings(PlatformOpenAI, []ReasoningEffortMapping{{From: "ultra", To: "high"}}) require.ErrorContains(t, err, "empty or unknown") }) + + t.Run("supports Anthropic values except minimal", func(t *testing.T) { + got, err := NormalizeReasoningEffortMappings(PlatformAnthropic, []ReasoningEffortMapping{{From: " MAX ", To: " x-high "}}) + require.NoError(t, err) + require.Equal(t, []ReasoningEffortMapping{{From: "max", To: "xhigh"}}, got) + + _, err = NormalizeReasoningEffortMappings(PlatformAnthropic, []ReasoningEffortMapping{{From: "minimal", To: "low"}}) + require.ErrorContains(t, err, "not supported for platform \"anthropic\"") + }) } func TestNormalizeMaxReasoningEffortForPlatform(t *testing.T) { @@ -168,9 +177,15 @@ func TestNormalizeMaxReasoningEffortForPlatform(t *testing.T) { require.NoError(t, err) require.Equal(t, "max", value) - for _, platform := range []string{PlatformAnthropic, PlatformGemini, PlatformAntigravity, PlatformGrok} { + value, err = normalizeMaxReasoningEffortForPlatform(PlatformAnthropic, "xhigh") + require.NoError(t, err) + require.Equal(t, "xhigh", value) + _, err = normalizeMaxReasoningEffortForPlatform(PlatformAnthropic, "minimal") + require.ErrorContains(t, err, "not supported") + + for _, platform := range []string{PlatformGemini, PlatformAntigravity, PlatformGrok} { _, err = normalizeMaxReasoningEffortForPlatform(platform, "low") - require.ErrorContains(t, err, "only supported for platforms \"openai\" and \"composite\"") + require.ErrorContains(t, err, "only supported for platforms") } _, err = normalizeMaxReasoningEffortForPlatform(PlatformOpenAI, "none") @@ -197,8 +212,9 @@ func TestNormalizeMaxReasoningEffortOverLimitForPlatform(t *testing.T) { require.NoError(t, err) require.Equal(t, ReasoningEffortOverLimitDowngrade, value) - _, err = normalizeMaxReasoningEffortOverLimitForPlatform(PlatformAnthropic, "deny") - require.ErrorContains(t, err, "only supported for platforms \"openai\" and \"composite\"") + value, err = normalizeMaxReasoningEffortOverLimitForPlatform(PlatformAnthropic, "deny") + require.NoError(t, err) + require.Equal(t, ReasoningEffortOverLimitDeny, value) _, err = normalizeMaxReasoningEffortOverLimitForPlatform(PlatformOpenAI, "block") require.ErrorContains(t, err, "not supported") @@ -243,6 +259,8 @@ func TestApplyOpenAIReasoningEffortPolicy(t *testing.T) { }{ {name: "nested caps high", body: `{"reasoning":{"effort":"xhigh"}}`, max: "medium", path: "reasoning.effort", want: "medium", changed: true}, {name: "flat caps high", body: `{"reasoning_effort":"high"}`, max: "low", path: "reasoning_effort", want: "low", changed: true}, + {name: "Anthropic output config caps max", body: `{"output_config":{"effort":"max"}}`, max: "xhigh", path: "output_config.effort", want: "xhigh", changed: true}, + {name: "Anthropic output config maps before cap", body: `{"output_config":{"effort":"max"}}`, max: "high", mappings: []ReasoningEffortMapping{{From: "max", To: "xhigh"}}, path: "output_config.effort", want: "high", changed: true}, {name: "does not raise omitted", body: `{"model":"gpt-5"}`, max: "low", path: "reasoning_effort", want: "", changed: false}, {name: "keeps lower value", body: `{"reasoning_effort":"low"}`, max: "high", path: "reasoning_effort", want: "low", changed: false}, {name: "normalizes request alias", body: `{"reasoning_effort":"x-high"}`, max: "xhigh", path: "reasoning_effort", want: "xhigh", changed: true}, diff --git a/backend/migrations/234_channel_max_reasoning_effort_multiplier.sql b/backend/migrations/234_channel_max_reasoning_effort_multiplier.sql new file mode 100644 index 0000000000..07409f1162 --- /dev/null +++ b/backend/migrations/234_channel_max_reasoning_effort_multiplier.sql @@ -0,0 +1,11 @@ +ALTER TABLE channel_model_pricing + ADD COLUMN IF NOT EXISTS max_reasoning_effort_multiplier NUMERIC(10,4); + +COMMENT ON COLUMN channel_model_pricing.max_reasoning_effort_multiplier IS + 'Billing/quota multiplier applied when the forwarded reasoning effort is max'; + +ALTER TABLE channel_model_pricing + DROP CONSTRAINT IF EXISTS chk_channel_model_pricing_max_reasoning_effort_multiplier_positive; +ALTER TABLE channel_model_pricing + ADD CONSTRAINT chk_channel_model_pricing_max_reasoning_effort_multiplier_positive + CHECK (max_reasoning_effort_multiplier IS NULL OR max_reasoning_effort_multiplier > 0); diff --git a/frontend/src/api/admin/channels.ts b/frontend/src/api/admin/channels.ts index 3b355bc85f..d956bb92c7 100644 --- a/frontend/src/api/admin/channels.ts +++ b/frontend/src/api/admin/channels.ts @@ -50,6 +50,7 @@ export interface ChannelModelPricing { cache_read_price: number | null fast_multiplier?: number | null flex_multiplier?: number | null + max_reasoning_effort_multiplier?: number | null image_input_price: number | null image_output_price: number | null per_request_price: number | null @@ -179,6 +180,7 @@ export interface ModelDefaultPricing { cache_read_price?: number image_input_price?: number image_output_price?: number + max_reasoning_effort_multiplier?: number | null } export async function getModelDefaultPricing(model: string): Promise { diff --git a/frontend/src/api/channels.ts b/frontend/src/api/channels.ts index 6a88cc5ed8..c998fc3efb 100644 --- a/frontend/src/api/channels.ts +++ b/frontend/src/api/channels.ts @@ -41,6 +41,7 @@ export interface UserSupportedModelPricing { cache_write_price: number | null cache_write_1h_price?: number | null cache_read_price: number | null + max_reasoning_effort_multiplier?: number | null image_input_price: number | null image_output_price: number | null per_request_price: number | null diff --git a/frontend/src/components/admin/channel/PricingEntryCard.vue b/frontend/src/components/admin/channel/PricingEntryCard.vue index 8465887dc2..ba6fe3cf2f 100644 --- a/frontend/src/components/admin/channel/PricingEntryCard.vue +++ b/frontend/src/components/admin/channel/PricingEntryCard.vue @@ -144,7 +144,7 @@
-
+
+
+ + +
@@ -308,6 +313,12 @@ const billingModeLabel = computed(() => { return opt ? opt.label : props.entry.billing_mode }) +const maxReasoningEffortMultiplierPlaceholder = computed(() => + props.entry.models.some(model => /fable(?:-5-1|-5\.1|5\.1|51)(?!\d)/i.test(model)) + ? t('admin.channels.form.fable51DefaultMaxReasoningMultiplier') + : t('admin.channels.form.multiplierPlaceholder') +) + function emitField(field: keyof PricingFormEntry, value: string) { emit('update', { ...props.entry, [field]: value === '' ? null : value }) } @@ -383,6 +394,7 @@ async function onModelsUpdate(newModels: string[]) { cache_read_price: perTokenToMTok(result.cache_read_price ?? null), image_input_price: perTokenToMTok(result.image_input_price ?? null), image_output_price: perTokenToMTok(result.image_output_price ?? null), + max_reasoning_effort_multiplier: result.max_reasoning_effort_multiplier ?? null, }) } } catch { diff --git a/frontend/src/components/admin/channel/__tests__/PricingEntryCard.timePricing.spec.ts b/frontend/src/components/admin/channel/__tests__/PricingEntryCard.timePricing.spec.ts index b139f95518..947ac41c1f 100644 --- a/frontend/src/components/admin/channel/__tests__/PricingEntryCard.timePricing.spec.ts +++ b/frontend/src/components/admin/channel/__tests__/PricingEntryCard.timePricing.spec.ts @@ -18,6 +18,7 @@ function createEntry(billingMode: PricingFormEntry['billing_mode'] = 'token'): P cache_read_price: null, fast_multiplier: null, flex_multiplier: null, + max_reasoning_effort_multiplier: null, image_input_price: null, image_output_price: null, per_request_price: null, @@ -72,8 +73,8 @@ describe('PricingEntryCard time pricing visibility', () => { }) }) -describe('PricingEntryCard service tier multipliers', () => { - it('shows Fast and Flex controls only when explicitly enabled', () => { +describe('PricingEntryCard request multipliers', () => { + it('shows Fast, Flex, and Max effort controls only when explicitly enabled', () => { const hidden = shallowMount(PricingEntryCard, { props: { entry: createEntry() } }) expect(hidden.text()).not.toContain('admin.channels.form.fastMultiplier') @@ -82,5 +83,6 @@ describe('PricingEntryCard service tier multipliers', () => { }) expect(shown.text()).toContain('admin.channels.form.fastMultiplier') expect(shown.text()).toContain('admin.channels.form.flexMultiplier') + expect(shown.text()).toContain('admin.channels.form.maxReasoningEffortMultiplier') }) }) diff --git a/frontend/src/components/admin/channel/types.ts b/frontend/src/components/admin/channel/types.ts index 8cdc1417a3..0575e1b8c6 100644 --- a/frontend/src/components/admin/channel/types.ts +++ b/frontend/src/components/admin/channel/types.ts @@ -29,6 +29,7 @@ export interface PricingFormEntry { cache_read_price: number | string | null fast_multiplier?: number | string | null flex_multiplier?: number | string | null + max_reasoning_effort_multiplier?: number | string | null image_input_price: number | string | null image_output_price: number | string | null per_request_price: number | string | null diff --git a/frontend/src/components/modelPlaza/PlazaModelPricingTable.vue b/frontend/src/components/modelPlaza/PlazaModelPricingTable.vue index 05eb586951..43aee2c140 100644 --- a/frontend/src/components/modelPlaza/PlazaModelPricingTable.vue +++ b/frontend/src/components/modelPlaza/PlazaModelPricingTable.vue @@ -99,6 +99,13 @@ > {{ t('modelPlaza.table.marginalBadge') }} + + {{ t('modelPlaza.table.maxReasoningMultiplierBadge', { multiplier: m.pricing.max_reasoning_effort_multiplier }) }} +
diff --git a/frontend/src/components/modelPlaza/__tests__/PlazaModelPricingTable.spec.ts b/frontend/src/components/modelPlaza/__tests__/PlazaModelPricingTable.spec.ts index 2971431a93..902195f384 100644 --- a/frontend/src/components/modelPlaza/__tests__/PlazaModelPricingTable.spec.ts +++ b/frontend/src/components/modelPlaza/__tests__/PlazaModelPricingTable.spec.ts @@ -68,6 +68,15 @@ describe('PlazaModelPricingTable', () => { expect(text).toContain('1x') }) + it('shows the Max reasoning billing multiplier', () => { + const model = tokenModel() + model.pricing!.max_reasoning_effort_multiplier = 3 + const wrapper = mountTable([model], 1) + + expect(wrapper.text()).toContain('modelPlaza.table.maxReasoningMultiplierBadge') + expect(wrapper.find('[title="modelPlaza.table.maxReasoningMultiplierHint"]').exists()).toBe(true) + }) + it('倍率 ≠ 1 时价格列为折后实付价,官方价列保持原价', () => { const wrapper = mountTable([tokenModel()], 0.5) const text = wrapper.text() diff --git a/frontend/src/i18n/locales/en/admin/channels.ts b/frontend/src/i18n/locales/en/admin/channels.ts index 5055dc8fd6..ff8648cdf2 100644 --- a/frontend/src/i18n/locales/en/admin/channels.ts +++ b/frontend/src/i18n/locales/en/admin/channels.ts @@ -138,8 +138,10 @@ export default { pricePlaceholder: 'Default', fastMultiplier: 'Fast Multiplier', flexMultiplier: 'Flex Multiplier', + maxReasoningEffortMultiplier: 'Max Effort Multiplier', + fable51DefaultMaxReasoningMultiplier: 'Default: 3', multiplierPlaceholder: 'Not set', - multiplierPositive: 'Fast/Flex multipliers must be greater than 0', + multiplierPositive: 'Fast/Flex/Max effort multipliers must be greater than 0', inputMultiplier: 'Input Mult.', outputMultiplier: 'Output Mult.', cacheWriteMultiplier: 'Cache Write Mult.', diff --git a/frontend/src/i18n/locales/en/admin/overview.ts b/frontend/src/i18n/locales/en/admin/overview.ts index a31cb65e10..a055077096 100644 --- a/frontend/src/i18n/locales/en/admin/overview.ts +++ b/frontend/src/i18n/locales/en/admin/overview.ts @@ -859,7 +859,7 @@ export default { rpmLimitHint: 'Max requests per minute for each user in this group; 0 = unlimited. Once set, it takes over per-user rate limiting in this group (overrides the user-level rpm_limit fallback).', maxReasoningEffort: 'Max reasoning effort', maxReasoningEffortUnlimited: 'Unlimited (follow request)', - maxReasoningEffortHint: 'Limits explicit OpenAI reasoning effort requests only. For Composite groups, it applies only to requests resolved to OpenAI. Omitted effort stays omitted. The ceiling takes precedence over reasoning effort mappings.', + maxReasoningEffortHint: 'Limits explicit Anthropic and OpenAI reasoning effort requests. For Composite groups, it applies to the resolved target platform. Omitted effort stays omitted. The ceiling takes precedence over reasoning effort mappings.', maxReasoningEffortOverLimit: 'Over-limit access control', maxReasoningEffortOverLimitDowngrade: 'Automatically downgrade when over limit', maxReasoningEffortOverLimitDeny: 'Deny access', diff --git a/frontend/src/i18n/locales/en/dashboard.ts b/frontend/src/i18n/locales/en/dashboard.ts index 576067c6a3..6855e41d03 100644 --- a/frontend/src/i18n/locales/en/dashboard.ts +++ b/frontend/src/i18n/locales/en/dashboard.ts @@ -635,6 +635,8 @@ export default { cacheReadShort: 'R', tierHint: 'The whole request is billed at the tier matching its total context (input + cache write + cache read)', tierHintMarginal: 'Only the portion above the threshold is billed at this tier; output is unaffected', + maxReasoningMultiplierBadge: 'Max ×{multiplier}', + maxReasoningMultiplierHint: 'When the forwarded reasoning effort is max, billing and quota usage for the request are multiplied by {multiplier}', marginalBadge: 'excess-only tiers', timePricingRowHint: 'Requests made within this period ({timezone} time) are billed at the prices in this row', timePricingRowHintWeekdays: diff --git a/frontend/src/i18n/locales/zh/admin/channels.ts b/frontend/src/i18n/locales/zh/admin/channels.ts index b9e40a6a20..d4a5145676 100644 --- a/frontend/src/i18n/locales/zh/admin/channels.ts +++ b/frontend/src/i18n/locales/zh/admin/channels.ts @@ -138,8 +138,10 @@ export default { pricePlaceholder: '默认', fastMultiplier: 'Fast 倍率', flexMultiplier: 'Flex 倍率', + maxReasoningEffortMultiplier: 'Max 推理倍率', + fable51DefaultMaxReasoningMultiplier: '默认 3', multiplierPlaceholder: '未配置', - multiplierPositive: 'Fast/Flex 倍率必须大于 0', + multiplierPositive: 'Fast/Flex/Max 推理倍率必须大于 0', inputMultiplier: '输入倍率', outputMultiplier: '输出倍率', cacheWriteMultiplier: '缓存写倍率', diff --git a/frontend/src/i18n/locales/zh/admin/overview.ts b/frontend/src/i18n/locales/zh/admin/overview.ts index 344bbc41f7..168e6e6824 100644 --- a/frontend/src/i18n/locales/zh/admin/overview.ts +++ b/frontend/src/i18n/locales/zh/admin/overview.ts @@ -841,7 +841,7 @@ export default { rpmLimitHint: '每用户在本分组每分钟最大请求数,0 = 不限制;一旦设置即接管该用户的限流(覆盖用户级 rpm_limit)', maxReasoningEffort: '推理强度上限', maxReasoningEffortUnlimited: '不限制(跟随请求)', - maxReasoningEffortHint: '仅限制客户端主动请求的 OpenAI reasoning effort;Composite 分组仅对解析到 OpenAI 的请求生效。不会为缺省请求主动开启推理。上限优先级高于推理强度映射。', + maxReasoningEffortHint: '仅限制客户端主动请求的 Anthropic/OpenAI 推理强度;Composite 分组按实际目标平台生效。不会为缺省请求主动开启推理。上限优先级高于推理强度映射。', maxReasoningEffortOverLimit: '超限访问控制', maxReasoningEffortOverLimitDowngrade: '超过上限时自动降档', maxReasoningEffortOverLimitDeny: '拒绝访问', diff --git a/frontend/src/i18n/locales/zh/dashboard.ts b/frontend/src/i18n/locales/zh/dashboard.ts index 60117abbbc..a3977e3bc1 100644 --- a/frontend/src/i18n/locales/zh/dashboard.ts +++ b/frontend/src/i18n/locales/zh/dashboard.ts @@ -640,6 +640,8 @@ export default { cacheReadShort: '读', tierHint: '按单次请求的总上下文(输入 + 缓存写入 + 缓存读取)所在档位对整单计价', tierHintMarginal: '仅超过阈值的部分按该档计价,输出不加价', + maxReasoningMultiplierBadge: 'Max ×{multiplier}', + maxReasoningMultiplierHint: '最终转发的推理强度为 max 时,整次请求的计费与额度消耗乘以 {multiplier}', marginalBadge: '超出部分计价', timePricingRowHint: '按 {timezone} 时间,在该时段内发起的请求按本行价格计费', timePricingRowHintWeekdays: diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 1c2fcc6f18..8334b5a809 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -560,7 +560,7 @@ export interface Group { platform: GroupPlatform rate_multiplier: number rpm_limit?: number // Group-level RPM cap (0 = unlimited); overrides user-level rpm_limit when set - max_reasoning_effort?: string // OpenAI/Codex reasoning ceiling; empty means unlimited + max_reasoning_effort?: string // Anthropic/OpenAI reasoning ceiling; empty means unlimited max_reasoning_effort_over_limit?: string // downgrade (default) or deny when over the ceiling reasoning_effort_mappings?: ReasoningEffortMapping[] is_exclusive: boolean diff --git a/frontend/src/views/admin/ChannelsView.vue b/frontend/src/views/admin/ChannelsView.vue index f040d2e64a..6d1754dfe2 100644 --- a/frontend/src/views/admin/ChannelsView.vue +++ b/frontend/src/views/admin/ChannelsView.vue @@ -865,6 +865,7 @@ function addPricingEntry(sectionIdx: number) { cache_read_price: null, fast_multiplier: null, flex_multiplier: null, + max_reasoning_effort_multiplier: null, image_input_price: null, image_output_price: null, per_request_price: null, @@ -902,6 +903,7 @@ async function syncLatestModels(sectionIdx: number) { cache_read_price: null, fast_multiplier: null, flex_multiplier: null, + max_reasoning_effort_multiplier: null, image_input_price: null, image_output_price: null, per_request_price: null, @@ -1132,6 +1134,7 @@ function formToAPI(): { group_ids: number[], model_pricing: ChannelModelPricing[ cache_read_price: mTokToPerToken(entry.cache_read_price), fast_multiplier: entry.fast_multiplier != null && entry.fast_multiplier !== '' ? Number(entry.fast_multiplier) : null, flex_multiplier: entry.flex_multiplier != null && entry.flex_multiplier !== '' ? Number(entry.flex_multiplier) : null, + max_reasoning_effort_multiplier: entry.max_reasoning_effort_multiplier != null && entry.max_reasoning_effort_multiplier !== '' ? Number(entry.max_reasoning_effort_multiplier) : null, image_input_price: mTokToPerToken(entry.image_input_price), image_output_price: mTokToPerToken(entry.image_output_price), per_request_price: entry.per_request_price != null && entry.per_request_price !== '' ? Number(entry.per_request_price) : null, @@ -1234,6 +1237,7 @@ function apiToForm(channel: Channel): PlatformSection[] { cache_read_price: perTokenToMTok(p.cache_read_price), fast_multiplier: p.fast_multiplier, flex_multiplier: p.flex_multiplier, + max_reasoning_effort_multiplier: p.max_reasoning_effort_multiplier, image_input_price: perTokenToMTok(p.image_input_price), image_output_price: perTokenToMTok(p.image_output_price), per_request_price: p.per_request_price, @@ -1541,7 +1545,8 @@ async function handleSubmit() { for (const section of form.platforms.filter(s => s.enabled)) { for (const entry of section.model_pricing) { if (!isValidPositiveMultiplier(entry.fast_multiplier) || - !isValidPositiveMultiplier(entry.flex_multiplier)) { + !isValidPositiveMultiplier(entry.flex_multiplier) || + !isValidPositiveMultiplier(entry.max_reasoning_effort_multiplier)) { const platformLabel = t('admin.groups.platforms.' + section.platform, section.platform) const modelLabel = entry.models.join(', ') || t('admin.channels.form.unnamed') appStore.showError(`${platformLabel} - ${modelLabel}: ${t('admin.channels.form.multiplierPositive')}`) diff --git a/frontend/src/views/admin/__tests__/groupsReasoningEffort.spec.ts b/frontend/src/views/admin/__tests__/groupsReasoningEffort.spec.ts index 663d7cefd3..a9aec9ebd4 100644 --- a/frontend/src/views/admin/__tests__/groupsReasoningEffort.spec.ts +++ b/frontend/src/views/admin/__tests__/groupsReasoningEffort.spec.ts @@ -33,12 +33,14 @@ describe("groupsReasoningEffort", () => { ).toEqual(expected); expect(supportsReasoningEffortPolicyPlatform(platform)).toBe(true); } - for (const platform of [ - "anthropic", - "gemini", - "antigravity", - "grok", - ] as const) { + expect( + reasoningEffortOptionsForPlatform("anthropic").map( + (option) => option.value, + ), + ).toEqual(["low", "medium", "high", "xhigh", "max"]); + expect(supportsReasoningEffortPolicyPlatform("anthropic")).toBe(true); + + for (const platform of ["gemini", "antigravity", "grok"] as const) { expect(reasoningEffortOptionsForPlatform(platform)).toEqual([]); expect(supportsReasoningEffortPolicyPlatform(platform)).toBe(false); } @@ -129,6 +131,12 @@ describe("groupsReasoningEffort", () => { expect(normalizeReasoningEffortForPlatform("composite", " MAX ")).toBe( "max", ); + expect(normalizeReasoningEffortForPlatform("anthropic", "xhigh")).toBe( + "xhigh", + ); + expect(normalizeReasoningEffortForPlatform("anthropic", "minimal")).toBe( + "", + ); expect(normalizeReasoningEffortForPlatform("grok", "max")).toBe(""); expect(normalizeReasoningEffortForPlatform("openai", "none")).toBe(""); }); diff --git a/frontend/src/views/admin/groupsReasoningEffort.ts b/frontend/src/views/admin/groupsReasoningEffort.ts index de2fbbee4b..d3f4544d4e 100644 --- a/frontend/src/views/admin/groupsReasoningEffort.ts +++ b/frontend/src/views/admin/groupsReasoningEffort.ts @@ -13,6 +13,14 @@ const openAIReasoningEffortValues = [ "max", ] as const; +const anthropicReasoningEffortValues = [ + "low", + "medium", + "high", + "xhigh", + "max", +] as const; + const reasoningEffortMatchTypes: readonly ReasoningEffortMatchType[] = [ "exact", "prefix", @@ -25,14 +33,16 @@ export const reasoningEffortOverLimitDeny = "deny"; const reasoningEffortValuesForPlatform = ( platform: GroupPlatform, ): readonly string[] => - supportsReasoningEffortPolicyPlatform(platform) - ? openAIReasoningEffortValues - : []; + platform === "anthropic" + ? anthropicReasoningEffortValues + : supportsReasoningEffortPolicyPlatform(platform) + ? openAIReasoningEffortValues + : []; export function supportsReasoningEffortPolicyPlatform( platform: GroupPlatform, ): boolean { - return platform === "openai" || platform === "composite"; + return platform === "anthropic" || platform === "openai" || platform === "composite"; } export function reasoningEffortOptionsForPlatform(platform: GroupPlatform) { From 10c8f523c49663d6061eabc1cdce13a2a975063f Mon Sep 17 00:00:00 2001 From: wucm667 Date: Thu, 3 Sep 2026 09:42:50 +0800 Subject: [PATCH 021/145] fix(openai): preserve passthrough reasoning effort --- .../service/openai_gateway_request_body.go | 3 +++ ...penai_gateway_request_body_reasoning_test.go | 17 +++++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/backend/internal/service/openai_gateway_request_body.go b/backend/internal/service/openai_gateway_request_body.go index 46f69b54d6..9dd1654727 100644 --- a/backend/internal/service/openai_gateway_request_body.go +++ b/backend/internal/service/openai_gateway_request_body.go @@ -59,6 +59,9 @@ func shouldPreserveOpenAIResponsesNoneReasoningEffort(account *Account) bool { if account == nil { return false } + if account.IsOpenAIPassthroughEnabled() { + return true + } if account.IsOpenAIOAuthLike() { return true } diff --git a/backend/internal/service/openai_gateway_request_body_reasoning_test.go b/backend/internal/service/openai_gateway_request_body_reasoning_test.go index 4805eeeb24..013aaa2531 100644 --- a/backend/internal/service/openai_gateway_request_body_reasoning_test.go +++ b/backend/internal/service/openai_gateway_request_body_reasoning_test.go @@ -331,6 +331,23 @@ func TestFilterOpenAIResponsesNoneReasoningEffortForAccount(t *testing.T) { } } +func TestFilterOpenAIResponsesNoneReasoningEffortForAccount_APIKeyAutomaticPassthroughPreservesRequest(t *testing.T) { + body := []byte(`{"model":"qwen3.8-27b","input":"hi","max_output_tokens":20,"reasoning":{"effort":"none"},"presence_penalty":1.5}`) + account := &Account{ + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Credentials: map[string]any{ + "base_url": "https://compat.example/v1", + }, + Extra: map[string]any{"openai_passthrough": true}, + } + + got, err := filterOpenAIResponsesNoneReasoningEffortForAccount(account, body) + + require.NoError(t, err) + require.JSONEq(t, string(body), string(got)) +} + // Lite 工具迁移到 input[].additional_tools 后,仍应按有工具请求处理。 func TestNormalizeOpenAIParallelToolCallsWithoutTools_KeepsResponsesLiteAdditionalTools(t *testing.T) { liteBody := []byte(`{"input":[{"type":"message","role":"user","content":"hi"},{"type":"additional_tools","tools":[{"type":"function","name":"spawn_agent"}]}],"parallel_tool_calls":false}`) From b6740c937f007a0e103d182bc6197c31e3c3a14f Mon Sep 17 00:00:00 2001 From: wucm667 Date: Thu, 3 Sep 2026 10:26:12 +0800 Subject: [PATCH 022/145] fix(openai): recover unavailable continuation responses --- .../service/openai_compat_model_test.go | 53 +++++++++++++++++-- .../service/openai_messages_continuation.go | 1 + 2 files changed, 49 insertions(+), 5 deletions(-) diff --git a/backend/internal/service/openai_compat_model_test.go b/backend/internal/service/openai_compat_model_test.go index b52a616f85..11383d6a61 100644 --- a/backend/internal/service/openai_compat_model_test.go +++ b/backend/internal/service/openai_compat_model_test.go @@ -753,7 +753,7 @@ func TestForwardAsAnthropic_PreviousResponseIDKeepsMultiToolCallContext(t *testi require.Equal(t, "continue", gjson.GetBytes(upstream.lastBody, "input.5.content.0.text").String()) } -func TestForwardAsAnthropic_ReplaysWithoutContinuationWhenPreviousResponseMissing(t *testing.T) { +func TestForwardAsAnthropic_ReplaysFullToolHistoryWhenPreviousResponseUnavailable(t *testing.T) { t.Parallel() gin.SetMode(gin.TestMode) @@ -775,12 +775,12 @@ func TestForwardAsAnthropic_ReplaysWithoutContinuationWhenPreviousResponseMissin } svc.bindOpenAICompatSessionResponseID(context.Background(), nil, account, "stable-cache-key", "resp_missing") - secondBody := []byte(`{"model":"claude-sonnet-4-5","max_tokens":16,"messages":[{"role":"user","content":"first"},{"role":"assistant","content":"ok"},{"role":"user","content":"second"}],"stream":false}`) + secondBody := []byte(`{"model":"claude-sonnet-4-5","max_tokens":16,"messages":[{"role":"user","content":"first"},{"role":"assistant","content":[{"type":"tool_use","id":"call_1","name":"lookup","input":{"q":"first"}}]},{"role":"user","content":[{"type":"tool_result","tool_use_id":"call_1","content":"found"},{"type":"text","text":"second"}]}],"tools":[{"name":"lookup","input_schema":{"type":"object"}}],"stream":false}`) upstream.responses = []*http.Response{ { StatusCode: http.StatusBadRequest, Header: http.Header{"Content-Type": []string{"application/json"}, "x-request-id": []string{"rid_prev_missing"}}, - Body: io.NopCloser(strings.NewReader(`{"error":{"code":"previous_response_not_found","message":"previous response not found"}}`)), + Body: io.NopCloser(strings.NewReader(`{"error":{"message":"previous_response_id is not available for this user","type":"invalid_request_error"}}`)), }, openAICompatSSECompletedResponse("resp_replayed", "gpt-5.3-codex"), } @@ -797,11 +797,54 @@ func TestForwardAsAnthropic_ReplaysWithoutContinuationWhenPreviousResponseMissin require.Len(t, upstream.requests, 2) require.Equal(t, "resp_missing", gjson.GetBytes(upstream.bodies[0], "previous_response_id").String()) require.False(t, gjson.GetBytes(upstream.bodies[1], "previous_response_id").Exists()) - require.Equal(t, int64(4), gjson.GetBytes(upstream.bodies[1], "input.#").Int()) + require.Equal(t, int64(5), gjson.GetBytes(upstream.bodies[1], "input.#").Int()) require.Equal(t, "developer", gjson.GetBytes(upstream.bodies[1], "input.0.role").String()) require.Contains(t, gjson.GetBytes(upstream.bodies[1], "input.0.content.0.text").String(), "") require.Equal(t, "first", gjson.GetBytes(upstream.bodies[1], "input.1.content.0.text").String()) - require.Equal(t, "second", gjson.GetBytes(upstream.bodies[1], "input.3.content.0.text").String()) + require.Equal(t, "function_call", gjson.GetBytes(upstream.bodies[1], "input.2.type").String()) + require.Equal(t, "call_1", gjson.GetBytes(upstream.bodies[1], "input.2.call_id").String()) + require.Equal(t, "function_call_output", gjson.GetBytes(upstream.bodies[1], "input.3.type").String()) + require.Equal(t, "call_1", gjson.GetBytes(upstream.bodies[1], "input.3.call_id").String()) + require.Equal(t, "found", gjson.GetBytes(upstream.bodies[1], "input.3.output").String()) + require.Equal(t, "second", gjson.GetBytes(upstream.bodies[1], "input.4.content.0.text").String()) +} + +func TestOpenAICompatPreviousResponseUnavailableRecognitionIsStrict(t *testing.T) { + t.Parallel() + body := []byte(`{"error":{"message":"previous_response_id is not available for this user","type":"invalid_request_error"}}`) + require.True(t, isOpenAICompatPreviousResponseNotFound(http.StatusBadRequest, "", body)) + require.False(t, isOpenAICompatPreviousResponseNotFound(http.StatusForbidden, "", body)) + require.False(t, isOpenAICompatPreviousResponseNotFound(http.StatusBadRequest, "permission is not available for this user", nil)) + require.False(t, isOpenAICompatPreviousResponseNotFound(http.StatusBadRequest, "previous_response_id is not available for this project", nil)) +} + +func TestForwardAsAnthropic_PreviousResponseUnavailableRetryFailureDoesNotLoop(t *testing.T) { + t.Parallel() + gin.SetMode(gin.TestMode) + + unavailable := func() *http.Response { + return &http.Response{ + StatusCode: http.StatusBadRequest, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"error":{"message":"previous_response_id is not available for this user","type":"invalid_request_error"}}`)), + } + } + upstream := &httpUpstreamRecorder{responses: []*http.Response{unavailable(), unavailable()}} + svc := &OpenAIGatewayService{ + httpUpstream: upstream, + cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}}, + } + account := &Account{ID: 1, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Concurrency: 1, Credentials: map[string]any{"api_key": "sk-test", "base_url": "https://api.openai.com/v1"}} + svc.bindOpenAICompatSessionResponseID(context.Background(), nil, account, "stable-cache-key", "resp_missing") + body := []byte(`{"model":"claude-sonnet-4-5","max_tokens":16,"messages":[{"role":"user","content":"hello"}],"stream":false}`) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body)) + + _, _ = svc.ForwardAsAnthropic(context.Background(), c, account, body, "stable-cache-key", "gpt-5.3-codex") + require.Len(t, upstream.requests, 2) + require.True(t, gjson.GetBytes(upstream.bodies[0], "previous_response_id").Exists()) + require.False(t, gjson.GetBytes(upstream.bodies[1], "previous_response_id").Exists()) } func TestForwardAsAnthropic_DisablesAPIKeyContinuationWhenUpstreamRequiresWebSocketV2(t *testing.T) { diff --git a/backend/internal/service/openai_messages_continuation.go b/backend/internal/service/openai_messages_continuation.go index b3c900fbc6..9beed51627 100644 --- a/backend/internal/service/openai_messages_continuation.go +++ b/backend/internal/service/openai_messages_continuation.go @@ -111,6 +111,7 @@ func isOpenAICompatPreviousResponseNotFound(statusCode int, upstreamMsg string, check := func(s string) bool { lower := strings.ToLower(strings.TrimSpace(s)) return strings.Contains(lower, "previous_response_not_found") || + lower == "previous_response_id is not available for this user" || (strings.Contains(lower, "previous response") && strings.Contains(lower, "not found")) || (strings.Contains(lower, "unsupported parameter") && strings.Contains(lower, "previous_response_id")) } From fdc2ec17e6776bef109c5ea614af28218e56299b Mon Sep 17 00:00:00 2001 From: feeeei Date: Thu, 3 Sep 2026 10:33:34 +0800 Subject: [PATCH 023/145] =?UTF-8?q?feat(pricing):=20fallback/override=20?= =?UTF-8?q?=E5=AE=9A=E4=BB=B7=E6=96=87=E4=BB=B6=E6=8C=89=E5=86=85=E5=AE=B9?= =?UTF-8?q?=E5=93=88=E5=B8=8C=E9=9A=8F=E5=AE=9A=E6=97=B6=E5=99=A8=E7=83=AD?= =?UTF-8?q?=E9=87=8D=E8=BD=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PricingService 的定时器(hash_check_interval_minutes,默认 10 分钟)每轮先做 远程目录哈希同步,再比对 fallback_file/override_file 两个文件内容的联合 sha256 (customFilesHash),变化时从本地 model_pricing.json 重建叠加层,只替换内存数据 与指纹,不改远程同步锚点 localHash。只配置了自定义文件而 remote_url 为空时 调度器同样启动。 - 三条重建路径(启动加载、远程下载、热重载)统一走 buildPricingData,指纹在合并 读取之前采样,并发改文件只会落后于数据、下一轮补重建 - 删除文件视为该层清空,下一轮生效;文件存在但不可读或不是 JSON 对象时保留旧数据、 不更新指纹,每轮重试并告警 - config.example.yaml 同步生效说明;新增 pricing_service_hot_reload_test.go --- backend/internal/service/pricing_service.go | 149 ++++++++++-- .../pricing_service_hot_reload_test.go | 217 ++++++++++++++++++ deploy/config.example.yaml | 19 +- 3 files changed, 364 insertions(+), 21 deletions(-) create mode 100644 backend/internal/service/pricing_service_hot_reload_test.go diff --git a/backend/internal/service/pricing_service.go b/backend/internal/service/pricing_service.go index 826f6a7803..63677df838 100644 --- a/backend/internal/service/pricing_service.go +++ b/backend/internal/service/pricing_service.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "crypto/sha256" + "encoding/binary" "encoding/hex" "encoding/json" "fmt" @@ -170,6 +171,9 @@ type PricingService struct { pricingData map[string]*LiteLLMModelPricing lastUpdated time.Time localHash string + // fallback/override 文件在最近一次成功重建时的内容指纹,定时器据此判断是否 + // 需要从本地目录缓存重建叠加层。 + customFilesHash string // 停止信号 stopCh chan struct{} @@ -216,14 +220,21 @@ func (s *PricingService) Stop() { logger.LegacyPrintf("service.pricing", "%s", "[Pricing] Service stopped") } -// startUpdateScheduler 启动定时更新调度器 +// startUpdateScheduler 启动定时调度器:每个周期先做远程目录哈希同步(配置了 remote_url 时), +// 再比对 fallback/override 文件指纹做本地热重载(配置了任一文件时)。两者都未配置则不启动。 func (s *PricingService) startUpdateScheduler() { - if s == nil || s.cfg == nil || strings.TrimSpace(s.cfg.Pricing.RemoteURL) == "" { + if s == nil || s.cfg == nil { + return + } + remoteEnabled := strings.TrimSpace(s.cfg.Pricing.RemoteURL) != "" + watchCustom := s.hasCustomPricingFiles() + if !remoteEnabled { logger.LegacyPrintf("service.pricing", "%s", "[Pricing] Remote sync disabled: pricing remote URL is empty") + } + if !remoteEnabled && !watchCustom { return } - // 定期检查哈希更新 hashInterval := time.Duration(s.cfg.Pricing.HashCheckIntervalMinutes) * time.Minute if hashInterval < time.Minute { hashInterval = 10 * time.Minute @@ -238,8 +249,13 @@ func (s *PricingService) startUpdateScheduler() { for { select { case <-ticker.C: - if err := s.syncWithRemote(); err != nil { - logger.LegacyPrintf("service.pricing", "[Pricing] Sync failed: %v", err) + if remoteEnabled { + if err := s.syncWithRemote(); err != nil { + logger.LegacyPrintf("service.pricing", "[Pricing] Sync failed: %v", err) + } + } + if watchCustom { + s.reloadIfCustomFilesChanged() } case <-s.stopCh: return @@ -247,7 +263,7 @@ func (s *PricingService) startUpdateScheduler() { } }() - logger.LegacyPrintf("service.pricing", "[Pricing] Update scheduler started (check every %v)", hashInterval) + logger.LegacyPrintf("service.pricing", "[Pricing] Update scheduler started (check every %v, remote sync=%t, custom file watch=%t)", hashInterval, remoteEnabled, watchCustom) } // checkAndUpdatePricing 检查并更新价格数据 @@ -348,6 +364,101 @@ func (s *PricingService) syncWithRemote() error { return nil } +// hasCustomPricingFiles 报告是否配置了 fallback/override 任一文件路径(不要求文件存在)。 +func (s *PricingService) hasCustomPricingFiles() bool { + if s == nil || s.cfg == nil { + return false + } + return strings.TrimSpace(s.cfg.Pricing.FallbackFile) != "" || strings.TrimSpace(s.cfg.Pricing.OverrideFile) != "" +} + +// customPricingFilesFingerprint 返回 fallback、override 两个文件当前内容的联合 sha256。 +// 每个文件以"长度前缀 + 正文"参与计算,不可读的文件按空正文处理;未配置任何文件返回空串。 +func (s *PricingService) customPricingFilesFingerprint() string { + if !s.hasCustomPricingFiles() { + return "" + } + h := sha256.New() + for _, path := range []string{s.cfg.Pricing.FallbackFile, s.cfg.Pricing.OverrideFile} { + var body []byte + if p := strings.TrimSpace(path); p != "" { + body, _ = os.ReadFile(p) + } + var size [8]byte + binary.BigEndian.PutUint64(size[:], uint64(len(body))) + _, _ = h.Write(size[:]) + _, _ = h.Write(body) + } + return hex.EncodeToString(h.Sum(nil)) +} + +// validateCustomPricingFiles 要求每个已配置且存在的 fallback/override 文件可读且为 JSON +// 对象,任一不满足即返回带路径的错误;文件不存在视为该层为空,属合法状态。 +func (s *PricingService) validateCustomPricingFiles() error { + for _, path := range []string{s.cfg.Pricing.FallbackFile, s.cfg.Pricing.OverrideFile} { + p := strings.TrimSpace(path) + if p == "" { + continue + } + body, err := os.ReadFile(p) + if os.IsNotExist(err) { + continue + } + if err != nil { + return err + } + var entries map[string]json.RawMessage + if err := json.Unmarshal(body, &entries); err != nil { + return fmt.Errorf("%s: %w", p, err) + } + } + return nil +} + +// reloadIfCustomFilesChanged 比对 fallback/override 文件指纹,与最近一次重建时不同则从 +// 本地目录缓存重建内存数据。文件被删除视为该层清空,照常重建;文件存在但不可读或不是 +// JSON 对象时保留当前数据且不更新指纹,下一轮会再次尝试并重复告警。目录正文与远程同步 +// 锚点(localHash)不受本路径影响。 +func (s *PricingService) reloadIfCustomFilesChanged() { + fingerprint := s.customPricingFilesFingerprint() + s.mu.RLock() + unchanged := fingerprint == s.customFilesHash + s.mu.RUnlock() + if unchanged { + return + } + if err := s.validateCustomPricingFiles(); err != nil { + logger.LegacyPrintf("service.pricing", "[Pricing] Custom pricing file changed but previous data kept: %v", err) + return + } + if err := s.reloadCustomPricingLayers(); err != nil { + logger.LegacyPrintf("service.pricing", "[Pricing] Custom pricing file changed but reload failed: %v", err) + } +} + +// reloadCustomPricingLayers 读取本地目录缓存并重新叠加 fallback/override,只替换内存数据 +// 与叠加层指纹。 +func (s *PricingService) reloadCustomPricingLayers() error { + pricingFile := s.getPricingFilePath() + body, err := os.ReadFile(pricingFile) + if err != nil { + return fmt.Errorf("read file failed: %w", err) + } + data, fingerprint, err := s.buildPricingData(body) + if err != nil { + return fmt.Errorf("parse pricing data: %w", err) + } + + s.mu.Lock() + warnDroppedLongContextLadders(s.pricingData, data) + s.pricingData = data + s.customFilesHash = fingerprint + s.mu.Unlock() + + logger.LegacyPrintf("service.pricing", "[Pricing] Custom pricing files changed, reloaded %d models from %s", len(data), pricingFile) + return nil +} + // downloadPricingData 从远程下载价格数据 func (s *PricingService) downloadPricingData() error { remoteURL, err := s.validatePricingURL(s.cfg.Pricing.RemoteURL) @@ -382,13 +493,10 @@ func (s *PricingService) downloadPricingData() error { remoteHash[:min(8, len(remoteHash))], dataHashStr[:8]) } - // 解析JSON数据(使用灵活的解析方式) - data, err := s.parsePricingData(body) + data, customFilesHash, err := s.buildPricingData(body) if err != nil { return fmt.Errorf("parse pricing data: %w", err) } - data = s.mergeFallbackPricingData(data) - data = s.mergeOverrideOnlyModels(data) // 保存到本地文件 pricingFile := s.getPricingFilePath() @@ -413,6 +521,7 @@ func (s *PricingService) downloadPricingData() error { s.pricingData = data s.lastUpdated = time.Now() s.localHash = syncHash + s.customFilesHash = customFilesHash s.mu.Unlock() logger.LegacyPrintf("service.pricing", "[Pricing] Downloaded %d models successfully", len(data)) @@ -798,6 +907,20 @@ func (s *PricingService) mergeOverrideOnlyModels(data map[string]*LiteLLMModelPr return data } +// buildPricingData 解析目录正文并依次叠加 fallback、override 两层,返回合并结果与 +// 叠加层文件指纹。指纹在合并读取之前采样:并发改文件只会让存下的指纹落后于实际 +// 合并的数据、不会领先,下一轮定时比对因此会再次重建。 +func (s *PricingService) buildPricingData(body []byte) (map[string]*LiteLLMModelPricing, string, error) { + fingerprint := s.customPricingFilesFingerprint() + data, err := s.parsePricingData(body) + if err != nil { + return nil, "", err + } + data = s.mergeFallbackPricingData(data) + data = s.mergeOverrideOnlyModels(data) + return data, fingerprint, nil +} + // loadPricingData 从本地文件加载价格数据 func (s *PricingService) loadPricingData(filePath string) error { data, err := os.ReadFile(filePath) @@ -805,13 +928,10 @@ func (s *PricingService) loadPricingData(filePath string) error { return fmt.Errorf("read file failed: %w", err) } - // 使用灵活的解析方式 - pricingData, err := s.parsePricingData(data) + pricingData, customFilesHash, err := s.buildPricingData(data) if err != nil { return fmt.Errorf("parse pricing data: %w", err) } - pricingData = s.mergeFallbackPricingData(pricingData) - pricingData = s.mergeOverrideOnlyModels(pricingData) // 计算哈希 hash := sha256.Sum256(data) @@ -821,6 +941,7 @@ func (s *PricingService) loadPricingData(filePath string) error { warnDroppedLongContextLadders(s.pricingData, pricingData) s.pricingData = pricingData s.localHash = hashStr + s.customFilesHash = customFilesHash info, _ := os.Stat(filePath) if info != nil { diff --git a/backend/internal/service/pricing_service_hot_reload_test.go b/backend/internal/service/pricing_service_hot_reload_test.go new file mode 100644 index 0000000000..31a50ac145 --- /dev/null +++ b/backend/internal/service/pricing_service_hot_reload_test.go @@ -0,0 +1,217 @@ +package service + +import ( + "context" + "os" + "path/filepath" + "strconv" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/stretchr/testify/require" +) + +const hotReloadCatalogJSON = `{ + "remote-model": {"litellm_provider": "test", "mode": "chat", + "input_cost_per_token": 1e-06, "output_cost_per_token": 2e-06} +}` + +func hotReloadModelJSON(name string, input, output float64) string { + return `"` + name + `": {"litellm_provider": "test", "mode": "chat", + "input_cost_per_token": ` + formatFloat(input) + `, "output_cost_per_token": ` + formatFloat(output) + `}` +} + +func formatFloat(v float64) string { + return strconv.FormatFloat(v, 'g', -1, 64) +} + +// newHotReloadPricingService 在数据目录里放好目录缓存与 fallback/override 文件并完成首次加载。 +// 传空串表示不配置对应文件。 +func newHotReloadPricingService(t *testing.T, fallbackJSON, overrideJSON string) *PricingService { + t.Helper() + dir := t.TempDir() + svc := &PricingService{cfg: &config.Config{}} + svc.cfg.Pricing.DataDir = dir + require.NoError(t, os.WriteFile(svc.getPricingFilePath(), []byte(hotReloadCatalogJSON), 0644)) + if fallbackJSON != "" { + svc.cfg.Pricing.FallbackFile = filepath.Join(dir, "fallback.json") + require.NoError(t, os.WriteFile(svc.cfg.Pricing.FallbackFile, []byte(fallbackJSON), 0644)) + } + if overrideJSON != "" { + svc.cfg.Pricing.OverrideFile = filepath.Join(dir, "overrides.json") + require.NoError(t, os.WriteFile(svc.cfg.Pricing.OverrideFile, []byte(overrideJSON), 0644)) + } + require.NoError(t, svc.loadPricingData(svc.getPricingFilePath())) + return svc +} + +func TestPricingCustomFilesFingerprint(t *testing.T) { + svc := &PricingService{cfg: &config.Config{}} + require.Empty(t, svc.customPricingFilesFingerprint(), "未配置文件返回空串") + + dir := t.TempDir() + svc.cfg.Pricing.FallbackFile = filepath.Join(dir, "fallback.json") + missing := svc.customPricingFilesFingerprint() + require.NotEmpty(t, missing) + require.Equal(t, missing, svc.customPricingFilesFingerprint(), "同一状态下指纹稳定") + + require.NoError(t, os.WriteFile(svc.cfg.Pricing.FallbackFile, []byte(`{}`), 0644)) + present := svc.customPricingFilesFingerprint() + require.NotEqual(t, missing, present, "文件出现即视为变化") + + svc.cfg.Pricing.OverrideFile = filepath.Join(dir, "overrides.json") + require.NoError(t, os.WriteFile(svc.cfg.Pricing.OverrideFile, []byte(`{}`), 0644)) + require.NotEqual(t, present, svc.customPricingFilesFingerprint(), "override 内容参与指纹") +} + +func TestPricingHotReload_FallbackChangeRebuildsWithoutTouchingSyncAnchor(t *testing.T) { + svc := newHotReloadPricingService(t, `{`+hotReloadModelJSON("custom-a", 4e-6, 8e-6)+`}`, "") + require.InDelta(t, 4e-6, svc.pricingData["custom-a"].InputCostPerToken, 1e-12) + require.Nil(t, svc.pricingData["custom-b"]) + require.NotEmpty(t, svc.customFilesHash) + anchor, updated := svc.localHash, svc.lastUpdated + + require.NoError(t, os.WriteFile(svc.cfg.Pricing.FallbackFile, []byte(`{`+ + hotReloadModelJSON("custom-a", 5e-6, 8e-6)+`,`+ + hotReloadModelJSON("custom-b", 1e-6, 3e-6)+`}`), 0644)) + svc.reloadIfCustomFilesChanged() + + require.InDelta(t, 5e-6, svc.pricingData["custom-a"].InputCostPerToken, 1e-12, "改价即时生效") + require.NotNil(t, svc.pricingData["custom-b"], "新模型即时并入") + require.InDelta(t, 1e-6, svc.pricingData["remote-model"].InputCostPerToken, 1e-12, "目录条目不受影响") + require.Equal(t, anchor, svc.localHash, "热重载不得改动远程同步锚点") + require.Equal(t, updated, svc.lastUpdated) + require.Equal(t, svc.customPricingFilesFingerprint(), svc.customFilesHash) +} + +func TestPricingHotReload_UnchangedFilesSkipRebuild(t *testing.T) { + svc := newHotReloadPricingService(t, + `{`+hotReloadModelJSON("custom-a", 4e-6, 8e-6)+`}`, + `{"remote-model": {"input_cost_per_token": 7e-06}}`) + svc.pricingData["sentinel"] = &LiteLLMModelPricing{} + + svc.reloadIfCustomFilesChanged() + + require.Contains(t, svc.pricingData, "sentinel", "指纹未变时不得重建") +} + +func TestPricingHotReload_OverrideChangePatchesCatalogAndAddsModels(t *testing.T) { + svc := newHotReloadPricingService(t, "", `{"remote-model": {"input_cost_per_token": 7e-06}}`) + require.InDelta(t, 7e-6, svc.pricingData["remote-model"].InputCostPerToken, 1e-12) + + require.NoError(t, os.WriteFile(svc.cfg.Pricing.OverrideFile, []byte(`{ + "remote-model": {"input_cost_per_token": 9e-06}, + `+hotReloadModelJSON("override-new-model", 5e-6, 1e-5)+`}`), 0644)) + svc.reloadIfCustomFilesChanged() + + require.InDelta(t, 9e-6, svc.pricingData["remote-model"].InputCostPerToken, 1e-12) + require.InDelta(t, 2e-6, svc.pricingData["remote-model"].OutputCostPerToken, 1e-12, "未覆盖字段保持目录值") + require.NotNil(t, svc.pricingData["override-new-model"]) + + // 清空补丁:目录条目回到原价,补丁新增的模型随之消失。 + require.NoError(t, os.WriteFile(svc.cfg.Pricing.OverrideFile, []byte(`{}`), 0644)) + svc.reloadIfCustomFilesChanged() + + require.InDelta(t, 1e-6, svc.pricingData["remote-model"].InputCostPerToken, 1e-12) + require.Nil(t, svc.pricingData["override-new-model"]) +} + +func TestPricingHotReload_InvalidFileKeepsCurrentDataUntilFixed(t *testing.T) { + svc := newHotReloadPricingService(t, `{`+hotReloadModelJSON("custom-a", 4e-6, 8e-6)+`}`, "") + before := svc.customFilesHash + + require.NoError(t, os.WriteFile(svc.cfg.Pricing.FallbackFile, []byte(`{"custom-a": {"input_cost_per_token": `), 0644)) + svc.reloadIfCustomFilesChanged() + require.InDelta(t, 4e-6, svc.pricingData["custom-a"].InputCostPerToken, 1e-12, "半写文件不得替换数据") + require.Equal(t, before, svc.customFilesHash, "指纹不更新,下一轮继续尝试") + + require.NoError(t, os.WriteFile(svc.cfg.Pricing.FallbackFile, []byte(`{`+hotReloadModelJSON("custom-a", 6e-6, 8e-6)+`}`), 0644)) + svc.reloadIfCustomFilesChanged() + require.InDelta(t, 6e-6, svc.pricingData["custom-a"].InputCostPerToken, 1e-12, "文件修好后正常重建") + require.NotEqual(t, before, svc.customFilesHash) +} + +// 删除文件等于清空该层:override 补丁撤销、fallback 独有模型消失,都在下一轮比对时生效, +// 且缺失状态被记录,之后不会每轮重建。 +func TestPricingHotReload_DeletedFileDropsItsLayer(t *testing.T) { + svc := newHotReloadPricingService(t, + `{`+hotReloadModelJSON("custom-a", 4e-6, 8e-6)+`}`, + `{"remote-model": {"input_cost_per_token": 7e-06}}`) + require.NotNil(t, svc.pricingData["custom-a"]) + require.InDelta(t, 7e-6, svc.pricingData["remote-model"].InputCostPerToken, 1e-12) + + require.NoError(t, os.Remove(svc.cfg.Pricing.OverrideFile)) + svc.reloadIfCustomFilesChanged() + require.InDelta(t, 1e-6, svc.pricingData["remote-model"].InputCostPerToken, 1e-12, "删除 override 后目录条目回到原价") + require.NotNil(t, svc.pricingData["custom-a"], "fallback 层不受影响") + + require.NoError(t, os.Remove(svc.cfg.Pricing.FallbackFile)) + svc.reloadIfCustomFilesChanged() + require.Nil(t, svc.pricingData["custom-a"], "删除 fallback 后其独有模型消失") + require.InDelta(t, 1e-6, svc.pricingData["remote-model"].InputCostPerToken, 1e-12, "目录条目不受影响") + + svc.pricingData["sentinel"] = &LiteLLMModelPricing{} + svc.reloadIfCustomFilesChanged() + require.Contains(t, svc.pricingData, "sentinel", "缺失状态已记录,不得每轮重建") + + require.NoError(t, os.WriteFile(svc.cfg.Pricing.FallbackFile, []byte(`{`+hotReloadModelJSON("custom-a", 4e-6, 8e-6)+`}`), 0644)) + svc.reloadIfCustomFilesChanged() + require.NotNil(t, svc.pricingData["custom-a"], "文件重新出现即恢复") +} + +type stubPricingRemoteClient struct{ body string } + +func (c stubPricingRemoteClient) FetchPricingJSON(context.Context, string) ([]byte, error) { + return []byte(c.body), nil +} + +func (c stubPricingRemoteClient) FetchHashText(context.Context, string) (string, error) { + return "", nil +} + +// 远程下载重建后指纹必须同步到当前文件内容,否则下一轮定时比对会多做一次无意义重载。 +func TestPricingHotReload_DownloadRefreshesFingerprint(t *testing.T) { + svc := newHotReloadPricingService(t, `{`+hotReloadModelJSON("custom-a", 4e-6, 8e-6)+`}`, "") + svc.cfg.Pricing.RemoteURL = "https://example.com/pricing.json" + svc.remoteClient = stubPricingRemoteClient{body: hotReloadCatalogJSON} + require.NoError(t, os.WriteFile(svc.cfg.Pricing.FallbackFile, []byte(`{`+hotReloadModelJSON("custom-b", 1e-6, 3e-6)+`}`), 0644)) + + require.NoError(t, svc.downloadPricingData()) + + require.NotNil(t, svc.pricingData["custom-b"]) + require.Nil(t, svc.pricingData["custom-a"]) + require.Equal(t, svc.customPricingFilesFingerprint(), svc.customFilesHash) + + svc.pricingData["sentinel"] = &LiteLLMModelPricing{} + svc.reloadIfCustomFilesChanged() + require.Contains(t, svc.pricingData, "sentinel", "下载已消化文件变化,不得再次重建") +} + +// 只配置了 fallback/override 而没有 remote_url 时调度器也要运行,否则文件改动无人比对。 +func TestPricingSchedulerStartsForCustomFilesWithoutRemoteURL(t *testing.T) { + svc := NewPricingService(&config.Config{Pricing: config.PricingConfig{ + RemoteURL: "", + FallbackFile: filepath.Join(t.TempDir(), "fallback.json"), + }}, nil) + + svc.startUpdateScheduler() + done := make(chan struct{}) + go func() { + svc.wg.Wait() + close(done) + }() + + select { + case <-done: + t.Fatal("custom file watch must keep the scheduler running") + case <-time.After(50 * time.Millisecond): + } + + svc.Stop() + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("scheduler must exit after Stop") + } +} diff --git a/deploy/config.example.yaml b/deploy/config.example.yaml index c8798ee171..0ff4c9086e 100644 --- a/deploy/config.example.yaml +++ b/deploy/config.example.yaml @@ -1063,22 +1063,27 @@ pricing: # Local data directory for caching # 本地数据缓存目录 data_dir: "./data" - # Fallback pricing file - # 备用定价文件 + # Fallback pricing file: fills in models missing from the catalog. Edits are + # detected by content hash at the next hash check and reloaded without restart. + # 备用定价文件:只补目录缺失的模型。修改后在下一次哈希检查时按内容哈希自动重载,无需重启。 fallback_file: "./resources/model-pricing/model_prices_and_context_window.json" # Override patch file (optional, highest priority): entries are shallow-merged # field-by-field over catalog/fallback data; a null field value removes that field. - # Model names must match catalog keys exactly. Edits take effect on restart or - # the next catalog download. + # Model names must match catalog keys exactly. Edits are detected by content hash + # at the next hash check and reloaded without restart. # 覆盖补丁文件(可选,优先级最高):条目按字段浅合并覆盖目录/回退数据, # 字段值为 null 表示删除该字段。模型名须与目录键完全一致。 - # 修改后重启或下次目录下载时生效。 + # 修改后在下一次哈希检查时按内容哈希自动重载,无需重启。 # override_file: "./data/model_pricing_overrides.json" # Update interval in hours # 更新间隔(小时) update_interval_hours: 24 - # Hash check interval in minutes - # 哈希检查间隔(分钟) + # Hash check interval in minutes. Each check compares the remote catalog hash and + # the local fallback/override file contents. Deleting a local file drops its + # entries at the next check; a present but non-JSON file keeps the previous data + # and is retried next check. + # 哈希检查间隔(分钟)。每次检查同时比对远程目录哈希与本地 fallback/override 文件内容; + # 删除本地文件会在下次检查时移除其条目,文件存在但不是 JSON 时保留旧数据、下次检查再试。 hash_check_interval_minutes: 10 # ============================================================================= From 5e4958c889e0e8a31591a819eb046562807d8beb Mon Sep 17 00:00:00 2001 From: wucm667 Date: Thu, 3 Sep 2026 10:51:12 +0800 Subject: [PATCH 024/145] fix(openai): schedule accounts with mapped model --- .../handler/openai_chat_completions.go | 3 +- backend/internal/handler/openai_embeddings.go | 3 +- .../handler/openai_gateway_handler.go | 26 ++++++++----- .../handler/openai_gateway_handler_test.go | 38 ++++++++++++++++++- 4 files changed, 56 insertions(+), 14 deletions(-) diff --git a/backend/internal/handler/openai_chat_completions.go b/backend/internal/handler/openai_chat_completions.go index e74b11b9b6..147f4460a8 100644 --- a/backend/internal/handler/openai_chat_completions.go +++ b/backend/internal/handler/openai_chat_completions.go @@ -116,6 +116,7 @@ func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) { // 解析渠道级模型映射 channelMapping, _ := h.gatewayService.ResolveChannelMappingAndRestrict(c.Request.Context(), apiKey.GroupID, reqModel) + forwardModel := openAIChannelForwardModel(channelMapping, reqModel) if h.errorPassthroughService != nil { service.BindErrorPassthroughService(c, h.errorPassthroughService) @@ -170,7 +171,7 @@ func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) { apiKey.GroupID, "", sessionHash, - reqModel, + forwardModel, failedAccountIDs, service.OpenAIUpstreamTransportAny, service.OpenAIEndpointCapabilityChatCompletions, diff --git a/backend/internal/handler/openai_embeddings.go b/backend/internal/handler/openai_embeddings.go index 913131114e..8dda9faf3f 100644 --- a/backend/internal/handler/openai_embeddings.go +++ b/backend/internal/handler/openai_embeddings.go @@ -85,6 +85,7 @@ func (h *OpenAIGatewayHandler) Embeddings(c *gin.Context) { } channelMapping, _ := h.gatewayService.ResolveChannelMappingAndRestrict(c.Request.Context(), apiKey.GroupID, reqModel) + forwardModel := openAIChannelForwardModel(channelMapping, reqModel) subscription, _ := middleware2.GetSubscriptionFromContext(c) service.SetOpsLatencyMs(c, service.OpsAuthLatencyMsKey, time.Since(requestStart).Milliseconds()) @@ -127,7 +128,7 @@ func (h *OpenAIGatewayHandler) Embeddings(c *gin.Context) { apiKey.GroupID, "", "", - reqModel, + forwardModel, failedAccountIDs, service.OpenAIUpstreamTransportHTTPSSE, service.OpenAIEndpointCapabilityEmbeddings, diff --git a/backend/internal/handler/openai_gateway_handler.go b/backend/internal/handler/openai_gateway_handler.go index ce67e07e85..317b17fe1a 100644 --- a/backend/internal/handler/openai_gateway_handler.go +++ b/backend/internal/handler/openai_gateway_handler.go @@ -140,6 +140,18 @@ func openAIWSTurnBillingModel(result *service.OpenAIForwardResult, mapping servi return billingModel } +// openAIChannelForwardModel returns the model used for upstream capability +// routing. The client-requested model remains separate for logs, billing, and +// responses. +func openAIChannelForwardModel(mapping service.ChannelMappingResult, requestedModel string) string { + if mapping.Mapped { + if mappedModel := strings.TrimSpace(mapping.MappedModel); mappedModel != "" { + return mappedModel + } + } + return requestedModel +} + type grokMediaEligibilityProber interface { ProbeMediaEligibility(ctx context.Context, accountID int64) (bool, string, error) } @@ -533,10 +545,7 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) { channelMapping, _ := h.gatewayService.ResolveChannelMappingAndRestrict(c.Request.Context(), apiKey.GroupID, reqModel) forwardBody := openAIModelMappedBody(body, channelMapping.Mapped, channelMapping.MappedModel, h.gatewayService.ReplaceModelInBody) seedOpenAIForwardImageIntentHint(c, channelMapping.Mapped, imageIntent) - forwardModel := reqModel - if channelMapping.Mapped { - forwardModel = channelMapping.MappedModel - } + forwardModel := openAIChannelForwardModel(channelMapping, reqModel) c.Request = c.Request.WithContext(service.WithOpenAIForwardModel( c.Request.Context(), forwardModel, @@ -629,7 +638,7 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) { apiKey.GroupID, previousResponseID, sessionHash, - reqModel, + forwardModel, failedAccountIDs, service.OpenAIUpstreamTransportAny, requiredCapability, @@ -2327,10 +2336,7 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) { // 解析渠道级模型映射 channelMappingWS, _ := h.gatewayService.ResolveChannelMappingAndRestrict(ctx, apiKey.GroupID, reqModel) - wsForwardModel := reqModel - if channelMappingWS.Mapped && strings.TrimSpace(channelMappingWS.MappedModel) != "" { - wsForwardModel = strings.TrimSpace(channelMappingWS.MappedModel) - } + wsForwardModel := openAIChannelForwardModel(channelMappingWS, reqModel) var currentUserRelease func() var currentAccountRelease func() @@ -2493,7 +2499,7 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) { apiKey.GroupID, previousResponseID, sessionHash, - reqModel, + wsForwardModel, failedAccountIDs, requiredTransport, requiredCapability, diff --git a/backend/internal/handler/openai_gateway_handler_test.go b/backend/internal/handler/openai_gateway_handler_test.go index 23acd158b3..54747cfdbd 100644 --- a/backend/internal/handler/openai_gateway_handler_test.go +++ b/backend/internal/handler/openai_gateway_handler_test.go @@ -1530,7 +1530,7 @@ func TestOpenAIResponsesWebSocket_PassthroughTracksModelPerTurn(t *testing.T) { "each turn must be billed with its own channel-mapped model") } -func TestOpenAIResponsesWebSocket_UnchangedChannelTargetOutsideAccountMappingKeysRemainsValid(t *testing.T) { +func TestOpenAIResponsesWebSocket_ChannelMappedTargetSelectsAccountWithoutRequestedAlias(t *testing.T) { got := runOpenAIResponsesWebSocketUsageLogCase(t, openAIResponsesWSUsageLogCase{ firstPayload: `{"type":"response.create","model":"public-alias","stream":false}`, secondPayload: `{"type":"response.create","stream":false}`, @@ -1538,7 +1538,7 @@ func TestOpenAIResponsesWebSocket_UnchangedChannelTargetOutsideAccountMappingKey "public-alias": "gpt-5.6-sol", }, accountModelMapping: map[string]any{ - "public-alias": "gpt-5.6-terra", + "gpt-5.6-sol": "gpt-5.6-sol", }, }) @@ -1710,6 +1710,40 @@ func TestOpenAIAccountScheduleModelUsesActualOrSharedResolver(t *testing.T) { require.Equal(t, "attempt-actual", openAIAccountScheduleModel(c, account, "public", true, nil)) } +func TestOpenAIChannelForwardModelForScheduler(t *testing.T) { + tests := []struct { + name string + mapping service.ChannelMappingResult + requested string + want string + }{ + { + name: "mapped model is used for capability filtering", + mapping: service.ChannelMappingResult{Mapped: true, MappedModel: " gpt-forward "}, + requested: "client-alias", + want: "gpt-forward", + }, + { + name: "unmapped request preserves client model", + mapping: service.ChannelMappingResult{MappedModel: "client-model"}, + requested: "client-model", + want: "client-model", + }, + { + name: "empty mapped model safely preserves client model", + mapping: service.ChannelMappingResult{Mapped: true, MappedModel: " "}, + requested: "client-model", + want: "client-model", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.want, openAIChannelForwardModel(tt.mapping, tt.requested)) + }) + } +} + func TestShouldReportOpenAIWSProxyAccountFailure(t *testing.T) { t.Run("unsupported client model switch does not penalize account", func(t *testing.T) { err := fmt.Errorf("wrapped ingress turn: %w", newOpenAIWSUnsupportedModelSwitchError("gpt-unsupported")) From af90a9bd1fbcee62c5e8a87dbb3bd71ee4216f27 Mon Sep 17 00:00:00 2001 From: NellPoi Date: Thu, 3 Sep 2026 11:39:23 +0800 Subject: [PATCH 025/145] =?UTF-8?q?fix(openai):=20=E5=85=BC=E5=AE=B9?= =?UTF-8?q?=E5=8E=86=E5=8F=B2=E4=BB=BB=E5=8A=A1=E5=A7=94=E6=B4=BE=E7=BB=AD?= =?UTF-8?q?=E8=81=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../openai_delegation_bootstrap_test.go | 18 ++++++--- .../handler/openai_gateway_handler.go | 37 ++++++++++++------- 2 files changed, 37 insertions(+), 18 deletions(-) diff --git a/backend/internal/handler/openai_delegation_bootstrap_test.go b/backend/internal/handler/openai_delegation_bootstrap_test.go index 9e30752aa6..38854c0781 100644 --- a/backend/internal/handler/openai_delegation_bootstrap_test.go +++ b/backend/internal/handler/openai_delegation_bootstrap_test.go @@ -43,11 +43,7 @@ func TestNormalizeCodexDelegationBootstrapRejectsUnsafeShapes(t *testing.T) { }{ {name: "ordinary missing call id", item: `{"type":"function_call_output","name":"other","namespace":"codex_app","output":"` + delegationEnvelope + `"}`}, {name: "valid call id", item: `{"type":"function_call_output","call_id":"call-1","name":"create_thread","namespace":"codex_app","output":"` + delegationEnvelope + `"}`}, - {name: "previous response", item: `{"type":"function_call_output","name":"create_thread","namespace":"codex_app","output":"` + delegationEnvelope + `"}`, extra: `,"previous_response_id":"resp-1"`}, - {name: "real call context", item: `{"type":"function_call_output","name":"create_thread","namespace":"codex_app","output":"` + delegationEnvelope + `"},{"type":"function_call","call_id":"call-1"}`}, {name: "ambiguous call context", item: `{"type":"function_call_output","name":"create_thread","namespace":"codex_app","output":"` + delegationEnvelope + `"},{"type":"function_call"}`}, - {name: "item reference context", item: `{"type":"function_call_output","name":"create_thread","namespace":"codex_app","output":"` + delegationEnvelope + `"},{"type":"item_reference","id":"call-1"}`}, - {name: "built-in call context", item: `{"type":"function_call_output","name":"create_thread","namespace":"codex_app","output":"` + delegationEnvelope + `"},{"type":"computer_call","call_id":"call-1"}`}, {name: "ambiguous built-in call context", item: `{"type":"function_call_output","name":"create_thread","namespace":"codex_app","output":"` + delegationEnvelope + `"},{"type":"computer_call"}`}, {name: "mixed missing call id output", item: `{"type":"function_call_output","name":"create_thread","namespace":"codex_app","output":"` + delegationEnvelope + `"},{"type":"computer_call_output","output":"done"}`}, {name: "mixed tool search output", item: `{"type":"function_call_output","name":"create_thread","namespace":"codex_app","output":"` + delegationEnvelope + `"},{"type":"tool_search_output","output":"done"}`}, @@ -67,6 +63,18 @@ func TestNormalizeCodexDelegationBootstrapRejectsUnsafeShapes(t *testing.T) { } } +func TestNormalizeCodexDelegationBootstrapWithHistoricalContext(t *testing.T) { + body := []byte(`{"model":"gpt-5","previous_response_id":"resp-1","input":[{"type":"function_call_output","namespace":"codex_app","name":"send_message_to_thread","output":"` + delegationEnvelope + `"},{"type":"function_call","call_id":"call-1"},{"type":"function_call_output","call_id":"call-1","output":"done"},{"type":"item_reference","id":"item-1"},{"type":"computer_call","call_id":"call-2"}]}`) + + got, changed := normalizeCodexDelegationBootstrap(body) + require.True(t, changed) + require.Equal(t, "message", gjson.GetBytes(got, "input.0.type").String()) + require.Equal(t, "user", gjson.GetBytes(got, "input.0.role").String()) + require.Equal(t, "resp-1", gjson.GetBytes(got, "previous_response_id").String()) + require.Equal(t, "call-1", gjson.GetBytes(got, "input.2.call_id").String()) + require.Equal(t, "item-1", gjson.GetBytes(got, "input.3.id").String()) +} + func TestNormalizeCodexDelegationBootstrapPreviousResponseID(t *testing.T) { tests := []struct { name string @@ -75,7 +83,7 @@ func TestNormalizeCodexDelegationBootstrapPreviousResponseID(t *testing.T) { }{ {name: "absent", changed: true}, {name: "blank string", value: `,"previous_response_id":" "`, changed: true}, - {name: "non-empty string", value: `,"previous_response_id":"resp-1"`}, + {name: "non-empty string", value: `,"previous_response_id":"resp-1"`, changed: true}, {name: "null", value: `,"previous_response_id":null`}, {name: "number", value: `,"previous_response_id":42`}, {name: "boolean", value: `,"previous_response_id":false`}, diff --git a/backend/internal/handler/openai_gateway_handler.go b/backend/internal/handler/openai_gateway_handler.go index ce67e07e85..3636ba8331 100644 --- a/backend/internal/handler/openai_gateway_handler.go +++ b/backend/internal/handler/openai_gateway_handler.go @@ -1600,14 +1600,17 @@ func (h *OpenAIGatewayHandler) validateFunctionCallOutputRequest(c *gin.Context, } func normalizeCodexDelegationBootstrap(body []byte) ([]byte, bool) { - return normalizeCodexCallOutputBootstrap(body, isCodexDelegationCandidate) + // 已有任务通过 send_message_to_thread 唤醒时会携带 previous_response_id; + // 完整历史回放还会带有已配对的调用项。delegation 仍是客户端注入的用户输入, + // 不属于这些历史调用的结果,因此允许它与可明确配对的历史上下文共存。 + return normalizeCodexCallOutputBootstrap(body, isCodexDelegationCandidate, true) } func normalizeCodexAutomationBootstrap(body []byte) ([]byte, bool) { - return normalizeCodexCallOutputBootstrap(body, isCodexAutomationCandidate) + return normalizeCodexCallOutputBootstrap(body, isCodexAutomationCandidate, false) } -func normalizeCodexCallOutputBootstrap(body []byte, isCandidate func(map[string]any) bool) ([]byte, bool) { +func normalizeCodexCallOutputBootstrap(body []byte, isCandidate func(map[string]any) bool, allowHistoricalContext bool) ([]byte, bool) { if !hasUniqueJSONMembers(body) { return body, false } @@ -1619,7 +1622,7 @@ func normalizeCodexCallOutputBootstrap(body []byte, isCandidate func(map[string] } if previousResponseID, exists := request["previous_response_id"]; exists { value, ok := previousResponseID.(string) - if !ok || strings.TrimSpace(value) != "" { + if !ok || (!allowHistoricalContext && strings.TrimSpace(value) != "") { return body, false } } @@ -1628,27 +1631,35 @@ func normalizeCodexCallOutputBootstrap(body []byte, isCandidate func(map[string] return body, false } - // Any call/reference anchor makes a call-less output ambiguous. Responses - // built-ins follow the *_call / *_call_output naming convention, so classify - // by the wire type shape instead of maintaining an incomplete allowlist. + // Responses built-ins follow the *_call / *_call_output naming convention, + // so classify by the wire type shape instead of maintaining an incomplete + // allowlist. Delegation may coexist with historical anchors only when their + // IDs make them unambiguous; automation retains the bootstrap-only boundary. for _, raw := range input { item, ok := raw.(map[string]any) if !ok { continue } typ := stringField(item, "type") - if typ == "item_reference" || strings.HasSuffix(typ, "_call") { - return body, false - } - if isResponsesCallOutputType(typ) { + if isCandidate(item) { callIDValue, exists := item["call_id"] callID, isString := callIDValue.(string) if exists && (!isString || strings.TrimSpace(callID) != "") { return body, false } - if !isCandidate(item) { - return body, false + continue + } + if typ == "item_reference" { + if allowHistoricalContext && strings.TrimSpace(stringField(item, "id")) != "" { + continue } + return body, false + } + if strings.HasSuffix(typ, "_call") || isResponsesCallOutputType(typ) { + if allowHistoricalContext && strings.TrimSpace(stringField(item, "call_id")) != "" { + continue + } + return body, false } } From 62198286e93a6ecf3c157417c80d136b68bd84c4 Mon Sep 17 00:00:00 2001 From: wucm667 Date: Thu, 3 Sep 2026 12:48:02 +0800 Subject: [PATCH 026/145] [verified] fix(handler): distinguish gateway admission rejections --- .../handler/concurrency_error_response.go | 15 ++- .../concurrency_error_response_test.go | 14 ++- backend/internal/handler/gateway_handler.go | 37 +++++--- .../handler/gateway_handler_responses.go | 2 +- .../handler/openai_gateway_handler.go | 32 +++---- .../internal/handler/stream_error_event.go | 9 +- .../handler/stream_error_event_test.go | 93 ++++++++++++++++++- 7 files changed, 163 insertions(+), 39 deletions(-) diff --git a/backend/internal/handler/concurrency_error_response.go b/backend/internal/handler/concurrency_error_response.go index 911f5a54d7..cd631a47b4 100644 --- a/backend/internal/handler/concurrency_error_response.go +++ b/backend/internal/handler/concurrency_error_response.go @@ -9,10 +9,15 @@ import ( const statusClientClosedRequest = 499 -func concurrencyErrorResponse(err error, slotType string) (int, string, string) { +const ( + gatewayQueueFullCode = "gateway_queue_full" + gatewayConcurrencyLimitCode = "gateway_concurrency_limit" +) + +func concurrencyErrorResponse(err error, slotType string) (int, string, string, string) { var waitQueueFullErr *WaitQueueFullError if errors.As(err, &waitQueueFullErr) { - return http.StatusTooManyRequests, "rate_limit_error", + return http.StatusTooManyRequests, "rate_limit_error", gatewayQueueFullCode, "Too many pending requests, please retry later" } @@ -21,13 +26,13 @@ func concurrencyErrorResponse(err error, slotType string) (int, string, string) if concurrencyErr.SlotType != "" { slotType = concurrencyErr.SlotType } - return http.StatusTooManyRequests, "rate_limit_error", + return http.StatusTooManyRequests, "rate_limit_error", gatewayConcurrencyLimitCode, fmt.Sprintf("Concurrency limit exceeded for %s, please retry later", slotType) } if errors.Is(err, context.Canceled) { - return statusClientClosedRequest, "api_error", "context canceled" + return statusClientClosedRequest, "api_error", "", "context canceled" } - return http.StatusServiceUnavailable, "api_error", "Service temporarily unavailable, please retry later" + return http.StatusServiceUnavailable, "api_error", "", "Service temporarily unavailable, please retry later" } diff --git a/backend/internal/handler/concurrency_error_response_test.go b/backend/internal/handler/concurrency_error_response_test.go index a2e6b9ab93..2d1b3d6ad2 100644 --- a/backend/internal/handler/concurrency_error_response_test.go +++ b/backend/internal/handler/concurrency_error_response_test.go @@ -16,6 +16,7 @@ func TestConcurrencyErrorResponse(t *testing.T) { slotType string wantStatus int wantType string + wantCode string wantMessage string }{ { @@ -24,8 +25,18 @@ func TestConcurrencyErrorResponse(t *testing.T) { slotType: "user", wantStatus: http.StatusTooManyRequests, wantType: "rate_limit_error", + wantCode: gatewayConcurrencyLimitCode, wantMessage: "Concurrency limit exceeded for account, please retry later", }, + { + name: "full local wait queue has gateway code", + err: &WaitQueueFullError{SlotType: "account"}, + slotType: "account", + wantStatus: http.StatusTooManyRequests, + wantType: "rate_limit_error", + wantCode: gatewayQueueFullCode, + wantMessage: "Too many pending requests, please retry later", + }, { name: "client cancellation is not classified as concurrency limit", err: context.Canceled, @@ -54,9 +65,10 @@ func TestConcurrencyErrorResponse(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - status, errType, message := concurrencyErrorResponse(tt.err, tt.slotType) + status, errType, code, message := concurrencyErrorResponse(tt.err, tt.slotType) require.Equal(t, tt.wantStatus, status) require.Equal(t, tt.wantType, errType) + require.Equal(t, tt.wantCode, code) require.Equal(t, tt.wantMessage, message) }) } diff --git a/backend/internal/handler/gateway_handler.go b/backend/internal/handler/gateway_handler.go index a4bcda50fa..9384d46643 100644 --- a/backend/internal/handler/gateway_handler.go +++ b/backend/internal/handler/gateway_handler.go @@ -395,7 +395,7 @@ func (h *GatewayHandler) Messages(c *gin.Context) { zap.Int64("account_id", account.ID), zap.Int("max_waiting", selection.WaitPlan.MaxWaiting), ) - h.handleStreamingAwareError(c, http.StatusTooManyRequests, "rate_limit_error", "Too many pending requests, please retry later", streamStarted) + h.handleStreamingAwareErrorWithCode(c, http.StatusTooManyRequests, "rate_limit_error", gatewayQueueFullCode, "Too many pending requests, please retry later", streamStarted) return } if err == nil && canWait { @@ -719,7 +719,7 @@ func (h *GatewayHandler) Messages(c *gin.Context) { zap.Int64("account_id", account.ID), zap.Int("max_waiting", selection.WaitPlan.MaxWaiting), ) - h.handleStreamingAwareError(c, http.StatusTooManyRequests, "rate_limit_error", "Too many pending requests, please retry later", streamStarted) + h.handleStreamingAwareErrorWithCode(c, http.StatusTooManyRequests, "rate_limit_error", gatewayQueueFullCode, "Too many pending requests, please retry later", streamStarted) return } if err == nil && canWait { @@ -1853,8 +1853,8 @@ func (h *GatewayHandler) calculateSubscriptionRemaining(group *service.Group, su // handleConcurrencyError handles concurrency-related acquire errors. func (h *GatewayHandler) handleConcurrencyError(c *gin.Context, err error, slotType string, streamStarted bool) { - status, errType, message := concurrencyErrorResponse(err, slotType) - h.handleStreamingAwareError(c, status, errType, message, streamStarted) + status, errType, code, message := concurrencyErrorResponse(err, slotType) + h.handleStreamingAwareErrorWithCode(c, status, errType, code, message, streamStarted) } func (h *GatewayHandler) handleFailoverExhausted(c *gin.Context, failoverErr *service.UpstreamFailoverError, platform string, streamStarted bool) { @@ -1925,6 +1925,10 @@ func (h *GatewayHandler) mapUpstreamError(statusCode int) (int, string, string) // handleStreamingAwareError handles errors that may occur after streaming has started func (h *GatewayHandler) handleStreamingAwareError(c *gin.Context, status int, errType, message string, streamStarted bool) { + h.handleStreamingAwareErrorWithCode(c, status, errType, "", message, streamStarted) +} + +func (h *GatewayHandler) handleStreamingAwareErrorWithCode(c *gin.Context, status int, errType, code, message string, streamStarted bool) { if streamStarted { // 响应状态码已固化为 200(ping/部分数据已 flush),错误只能就地以 SSE 帧回传。 // 标记本次流内错误,供 ops_error_logger 补记——否则该中间件按 status>=400 采集, @@ -1935,7 +1939,7 @@ func (h *GatewayHandler) handleStreamingAwareError(c *gin.Context, status int, e // response.completed/failed/incomplete/cancelled 集合。 // Anthropic-backed Responses 路径同样会因为通用 error 帧被拒。 if inboundIsResponses(c) { - if writeResponsesFailedSSE(c, errType, message) { + if writeResponsesFailedSSE(c, errType, code, message) { return } } @@ -1943,7 +1947,11 @@ func (h *GatewayHandler) handleStreamingAwareError(c *gin.Context, status int, e flusher, ok := c.Writer.(http.Flusher) if ok { // SSE 错误事件固定 schema,使用 Quote 直拼可避免额外 Marshal 分配。 - errorEvent := `data: {"type":"error","error":{"type":` + strconv.Quote(errType) + `,"message":` + strconv.Quote(message) + `}}` + "\n\n" + errorCode := "" + if code != "" { + errorCode = `,"code":` + strconv.Quote(code) + } + errorEvent := `data: {"type":"error","error":{"type":` + strconv.Quote(errType) + errorCode + `,"message":` + strconv.Quote(message) + `}}` + "\n\n" if _, err := fmt.Fprint(c.Writer, errorEvent); err != nil { _ = c.Error(err) } @@ -1953,7 +1961,7 @@ func (h *GatewayHandler) handleStreamingAwareError(c *gin.Context, status int, e } // Normal case: return JSON response with proper status code - h.errorResponse(c, status, errType, message) + h.errorResponseWithCode(c, status, errType, code, message) } // ensureForwardErrorResponse 在 Forward 返回错误但尚未写响应时补写统一错误响应。 @@ -2045,12 +2053,17 @@ func (h *GatewayHandler) checkClaudeCodeVersion(c *gin.Context) bool { // errorResponse 返回Claude API格式的错误响应 func (h *GatewayHandler) errorResponse(c *gin.Context, status int, errType, message string) { + h.errorResponseWithCode(c, status, errType, "", message) +} + +func (h *GatewayHandler) errorResponseWithCode(c *gin.Context, status int, errType, code, message string) { + errorObject := gin.H{"type": errType, "message": message} + if code != "" { + errorObject["code"] = code + } c.JSON(status, gin.H{ - "type": "error", - "error": gin.H{ - "type": errType, - "message": message, - }, + "type": "error", + "error": errorObject, }) } diff --git a/backend/internal/handler/gateway_handler_responses.go b/backend/internal/handler/gateway_handler_responses.go index 04a61cb16e..a170877315 100644 --- a/backend/internal/handler/gateway_handler_responses.go +++ b/backend/internal/handler/gateway_handler_responses.go @@ -394,7 +394,7 @@ func (h *GatewayHandler) handleResponsesFailoverExhausted(c *gin.Context, lastEr // generic response.failed. service.MarkOpsStreamError(c, code, message, status) if c != nil && c.Writer != nil && (c.Writer.Size() <= 0 || gatewayStreamHasOnlyHeartbeats(c)) { - writeResponsesFailedSSE(c, code, message) + writeResponsesFailedSSE(c, code, "", message) } return } diff --git a/backend/internal/handler/openai_gateway_handler.go b/backend/internal/handler/openai_gateway_handler.go index ce67e07e85..771169016b 100644 --- a/backend/internal/handler/openai_gateway_handler.go +++ b/backend/internal/handler/openai_gateway_handler.go @@ -2004,7 +2004,7 @@ func (h *OpenAIGatewayHandler) acquireResponsesAccountSlot( return h.acquireOpenAIAccountSlot(c, groupID, sessionHash, selection, reqStream, streamStarted, reqLog, nil) } -type openAISlotErrorWriter func(status int, errType, message string) +type openAISlotErrorWriter func(status int, errType, code, message string) // acquireOpenAIAccountSlot centralizes scheduler selection admission. The // optional error writer lets non-Responses endpoints retain their wire format @@ -2020,13 +2020,13 @@ func (h *OpenAIGatewayHandler) acquireOpenAIAccountSlot( writeError openAISlotErrorWriter, ) (func(), openAISlotAcquireResult) { if writeError == nil { - writeError = func(status int, errType, message string) { - h.handleStreamingAwareError(c, status, errType, message, *streamStarted) + writeError = func(status int, errType, code, message string) { + h.handleStreamingAwareErrorWithCode(c, status, errType, code, message, *streamStarted, false) } } if selection == nil || selection.Account == nil { markOpsRoutingCapacityLimited(c) - writeError(http.StatusServiceUnavailable, "api_error", "No available accounts") + writeError(http.StatusServiceUnavailable, "api_error", "", "No available accounts") return nil, openAISlotAcquireFailed } @@ -2056,7 +2056,7 @@ func (h *OpenAIGatewayHandler) acquireOpenAIAccountSlot( } if selection.WaitPlan == nil { markOpsRoutingCapacityLimited(c) - writeError(http.StatusServiceUnavailable, "api_error", "No available accounts") + writeError(http.StatusServiceUnavailable, "api_error", "", "No available accounts") return nil, openAISlotAcquireFailed } @@ -2067,8 +2067,8 @@ func (h *OpenAIGatewayHandler) acquireOpenAIAccountSlot( ) if err != nil { reqLog.Warn("openai.account_slot_quick_acquire_failed", zap.Int64("account_id", account.ID), zap.Error(err)) - status, errType, message := concurrencyErrorResponse(err, "account") - writeError(status, errType, message) + status, errType, code, message := concurrencyErrorResponse(err, "account") + writeError(status, errType, code, message) return nil, openAISlotAcquireFailed } if fastAcquired { @@ -2098,7 +2098,7 @@ func (h *OpenAIGatewayHandler) acquireOpenAIAccountSlot( zap.Int64("account_id", account.ID), zap.Int("max_waiting", selection.WaitPlan.MaxWaiting), ) - writeError(http.StatusTooManyRequests, "rate_limit_error", "Too many pending requests, please retry later") + writeError(http.StatusTooManyRequests, "rate_limit_error", gatewayQueueFullCode, "Too many pending requests, please retry later") return nil, openAISlotAcquireFailed } @@ -2121,8 +2121,8 @@ func (h *OpenAIGatewayHandler) acquireOpenAIAccountSlot( ) if err != nil { reqLog.Warn("openai.account_slot_acquire_failed", zap.Int64("account_id", account.ID), zap.Error(err)) - status, errType, message := concurrencyErrorResponse(err, "account") - writeError(status, errType, message) + status, errType, code, message := concurrencyErrorResponse(err, "account") + writeError(status, errType, code, message) return nil, openAISlotAcquireFailed } @@ -3172,14 +3172,14 @@ func (h *OpenAIGatewayHandler) acquireImageGenerationSlot(c *gin.Context, stream if acquired { return release, true } - h.handleStreamingAwareError(c, http.StatusTooManyRequests, "rate_limit_error", "Image generation concurrency limit exceeded, please retry later", streamStarted) + h.handleStreamingAwareErrorWithCode(c, http.StatusTooManyRequests, "rate_limit_error", gatewayConcurrencyLimitCode, "Image generation concurrency limit exceeded, please retry later", streamStarted, false) return nil, false } // handleConcurrencyError handles concurrency-related acquire errors. func (h *OpenAIGatewayHandler) handleConcurrencyError(c *gin.Context, err error, slotType string, streamStarted bool) { - status, errType, message := concurrencyErrorResponse(err, slotType) - h.handleStreamingAwareError(c, status, errType, message, streamStarted) + status, errType, code, message := concurrencyErrorResponse(err, slotType) + h.handleStreamingAwareErrorWithCode(c, status, errType, code, message, streamStarted, false) } func (h *OpenAIGatewayHandler) handleFailoverExhausted(c *gin.Context, failoverErr *service.UpstreamFailoverError, streamStarted bool) { @@ -3359,7 +3359,7 @@ func (h *OpenAIGatewayHandler) handleStreamingAwareErrorWithCode( // 通用 `event: error` 帧不被识别为终止事件,会导致 // "stream closed before response.completed"。 if inboundIsResponses(c) { - if writeResponsesFailedSSE(c, errType, message) { + if writeResponsesFailedSSE(c, errType, code, message) { return } } @@ -3526,7 +3526,7 @@ func (h *OpenAIGatewayHandler) errorResponse(c *gin.Context, status int, errType // 提交的 SSE 流交错,必须降级为 response.failed 终止事件(#3887)。 if service.StopOpenAICompactSSEKeepaliveCommitted(c) { service.MarkOpsStreamError(c, errType, message, status) - if writeResponsesFailedSSE(c, errType, message) { + if writeResponsesFailedSSE(c, errType, "", message) { return } } @@ -3844,7 +3844,7 @@ func (h *OpenAIGatewayHandler) rejectIfCyberSessionBlocked(c *gin.Context, apiKe // 写 JSON(#3887)。 if service.StopOpenAICompactSSEKeepaliveCommitted(c) { service.MarkOpsStreamError(c, "permission_error", cyberSessionBlockedClientMsg, http.StatusForbidden) - if writeResponsesFailedSSE(c, "permission_error", cyberSessionBlockedClientMsg) { + if writeResponsesFailedSSE(c, "permission_error", "", cyberSessionBlockedClientMsg) { h.enqueueCyberSessionBlockedOpsEntry(c, apiKey, model, key) return true } diff --git a/backend/internal/handler/stream_error_event.go b/backend/internal/handler/stream_error_event.go index 8ba6095cfb..f60e6c7834 100644 --- a/backend/internal/handler/stream_error_event.go +++ b/backend/internal/handler/stream_error_event.go @@ -56,7 +56,7 @@ type responsesFailedEvent struct { // 返回 false 表示 writer 不支持 Flusher,无法以 SSE 形式回报错误; // 此时 caller 也无法回退到 JSON(HTTP 200 已固化),通常意味着连接已经损坏, // 应当让请求处理函数 return,由上层关闭连接。 -func writeResponsesFailedSSE(c *gin.Context, errType, message string) bool { +func writeResponsesFailedSSE(c *gin.Context, errType, code, message string) bool { flusher, ok := c.Writer.(http.Flusher) if !ok { return false @@ -72,7 +72,7 @@ func writeResponsesFailedSSE(c *gin.Context, errType, message string) bool { Status: "failed", Output: []any{}, Error: responsesFailedError{ - Code: mapResponsesErrorCode(errType), + Code: mapResponsesErrorCode(errType, code), Message: message, }, }, @@ -155,7 +155,10 @@ func requestModel(c *gin.Context) string { // mapResponsesErrorCode 把内部 errType 映射为 Responses 协议常见的 error.code。 // 无明确映射时原样返回,保证至少可读。 -func mapResponsesErrorCode(errType string) string { +func mapResponsesErrorCode(errType, code string) string { + if code != "" { + return code + } switch errType { case "rate_limit_error": return "rate_limit_exceeded" diff --git a/backend/internal/handler/stream_error_event_test.go b/backend/internal/handler/stream_error_event_test.go index 187deda19d..94089e9fae 100644 --- a/backend/internal/handler/stream_error_event_test.go +++ b/backend/internal/handler/stream_error_event_test.go @@ -12,6 +12,7 @@ import ( "github.com/gin-gonic/gin" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" ) // Regression for the production incident on 2026-05-24 around 9:13 CST: @@ -77,6 +78,60 @@ func TestOpenAIHandleStreamingAwareError_ResponsesStreamingEmitsResponseFailed(t assert.Equal(t, "Concurrency limit exceeded for user, please retry later", errObj["message"]) } +func TestOpenAIAdmissionError_SynchronousNonResponsesIncludesGatewayCode(t *testing.T) { + c, w := newGinContextForEndpoint(t, EndpointChatCompletions) + h := &OpenAIGatewayHandler{} + h.handleStreamingAwareErrorWithCode(c, http.StatusTooManyRequests, "rate_limit_error", + gatewayQueueFullCode, "Too many pending requests, please retry later", false, false) + + assert.Equal(t, http.StatusTooManyRequests, w.Code) + assert.Equal(t, "rate_limit_error", gjson.GetBytes(w.Body.Bytes(), "error.type").String()) + assert.Equal(t, gatewayQueueFullCode, gjson.GetBytes(w.Body.Bytes(), "error.code").String()) +} + +func TestOpenAIAdmissionError_SynchronousResponsesIncludesGatewayCode(t *testing.T) { + c, w := newGinContextForEndpoint(t, EndpointResponses) + h := &OpenAIGatewayHandler{} + h.handleStreamingAwareErrorWithCode(c, http.StatusTooManyRequests, "rate_limit_error", + gatewayConcurrencyLimitCode, "Concurrency limit exceeded for account, please retry later", false, false) + + assert.Equal(t, http.StatusTooManyRequests, w.Code) + assert.Equal(t, "rate_limit_error", gjson.GetBytes(w.Body.Bytes(), "error.type").String()) + assert.Equal(t, gatewayConcurrencyLimitCode, gjson.GetBytes(w.Body.Bytes(), "error.code").String()) +} + +func TestOpenAIAdmissionError_ResponsesFailedIncludesGatewayCode(t *testing.T) { + c, w := newGinContextForEndpoint(t, EndpointResponses) + h := &OpenAIGatewayHandler{} + h.handleStreamingAwareErrorWithCode(c, http.StatusTooManyRequests, "rate_limit_error", + gatewayConcurrencyLimitCode, "Concurrency limit exceeded for account, please retry later", true, false) + + _, errObj := parseResponsesFailedSSE(t, w.Body.String()) + assert.Equal(t, gatewayConcurrencyLimitCode, errObj["code"]) +} + +func TestOpenAIAdmissionError_StreamingNonResponsesIncludesGatewayCode(t *testing.T) { + c, w := newGinContextForEndpoint(t, EndpointChatCompletions) + h := &OpenAIGatewayHandler{} + h.handleStreamingAwareErrorWithCode(c, http.StatusTooManyRequests, "rate_limit_error", + gatewayQueueFullCode, "Too many pending requests, please retry later", true, false) + + body := w.Body.String() + assert.True(t, strings.HasPrefix(body, "event: error\n")) + payload := body[strings.Index(body, "{"):] + assert.Equal(t, "rate_limit_error", gjson.Get(payload, "error.type").String()) + assert.Equal(t, gatewayQueueFullCode, gjson.Get(payload, "error.code").String()) +} + +func TestOpenAIUpstreamRateLimitWithoutLocalCodeRemainsUnchanged(t *testing.T) { + c, w := newGinContextForEndpoint(t, EndpointChatCompletions) + h := &OpenAIGatewayHandler{} + h.handleStreamingAwareError(c, http.StatusTooManyRequests, "rate_limit_error", "upstream limited", false) + + assert.Equal(t, "rate_limit_error", gjson.GetBytes(w.Body.Bytes(), "error.type").String()) + assert.False(t, gjson.GetBytes(w.Body.Bytes(), "error.code").Exists()) +} + // 当 setOpsRequestContext 写过 model,合成事件应回填该字段(与 codebase 已有 makeResponsesCompletedEvent 对齐)。 func TestOpenAIHandleStreamingAwareError_ResponsesStreamingIncludesModel(t *testing.T) { c, w := newGinContextForEndpoint(t, EndpointResponses) @@ -164,6 +219,41 @@ func TestGatewayHandleStreamingAwareError_ResponsesStreamingEmitsResponseFailed( assert.Equal(t, "upstream gone", errObj["message"]) } +func TestGatewayAdmissionError_SynchronousIncludesGatewayCode(t *testing.T) { + c, w := newGinContextForEndpoint(t, EndpointMessages) + h := &GatewayHandler{} + h.handleStreamingAwareErrorWithCode(c, http.StatusTooManyRequests, "rate_limit_error", + gatewayQueueFullCode, "Too many pending requests, please retry later", false) + + assert.Equal(t, http.StatusTooManyRequests, w.Code) + assert.Equal(t, "error", gjson.GetBytes(w.Body.Bytes(), "type").String()) + assert.Equal(t, "rate_limit_error", gjson.GetBytes(w.Body.Bytes(), "error.type").String()) + assert.Equal(t, gatewayQueueFullCode, gjson.GetBytes(w.Body.Bytes(), "error.code").String()) +} + +func TestGatewayAdmissionError_MessagesStreamingIncludesGatewayCode(t *testing.T) { + c, w := newGinContextForEndpoint(t, EndpointMessages) + h := &GatewayHandler{} + h.handleStreamingAwareErrorWithCode(c, http.StatusTooManyRequests, "rate_limit_error", + gatewayConcurrencyLimitCode, "Concurrency limit exceeded for account, please retry later", true) + + body := w.Body.String() + assert.True(t, strings.HasPrefix(body, `data: {"type":"error"`)) + payload := body[strings.Index(body, "{"):] + assert.Equal(t, "rate_limit_error", gjson.Get(payload, "error.type").String()) + assert.Equal(t, gatewayConcurrencyLimitCode, gjson.Get(payload, "error.code").String()) +} + +func TestGatewayAdmissionError_BareResponsesFailedIncludesGatewayCode(t *testing.T) { + c, w := newGinContextForEndpoint(t, "/responses") + h := &GatewayHandler{} + h.handleStreamingAwareErrorWithCode(c, http.StatusTooManyRequests, "rate_limit_error", + gatewayQueueFullCode, "Too many pending requests, please retry later", true) + + _, errObj := parseResponsesFailedSSE(t, w.Body.String()) + assert.Equal(t, gatewayQueueFullCode, errObj["code"]) +} + // Gateway handler: /v1/messages preserves the legacy data:{type:error,...} format // (Anthropic spec accepts a type:"error" stream event). func TestGatewayHandleStreamingAwareError_MessagesStreamingKeepsLegacy(t *testing.T) { @@ -264,6 +354,7 @@ func TestMapResponsesErrorCode(t *testing.T) { {"custom_thing", "custom_thing"}, } for _, tc := range cases { - assert.Equal(t, tc.out, mapResponsesErrorCode(tc.in), "in=%q", tc.in) + assert.Equal(t, tc.out, mapResponsesErrorCode(tc.in, ""), "in=%q", tc.in) } + assert.Equal(t, gatewayQueueFullCode, mapResponsesErrorCode("rate_limit_error", gatewayQueueFullCode)) } From 78fc7b171ec1a802a8d267d0dcc159539ddb0094 Mon Sep 17 00:00:00 2001 From: wucm667 Date: Thu, 3 Sep 2026 15:16:18 +0800 Subject: [PATCH 027/145] test: skip live token comparison on network errors --- .../service/openai_gateway_count_tokens_test.go | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/backend/internal/service/openai_gateway_count_tokens_test.go b/backend/internal/service/openai_gateway_count_tokens_test.go index 6aa8825c9e..37bfa91e00 100644 --- a/backend/internal/service/openai_gateway_count_tokens_test.go +++ b/backend/internal/service/openai_gateway_count_tokens_test.go @@ -4,8 +4,10 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" "io" + "net" "net/http" "net/http/httptest" "os" @@ -350,9 +352,13 @@ func TestEstimateOpenAIInputTokens_CompareWithOpenAIAPI(t *testing.T) { actual, err := callOpenAIInputTokensAPIForTest(client, apiKey, prepared.Request) if err != nil { - // Live-API comparison only; invalid/expired local keys should skip, not fail CI. - if strings.Contains(err.Error(), "status=401") || strings.Contains(err.Error(), "invalid_api_key") { - t.Skipf("OPENAI_API_KEY rejected by OpenAI: %v", err) + // This is an optional live-API comparison. Credential and transient + // network failures must not make the deterministic unit suite fail. + var netErr net.Error + if strings.Contains(err.Error(), "status=401") || + strings.Contains(err.Error(), "invalid_api_key") || + errors.As(err, &netErr) { + t.Skipf("OpenAI live comparison unavailable: %v", err) } require.NoError(t, err) } From de8d756afae96f74d46c08f7633eb7e186c25176 Mon Sep 17 00:00:00 2001 From: weiness <840833211@qq.com> Date: Thu, 3 Sep 2026 16:33:30 +0800 Subject: [PATCH 028/145] fix(payment): reconcile pending Alipay orders --- .../service/payment_order_expiry_service.go | 6 +- .../service/payment_order_lifecycle.go | 16 ++++-- .../service/payment_order_lifecycle_test.go | 57 ++++++++++++++++++- .../components/payment/PaymentStatusPanel.vue | 2 +- .../__tests__/PaymentStatusPanel.spec.ts | 28 +++++++++ 5 files changed, 97 insertions(+), 12 deletions(-) diff --git a/backend/internal/service/payment_order_expiry_service.go b/backend/internal/service/payment_order_expiry_service.go index b292ef89ae..ed66e200e6 100644 --- a/backend/internal/service/payment_order_expiry_service.go +++ b/backend/internal/service/payment_order_expiry_service.go @@ -98,12 +98,12 @@ func (s *PaymentOrderExpiryService) runOnce() { defer release() reconcileCtx, cancel := context.WithTimeout(context.Background(), expiryCheckTimeout) - recovered, err := s.paymentSvc.ReconcilePendingWxpayOrders(reconcileCtx) + recovered, err := s.paymentSvc.ReconcilePendingPaymentOrders(reconcileCtx) cancel() if err != nil { - slog.Warn("[PaymentOrderExpiry] failed to reconcile pending wxpay orders", "error", err) + slog.Warn("[PaymentOrderExpiry] failed to reconcile pending payment orders", "error", err) } else if recovered > 0 { - slog.Info("[PaymentOrderExpiry] reconciled paid wxpay orders", "count", recovered) + slog.Info("[PaymentOrderExpiry] reconciled paid orders", "count", recovered) } expireCtx, cancel := context.WithTimeout(context.Background(), expiryCheckTimeout) diff --git a/backend/internal/service/payment_order_lifecycle.go b/backend/internal/service/payment_order_lifecycle.go index 46a2e00605..d534bf7b33 100644 --- a/backend/internal/service/payment_order_lifecycle.go +++ b/backend/internal/service/payment_order_lifecycle.go @@ -27,7 +27,7 @@ const ( checkPaidResultAlreadyPaid = "already_paid" checkPaidResultCancelled = "cancelled" - pendingWxpayReconcileLimit = 20 + pendingPaymentReconcileLimit = 20 ) type checkPaidOptions struct { @@ -303,9 +303,9 @@ func (s *PaymentService) VerifyOrderByOutTradeNo(ctx context.Context, outTradeNo return o, nil } -// ReconcilePendingWxpayOrders actively checks recent pending WeChat orders so -// missed provider notifications do not wait until order expiry to fulfill. -func (s *PaymentService) ReconcilePendingWxpayOrders(ctx context.Context) (int, error) { +// ReconcilePendingPaymentOrders actively checks recent pending Alipay and WeChat +// orders so missed provider notifications do not wait until order expiry to fulfill. +func (s *PaymentService) ReconcilePendingPaymentOrders(ctx context.Context) (int, error) { now := time.Now() orders, err := s.entClient.PaymentOrder.Query(). Where( @@ -316,13 +316,17 @@ func (s *PaymentService) ReconcilePendingWxpayOrders(ctx context.Context) (int, paymentorder.PaymentTypeHasPrefix(payment.TypeWxpay+"_"), paymentorder.ProviderKeyEQ(payment.TypeWxpay), paymentorder.ProviderKeyHasPrefix(payment.TypeWxpay+"_"), + paymentorder.PaymentTypeEQ(payment.TypeAlipay), + paymentorder.PaymentTypeHasPrefix(payment.TypeAlipay+"_"), + paymentorder.ProviderKeyEQ(payment.TypeAlipay), + paymentorder.ProviderKeyHasPrefix(payment.TypeAlipay+"_"), ), ). Order(dbent.Asc(paymentorder.FieldCreatedAt)). - Limit(pendingWxpayReconcileLimit). + Limit(pendingPaymentReconcileLimit). All(ctx) if err != nil { - return 0, fmt.Errorf("query pending wxpay orders: %w", err) + return 0, fmt.Errorf("query pending payment orders: %w", err) } recovered := 0 diff --git a/backend/internal/service/payment_order_lifecycle_test.go b/backend/internal/service/payment_order_lifecycle_test.go index 658a1806c6..c5a5f2fb9c 100644 --- a/backend/internal/service/payment_order_lifecycle_test.go +++ b/backend/internal/service/payment_order_lifecycle_test.go @@ -567,7 +567,7 @@ func TestCancelOrderStillClosesUnpaidUpstreamOrder(t *testing.T) { require.Equal(t, OrderStatusCancelled, reloaded.Status) } -func TestReconcilePendingWxpayOrdersBackfillsPaidOrder(t *testing.T) { +func TestReconcilePendingPaymentOrdersBackfillsPaidOrder(t *testing.T) { ctx := context.Background() client := newPaymentOrderLifecycleTestClient(t) @@ -655,7 +655,7 @@ func TestReconcilePendingWxpayOrdersBackfillsPaidOrder(t *testing.T) { providersLoaded: true, } - recovered, err := svc.ReconcilePendingWxpayOrders(ctx) + recovered, err := svc.ReconcilePendingPaymentOrders(ctx) require.NoError(t, err) require.Equal(t, 1, recovered) require.Equal(t, order.OutTradeNo, provider.lastQueryTradeNo) @@ -669,6 +669,59 @@ func TestReconcilePendingWxpayOrdersBackfillsPaidOrder(t *testing.T) { require.Len(t, redeemRepo.useCalls, 1) } +func TestReconcilePendingPaymentOrdersQueriesAlipayOrder(t *testing.T) { + ctx := context.Background() + client := newPaymentOrderLifecycleTestClient(t) + + user, err := client.User.Create(). + SetEmail("alipay-reconcile@example.com"). + SetPasswordHash("hash"). + SetUsername("alipay-reconcile-user"). + Save(ctx) + require.NoError(t, err) + + order, err := client.PaymentOrder.Create(). + SetUserID(user.ID). + SetUserEmail(user.Email). + SetUserName(user.Username). + SetAmount(50). + SetPayAmount(50). + SetFeeRate(0). + SetRechargeCode("ALIPAY-RECONCILE"). + SetOutTradeNo("sub2_alipay_reconcile"). + SetPaymentType(payment.TypeAlipay). + SetPaymentTradeNo(""). + SetOrderType(payment.OrderTypeBalance). + SetStatus(OrderStatusPending). + SetExpiresAt(time.Now().Add(time.Hour)). + SetClientIP("127.0.0.1"). + SetSrcHost("api.example.com"). + Save(ctx) + require.NoError(t, err) + + registry := payment.NewRegistry() + provider := &paymentOrderLifecycleQueryProvider{ + key: payment.TypeAlipay, + resp: &payment.QueryOrderResponse{ + TradeNo: order.OutTradeNo, + Status: payment.ProviderStatusPending, + }, + } + registry.Register(provider) + + svc := &PaymentService{ + entClient: client, + registry: registry, + providersLoaded: true, + } + + recovered, err := svc.ReconcilePendingPaymentOrders(ctx) + require.NoError(t, err) + require.Zero(t, recovered) + require.Equal(t, 1, provider.queryCalls) + require.Equal(t, order.OutTradeNo, provider.lastQueryTradeNo) +} + func TestVerifyOrderByOutTradeNoUsesOutTradeNoWhenPaymentTradeNoAlreadyExistsForAlipay(t *testing.T) { ctx := context.Background() client := newPaymentOrderLifecycleTestClient(t) diff --git a/frontend/src/components/payment/PaymentStatusPanel.vue b/frontend/src/components/payment/PaymentStatusPanel.vue index 9b2b78e800..3400b89c69 100644 --- a/frontend/src/components/payment/PaymentStatusPanel.vue +++ b/frontend/src/components/payment/PaymentStatusPanel.vue @@ -393,7 +393,7 @@ function saveQRCode() { } async function tryRecoverPendingOrder(order: PaymentOrder): Promise { - if (!isWxpay.value && !isMobileAlipayDeepLink.value) return order + if (!isWxpay.value && !isAlipay.value) return order const outTradeNo = String(order.out_trade_no || '').trim() if (!outTradeNo) return order const normalizedStatus = String(order.status || '').trim().toUpperCase() diff --git a/frontend/src/components/payment/__tests__/PaymentStatusPanel.spec.ts b/frontend/src/components/payment/__tests__/PaymentStatusPanel.spec.ts index 09c5067172..ce99922878 100644 --- a/frontend/src/components/payment/__tests__/PaymentStatusPanel.spec.ts +++ b/frontend/src/components/payment/__tests__/PaymentStatusPanel.spec.ts @@ -226,6 +226,34 @@ describe('PaymentStatusPanel', () => { if (originalHidden) Object.defineProperty(document, 'hidden', originalHidden) }) + it('actively verifies a pending desktop Alipay order', async () => { + pollOrderStatus.mockResolvedValue(orderFactory('PENDING')) + verifyOrder.mockResolvedValue({ data: orderFactory('COMPLETED') }) + + const wrapper = mount(PaymentStatusPanel, { + props: { + orderId: 42, + amount: 88, + payAmount: 88, + qrCode: 'https://qr.alipay.com/desktop-order-42', + expiresAt: '2099-01-01T12:30:00Z', + paymentType: 'alipay', + orderType: 'balance', + outTradeNo: 'sub2_20260420abcd1234', + }, + global: { stubs: { Icon: true } }, + }) + + await flushPromises() + await vi.advanceTimersByTimeAsync(3000) + await flushPromises() + + expect(verifyOrder).toHaveBeenCalledWith('sub2_20260420abcd1234') + expect(wrapper.emitted('success')).toHaveLength(1) + + wrapper.unmount() + }) + it('keeps the QR fallback hidden until the Alipay app launch times out', async () => { const originalLocation = window.location const originalHidden = Object.getOwnPropertyDescriptor(document, 'hidden') From 28dde982cb5526d873204a50289928357c10f64f Mon Sep 17 00:00:00 2001 From: wucm667 Date: Thu, 3 Sep 2026 17:29:04 +0800 Subject: [PATCH 029/145] fix(openai): normalize automation heartbeat bootstrap --- .../openai_automation_bootstrap_test.go | 43 +++++++++++++-- .../handler/openai_gateway_handler.go | 52 ++++++++++++++++++- 2 files changed, 90 insertions(+), 5 deletions(-) diff --git a/backend/internal/handler/openai_automation_bootstrap_test.go b/backend/internal/handler/openai_automation_bootstrap_test.go index 7bb7fd68b1..698a3bbd5e 100644 --- a/backend/internal/handler/openai_automation_bootstrap_test.go +++ b/backend/internal/handler/openai_automation_bootstrap_test.go @@ -49,6 +49,20 @@ func TestNormalizeCodexAutomationBootstrapSupportedLastRunValues(t *testing.T) { } } +func TestNormalizeCodexAutomationBootstrapHeartbeat(t *testing.T) { + output := `wiki` + got, changed := normalizeCodexAutomationBootstrap(codexAutomationBootstrapBody(t, output, "")) + require.True(t, changed) + require.Equal(t, "message", gjson.GetBytes(got, "input.0.type").String()) + require.Equal(t, "user", gjson.GetBytes(got, "input.0.role").String()) + require.Equal(t, output, gjson.GetBytes(got, "input.0.content.0.text").String()) + require.False(t, gjson.GetBytes(got, "input.0.call_id").Exists()) + + again, changedAgain := normalizeCodexAutomationBootstrap(got) + require.False(t, changedAgain) + require.Equal(t, got, again) +} + func TestNormalizeCodexAutomationBootstrapRejectsUnsafeShapes(t *testing.T) { validOutput := codexAutomationBootstrap("wiki", "never", automationBootstrapPrompt) tests := []struct { @@ -75,10 +89,6 @@ func TestNormalizeCodexAutomationBootstrapRejectsUnsafeShapes(t *testing.T) { name: "real call context", body: []byte(`{"model":"gpt-5","input":[{"type":"function_call_output","namespace":"codex_app","name":"automation_update","output":` + mustJSON(t, validOutput) + `},{"type":"function_call","call_id":"call-1"}]}`), }, - { - name: "heartbeat output", - body: codexAutomationBootstrapBody(t, `wiki`, ""), - }, { name: "mismatched memory id", body: codexAutomationBootstrapBody(t, strings.Replace(validOutput, "/wiki/memory.md", "/other/memory.md", 1), ""), @@ -117,6 +127,31 @@ func TestNormalizeCodexAutomationBootstrapRejectsUnsafeShapes(t *testing.T) { } } +func TestNormalizeCodexAutomationBootstrapRejectsUnsafeHeartbeatShapes(t *testing.T) { + tests := []struct { + name string + output string + }{ + {name: "arbitrary tool output", output: `wiki`}, + {name: "root attribute", output: `wiki`}, + {name: "namespaced root", output: `wiki`}, + {name: "extra child", output: `wikiok`}, + {name: "nested id content", output: `wiki`}, + {name: "padded id", output: ` wiki `}, + {name: "unsafe id", output: `../wiki`}, + {name: "comment", output: `wiki`}, + {name: "trailing content", output: `wikiextra`}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + body := codexAutomationBootstrapBody(t, tt.output, "") + got, changed := normalizeCodexAutomationBootstrap(body) + require.False(t, changed) + require.Equal(t, body, got) + }) + } +} + func TestNormalizeCodexAutomationBootstrapPreservesOrderAndIsIdempotent(t *testing.T) { output := codexAutomationBootstrap("wiki", "never", automationBootstrapPrompt) body := []byte(`{"model":"gpt-5","input":[{"type":"message","role":"user","content":"before"},{"type":"function_call_output","namespace":"codex_app","name":"automation_update","output":` + mustJSON(t, output) + `},{"type":"message","role":"user","content":"after"}]}`) diff --git a/backend/internal/handler/openai_gateway_handler.go b/backend/internal/handler/openai_gateway_handler.go index ce67e07e85..c895b90c53 100644 --- a/backend/internal/handler/openai_gateway_handler.go +++ b/backend/internal/handler/openai_gateway_handler.go @@ -1756,7 +1756,7 @@ func isCodexAutomationCandidate(item map[string]any) bool { return false } output, ok := item["output"].(string) - return ok && validCodexAutomationBootstrap(output) + return ok && (validCodexAutomationBootstrap(output) || validCodexAutomationHeartbeat(output)) } func stringField(item map[string]any, key string) string { @@ -1834,6 +1834,56 @@ func validCodexAutomationLastRun(value string) bool { return err == nil && runAt.UnixMilli() == epochMillis } +func validCodexAutomationHeartbeat(value string) bool { + decoder := xml.NewDecoder(strings.NewReader(value)) + var rootSeen, automationIDSeen bool + var automationID bytes.Buffer + depth := 0 + for { + token, err := decoder.Token() + if err == io.EOF { + id := automationID.String() + return rootSeen && automationIDSeen && depth == 0 && + strings.TrimSpace(id) == id && validCodexAutomationID(id) + } + if err != nil { + return false + } + switch current := token.(type) { + case xml.StartElement: + depth++ + if current.Name.Space != "" || len(current.Attr) != 0 || depth > 2 { + return false + } + if depth == 1 { + if rootSeen || current.Name.Local != "heartbeat" { + return false + } + rootSeen = true + } else if automationIDSeen || current.Name.Local != "automation_id" { + return false + } + automationIDSeen = depth == 2 + case xml.EndElement: + if current.Name.Space != "" { + return false + } + depth-- + if depth < 0 { + return false + } + case xml.CharData: + if depth == 2 { + _, _ = automationID.Write(current) + } else if len(bytes.TrimSpace(current)) != 0 { + return false + } + case xml.Comment, xml.ProcInst, xml.Directive: + return false + } + } +} + func validCodexDelegationEnvelope(value string) bool { decoder := xml.NewDecoder(strings.NewReader(value)) var rootSeen, sourceSeen, inputSeen bool From de27905e88aa291acd2a292e2b439bce3de0f118 Mon Sep 17 00:00:00 2001 From: feeeei Date: Wed, 2 Sep 2026 17:24:39 +0800 Subject: [PATCH 030/145] =?UTF-8?q?feat(usage):=20=E7=94=A8=E9=87=8F?= =?UTF-8?q?=E8=AE=B0=E5=BD=95=E8=90=BD=E5=BA=93=E4=B8=8A=E6=B8=B8=E8=AF=B7?= =?UTF-8?q?=E6=B1=82=E6=A0=87=E8=AF=86=E5=B9=B6=E6=94=AF=E6=8C=81=E6=8C=89?= =?UTF-8?q?=E8=B4=A6=E6=88=B7=E9=85=8D=E7=BD=AE=E5=93=8D=E5=BA=94=E5=A4=B4?= =?UTF-8?q?=E5=90=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - usage_logs 新增 upstream_request_id 列(迁移 232)与仅索引非空行的 CONCURRENTLY 部分索引(迁移 233,runner 登记失效索引清理) - 账户 extra.upstream_request_id_header 指定要读取的上游响应头,只读该头;留空不记录;创建/更新/批量入口校验头名合法且不超过 64 字节 - ForwardResult/OpenAIForwardResult 携带 UpstreamHeaders,中央计费按账户解析落库,WS 轮次保持 NULL - 管理端用量明细新增默认隐藏的"上游ID"列,带与请求ID列一致的复制按钮,并同步导出 - 创建/编辑账户弹窗新增"上游ID"输入(UpstreamRequestIdHeaderField),提示按接入类型与平台分条列出常见头名 - HelpTooltip 悬停模式下指针移入提示框时保持打开,便于选中复制提示内容 --- .../internal/handler/admin/account_handler.go | 25 +++ backend/internal/handler/dto/mappers.go | 1 + backend/internal/handler/dto/types.go | 2 + .../internal/repository/migrations_runner.go | 4 + .../repository/usage_log_repo_insert.go | 15 +- .../usage_log_repo_insert_shape_unit_test.go | 145 ++++++++++++++++++ .../repository/usage_log_repo_query.go | 7 +- .../usage_log_repo_request_type_test.go | 6 + .../service/antigravity_gateway_claude.go | 1 + .../service/antigravity_gateway_compat.go | 1 + .../service/antigravity_gateway_gemini.go | 1 + .../service/gateway_anthropic_passthrough.go | 1 + backend/internal/service/gateway_bedrock.go | 1 + backend/internal/service/gateway_forward.go | 1 + .../gateway_forward_as_chat_completions.go | 2 + .../service/gateway_forward_as_responses.go | 2 + backend/internal/service/gateway_service.go | 6 +- .../service/gateway_upstream_response.go | 1 + .../internal/service/gateway_usage_billing.go | 1 + .../gemini_chat_completions_compat_service.go | 1 + .../service/gemini_messages_compat_service.go | 17 +- backend/internal/service/grok_audio.go | 11 +- backend/internal/service/grok_media.go | 2 + .../internal/service/openai_alpha_search.go | 12 +- backend/internal/service/openai_embeddings.go | 15 +- .../openai_gateway_chat_completions.go | 2 + ...teway_chat_completions_anthropic_native.go | 2 + .../openai_gateway_chat_completions_raw.go | 2 + .../service/openai_gateway_forward.go | 1 + .../internal/service/openai_gateway_grok.go | 1 + .../service/openai_gateway_messages.go | 2 + ...penai_gateway_messages_anthropic_native.go | 2 + .../openai_gateway_messages_chat_fallback.go | 3 + .../service/openai_gateway_passthrough.go | 1 + ...enai_gateway_responses_anthropic_native.go | 2 + .../openai_gateway_responses_chat_fallback.go | 4 + .../service/openai_gateway_service.go | 6 +- .../internal/service/openai_gateway_usage.go | 1 + backend/internal/service/openai_images.go | 3 + .../service/openai_images_responses.go | 2 + .../internal/service/upstream_request_id.go | 84 ++++++++++ .../service/upstream_request_id_test.go | 78 ++++++++++ backend/internal/service/usage_log.go | 4 + .../232_add_usage_log_upstream_request_id.sql | 5 + ...age_log_upstream_request_id_index_notx.sql | 4 + .../components/account/CreateAccountModal.vue | 37 +++-- .../components/account/EditAccountModal.vue | 27 ++++ .../account/UpstreamRequestIdHeaderField.vue | 78 ++++++++++ .../__tests__/CreateAccountModal.spec.ts | 21 +++ .../__tests__/EditAccountModal.spec.ts | 43 ++++++ .../UpstreamRequestIdHeaderField.spec.ts | 66 ++++++++ .../src/components/admin/usage/UsageTable.vue | 32 +++- .../admin/usage/__tests__/UsageTable.spec.ts | 31 ++++ .../src/components/common/HelpTooltip.vue | 18 ++- .../common/__tests__/HelpTooltip.spec.ts | 30 ++++ .../src/i18n/locales/en/admin/accounts.ts | 8 + .../src/i18n/locales/en/admin/resources.ts | 2 + .../src/i18n/locales/zh/admin/accounts.ts | 8 + .../src/i18n/locales/zh/admin/resources.ts | 2 + frontend/src/types/index.ts | 1 + frontend/src/views/admin/UsageView.vue | 19 ++- .../views/admin/__tests__/UsageView.spec.ts | 44 +++++- 62 files changed, 901 insertions(+), 56 deletions(-) create mode 100644 backend/internal/repository/usage_log_repo_insert_shape_unit_test.go create mode 100644 backend/internal/service/upstream_request_id.go create mode 100644 backend/internal/service/upstream_request_id_test.go create mode 100644 backend/migrations/232_add_usage_log_upstream_request_id.sql create mode 100644 backend/migrations/233_add_usage_log_upstream_request_id_index_notx.sql create mode 100644 frontend/src/components/account/UpstreamRequestIdHeaderField.vue create mode 100644 frontend/src/components/account/__tests__/UpstreamRequestIdHeaderField.spec.ts diff --git a/backend/internal/handler/admin/account_handler.go b/backend/internal/handler/admin/account_handler.go index 599a04a34e..495b8e7f0e 100644 --- a/backend/internal/handler/admin/account_handler.go +++ b/backend/internal/handler/admin/account_handler.go @@ -837,6 +837,10 @@ func (h *AccountHandler) Create(c *gin.Context) { } // base_rpm 输入校验:负值归零,超过 10000 截断 sanitizeExtraBaseRPM(req.Extra) + if err := service.ValidateUpstreamRequestIDHeaderExtra(req.Extra); err != nil { + response.ErrorFrom(c, err) + return + } // 确定是否跳过混合渠道检查 skipCheck := req.ConfirmMixedChannelRisk != nil && *req.ConfirmMixedChannelRisk @@ -970,6 +974,10 @@ func (h *AccountHandler) Update(c *gin.Context) { } // base_rpm 输入校验:负值归零,超过 10000 截断 sanitizeExtraBaseRPM(req.Extra) + if err := service.ValidateUpstreamRequestIDHeaderExtra(req.Extra); err != nil { + response.ErrorFrom(c, err) + return + } // 确定是否跳过混合渠道检查 skipCheck := req.ConfirmMixedChannelRisk != nil && *req.ConfirmMixedChannelRisk @@ -1424,6 +1432,10 @@ func (h *AccountHandler) ApplyOAuthCredentials(c *gin.Context) { response.ErrorFrom(c, err) return } + if err := service.ValidateUpstreamRequestIDHeaderExtra(req.Extra); err != nil { + response.ErrorFrom(c, err) + return + } // Drop SSO/password residue; re-auth must leave only OAuth tokens on disk. req.Credentials = service.SanitizeStoredCredentials(existing.Platform, req.Credentials) @@ -1897,6 +1909,15 @@ func (h *AccountHandler) BatchCreate(c *gin.Context) { // base_rpm 输入校验:负值归零,超过 10000 截断 sanitizeExtraBaseRPM(item.Extra) + if err := service.ValidateUpstreamRequestIDHeaderExtra(item.Extra); err != nil { + failed++ + results = append(results, gin.H{ + "name": item.Name, + "success": false, + "error": err.Error(), + }) + continue + } skipCheck := item.ConfirmMixedChannelRisk != nil && *item.ConfirmMixedChannelRisk @@ -2091,6 +2112,10 @@ func (h *AccountHandler) BulkUpdate(c *gin.Context) { } // base_rpm 输入校验:负值归零,超过 10000 截断 sanitizeExtraBaseRPM(req.Extra) + if err := service.ValidateUpstreamRequestIDHeaderExtra(req.Extra); err != nil { + response.ErrorFrom(c, err) + return + } // 确定是否跳过混合渠道检查 skipCheck := req.ConfirmMixedChannelRisk != nil && *req.ConfirmMixedChannelRisk diff --git a/backend/internal/handler/dto/mappers.go b/backend/internal/handler/dto/mappers.go index e6759a110c..bcee796561 100644 --- a/backend/internal/handler/dto/mappers.go +++ b/backend/internal/handler/dto/mappers.go @@ -723,6 +723,7 @@ func UsageLogFromServiceAdmin(l *service.UsageLog) *AdminUsageLog { UpstreamModelMismatch: l.UpstreamModelMismatch, ChannelID: l.ChannelID, ModelMappingChain: l.ModelMappingChain, + UpstreamRequestID: l.UpstreamRequestID, BillingTier: l.BillingTier, AccountRateMultiplier: l.AccountRateMultiplier, AccountStatsCost: l.AccountStatsCost, diff --git a/backend/internal/handler/dto/types.go b/backend/internal/handler/dto/types.go index 7fb8373810..57b80d645e 100644 --- a/backend/internal/handler/dto/types.go +++ b/backend/internal/handler/dto/types.go @@ -588,6 +588,8 @@ type AdminUsageLog struct { ChannelID *int64 `json:"channel_id,omitempty"` // ModelMappingChain 模型映射链,如 "a→b→c" ModelMappingChain *string `json:"model_mapping_chain,omitempty"` + // UpstreamRequestID 是直接上游声明的请求标识,仅管理端可见。 + UpstreamRequestID *string `json:"upstream_request_id,omitempty"` // BillingTier 计费层级标签(per_request/image 模式) BillingTier *string `json:"billing_tier,omitempty"` diff --git a/backend/internal/repository/migrations_runner.go b/backend/internal/repository/migrations_runner.go index 0b786616a5..8c69eb50dd 100644 --- a/backend/internal/repository/migrations_runner.go +++ b/backend/internal/repository/migrations_runner.go @@ -62,6 +62,8 @@ const usageLogsUpstreamModelMismatchIndex = "idx_usage_logs_upstream_model_misma const usageLogsEffectiveModelIndexesMigration = "226_add_usage_log_effective_model_indexes_notx.sql" const usageLogsEffectiveRequestedModelIndex = "idx_usage_logs_effective_requested_model_created" const usageLogsEffectiveUpstreamModelIndex = "idx_usage_logs_effective_upstream_model_created" +const usageLogsUpstreamRequestIDIndexMigration = "233_add_usage_log_upstream_request_id_index_notx.sql" +const usageLogsUpstreamRequestIDIndex = "idx_usage_logs_upstream_request_id" type migrationChecksumCompatibilityRule struct { fileChecksum string @@ -305,6 +307,8 @@ func prepareNonTransactionalMigration(ctx context.Context, db migrationConnectio } } return nil + case usageLogsUpstreamRequestIDIndexMigration: + return dropInvalidIndexIfPresent(ctx, db, usageLogsUpstreamRequestIDIndex) default: return nil } diff --git a/backend/internal/repository/usage_log_repo_insert.go b/backend/internal/repository/usage_log_repo_insert.go index f70706d481..e004433f3d 100644 --- a/backend/internal/repository/usage_log_repo_insert.go +++ b/backend/internal/repository/usage_log_repo_insert.go @@ -82,6 +82,7 @@ var usageLogInsertArgTypes = [...]string{ "text", // billing_tier "text", // billing_mode "numeric", // account_stats_cost + "text", // upstream_request_id "text", // session_id "boolean", // native_compaction_v2 "timestamptz", // created_at @@ -282,6 +283,7 @@ func (r *usageLogRepository) createSingle(ctx context.Context, sqlq sqlExecutor, billing_tier, billing_mode, account_stats_cost, + upstream_request_id, session_id, native_compaction_v2, created_at @@ -291,7 +293,7 @@ func (r *usageLogRepository) createSingle(ctx context.Context, sqlq sqlExecutor, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, - $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44, $45, $46, $47, $48, $49, $50, $51, $52, $53, $54, $55, $56, $57, $58, $59, $60, $61 + $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44, $45, $46, $47, $48, $49, $50, $51, $52, $53, $54, $55, $56, $57, $58, $59, $60, $61, $62 ) ON CONFLICT (request_id, api_key_id) DO NOTHING RETURNING id, created_at @@ -741,6 +743,7 @@ func buildUsageLogBatchInsertQuery(keys []string, preparedByKey map[string]usage billing_tier, billing_mode, account_stats_cost, + upstream_request_id, session_id, native_compaction_v2, created_at @@ -835,6 +838,7 @@ func buildUsageLogBatchInsertQuery(keys []string, preparedByKey map[string]usage billing_tier, billing_mode, account_stats_cost, + upstream_request_id, session_id, native_compaction_v2, created_at @@ -898,6 +902,7 @@ func buildUsageLogBatchInsertQuery(keys []string, preparedByKey map[string]usage billing_tier, billing_mode, account_stats_cost, + upstream_request_id, session_id, native_compaction_v2, created_at @@ -1001,6 +1006,7 @@ func buildUsageLogBestEffortInsertQuery(preparedList []usageLogInsertPrepared) ( billing_tier, billing_mode, account_stats_cost, + upstream_request_id, session_id, native_compaction_v2, created_at @@ -1090,6 +1096,7 @@ func buildUsageLogBestEffortInsertQuery(preparedList []usageLogInsertPrepared) ( billing_tier, billing_mode, account_stats_cost, + upstream_request_id, session_id, native_compaction_v2, created_at @@ -1153,6 +1160,7 @@ func buildUsageLogBestEffortInsertQuery(preparedList []usageLogInsertPrepared) ( billing_tier, billing_mode, account_stats_cost, + upstream_request_id, session_id, native_compaction_v2, created_at @@ -1224,6 +1232,7 @@ func execUsageLogInsertNoResult(ctx context.Context, sqlq sqlExecutor, prepared billing_tier, billing_mode, account_stats_cost, + upstream_request_id, session_id, native_compaction_v2, created_at @@ -1233,7 +1242,7 @@ func execUsageLogInsertNoResult(ctx context.Context, sqlq sqlExecutor, prepared $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, - $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44, $45, $46, $47, $48, $49, $50, $51, $52, $53, $54, $55, $56, $57, $58, $59, $60, $61 + $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44, $45, $46, $47, $48, $49, $50, $51, $52, $53, $54, $55, $56, $57, $58, $59, $60, $61, $62 ) ON CONFLICT (request_id, api_key_id) DO NOTHING `, prepared.args...) @@ -1275,6 +1284,7 @@ func prepareUsageLogInsert(log *service.UsageLog) usageLogInsertPrepared { modelMappingChain := nullString(log.ModelMappingChain) billingTier := nullString(log.BillingTier) billingMode := nullString(log.BillingMode) + upstreamRequestID := nullString(log.UpstreamRequestID) sessionID := nullString(log.SessionID) requestedModel := strings.TrimSpace(log.RequestedModel) if requestedModel == "" { @@ -1353,6 +1363,7 @@ func prepareUsageLogInsert(log *service.UsageLog) usageLogInsertPrepared { billingTier, billingMode, log.AccountStatsCost, // account_stats_cost + upstreamRequestID, // upstream_request_id sessionID, // session_id log.NativeCompactionV2, createdAt, diff --git a/backend/internal/repository/usage_log_repo_insert_shape_unit_test.go b/backend/internal/repository/usage_log_repo_insert_shape_unit_test.go new file mode 100644 index 0000000000..74f6aafb60 --- /dev/null +++ b/backend/internal/repository/usage_log_repo_insert_shape_unit_test.go @@ -0,0 +1,145 @@ +//go:build unit + +package repository + +import ( + "context" + "database/sql" + "regexp" + "strconv" + "strings" + "testing" + "time" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/stretchr/testify/require" +) + +var ( + usageLogStaticInsertShapeRe = regexp.MustCompile(`(?s)INSERT INTO usage_logs \((.*?)\) VALUES \((.*?)\)`) + usageLogPlaceholderRe = regexp.MustCompile(`\$(\d+)`) +) + +// newSQLCapturingMock 返回把实际下发 SQL 记录到 captured 的 sqlmock;语句一律视为匹配, +// 参数仍由 WithArgs 校验。 +func newSQLCapturingMock(t *testing.T, captured *[]string) (*sql.DB, sqlmock.Sqlmock) { + t.Helper() + matcher := sqlmock.QueryMatcherFunc(func(_, actualSQL string) error { + *captured = append(*captured, actualSQL) + return nil + }) + db, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(matcher)) + require.NoError(t, err) + t.Cleanup(func() { _ = db.Close() }) + return db, mock +} + +// requireStaticInsertMatchesArgTypes 断言手写的 INSERT:列清单长度与 VALUES 占位符数量 +// 都等于 usageLogInsertArgTypes,且占位符恰为 $1..$N 各出现一次。 +func requireStaticInsertMatchesArgTypes(t *testing.T, query string) { + t.Helper() + m := usageLogStaticInsertShapeRe.FindStringSubmatch(query) + require.Len(t, m, 3, "unrecognised INSERT shape:\n%s", query) + + want := len(usageLogInsertArgTypes) + columns := 0 + for _, col := range strings.Split(m[1], ",") { + if strings.TrimSpace(col) != "" { + columns++ + } + } + require.Equal(t, want, columns, "INSERT column list must match usageLogInsertArgTypes") + + seen := make(map[int]struct{}, want) + for _, ph := range usageLogPlaceholderRe.FindAllStringSubmatch(m[2], -1) { + n, err := strconv.Atoi(ph[1]) + require.NoError(t, err) + _, dup := seen[n] + require.False(t, dup, "duplicate placeholder $%d", n) + seen[n] = struct{}{} + } + require.Len(t, seen, want, "VALUES placeholder count must match usageLogInsertArgTypes") + for i := 1; i <= want; i++ { + _, ok := seen[i] + require.True(t, ok, "missing placeholder $%d", i) + } +} + +// TestUsageLogStaticInsertShape_PlaceholdersMatchArgTypes 覆盖两条不经占位符生成器、 +// 直接手写 $1..$N 的 INSERT 路径,防止加列后漏补占位符只在集成测试才暴露。 +func TestUsageLogStaticInsertShape_PlaceholdersMatchArgTypes(t *testing.T) { + upstreamRequestID := "20260902080329-oneapi" + log := &service.UsageLog{ + UserID: 1, + APIKeyID: 2, + AccountID: 3, + RequestID: "client:insert-shape", + UpstreamRequestID: &upstreamRequestID, + Model: "claude-3", + InputTokens: 10, + CreatedAt: time.Date(2026, 9, 2, 12, 0, 0, 0, time.UTC), + } + prepared := prepareUsageLogInsert(log) + args := anySliceToDriverValues(prepared.args) + + t.Run("createSingle", func(t *testing.T) { + var captured []string + db, mock := newSQLCapturingMock(t, &captured) + repo := &usageLogRepository{sql: db} + + mock.ExpectQuery("INSERT INTO usage_logs"). + WithArgs(args...). + WillReturnRows(sqlmock.NewRows([]string{"id", "created_at"}).AddRow(int64(1), log.CreatedAt)) + + inserted, err := repo.Create(context.Background(), log) + require.NoError(t, err) + require.True(t, inserted) + require.NoError(t, mock.ExpectationsWereMet()) + require.Len(t, captured, 1) + requireStaticInsertMatchesArgTypes(t, captured[0]) + }) + + t.Run("execUsageLogInsertNoResult", func(t *testing.T) { + var captured []string + db, mock := newSQLCapturingMock(t, &captured) + + mock.ExpectExec("INSERT INTO usage_logs"). + WithArgs(args...). + WillReturnResult(sqlmock.NewResult(0, 1)) + + require.NoError(t, execUsageLogInsertNoResult(context.Background(), db, prepared)) + require.NoError(t, mock.ExpectationsWereMet()) + require.Len(t, captured, 1) + requireStaticInsertMatchesArgTypes(t, captured[0]) + }) +} + +// TestPrepareUsageLogInsert_UpstreamRequestIDArgWiring 把 upstream_request_id 钉在 +// session_id 之前,与参数类型表保持同位;缺失时落 NULL 而不是空串。 +func TestPrepareUsageLogInsert_UpstreamRequestIDArgWiring(t *testing.T) { + upstreamRequestID := "req_upstream_123" + prepared := prepareUsageLogInsert(&service.UsageLog{ + UserID: 1, + APIKeyID: 2, + RequestID: "client:wiring", + Model: "gpt-5", + UpstreamRequestID: &upstreamRequestID, + CreatedAt: time.Now().UTC(), + }) + require.Len(t, prepared.args, len(usageLogInsertArgTypes)) + + idx := len(prepared.args) - 4 + arg, ok := prepared.args[idx].(sql.NullString) + require.True(t, ok, "upstream_request_id arg should be sql.NullString, got %T", prepared.args[idx]) + require.True(t, arg.Valid) + require.Equal(t, upstreamRequestID, arg.String) + require.Equal(t, "text", usageLogInsertArgTypes[idx]) + + absent := prepareUsageLogInsert(&service.UsageLog{UserID: 1, APIKeyID: 2, RequestID: "client:absent", Model: "gpt-5", CreatedAt: time.Now().UTC()}) + nullArg, ok := absent.args[idx].(sql.NullString) + require.True(t, ok) + require.False(t, nullArg.Valid, "absent upstream request id must be NULL") + + require.Contains(t, usageLogSelectColumns, "upstream_request_id") +} diff --git a/backend/internal/repository/usage_log_repo_query.go b/backend/internal/repository/usage_log_repo_query.go index 556dd3ef88..5f637e1e52 100644 --- a/backend/internal/repository/usage_log_repo_query.go +++ b/backend/internal/repository/usage_log_repo_query.go @@ -19,7 +19,7 @@ import ( "github.com/Wei-Shaw/sub2api/internal/service" ) -const usageLogSelectColumns = "id, user_id, api_key_id, account_id, request_id, model, requested_model, upstream_model, upstream_response_model, upstream_model_mismatch, group_id, subscription_id, input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens, cache_creation_5m_tokens, cache_creation_1h_tokens, image_output_tokens, image_output_cost, image_input_tokens, image_input_cost, input_cost, output_cost, cache_creation_cost, cache_read_cost, total_cost, actual_cost, rate_multiplier, account_rate_multiplier, billing_type, request_type, stream, openai_ws_mode, duration_ms, first_token_ms, user_agent, ip_address, image_count, image_size, image_input_size, image_output_size, image_size_source, image_size_breakdown, video_count, video_resolution, video_duration_seconds, service_tier, reasoning_effort, requested_reasoning_effort, inbound_endpoint, upstream_endpoint, cache_ttl_overridden, long_context_billing_applied, channel_id, model_mapping_chain, billing_tier, billing_mode, account_stats_cost, session_id, native_compaction_v2, created_at" +const usageLogSelectColumns = "id, user_id, api_key_id, account_id, request_id, model, requested_model, upstream_model, upstream_response_model, upstream_model_mismatch, group_id, subscription_id, input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens, cache_creation_5m_tokens, cache_creation_1h_tokens, image_output_tokens, image_output_cost, image_input_tokens, image_input_cost, input_cost, output_cost, cache_creation_cost, cache_read_cost, total_cost, actual_cost, rate_multiplier, account_rate_multiplier, billing_type, request_type, stream, openai_ws_mode, duration_ms, first_token_ms, user_agent, ip_address, image_count, image_size, image_input_size, image_output_size, image_size_source, image_size_breakdown, video_count, video_resolution, video_duration_seconds, service_tier, reasoning_effort, requested_reasoning_effort, inbound_endpoint, upstream_endpoint, cache_ttl_overridden, long_context_billing_applied, channel_id, model_mapping_chain, billing_tier, billing_mode, account_stats_cost, upstream_request_id, session_id, native_compaction_v2, created_at" func (r *usageLogRepository) GetByID(ctx context.Context, id int64) (log *service.UsageLog, err error) { query := "SELECT " + usageLogSelectColumns + " FROM usage_logs WHERE id = $1" @@ -499,6 +499,7 @@ func scanUsageLog(scanner interface{ Scan(...any) error }) (*service.UsageLog, e billingTier sql.NullString billingMode sql.NullString accountStatsCost sql.NullFloat64 + upstreamRequestID sql.NullString sessionID sql.NullString nativeCompactionV2 bool createdAt time.Time @@ -564,6 +565,7 @@ func scanUsageLog(scanner interface{ Scan(...any) error }) (*service.UsageLog, e &billingTier, &billingMode, &accountStatsCost, + &upstreamRequestID, &sessionID, &nativeCompactionV2, &createdAt, @@ -700,6 +702,9 @@ func scanUsageLog(scanner interface{ Scan(...any) error }) (*service.UsageLog, e if sessionID.Valid { log.SessionID = &sessionID.String } + if upstreamRequestID.Valid { + log.UpstreamRequestID = &upstreamRequestID.String + } return log, nil } diff --git a/backend/internal/repository/usage_log_repo_request_type_test.go b/backend/internal/repository/usage_log_repo_request_type_test.go index 434bb7163f..87d6c8672c 100644 --- a/backend/internal/repository/usage_log_repo_request_type_test.go +++ b/backend/internal/repository/usage_log_repo_request_type_test.go @@ -99,6 +99,7 @@ func TestUsageLogRepositoryCreateSyncRequestTypeAndLegacyFields(t *testing.T) { sqlmock.AnyArg(), // billing_tier sqlmock.AnyArg(), // billing_mode sqlmock.AnyArg(), // account_stats_cost + sqlmock.AnyArg(), // upstream_request_id sqlmock.AnyArg(), // session_id log.NativeCompactionV2, createdAt, @@ -193,6 +194,7 @@ func TestUsageLogRepositoryCreate_PersistsServiceTier(t *testing.T) { sqlmock.AnyArg(), // billing_tier sqlmock.AnyArg(), // billing_mode sqlmock.AnyArg(), // account_stats_cost + sqlmock.AnyArg(), // upstream_request_id sqlmock.AnyArg(), // session_id log.NativeCompactionV2, createdAt, @@ -955,6 +957,7 @@ func TestScanUsageLogRequestTypeAndLegacyFallback(t *testing.T) { sql.NullString{}, sql.NullString{}, sql.NullFloat64{}, + sql.NullString{}, // upstream_request_id sql.NullString{}, false, // native_compaction_v2 now, @@ -1034,6 +1037,7 @@ func TestScanUsageLogRequestTypeAndLegacyFallback(t *testing.T) { sql.NullString{}, // billing_tier sql.NullString{}, // billing_mode sql.NullFloat64{}, // account_stats_cost + sql.NullString{}, // upstream_request_id sql.NullString{}, // session_id false, // native_compaction_v2 now, @@ -1096,6 +1100,7 @@ func TestScanUsageLogRequestTypeAndLegacyFallback(t *testing.T) { sql.NullString{}, // billing_tier sql.NullString{}, // billing_mode sql.NullFloat64{}, // account_stats_cost + sql.NullString{}, // upstream_request_id sql.NullString{}, // session_id true, // native_compaction_v2 now, @@ -1159,6 +1164,7 @@ func TestScanUsageLogRequestTypeAndLegacyFallback(t *testing.T) { sql.NullString{}, // billing_tier sql.NullString{}, // billing_mode sql.NullFloat64{}, // account_stats_cost + sql.NullString{}, // upstream_request_id sql.NullString{}, // session_id false, // native_compaction_v2 now, diff --git a/backend/internal/service/antigravity_gateway_claude.go b/backend/internal/service/antigravity_gateway_claude.go index 3511f60288..1a94bbd747 100644 --- a/backend/internal/service/antigravity_gateway_claude.go +++ b/backend/internal/service/antigravity_gateway_claude.go @@ -450,6 +450,7 @@ func (s *AntigravityGatewayService) Forward(ctx context.Context, c *gin.Context, return &ForwardResult{ RequestID: requestID, + UpstreamHeaders: resp.Header, Usage: *usage, Model: originalModel, UpstreamModel: billingModel, diff --git a/backend/internal/service/antigravity_gateway_compat.go b/backend/internal/service/antigravity_gateway_compat.go index 1d6d39e1aa..f4e5911819 100644 --- a/backend/internal/service/antigravity_gateway_compat.go +++ b/backend/internal/service/antigravity_gateway_compat.go @@ -364,6 +364,7 @@ func (s *AntigravityGatewayService) consumeAntigravityCompatResponse( return &ForwardResult{ RequestID: requestID, + UpstreamHeaders: resp.Header, Usage: *streamResult.usage, Model: call.request.originalModel, UpstreamModel: call.billingModel, diff --git a/backend/internal/service/antigravity_gateway_gemini.go b/backend/internal/service/antigravity_gateway_gemini.go index 77a7be3df7..520291b452 100644 --- a/backend/internal/service/antigravity_gateway_gemini.go +++ b/backend/internal/service/antigravity_gateway_gemini.go @@ -434,6 +434,7 @@ handleSuccess: return &ForwardResult{ RequestID: requestID, + UpstreamHeaders: resp.Header, Usage: *usage, Model: originalModel, UpstreamModel: forwardedModel, diff --git a/backend/internal/service/gateway_anthropic_passthrough.go b/backend/internal/service/gateway_anthropic_passthrough.go index bf899211e8..664a93fbc7 100644 --- a/backend/internal/service/gateway_anthropic_passthrough.go +++ b/backend/internal/service/gateway_anthropic_passthrough.go @@ -272,6 +272,7 @@ func (s *GatewayService) forwardAnthropicAPIKeyPassthroughWithInput( return &ForwardResult{ RequestID: resp.Header.Get("x-request-id"), + UpstreamHeaders: resp.Header, Usage: *usage, Model: input.OriginalModel, UpstreamModel: input.RequestModel, diff --git a/backend/internal/service/gateway_bedrock.go b/backend/internal/service/gateway_bedrock.go index 77baf3f29b..457f233192 100644 --- a/backend/internal/service/gateway_bedrock.go +++ b/backend/internal/service/gateway_bedrock.go @@ -159,6 +159,7 @@ func (s *GatewayService) forwardBedrock( return &ForwardResult{ RequestID: resp.Header.Get("x-amzn-requestid"), + UpstreamHeaders: resp.Header, Usage: *usage, Model: reqModel, UpstreamModel: mappedModel, diff --git a/backend/internal/service/gateway_forward.go b/backend/internal/service/gateway_forward.go index 2571079403..a370371d89 100644 --- a/backend/internal/service/gateway_forward.go +++ b/backend/internal/service/gateway_forward.go @@ -862,6 +862,7 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A return &ForwardResult{ RequestID: resp.Header.Get("x-request-id"), + UpstreamHeaders: resp.Header, Usage: *usage, Model: originalModel, // 使用原始模型用于计费和日志 UpstreamModel: mappedModel, diff --git a/backend/internal/service/gateway_forward_as_chat_completions.go b/backend/internal/service/gateway_forward_as_chat_completions.go index b3d6c565ba..60b8bb6506 100644 --- a/backend/internal/service/gateway_forward_as_chat_completions.go +++ b/backend/internal/service/gateway_forward_as_chat_completions.go @@ -337,6 +337,7 @@ func (s *GatewayService) handleCCBufferedFromAnthropic( return &ForwardResult{ RequestID: requestID, + UpstreamHeaders: resp.Header, Usage: usage, Model: originalModel, UpstreamModel: mappedModel, @@ -389,6 +390,7 @@ func (s *GatewayService) handleCCStreamingFromAnthropic( resultWithUsage := func() *ForwardResult { return &ForwardResult{ RequestID: requestID, + UpstreamHeaders: resp.Header, Usage: usage, Model: originalModel, UpstreamModel: mappedModel, diff --git a/backend/internal/service/gateway_forward_as_responses.go b/backend/internal/service/gateway_forward_as_responses.go index 7c6cde8328..49cf40bde5 100644 --- a/backend/internal/service/gateway_forward_as_responses.go +++ b/backend/internal/service/gateway_forward_as_responses.go @@ -466,6 +466,7 @@ func (s *GatewayService) handleResponsesBufferedStreamingResponse( return &ForwardResult{ RequestID: requestID, + UpstreamHeaders: resp.Header, Usage: usage, Model: originalModel, UpstreamModel: mappedModel, @@ -514,6 +515,7 @@ func (s *GatewayService) handleResponsesStreamingResponse( resultWithUsage := func() *ForwardResult { return &ForwardResult{ RequestID: requestID, + UpstreamHeaders: resp.Header, Usage: usage, Model: originalModel, UpstreamModel: mappedModel, diff --git a/backend/internal/service/gateway_service.go b/backend/internal/service/gateway_service.go index 33652e733c..54ebcfde18 100644 --- a/backend/internal/service/gateway_service.go +++ b/backend/internal/service/gateway_service.go @@ -610,8 +610,10 @@ type AudioUsage struct { type ForwardResult struct { RequestID string - Usage ClaudeUsage - Model string + // UpstreamHeaders 是直接上游的响应头,用于按账户配置解析上游请求标识。 + UpstreamHeaders http.Header + Usage ClaudeUsage + Model string // UpstreamModel is the actual upstream model after mapping. // Prefer empty when it is identical to Model; persistence normalizes equal values away as no-op mappings. UpstreamModel string diff --git a/backend/internal/service/gateway_upstream_response.go b/backend/internal/service/gateway_upstream_response.go index 5663333a91..7c173ca456 100644 --- a/backend/internal/service/gateway_upstream_response.go +++ b/backend/internal/service/gateway_upstream_response.go @@ -675,6 +675,7 @@ func partialStreamUsageResult(c *gin.Context, resp *http.Response, streamResult } return &ForwardResult{ RequestID: resp.Header.Get("x-request-id"), + UpstreamHeaders: resp.Header, Usage: *streamResult.usage, Model: model, UpstreamModel: upstreamModel, diff --git a/backend/internal/service/gateway_usage_billing.go b/backend/internal/service/gateway_usage_billing.go index 069758baae..e134fbdb17 100644 --- a/backend/internal/service/gateway_usage_billing.go +++ b/backend/internal/service/gateway_usage_billing.go @@ -1143,6 +1143,7 @@ func (s *GatewayService) buildRecordUsageLog( APIKeyID: apiKey.ID, AccountID: account.ID, RequestID: requestID, + UpstreamRequestID: usageUpstreamRequestIDPtr(account, result.UpstreamHeaders, false), Model: result.Model, RequestedModel: requestedModel, UpstreamModel: optionalTrimmedStringPtr(result.UpstreamModel), diff --git a/backend/internal/service/gemini_chat_completions_compat_service.go b/backend/internal/service/gemini_chat_completions_compat_service.go index a56a1b3ebc..21abe5f894 100644 --- a/backend/internal/service/gemini_chat_completions_compat_service.go +++ b/backend/internal/service/gemini_chat_completions_compat_service.go @@ -290,6 +290,7 @@ func (s *GeminiMessagesCompatService) forwardClaudeBodyAsChatCompletions( return &ForwardResult{ RequestID: requestID, + UpstreamHeaders: resp.Header, Usage: *usage, Model: originalModel, UpstreamModel: mappedModel, diff --git a/backend/internal/service/gemini_messages_compat_service.go b/backend/internal/service/gemini_messages_compat_service.go index 5f9854167d..51ab2befb0 100644 --- a/backend/internal/service/gemini_messages_compat_service.go +++ b/backend/internal/service/gemini_messages_compat_service.go @@ -1103,6 +1103,7 @@ func (s *GeminiMessagesCompatService) Forward(ctx context.Context, c *gin.Contex return &ForwardResult{ RequestID: requestID, + UpstreamHeaders: resp.Header, Usage: *usage, Model: originalModel, UpstreamModel: mappedModel, @@ -1451,13 +1452,14 @@ func (s *GeminiMessagesCompatService) ForwardNative(ctx context.Context, c *gin. estimated := estimateGeminiCountTokens(body) c.JSON(http.StatusOK, map[string]any{"totalTokens": estimated}) return &ForwardResult{ - RequestID: requestID, - Usage: ClaudeUsage{}, - Model: originalModel, - UpstreamModel: mappedModel, - Stream: false, - Duration: time.Since(startTime), - FirstTokenMs: nil, + RequestID: requestID, + UpstreamHeaders: resp.Header, + Usage: ClaudeUsage{}, + Model: originalModel, + UpstreamModel: mappedModel, + Stream: false, + Duration: time.Since(startTime), + FirstTokenMs: nil, }, nil } @@ -1604,6 +1606,7 @@ func (s *GeminiMessagesCompatService) ForwardNative(ctx context.Context, c *gin. return &ForwardResult{ RequestID: requestID, + UpstreamHeaders: resp.Header, Usage: *usage, Model: originalModel, UpstreamModel: mappedModel, diff --git a/backend/internal/service/grok_audio.go b/backend/internal/service/grok_audio.go index 0cd138cad5..c726b339df 100644 --- a/backend/internal/service/grok_audio.go +++ b/backend/internal/service/grok_audio.go @@ -111,11 +111,12 @@ func (s *OpenAIGatewayService) ForwardGrokVoice(ctx context.Context, c *gin.Cont upstreamID := firstNonEmpty(resp.Header.Get("x-request-id"), resp.Header.Get("xai-request-id")) return &OpenAIForwardResult{ // Forced durable money-event id so usage_billing_dedup cannot collapse under a reused client id. - RequestID: StableGrokAudioBillingRequestID(upstreamID), - Model: baseEndpoint, - UpstreamModel: baseEndpoint, - Duration: time.Since(started), - AudioUsage: audioUsage, + RequestID: StableGrokAudioBillingRequestID(upstreamID), + UpstreamHeaders: resp.Header, + Model: baseEndpoint, + UpstreamModel: baseEndpoint, + Duration: time.Since(started), + AudioUsage: audioUsage, }, nil } diff --git a/backend/internal/service/grok_media.go b/backend/internal/service/grok_media.go index 85dd1b14ec..eb4e9fc5bd 100644 --- a/backend/internal/service/grok_media.go +++ b/backend/internal/service/grok_media.go @@ -739,6 +739,7 @@ func (s *OpenAIGatewayService) ForwardGrokMedia( } return &OpenAIForwardResult{ RequestID: requestIDHeader, + UpstreamHeaders: resp.Header, ResponseID: usage.ResponseID, Usage: usage.Usage, Model: resultModel, @@ -873,6 +874,7 @@ func (s *OpenAIGatewayService) forwardGrokMediaVideoContent( // (same path as status polling). Pending snapshot is merged in the handler. result := &OpenAIForwardResult{ RequestID: contentRequestID, + UpstreamHeaders: contentResp.Header, ResponseHeaders: contentResp.Header.Clone(), Duration: time.Since(startTime), } diff --git a/backend/internal/service/openai_alpha_search.go b/backend/internal/service/openai_alpha_search.go index ac242f050c..caff28208b 100644 --- a/backend/internal/service/openai_alpha_search.go +++ b/backend/internal/service/openai_alpha_search.go @@ -128,11 +128,12 @@ func (s *OpenAIGatewayService) ForwardAlphaSearch(ctx context.Context, c *gin.Co return nil, nil } return &OpenAIForwardResult{ - RequestID: strings.TrimSpace(resp.Header.Get("x-request-id")), - Model: requestedModel, - UpstreamModel: upstreamModel, - Duration: time.Since(upstreamStart), - WebSearchCalls: 1, + RequestID: strings.TrimSpace(resp.Header.Get("x-request-id")), + UpstreamHeaders: resp.Header, + Model: requestedModel, + UpstreamModel: upstreamModel, + Duration: time.Since(upstreamStart), + WebSearchCalls: 1, }, nil } @@ -212,6 +213,7 @@ func (s *OpenAIGatewayService) forwardAlphaSearchViaResponsesWebSearch( c.Data(http.StatusOK, "application/json", alphaRespBody) return &OpenAIForwardResult{ RequestID: strings.TrimSpace(resp.Header.Get("x-request-id")), + UpstreamHeaders: resp.Header, Model: requestedModel, UpstreamModel: upstreamModel, UpstreamEndpoint: "/v1/responses", diff --git a/backend/internal/service/openai_embeddings.go b/backend/internal/service/openai_embeddings.go index 87b1904923..6512c2e3d6 100644 --- a/backend/internal/service/openai_embeddings.go +++ b/backend/internal/service/openai_embeddings.go @@ -160,13 +160,14 @@ func (s *OpenAIGatewayService) ForwardEmbeddings( writeOpenAIEmbeddingsUpstreamResponse(c, resp, respBody, s.responseHeaderFilter) return &OpenAIForwardResult{ - RequestID: firstNonEmptyString(resp.Header.Get("x-request-id"), resp.Header.Get("request-id")), - Usage: extractOpenAIEmbeddingsUsage(respBody), - Model: originalModel, - BillingModel: billingModel, - UpstreamModel: upstreamModel, - Stream: false, - Duration: time.Since(startTime), + RequestID: firstNonEmptyString(resp.Header.Get("x-request-id"), resp.Header.Get("request-id")), + UpstreamHeaders: resp.Header, + Usage: extractOpenAIEmbeddingsUsage(respBody), + Model: originalModel, + BillingModel: billingModel, + UpstreamModel: upstreamModel, + Stream: false, + Duration: time.Since(startTime), }, nil } diff --git a/backend/internal/service/openai_gateway_chat_completions.go b/backend/internal/service/openai_gateway_chat_completions.go index c9a7e34b6a..6507d8b57c 100644 --- a/backend/internal/service/openai_gateway_chat_completions.go +++ b/backend/internal/service/openai_gateway_chat_completions.go @@ -577,6 +577,7 @@ func (s *OpenAIGatewayService) handleChatBufferedStreamingResponse( result := &OpenAIForwardResult{ RequestID: requestID, + UpstreamHeaders: resp.Header, Usage: usage, Model: originalModel, BillingModel: billingModel, @@ -698,6 +699,7 @@ func (s *OpenAIGatewayService) handleChatStreamingResponse( resultWithUsage := func() *OpenAIForwardResult { out := &OpenAIForwardResult{ RequestID: requestID, + UpstreamHeaders: resp.Header, Usage: usage, Model: originalModel, BillingModel: billingModel, diff --git a/backend/internal/service/openai_gateway_chat_completions_anthropic_native.go b/backend/internal/service/openai_gateway_chat_completions_anthropic_native.go index 6e24fec392..1f9262055e 100644 --- a/backend/internal/service/openai_gateway_chat_completions_anthropic_native.go +++ b/backend/internal/service/openai_gateway_chat_completions_anthropic_native.go @@ -281,6 +281,7 @@ func (s *OpenAIGatewayService) handleCCBufferedFromNativeAnthropic( return &OpenAIForwardResult{ RequestID: requestID, + UpstreamHeaders: resp.Header, Usage: claudeUsageToOpenAIUsage(&usage), Model: originalModel, BillingModel: billingModel, @@ -336,6 +337,7 @@ func (s *OpenAIGatewayService) handleCCStreamingFromNativeAnthropic( resultWithUsage := func() *OpenAIForwardResult { return &OpenAIForwardResult{ RequestID: requestID, + UpstreamHeaders: resp.Header, Usage: claudeUsageToOpenAIUsage(&usage), Model: originalModel, BillingModel: billingModel, diff --git a/backend/internal/service/openai_gateway_chat_completions_raw.go b/backend/internal/service/openai_gateway_chat_completions_raw.go index a98c11e5e8..7f95b05a74 100644 --- a/backend/internal/service/openai_gateway_chat_completions_raw.go +++ b/backend/internal/service/openai_gateway_chat_completions_raw.go @@ -356,6 +356,7 @@ func (s *OpenAIGatewayService) streamRawChatCompletions( resultWithUsage := func() *OpenAIForwardResult { return &OpenAIForwardResult{ RequestID: requestID, + UpstreamHeaders: resp.Header, Usage: usage, Model: originalModel, BillingModel: billingModel, @@ -524,6 +525,7 @@ func (s *OpenAIGatewayService) bufferRawChatCompletions( return &OpenAIForwardResult{ RequestID: requestID, + UpstreamHeaders: resp.Header, Usage: usage, Model: originalModel, BillingModel: billingModel, diff --git a/backend/internal/service/openai_gateway_forward.go b/backend/internal/service/openai_gateway_forward.go index 18b5aff28e..4f56879d4f 100644 --- a/backend/internal/service/openai_gateway_forward.go +++ b/backend/internal/service/openai_gateway_forward.go @@ -1238,6 +1238,7 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco forwardResult := &OpenAIForwardResult{ RequestID: resp.Header.Get("x-request-id"), + UpstreamHeaders: resp.Header, ResponseID: responseID, Usage: *usage, Model: originalModel, diff --git a/backend/internal/service/openai_gateway_grok.go b/backend/internal/service/openai_gateway_grok.go index efcbaa8d44..8047590c1c 100644 --- a/backend/internal/service/openai_gateway_grok.go +++ b/backend/internal/service/openai_gateway_grok.go @@ -248,6 +248,7 @@ func (s *OpenAIGatewayService) forwardGrokResponses( reasoningEffort := extractOpenAIReasoningEffortFromBody(patchedBody, originalModel) result := &OpenAIForwardResult{ RequestID: firstNonEmpty(resp.Header.Get("x-request-id"), resp.Header.Get("xai-request-id")), + UpstreamHeaders: resp.Header, ResponseID: responseID, Usage: *usage, Model: originalModel, diff --git a/backend/internal/service/openai_gateway_messages.go b/backend/internal/service/openai_gateway_messages.go index b12ab68044..1b3819a17e 100644 --- a/backend/internal/service/openai_gateway_messages.go +++ b/backend/internal/service/openai_gateway_messages.go @@ -651,6 +651,7 @@ func (s *OpenAIGatewayService) handleAnthropicBufferedStreamingResponse( result := &OpenAIForwardResult{ RequestID: requestID, + UpstreamHeaders: resp.Header, ResponseID: finalResponse.ID, Usage: usage, Model: originalModel, @@ -955,6 +956,7 @@ func (s *OpenAIGatewayService) handleAnthropicStreamingResponse( resultWithUsage := func() *OpenAIForwardResult { out := &OpenAIForwardResult{ RequestID: requestID, + UpstreamHeaders: resp.Header, ResponseID: responseID, Usage: usage, Model: originalModel, diff --git a/backend/internal/service/openai_gateway_messages_anthropic_native.go b/backend/internal/service/openai_gateway_messages_anthropic_native.go index 4fb5c2ec5a..444b5b999f 100644 --- a/backend/internal/service/openai_gateway_messages_anthropic_native.go +++ b/backend/internal/service/openai_gateway_messages_anthropic_native.go @@ -244,6 +244,7 @@ func (s *OpenAIGatewayService) handleNativeAnthropicBufferedResponse( return &OpenAIForwardResult{ RequestID: resp.Header.Get("x-request-id"), + UpstreamHeaders: resp.Header, Usage: claudeUsageToOpenAIUsage(usage), Model: originalModel, BillingModel: billingModel, @@ -517,6 +518,7 @@ func (s *OpenAIGatewayService) nativeAnthropicStreamResult( } return &OpenAIForwardResult{ RequestID: resp.Header.Get("x-request-id"), + UpstreamHeaders: resp.Header, Usage: claudeUsageToOpenAIUsage(usage), Model: originalModel, BillingModel: billingModel, diff --git a/backend/internal/service/openai_gateway_messages_chat_fallback.go b/backend/internal/service/openai_gateway_messages_chat_fallback.go index c4fcdd772f..270762a0dc 100644 --- a/backend/internal/service/openai_gateway_messages_chat_fallback.go +++ b/backend/internal/service/openai_gateway_messages_chat_fallback.go @@ -163,6 +163,7 @@ func (s *OpenAIGatewayService) bufferChatCompletionsAsAnthropic( return &OpenAIForwardResult{ RequestID: requestID, + UpstreamHeaders: resp.Header, Usage: usage, Model: originalModel, BillingModel: billingModel, @@ -224,6 +225,7 @@ func (s *OpenAIGatewayService) streamChatCompletionsAsAnthropic( // (mirrors forwardResponsesViaRawChatCompletions). return &OpenAIForwardResult{ RequestID: requestID, + UpstreamHeaders: resp.Header, Usage: usage, Model: originalModel, BillingModel: billingModel, @@ -260,6 +262,7 @@ func (s *OpenAIGatewayService) streamChatCompletionsAsAnthropic( return &OpenAIForwardResult{ RequestID: requestID, + UpstreamHeaders: resp.Header, Usage: usage, Model: originalModel, BillingModel: billingModel, diff --git a/backend/internal/service/openai_gateway_passthrough.go b/backend/internal/service/openai_gateway_passthrough.go index ecca154781..107ac2ada2 100644 --- a/backend/internal/service/openai_gateway_passthrough.go +++ b/backend/internal/service/openai_gateway_passthrough.go @@ -519,6 +519,7 @@ func (s *OpenAIGatewayService) forwardOpenAIPassthrough( forwardResult := &OpenAIForwardResult{ RequestID: resp.Header.Get("x-request-id"), + UpstreamHeaders: resp.Header, ResponseID: responseID, Usage: *usage, Model: reqModel, diff --git a/backend/internal/service/openai_gateway_responses_anthropic_native.go b/backend/internal/service/openai_gateway_responses_anthropic_native.go index 423cba3459..9a513bce1e 100644 --- a/backend/internal/service/openai_gateway_responses_anthropic_native.go +++ b/backend/internal/service/openai_gateway_responses_anthropic_native.go @@ -287,6 +287,7 @@ func (s *OpenAIGatewayService) handleResponsesBufferedFromNativeAnthropic( return &OpenAIForwardResult{ RequestID: requestID, + UpstreamHeaders: resp.Header, Usage: claudeUsageToOpenAIUsage(&usage), Model: originalModel, BillingModel: billingModel, @@ -340,6 +341,7 @@ func (s *OpenAIGatewayService) handleResponsesStreamingFromNativeAnthropic( resultWithUsage := func() *OpenAIForwardResult { return &OpenAIForwardResult{ RequestID: requestID, + UpstreamHeaders: resp.Header, Usage: claudeUsageToOpenAIUsage(&usage), Model: originalModel, BillingModel: billingModel, diff --git a/backend/internal/service/openai_gateway_responses_chat_fallback.go b/backend/internal/service/openai_gateway_responses_chat_fallback.go index e5ba05d5f9..9939c5b469 100644 --- a/backend/internal/service/openai_gateway_responses_chat_fallback.go +++ b/backend/internal/service/openai_gateway_responses_chat_fallback.go @@ -154,6 +154,7 @@ func (s *OpenAIGatewayService) bufferChatCompletionsAsResponses( return &OpenAIForwardResult{ RequestID: requestID, + UpstreamHeaders: resp.Header, Usage: usage, Model: originalModel, BillingModel: billingModel, @@ -225,6 +226,7 @@ func (s *OpenAIGatewayService) streamChatCompletionsAsResponses( if scan.Err != nil { return &OpenAIForwardResult{ RequestID: requestID, + UpstreamHeaders: resp.Header, Usage: scan.Usage, Model: originalModel, BillingModel: billingModel, @@ -240,6 +242,7 @@ func (s *OpenAIGatewayService) streamChatCompletionsAsResponses( if err := state.ValidateToolCallArguments(); err != nil { return &OpenAIForwardResult{ RequestID: requestID, + UpstreamHeaders: resp.Header, Usage: scan.Usage, Model: originalModel, BillingModel: billingModel, @@ -271,6 +274,7 @@ func (s *OpenAIGatewayService) streamChatCompletionsAsResponses( return &OpenAIForwardResult{ RequestID: requestID, + UpstreamHeaders: resp.Header, Usage: scan.Usage, Model: originalModel, BillingModel: billingModel, diff --git a/backend/internal/service/openai_gateway_service.go b/backend/internal/service/openai_gateway_service.go index aaa2e30af7..bc115d2186 100644 --- a/backend/internal/service/openai_gateway_service.go +++ b/backend/internal/service/openai_gateway_service.go @@ -233,8 +233,10 @@ type OpenAIUsage struct { type OpenAIForwardResult struct { RequestID string ResponseID string - Usage OpenAIUsage - Model string // 原始模型(用于响应和日志显示) + // UpstreamHeaders 是直接上游的响应头,用于按账户配置解析上游请求标识。 + UpstreamHeaders http.Header + Usage OpenAIUsage + Model string // 原始模型(用于响应和日志显示) // BillingModel is the model used for cost calculation. // When non-empty, CalculateCost uses this instead of Model. // This is set by the Anthropic Messages conversion path where diff --git a/backend/internal/service/openai_gateway_usage.go b/backend/internal/service/openai_gateway_usage.go index cb998b55fa..ea5345514e 100644 --- a/backend/internal/service/openai_gateway_usage.go +++ b/backend/internal/service/openai_gateway_usage.go @@ -371,6 +371,7 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec APIKeyID: apiKey.ID, AccountID: account.ID, RequestID: requestID, + UpstreamRequestID: usageUpstreamRequestIDPtr(account, result.UpstreamHeaders, result.OpenAIWSMode), Model: result.Model, RequestedModel: requestedModel, UpstreamModel: optionalTrimmedStringPtr(result.UpstreamModel), diff --git a/backend/internal/service/openai_images.go b/backend/internal/service/openai_images.go index c661aade36..9062104361 100644 --- a/backend/internal/service/openai_images.go +++ b/backend/internal/service/openai_images.go @@ -682,6 +682,7 @@ func (s *OpenAIGatewayService) forwardOpenAIImagesAPIKey( if streamCount > 0 { return &OpenAIForwardResult{ RequestID: resp.Header.Get("x-request-id"), + UpstreamHeaders: resp.Header, Usage: streamUsage, Model: requestModel, UpstreamModel: upstreamModel, @@ -703,6 +704,7 @@ func (s *OpenAIGatewayService) forwardOpenAIImagesAPIKey( firstTokenMs = ttft return &OpenAIForwardResult{ RequestID: resp.Header.Get("x-request-id"), + UpstreamHeaders: resp.Header, Usage: usage, Model: requestModel, UpstreamModel: upstreamModel, @@ -726,6 +728,7 @@ func (s *OpenAIGatewayService) forwardOpenAIImagesAPIKey( } return &OpenAIForwardResult{ RequestID: resp.Header.Get("x-request-id"), + UpstreamHeaders: resp.Header, Usage: usage, Model: requestModel, UpstreamModel: upstreamModel, diff --git a/backend/internal/service/openai_images_responses.go b/backend/internal/service/openai_images_responses.go index 434c352da2..8fb73158eb 100644 --- a/backend/internal/service/openai_images_responses.go +++ b/backend/internal/service/openai_images_responses.go @@ -1891,6 +1891,7 @@ func (s *OpenAIGatewayService) forwardOpenAIImagesOAuth( if imageCount > 0 { return &OpenAIForwardResult{ RequestID: resp.Header.Get("x-request-id"), + UpstreamHeaders: resp.Header, Usage: usage, Model: requestModel, UpstreamModel: requestModel, @@ -1935,6 +1936,7 @@ func (s *OpenAIGatewayService) forwardOpenAIImagesOAuth( } return &OpenAIForwardResult{ RequestID: resp.Header.Get("x-request-id"), + UpstreamHeaders: resp.Header, Usage: usage, Model: requestModel, UpstreamModel: requestModel, diff --git a/backend/internal/service/upstream_request_id.go b/backend/internal/service/upstream_request_id.go new file mode 100644 index 0000000000..1906601d35 --- /dev/null +++ b/backend/internal/service/upstream_request_id.go @@ -0,0 +1,84 @@ +package service + +import ( + "net/http" + "strings" + + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" + "golang.org/x/net/http/httpguts" +) + +// AccountExtraUpstreamRequestIDHeader 是账户 extra 中的键,值为直接上游声明请求标识的响应头名。 +// 未指定时不记录上游请求标识。 +const AccountExtraUpstreamRequestIDHeader = "upstream_request_id_header" + +const ( + maxUpstreamRequestIDHeaderNameLen = 64 + // maxUsageUpstreamRequestIDLen 与 usage_logs.upstream_request_id VARCHAR(128) 对齐。 + maxUsageUpstreamRequestIDLen = 128 +) + +// UpstreamRequestIDHeaderName 返回账户指定的上游请求标识头名,未指定时为空串。 +func UpstreamRequestIDHeaderName(account *Account) string { + if account == nil { + return "" + } + return strings.TrimSpace(account.GetExtraString(AccountExtraUpstreamRequestIDHeader)) +} + +// UpstreamRequestIDFromHeaders 从直接上游的响应头解析请求标识。 +// 只读账户指定的头;账户未指定头名时恒为空串。 +func UpstreamRequestIDFromHeaders(account *Account, h http.Header) string { + if len(h) == 0 { + return "" + } + name := UpstreamRequestIDHeaderName(account) + if name == "" { + return "" + } + return strings.TrimSpace(h.Get(name)) +} + +// usageUpstreamRequestIDPtr 生成落库到 usage_logs.upstream_request_id 的值。 +// WS 轮次没有 HTTP 响应头,保持 nil;超长时截断到列宽而不是让整条用量行失败。 +func usageUpstreamRequestIDPtr(account *Account, h http.Header, wsMode bool) *string { + if wsMode { + return nil + } + id := UpstreamRequestIDFromHeaders(account, h) + if id == "" { + return nil + } + if len(id) > maxUsageUpstreamRequestIDLen { + id = id[:maxUsageUpstreamRequestIDLen] + } + return &id +} + +// ValidateUpstreamRequestIDHeaderExtra 校验并规范化 extra 中的上游请求标识头名: +// 必须是合法的 HTTP 头字段名且不超过 64 字节;空白值视为未指定并从 extra 中移除。 +func ValidateUpstreamRequestIDHeaderExtra(extra map[string]any) error { + if extra == nil { + return nil + } + raw, ok := extra[AccountExtraUpstreamRequestIDHeader] + if !ok || raw == nil { + return nil + } + name, ok := raw.(string) + if !ok { + return infraerrors.BadRequest("INVALID_UPSTREAM_REQUEST_ID_HEADER", + "upstream_request_id_header must be a string") + } + name = strings.TrimSpace(name) + if name == "" { + delete(extra, AccountExtraUpstreamRequestIDHeader) + return nil + } + if len(name) > maxUpstreamRequestIDHeaderNameLen || !httpguts.ValidHeaderFieldName(name) { + return infraerrors.BadRequest("INVALID_UPSTREAM_REQUEST_ID_HEADER", + "upstream_request_id_header must be a valid HTTP header name of at most 64 bytes") + } + extra[AccountExtraUpstreamRequestIDHeader] = name + return nil +} diff --git a/backend/internal/service/upstream_request_id_test.go b/backend/internal/service/upstream_request_id_test.go new file mode 100644 index 0000000000..bdeebaf198 --- /dev/null +++ b/backend/internal/service/upstream_request_id_test.go @@ -0,0 +1,78 @@ +package service + +import ( + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestUpstreamRequestIDFromHeaders_UnconfiguredAccountRecordsNothing(t *testing.T) { + h := http.Header{} + h.Set("X-Client-Request-ID", "sub2api-client") + h.Set("X-Request-ID", "sub2api-local") + h.Set("X-Oneapi-Request-Id", "oneapi-1") + h.Set("Request-Id", "req_official") + h.Set("xai-request-id", "xai-1") + h.Set("x-goog-request-id", "goog-1") + + require.Equal(t, "", UpstreamRequestIDFromHeaders(nil, h)) + for _, platform := range []string{PlatformAnthropic, PlatformOpenAI, PlatformGemini, PlatformAntigravity, PlatformGrok} { + require.Equal(t, "", UpstreamRequestIDFromHeaders(&Account{Platform: platform}, h), platform) + } + blank := &Account{Platform: PlatformOpenAI, Extra: map[string]any{AccountExtraUpstreamRequestIDHeader: " "}} + require.Equal(t, "", UpstreamRequestIDFromHeaders(blank, h)) +} + +func TestUpstreamRequestIDFromHeaders_ReadsOnlyConfiguredHeader(t *testing.T) { + account := &Account{ + Platform: PlatformOpenAI, + Extra: map[string]any{AccountExtraUpstreamRequestIDHeader: " x-oneapi-request-id "}, + } + h := http.Header{} + h.Set("X-Request-ID", "passthrough-from-real-upstream") + require.Equal(t, "", UpstreamRequestIDFromHeaders(account, h)) + + h.Set("X-Oneapi-Request-Id", " oneapi-2 ") + require.Equal(t, "oneapi-2", UpstreamRequestIDFromHeaders(account, h)) + require.Equal(t, "", UpstreamRequestIDFromHeaders(account, nil)) + + official := &Account{Platform: PlatformAnthropic, Extra: map[string]any{AccountExtraUpstreamRequestIDHeader: "request-id"}} + only := http.Header{} + only.Set("Request-Id", "req_official") + require.Equal(t, "req_official", UpstreamRequestIDFromHeaders(official, only)) +} + +func TestUsageUpstreamRequestIDPtr(t *testing.T) { + account := &Account{Extra: map[string]any{AccountExtraUpstreamRequestIDHeader: "X-Request-ID"}} + h := http.Header{} + h.Set("X-Request-ID", strings.Repeat("a", 200)) + require.Nil(t, usageUpstreamRequestIDPtr(account, h, true)) + require.Nil(t, usageUpstreamRequestIDPtr(account, http.Header{}, false)) + require.Nil(t, usageUpstreamRequestIDPtr(nil, h, false)) + require.Nil(t, usageUpstreamRequestIDPtr(&Account{}, h, false)) + + got := usageUpstreamRequestIDPtr(account, h, false) + require.NotNil(t, got) + require.Len(t, *got, maxUsageUpstreamRequestIDLen) +} + +func TestValidateUpstreamRequestIDHeaderExtra(t *testing.T) { + require.NoError(t, ValidateUpstreamRequestIDHeaderExtra(nil)) + require.NoError(t, ValidateUpstreamRequestIDHeaderExtra(map[string]any{})) + + blank := map[string]any{AccountExtraUpstreamRequestIDHeader: " "} + require.NoError(t, ValidateUpstreamRequestIDHeaderExtra(blank)) + _, present := blank[AccountExtraUpstreamRequestIDHeader] + require.False(t, present, "blank header name must be removed") + + valid := map[string]any{AccountExtraUpstreamRequestIDHeader: " X-Oneapi-Request-Id "} + require.NoError(t, ValidateUpstreamRequestIDHeaderExtra(valid)) + require.Equal(t, "X-Oneapi-Request-Id", valid[AccountExtraUpstreamRequestIDHeader]) + + require.Error(t, ValidateUpstreamRequestIDHeaderExtra(map[string]any{AccountExtraUpstreamRequestIDHeader: 1})) + require.Error(t, ValidateUpstreamRequestIDHeaderExtra(map[string]any{AccountExtraUpstreamRequestIDHeader: "X Request Id"})) + require.Error(t, ValidateUpstreamRequestIDHeaderExtra(map[string]any{AccountExtraUpstreamRequestIDHeader: "X-Request-Id:"})) + require.Error(t, ValidateUpstreamRequestIDHeaderExtra(map[string]any{AccountExtraUpstreamRequestIDHeader: strings.Repeat("x", 65)})) +} diff --git a/backend/internal/service/usage_log.go b/backend/internal/service/usage_log.go index e20e9d56ca..64df9e2695 100644 --- a/backend/internal/service/usage_log.go +++ b/backend/internal/service/usage_log.go @@ -186,6 +186,10 @@ type UsageLog struct { // (e.g. the session_id / X-Session-Id headers). Nil when the client sent no // valid session header. It is never derived from prompt_cache_key or content. SessionID *string + // UpstreamRequestID 是直接上游在响应头中声明的请求标识,只读账户 + // extra.upstream_request_id_header 指定的头;账户未指定头名、WS 轮次 + // 与上游没有该头的路径为 nil。 + UpstreamRequestID *string // Cache TTL Override 标记(管理员强制替换了缓存 TTL 计费) CacheTTLOverridden bool diff --git a/backend/migrations/232_add_usage_log_upstream_request_id.sql b/backend/migrations/232_add_usage_log_upstream_request_id.sql new file mode 100644 index 0000000000..917739c019 --- /dev/null +++ b/backend/migrations/232_add_usage_log_upstream_request_id.sql @@ -0,0 +1,5 @@ +-- usage_logs.upstream_request_id 记录直接上游在响应头中声明的请求标识, +-- 头名由账户 extra.upstream_request_id_header 指定,未指定时按默认识别链取值。 +-- NULL 表示历史行、WS 轮次或该路径没有上游请求标识。 +ALTER TABLE usage_logs + ADD COLUMN IF NOT EXISTS upstream_request_id VARCHAR(128); diff --git a/backend/migrations/233_add_usage_log_upstream_request_id_index_notx.sql b/backend/migrations/233_add_usage_log_upstream_request_id_index_notx.sql new file mode 100644 index 0000000000..8ebeeb2458 --- /dev/null +++ b/backend/migrations/233_add_usage_log_upstream_request_id_index_notx.sql @@ -0,0 +1,4 @@ +-- 只索引带上游请求标识的行;CONCURRENTLY 避免阻塞热表写入。 +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_usage_logs_upstream_request_id + ON usage_logs (upstream_request_id) + WHERE upstream_request_id IS NOT NULL; diff --git a/frontend/src/components/account/CreateAccountModal.vue b/frontend/src/components/account/CreateAccountModal.vue index 8702ca7595..a8db1d4e59 100644 --- a/frontend/src/components/account/CreateAccountModal.vue +++ b/frontend/src/components/account/CreateAccountModal.vue @@ -2922,6 +2922,12 @@ + +
@@ -3798,6 +3804,7 @@ import BaseDialog from '@/components/common/BaseDialog.vue' import ConfirmDialog from '@/components/common/ConfirmDialog.vue' import Select from '@/components/common/Select.vue' import HelpTooltip from '@/components/common/HelpTooltip.vue' +import UpstreamRequestIdHeaderField from '@/components/account/UpstreamRequestIdHeaderField.vue' import PlatformIcon from '@/components/common/PlatformIcon.vue' import Icon from '@/components/icons/Icon.vue' import ProxySelector from '@/components/common/ProxySelector.vue' @@ -3871,6 +3878,14 @@ const oauthStepTitle = computed(() => { }) // Platform-specific hints for API Key type +// 上游ID:直接上游声明请求标识的响应头名,留空不记录。 +const upstreamRequestIdHeader = ref('') +const withUpstreamRequestIdHeader = | undefined>(extra: T): T | Record => { + const name = upstreamRequestIdHeader.value.trim() + if (!name) return extra + return { ...(extra || {}), upstream_request_id_header: name } +} + const baseUrlHint = computed(() => { if (form.platform === 'openai') return t('admin.accounts.openai.baseUrlHint') if (form.platform === 'gemini') return t('admin.accounts.gemini.baseUrlHint') @@ -5648,7 +5663,7 @@ const handleSubmit = async () => { await doCreateAccount({ ...form, group_ids: form.group_ids, - extra, + extra: withUpstreamRequestIdHeader(extra), upstream_billing_probe_enabled: upstreamBillingAutoProbeEnabled.value, auto_pause_on_expired: autoPauseOnExpired.value }) @@ -5711,9 +5726,9 @@ const createAccountAndFinish = async ( return } // Inject quota limits for apikey/bedrock accounts - let finalExtra = extra + let finalExtra = withUpstreamRequestIdHeader(extra) if (type === 'apikey' || type === 'bedrock') { - const quotaExtra: Record = { ...(extra || {}) } + const quotaExtra: Record = { ...(finalExtra || {}) } if (editQuotaLimit.value != null && editQuotaLimit.value > 0) { quotaExtra.quota_limit = editQuotaLimit.value } @@ -5837,7 +5852,7 @@ const handleGrokValidateRT = async (refreshTokenInput: string) => { platform: 'grok', type: 'oauth', credentials, - extra, + extra: withUpstreamRequestIdHeader(extra), proxy_id: form.proxy_id, concurrency: form.concurrency, load_factor: form.load_factor ?? undefined, @@ -6014,7 +6029,7 @@ const handleGrokAuthorizePassword = async (emailPasswordInput: string) => { platform: 'grok', type: 'oauth', credentials, - extra, + extra: withUpstreamRequestIdHeader(extra), proxy_id: form.proxy_id, concurrency: form.concurrency, load_factor: form.load_factor ?? undefined, @@ -6113,7 +6128,7 @@ const handleOpenAIExchange = async (authCode: string) => { platform: 'openai', type: 'oauth', credentials, - extra, + extra: withUpstreamRequestIdHeader(extra), proxy_id: form.proxy_id, concurrency: form.concurrency, load_factor: form.load_factor ?? undefined, @@ -6228,7 +6243,7 @@ const handleOpenAIImportCodexSession = async (content: string) => { expires_at: form.expires_at, auto_pause_on_expired: autoPauseOnExpired.value, credential_extras: Object.keys(credentialExtras).length > 0 ? credentialExtras : undefined, - extra, + extra: withUpstreamRequestIdHeader(extra), update_existing: true }) @@ -6306,7 +6321,7 @@ const handleOpenAIImportCodexPAT = async (accessToken: string) => { expires_at: form.expires_at, auto_pause_on_expired: autoPauseOnExpired.value, credential_extras: Object.keys(credentialExtras).length > 0 ? credentialExtras : undefined, - extra + extra: withUpstreamRequestIdHeader(extra) }) appStore.showSuccess(t('admin.accounts.messages.accountCreated')) @@ -6394,7 +6409,7 @@ const handleOpenAIBatchRT = async (refreshTokenInput: string, clientId?: string) platform: 'openai', type: 'oauth', credentials, - extra, + extra: withUpstreamRequestIdHeader(extra), proxy_id: form.proxy_id, concurrency: form.concurrency, load_factor: form.load_factor ?? undefined, @@ -6493,7 +6508,7 @@ const handleAntigravityValidateRT = async (refreshTokenInput: string) => { platform: 'antigravity', type: 'oauth', credentials, - extra: {}, + extra: withUpstreamRequestIdHeader({}), proxy_id: form.proxy_id, concurrency: form.concurrency, load_factor: form.load_factor ?? undefined, @@ -6874,7 +6889,7 @@ const handleCookieAuth = async (sessionKey: string) => { platform: form.platform, type: addMethod.value, // Use addMethod as type: 'oauth' or 'setup-token' credentials, - extra, + extra: withUpstreamRequestIdHeader(extra), proxy_id: form.proxy_id, concurrency: form.concurrency, load_factor: form.load_factor ?? undefined, diff --git a/frontend/src/components/account/EditAccountModal.vue b/frontend/src/components/account/EditAccountModal.vue index 8ef424f4d9..1c0beebf50 100644 --- a/frontend/src/components/account/EditAccountModal.vue +++ b/frontend/src/components/account/EditAccountModal.vue @@ -1543,6 +1543,12 @@
+ +
@@ -2887,6 +2893,7 @@ import BaseDialog from '@/components/common/BaseDialog.vue' import ConfirmDialog from '@/components/common/ConfirmDialog.vue' import Select from '@/components/common/Select.vue' import HelpTooltip from '@/components/common/HelpTooltip.vue' +import UpstreamRequestIdHeaderField from '@/components/account/UpstreamRequestIdHeaderField.vue' import Toggle from '@/components/common/Toggle.vue' import Icon from '@/components/icons/Icon.vue' import ProxySelector from '@/components/common/ProxySelector.vue' @@ -3207,6 +3214,12 @@ const autoResetCredit7dThreshold = ref(100) const upstreamBillingAutoProbeEnabled = ref(false) const upstreamBillingRateSyncEnabled = ref(false) const mixedScheduling = ref(false) // For antigravity accounts: enable mixed scheduling +// 上游ID:直接上游声明请求标识的响应头名,留空不记录。 +const upstreamRequestIdHeader = ref('') +const readUpstreamRequestIdHeader = (extra: unknown): string => { + const value = (extra as Record | undefined)?.upstream_request_id_header + return typeof value === 'string' ? value : '' +} const allowOverages = ref(false) // For antigravity accounts: enable AI Credits overages const antigravityProjectId = ref('') const antigravityModelRestrictionMode = ref<'whitelist' | 'mapping'>('whitelist') @@ -3728,6 +3741,7 @@ const syncFormFromAccount = (newAccount: Account | null) => { const extra = newAccount.extra as Record | undefined mixedScheduling.value = extra?.mixed_scheduling === true allowOverages.value = extra?.allow_overages === true + upstreamRequestIdHeader.value = readUpstreamRequestIdHeader(extra) autoPause5hThreshold.value = typeof extra?.auto_pause_5h_threshold === 'number' ? extra.auto_pause_5h_threshold * 100 : null autoPause7dThreshold.value = typeof extra?.auto_pause_7d_threshold === 'number' ? extra.auto_pause_7d_threshold * 100 : null autoPause5hDisabled.value = extra?.auto_pause_5h_disabled === true @@ -5337,6 +5351,19 @@ const handleSubmit = async () => { updatePayload.extra = newExtra } + // 上游ID头名只在改动时写回 extra,避免用弹窗打开时的快照覆盖运行态键。 + const nextUpstreamRequestIdHeader = upstreamRequestIdHeader.value.trim() + if (nextUpstreamRequestIdHeader !== readUpstreamRequestIdHeader(props.account.extra)) { + const currentExtra = (updatePayload.extra as Record) || (props.account.extra as Record) || {} + const newExtra: Record = { ...currentExtra } + if (nextUpstreamRequestIdHeader) { + newExtra.upstream_request_id_header = nextUpstreamRequestIdHeader + } else { + delete newExtra.upstream_request_id_header + } + updatePayload.extra = newExtra + } + const canContinue = await ensureAntigravityMixedChannelConfirmed(async () => { await submitUpdateAccount(accountID, updatePayload) }) diff --git a/frontend/src/components/account/UpstreamRequestIdHeaderField.vue b/frontend/src/components/account/UpstreamRequestIdHeaderField.vue new file mode 100644 index 0000000000..2f90ab5a73 --- /dev/null +++ b/frontend/src/components/account/UpstreamRequestIdHeaderField.vue @@ -0,0 +1,78 @@ + + + diff --git a/frontend/src/components/account/__tests__/CreateAccountModal.spec.ts b/frontend/src/components/account/__tests__/CreateAccountModal.spec.ts index 876e719fd7..4a052d61f8 100644 --- a/frontend/src/components/account/__tests__/CreateAccountModal.spec.ts +++ b/frontend/src/components/account/__tests__/CreateAccountModal.spec.ts @@ -247,6 +247,27 @@ describe('CreateAccountModal OpenAI long-context billing', () => { expect(createAccountMock.mock.calls[0]?.[0]?.extra?.openai_long_context_billing_enabled).toBe(false) }) + it('omits the upstream request id header from extra when left empty', async () => { + await submitApiKeyAccount('openai') + + expect(createAccountMock).toHaveBeenCalledTimes(1) + expect(createAccountMock.mock.calls[0]?.[0]?.extra).not.toHaveProperty('upstream_request_id_header') + }) + + it('sends the trimmed upstream request id header in extra when filled', async () => { + const wrapper = mountModal() + await selectButtonByText(wrapper, 'OpenAI') + await selectButtonByText(wrapper, 'API Key') + await wrapper.get('form#create-account-form input[type="text"]').setValue('openai account') + await wrapper.get('form#create-account-form input[type="password"]').setValue('test-api-key') + await wrapper.get('[data-testid="upstream-request-id-header"]').setValue(' X-Oneapi-Request-Id ') + await wrapper.get('form#create-account-form').trigger('submit.prevent') + await flushPromises() + + expect(createAccountMock).toHaveBeenCalledTimes(1) + expect(createAccountMock.mock.calls[0]?.[0]?.extra?.upstream_request_id_header).toBe('X-Oneapi-Request-Id') + }) + it('persists upstream model metadata after creating an account from preview', async () => { const wrapper = mountModal() await selectButtonByText(wrapper, 'OpenAI') diff --git a/frontend/src/components/account/__tests__/EditAccountModal.spec.ts b/frontend/src/components/account/__tests__/EditAccountModal.spec.ts index d81ffacebe..8232e2e619 100644 --- a/frontend/src/components/account/__tests__/EditAccountModal.spec.ts +++ b/frontend/src/components/account/__tests__/EditAccountModal.spec.ts @@ -675,6 +675,49 @@ describe('EditAccountModal', () => { ) }) + it('writes the upstream request id header into extra only when it changes', async () => { + const account = buildAccount() + account.extra = { openai_compact_mode: 'force_on' } + updateAccountMock.mockReset() + checkMixedChannelRiskMock.mockReset() + checkMixedChannelRiskMock.mockResolvedValue({ has_risk: false }) + updateAccountMock.mockResolvedValue(account) + + const untouched = mountModal(account) + await untouched.get('form#edit-account-form').trigger('submit.prevent') + expect(updateAccountMock).toHaveBeenCalledTimes(1) + expect(updateAccountMock.mock.calls[0]?.[1]?.extra?.upstream_request_id_header).toBeUndefined() + + updateAccountMock.mockClear() + const wrapper = mountModal(account) + await wrapper.get('[data-testid="upstream-request-id-header"]').setValue(' X-Oneapi-Request-Id ') + await wrapper.get('form#edit-account-form').trigger('submit.prevent') + + expect(updateAccountMock).toHaveBeenCalledTimes(1) + expect(updateAccountMock.mock.calls[0]?.[1]?.extra).toMatchObject({ + openai_compact_mode: 'force_on', + upstream_request_id_header: 'X-Oneapi-Request-Id' + }) + }) + + it('removes the upstream request id header from extra when cleared', async () => { + const account = buildAccount() + account.extra = { upstream_request_id_header: 'X-Request-ID' } + updateAccountMock.mockReset() + checkMixedChannelRiskMock.mockReset() + checkMixedChannelRiskMock.mockResolvedValue({ has_risk: false }) + updateAccountMock.mockResolvedValue(account) + + const wrapper = mountModal(account) + expect((wrapper.get('[data-testid="upstream-request-id-header"]').element as HTMLInputElement).value).toBe('X-Request-ID') + await wrapper.get('[data-testid="upstream-request-id-header"]').setValue('') + await wrapper.get('form#edit-account-form').trigger('submit.prevent') + + expect(updateAccountMock).toHaveBeenCalledTimes(1) + expect(updateAccountMock.mock.calls[0]?.[1]?.extra).toBeDefined() + expect(updateAccountMock.mock.calls[0]?.[1]?.extra).not.toHaveProperty('upstream_request_id_header') + }) + it('hides the Codex namespace flatten toggle for non-OAuth OpenAI accounts', async () => { const account = buildAccount() const wrapper = mountModal(account) diff --git a/frontend/src/components/account/__tests__/UpstreamRequestIdHeaderField.spec.ts b/frontend/src/components/account/__tests__/UpstreamRequestIdHeaderField.spec.ts new file mode 100644 index 0000000000..363a883ee7 --- /dev/null +++ b/frontend/src/components/account/__tests__/UpstreamRequestIdHeaderField.spec.ts @@ -0,0 +1,66 @@ +import { describe, expect, it, vi } from 'vitest' +import { mount } from '@vue/test-utils' +import UpstreamRequestIdHeaderField from '../UpstreamRequestIdHeaderField.vue' + +vi.mock('vue-i18n', async () => { + const actual = await vi.importActual('vue-i18n') + return { + ...actual, + useI18n: () => ({ + t: (key: string, params?: Record) => + params?.platform ? `${key}:${String(params.platform)}` : key, + }), + } +}) + +function mountField(props: { platform?: string; type?: string; modelValue?: string }) { + return mount(UpstreamRequestIdHeaderField, { + props, + global: { + stubs: { Teleport: true }, + }, + }) +} + +describe('UpstreamRequestIdHeaderField', () => { + it('lists relay and official header examples for API key accounts', () => { + const wrapper = mountField({ platform: 'openai', type: 'apikey' }) + const text = wrapper.text() + + expect(text).toContain('admin.accounts.upstreamRequestIdHeaderHelp.intro') + expect(text).toContain('admin.accounts.upstreamRequestIdHeaderHelp.examplesTitle') + expect(text).toContain('X-Client-Request-ID') + expect(text).toContain('admin.accounts.upstreamRequestIdHeaderHelp.sub2apiNote') + expect(text).toContain('X-Oneapi-Request-Id') + expect(text).toContain('admin.accounts.upstreamRequestIdHeaderHelp.official:OpenAI') + expect(text).toContain('x-request-id') + }) + + it('lists only the official header for accounts that connect to the platform directly', () => { + const wrapper = mountField({ platform: 'anthropic', type: 'oauth' }) + const text = wrapper.text() + + expect(text).toContain('admin.accounts.upstreamRequestIdHeaderHelp.official:Anthropic') + expect(text).toContain('request-id') + expect(text).not.toContain('X-Client-Request-ID') + expect(text).not.toContain('X-Oneapi-Request-Id') + }) + + it('omits the examples section when no header is known for the platform', () => { + const wrapper = mountField({ platform: 'kimi', type: 'oauth' }) + const text = wrapper.text() + + expect(text).toContain('admin.accounts.upstreamRequestIdHeaderHelp.intro') + expect(text).not.toContain('admin.accounts.upstreamRequestIdHeaderHelp.examplesTitle') + expect(wrapper.findAll('code')).toHaveLength(0) + }) + + it('binds the header name through v-model', async () => { + const wrapper = mountField({ platform: 'openai', type: 'apikey', modelValue: 'X-Request-ID' }) + const input = wrapper.get('[data-testid="upstream-request-id-header"]') + expect((input.element as HTMLInputElement).value).toBe('X-Request-ID') + + await input.setValue('X-Oneapi-Request-Id') + expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['X-Oneapi-Request-Id']) + }) +}) diff --git a/frontend/src/components/admin/usage/UsageTable.vue b/frontend/src/components/admin/usage/UsageTable.vue index df0ad10bad..3b2509129b 100644 --- a/frontend/src/components/admin/usage/UsageTable.vue +++ b/frontend/src/components/admin/usage/UsageTable.vue @@ -273,6 +273,24 @@ - + + @@ -258,6 +258,9 @@ ', + '', + '[Unsafe](javascript:alert%281%29)', + '[Support](https://example.com/help)', + ].join('\n\n')) + const help = wrapper.get('.markdown-body') + expect(help.find('script').exists()).toBe(false) + expect(help.get('img').attributes('onerror')).toBeUndefined() + expect(help.findAll('a').map(link => link.attributes('href'))).toEqual([undefined, 'https://example.com/help']) + }) + + it('keeps plain-text soft line breaks and the separate help image preview', async () => { + const wrapper = await mountHelp('First line\nSecond line', 'https://example.com/help.png') + const help = wrapper.get('.markdown-body') + expect(help.get('p').text()).toBe('First line\nSecond line') + expect(help.find('br').exists()).toBe(false) + await wrapper.get('img').trigger('click') + expect(wrapper.findAll('img')).toHaveLength(2) + expect(wrapper.findAll('img')[1].attributes('src')).toBe('https://example.com/help.png') + }) + + it('keeps image-only help without an empty Markdown container', async () => { + const wrapper = await mountHelp('', 'https://example.com/help.png') + expect(wrapper.find('.markdown-body').exists()).toBe(false) + expect(wrapper.get('img').attributes('src')).toBe('https://example.com/help.png') + }) +}) + describe('PaymentView subscription plan grid', () => { it.each([3, 4, 6])('keeps %i plans on the existing mobile/tablet/desktop grid', async (planCount) => { const wrapper = await mountSubscriptionPlanList(planCount) From 7f0f579bb0a4e71c2041e9ff1fc16de4b1883fac Mon Sep 17 00:00:00 2001 From: "chihao.ou" Date: Mon, 7 Sep 2026 20:01:24 +0800 Subject: [PATCH 118/145] fix(accounts): keep failed token refresh accounts selected --- frontend/src/views/admin/AccountsView.vue | 5 +- .../AccountsView.selectAllResults.spec.ts | 47 +++++++++++++++++-- 2 files changed, 47 insertions(+), 5 deletions(-) diff --git a/frontend/src/views/admin/AccountsView.vue b/frontend/src/views/admin/AccountsView.vue index d156b389e9..7270ffe35b 100644 --- a/frontend/src/views/admin/AccountsView.vue +++ b/frontend/src/views/admin/AccountsView.vue @@ -1936,10 +1936,13 @@ const handleBulkResetStatus = async () => { } const handleBulkRefreshToken = async () => { if (!confirm(t('common.confirm'))) return + const accountIds = [...selIds.value] try { - const result = await adminAPI.accounts.batchRefresh(selIds.value) + const result = await adminAPI.accounts.batchRefresh(accountIds) if (result.failed > 0) { appStore.showError(t('admin.accounts.bulkActions.partialSuccess', { success: result.success, failed: result.failed })) + const failedIds = result.errors?.map(error => error.account_id) ?? [] + setSelectedIds(failedIds.length > 0 ? failedIds : accountIds) } else { appStore.showSuccess(t('admin.accounts.bulkActions.refreshTokenSuccess', { count: result.success })) clearSelection() diff --git a/frontend/src/views/admin/__tests__/AccountsView.selectAllResults.spec.ts b/frontend/src/views/admin/__tests__/AccountsView.selectAllResults.spec.ts index e3fe225224..c80ddcaaab 100644 --- a/frontend/src/views/admin/__tests__/AccountsView.selectAllResults.spec.ts +++ b/frontend/src/views/admin/__tests__/AccountsView.selectAllResults.spec.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { flushPromises, mount } from '@vue/test-utils' import AccountsView from '../AccountsView.vue' @@ -6,6 +6,7 @@ import AccountsView from '../AccountsView.vue' const { listAccounts, listWithEtag, + batchRefresh, getBatchTodayStats, getUpstreamBillingProbeSettings, getAllProxies, @@ -14,6 +15,7 @@ const { } = vi.hoisted(() => ({ listAccounts: vi.fn(), listWithEtag: vi.fn(), + batchRefresh: vi.fn(), getBatchTodayStats: vi.fn(), getUpstreamBillingProbeSettings: vi.fn(), getAllProxies: vi.fn(), @@ -30,7 +32,7 @@ vi.mock('@/api/admin', () => ({ getUpstreamBillingProbeSettings, batchDelete: vi.fn(), batchClearError: vi.fn(), - batchRefresh: vi.fn(), + batchRefresh, bulkUpdate: vi.fn() }, proxies: { @@ -79,7 +81,7 @@ const makeAccounts = (count: number) => Array.from({ length: count }, (_, index) const AccountBulkActionsBarStub = { props: ['selectedIds', 'totalResults', 'selectingAll', 'allResultsSelected'], - emits: ['select-all-results', 'select-page', 'clear'], + emits: ['select-all-results', 'select-page', 'clear', 'refresh-token'], template: `
{{ selectedIds.length }} @@ -88,6 +90,7 @@ const AccountBulkActionsBarStub = { +
` } @@ -104,7 +107,10 @@ const mountView = () => mount(AccountsView, { TablePageLayout: { template: '
' }, - DataTable: { props: ['data'], template: '
' }, + DataTable: { + props: ['data'], + template: '
' + }, Pagination: true, ConfirmDialog: true, AccountTableActions: { template: '
' }, @@ -139,6 +145,7 @@ describe('admin AccountsView select all filtered results', () => { localStorage.clear() listAccounts.mockReset() listWithEtag.mockReset() + batchRefresh.mockReset() getBatchTodayStats.mockReset() getUpstreamBillingProbeSettings.mockReset() getAllProxies.mockReset() @@ -156,6 +163,38 @@ describe('admin AccountsView select all filtered results', () => { getAllGroups.mockResolvedValue([]) }) + afterEach(() => { + vi.restoreAllMocks() + }) + + it.each([ + { name: 'keeps only failed accounts selected', result: { total: 3, success: 2, failed: 1, errors: [{ account_id: 2, error: 'no refresh token available' }] }, expectedIds: [2] }, + { name: 'clears the selection after every account succeeds', result: { total: 3, success: 3, failed: 0 }, expectedIds: [] }, + { name: 'keeps the original selection when failure details are missing', result: { total: 3, success: 2, failed: 1 }, expectedIds: [1, 2, 3] }, + ])('$name after a batch token refresh and table reload', async ({ result, expectedIds }) => { + listAccounts.mockResolvedValue({ items: makeAccounts(3), total: 3, page: 1, page_size: 20, pages: 1 }) + batchRefresh.mockResolvedValue(result) + vi.spyOn(window, 'confirm').mockReturnValue(true) + const wrapper = mountView() + await flushPromises() + await wrapper.get('[data-test="select-page"]').trigger('click') + await wrapper.get('[data-test="refresh-token"]').trigger('click') + await flushPromises() + + expect(batchRefresh).toHaveBeenCalledWith([1, 2, 3]) + expect(listAccounts).toHaveBeenCalledTimes(2) + expect(wrapper.getComponent(AccountBulkActionsBarStub).props('selectedIds')).toEqual(expectedIds) + expect(wrapper.findAll('[data-test="data-table"] input').map(input => input.element.checked)) + .toEqual([1, 2, 3].map(id => expectedIds.includes(id))) + if (result.failed > 0) { + expect(showError).toHaveBeenCalledWith('admin.accounts.bulkActions.partialSuccess') + await wrapper.get('[data-test="refresh-token"]').trigger('click') + await flushPromises() + expect(batchRefresh).toHaveBeenLastCalledWith(expectedIds) + } + wrapper.unmount() + }) + it('selects all matching IDs in one commit and clears the selection when filters change', async () => { const allAccounts = makeAccounts(45) listAccounts.mockImplementation(async (_page: number, pageSize: number) => { From 941a0487abc30ff9c66af5056244b25087d15b17 Mon Sep 17 00:00:00 2001 From: "chihao.ou" Date: Sat, 5 Sep 2026 19:55:52 +0800 Subject: [PATCH 119/145] fix(accounts): allow removing inactive group assignments --- .../components/account/EditAccountModal.vue | 14 +++++- .../__tests__/EditAccountModal.spec.ts | 50 ++++++++++++++++++- .../src/components/common/GroupSelector.vue | 6 +-- 3 files changed, 64 insertions(+), 6 deletions(-) diff --git a/frontend/src/components/account/EditAccountModal.vue b/frontend/src/components/account/EditAccountModal.vue index 6fe23eef0b..1615a78513 100644 --- a/frontend/src/components/account/EditAccountModal.vue +++ b/frontend/src/components/account/EditAccountModal.vue @@ -2851,7 +2851,7 @@ { + const groups = new Map(props.groups.map(group => [group.id, group])) + const assignedIds = new Set(props.account?.group_ids ?? []) + for (const group of props.account?.groups ?? []) { + if (assignedIds.has(group.id) && !groups.has(group.id)) { + groups.set(group.id, group) + } + } + return Array.from(groups.values()) +}) + // Spark 影子账号(parent_account_id 非空):代理恒继承母账号,不可独立编辑(外审 B/P1), // 故隐藏代理选择器。 const isSparkShadow = computed(() => props.account?.parent_account_id != null) diff --git a/frontend/src/components/account/__tests__/EditAccountModal.spec.ts b/frontend/src/components/account/__tests__/EditAccountModal.spec.ts index 5c85b7c2af..9f9b68a539 100644 --- a/frontend/src/components/account/__tests__/EditAccountModal.spec.ts +++ b/frontend/src/components/account/__tests__/EditAccountModal.spec.ts @@ -302,7 +302,7 @@ function buildOpenAIOAuthParentAccount() { } as any } -function mountModal(account = buildAccount()) { +function mountModal(account = buildAccount(), renderGroupSelector = false) { return mount(EditAccountModal, { props: { show: true, @@ -316,7 +316,7 @@ function mountModal(account = buildAccount()) { Select: SelectStub, Icon: true, ProxySelector: true, - GroupSelector: GroupSelectorStub, + GroupSelector: renderGroupSelector ? false : GroupSelectorStub, ModelWhitelistSelector: ModelWhitelistSelectorStub } } @@ -372,6 +372,52 @@ describe('EditAccountModal', () => { wrapper.unmount() }) + it('allows removing assigned inactive groups and undoing the selection before saving', async () => { + authIsSimpleMode.value = false + const account = buildAccount() + const activeGroup = { + id: 1, + name: 'Active group', + platform: 'openai', + status: 'active', + subscription_type: 'standard', + rate_multiplier: 1 + } + const inactiveGroup = { ...activeGroup, id: 2, name: 'Paused group', status: 'inactive' } + account.group_ids = [1, 2] + account.groups = [ + { ...activeGroup, name: 'Outdated name' }, + inactiveGroup, + inactiveGroup, + { ...inactiveGroup, id: 3, name: 'Unassigned paused group' } + ] + updateAccountMock.mockReset() + checkMixedChannelRiskMock.mockReset() + checkMixedChannelRiskMock.mockResolvedValue({ has_risk: false }) + updateAccountMock.mockResolvedValue(account) + + const wrapper = mountModal(account, true) + await wrapper.setProps({ groups: [activeGroup] as any }) + const selector = wrapper.get('[data-tour="account-form-groups"]') + expect(selector.findAll('input[type="checkbox"]').map(input => input.attributes('value'))) + .toEqual(['1', '2']) + expect(selector.text()).toContain('Active group') + expect(selector.text()).not.toContain('Outdated name') + const pausedCheckbox = selector.get('input[value="2"]') + expect(pausedCheckbox.element.checked).toBe(true) + + await pausedCheckbox.setValue(false) + expect(selector.get('input[value="2"]').element.checked).toBe(false) + await pausedCheckbox.setValue(true) + expect(pausedCheckbox.element.checked).toBe(true) + await pausedCheckbox.setValue(false) + await wrapper.get('form#edit-account-form').trigger('submit.prevent') + + expect(updateAccountMock).toHaveBeenCalledTimes(1) + expect(updateAccountMock.mock.calls[0]?.[1]?.group_ids).toEqual([1]) + expect(account.group_ids).toEqual([1, 2]) + }) + it('reopening the same account rehydrates the OpenAI whitelist from props', async () => { const account = buildAccount() updateAccountMock.mockReset() diff --git a/frontend/src/components/common/GroupSelector.vue b/frontend/src/components/common/GroupSelector.vue index fe2f372937..0cd01c21aa 100644 --- a/frontend/src/components/common/GroupSelector.vue +++ b/frontend/src/components/common/GroupSelector.vue @@ -61,7 +61,7 @@ import { computed, ref, watch } from 'vue' import { useI18n } from 'vue-i18n' import GroupBadge from './GroupBadge.vue' import Icon from '@/components/icons/Icon.vue' -import type { AdminGroup, GroupPlatform } from '@/types' +import type { Group, GroupPlatform } from '@/types' import { useAuthStore } from '@/stores' const { t } = useI18n() @@ -69,7 +69,7 @@ const authStore = useAuthStore() interface Props { modelValue: number[] - groups: AdminGroup[] + groups: (Group & { account_count?: number })[] platform?: GroupPlatform // Optional platform filter mixedScheduling?: boolean // For antigravity accounts: allow anthropic/gemini groups searchable?: boolean | 'auto' @@ -91,7 +91,7 @@ const isSearchable = computed(() => { // Filter groups by platform if specified const filteredGroups = computed(() => { - let result: AdminGroup[] = authStore.isSimpleMode + let result = authStore.isSimpleMode ? props.groups.filter((g) => g.platform !== 'composite') : props.groups if (props.platform) { From b7dba62678a834080564966c002fd0ca2b328b7a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 7 Sep 2026 13:01:57 +0000 Subject: [PATCH 120/145] chore: sync VERSION to 0.2.2 [skip ci] --- backend/cmd/server/VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/cmd/server/VERSION b/backend/cmd/server/VERSION index 0c62199f16..ee1372d33a 100644 --- a/backend/cmd/server/VERSION +++ b/backend/cmd/server/VERSION @@ -1 +1 @@ -0.2.1 +0.2.2 From cc91155fe4cb7dcb37228be034749924750465c8 Mon Sep 17 00:00:00 2001 From: alfadb Date: Mon, 7 Sep 2026 16:31:00 +0800 Subject: [PATCH 121/145] fix(ollama): clamp DeepSeek output limits across API protocols --- .../service/gateway_anthropic_passthrough.go | 4 + .../service/gateway_upstream_request.go | 5 + .../ollama_cloud_messages_max_tokens.go | 25 ++ .../ollama_cloud_messages_max_tokens_test.go | 245 +++++++++++ .../openai_gateway_chat_completions_raw.go | 1 + .../service/openai_gateway_forward.go | 8 + ...penai_gateway_messages_anthropic_native.go | 5 + ...penai_gateway_ollama_cloud_cc_reasoning.go | 5 +- .../openai_gateway_ollama_cloud_max_tokens.go | 57 ++- ...ai_gateway_ollama_cloud_max_tokens_test.go | 407 +++++++++++++++++- .../openai_gateway_responses_chat_fallback.go | 3 + 11 files changed, 753 insertions(+), 12 deletions(-) create mode 100644 backend/internal/service/ollama_cloud_messages_max_tokens.go create mode 100644 backend/internal/service/ollama_cloud_messages_max_tokens_test.go diff --git a/backend/internal/service/gateway_anthropic_passthrough.go b/backend/internal/service/gateway_anthropic_passthrough.go index 99ff3b267c..b202e7dbba 100644 --- a/backend/internal/service/gateway_anthropic_passthrough.go +++ b/backend/internal/service/gateway_anthropic_passthrough.go @@ -325,6 +325,10 @@ func (s *GatewayService) buildUpstreamRequestAnthropicAPIKeyPassthrough( body = sanitized } + // Ollama Cloud DeepSeek 出站 max_tokens clamp:判定与上方 targetURL 的 + // base 取值同源(GetBaseURL),详见 helper 注释。 + body = clampOllamaCloudAnthropicMessagesMaxTokens(account, account.GetBaseURL(), body) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, targetURL, bytes.NewReader(body)) if err != nil { return nil, nil, err diff --git a/backend/internal/service/gateway_upstream_request.go b/backend/internal/service/gateway_upstream_request.go index 675cf40001..93367eef89 100644 --- a/backend/internal/service/gateway_upstream_request.go +++ b/backend/internal/service/gateway_upstream_request.go @@ -117,6 +117,11 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex body = sanitized } + // Ollama Cloud DeepSeek 出站 max_tokens clamp:判定与上方 targetURL 的 + // base 取值同源(GetBaseURL),仅实际上游为 ollama.com 且映射后出站模型 + // 为 DeepSeek 系时压到 cap,详见 helper 注释。 + body = clampOllamaCloudAnthropicMessagesMaxTokens(account, account.GetBaseURL(), body) + req, err := http.NewRequestWithContext(ctx, "POST", targetURL, bytes.NewReader(body)) if err != nil { return nil, nil, err diff --git a/backend/internal/service/ollama_cloud_messages_max_tokens.go b/backend/internal/service/ollama_cloud_messages_max_tokens.go new file mode 100644 index 0000000000..66bb4831a3 --- /dev/null +++ b/backend/internal/service/ollama_cloud_messages_max_tokens.go @@ -0,0 +1,25 @@ +package service + +import ( + "github.com/tidwall/gjson" +) + +// clampOllamaCloudAnthropicMessagesMaxTokens 是 Anthropic Messages 出站 +// (/v1/messages,含 CC / Responses 桥接与 passthrough)的 max_tokens clamp 钩子, +// 与 raw CC / Responses 路径共用 cap 配置与实现。判定与出站 URL 组装同源:调用方 +// 传入本次实际选用的 Anthropic 上游 base_url(GetBaseURL 或 +// GetAnthropicProtocolBaseURL,不用 GetOpenAIBaseURL——adaptive 时那是 CC/Responses +// 地址),仅当其为 ollama.com 且 body 中映射后出站 model 为 DeepSeek 系时压到 cap, +// 否则原样返回。 +func clampOllamaCloudAnthropicMessagesMaxTokens(account *Account, baseURL string, body []byte) []byte { + if account == nil || len(body) == 0 { + return body + } + if !isOllamaCloudBaseURL(baseURL) { + return body + } + if !isDeepSeekModel(gjson.GetBytes(body, "model").String()) { + return body + } + return clampOllamaCloudMaxTokens(account, body) +} diff --git a/backend/internal/service/ollama_cloud_messages_max_tokens_test.go b/backend/internal/service/ollama_cloud_messages_max_tokens_test.go new file mode 100644 index 0000000000..e8fab5d1fe --- /dev/null +++ b/backend/internal/service/ollama_cloud_messages_max_tokens_test.go @@ -0,0 +1,245 @@ +//go:build unit + +package service + +import ( + "context" + "net/http" + "net/http/httptest" + "strconv" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +// Anthropic Messages 出站(三个 builder)的 Ollama Cloud DeepSeek max_tokens clamp +// 接线验证。实测背景:POST ollama.com/v1/messages(Bearer)max_tokens=256000 被上游 +// 以 "max_tokens (256000) exceeds model's maximum output tokens (65536)" 400 拒绝。 +// 用例直接调用各 builder 实际生成 *http.Request,同时检查出站 URL 与 wire body。 + +func messagesClampTestConfig() *config.Config { + cfg := rawChatCompletionsTestConfig() + cfg.Gateway = config.GatewayConfig{MaxLineSize: defaultMaxLineSize} + return cfg +} + +func newMessagesClampTestContext(t *testing.T) *gin.Context { + t.Helper() + gin.SetMode(gin.TestMode) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil) + return c +} + +// messagesClampOllamaAccount 构造挂在实际 ollama.com 上游的 APIKey 账号。 +// builder A / B 的 Messages base 取 GetBaseURL()(凭证 base_url),builder C 的 +// Anthropic 协议 base 取 GetAnthropicProtocolBaseURL()(anthropic 协议时同为 +// 凭证 base_url)。 +func messagesClampOllamaAccount(id int64, platform string) *Account { + return &Account{ + ID: id, + Name: "ollama-cloud-messages", + Platform: platform, + Type: AccountTypeAPIKey, + Credentials: map[string]any{ + "api_key": "sk-test", + "base_url": "https://ollama.com", + }, + Extra: map[string]any{}, + } +} + +func messagesClampBody(model string, maxTokens int) []byte { + return []byte(`{"model":"` + model + `","max_tokens":` + strconv.Itoa(maxTokens) + + `,"stream":false,"messages":[{"role":"user","content":"hi"}]}`) +} + +// TestBuildUpstreamRequest_ClampsOllamaCloudDeepSeekMaxTokens 覆盖 GatewayService +// .buildUpstreamRequest(Anthropic 平台原生 Messages + 共享重试路径)。 +func TestBuildUpstreamRequest_ClampsOllamaCloudDeepSeekMaxTokens(t *testing.T) { + svc := &GatewayService{cfg: messagesClampTestConfig()} + c := newMessagesClampTestContext(t) + + body := messagesClampBody("deepseek-v4-flash", 256000) + account := messagesClampOllamaAccount(401, PlatformAnthropic) + + req, wireBody, err := svc.buildUpstreamRequest( + context.Background(), c, account, body, "sk-test", "api_key", + "deepseek-v4-flash", false, false, + ) + require.NoError(t, err) + require.Equal(t, "https://ollama.com/v1/messages?beta=true", req.URL.String()) + require.Equal(t, "deepseek-v4-flash", gjson.GetBytes(wireBody, "model").String()) + require.Equal(t, int64(65535), gjson.GetBytes(wireBody, "max_tokens").Int()) + + t.Run("official anthropic base is untouched", func(t *testing.T) { + official := messagesClampOllamaAccount(402, PlatformAnthropic) + official.Credentials["base_url"] = "https://api.anthropic.com" + _, wire, err := svc.buildUpstreamRequest( + context.Background(), c, official, body, "sk-test", "api_key", + "deepseek-v4-flash", false, false, + ) + require.NoError(t, err) + require.Equal(t, int64(256000), gjson.GetBytes(wire, "max_tokens").Int()) + }) + + t.Run("non deepseek model untouched", func(t *testing.T) { + nonDeepSeek := messagesClampBody("k3-256k", 256000) + _, wire, err := svc.buildUpstreamRequest( + context.Background(), c, account, nonDeepSeek, "sk-test", "api_key", + "k3-256k", false, false, + ) + require.NoError(t, err) + require.Equal(t, int64(256000), gjson.GetBytes(wire, "max_tokens").Int()) + }) + + t.Run("at cap and extra cap zero untouched", func(t *testing.T) { + atCap := messagesClampBody("deepseek-v4-flash", 65535) + _, wire, err := svc.buildUpstreamRequest( + context.Background(), c, account, atCap, "sk-test", "api_key", + "deepseek-v4-flash", false, false, + ) + require.NoError(t, err) + require.Equal(t, int64(65535), gjson.GetBytes(wire, "max_tokens").Int()) + + disabled := messagesClampOllamaAccount(403, PlatformAnthropic) + disabled.Extra[OllamaCloudMaxTokensCapExtraKey] = 0 + _, wire, err = svc.buildUpstreamRequest( + context.Background(), c, disabled, body, "sk-test", "api_key", + "deepseek-v4-flash", false, false, + ) + require.NoError(t, err) + require.Equal(t, int64(256000), gjson.GetBytes(wire, "max_tokens").Int()) + }) +} + +// TestBuildUpstreamRequestAnthropicAPIKeyPassthrough_ClampsOllamaCloudDeepSeekMaxTokens +// 覆盖 Anthropic 平台 APIKey passthrough builder。 +func TestBuildUpstreamRequestAnthropicAPIKeyPassthrough_ClampsOllamaCloudDeepSeekMaxTokens(t *testing.T) { + svc := &GatewayService{cfg: messagesClampTestConfig()} + c := newMessagesClampTestContext(t) + + body := messagesClampBody("deepseek-v4-flash", 256000) + account := messagesClampOllamaAccount(411, PlatformAnthropic) + + req, wireBody, err := svc.buildUpstreamRequestAnthropicAPIKeyPassthrough( + context.Background(), c, account, body, "sk-test", + ) + require.NoError(t, err) + require.Equal(t, "https://ollama.com/v1/messages?beta=true", req.URL.String()) + require.Equal(t, int64(65535), gjson.GetBytes(wireBody, "max_tokens").Int()) + + // 官方 Anthropic(api.anthropic.com)即使 max_tokens 超过 65535 也字节级不变。 + official := newAnthropicAPIKeyAccountForTest() + _, wire, err := svc.buildUpstreamRequestAnthropicAPIKeyPassthrough( + context.Background(), c, official, body, "upstream-anthropic-key", + ) + require.NoError(t, err) + require.Equal(t, int64(256000), gjson.GetBytes(wire, "max_tokens").Int()) +} + +// TestBuildNativeAnthropicUpstreamRequest_ClampsOllamaCloudDeepSeekMaxTokens 覆盖 +// 国产供应商原生 Anthropic 协议 builder(Messages / Responses / CC 桥接共享)。 +func TestBuildNativeAnthropicUpstreamRequest_ClampsOllamaCloudDeepSeekMaxTokens(t *testing.T) { + svc := &OpenAIGatewayService{cfg: messagesClampTestConfig()} + c := newMessagesClampTestContext(t) + + body := messagesClampBody("deepseek-v4-flash", 256000) + + t.Run("anthropic protocol ollama base is clamped", func(t *testing.T) { + account := messagesClampOllamaAccount(421, PlatformDeepseek) + account.Credentials["api_protocol"] = APIProtocolAnthropic + + targetURL, err := svc.nativeAnthropicTargetURL(account) + require.NoError(t, err) + require.Equal(t, "https://ollama.com/v1/messages", targetURL) + + req, wireBody, err := svc.buildNativeAnthropicUpstreamRequest( + context.Background(), c, account, body, "sk-test", targetURL, + ) + require.NoError(t, err) + require.Equal(t, "https://ollama.com/v1/messages", req.URL.String()) + require.Equal(t, int64(65535), gjson.GetBytes(wireBody, "max_tokens").Int()) + }) + + // adaptive 账号按 Anthropic 协议地址(api_base_urls[anthropic])判定,而非 CC + // 地址:anthropic 指向 ollama.com、chat_completions 指向官方 DeepSeek 时命中。 + t.Run("adaptive uses anthropic base not cc base", func(t *testing.T) { + account := messagesClampOllamaAccount(422, PlatformDeepseek) + account.Credentials["api_protocol"] = APIProtocolAdaptive + account.Credentials["api_base_urls"] = map[string]any{ + APIProtocolAnthropic: "https://ollama.com", + APIProtocolChatCompletions: "https://api.deepseek.com", + } + + targetURL, err := svc.nativeAnthropicTargetURL(account) + require.NoError(t, err) + req, wireBody, err := svc.buildNativeAnthropicUpstreamRequest( + context.Background(), c, account, body, "sk-test", targetURL, + ) + require.NoError(t, err) + require.Equal(t, "https://ollama.com/v1/messages", req.URL.String()) + require.Equal(t, int64(65535), gjson.GetBytes(wireBody, "max_tokens").Int()) + }) + + // 反向:实际 Anthropic 上游非 ollama.com(官方 anthropic 端点),即使 CC 地址 + // 指向 ollama.com 且残留 usage extra,也不 clamp。 + t.Run("non-ollama anthropic base with cc ollama base and usage extra untouched", func(t *testing.T) { + account := messagesClampOllamaAccount(423, PlatformDeepseek) + account.Credentials["api_protocol"] = APIProtocolAdaptive + account.Credentials["api_base_urls"] = map[string]any{ + APIProtocolAnthropic: "https://api.deepseek.com/anthropic", + APIProtocolChatCompletions: "https://ollama.com", + } + account.Extra[OllamaCloudUsageSnapshotExtraKey] = map[string]any{"status": "ok"} + + targetURL, err := svc.nativeAnthropicTargetURL(account) + require.NoError(t, err) + req, wireBody, err := svc.buildNativeAnthropicUpstreamRequest( + context.Background(), c, account, body, "sk-test", targetURL, + ) + require.NoError(t, err) + require.Equal(t, "https://api.deepseek.com/anthropic/v1/messages", req.URL.String()) + require.Equal(t, int64(256000), gjson.GetBytes(wireBody, "max_tokens").Int()) + }) + + // 官方 DeepSeek Anthropic 协议(平台默认端点)字节级不变。 + t.Run("official deepseek anthropic default untouched", func(t *testing.T) { + account := &Account{ + ID: 424, + Name: "official-deepseek-anthropic", + Platform: PlatformDeepseek, + Type: AccountTypeAPIKey, + Credentials: map[string]any{ + "api_key": "sk-test", + "api_protocol": APIProtocolAnthropic, + }, + Extra: map[string]any{}, + } + targetURL, err := svc.nativeAnthropicTargetURL(account) + require.NoError(t, err) + _, wireBody, err := svc.buildNativeAnthropicUpstreamRequest( + context.Background(), c, account, body, "sk-test", targetURL, + ) + require.NoError(t, err) + require.Equal(t, int64(256000), gjson.GetBytes(wireBody, "max_tokens").Int()) + }) + + // 非 DeepSeek 模型(映射后出站 model)不变。 + t.Run("non deepseek model untouched", func(t *testing.T) { + account := messagesClampOllamaAccount(425, PlatformDeepseek) + account.Credentials["api_protocol"] = APIProtocolAnthropic + nonDeepSeek := messagesClampBody("glm-4.7", 256000) + targetURL, err := svc.nativeAnthropicTargetURL(account) + require.NoError(t, err) + _, wireBody, err := svc.buildNativeAnthropicUpstreamRequest( + context.Background(), c, account, nonDeepSeek, "sk-test", targetURL, + ) + require.NoError(t, err) + require.Equal(t, int64(256000), gjson.GetBytes(wireBody, "max_tokens").Int()) + }) +} diff --git a/backend/internal/service/openai_gateway_chat_completions_raw.go b/backend/internal/service/openai_gateway_chat_completions_raw.go index 67ffc3d265..98394c07e7 100644 --- a/backend/internal/service/openai_gateway_chat_completions_raw.go +++ b/backend/internal/service/openai_gateway_chat_completions_raw.go @@ -159,6 +159,7 @@ func (s *OpenAIGatewayService) forwardAsRawChatCompletions( } } upstreamBody = applyOllamaCloudRawChatCompletionsRequest(account, upstreamBody) + upstreamBody = clampOllamaCloudUpstreamMaxTokens(account, upstreamBody) logger.L().Debug("openai chat_completions raw: forwarding without protocol conversion", zap.Int64("account_id", account.ID), diff --git a/backend/internal/service/openai_gateway_forward.go b/backend/internal/service/openai_gateway_forward.go index 56673c1c23..6837cb8236 100644 --- a/backend/internal/service/openai_gateway_forward.go +++ b/backend/internal/service/openai_gateway_forward.go @@ -610,6 +610,14 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco } } } + // Ollama Cloud(实际 Responses 上游为 ollama.com)输出上限 clamp:对 Codex 与 + // 非 Codex 客户端一律执行(真实 Codex 客户端同样会带超限 max_output_tokens 被 + // ollama.com 以 400 拒绝)。在 `!isCodexCLI` 归一化块之后独立调用:非 Codex 时 + // 位于平台字段归一化之后,不跳过原有平台 switch(patch 按追加顺序应用,set 在 + // 先前的 delete/set 之后生效);Codex 请求不做归一化,直接按 body 现值判定。 + if clampedCap, ok := ollamaCloudResponsesMaxOutputTokensClamp(account, upstreamModel, body); ok { + markPatchSet("max_output_tokens", clampedCap) + } if wsDecision.Transport != OpenAIUpstreamTransportResponsesWebsocketV2 && !account.IsOpenAIApiKey() && gjson.GetBytes(body, "previous_response_id").Exists() { markPatchDelete("previous_response_id") diff --git a/backend/internal/service/openai_gateway_messages_anthropic_native.go b/backend/internal/service/openai_gateway_messages_anthropic_native.go index d5daef8442..a629e977df 100644 --- a/backend/internal/service/openai_gateway_messages_anthropic_native.go +++ b/backend/internal/service/openai_gateway_messages_anthropic_native.go @@ -158,6 +158,11 @@ func (s *OpenAIGatewayService) buildNativeAnthropicUpstreamRequest( body = sanitized } + // Ollama Cloud DeepSeek 出站 max_tokens clamp:判定与 nativeAnthropicTargetURL + // 的 base 取值同源(GetAnthropicProtocolBaseURL,adaptive 时是 Anthropic 协议 + // 地址而非 CC/Responses 地址),详见 helper 注释。 + body = clampOllamaCloudAnthropicMessagesMaxTokens(account, account.GetAnthropicProtocolBaseURL(), body) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, targetURL, bytes.NewReader(body)) if err != nil { return nil, nil, err diff --git a/backend/internal/service/openai_gateway_ollama_cloud_cc_reasoning.go b/backend/internal/service/openai_gateway_ollama_cloud_cc_reasoning.go index f78a375860..4fd1c317b6 100644 --- a/backend/internal/service/openai_gateway_ollama_cloud_cc_reasoning.go +++ b/backend/internal/service/openai_gateway_ollama_cloud_cc_reasoning.go @@ -47,12 +47,13 @@ func accountHasOllamaCloudUsageExtra(account *Account) bool { return false } +// applyOllamaCloudRawChatCompletionsRequest 只做 Ollama Cloud reasoning 归一化; +// max_tokens clamp 已解耦到独立钩子 clampOllamaCloudUpstreamMaxTokens,由出站方依次调用。 func applyOllamaCloudRawChatCompletionsRequest(account *Account, body []byte) []byte { if !isOllamaCloudRawChatCompletionsAccount(account) || len(body) == 0 { return body } - body = normalizeOllamaCloudChatCompletionsRequest(body) - return clampOllamaCloudMaxTokens(account, body) + return normalizeOllamaCloudChatCompletionsRequest(body) } func applyOllamaCloudRawChatCompletionsResponse(account *Account, body []byte) []byte { diff --git a/backend/internal/service/openai_gateway_ollama_cloud_max_tokens.go b/backend/internal/service/openai_gateway_ollama_cloud_max_tokens.go index 32a508481a..4c69a32b23 100644 --- a/backend/internal/service/openai_gateway_ollama_cloud_max_tokens.go +++ b/backend/internal/service/openai_gateway_ollama_cloud_max_tokens.go @@ -18,9 +18,60 @@ const OllamaCloudMaxTokensCapExtraKey = "ollama_max_tokens_cap" // (约 65535),max_tokens 超过该值会被上游直接 400 拒绝;该上限与模型无关,不做模型过滤。 const ollamaCloudDefaultMaxTokensCap = 65535 -// 本文件的 clampOllamaCloudMaxTokens 被 -// applyOllamaCloudRawChatCompletionsRequest(openai_gateway_ollama_cloud_cc_reasoning.go) -// 调用,账号检测(isOllamaCloudRawChatCompletionsAccount)由调用方完成,此处不再重复判断。 +// clampOllamaCloudUpstreamMaxTokens 是 raw CC 出站(forwardAsRawChatCompletions 与 +// /v1/responses 降级 forwardResponsesViaRawChatCompletions 共用)的独立 token 钩子, +// 与 reasoning 钩子解耦:只看本次请求实际选用的 CC 上游 base_url(GetOpenAIBaseURL, +// 与 openAIChatCompletionsTargetURL 的取值一致)是否 ollama.com,不读 usage extra。 +// DeepSeek 系模型(用模型映射后的真实出站 model,body 中 model 已改写)任意平台均 +// clamp;非 DeepSeek 模型仅保留既有 openai 平台 Ollama 账号(force_chat_completions +// 判定)的 clamp,不扩展到其它平台。 +func clampOllamaCloudUpstreamMaxTokens(account *Account, body []byte) []byte { + if account == nil || len(body) == 0 { + return body + } + if !isOllamaCloudBaseURL(account.GetOpenAIBaseURL()) { + return body + } + if !isDeepSeekModel(gjson.GetBytes(body, "model").String()) && !isOllamaCloudRawChatCompletionsAccount(account) { + return body + } + return clampOllamaCloudMaxTokens(account, body) +} + +// ollamaCloudResponsesUpstreamBaseURL 返回原生 /v1/responses 本次实际选用的上游 +// base_url,取值与 buildUpstreamRequest 一致:adaptive 原生 CN 账号用 api_base_urls +// 的 responses 地址,其余用 GetOpenAIBaseURL。 +func ollamaCloudResponsesUpstreamBaseURL(account *Account) string { + if account.UsesNativeCNResponses() && account.IsAdaptiveAPIProtocol() { + return account.GetCNProtocolBaseURL(APIProtocolResponses) + } + return account.GetOpenAIBaseURL() +} + +// ollamaCloudResponsesMaxOutputTokensClamp 是原生 /v1/responses 路径的 clamp:在 +// 平台字段归一化之后独立调用,不改写原有的 max_output_tokens 平台 switch。判定: +// 实际 Responses 上游(ollamaCloudResponsesUpstreamBaseURL)为 ollama.com 的 +// APIKey 账号 + 映射后的出站模型是 DeepSeek 系;openai 平台 max_tokens 刚被归一化 +// patch 到 max_output_tokens 时取 max_tokens 为生效值。未命中返回 ok=false。 +// 实测:ollama.com/v1/responses max_output_tokens=256000 被上游以 "max_tokens +// (256000) exceeds model's maximum output tokens (65536)" 400 拒绝(DeepSeek 模型)。 +func ollamaCloudResponsesMaxOutputTokensClamp(account *Account, upstreamModel string, body []byte) (int64, bool) { + if account == nil || account.Type != AccountTypeAPIKey || !isDeepSeekModel(upstreamModel) { + return 0, false + } + if !isOllamaCloudBaseURL(ollamaCloudResponsesUpstreamBaseURL(account)) { + return 0, false + } + value := gjson.GetBytes(body, "max_output_tokens") + if !value.Exists() && account.Platform == PlatformOpenAI { + value = gjson.GetBytes(body, "max_tokens") + } + cap := ollamaCloudMaxTokensCap(account) + if cap <= 0 || !value.Exists() || value.Type != gjson.Number || value.Int() <= cap { + return 0, false + } + return cap, true +} // ollamaCloudMaxTokensCap 返回账号配置的 max_tokens 上限。账号为 nil 或 extra 中 // 无该键时返回默认值;键值为数值类型(float64/int64/int/json.Number)时返回其整数 diff --git a/backend/internal/service/openai_gateway_ollama_cloud_max_tokens_test.go b/backend/internal/service/openai_gateway_ollama_cloud_max_tokens_test.go index 6bbc2d7e5f..f818c37973 100644 --- a/backend/internal/service/openai_gateway_ollama_cloud_max_tokens_test.go +++ b/backend/internal/service/openai_gateway_ollama_cloud_max_tokens_test.go @@ -3,11 +3,15 @@ package service import ( + "context" "encoding/json" + "errors" "testing" "github.com/Wei-Shaw/sub2api/internal/pkg/openai_compat" + "github.com/gin-gonic/gin" "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" ) // ollamaMaxTokensCapTestAccount 构造带自定义 cap 的 Ollama Cloud usage 账号。 @@ -129,17 +133,19 @@ func TestOllamaCloudMaxTokensCap(t *testing.T) { } } -// TestApplyOllamaCloudRawChatCompletionsRequestClampsMaxTokens 验证 max_tokens clamp -// 已接入组合钩子 applyOllamaCloudRawChatCompletionsRequest,并遵循该钩子的账号判定门槛 -// (isOllamaCloudRawChatCompletionsAccount:platform openai + type apikey + -// force_chat_completions + ollama.com 或 Ollama usage extra)。 +// TestApplyOllamaCloudRawChatCompletionsRequestClampsMaxTokens 验证 reasoning 钩子 +// 与 token clamp 已解耦:reasoning 钩子只做 reasoning 归一化,clamp 由独立 token +// 钩子 clampOllamaCloudUpstreamMaxTokens 在出站时接续执行(两条 raw CC 出站路径 +// 均依次调用两者,端到端行为不变)。 func TestApplyOllamaCloudRawChatCompletionsRequestClampsMaxTokens(t *testing.T) { body := []byte(`{"model":"deepseek-chat","max_tokens":100000}`) - // Ollama Cloud 账号(ollama.com + force_chat_completions)→ clamp 到 65535。 + // Ollama Cloud 账号:reasoning 钩子不再 clamp,字节级原样。 ollama := ollamaCloudRawChatCompletionsTestAccount() + require.Equal(t, string(body), string(applyOllamaCloudRawChatCompletionsRequest(ollama, body))) + // 独立 token 钩子接续 clamp 到既有默认 cap。 require.JSONEq(t, `{"model":"deepseek-chat","max_tokens":65535}`, - string(applyOllamaCloudRawChatCompletionsRequest(ollama, body))) + string(clampOllamaCloudUpstreamMaxTokens(ollama, body))) // 官方 DeepSeek(api.deepseek.com + force_chat_completions)→ 字节级不变。 official := rawChatCompletionsTestAccount() @@ -148,13 +154,400 @@ func TestApplyOllamaCloudRawChatCompletionsRequestClampsMaxTokens(t *testing.T) openai_compat.ExtraKeyResponsesMode: string(openai_compat.ResponsesSupportModeForceChatCompletions), } require.Equal(t, body, applyOllamaCloudRawChatCompletionsRequest(official, body)) + require.Equal(t, string(body), string(clampOllamaCloudUpstreamMaxTokens(official, body))) - // ollama.com 但无 force_chat_completions(Extra 缺键)→ 不通过钩子判定门槛,字节级不变。 + // ollama.com 但无 force_chat_completions:reasoning 钩子不生效;独立钩子按 + // DeepSeek 系模型判定仍 clamp(DeepSeek 覆盖不依赖 responses_mode extra)。 noForce := ollamaCloudRawChatCompletionsTestAccount() noForce.Extra = nil require.Equal(t, body, applyOllamaCloudRawChatCompletionsRequest(noForce, body)) + require.JSONEq(t, `{"model":"deepseek-chat","max_tokens":65535}`, + string(clampOllamaCloudUpstreamMaxTokens(noForce, body))) // 空 body → 原样返回。 require.Equal(t, []byte(nil), applyOllamaCloudRawChatCompletionsRequest(ollama, nil)) require.Equal(t, []byte{}, applyOllamaCloudRawChatCompletionsRequest(ollama, []byte{})) } + +// ollamaUpstreamTestAccount 构造挂在实际 ollama.com 上游的 APIKey 账号。平台标签 +// 不参与 clamp 判定,这里用不同平台仅为了覆盖生产里的挂载方式;base_url 与生产 +// 实测一致(https://ollama.com/v1)。 +func ollamaUpstreamTestAccount(platform string, id int64) *Account { + return &Account{ + ID: id, + Name: "ollama-cloud", + Platform: platform, + Type: AccountTypeAPIKey, + Credentials: map[string]any{ + "api_key": "sk-test", + "base_url": "https://ollama.com/v1", + }, + Extra: map[string]any{}, + } +} + +// officialDeepSeekTestAccount 构造官方 DeepSeek 账号,extra 里带残留的 Ollama +// usage 快照键:usage extra 不得把非 Ollama host 识别为 Ollama 上游。 +func officialDeepSeekTestAccount(id int64) *Account { + return &Account{ + ID: id, + Name: "official-deepseek", + Platform: PlatformDeepseek, + Type: AccountTypeAPIKey, + Credentials: map[string]any{ + "api_key": "sk-test", + "base_url": "https://api.deepseek.com", + }, + Extra: map[string]any{ + OllamaCloudUsageSnapshotExtraKey: map[string]any{"status": "ok"}, + }, + } +} + +func TestClampOllamaCloudUpstreamMaxTokens(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + account *Account + body string + want string + }{ + { + // 生产问题场景:deepseek 平台 Ollama 账号,无 openai_responses_mode extra, + // raw CC 直转时 max_tokens 超限被上游 400。 + name: "deepseek platform ollama.com without responses_mode is clamped", + account: ollamaUpstreamTestAccount(PlatformDeepseek, 301), + body: `{"model":"deepseek-v4-flash","max_tokens":256000}`, + want: `{"model":"deepseek-v4-flash","max_tokens":65535}`, + }, + { + name: "max_completion_tokens is clamped too", + account: ollamaUpstreamTestAccount(PlatformDeepseek, 301), + body: `{"model":"deepseek-v4-flash","max_completion_tokens":256000}`, + want: `{"model":"deepseek-v4-flash","max_completion_tokens":65535}`, + }, + { + // 新增范围只限 DeepSeek 系模型:kimi/zhipu 平台挂 ollama.com 跑非 + // DeepSeek 模型的请求保持不变。 + name: "kimi platform non-deepseek model untouched", + account: ollamaUpstreamTestAccount(PlatformKimi, 302), + body: `{"model":"k3-256k","max_tokens":256000}`, + want: `{"model":"k3-256k","max_tokens":256000}`, + }, + { + name: "zhipu platform non-deepseek model untouched", + account: ollamaUpstreamTestAccount(PlatformZhipu, 302), + body: `{"model":"glm-4.7","max_tokens":256000}`, + want: `{"model":"glm-4.7","max_tokens":256000}`, + }, + { + // 既有 openai 平台 Ollama 账号对 DeepSeek 模型的 clamp 幂等保持。 + name: "openai platform ollama.com deepseek model stays clamped", + account: ollamaUpstreamTestAccount(PlatformOpenAI, 303), + body: `{"model":"deepseek-v4-flash","max_tokens":70000}`, + want: `{"model":"deepseek-v4-flash","max_tokens":65535}`, + }, + { + // 既有 openai 平台 Ollama(真实 ollama.com + force_chat_completions)对 + // 非 DeepSeek 模型的 clamp 保留。 + name: "openai platform ollama.com non-deepseek model keeps legacy clamp", + account: ollamaCloudRawChatCompletionsTestAccount(), + body: `{"model":"gpt-oss:120b-cloud","max_tokens":70000}`, + want: `{"model":"gpt-oss:120b-cloud","max_tokens":65535}`, + }, + { + // 非 DeepSeek 模型不扩展到其它平台。 + name: "openai platform ollama.com non-deepseek model without force_cc untouched", + account: func() *Account { + account := ollamaUpstreamTestAccount(PlatformOpenAI, 303) + account.Extra = nil + return account + }(), + body: `{"model":"gpt-oss:120b-cloud","max_tokens":70000}`, + want: `{"model":"gpt-oss:120b-cloud","max_tokens":70000}`, + }, + { + // 残留 usage extra 不得把非 Ollama host 变成 Ollama(旧 reasoning 钩子内的 + // clamp 会命中该账号,解耦后已封堵)。 + name: "openai platform non-ollama host with leftover usage extra untouched", + account: func() *Account { + account := ollamaCloudRawChatCompletionsTestAccount() + account.Credentials["base_url"] = "https://example.invalid/v1" + account.Extra[OllamaCloudUsageSnapshotExtraKey] = map[string]any{"status": "ok"} + return account + }(), + body: `{"model":"gpt-oss:120b-cloud","max_tokens":70000}`, + want: `{"model":"gpt-oss:120b-cloud","max_tokens":70000}`, + }, + { + name: "extra cap zero disables clamping", + account: func() *Account { + account := ollamaUpstreamTestAccount(PlatformDeepseek, 304) + account.Extra[OllamaCloudMaxTokensCapExtraKey] = 0 + return account + }(), + body: `{"model":"deepseek-v4-flash","max_tokens":256000}`, + want: `{"model":"deepseek-v4-flash","max_tokens":256000}`, + }, + { + name: "value at cap is untouched", + account: ollamaUpstreamTestAccount(PlatformDeepseek, 304), + body: `{"model":"deepseek-v4-flash","max_tokens":65535}`, + want: `{"model":"deepseek-v4-flash","max_tokens":65535}`, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + require.JSONEq(t, test.want, string(clampOllamaCloudUpstreamMaxTokens(test.account, []byte(test.body)))) + }) + } + + // 非 Ollama 实际上游(api.deepseek.com)即使残留 usage extra 也不 clamp。 + official := officialDeepSeekTestAccount(305) + body := []byte(`{"model":"deepseek-v4-flash","max_tokens":256000,"max_completion_tokens":256000}`) + require.Equal(t, string(body), string(clampOllamaCloudUpstreamMaxTokens(official, body))) + + // 空 body / nil 账号原样返回。 + require.Equal(t, []byte(nil), clampOllamaCloudUpstreamMaxTokens(official, nil)) +} + +// TestForwardAsRawChatCompletions_DeepseekOllamaCloudClampsMaxTokens 端到端验证 raw +// CC 出站钩子:判定用模型映射后的真实出站 model,官方 DeepSeek 字节级不变。 +func TestForwardAsRawChatCompletions_DeepseekOllamaCloudClampsMaxTokens(t *testing.T) { + gin.SetMode(gin.TestMode) + + account := ollamaUpstreamTestAccount(PlatformDeepseek, 311) + account.Credentials["api_protocol"] = APIProtocolChatCompletions + account.Credentials["model_mapping"] = map[string]any{"deepseek-chat": "deepseek-v4-flash"} + + body := []byte(`{"model":"deepseek-chat","max_tokens":256000,"messages":[{"role":"user","content":"hi"}],"stream":false}`) + upstream := &httpUpstreamRecorder{err: errors.New("stop after capture")} + svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream} + + _, err := svc.forwardAsRawChatCompletions(context.Background(), adaptiveProtocolTestContext("/v1/chat/completions", body), account, body, "") + require.Error(t, err) + require.Equal(t, "deepseek-v4-flash", gjson.GetBytes(upstream.lastBody, "model").String()) + require.Equal(t, int64(65535), gjson.GetBytes(upstream.lastBody, "max_tokens").Int()) + + // 官方 DeepSeek(api.deepseek.com)+ 残留 usage extra:字节级不变。 + official := officialDeepSeekTestAccount(312) + official.Credentials["api_protocol"] = APIProtocolChatCompletions + officialBody := []byte(`{"model":"deepseek-v4-flash","max_tokens":256000,"messages":[{"role":"user","content":"hi"}],"stream":false}`) + officialUpstream := &httpUpstreamRecorder{err: errors.New("stop after capture")} + officialSvc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: officialUpstream} + + _, err = officialSvc.forwardAsRawChatCompletions(context.Background(), adaptiveProtocolTestContext("/v1/chat/completions", officialBody), official, officialBody, "") + require.Error(t, err) + require.Equal(t, string(officialBody), string(officialUpstream.lastBody)) +} + +// TestForwardResponsesViaRawChatCompletions_DeepseekOllamaCloudClampsMaxTokens 验证 +// /v1/responses 降级到 raw CC 的路径确实经过同一个独立 token 钩子(Responses 请求的 +// max_output_tokens 经 apicompat 转成 CC 的 max_completion_tokens)。 +func TestForwardResponsesViaRawChatCompletions_DeepseekOllamaCloudClampsMaxTokens(t *testing.T) { + gin.SetMode(gin.TestMode) + + account := ollamaUpstreamTestAccount(PlatformDeepseek, 321) + account.Credentials["api_protocol"] = APIProtocolChatCompletions + + body := []byte(`{"model":"deepseek-v4-flash","input":"Reply with exactly OK and nothing else.","max_output_tokens":256000,"stream":false}`) + upstream := &httpUpstreamRecorder{err: errors.New("stop after capture")} + svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream} + + _, err := svc.Forward(context.Background(), adaptiveProtocolTestContext("/v1/responses", body), account, body) + require.Error(t, err) + require.Equal(t, "https://ollama.com/v1/chat/completions", upstream.lastReq.URL.String()) + require.Equal(t, "deepseek-v4-flash", gjson.GetBytes(upstream.lastBody, "model").String()) + require.Equal(t, int64(65535), gjson.GetBytes(upstream.lastBody, "max_completion_tokens").Int()) +} + +// TestForwardResponsesClampsOllamaCloudMaxOutputTokens 覆盖原生 /v1/responses 路径 +// (openai force_responses 与 deepseek api_protocol=responses)的 max_output_tokens +// clamp,实测依据:POST ollama.com/v1/responses max_output_tokens=256000 被上游以 +// "max_tokens (256000) exceeds model's maximum output tokens (65536)" 400 拒绝。 +func TestForwardResponsesClampsOllamaCloudMaxOutputTokens(t *testing.T) { + gin.SetMode(gin.TestMode) + + responsesBody := []byte(`{"model":"deepseek-v4-flash","input":"Reply with exactly OK and nothing else.","max_output_tokens":256000,"stream":false}`) + + run := func(account *Account, body []byte) (*httpUpstreamRecorder, error) { + upstream := &httpUpstreamRecorder{err: errors.New("stop after capture")} + svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream} + _, err := svc.Forward(context.Background(), adaptiveProtocolTestContext("/v1/responses", body), account, body) + return upstream, err + } + + t.Run("deepseek platform native responses is clamped", func(t *testing.T) { + account := ollamaUpstreamTestAccount(PlatformDeepseek, 331) + account.Credentials["api_protocol"] = APIProtocolResponses + upstream, err := run(account, responsesBody) + require.Error(t, err) + require.Equal(t, "https://ollama.com/v1/responses", upstream.lastReq.URL.String()) + require.Equal(t, int64(65535), gjson.GetBytes(upstream.lastBody, "max_output_tokens").Int()) + }) + + t.Run("openai platform force_responses is clamped", func(t *testing.T) { + account := ollamaUpstreamTestAccount(PlatformOpenAI, 332) + account.Extra[openai_compat.ExtraKeyResponsesMode] = string(openai_compat.ResponsesSupportModeForceResponses) + upstream, err := run(account, responsesBody) + require.Error(t, err) + require.Equal(t, int64(65535), gjson.GetBytes(upstream.lastBody, "max_output_tokens").Int()) + }) + + t.Run("official deepseek with leftover usage extra is untouched", func(t *testing.T) { + account := officialDeepSeekTestAccount(333) + account.Credentials["api_protocol"] = APIProtocolResponses + upstream, err := run(account, responsesBody) + require.Error(t, err) + require.Equal(t, int64(256000), gjson.GetBytes(upstream.lastBody, "max_output_tokens").Int()) + }) + + t.Run("at cap is untouched", func(t *testing.T) { + account := ollamaUpstreamTestAccount(PlatformDeepseek, 334) + account.Credentials["api_protocol"] = APIProtocolResponses + atCap := []byte(`{"model":"deepseek-v4-flash","input":"hi","max_output_tokens":65535,"stream":false}`) + upstream, err := run(account, atCap) + require.Error(t, err) + require.Equal(t, int64(65535), gjson.GetBytes(upstream.lastBody, "max_output_tokens").Int()) + }) + + t.Run("extra cap zero disables clamping", func(t *testing.T) { + account := ollamaUpstreamTestAccount(PlatformDeepseek, 335) + account.Credentials["api_protocol"] = APIProtocolResponses + account.Extra[OllamaCloudMaxTokensCapExtraKey] = 0 + upstream, err := run(account, responsesBody) + require.Error(t, err) + require.Equal(t, int64(256000), gjson.GetBytes(upstream.lastBody, "max_output_tokens").Int()) + }) +} + +// TestForwardResponsesClampUsesResponsesUpstreamBaseURL 验证原生 Responses clamp 按 +// 实际 Responses 上游判定:adaptive 账号取 api_base_urls 的 responses 地址,而非 CC +// 地址(与 buildUpstreamRequest 的 URL 选择一致)。 +func TestForwardResponsesClampUsesResponsesUpstreamBaseURL(t *testing.T) { + gin.SetMode(gin.TestMode) + + body := []byte(`{"model":"deepseek-v4-flash","input":"hi","max_output_tokens":256000,"stream":false}`) + run := func(account *Account) (*httpUpstreamRecorder, error) { + upstream := &httpUpstreamRecorder{err: errors.New("stop after capture")} + svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream} + _, err := svc.Forward(context.Background(), adaptiveProtocolTestContext("/v1/responses", body), account, body) + return upstream, err + } + + t.Run("ollama CC base but non-ollama responses base is not clamped", func(t *testing.T) { + account := ollamaUpstreamTestAccount(PlatformDeepseek, 341) + account.Credentials["api_protocol"] = APIProtocolAdaptive + account.Credentials["api_base_urls"] = map[string]any{ + APIProtocolChatCompletions: "https://ollama.com/v1", + APIProtocolResponses: "https://api.deepseek.com", + } + upstream, err := run(account) + require.Error(t, err) + require.Equal(t, "https://api.deepseek.com/responses", upstream.lastReq.URL.String()) + require.Equal(t, int64(256000), gjson.GetBytes(upstream.lastBody, "max_output_tokens").Int()) + }) + + t.Run("non-ollama CC base but ollama responses base is clamped", func(t *testing.T) { + account := ollamaUpstreamTestAccount(PlatformDeepseek, 342) + account.Credentials["api_protocol"] = APIProtocolAdaptive + account.Credentials["api_base_urls"] = map[string]any{ + APIProtocolChatCompletions: "https://api.deepseek.com", + APIProtocolResponses: "https://ollama.com/v1", + } + upstream, err := run(account) + require.Error(t, err) + require.Equal(t, "https://ollama.com/v1/responses", upstream.lastReq.URL.String()) + require.Equal(t, int64(65535), gjson.GetBytes(upstream.lastBody, "max_output_tokens").Int()) + }) +} + +// TestForwardResponsesClampsOllamaCloudMaxOutputTokensForCodexClients 回归:原生 +// /v1/responses 的 Ollama Cloud clamp 必须对真实 Codex 客户端同样生效(clamp 曾位于 +// !isCodexCLI 块内被跳过,ollama.com 对 >65535 的 max_output_tokens 一律 400)。 +func TestForwardResponsesClampsOllamaCloudMaxOutputTokensForCodexClients(t *testing.T) { + gin.SetMode(gin.TestMode) + + responsesBody := []byte(`{"model":"deepseek-v4-flash","input":"Reply with exactly OK and nothing else.","max_output_tokens":256000,"stream":false}`) + + ollamaResponsesAccount := func() *Account { + account := ollamaUpstreamTestAccount(PlatformDeepseek, 336) + account.Credentials["api_protocol"] = APIProtocolResponses + return account + } + officialResponsesAccount := func() *Account { + account := officialDeepSeekTestAccount(337) + account.Credentials["api_protocol"] = APIProtocolResponses + return account + } + codexUA := func(c *gin.Context) { + c.Request.Header.Set("User-Agent", "codex_cli_rs/0.55.0 (Ubuntu 24.04; x86_64)") + } + codexOriginator := func(c *gin.Context) { c.Request.Header.Set("originator", "codex-tui") } + + tests := []struct { + name string + account *Account + setHeaders func(*gin.Context) + forceCodexCLI bool + wantCap int64 + wantURL string + }{ + { + name: "codex UA is clamped", + account: ollamaResponsesAccount(), + setHeaders: codexUA, + wantCap: 65535, + wantURL: "https://ollama.com/v1/responses", + }, + { + name: "codex originator is clamped", + account: ollamaResponsesAccount(), + setHeaders: codexOriginator, + wantCap: 65535, + wantURL: "https://ollama.com/v1/responses", + }, + { + name: "ForceCodexCLI is clamped", + account: ollamaResponsesAccount(), + forceCodexCLI: true, + wantCap: 65535, + wantURL: "https://ollama.com/v1/responses", + }, + { + // 非 Codex 正常路径对照:与既有用例行为一致,不回归。 + name: "non-codex control is clamped", + account: ollamaResponsesAccount(), + wantCap: 65535, + wantURL: "https://ollama.com/v1/responses", + }, + { + // 非 Ollama 上游:同样的 Codex 头不 clamp。 + name: "official deepseek with codex UA is untouched", + account: officialResponsesAccount(), + setHeaders: codexUA, + wantCap: 256000, + wantURL: "https://api.deepseek.com/responses", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + upstream := &httpUpstreamRecorder{err: errors.New("stop after capture")} + cfg := rawChatCompletionsTestConfig() + cfg.Gateway.ForceCodexCLI = test.forceCodexCLI + svc := &OpenAIGatewayService{cfg: cfg, httpUpstream: upstream} + c := adaptiveProtocolTestContext("/v1/responses", responsesBody) + if test.setHeaders != nil { + test.setHeaders(c) + } + _, err := svc.Forward(context.Background(), c, test.account, responsesBody) + require.Error(t, err) + require.Equal(t, test.wantURL, upstream.lastReq.URL.String()) + require.Equal(t, test.wantCap, gjson.GetBytes(upstream.lastBody, "max_output_tokens").Int()) + }) + } +} diff --git a/backend/internal/service/openai_gateway_responses_chat_fallback.go b/backend/internal/service/openai_gateway_responses_chat_fallback.go index 9939c5b469..67d3e2b4e7 100644 --- a/backend/internal/service/openai_gateway_responses_chat_fallback.go +++ b/backend/internal/service/openai_gateway_responses_chat_fallback.go @@ -87,6 +87,9 @@ func (s *OpenAIGatewayService) forwardResponsesViaRawChatCompletions( } return nil, err } + // /v1/responses 降级到 raw CC 的出站与 forwardAsRawChatCompletions 共用同一个 + // 独立 Ollama Cloud token 钩子;chatReq.Model 已是模型映射后的 upstreamModel。 + chatBody = clampOllamaCloudUpstreamMaxTokens(account, chatBody) // Keep the final outbound tier for usage-time reconciliation. A policy // filter that removes the field therefore leaves this nil. serviceTier := extractOpenAIServiceTierFromBody(chatBody) From 2fc24d8879951d571e540718aa011c30ccd7b9a6 Mon Sep 17 00:00:00 2001 From: alfadb Date: Mon, 7 Sep 2026 17:23:16 +0800 Subject: [PATCH 122/145] fix(ollama): normalize Messages base URLs before token clamping --- .../ollama_cloud_messages_max_tokens.go | 9 +- .../ollama_cloud_messages_max_tokens_test.go | 97 +++++++++++++++++++ 2 files changed, 103 insertions(+), 3 deletions(-) diff --git a/backend/internal/service/ollama_cloud_messages_max_tokens.go b/backend/internal/service/ollama_cloud_messages_max_tokens.go index 66bb4831a3..d178488460 100644 --- a/backend/internal/service/ollama_cloud_messages_max_tokens.go +++ b/backend/internal/service/ollama_cloud_messages_max_tokens.go @@ -1,6 +1,8 @@ package service import ( + "strings" + "github.com/tidwall/gjson" ) @@ -9,13 +11,14 @@ import ( // 与 raw CC / Responses 路径共用 cap 配置与实现。判定与出站 URL 组装同源:调用方 // 传入本次实际选用的 Anthropic 上游 base_url(GetBaseURL 或 // GetAnthropicProtocolBaseURL,不用 GetOpenAIBaseURL——adaptive 时那是 CC/Responses -// 地址),仅当其为 ollama.com 且 body 中映射后出站 model 为 DeepSeek 系时压到 cap, -// 否则原样返回。 +// 地址),与真实出站组装一样先 TrimRight "/"(urlvalidator 与 +// nativeAnthropicTargetURL 同款归一化),仅当其为 ollama.com 且 body 中映射后出站 +// model 为 DeepSeek 系时压到 cap,否则原样返回。 func clampOllamaCloudAnthropicMessagesMaxTokens(account *Account, baseURL string, body []byte) []byte { if account == nil || len(body) == 0 { return body } - if !isOllamaCloudBaseURL(baseURL) { + if !isOllamaCloudBaseURL(strings.TrimRight(strings.TrimSpace(baseURL), "/")) { return body } if !isDeepSeekModel(gjson.GetBytes(body, "model").String()) { diff --git a/backend/internal/service/ollama_cloud_messages_max_tokens_test.go b/backend/internal/service/ollama_cloud_messages_max_tokens_test.go index e8fab5d1fe..66b3e6e157 100644 --- a/backend/internal/service/ollama_cloud_messages_max_tokens_test.go +++ b/backend/internal/service/ollama_cloud_messages_max_tokens_test.go @@ -243,3 +243,100 @@ func TestBuildNativeAnthropicUpstreamRequest_ClampsOllamaCloudDeepSeekMaxTokens( require.Equal(t, int64(256000), gjson.GetBytes(wireBody, "max_tokens").Int()) }) } + +// messagesClampProductionAccount 复刻生产账号 162:platform=deepseek、type=apikey、 +// api_protocol=adaptive、base_url 与 api_base_urls 均指向 ollama.com(anthropic 地址 +// 带 trailing '/',CC/Responses 地址带 /v1 后缀)。 +func messagesClampProductionAccount(id int64) *Account { + return &Account{ + ID: id, + Name: "ollama-cloud-prod", + Platform: PlatformDeepseek, + Type: AccountTypeAPIKey, + Credentials: map[string]any{ + "api_key": "sk-test", + "api_protocol": APIProtocolAdaptive, + "base_url": "https://ollama.com/v1", + "api_base_urls": map[string]any{ + APIProtocolAnthropic: "https://ollama.com/", + APIProtocolResponses: "https://ollama.com/v1", + APIProtocolChatCompletions: "https://ollama.com/v1", + }, + }, + Extra: map[string]any{}, + } +} + +// TestBuildNativeAnthropicUpstreamRequest_ClampsTrailingSlashOllamaBase 复刻生产 +// 失败:anthropic 协议地址配置为 "https://ollama.com/"(trailing '/')。真实出站 +// URL 组装(ValidateURLFormat 与 nativeAnthropicTargetURL 均做 TrimRight "/")会 +// 正确打到 https://ollama.com/v1/messages,因此 clamp 判定也必须对同一归一化后的 +// base 生效,max_tokens=256000 不得原样透传被上游 400。 +func TestBuildNativeAnthropicUpstreamRequest_ClampsTrailingSlashOllamaBase(t *testing.T) { + svc := &OpenAIGatewayService{cfg: messagesClampTestConfig()} + c := newMessagesClampTestContext(t) + account := messagesClampProductionAccount(431) + body := messagesClampBody("deepseek-v4-flash", 256000) + + // 真实出站 URL:trailing '/' 被既有组装归一化掉,请求确实可达 ollama.com。 + targetURL, err := svc.nativeAnthropicTargetURL(account) + require.NoError(t, err) + require.Equal(t, "https://ollama.com/v1/messages", targetURL) + + req, wireBody, err := svc.buildNativeAnthropicUpstreamRequest( + context.Background(), c, account, body, "sk-test", targetURL, + ) + require.NoError(t, err) + require.Equal(t, "https://ollama.com/v1/messages", req.URL.String()) + require.Equal(t, "deepseek-v4-flash", gjson.GetBytes(wireBody, "model").String()) + require.Equal(t, int64(65535), gjson.GetBytes(wireBody, "max_tokens").Int(), + "anthropic base trailing '/' 时仍须命中 clamp(生产 400 回归)") +} + +// TestBuildUpstreamRequest_ClampsTrailingSlashOllamaBase 覆盖 builder A / B 的相同 +// 场景:GetBaseURL() 原样返回 trailing '/',而 validateUpstreamBaseURL 归一化后 +// 实际出站 URL 可达 ollama.com/v1/messages,clamp 判定须与归一化同源。 +func TestBuildUpstreamRequest_ClampsTrailingSlashOllamaBase(t *testing.T) { + svc := &GatewayService{cfg: messagesClampTestConfig()} + c := newMessagesClampTestContext(t) + body := messagesClampBody("deepseek-v4-flash", 256000) + + newAccount := func(id int64) *Account { + account := messagesClampOllamaAccount(id, PlatformAnthropic) + account.Credentials["base_url"] = "https://ollama.com/" + return account + } + + t.Run("builder A trailing slash base is clamped", func(t *testing.T) { + req, wireBody, err := svc.buildUpstreamRequest( + context.Background(), c, newAccount(441), body, "sk-test", "api_key", + "deepseek-v4-flash", false, false, + ) + require.NoError(t, err) + require.Equal(t, "https://ollama.com/v1/messages?beta=true", req.URL.String()) + require.Equal(t, int64(65535), gjson.GetBytes(wireBody, "max_tokens").Int()) + }) + + t.Run("builder B trailing slash base is clamped", func(t *testing.T) { + req, wireBody, err := svc.buildUpstreamRequestAnthropicAPIKeyPassthrough( + context.Background(), c, newAccount(442), body, "sk-test", + ) + require.NoError(t, err) + require.Equal(t, "https://ollama.com/v1/messages?beta=true", req.URL.String()) + require.Equal(t, int64(65535), gjson.GetBytes(wireBody, "max_tokens").Int()) + }) + + t.Run("evil suffix and custom path never match", func(t *testing.T) { + for _, base := range []string{"https://ollama.com.evil.com/", "https://ollama.com/anthropic/"} { + account := newAccount(443) + account.Credentials["base_url"] = base + _, wireBody, err := svc.buildUpstreamRequest( + context.Background(), c, account, body, "sk-test", "api_key", + "deepseek-v4-flash", false, false, + ) + require.NoError(t, err) + require.Equal(t, int64(256000), gjson.GetBytes(wireBody, "max_tokens").Int(), + "base %q 不得被识别为 Ollama Cloud", base) + } + }) +} From 57387445f3cf204717d9b15b3e8a863c52d1115d Mon Sep 17 00:00:00 2001 From: alfadb Date: Mon, 7 Sep 2026 17:32:31 +0800 Subject: [PATCH 123/145] fix(ollama): select Bearer auth for Anthropic endpoints automatically --- .../internal/service/account_test_service.go | 4 +- .../account_test_service_cn_adaptive.go | 8 +- .../internal/service/anthropic_apikey_auth.go | 21 +- .../anthropic_apikey_auth_ollama_test.go | 335 ++++++++++++++++++ .../service/gateway_anthropic_passthrough.go | 4 +- .../internal/service/gateway_count_tokens.go | 8 +- .../service/gateway_upstream_request.go | 4 +- ...penai_gateway_messages_anthropic_native.go | 5 +- backend/internal/service/upstream_models.go | 4 +- 9 files changed, 382 insertions(+), 11 deletions(-) create mode 100644 backend/internal/service/anthropic_apikey_auth_ollama_test.go diff --git a/backend/internal/service/account_test_service.go b/backend/internal/service/account_test_service.go index ffae4ce8b7..4a8dec13b1 100644 --- a/backend/internal/service/account_test_service.go +++ b/backend/internal/service/account_test_service.go @@ -463,7 +463,9 @@ func (s *AccountTestService) testClaudeAccountConnection(c *gin.Context, account req.Header.Set("Authorization", "Bearer "+authToken) } else { req.Header.Set("anthropic-beta", claude.APIKeyBetaHeader) - setAnthropicAPIKeyAuthHeader(req.Header, account, authToken) + // Ollama Cloud Anthropic 兼容端点按实际 base_url 强制 Bearer, + // 其余保持 extra/default 行为。 + setAnthropicAPIKeyAuthHeader(req.Header, account, authToken, account.GetBaseURL()) } // 账号级请求头覆写:测试请求与真实转发保持一致的最终头 diff --git a/backend/internal/service/account_test_service_cn_adaptive.go b/backend/internal/service/account_test_service_cn_adaptive.go index db7b2418e1..c0491fce4f 100644 --- a/backend/internal/service/account_test_service_cn_adaptive.go +++ b/backend/internal/service/account_test_service_cn_adaptive.go @@ -81,7 +81,9 @@ func (s *AccountTestService) testCNProviderAdaptiveAnthropicConnection(c *gin.Co req.Header.Set(key, value) } req.Header.Set("anthropic-beta", claude.APIKeyBetaHeader) - setAnthropicAPIKeyAuthHeader(req.Header, account, authToken) + // Ollama Cloud Anthropic 兼容端点按 adaptive 实际选用的 Anthropic + // base_url 强制 Bearer,其余保持 extra/default 行为。 + setAnthropicAPIKeyAuthHeader(req.Header, account, authToken, account.GetCNProtocolBaseURL(APIProtocolAnthropic)) account.ApplyHeaderOverrides(req.Header) resp, err := s.doCNProviderAdaptiveRequest(req, account) @@ -260,7 +262,9 @@ func (s *AccountTestService) testCNProviderAnthropicConnection(c *gin.Context, a for key, value := range claude.DefaultHeaders { req.Header.Set(key, value) } - setAnthropicAPIKeyAuthHeader(req.Header, account, authToken) + // Ollama Cloud Anthropic 兼容端点按实际 base_url 强制 Bearer,其余保持 + // extra/default 行为。 + setAnthropicAPIKeyAuthHeader(req.Header, account, authToken, account.GetAnthropicProtocolBaseURL()) account.ApplyHeaderOverrides(req.Header) resp, err := s.doCNProviderAdaptiveRequest(req, account) diff --git a/backend/internal/service/anthropic_apikey_auth.go b/backend/internal/service/anthropic_apikey_auth.go index 044d5be96c..8bd8c4ba8f 100644 --- a/backend/internal/service/anthropic_apikey_auth.go +++ b/backend/internal/service/anthropic_apikey_auth.go @@ -33,7 +33,26 @@ func (a *Account) GetAnthropicAPIKeyAuthScheme() string { } } -func setAnthropicAPIKeyAuthHeader(header http.Header, account *Account, token string) { +// isOllamaCloudAnthropicAuthBaseURL 判定本次实际选用的 Anthropic 上游 base_url +// 是否指向 Ollama Cloud。与真实出站 URL 组装同源:先 TrimSpace + TrimRight "/" +// (urlvalidator 与 nativeAnthropicTargetURL 同款归一化,'https://ollama.com/' +// 与 '.../v1' 均合理),再复用既有严格 host helper isOllamaCloudBaseURL(精确 +// host、https、无 query/fragment、路径仅空或 /v1),不按 substring 匹配。 +func isOllamaCloudAnthropicAuthBaseURL(baseURL string) bool { + return isOllamaCloudBaseURL(strings.TrimRight(strings.TrimSpace(baseURL), "/")) +} + +// setAnthropicAPIKeyAuthHeader 写入上游认证头。除 extra 覆写外,Ollama Cloud +// 官方 Anthropic 兼容端点只认 Authorization: Bearer:判定依据是本次实际选用的 +// 上游 base_url(与出站 URL 组装同源,而非 account.Platform——Ollama Cloud key +// 可挂在多个平台分组下),即使 extra 缺失或显式 x_api_key 也强制 Bearer; +// 其它上游保持历史 extra/default 行为。baseURL 为该请求实际选用的 Anthropic +// 上游 base(GetBaseURL / GetAnthropicProtocolBaseURL 等),默认官方端点时传空。 +func setAnthropicAPIKeyAuthHeader(header http.Header, account *Account, token, baseURL string) { + if account.Type == AccountTypeAPIKey && isOllamaCloudAnthropicAuthBaseURL(baseURL) { + header.Set("Authorization", "Bearer "+token) + return + } if account.GetAnthropicAPIKeyAuthScheme() == AnthropicAPIKeyAuthSchemeAuthorizationBearer { header.Set("Authorization", "Bearer "+token) return diff --git a/backend/internal/service/anthropic_apikey_auth_ollama_test.go b/backend/internal/service/anthropic_apikey_auth_ollama_test.go new file mode 100644 index 0000000000..6870d7e299 --- /dev/null +++ b/backend/internal/service/anthropic_apikey_auth_ollama_test.go @@ -0,0 +1,335 @@ +package service + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +// newOllamaCloudAnthropicAuthAccount 构造挂在 Anthropic 平台分组下的 Ollama Cloud +// API Key 账号(base_url 指向官方 ollama.com Anthropic 兼容端点)。 +func newOllamaCloudAnthropicAuthAccount(baseURL string, extra map[string]any) *Account { + if extra == nil { + extra = map[string]any{} + } + return &Account{ + ID: 901, + Name: "ollama-cloud-anthropic-auth", + Platform: PlatformAnthropic, + Type: AccountTypeAPIKey, + Concurrency: 1, + Credentials: map[string]any{ + "api_key": "ollama-cloud-key", + "base_url": baseURL, + }, + Extra: extra, + Status: StatusActive, + Schedulable: true, + } +} + +// TestSetAnthropicAPIKeyAuthHeader_OllamaCloudForcesBearer:Ollama Cloud 官方 +// Anthropic 兼容端点只认 Authorization: Bearer——extra 缺失或显式 x_api_key 均 +// 强制 Bearer;判定与实际 base_url 同源并做与出站组装一致的尾斜杠归一化; +// 其它上游保持历史 extra/default 行为,恶意相似 host 不误判。 +func TestSetAnthropicAPIKeyAuthHeader_OllamaCloudForcesBearer(t *testing.T) { + tests := []struct { + name string + baseURL string + extra map[string]any + accountType string + wantAuth string // "" = 不应出现 Authorization 头 + wantXAPIKey string // "" = 不应出现 x-api-key 头 + }{ + { + name: "ollama 缺 extra 默认 Bearer", + baseURL: "https://ollama.com", + wantAuth: "Bearer ollama-cloud-key", + }, + { + name: "ollama base 尾斜杠仍 Bearer", + baseURL: "https://ollama.com/", + wantAuth: "Bearer ollama-cloud-key", + }, + { + name: "ollama /v1 路径 Bearer", + baseURL: "https://ollama.com/v1", + wantAuth: "Bearer ollama-cloud-key", + }, + { + name: "ollama /v1 尾斜杠 Bearer", + baseURL: "https://www.ollama.com/v1/", + wantAuth: "Bearer ollama-cloud-key", + }, + { + name: "host 大小写不敏感 Bearer", + baseURL: "https://OLLAMA.COM", + wantAuth: "Bearer ollama-cloud-key", + }, + { + name: "ollama 显式 x_api_key 也强制 Bearer", + baseURL: "https://ollama.com", + extra: map[string]any{anthropicAPIKeyAuthSchemeExtraKey: AnthropicAPIKeyAuthSchemeXAPIKey}, + wantAuth: "Bearer ollama-cloud-key", + }, + { + name: "非 Ollama 默认 x-api-key", + baseURL: "https://api.anthropic.com", + wantXAPIKey: "ollama-cloud-key", + }, + { + name: "默认官方端点(空 base_url)x-api-key", + baseURL: "", + wantXAPIKey: "ollama-cloud-key", + }, + { + name: "非 Ollama 显式 authorization_bearer 保持 Bearer", + baseURL: "https://api.anthropic.com", + extra: map[string]any{anthropicAPIKeyAuthSchemeExtraKey: AnthropicAPIKeyAuthSchemeAuthorizationBearer}, + wantAuth: "Bearer ollama-cloud-key", + }, + { + name: "恶意相似 host 子串不命中", + baseURL: "https://evil.com/ollama.com", + wantXAPIKey: "ollama-cloud-key", + }, + { + name: "恶意相似 host 后缀不命中", + baseURL: "https://ollama.com.evil.com", + wantXAPIKey: "ollama-cloud-key", + }, + { + name: "http 明文不命中", + baseURL: "http://ollama.com", + wantXAPIKey: "ollama-cloud-key", + }, + { + name: "非标准端口不命中", + baseURL: "https://ollama.com:8443", + wantXAPIKey: "ollama-cloud-key", + }, + { + name: "带 path 的 messages 地址不命中(判定用 base 而非完整 URL)", + baseURL: "https://ollama.com/v1/messages", + wantXAPIKey: "ollama-cloud-key", + }, + { + name: "非 APIKey 账号类型不强制", + baseURL: "https://ollama.com", + accountType: AccountTypeOAuth, + wantXAPIKey: "ollama-cloud-key", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + accountType := AccountTypeAPIKey + if tt.accountType != "" { + accountType = tt.accountType + } + account := newOllamaCloudAnthropicAuthAccount(tt.baseURL, tt.extra) + account.Type = accountType + + header := http.Header{} + setAnthropicAPIKeyAuthHeader(header, account, "ollama-cloud-key", tt.baseURL) + require.Equal(t, tt.wantAuth, header.Get("Authorization"), "Authorization 头不符") + require.Equal(t, tt.wantXAPIKey, header.Get("x-api-key"), "x-api-key 头不符") + }) + } +} + +// TestSetAnthropicAPIKeyAuthHeader_CNAdaptiveBaseURLResolution:adaptive 账号经 +// GetCNProtocolBaseURL(anthropic) / GetAnthropicProtocolBaseURL 选出的分协议地址 +// 才是判定依据,Chat Completions 地址(GetOpenAIBaseURL 选到的 CC base)不参与。 +func TestSetAnthropicAPIKeyAuthHeader_CNAdaptiveBaseURL(t *testing.T) { + // adaptive:anthropic 分协议地址指向 ollama.com → Bearer;CC 地址同挂 ollama + // 不影响(判定只用 anthropic 分协议地址)。 + adaptiveOllamaAnthropic := &Account{ + ID: 902, + Name: "adaptive-ollama-anthropic", + Platform: PlatformKimi, + Type: AccountTypeAPIKey, + Credentials: map[string]any{ + "api_key": "kimi-key", + "api_protocol": APIProtocolAdaptive, + "api_base_urls": map[string]any{ + APIProtocolAnthropic: "https://ollama.com", + APIProtocolChatCompletions: "https://api.moonshot.cn", + }, + }, + } + anthropicBase := adaptiveOllamaAnthropic.GetCNProtocolBaseURL(APIProtocolAnthropic) + require.Equal(t, "https://ollama.com", anthropicBase) + header := http.Header{} + setAnthropicAPIKeyAuthHeader(header, adaptiveOllamaAnthropic, "kimi-key", anthropicBase) + require.Equal(t, "Bearer kimi-key", header.Get("Authorization")) + require.Empty(t, header.Get("x-api-key")) + + // 反向:CC 地址挂 ollama.com,anthropic 分协议地址非 ollama → 不误判, + // 保持 x-api-key。 + adaptiveOllamaCC := &Account{ + ID: 903, + Name: "adaptive-ollama-cc", + Platform: PlatformKimi, + Type: AccountTypeAPIKey, + Credentials: map[string]any{ + "api_key": "kimi-key", + "api_protocol": APIProtocolAdaptive, + "api_base_urls": map[string]any{ + APIProtocolAnthropic: "https://api.moonshot.cn/anthropic", + APIProtocolChatCompletions: "https://ollama.com/v1", + }, + }, + } + anthropicBase = adaptiveOllamaCC.GetCNProtocolBaseURL(APIProtocolAnthropic) + require.Equal(t, "https://api.moonshot.cn/anthropic", anthropicBase) + header = http.Header{} + setAnthropicAPIKeyAuthHeader(header, adaptiveOllamaCC, "kimi-key", anthropicBase) + require.Empty(t, header.Get("Authorization")) + require.Equal(t, "kimi-key", header.Get("x-api-key")) + + // anthropic 协议(非 adaptive):凭证 base_url 指向 ollama.com → Bearer。 + anthropicProtocolOllama := &Account{ + ID: 904, + Name: "anthropic-protocol-ollama", + Platform: PlatformKimi, + Type: AccountTypeAPIKey, + Credentials: map[string]any{ + "api_key": "kimi-key", + "api_protocol": APIProtocolAnthropic, + "base_url": "https://ollama.com", + }, + } + anthropicBase = anthropicProtocolOllama.GetAnthropicProtocolBaseURL() + require.Equal(t, "https://ollama.com", anthropicBase) + header = http.Header{} + setAnthropicAPIKeyAuthHeader(header, anthropicProtocolOllama, "kimi-key", anthropicBase) + require.Equal(t, "Bearer kimi-key", header.Get("Authorization")) + require.Empty(t, header.Get("x-api-key")) + + // 同协议但 base 为 kimi 官方 → 保持 x-api-key。 + anthropicProtocolKimi := &Account{ + ID: 905, + Name: "anthropic-protocol-kimi", + Platform: PlatformKimi, + Type: AccountTypeAPIKey, + Credentials: map[string]any{ + "api_key": "kimi-key", + "api_protocol": APIProtocolAnthropic, + "base_url": "https://api.moonshot.cn/anthropic", + }, + } + header = http.Header{} + setAnthropicAPIKeyAuthHeader(header, anthropicProtocolKimi, "kimi-key", anthropicProtocolKimi.GetAnthropicProtocolBaseURL()) + require.Empty(t, header.Get("Authorization")) + require.Equal(t, "kimi-key", header.Get("x-api-key")) +} + +// TestGatewayService_AnthropicPassthrough_OllamaCloudBearer:经 passthrough +// builder 真实构造 http.Request 验证最终 header——Ollama Cloud 强制 Bearer 且 +// 不泄漏客户端入站认证;非 Ollama 上游保持 x-api-key。 +func TestGatewayService_AnthropicPassthrough_OllamaCloudBearer(t *testing.T) { + gin.SetMode(gin.TestMode) + + buildReq := func(t *testing.T, baseURL string) *http.Request { + t.Helper() + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil) + // 入站认证残留:正确上游只发一种认证头,不得泄漏客户端凭证 + c.Request.Header.Set("Authorization", "Bearer inbound-token") + c.Request.Header.Set("X-Api-Key", "inbound-api-key") + + svc := &GatewayService{cfg: &config.Config{}} + account := newOllamaCloudAnthropicAuthAccount(baseURL, nil) + body := []byte(`{"model":"claude-3-7-sonnet-20250219","max_tokens":64,"messages":[{"role":"user","content":"hi"}]}`) + req, _, err := svc.buildUpstreamRequestAnthropicAPIKeyPassthrough(context.Background(), c, account, body, "ollama-cloud-key") + require.NoError(t, err) + return req + } + + ollamaReq := buildReq(t, "https://ollama.com/") + require.Equal(t, "Bearer ollama-cloud-key", ollamaReq.Header.Get("Authorization")) + require.Empty(t, ollamaReq.Header.Get("x-api-key"), "Ollama Cloud 上游不得再发 x-api-key") + require.NotContains(t, ollamaReq.Header.Get("Authorization"), "inbound-token") + require.Empty(t, ollamaReq.Header.Get("x-inbound"), "sanity") + + anthropicReq := buildReq(t, "https://api.anthropic.com") + require.Empty(t, anthropicReq.Header.Get("Authorization")) + require.Equal(t, "ollama-cloud-key", anthropicReq.Header.Get("x-api-key")) + require.NotContains(t, anthropicReq.Header.Get("x-api-key"), "inbound-api-key") +} + +// TestGatewayService_BuildUpstreamRequest_OllamaCloudBearer:经 Anthropic 原生 +// GetBaseURL builder 真实构造 http.Request 验证最终 header。 +func TestGatewayService_BuildUpstreamRequest_OllamaCloudBearer(t *testing.T) { + gin.SetMode(gin.TestMode) + + buildReq := func(t *testing.T, baseURL string) *http.Request { + t.Helper() + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil) + c.Request.Header.Set("Authorization", "Bearer inbound-token") + c.Request.Header.Set("X-Api-Key", "inbound-api-key") + + svc := &GatewayService{cfg: &config.Config{}} + account := newOllamaCloudAnthropicAuthAccount(baseURL, nil) + body := []byte(`{"model":"claude-3-7-sonnet-20250219","max_tokens":64,"messages":[{"role":"user","content":"hi"}]}`) + req, _, err := svc.buildUpstreamRequest(context.Background(), c, account, body, "ollama-cloud-key", "apikey", "claude-3-7-sonnet-20250219", false, false) + require.NoError(t, err) + return req + } + + ollamaReq := buildReq(t, "https://ollama.com") + require.Equal(t, "https://ollama.com/v1/messages?beta=true", ollamaReq.URL.String()) + require.Equal(t, "Bearer ollama-cloud-key", ollamaReq.Header.Get("Authorization")) + require.Empty(t, ollamaReq.Header.Get("x-api-key")) + + anthropicReq := buildReq(t, "https://api.anthropic.com") + require.Empty(t, anthropicReq.Header.Get("Authorization")) + require.Equal(t, "ollama-cloud-key", anthropicReq.Header.Get("x-api-key")) +} + +// TestOpenAIGatewayService_NativeAnthropicBridge_OllamaCloudBearer:经 native +// Anthropic 桥接 builder(CC/Responses → /v1/messages)真实构造 http.Request +// 验证最终 header。 +func TestOpenAIGatewayService_NativeAnthropicBridge_OllamaCloudBearer(t *testing.T) { + gin.SetMode(gin.TestMode) + + svc := &OpenAIGatewayService{cfg: &config.Config{}} + account := &Account{ + ID: 906, + Name: "anthropic-protocol-ollama-bridge", + Platform: PlatformKimi, + Type: AccountTypeAPIKey, + Credentials: map[string]any{ + "api_key": "ollama-cloud-key", + "api_protocol": APIProtocolAnthropic, + "base_url": "https://ollama.com", + }, + } + targetURL, err := svc.nativeAnthropicTargetURL(account) + require.NoError(t, err) + require.Equal(t, "https://ollama.com/v1/messages", targetURL) + + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + body := []byte(`{"model":"kimi-k2-thinking","max_tokens":64,"messages":[{"role":"user","content":"hi"}]}`) + req, _, err := svc.buildNativeAnthropicUpstreamRequest(context.Background(), c, account, body, "ollama-cloud-key", targetURL) + require.NoError(t, err) + require.Equal(t, "Bearer ollama-cloud-key", req.Header.Get("Authorization")) + require.Empty(t, req.Header.Get("x-api-key")) + + // 非 Ollama 上游:保持 x-api-key,无 Authorization。 + account.Credentials["base_url"] = "https://api.moonshot.cn/anthropic" + targetURL, err = svc.nativeAnthropicTargetURL(account) + require.NoError(t, err) + req, _, err = svc.buildNativeAnthropicUpstreamRequest(context.Background(), c, account, body, "ollama-cloud-key", targetURL) + require.NoError(t, err) + require.Empty(t, req.Header.Get("Authorization")) + require.Equal(t, "ollama-cloud-key", req.Header.Get("x-api-key")) +} diff --git a/backend/internal/service/gateway_anthropic_passthrough.go b/backend/internal/service/gateway_anthropic_passthrough.go index b202e7dbba..d9393e5a0c 100644 --- a/backend/internal/service/gateway_anthropic_passthrough.go +++ b/backend/internal/service/gateway_anthropic_passthrough.go @@ -352,7 +352,9 @@ func (s *GatewayService) buildUpstreamRequestAnthropicAPIKeyPassthrough( req.Header.Del("x-api-key") req.Header.Del("x-goog-api-key") req.Header.Del("cookie") - setAnthropicAPIKeyAuthHeader(req.Header, account, token) + // Ollama Cloud Anthropic 兼容端点按实际 base_url 强制 Bearer(同上方 + // targetURL 的 base 取值),其余保持 extra/default 行为。 + setAnthropicAPIKeyAuthHeader(req.Header, account, token, account.GetBaseURL()) if getHeaderRaw(req.Header, "content-type") == "" { setHeaderRaw(req.Header, "content-type", "application/json") diff --git a/backend/internal/service/gateway_count_tokens.go b/backend/internal/service/gateway_count_tokens.go index fb745d69b3..5ff4f323a8 100644 --- a/backend/internal/service/gateway_count_tokens.go +++ b/backend/internal/service/gateway_count_tokens.go @@ -417,7 +417,9 @@ func (s *GatewayService) buildCountTokensRequestAnthropicAPIKeyPassthrough( req.Header.Del("x-api-key") req.Header.Del("x-goog-api-key") req.Header.Del("cookie") - setAnthropicAPIKeyAuthHeader(req.Header, account, token) + // Ollama Cloud Anthropic 兼容端点按实际 base_url 强制 Bearer(同上方 + // targetURL 的 base 取值),其余保持 extra/default 行为。 + setAnthropicAPIKeyAuthHeader(req.Header, account, token, account.GetBaseURL()) if req.Header.Get("content-type") == "" { req.Header.Set("content-type", "application/json") @@ -522,7 +524,9 @@ func (s *GatewayService) buildCountTokensRequest(ctx context.Context, c *gin.Con if tokenType == "oauth" { setHeaderRaw(req.Header, "authorization", "Bearer "+token) } else { - setAnthropicAPIKeyAuthHeader(req.Header, account, token) + // Ollama Cloud Anthropic 兼容端点按实际 base_url 强制 Bearer(同上方 + // targetURL 的 base 取值),其余保持 extra/default 行为。 + setAnthropicAPIKeyAuthHeader(req.Header, account, token, account.GetBaseURL()) } // 白名单透传 headers(恢复真实 wire casing) diff --git a/backend/internal/service/gateway_upstream_request.go b/backend/internal/service/gateway_upstream_request.go index 93367eef89..4d8e74c162 100644 --- a/backend/internal/service/gateway_upstream_request.go +++ b/backend/internal/service/gateway_upstream_request.go @@ -131,7 +131,9 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex if tokenType == "oauth" { setHeaderRaw(req.Header, "authorization", "Bearer "+token) } else { - setAnthropicAPIKeyAuthHeader(req.Header, account, token) + // Ollama Cloud Anthropic 兼容端点按实际 base_url 强制 Bearer(同上方 + // targetURL 的 base 取值),其余保持 extra/default 行为。 + setAnthropicAPIKeyAuthHeader(req.Header, account, token, account.GetBaseURL()) } // 白名单透传 headers diff --git a/backend/internal/service/openai_gateway_messages_anthropic_native.go b/backend/internal/service/openai_gateway_messages_anthropic_native.go index a629e977df..6624c30f5a 100644 --- a/backend/internal/service/openai_gateway_messages_anthropic_native.go +++ b/backend/internal/service/openai_gateway_messages_anthropic_native.go @@ -182,12 +182,13 @@ func (s *OpenAIGatewayService) buildNativeAnthropicUpstreamRequest( } // 覆盖入站鉴权残留,注入上游认证(默认 x-api-key;可经 extra - // anthropic_apikey_auth_scheme 切换 Authorization: Bearer)。 + // anthropic_apikey_auth_scheme 切换 Authorization: Bearer;Ollama Cloud + // 上游按实际 base_url 强制 Bearer,与 nativeAnthropicTargetURL 同源)。 req.Header.Del("authorization") req.Header.Del("x-api-key") req.Header.Del("x-goog-api-key") req.Header.Del("cookie") - setAnthropicAPIKeyAuthHeader(req.Header, account, apiKey) + setAnthropicAPIKeyAuthHeader(req.Header, account, apiKey, account.GetAnthropicProtocolBaseURL()) if getHeaderRaw(req.Header, "content-type") == "" { setHeaderRaw(req.Header, "content-type", "application/json") diff --git a/backend/internal/service/upstream_models.go b/backend/internal/service/upstream_models.go index 0916596657..4b106ddfe2 100644 --- a/backend/internal/service/upstream_models.go +++ b/backend/internal/service/upstream_models.go @@ -940,7 +940,9 @@ func (s *AccountTestService) buildAnthropicUpstreamModelsRequest(ctx context.Con if authHeaderName != "" { req.Header.Set(authHeaderName, authHeaderValue) } else { - setAnthropicAPIKeyAuthHeader(req.Header, account, apiKeyAuthToken) + // Ollama Cloud Anthropic 兼容端点按实际 base_url 强制 Bearer,其余保持 + // extra/default 行为。 + setAnthropicAPIKeyAuthHeader(req.Header, account, apiKeyAuthToken, normalizedBaseURL) } // 账号级请求头覆写:模型列表探测与真实转发保持一致的最终头 account.ApplyHeaderOverrides(req.Header) From e094a3f40b1c84b78c5f6298004145587a3ba2a0 Mon Sep 17 00:00:00 2001 From: Zero Clover Date: Mon, 7 Sep 2026 21:59:35 +0800 Subject: [PATCH 124/145] fix(accounts): populate display names for live test models --- .../service/account_test_models_test.go | 64 +++++++++++++++++++ .../internal/service/account_test_service.go | 11 ++++ 2 files changed, 75 insertions(+) create mode 100644 backend/internal/service/account_test_models_test.go diff --git a/backend/internal/service/account_test_models_test.go b/backend/internal/service/account_test_models_test.go new file mode 100644 index 0000000000..789bb33bc3 --- /dev/null +++ b/backend/internal/service/account_test_models_test.go @@ -0,0 +1,64 @@ +package service + +import ( + "context" + "net/http" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestFetchOpenAIAccountModelsOAuthPopulatesPickerFields(t *testing.T) { + _, calls := newCodexModelsOAuthCacheServer(t, `{"models":[{"slug":"new-oauth-model"},{"slug":"gpt-6-astra"}]}`) + gateway := &OpenAIGatewayService{} + svc := &AccountTestService{openaiGatewayService: gateway} + account := newCodexModelsTestAccount() + ctx := context.Background() + + before, err := gateway.FetchOpenAIModelsList(ctx, account) + require.NoError(t, err) + models, err := svc.FetchOpenAIAccountModels(ctx, account) + require.NoError(t, err) + require.Len(t, models, 2) + for i, id := range []string{"new-oauth-model", "gpt-6-astra"} { + require.Equal(t, id, models[i].ID) + require.Equal(t, id, models[i].DisplayName) + require.Equal(t, "model", models[i].Type) + } + after, err := gateway.FetchOpenAIModelsList(ctx, account) + require.NoError(t, err) + require.Equal(t, before.Body, after.Body, "picker fields must not change the shared catalog") + require.NotContains(t, string(after.Body), "display_name") + require.EqualValues(t, 1, calls.Load(), "picker must reuse the shared discovery cache") +} + +func TestFetchOpenAIAccountModelsAPIKeyPopulatesPickerFields(t *testing.T) { + gateway := newCodexModelsAPIKeyTestService(&codexModelsHTTPUpstreamStub{do: func(_ *http.Request, _ string, _ int64, _ int) (*http.Response, error) { + return ordinaryModelsUpstreamResponse(`{"data":[ + {"id":"new-api-model","owned_by":"provider","created":123}, + {"id":"blank-label","display_name":" ","type":""}, + {"id":"named-model","display_name":"Provider Model","type":"model"} + ]}`), nil + }}) + svc := &AccountTestService{openaiGatewayService: gateway} + models, err := svc.FetchOpenAIAccountModels(context.Background(), newCodexModelsAPIKeyTestAccount("https://models.example/v1")) + require.NoError(t, err) + require.Len(t, models, 3) + for i, name := range []string{"new-api-model", "blank-label", "Provider Model"} { + require.Equal(t, name, models[i].DisplayName) + require.Equal(t, "model", models[i].Type) + } + require.Equal(t, "provider", models[0].OwnedBy) + require.EqualValues(t, 123, models[0].Created) + require.Equal(t, "named-model", models[2].ID) +} + +func TestFetchOpenAIAccountModelsPreservesEmptyCatalog(t *testing.T) { + gateway := newCodexModelsAPIKeyTestService(&codexModelsHTTPUpstreamStub{do: func(_ *http.Request, _ string, _ int64, _ int) (*http.Response, error) { + return ordinaryModelsUpstreamResponse(`{"data":[]}`), nil + }}) + svc := &AccountTestService{openaiGatewayService: gateway} + models, err := svc.FetchOpenAIAccountModels(context.Background(), newCodexModelsAPIKeyTestAccount("https://models.example/v1")) + require.NoError(t, err) + require.Empty(t, models, "an empty upstream catalog must not become a static model list") +} diff --git a/backend/internal/service/account_test_service.go b/backend/internal/service/account_test_service.go index ffae4ce8b7..ff684d2179 100644 --- a/backend/internal/service/account_test_service.go +++ b/backend/internal/service/account_test_service.go @@ -192,6 +192,17 @@ func (s *AccountTestService) FetchOpenAIAccountModels(ctx context.Context, accou if err := json.Unmarshal(response.Body, &payload); err != nil { return nil, fmt.Errorf("decode OpenAI account models: %w", err) } + // Standard model catalogs do not require the fields used by the admin picker. + // Populate them here without changing the shared discovery response or cache. + for i := range payload.Data { + model := &payload.Data[i] + if strings.TrimSpace(model.DisplayName) == "" { + model.DisplayName = model.ID + } + if strings.TrimSpace(model.Type) == "" { + model.Type = "model" + } + } return payload.Data, nil } From 335fcdc1d657f93833a5fd8183f9acb2ed142fc0 Mon Sep 17 00:00:00 2001 From: xlplbo Date: Mon, 7 Sep 2026 21:21:47 +0800 Subject: [PATCH 125/145] =?UTF-8?q?fix(plugin):=20=E6=8F=90=E4=BA=A4?= =?UTF-8?q?=E6=8F=92=E4=BB=B6=E5=8C=85=E5=89=8D=E5=85=88=E5=85=B3=E9=97=AD?= =?UTF-8?q?=20ZIP=20=E8=AF=BB=E5=8F=96=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01SAJmqcVA9TLacTCD1bLh4i --- backend/internal/service/plugin_package.go | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/backend/internal/service/plugin_package.go b/backend/internal/service/plugin_package.go index 2ef9130b0c..a6aceaf957 100644 --- a/backend/internal/service/plugin_package.go +++ b/backend/internal/service/plugin_package.go @@ -107,7 +107,14 @@ func (i *PluginPackageInstaller) Install(ctx context.Context, reader io.Reader, if err != nil { return nil, fmt.Errorf("插件包不是有效的 ZIP: %w", err) } - defer func() { _ = archive.Close() }() + archiveClosed := false + closeArchive := func() { + if !archiveClosed { + archiveClosed = true + _ = archive.Close() + } + } + defer closeArchive() manifest, _, signatureStatus, err := i.inspectArchive(&archive.Reader) if err != nil { return nil, err @@ -137,6 +144,8 @@ func (i *PluginPackageInstaller) Install(ctx context.Context, reader io.Reader, if err := i.extractArchive(ctx, &archive.Reader, manifest, extractPath); err != nil { return nil, err } + // Windows 不允许重命名仍被打开的文件,提交前先释放 ZIP 读取器。 + closeArchive() if err := os.Rename(extractPath, installPath); err != nil { return nil, fmt.Errorf("提交插件安装目录: %w", err) } From b42157a725fa9079192c1d1badc12a8647f6ff52 Mon Sep 17 00:00:00 2001 From: xlplbo Date: Mon, 7 Sep 2026 21:21:48 +0800 Subject: [PATCH 126/145] =?UTF-8?q?test(service):=20=E4=BF=AE=E6=AD=A3=20W?= =?UTF-8?q?indows=20=E4=B8=8B=E4=B8=A4=E4=BE=8B=E7=8E=AF=E5=A2=83=E7=9B=B8?= =?UTF-8?q?=E5=85=B3=E7=94=A8=E4=BE=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01SAJmqcVA9TLacTCD1bLh4i --- .../service/content_moderation_runtime_cache_test.go | 5 +++++ backend/internal/service/plugin_package_test.go | 5 ++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/backend/internal/service/content_moderation_runtime_cache_test.go b/backend/internal/service/content_moderation_runtime_cache_test.go index 2b34d1fa57..18fa2b66ea 100644 --- a/backend/internal/service/content_moderation_runtime_cache_test.go +++ b/backend/internal/service/content_moderation_runtime_cache_test.go @@ -276,10 +276,15 @@ func TestContentModerationRuntimeSnapshotRefreshFailureKeepsStaleConfig(t *testi decision, err = svc.Check(context.Background(), input) require.NoError(t, err) require.True(t, decision.Blocked) + // Windows 单调时钟步进约 0.5ms,两次相邻调用可能读到同一时刻,需反复调用才能观察到 TTL 过期。 require.Eventually(t, func() bool { + _, _ = svc.Check(context.Background(), input) _, calls := repo.calls() return calls >= 2 }, time.Second, time.Millisecond) + decision, err = svc.Check(context.Background(), input) + require.NoError(t, err) + require.True(t, decision.Blocked) } func TestContentModerationRuntimeSnapshotRefreshFailureBacksOff(t *testing.T) { diff --git a/backend/internal/service/plugin_package_test.go b/backend/internal/service/plugin_package_test.go index cd0e42de1a..194184dbc5 100644 --- a/backend/internal/service/plugin_package_test.go +++ b/backend/internal/service/plugin_package_test.go @@ -12,6 +12,7 @@ import ( "encoding/json" "os" "path/filepath" + "runtime" "testing" "github.com/Wei-Shaw/sub2api/internal/config" @@ -33,7 +34,9 @@ func TestPluginPackageInstallerInstallUnsignedDevelopmentPackage(t *testing.T) { assert.FileExists(t, installation.ArtifactPath) info, statErr := os.Stat(installation.BinaryPath) require.NoError(t, statErr) - assert.NotZero(t, info.Mode()&0o100) + if runtime.GOOS != "windows" { + assert.NotZero(t, info.Mode()&0o100) + } assert.Contains(t, installation.InstallPath, filepath.Join("installed", "com.example.openai-transport")) } From 4a7739acdf4ff12a1683c74a9e99163d15f7d11c Mon Sep 17 00:00:00 2001 From: shaw Date: Tue, 8 Sep 2026 09:15:23 +0800 Subject: [PATCH 127/145] fix(db): converge groups.model_allowlist on upgrade MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 235 只在「models_list_config 存在且 model_allowlist 不存在」时重命名,而迁移一旦 写入 schema_migrations 就会按「文件名 + checksum」整份跳过,不再重跑。因此只要数据库 在 235 之后回到旧结构(为回滚旧版本镜像手工改回列名、或按旧结构做了部分恢复), 应用依旧能正常启动,但每个关联 groups 的查询都会失败: pq: column groups.model_allowlist does not exist 表现为「API 密钥」「我的订阅」「管理端订阅列表」等页面加载失败。 新增可重放的收敛迁移 236,升级即自愈:只有旧列则重命名(数据原样保留);两列并存 则在新列仍为默认空值时回填旧列配置;两列都没有则按默认值补建。收尾统一把 model_allowlist 归一成 NOT NULL DEFAULT '{}',与 ent schema 对齐。 判定列是否存在改用 attrelid = 'groups'::regclass(跟随 search_path), 不再写死 table_schema = 'public',避免与 ALTER TABLE 的解析路径分叉。 Fixes #6780 --- ...wlist_repair_migration_integration_test.go | 127 ++++++++++++++++++ .../236_group_model_allowlist_repair.sql | 52 +++++++ ...p_model_allowlist_repair_migration_test.go | 27 ++++ 3 files changed, 206 insertions(+) create mode 100644 backend/internal/repository/group_model_allowlist_repair_migration_integration_test.go create mode 100644 backend/migrations/236_group_model_allowlist_repair.sql create mode 100644 backend/migrations/group_model_allowlist_repair_migration_test.go diff --git a/backend/internal/repository/group_model_allowlist_repair_migration_integration_test.go b/backend/internal/repository/group_model_allowlist_repair_migration_integration_test.go new file mode 100644 index 0000000000..db66796771 --- /dev/null +++ b/backend/internal/repository/group_model_allowlist_repair_migration_integration_test.go @@ -0,0 +1,127 @@ +//go:build integration + +package repository + +import ( + "context" + "database/sql" + "testing" + + dbmigrations "github.com/Wei-Shaw/sub2api/migrations" + "github.com/stretchr/testify/require" +) + +const groupModelAllowlistRepairMigration = "236_group_model_allowlist_repair.sql" + +// 236 是可重放的修复迁移:235 的重命名一旦被记账就不会重跑,数据库若回到旧结构 +// (手工改回列名、按旧结构部分恢复)应用仍能启动,但所有关联 groups 的查询都会 +// 报 column groups.model_allowlist does not exist(issue #6780)。 +func TestMigration236RenamesLegacyModelsListConfigColumn(t *testing.T) { + tx := testTx(t) + ctx := context.Background() + + _, err := tx.ExecContext(ctx, "ALTER TABLE groups RENAME COLUMN model_allowlist TO models_list_config") + require.NoError(t, err) + + var groupID int64 + require.NoError(t, tx.QueryRowContext(ctx, ` +INSERT INTO groups (name, platform, rate_multiplier, status, models_list_config) +VALUES ('migration-236-rename', 'anthropic', 1, 'active', '{"enabled":true,"models":["claude-sonnet-5"]}'::jsonb) +RETURNING id +`).Scan(&groupID)) + + applyGroupModelAllowlistRepair(ctx, t, tx) + + // 重命名保留原数据,且新列恢复 NOT NULL DEFAULT '{}' 的形状。 + var allowlist string + require.NoError(t, tx.QueryRowContext(ctx, + "SELECT model_allowlist::text FROM groups WHERE id = $1", groupID).Scan(&allowlist)) + require.JSONEq(t, `{"enabled":true,"models":["claude-sonnet-5"]}`, allowlist) + requireModelAllowlistColumnShape(ctx, t, tx) + + // 可重放:重复执行不报错也不改变结果。 + applyGroupModelAllowlistRepair(ctx, t, tx) + require.NoError(t, tx.QueryRowContext(ctx, + "SELECT model_allowlist::text FROM groups WHERE id = $1", groupID).Scan(&allowlist)) + require.JSONEq(t, `{"enabled":true,"models":["claude-sonnet-5"]}`, allowlist) +} + +func TestMigration236BackfillsWhenBothColumnsExist(t *testing.T) { + tx := testTx(t) + ctx := context.Background() + + _, err := tx.ExecContext(ctx, + "ALTER TABLE groups ADD COLUMN models_list_config JSONB NOT NULL DEFAULT '{}'::jsonb") + require.NoError(t, err) + + // 新列仍是默认空值:旧列里的配置应该被补回来。 + var staleID int64 + require.NoError(t, tx.QueryRowContext(ctx, ` +INSERT INTO groups (name, platform, rate_multiplier, status, model_allowlist, models_list_config) +VALUES ('migration-236-backfill', 'anthropic', 1, 'active', '{}'::jsonb, '{"enabled":true,"models":["legacy-model"]}'::jsonb) +RETURNING id +`).Scan(&staleID)) + + // 新列已有配置:不能被旧列覆盖。 + var currentID int64 + require.NoError(t, tx.QueryRowContext(ctx, ` +INSERT INTO groups (name, platform, rate_multiplier, status, model_allowlist, models_list_config) +VALUES ('migration-236-keep', 'anthropic', 1, 'active', '{"enabled":true,"models":["current-model"]}'::jsonb, '{"enabled":true,"models":["legacy-model"]}'::jsonb) +RETURNING id +`).Scan(¤tID)) + + applyGroupModelAllowlistRepair(ctx, t, tx) + + var backfilled, kept string + require.NoError(t, tx.QueryRowContext(ctx, + "SELECT model_allowlist::text FROM groups WHERE id = $1", staleID).Scan(&backfilled)) + require.JSONEq(t, `{"enabled":true,"models":["legacy-model"]}`, backfilled) + require.NoError(t, tx.QueryRowContext(ctx, + "SELECT model_allowlist::text FROM groups WHERE id = $1", currentID).Scan(&kept)) + require.JSONEq(t, `{"enabled":true,"models":["current-model"]}`, kept) +} + +func TestMigration236RecreatesMissingModelAllowlistColumn(t *testing.T) { + tx := testTx(t) + ctx := context.Background() + + _, err := tx.ExecContext(ctx, "ALTER TABLE groups DROP COLUMN model_allowlist") + require.NoError(t, err) + + var groupID int64 + require.NoError(t, tx.QueryRowContext(ctx, ` +INSERT INTO groups (name, platform, rate_multiplier, status) +VALUES ('migration-236-recreate', 'anthropic', 1, 'active') +RETURNING id +`).Scan(&groupID)) + + applyGroupModelAllowlistRepair(ctx, t, tx) + + var allowlist string + require.NoError(t, tx.QueryRowContext(ctx, + "SELECT model_allowlist::text FROM groups WHERE id = $1", groupID).Scan(&allowlist)) + require.JSONEq(t, `{}`, allowlist) + requireModelAllowlistColumnShape(ctx, t, tx) +} + +func applyGroupModelAllowlistRepair(ctx context.Context, t *testing.T, tx *sql.Tx) { + t.Helper() + + migrationSQL, err := dbmigrations.FS.ReadFile(groupModelAllowlistRepairMigration) + require.NoError(t, err) + _, err = tx.ExecContext(ctx, string(migrationSQL)) + require.NoError(t, err) +} + +func requireModelAllowlistColumnShape(ctx context.Context, t *testing.T, tx *sql.Tx) { + t.Helper() + + var isNullable, columnDefault string + require.NoError(t, tx.QueryRowContext(ctx, ` +SELECT is_nullable, COALESCE(column_default, '') +FROM information_schema.columns +WHERE table_name = 'groups' AND column_name = 'model_allowlist' +`).Scan(&isNullable, &columnDefault)) + require.Equal(t, "NO", isNullable) + require.Contains(t, columnDefault, "'{}'::jsonb") +} diff --git a/backend/migrations/236_group_model_allowlist_repair.sql b/backend/migrations/236_group_model_allowlist_repair.sql new file mode 100644 index 0000000000..0110225a06 --- /dev/null +++ b/backend/migrations/236_group_model_allowlist_repair.sql @@ -0,0 +1,52 @@ +-- 236: 收敛 groups 的模型白名单列,修复 235 未落地却已被记账的实例。 +-- +-- 235 只在「models_list_config 存在且 model_allowlist 不存在」时执行重命名, +-- 而迁移一旦写入 schema_migrations 就会按「文件名 + checksum」整份跳过,不再重跑。 +-- 因此只要数据库在 235 之后回到了旧结构——例如为了回滚到旧版本镜像而手工把列名 +-- 改回 models_list_config,或者用旧结构的备份做了部分恢复——应用依旧能正常启动, +-- 但每个关联 groups 的查询都会失败: +-- pq: column groups.model_allowlist does not exist +-- 表现为「API 密钥」「我的订阅」「管理端订阅列表」等页面加载失败(issue #6780)。 +-- +-- 本迁移可重放,并且用 regclass 解析表(跟随 search_path),不再假定 schema 为 public: +-- 1) 只有旧列 -> 重命名,数据原样保留; +-- 2) 两列并存 -> 新列仍是默认空值时,把旧列里的配置回填过来,旧列保留不动; +-- 3) 两列都没有 -> 按默认值补建新列。 +-- 结束后保证 groups.model_allowlist 一定存在,且为 NOT NULL DEFAULT '{}'。 +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM pg_attribute + WHERE attrelid = 'groups'::regclass + AND attname = 'models_list_config' + AND NOT attisdropped + ) THEN + IF NOT EXISTS ( + SELECT 1 FROM pg_attribute + WHERE attrelid = 'groups'::regclass + AND attname = 'model_allowlist' + AND NOT attisdropped + ) THEN + ALTER TABLE groups RENAME COLUMN models_list_config TO model_allowlist; + ELSE + EXECUTE $backfill$ + UPDATE groups + SET model_allowlist = models_list_config + WHERE COALESCE(model_allowlist, '{}'::jsonb) = '{}'::jsonb + AND COALESCE(models_list_config, '{}'::jsonb) <> '{}'::jsonb + $backfill$; + END IF; + END IF; +END +$$; + +ALTER TABLE groups + ADD COLUMN IF NOT EXISTS model_allowlist JSONB NOT NULL DEFAULT '{}'::jsonb; + +UPDATE groups SET model_allowlist = '{}'::jsonb WHERE model_allowlist IS NULL; + +ALTER TABLE groups ALTER COLUMN model_allowlist SET DEFAULT '{}'::jsonb; +ALTER TABLE groups ALTER COLUMN model_allowlist SET NOT NULL; + +COMMENT ON COLUMN groups.model_allowlist IS + 'Group model allowlist: constrains both model listing responses and request admission'; diff --git a/backend/migrations/group_model_allowlist_repair_migration_test.go b/backend/migrations/group_model_allowlist_repair_migration_test.go new file mode 100644 index 0000000000..1486243445 --- /dev/null +++ b/backend/migrations/group_model_allowlist_repair_migration_test.go @@ -0,0 +1,27 @@ +package migrations + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestGroupModelAllowlistRepairMigration(t *testing.T) { + content, err := FS.ReadFile("236_group_model_allowlist_repair.sql") + require.NoError(t, err) + + sql := strings.Join(strings.Fields(string(content)), " ") + + // 三种残留状态都要收敛到 model_allowlist。 + require.Contains(t, sql, "ALTER TABLE groups RENAME COLUMN models_list_config TO model_allowlist") + require.Contains(t, sql, "ADD COLUMN IF NOT EXISTS model_allowlist JSONB NOT NULL DEFAULT '{}'::jsonb") + require.Contains(t, sql, "SET model_allowlist = models_list_config") + require.Contains(t, sql, "ALTER TABLE groups ALTER COLUMN model_allowlist SET NOT NULL") + require.Contains(t, sql, "COMMENT ON COLUMN groups.model_allowlist") + + // 235 用 table_schema = 'public' 判定列是否存在,而 ALTER TABLE 走的是 search_path; + // 修复迁移必须用 regclass 解析,两者才不会在非 public schema 上分叉。 + require.NotContains(t, sql, "table_schema = 'public'") + require.Contains(t, sql, "attrelid = 'groups'::regclass") +} From 772a0382f079676983c06f24b0d41e09139a8462 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 8 Sep 2026 02:35:11 +0000 Subject: [PATCH 128/145] chore: sync VERSION to 0.2.3 [skip ci] --- backend/cmd/server/VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/cmd/server/VERSION b/backend/cmd/server/VERSION index ee1372d33a..7179039691 100644 --- a/backend/cmd/server/VERSION +++ b/backend/cmd/server/VERSION @@ -1 +1 @@ -0.2.2 +0.2.3 From df64b5f368cce77b5ebc32a86084dd1f8dd40dbf Mon Sep 17 00:00:00 2001 From: savvym Date: Tue, 8 Sep 2026 10:46:56 +0800 Subject: [PATCH 129/145] fix(frontend): keep account action menu within viewport --- .../admin/account/AccountActionMenu.vue | 45 +++++- .../AccountActionMenu.position.spec.ts | 152 ++++++++++++++++++ .../AccountActionMenu.spark_shadow.spec.ts | 22 +-- frontend/src/views/admin/AccountsView.vue | 54 +------ .../admin/__tests__/AccountsView.lite.spec.ts | 30 +++- 5 files changed, 234 insertions(+), 69 deletions(-) create mode 100644 frontend/src/components/admin/account/__tests__/AccountActionMenu.position.spec.ts diff --git a/frontend/src/components/admin/account/AccountActionMenu.vue b/frontend/src/components/admin/account/AccountActionMenu.vue index 14922a37b7..0523dcad02 100644 --- a/frontend/src/components/admin/account/AccountActionMenu.vue +++ b/frontend/src/components/admin/account/AccountActionMenu.vue @@ -1,11 +1,12 @@