feat: add hierarchical routing — groups can target other groups
CI / Lint (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Build (push) Has been cancelled

RouteToConcrete() recursively resolves group chains until a concrete
model is reached, with cycle detection and max depth (10) guard.

Example: all-purpose -> fast-flow -> deepseek-v4-flash
The dashboard log shows the full chain: 'deepseek-v4-flash (hierarchical:
fast-flow (default (first target)) -> deepseek-v4-flash (default (first target)))'
This commit is contained in:
2026-05-07 12:28:31 -04:00
parent 19517b0847
commit 7517307c11
2 changed files with 51 additions and 5 deletions
+44
View File
@@ -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)