diff --git a/internal/router/router.go b/internal/router/router.go index cc7f1e57..c0bc92cb 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "strings" "gophergate/internal/db" ) @@ -76,6 +77,49 @@ func (r *Router) Route(ctx context.Context, groupID string, userMessage string) } } +// RouteToConcrete resolves a model name to a concrete model, following group +// chains recursively until a non-group target is reached. Returns the original +// name unchanged if it is not a group. +func (r *Router) RouteToConcrete(ctx context.Context, modelID string, userMessage string) (*Decision, error) { + const maxDepth = 10 + visited := make(map[string]bool) + current := modelID + var chain []*Decision + + for depth := 0; depth < maxDepth; depth++ { + if !r.IsGroup(current) { + // Build a composite reason showing the chain traversed + reason := "direct" + if len(chain) > 0 { + parts := make([]string, len(chain)) + for i, d := range chain { + parts[i] = d.SelectedModel + " (" + d.Reason + ")" + } + reason = strings.Join(parts, " -> ") + } + return &Decision{ + SelectedModel: current, + Strategy: "hierarchical", + Reason: reason, + }, nil + } + + if visited[current] { + return nil, fmt.Errorf("routing cycle detected: group %s already visited", current) + } + visited[current] = true + + decision, err := r.Route(ctx, current, userMessage) + if err != nil { + return nil, err + } + chain = append(chain, decision) + current = decision.SelectedModel + } + + return nil, fmt.Errorf("routing depth exceeded: reached max depth of %d", maxDepth) +} + // Reload replaces the group definitions without recreating the router. func (r *Router) Reload(groups []db.ModelGroup) { r.groups = make(map[string]db.ModelGroup) diff --git a/internal/server/server.go b/internal/server/server.go index b1fddab1..f6b01848 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -541,18 +541,20 @@ func (s *Server) handleChatCompletions(c *gin.Context) { } } - // Check if model is a group and route to a concrete model + // Resolve model groups to concrete models (hierarchical — groups can target groups) modelGroup := "" - if s.modelRouter != nil && s.modelRouter.IsGroup(modelID) { - modelGroup = modelID + if s.modelRouter != nil { userMessage := extractUserMessage(req.Messages) - decision, err := s.modelRouter.Route(c.Request.Context(), modelID, userMessage) + decision, err := s.modelRouter.RouteToConcrete(c.Request.Context(), modelID, userMessage) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("model routing failed: %v", err)}) return } + if decision.SelectedModel != modelID { + modelGroup = modelID + } modelID = decision.SelectedModel - log.Printf("[ROUTER] %s -> %s (%s: %s)", modelGroup, modelID, decision.Strategy, decision.Reason) + log.Printf("[ROUTER] %s (%s: %s)", modelID, decision.Strategy, decision.Reason) } // Convert ChatCompletionRequest to UnifiedRequest