7517307c11
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)))'
130 lines
3.5 KiB
Go
130 lines
3.5 KiB
Go
package router
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"gophergate/internal/db"
|
|
)
|
|
|
|
// Decision holds the result of a routing decision.
|
|
type Decision struct {
|
|
SelectedModel string `json:"selected_model"`
|
|
Strategy string `json:"strategy"` // "heuristic" or "classifier"
|
|
Reason string `json:"reason"`
|
|
}
|
|
|
|
// ClassifierFunc is the callback for classifier-based routing.
|
|
type ClassifierFunc func(ctx context.Context, selectorModel, systemPrompt, userMessage string) (string, error)
|
|
|
|
// Router resolves model groups to concrete models.
|
|
type Router struct {
|
|
groups map[string]db.ModelGroup
|
|
classify ClassifierFunc
|
|
}
|
|
|
|
// New creates a Router. classify may be nil if no classifier groups exist.
|
|
func New(groups []db.ModelGroup, classify ClassifierFunc) *Router {
|
|
r := &Router{
|
|
groups: make(map[string]db.ModelGroup),
|
|
classify: classify,
|
|
}
|
|
for _, g := range groups {
|
|
r.groups[g.ID] = g
|
|
}
|
|
return r
|
|
}
|
|
|
|
// Groups returns all registered model group IDs.
|
|
func (r *Router) Groups() []string {
|
|
ids := make([]string, 0, len(r.groups))
|
|
for id := range r.groups {
|
|
ids = append(ids, id)
|
|
}
|
|
return ids
|
|
}
|
|
|
|
// IsGroup returns true if the model name is a group ID.
|
|
func (r *Router) IsGroup(modelID string) bool {
|
|
_, ok := r.groups[modelID]
|
|
return ok
|
|
}
|
|
|
|
// Route resolves a group to a concrete model.
|
|
func (r *Router) Route(ctx context.Context, groupID string, userMessage string) (*Decision, error) {
|
|
group, ok := r.groups[groupID]
|
|
if !ok {
|
|
return nil, fmt.Errorf("unknown model group: %s", groupID)
|
|
}
|
|
|
|
var targets []string
|
|
if err := json.Unmarshal([]byte(group.Targets), &targets); err != nil || len(targets) == 0 {
|
|
return nil, fmt.Errorf("invalid or empty targets for group %s", groupID)
|
|
}
|
|
|
|
switch group.Strategy {
|
|
case "heuristic":
|
|
return routeHeuristic(group, targets, userMessage)
|
|
case "classifier":
|
|
if r.classify == nil {
|
|
return routeHeuristic(group, targets, userMessage)
|
|
}
|
|
return routeClassifier(ctx, r.classify, group, targets, userMessage)
|
|
default:
|
|
return nil, fmt.Errorf("unknown strategy: %s", group.Strategy)
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
for _, g := range groups {
|
|
r.groups[g.ID] = g
|
|
}
|
|
}
|