Files
sub2api/backend/internal/service/composite_route_resolver.go
T
Long Li 3e98a5a1ad fix(composite): route exact account model aliases
Composite model catalogs expose exact account model_mapping aliases, but request routing only considered explicit routes or model-name detection. Unprefixed aliases were selectable but unroutable.

Resolve unique exact account ownership before the detector, keep explicit routes authoritative, and fail closed when an alias is claimed across platforms.
2026-08-22 14:43:40 +09:00

177 lines
4.9 KiB
Go

package service
import (
"context"
"fmt"
"sort"
"strings"
)
type CompositeRouteResolver struct {
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)
decision := CompositeRouteDecision{
GroupID: groupID,
PublicModel: model,
Endpoint: endpoint,
}
if model == "" {
decision.Reason = "model is required"
return decision, nil
}
if r != nil && r.repo != nil && groupID > 0 {
routes, err := r.repo.ListByGroup(ctx, groupID, false)
if err != nil {
return decision, fmt.Errorf("list composite routes: %w", err)
}
if route, ok := matchCompositeRoute(routes, model, endpoint); ok {
upstreamModel := strings.TrimSpace(route.UpstreamModel)
if upstreamModel == "" {
upstreamModel = model
}
return CompositeRouteDecision{
Matched: true,
Source: CompositeRouteSourceExplicit,
GroupID: groupID,
PublicModel: model,
TargetPlatform: route.TargetPlatform,
UpstreamModel: upstreamModel,
Endpoint: endpoint,
Route: &route,
}, nil
}
}
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,
Source: CompositeRouteSourceDetector,
GroupID: groupID,
PublicModel: model,
TargetPlatform: platform,
UpstreamModel: model,
Endpoint: endpoint,
}, nil
}
decision.Reason = "no explicit route or built-in detector match"
return decision, nil
}
func matchCompositeRoute(routes []CompositeModelRoute, model, endpoint string) (CompositeModelRoute, bool) {
if len(routes) == 0 {
return CompositeModelRoute{}, false
}
type candidate struct {
route CompositeModelRoute
matchStrength int
endpointWeight int
prefixLen int
}
candidates := make([]candidate, 0, len(routes))
for _, route := range routes {
route.Endpoint = normalizeCompositeRouteEndpoint(route.Endpoint)
if route.Endpoint != endpoint && route.Endpoint != CompositeRouteEndpointAny {
continue
}
route.MatchType = normalizeCompositeRouteMatchType(route.MatchType)
publicModel := strings.TrimSpace(route.PublicModel)
if publicModel == "" {
continue
}
matchStrength := 0
prefixLen := len(publicModel)
switch route.MatchType {
case CompositeRouteMatchExact:
if publicModel != model {
continue
}
matchStrength = 2
case CompositeRouteMatchPrefix:
if !strings.HasPrefix(model, publicModel) {
continue
}
matchStrength = 1
default:
continue
}
endpointWeight := 0
if route.Endpoint == endpoint {
endpointWeight = 1
}
candidates = append(candidates, candidate{
route: route,
matchStrength: matchStrength,
endpointWeight: endpointWeight,
prefixLen: prefixLen,
})
}
if len(candidates) == 0 {
return CompositeModelRoute{}, false
}
sort.SliceStable(candidates, func(i, j int) bool {
a, b := candidates[i], candidates[j]
if a.matchStrength != b.matchStrength {
return a.matchStrength > b.matchStrength
}
if a.endpointWeight != b.endpointWeight {
return a.endpointWeight > b.endpointWeight
}
if a.prefixLen != b.prefixLen {
return a.prefixLen > b.prefixLen
}
if a.route.Priority != b.route.Priority {
return a.route.Priority < b.route.Priority
}
return a.route.ID < b.route.ID
})
return candidates[0].route, true
}