diff --git a/backend/internal/service/composite_model_route.go b/backend/internal/service/composite_model_route.go index a008a3e9fa..1201dad5e4 100644 --- a/backend/internal/service/composite_model_route.go +++ b/backend/internal/service/composite_model_route.go @@ -23,8 +23,19 @@ const ( CompositeRouteSourceExplicit = "route" CompositeRouteSourceDetector = "detector" + CompositeRouteSourceAccount = "account_model" ) +// CompositeModelOwnership identifies the concrete provider that exposes a +// public model through an account-level exact mapping. +type CompositeModelOwnership struct { + TargetPlatform string + Matched bool + Ambiguous bool +} + +type CompositeModelOwnershipResolver func(context.Context, int64, string) (CompositeModelOwnership, error) + var ( ErrCompositeRouteNotFound = infraerrors.NotFound("COMPOSITE_ROUTE_NOT_FOUND", "composite route not found") ErrCompositeRouteExists = infraerrors.Conflict("COMPOSITE_ROUTE_EXISTS", "composite route already exists") diff --git a/backend/internal/service/composite_platform_test.go b/backend/internal/service/composite_platform_test.go index e145634889..e302d3040c 100644 --- a/backend/internal/service/composite_platform_test.go +++ b/backend/internal/service/composite_platform_test.go @@ -8,6 +8,150 @@ import ( "github.com/stretchr/testify/require" ) +type compositeOwnershipAccountRepo struct { + AccountRepository + accounts []Account +} + +func (r *compositeOwnershipAccountRepo) ListSchedulableByGroupID(context.Context, int64) ([]Account, error) { + return r.accounts, nil +} + +// Scenario: 唯一平台的精确别名可路由 +func TestResolveCompositeModelOwnershipKeepsProviderAccountsIsolated(t *testing.T) { + groupID := int64(7) + repo := &compositeOwnershipAccountRepo{ + accounts: []Account{ + { + ID: 1, + Platform: PlatformOpenAI, + Credentials: map[string]any{ + "model_mapping": map[string]any{"gpt-public": "gpt-5"}, + }, + }, + { + ID: 2, + Platform: PlatformDeepseek, + Credentials: map[string]any{ + "model_mapping": map[string]any{"reasoning-alias": "deepseek-v4-pro"}, + }, + }, + }, + } + svc := &GatewayService{accountRepo: repo} + + deepSeekOwnership, err := svc.resolveCompositeModelOwnership(context.Background(), groupID, "reasoning-alias") + require.NoError(t, err) + require.Equal(t, CompositeModelOwnership{TargetPlatform: PlatformDeepseek, Matched: true}, deepSeekOwnership) + + openAIOwnership, err := svc.resolveCompositeModelOwnership(context.Background(), groupID, "gpt-public") + require.NoError(t, err) + require.Equal(t, CompositeModelOwnership{TargetPlatform: PlatformOpenAI, Matched: true}, openAIOwnership) +} + +// Scenario: 通配符和空映射不声明所有权 +func TestResolveCompositeModelOwnershipRequiresNonEmptyExactMappings(t *testing.T) { + groupID := int64(7) + repo := &compositeOwnershipAccountRepo{ + accounts: []Account{ + { + ID: 1, + Platform: PlatformOpenAI, + Credentials: map[string]any{ + "model_mapping": map[string]any{"*": "gpt-5", "gpt-*": "gpt-5", "empty-alias": ""}, + }, + }, + { + ID: 2, + Platform: PlatformGrok, + Credentials: map[string]any{ + "model_mapping": map[string]any{"grok-public": "grok-4"}, + }, + }, + }, + } + svc := &GatewayService{accountRepo: repo} + + for _, model := range []string{"gpt-5", "empty-alias", "unknown-alias"} { + ownership, err := svc.resolveCompositeModelOwnership(context.Background(), groupID, model) + require.NoError(t, err) + require.Equal(t, CompositeModelOwnership{}, ownership, "model=%s", model) + } + + ownership, err := svc.resolveCompositeModelOwnership(context.Background(), groupID, "grok-public") + require.NoError(t, err) + require.Equal(t, CompositeModelOwnership{TargetPlatform: PlatformGrok, Matched: true}, ownership) +} + +func TestResolveCompositeModelOwnershipAllowsSamePlatformAndRejectsCrossPlatformAliases(t *testing.T) { + groupID := int64(7) + repo := &compositeOwnershipAccountRepo{ + accounts: []Account{ + {ID: 1, Platform: PlatformOpenAI, Credentials: map[string]any{"model_mapping": map[string]any{"shared-openai": "gpt-5", "ambiguous": "gpt-5"}}}, + {ID: 2, Platform: PlatformOpenAI, Credentials: map[string]any{"model_mapping": map[string]any{"shared-openai": "gpt-5.1"}}}, + {ID: 3, Platform: PlatformDeepseek, Credentials: map[string]any{"model_mapping": map[string]any{"ambiguous": "deepseek-v4-pro"}}}, + }, + } + svc := &GatewayService{accountRepo: repo} + + samePlatform, err := svc.resolveCompositeModelOwnership(context.Background(), groupID, "shared-openai") + require.NoError(t, err) + require.Equal(t, CompositeModelOwnership{TargetPlatform: PlatformOpenAI, Matched: true}, samePlatform) + + ambiguous, err := svc.resolveCompositeModelOwnership(context.Background(), groupID, "ambiguous") + require.NoError(t, err) + require.Equal(t, CompositeModelOwnership{Ambiguous: true}, ambiguous) +} + +func TestNewGatewayServiceWiresCompositeModelOwnershipResolver(t *testing.T) { + groupID := int64(7) + repo := &compositeOwnershipAccountRepo{ + accounts: []Account{{ + ID: 1, + Platform: PlatformDeepseek, + Credentials: map[string]any{"model_mapping": map[string]any{"reasoning-alias": "deepseek-v4-pro"}}, + }}, + } + resolver := NewCompositeRouteResolver(nil) + svc := NewGatewayService( + repo, + nil, + nil, + nil, + nil, + nil, + nil, + nil, + nil, + nil, + nil, + nil, + nil, + nil, + nil, + nil, + nil, + nil, + nil, + nil, + nil, + nil, + nil, + nil, + nil, + resolver, + nil, + nil, + ) + require.Same(t, resolver, svc.compositeResolver) + + decision, err := resolver.Resolve(context.Background(), groupID, "reasoning-alias", CompositeRouteEndpointResponses) + require.NoError(t, err) + require.True(t, decision.Matched) + require.Equal(t, CompositeRouteSourceAccount, decision.Source) + require.Equal(t, PlatformDeepseek, decision.TargetPlatform) +} + func TestDetectModelPlatform(t *testing.T) { tests := []struct { name string diff --git a/backend/internal/service/composite_route_resolver.go b/backend/internal/service/composite_route_resolver.go index 3d69f13553..f2e1928ace 100644 --- a/backend/internal/service/composite_route_resolver.go +++ b/backend/internal/service/composite_route_resolver.go @@ -8,13 +8,20 @@ import ( ) type CompositeRouteResolver struct { - repo CompositeModelRouteRepository + repo CompositeModelRouteRepository + modelOwnershipResolver CompositeModelOwnershipResolver } func NewCompositeRouteResolver(repo CompositeModelRouteRepository) *CompositeRouteResolver { return &CompositeRouteResolver{repo: repo} } +func (r *CompositeRouteResolver) SetModelOwnershipResolver(resolver CompositeModelOwnershipResolver) { + if r != nil { + r.modelOwnershipResolver = resolver + } +} + func (r *CompositeRouteResolver) Resolve(ctx context.Context, groupID int64, model, endpoint string) (CompositeRouteDecision, error) { model = strings.TrimSpace(model) endpoint = normalizeCompositeRouteEndpoint(endpoint) @@ -51,6 +58,35 @@ func (r *CompositeRouteResolver) Resolve(ctx context.Context, groupID int64, mod } } + if r != nil && r.modelOwnershipResolver != nil && groupID > 0 { + ownership, err := r.modelOwnershipResolver(ctx, groupID, model) + if err != nil { + // A recognizable model can still use the existing detector when the + // account catalog is temporarily unavailable. Unknown aliases cannot. + if _, detectable := DetectModelPlatform(model); !detectable { + return decision, fmt.Errorf("resolve account model ownership: %w", err) + } + } else if ownership.Ambiguous { + decision.Reason = "model is exposed by multiple provider platforms" + return decision, nil + } else if ownership.Matched { + platform := strings.TrimSpace(ownership.TargetPlatform) + if !isConcreteRequestPlatform(platform) { + decision.Reason = "account model ownership has no concrete target platform" + return decision, nil + } + return CompositeRouteDecision{ + Matched: true, + Source: CompositeRouteSourceAccount, + GroupID: groupID, + PublicModel: model, + TargetPlatform: platform, + UpstreamModel: model, + Endpoint: endpoint, + }, nil + } + } + if platform, ok := DetectModelPlatform(model); ok { return CompositeRouteDecision{ Matched: true, diff --git a/backend/internal/service/composite_route_resolver_test.go b/backend/internal/service/composite_route_resolver_test.go index 35028934dd..2c11388dd6 100644 --- a/backend/internal/service/composite_route_resolver_test.go +++ b/backend/internal/service/composite_route_resolver_test.go @@ -2,6 +2,7 @@ package service import ( "context" + "errors" "testing" "github.com/stretchr/testify/require" @@ -69,6 +70,98 @@ func TestCompositeRouteResolverExplicitExactRouteRewritesModel(t *testing.T) { require.Equal(t, int64(10), decision.Route.ID) } +// Scenario: 唯一平台的精确别名可路由 +func TestCompositeRouteResolverUsesAccountModelOwnershipForUnprefixedAlias(t *testing.T) { + resolver := NewCompositeRouteResolver(nil) + resolver.SetModelOwnershipResolver(func(_ context.Context, groupID int64, model string) (CompositeModelOwnership, error) { + require.Equal(t, int64(7), groupID) + require.Equal(t, "reasoning-alias", model) + return CompositeModelOwnership{TargetPlatform: PlatformDeepseek, Matched: true}, nil + }) + + decision, err := resolver.Resolve(context.Background(), 7, "reasoning-alias", CompositeRouteEndpointChatCompletions) + + require.NoError(t, err) + require.True(t, decision.Matched) + require.Equal(t, CompositeRouteSourceAccount, decision.Source) + require.Equal(t, PlatformDeepseek, decision.TargetPlatform) + require.Equal(t, "reasoning-alias", decision.UpstreamModel) +} + +func TestCompositeRouteResolverAccountOwnershipOverridesBuiltInDetector(t *testing.T) { + resolver := NewCompositeRouteResolver(nil) + resolver.SetModelOwnershipResolver(func(context.Context, int64, string) (CompositeModelOwnership, error) { + return CompositeModelOwnership{TargetPlatform: PlatformDeepseek, Matched: true}, nil + }) + + decision, err := resolver.Resolve(context.Background(), 7, "gpt-5", CompositeRouteEndpointResponses) + + require.NoError(t, err) + require.True(t, decision.Matched) + require.Equal(t, CompositeRouteSourceAccount, decision.Source) + require.Equal(t, PlatformDeepseek, decision.TargetPlatform) +} + +// Scenario: 显式路由保持最高优先级 +func TestCompositeRouteResolverExplicitRouteBeatsAccountOwnership(t *testing.T) { + resolver := NewCompositeRouteResolver(compositeRouteRepoStub{ + routes: []CompositeModelRoute{{ + ID: 10, + GroupID: 7, + PublicModel: "reasoning-alias", + MatchType: CompositeRouteMatchExact, + TargetPlatform: PlatformOpenAI, + UpstreamModel: "gpt-5", + Endpoint: CompositeRouteEndpointAny, + Enabled: true, + }}, + }) + resolver.SetModelOwnershipResolver(func(context.Context, int64, string) (CompositeModelOwnership, error) { + return CompositeModelOwnership{TargetPlatform: PlatformDeepseek, Matched: true}, nil + }) + + decision, err := resolver.Resolve(context.Background(), 7, "reasoning-alias", CompositeRouteEndpointChatCompletions) + + require.NoError(t, err) + require.True(t, decision.Matched) + require.Equal(t, CompositeRouteSourceExplicit, decision.Source) + require.Equal(t, PlatformOpenAI, decision.TargetPlatform) + require.Equal(t, "gpt-5", decision.UpstreamModel) +} + +// Scenario: 跨平台同名别名不被猜测 +func TestCompositeRouteResolverDoesNotGuessAmbiguousAccountOwnership(t *testing.T) { + resolver := NewCompositeRouteResolver(nil) + resolver.SetModelOwnershipResolver(func(context.Context, int64, string) (CompositeModelOwnership, error) { + return CompositeModelOwnership{Ambiguous: true}, nil + }) + + decision, err := resolver.Resolve(context.Background(), 7, "shared-alias", CompositeRouteEndpointChatCompletions) + + require.NoError(t, err) + require.False(t, decision.Matched) + require.Empty(t, decision.TargetPlatform) + require.Equal(t, "model is exposed by multiple provider platforms", decision.Reason) +} + +func TestCompositeRouteResolverOwnershipLookupErrorFallsBackOnlyForDetectableModels(t *testing.T) { + lookupErr := errors.New("account catalog unavailable") + resolver := NewCompositeRouteResolver(nil) + resolver.SetModelOwnershipResolver(func(context.Context, int64, string) (CompositeModelOwnership, error) { + return CompositeModelOwnership{}, lookupErr + }) + + detected, err := resolver.Resolve(context.Background(), 7, "gpt-5", CompositeRouteEndpointResponses) + require.NoError(t, err) + require.True(t, detected.Matched) + require.Equal(t, CompositeRouteSourceDetector, detected.Source) + require.Equal(t, PlatformOpenAI, detected.TargetPlatform) + + unknown, err := resolver.Resolve(context.Background(), 7, "company-model", CompositeRouteEndpointResponses) + require.ErrorIs(t, err, lookupErr) + require.False(t, unknown.Matched) +} + func TestCompositeRouteResolverPrefersEndpointSpecificLongestPrefix(t *testing.T) { resolver := NewCompositeRouteResolver(compositeRouteRepoStub{ routes: []CompositeModelRoute{ diff --git a/backend/internal/service/gateway_hotpath_optimization_test.go b/backend/internal/service/gateway_hotpath_optimization_test.go index 1bdf48bd41..8855b13e52 100644 --- a/backend/internal/service/gateway_hotpath_optimization_test.go +++ b/backend/internal/service/gateway_hotpath_optimization_test.go @@ -564,6 +564,46 @@ func TestGetAvailableModels_UsesShortCacheAndSupportsInvalidation(t *testing.T) require.Equal(t, int64(2), store) } +// Scenario: 账号模型变更会失效所属平台缓存 +func TestResolveCompositeModelOwnershipUsesModelsCacheInvalidation(t *testing.T) { + groupID := int64(9) + repo := &modelsListAccountRepoStub{ + byGroup: map[int64][]Account{ + groupID: {{ + ID: 1, + Platform: PlatformDeepseek, + Credentials: map[string]any{"model_mapping": map[string]any{"company-model": "deepseek-v4-pro"}}, + }}, + }, + } + svc := &GatewayService{ + accountRepo: repo, + modelsListCache: gocache.New(time.Minute, time.Minute), + modelsListCacheTTL: time.Minute, + } + + first, err := svc.resolveCompositeModelOwnership(context.Background(), groupID, "company-model") + require.NoError(t, err) + require.Equal(t, CompositeModelOwnership{TargetPlatform: PlatformDeepseek, Matched: true}, first) + require.Equal(t, int64(1), repo.listByGroupCalls.Load()) + + repo.byGroup[groupID] = []Account{{ + ID: 2, + Platform: PlatformOpenAI, + Credentials: map[string]any{"model_mapping": map[string]any{"company-model": "gpt-5"}}, + }} + cached, err := svc.resolveCompositeModelOwnership(context.Background(), groupID, "company-model") + require.NoError(t, err) + require.Equal(t, first, cached) + require.Equal(t, int64(1), repo.listByGroupCalls.Load()) + + svc.InvalidateAvailableModelsCache(&groupID, PlatformDeepseek) + refreshed, err := svc.resolveCompositeModelOwnership(context.Background(), groupID, "company-model") + require.NoError(t, err) + require.Equal(t, CompositeModelOwnership{TargetPlatform: PlatformOpenAI, Matched: true}, refreshed) + require.Equal(t, int64(2), repo.listByGroupCalls.Load()) +} + func TestGetAvailableModels_ErrorAndGlobalListBranches(t *testing.T) { resetGatewayHotpathStatsForTest() diff --git a/backend/internal/service/gateway_service.go b/backend/internal/service/gateway_service.go index fd39ce2bc3..bc58ba41aa 100644 --- a/backend/internal/service/gateway_service.go +++ b/backend/internal/service/gateway_service.go @@ -71,8 +71,9 @@ const ( ) const ( - cacheTTLTarget5m = "5m" - cacheTTLTarget1h = "1h" + cacheTTLTarget5m = "5m" + cacheTTLTarget1h = "1h" + compositeModelOwnershipCachePrefix = "composite-owner|" ) // ForceCacheBillingContextKey 强制缓存计费上下文键 @@ -528,6 +529,10 @@ func modelsListCacheKey(groupID *int64, platform string) string { return fmt.Sprintf("%d|%s", derefGroupID(groupID), strings.TrimSpace(platform)) } +func compositeModelOwnershipCacheKey(groupID int64, model string) string { + return fmt.Sprintf("%s%d|%s", compositeModelOwnershipCachePrefix, groupID, strings.TrimSpace(model)) +} + func prefetchedStickyGroupIDFromContext(ctx context.Context) (int64, bool) { return PrefetchedStickyGroupIDFromContext(ctx) } @@ -853,6 +858,9 @@ func NewGatewayService( balanceNotifyService: balanceNotifyService, userPlatformQuotaRepo: userPlatformQuotaRepo, } + if compositeResolver != nil { + compositeResolver.SetModelOwnershipResolver(svc.resolveCompositeModelOwnership) + } svc.userGroupRateResolver = newUserGroupRateResolver( userGroupRateRepo, svc.userGroupRateCache, @@ -1442,6 +1450,59 @@ func (s *GatewayService) GetAvailableModels(ctx context.Context, groupID *int64, return cloneStringSlice(models) } +func (s *GatewayService) resolveCompositeModelOwnership(ctx context.Context, groupID int64, model string) (CompositeModelOwnership, error) { + model = strings.TrimSpace(model) + if s == nil || s.accountRepo == nil || groupID <= 0 || model == "" { + return CompositeModelOwnership{}, nil + } + + cacheKey := compositeModelOwnershipCacheKey(groupID, model) + if s.modelsListCache != nil { + if cached, found := s.modelsListCache.Get(cacheKey); found { + if ownership, ok := cached.(CompositeModelOwnership); ok { + return ownership, nil + } + } + } + + accounts, err := s.accountRepo.ListSchedulableByGroupID(ctx, groupID) + if err != nil { + return CompositeModelOwnership{}, err + } + + platforms := make(map[string]struct{}) + for _, account := range accounts { + platform := strings.TrimSpace(account.Platform) + if !isConcreteRequestPlatform(platform) || !explicitModelMappingClaims(account, model) { + continue + } + platforms[platform] = struct{}{} + } + + ownership := CompositeModelOwnership{} + if len(platforms) == 1 { + for platform := range platforms { + ownership.TargetPlatform = platform + } + ownership.Matched = true + } else if len(platforms) > 1 { + ownership.Ambiguous = true + } + + if s.modelsListCache != nil { + s.modelsListCache.Set(cacheKey, ownership, s.modelsListCacheTTL) + } + return ownership, nil +} + +func explicitModelMappingClaims(account Account, model string) bool { + if account.Credentials == nil || model == "" { + return false + } + mapped, ok := stringMappingFromRaw(account.Credentials["model_mapping"])[model] + return ok && strings.TrimSpace(mapped) != "" +} + // GetSchedulablePlatforms returns the concrete platforms that currently have // schedulable accounts in the target group. func (s *GatewayService) GetSchedulablePlatforms(ctx context.Context, groupID *int64) map[string]struct{} { @@ -1474,6 +1535,7 @@ func (s *GatewayService) InvalidateAvailableModelsCache(groupID *int64, platform if s == nil || s.modelsListCache == nil { return } + s.invalidateCompositeModelOwnershipCache(groupID) normalizedPlatform := strings.TrimSpace(platform) // 完整匹配时精准失效;否则按维度批量失效。 @@ -1502,6 +1564,26 @@ func (s *GatewayService) InvalidateAvailableModelsCache(groupID *int64, platform } } +func (s *GatewayService) invalidateCompositeModelOwnershipCache(groupID *int64) { + for key := range s.modelsListCache.Items() { + if !strings.HasPrefix(key, compositeModelOwnershipCachePrefix) { + continue + } + if groupID == nil { + s.modelsListCache.Delete(key) + continue + } + parts := strings.SplitN(strings.TrimPrefix(key, compositeModelOwnershipCachePrefix), "|", 2) + if len(parts) != 2 { + continue + } + cachedGroupID, err := strconv.ParseInt(parts[0], 10, 64) + if err == nil && cachedGroupID == *groupID { + s.modelsListCache.Delete(key) + } + } +} + const debugGatewayBodyDefaultFilename = "gateway_debug.log" // initDebugGatewayBodyFile 初始化网关调试日志文件。