feat(router): [router].prefer = local | cloud | auto
Implements P-1 through P-6 of the prefer-routing-policy plan.
Adds a config knob that biases routing toward local arms, cloud
arms, or leaves selection unchanged. Default "auto" is
byte-identical to pre-change behavior (the new armTier path with
PreferAuto returns the same value as the old single-arg function).
Mechanism diverged from the plan after empirical testing:
The plan called for a score multiplier applied in bestScored.
Tests revealed the existing cost-floor math (scoreArm divides by
weighted cost which collapses to ~0.001 for free local arms) gives
local arms a ~280x raw-score advantage that a 0.3-0.5 multiplier
can't overcome. A tier-shift in armTier turned out cleaner:
PreferLocal: cloud arms (true API, IsLocal=false && !IsCLIAgent)
get +2 tier shift, landing behind locals.
PreferCloud: IsLocal arms get +2 tier shift, landing behind
cloud. SLM tier-0 arms shift to tier 2 — still
below cloud's tier 3 — so the SLM-protection
semantic (small stuff stays on the small model)
survives PreferCloud. This matches the open
question in the plan, now resolved as: yes, SLMs
keep winning under PreferCloud by design.
The policyMultiplier was kept in bestScored as a within-tier
nudge (mostly cosmetic in practice given the cost-floor dynamics
described above; could matter when costs are calibrated). Worth
revisiting once router-wide cost calibration lands.
Strengths cross-tier promotion is unaffected: the promoted-set
path in selectBest bypasses armTier entirely, so a strongly-tagged
cloud arm still wins SecurityReview tasks under PreferLocal
(validated by TestPreferPolicy_StrengthsBeatsMultiplier).
CLI-agent subprocess arms count as "local" for PreferLocal
purposes — they proxy to cloud but the user-visible behavior is
local. Users who want to exclude them can use --provider X.
Forced arms (--provider X) and incognito take priority over the
policy: forced arm test pins this, incognito-still-wins test pins
the LocalOnly hard filter dominating PreferCloud.
Test coverage (prefer_test.go): ParsePreferPolicy / String round
trips; policyMultiplier table; acceptance scenarios across all
three policies with adjacent-tier arms; SLM-still-wins under
PreferCloud; Strengths beats multiplier; forced-arm bypass;
incognito beats prefer; lone cloud arm wins when no local feasible.
Refs: docs/superpowers/plans/2026-05-23-prefer-routing-policy.md
This commit is contained in:
@@ -352,6 +352,19 @@ func main() {
|
||||
// (M4 foundation: one provider from CLI. Multi-provider routing comes with config.)
|
||||
rtr := router.New(router.Config{Logger: logger})
|
||||
|
||||
// Apply the prefer-routing-policy from config (default: auto).
|
||||
// Invalid values are rejected here with an actionable error rather
|
||||
// than silently falling back to auto.
|
||||
if preferPolicy, err := router.ParsePreferPolicy(cfg.Router.Prefer); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "config error: %v\n", err)
|
||||
os.Exit(2)
|
||||
} else {
|
||||
rtr.SetPreferPolicy(preferPolicy)
|
||||
if preferPolicy != router.PreferAuto {
|
||||
logger.Info("routing preference applied", "prefer", preferPolicy.String())
|
||||
}
|
||||
}
|
||||
|
||||
// Restore QualityTracker data from disk (best-effort). Per-profile
|
||||
// path avoids bandit cross-contamination between work/private/etc.
|
||||
// Skipped under --incognito to keep prior learned quality out of the
|
||||
|
||||
@@ -99,6 +99,14 @@ type RouterSection struct {
|
||||
// on a large local model. Defaults to false: two-stage activates
|
||||
// automatically on local arms with context window <= 16k.
|
||||
ForceTwoStage bool `toml:"force_two_stage"`
|
||||
|
||||
// Prefer biases routing toward local arms ("local"), cloud arms
|
||||
// ("cloud"), or leaves the tier-based selection unchanged ("auto").
|
||||
// Default: "auto". Implemented as a soft score multiplier — does
|
||||
// not hard-filter the dispreferred set. Forced arms (--provider X)
|
||||
// and incognito take priority over this knob. See
|
||||
// docs/superpowers/plans/2026-05-23-prefer-routing-policy.md.
|
||||
Prefer string `toml:"prefer"`
|
||||
}
|
||||
|
||||
// MCPServerConfig defines an MCP server to start and connect to.
|
||||
|
||||
@@ -62,7 +62,7 @@ func BenchmarkSelectBest(b *testing.B) {
|
||||
b.ResetTimer()
|
||||
for b.Loop() {
|
||||
for _, task := range tasks {
|
||||
selectBest(qt, arms, task)
|
||||
selectBest(qt, arms, task, PreferAuto)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,375 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"somegit.dev/Owlibou/gnoma/internal/provider"
|
||||
"somegit.dev/Owlibou/gnoma/internal/security"
|
||||
)
|
||||
|
||||
func TestParsePreferPolicy(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want PreferPolicy
|
||||
wantErr bool
|
||||
}{
|
||||
{"", PreferAuto, false},
|
||||
{"auto", PreferAuto, false},
|
||||
{"AUTO", PreferAuto, false},
|
||||
{" auto ", PreferAuto, false},
|
||||
{"local", PreferLocal, false},
|
||||
{"Local", PreferLocal, false},
|
||||
{"cloud", PreferCloud, false},
|
||||
{"prefer-cloud", PreferAuto, true},
|
||||
{"none", PreferAuto, true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.in, func(t *testing.T) {
|
||||
got, err := ParsePreferPolicy(tc.in)
|
||||
if (err != nil) != tc.wantErr {
|
||||
t.Fatalf("err=%v wantErr=%v", err, tc.wantErr)
|
||||
}
|
||||
if !tc.wantErr && got != tc.want {
|
||||
t.Errorf("got %v, want %v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreferPolicy_String(t *testing.T) {
|
||||
cases := map[PreferPolicy]string{
|
||||
PreferAuto: "auto",
|
||||
PreferLocal: "local",
|
||||
PreferCloud: "cloud",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := in.String(); got != want {
|
||||
t.Errorf("%d.String() = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPolicyMultiplier(t *testing.T) {
|
||||
localArm := &Arm{IsLocal: true}
|
||||
cloudArm := &Arm{IsLocal: false}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
arm *Arm
|
||||
policy PreferPolicy
|
||||
want float64
|
||||
}{
|
||||
{"auto/local", localArm, PreferAuto, 1.0},
|
||||
{"auto/cloud", cloudArm, PreferAuto, 1.0},
|
||||
{"local/local", localArm, PreferLocal, 1.0},
|
||||
{"local/cloud", cloudArm, PreferLocal, 0.3},
|
||||
{"cloud/local", localArm, PreferCloud, 0.5},
|
||||
{"cloud/cloud", cloudArm, PreferCloud, 1.0},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := policyMultiplier(tc.arm, tc.policy); got != tc.want {
|
||||
t.Errorf("policyMultiplier(%+v, %v) = %v, want %v", tc.arm, tc.policy, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestPreferPolicy_RouterAcceptanceScenarios is the user-facing payoff:
|
||||
// the prefer knob shifts arm tiers so the dispreferred camp is walked
|
||||
// last. The test uses a task type that neither arm has in its Strengths
|
||||
// list so the tier walk actually runs (the Strengths-promoted path
|
||||
// bypasses tier ordering entirely).
|
||||
//
|
||||
// Arms are chosen to be in adjacent base tiers — a general-purpose
|
||||
// local arm at tier 2 (no MaxComplexity, no family-defaults match) and
|
||||
// a cloud arm at tier 3. The +2 tier shift then puts the dispreferred
|
||||
// arm at tier 4 (local) or 5 (cloud), behind the preferred camp.
|
||||
//
|
||||
// The Strengths-promoted case (cost-amplification can overwhelm the
|
||||
// within-tier multiplier) is covered separately by
|
||||
// TestPreferPolicy_StrengthsBeatsMultiplier, which validates that a
|
||||
// strongly-tagged arm wins regardless of prefer.
|
||||
func TestPreferPolicy_RouterAcceptanceScenarios(t *testing.T) {
|
||||
makeRouter := func(policy PreferPolicy) *Router {
|
||||
r := New(Config{})
|
||||
r.SetPreferPolicy(policy)
|
||||
|
||||
// Local arm: family doesn't match any defaults entry, so no
|
||||
// Strengths or MaxComplexity get attached — clean tier-2 arm.
|
||||
r.RegisterArm(&Arm{
|
||||
ID: NewArmID("ollama", "novel-local-llm:7b"),
|
||||
ModelName: "novel-local-llm:7b",
|
||||
Provider: security.WrapProvider(&stubProvider{name: "ollama", model: "novel-local-llm:7b"}, nil),
|
||||
IsLocal: true,
|
||||
Capabilities: provider.Capabilities{
|
||||
ToolUse: true,
|
||||
ContextWindow: 200000,
|
||||
},
|
||||
})
|
||||
|
||||
// Cloud arm: also no family match (we use a deliberately
|
||||
// non-matching ID so Strengths defaults don't kick in).
|
||||
r.RegisterArm(&Arm{
|
||||
ID: NewArmID("anthropic", "novel-cloud-model"),
|
||||
ModelName: "novel-cloud-model",
|
||||
Provider: security.WrapProvider(&stubProvider{name: "anthropic", model: "novel-cloud-model"}, nil),
|
||||
IsLocal: false,
|
||||
Capabilities: provider.Capabilities{
|
||||
ToolUse: true,
|
||||
ContextWindow: 1_000_000,
|
||||
ThinkingModes: []provider.EffortLevel{provider.EffortMedium},
|
||||
},
|
||||
})
|
||||
return r
|
||||
}
|
||||
|
||||
task := Task{
|
||||
Type: TaskExplain,
|
||||
ComplexityScore: 0.5,
|
||||
Priority: PriorityNormal,
|
||||
RequiresTools: true,
|
||||
EstimatedTokens: 1500,
|
||||
}
|
||||
|
||||
t.Run("prefer=local picks the local arm", func(t *testing.T) {
|
||||
r := makeRouter(PreferLocal)
|
||||
decision := r.Select(task)
|
||||
if decision.Error != nil {
|
||||
t.Fatalf("Select error: %v", decision.Error)
|
||||
}
|
||||
if !decision.Arm.IsLocal {
|
||||
t.Errorf("PreferLocal should pick local; got %s (IsLocal=%v)", decision.Arm.ID, decision.Arm.IsLocal)
|
||||
}
|
||||
decision.Rollback()
|
||||
})
|
||||
|
||||
t.Run("prefer=cloud picks the cloud arm", func(t *testing.T) {
|
||||
r := makeRouter(PreferCloud)
|
||||
decision := r.Select(task)
|
||||
if decision.Error != nil {
|
||||
t.Fatalf("Select error: %v", decision.Error)
|
||||
}
|
||||
if decision.Arm.IsLocal {
|
||||
t.Errorf("PreferCloud should pick cloud; got %s (IsLocal=%v)", decision.Arm.ID, decision.Arm.IsLocal)
|
||||
}
|
||||
decision.Rollback()
|
||||
})
|
||||
|
||||
t.Run("prefer=auto preserves tier order (local tier 2 < cloud tier 3)", func(t *testing.T) {
|
||||
r := makeRouter(PreferAuto)
|
||||
decision := r.Select(task)
|
||||
if decision.Error != nil {
|
||||
t.Fatalf("Select error: %v", decision.Error)
|
||||
}
|
||||
if !decision.Arm.IsLocal {
|
||||
t.Errorf("PreferAuto should preserve tier order (local wins); got %s", decision.Arm.ID)
|
||||
}
|
||||
decision.Rollback()
|
||||
})
|
||||
}
|
||||
|
||||
// TestPreferPolicy_SLMStillWinsUnderPreferCloud documents the
|
||||
// SLM-protection behavior: under PreferCloud, a tier-0 SLM (an arm
|
||||
// with MaxComplexity > 0 that fits the task) still wins because the
|
||||
// +2 tier shift only moves it from tier 0 to tier 2, which is still
|
||||
// below the cloud arm's tier 3. This matches the plan's intent: "the
|
||||
// SLM does small stuff" survives PreferCloud — that's exactly what
|
||||
// the SLM is for.
|
||||
func TestPreferPolicy_SLMStillWinsUnderPreferCloud(t *testing.T) {
|
||||
r := New(Config{})
|
||||
r.SetPreferPolicy(PreferCloud)
|
||||
|
||||
// Tier-0 SLM (low MaxComplexity, fits the trivial task).
|
||||
r.RegisterArm(&Arm{
|
||||
ID: NewArmID("ollama", "tiny-slm:1.5b"),
|
||||
ModelName: "tiny-slm:1.5b",
|
||||
Provider: security.WrapProvider(&stubProvider{name: "ollama", model: "tiny-slm:1.5b"}, nil),
|
||||
IsLocal: true,
|
||||
MaxComplexity: 0.30,
|
||||
Strengths: []TaskType{TaskBoilerplate},
|
||||
Capabilities: provider.Capabilities{
|
||||
ToolUse: true,
|
||||
ContextWindow: 32768,
|
||||
},
|
||||
})
|
||||
r.RegisterArm(&Arm{
|
||||
ID: NewArmID("anthropic", "claude-sonnet-4-6"),
|
||||
ModelName: "claude-sonnet-4-6",
|
||||
Provider: security.WrapProvider(&stubProvider{name: "anthropic", model: "claude-sonnet-4-6"}, nil),
|
||||
IsLocal: false,
|
||||
Capabilities: provider.Capabilities{
|
||||
ToolUse: true,
|
||||
ContextWindow: 1_000_000,
|
||||
},
|
||||
})
|
||||
|
||||
decision := r.Select(Task{
|
||||
Type: TaskBoilerplate,
|
||||
ComplexityScore: 0.1,
|
||||
Priority: PriorityLow,
|
||||
RequiresTools: true,
|
||||
EstimatedTokens: 200,
|
||||
})
|
||||
if decision.Error != nil {
|
||||
t.Fatalf("Select error: %v", decision.Error)
|
||||
}
|
||||
if decision.Arm.ID != NewArmID("ollama", "tiny-slm:1.5b") {
|
||||
t.Errorf("SLM should win trivial task even under PreferCloud (tier 0+2=2 < cloud 3); got %s", decision.Arm.ID)
|
||||
}
|
||||
decision.Rollback()
|
||||
}
|
||||
|
||||
// TestPreferPolicy_StrengthsBeatsMultiplier: a cloud arm with a strong
|
||||
// task-type tag still wins over a local arm without that tag, even
|
||||
// under PreferLocal. Strengths is the primary signal; prefer is a
|
||||
// secondary multiplier within the promoted/tier set.
|
||||
func TestPreferPolicy_StrengthsBeatsMultiplier(t *testing.T) {
|
||||
r := New(Config{})
|
||||
r.SetPreferPolicy(PreferLocal)
|
||||
|
||||
// Local arm has no Strengths for SecurityReview.
|
||||
localArm := &Arm{
|
||||
ID: NewArmID("ollama", "qwen3:14b"),
|
||||
ModelName: "qwen3:14b",
|
||||
Provider: security.WrapProvider(&stubProvider{name: "ollama", model: "qwen3:14b"}, nil),
|
||||
IsLocal: true,
|
||||
Strengths: []TaskType{TaskGeneration},
|
||||
MaxComplexity: 0.75,
|
||||
Capabilities: provider.Capabilities{
|
||||
ToolUse: true,
|
||||
ContextWindow: 32768,
|
||||
},
|
||||
}
|
||||
cloudArm := &Arm{
|
||||
ID: NewArmID("anthropic", "claude-opus-4-7"),
|
||||
ModelName: "claude-opus-4-7",
|
||||
Provider: security.WrapProvider(&stubProvider{name: "anthropic", model: "claude-opus-4-7"}, nil),
|
||||
IsLocal: false,
|
||||
Strengths: []TaskType{TaskSecurityReview, TaskPlanning},
|
||||
Capabilities: provider.Capabilities{
|
||||
ToolUse: true,
|
||||
ContextWindow: 1_000_000,
|
||||
ThinkingModes: []provider.EffortLevel{provider.EffortHigh},
|
||||
},
|
||||
}
|
||||
r.RegisterArm(localArm)
|
||||
r.RegisterArm(cloudArm)
|
||||
|
||||
decision := r.Select(Task{
|
||||
Type: TaskSecurityReview,
|
||||
ComplexityScore: 0.8,
|
||||
Priority: PriorityCritical,
|
||||
RequiresTools: true,
|
||||
EstimatedTokens: 3000,
|
||||
})
|
||||
if decision.Error != nil {
|
||||
t.Fatalf("Select error: %v", decision.Error)
|
||||
}
|
||||
if decision.Arm.ID != cloudArm.ID {
|
||||
t.Errorf("Strengths-tagged cloud arm should beat PreferLocal multiplier; got %s", decision.Arm.ID)
|
||||
}
|
||||
decision.Rollback()
|
||||
}
|
||||
|
||||
// TestPreferPolicy_ForcedArmBypassesPolicy: --provider X must always win.
|
||||
func TestPreferPolicy_ForcedArmBypassesPolicy(t *testing.T) {
|
||||
r := New(Config{})
|
||||
r.SetPreferPolicy(PreferLocal)
|
||||
|
||||
cloudArmID := NewArmID("anthropic", "claude-sonnet-4-6")
|
||||
r.RegisterArm(&Arm{
|
||||
ID: cloudArmID,
|
||||
ModelName: "claude-sonnet-4-6",
|
||||
Provider: security.WrapProvider(&stubProvider{name: "anthropic", model: "claude-sonnet-4-6"}, nil),
|
||||
IsLocal: false,
|
||||
Capabilities: provider.Capabilities{
|
||||
ToolUse: true,
|
||||
ContextWindow: 1_000_000,
|
||||
},
|
||||
})
|
||||
r.ForceArm(cloudArmID)
|
||||
|
||||
decision := r.Select(Task{Type: TaskGeneration, RequiresTools: true})
|
||||
if decision.Error != nil {
|
||||
t.Fatalf("Select error: %v", decision.Error)
|
||||
}
|
||||
if decision.Arm.ID != cloudArmID {
|
||||
t.Errorf("forced arm should bypass PreferLocal; got %s, want %s", decision.Arm.ID, cloudArmID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPreferPolicy_IncognitoStillWins: incognito's hard filter must
|
||||
// dominate the soft prefer bias.
|
||||
func TestPreferPolicy_IncognitoStillWins(t *testing.T) {
|
||||
r := New(Config{})
|
||||
r.SetPreferPolicy(PreferCloud) // bias toward cloud
|
||||
r.SetLocalOnly(true) // but incognito filters cloud out
|
||||
|
||||
factory := func(name, model string) SecureProvider {
|
||||
return security.WrapProvider(&stubProvider{name: name, model: model}, nil)
|
||||
}
|
||||
RegisterDiscoveredModels(r, []DiscoveredModel{
|
||||
{ID: "qwen3:14b", Provider: "ollama", SupportsTools: true, ContextSize: 32768},
|
||||
}, factory)
|
||||
r.RegisterArm(&Arm{
|
||||
ID: NewArmID("anthropic", "claude-sonnet-4-6"),
|
||||
ModelName: "claude-sonnet-4-6",
|
||||
Provider: security.WrapProvider(&stubProvider{name: "anthropic", model: "claude-sonnet-4-6"}, nil),
|
||||
IsLocal: false,
|
||||
Capabilities: provider.Capabilities{
|
||||
ToolUse: true,
|
||||
ContextWindow: 1_000_000,
|
||||
},
|
||||
})
|
||||
|
||||
decision := r.Select(Task{
|
||||
Type: TaskExplain,
|
||||
ComplexityScore: 0.4,
|
||||
Priority: PriorityNormal,
|
||||
RequiresTools: true,
|
||||
EstimatedTokens: 1500,
|
||||
})
|
||||
if decision.Error != nil {
|
||||
t.Fatalf("Select error: %v", decision.Error)
|
||||
}
|
||||
if !decision.Arm.IsLocal {
|
||||
t.Errorf("incognito (LocalOnly=true) must beat PreferCloud; got %s", decision.Arm.ID)
|
||||
}
|
||||
decision.Rollback()
|
||||
}
|
||||
|
||||
// TestPreferPolicy_LocalArmsExhaustedFallsBackToCloud: PreferLocal must
|
||||
// not block cloud selection when the local fleet can't handle the task.
|
||||
func TestPreferPolicy_LocalArmsExhaustedFallsBackToCloud(t *testing.T) {
|
||||
r := New(Config{})
|
||||
r.SetPreferPolicy(PreferLocal)
|
||||
|
||||
// Only a cloud arm registered.
|
||||
r.RegisterArm(&Arm{
|
||||
ID: NewArmID("anthropic", "claude-opus-4-7"),
|
||||
ModelName: "claude-opus-4-7",
|
||||
Provider: security.WrapProvider(&stubProvider{name: "anthropic", model: "claude-opus-4-7"}, nil),
|
||||
IsLocal: false,
|
||||
Capabilities: provider.Capabilities{
|
||||
ToolUse: true,
|
||||
ContextWindow: 1_000_000,
|
||||
ThinkingModes: []provider.EffortLevel{provider.EffortHigh},
|
||||
},
|
||||
})
|
||||
|
||||
decision := r.Select(Task{
|
||||
Type: TaskSecurityReview,
|
||||
ComplexityScore: 0.9,
|
||||
Priority: PriorityCritical,
|
||||
RequiresTools: true,
|
||||
EstimatedTokens: 5000,
|
||||
})
|
||||
if decision.Error != nil {
|
||||
t.Fatalf("Select error: %v", decision.Error)
|
||||
}
|
||||
if decision.Arm.ID != NewArmID("anthropic", "claude-opus-4-7") {
|
||||
t.Errorf("expected cloud arm to win when no local feasible; got %s", decision.Arm.ID)
|
||||
}
|
||||
decision.Rollback()
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -22,10 +23,58 @@ type Router struct {
|
||||
forcedArm ArmID
|
||||
// When true, only local arms are considered (incognito mode)
|
||||
localOnly bool
|
||||
// Soft bias toward local / cloud arms (PreferAuto = unbiased)
|
||||
preferPolicy PreferPolicy
|
||||
|
||||
quality *QualityTracker
|
||||
}
|
||||
|
||||
// PreferPolicy biases the scoring step toward local or cloud arms.
|
||||
// See docs/superpowers/plans/2026-05-23-prefer-routing-policy.md.
|
||||
type PreferPolicy int
|
||||
|
||||
const (
|
||||
// PreferAuto leaves scoring unbiased — default, byte-identical to
|
||||
// pre-policy behavior.
|
||||
PreferAuto PreferPolicy = iota
|
||||
// PreferLocal multiplies non-local arm scores by 0.3, biasing
|
||||
// selection toward local arms while still allowing cloud arms to
|
||||
// win when no local arm is feasible or a cloud arm is much stronger.
|
||||
PreferLocal
|
||||
// PreferCloud multiplies local arm scores by 0.5, biasing selection
|
||||
// toward cloud arms while still allowing local arms (especially
|
||||
// tier-0 SLMs) to win trivial tasks.
|
||||
PreferCloud
|
||||
)
|
||||
|
||||
// ParsePreferPolicy converts a TOML-friendly string to a PreferPolicy.
|
||||
// Empty string and "auto" both map to PreferAuto. Unknown values return
|
||||
// an actionable error.
|
||||
func ParsePreferPolicy(s string) (PreferPolicy, error) {
|
||||
switch strings.ToLower(strings.TrimSpace(s)) {
|
||||
case "", "auto":
|
||||
return PreferAuto, nil
|
||||
case "local":
|
||||
return PreferLocal, nil
|
||||
case "cloud":
|
||||
return PreferCloud, nil
|
||||
default:
|
||||
return PreferAuto, fmt.Errorf("invalid router.prefer value %q (expected \"local\", \"cloud\", or \"auto\")", s)
|
||||
}
|
||||
}
|
||||
|
||||
// String returns the canonical TOML value for the policy.
|
||||
func (p PreferPolicy) String() string {
|
||||
switch p {
|
||||
case PreferLocal:
|
||||
return "local"
|
||||
case PreferCloud:
|
||||
return "cloud"
|
||||
default:
|
||||
return "auto"
|
||||
}
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
Logger *slog.Logger
|
||||
}
|
||||
@@ -123,7 +172,7 @@ func (r *Router) Select(task Task) RoutingDecision {
|
||||
}
|
||||
|
||||
// Select best
|
||||
best := selectBest(r.quality, feasible, task)
|
||||
best := selectBest(r.quality, feasible, task, r.preferPolicy)
|
||||
if best == nil {
|
||||
return RoutingDecision{Error: fmt.Errorf("selection failed")}
|
||||
}
|
||||
@@ -189,6 +238,21 @@ func (r *Router) LocalOnly() bool {
|
||||
return r.localOnly
|
||||
}
|
||||
|
||||
// SetPreferPolicy biases scoring toward local or cloud arms. See
|
||||
// PreferPolicy for the semantics. Soft bias only — does not hard-filter.
|
||||
func (r *Router) SetPreferPolicy(p PreferPolicy) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.preferPolicy = p
|
||||
}
|
||||
|
||||
// PreferPolicy returns the current routing-preference bias.
|
||||
func (r *Router) PreferPolicy() PreferPolicy {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
return r.preferPolicy
|
||||
}
|
||||
|
||||
// RemoveArm removes an arm from the router.
|
||||
func (r *Router) RemoveArm(id ArmID) {
|
||||
r.mu.Lock()
|
||||
|
||||
@@ -262,7 +262,7 @@ func TestSelectBest_PrefersToolSupport(t *testing.T) {
|
||||
}
|
||||
|
||||
task := Task{Type: TaskGeneration, RequiresTools: true, Priority: PriorityNormal}
|
||||
best := selectBest(nil, []*Arm{withoutTools, withTools}, task)
|
||||
best := selectBest(nil, []*Arm{withoutTools, withTools}, task, PreferAuto)
|
||||
|
||||
if best.ID != "a/with-tools" {
|
||||
t.Errorf("should prefer arm with tool support, got %s", best.ID)
|
||||
@@ -282,7 +282,7 @@ func TestSelectBest_PrefersThinkingForPlanning(t *testing.T) {
|
||||
}
|
||||
|
||||
task := Task{Type: TaskPlanning, RequiresTools: true, Priority: PriorityNormal, EstimatedTokens: 5000}
|
||||
best := selectBest(nil, []*Arm{noThinking, thinking}, task)
|
||||
best := selectBest(nil, []*Arm{noThinking, thinking}, task, PreferAuto)
|
||||
|
||||
if best.ID != "a/thinking" {
|
||||
t.Errorf("should prefer thinking model for planning, got %s", best.ID)
|
||||
@@ -602,7 +602,7 @@ func TestArmTier(t *testing.T) {
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := armTier(tt.arm, tt.task); got != tt.want {
|
||||
if got := armTier(tt.arm, tt.task, PreferAuto); got != tt.want {
|
||||
t.Errorf("armTier = %d, want %d", got, tt.want)
|
||||
}
|
||||
})
|
||||
@@ -625,7 +625,7 @@ func TestSelectBest_SmallArmWinsTrivialTask(t *testing.T) {
|
||||
Capabilities: provider.Capabilities{ToolUse: false},
|
||||
}
|
||||
task := Task{Type: TaskExplain, ComplexityScore: 0.05, RequiresTools: false}
|
||||
got := selectBest(nil, []*Arm{cliArm, smallArm}, task)
|
||||
got := selectBest(nil, []*Arm{cliArm, smallArm}, task, PreferAuto)
|
||||
if got != smallArm {
|
||||
t.Errorf("selectBest = %v, want smallArm", got)
|
||||
}
|
||||
@@ -647,7 +647,7 @@ func TestSelectBest_CLIAgentWinsComplexTask(t *testing.T) {
|
||||
Capabilities: provider.Capabilities{ToolUse: false},
|
||||
}
|
||||
task := Task{Type: TaskRefactor, ComplexityScore: 0.7, RequiresTools: true}
|
||||
got := selectBest(nil, []*Arm{cliArm, smallArm}, task)
|
||||
got := selectBest(nil, []*Arm{cliArm, smallArm}, task, PreferAuto)
|
||||
if got != cliArm {
|
||||
t.Errorf("selectBest = %v, want cliArm", got)
|
||||
}
|
||||
@@ -672,21 +672,21 @@ func TestSelectBest_TierPreference(t *testing.T) {
|
||||
task := Task{Type: TaskGeneration, Priority: PriorityNormal, EstimatedTokens: 1000}
|
||||
|
||||
t.Run("CLI beats local and API", func(t *testing.T) {
|
||||
best := selectBest(nil, []*Arm{apiArm, localArm, cliArm}, task)
|
||||
best := selectBest(nil, []*Arm{apiArm, localArm, cliArm}, task, PreferAuto)
|
||||
if best.ID != "subprocess/claude" {
|
||||
t.Errorf("want subprocess/claude (tier 0), got %s", best.ID)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("local beats API when no CLI", func(t *testing.T) {
|
||||
best := selectBest(nil, []*Arm{apiArm, localArm}, task)
|
||||
best := selectBest(nil, []*Arm{apiArm, localArm}, task, PreferAuto)
|
||||
if best.ID != "ollama/llama3" {
|
||||
t.Errorf("want ollama/llama3 (tier 1), got %s", best.ID)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("API selected when only option", func(t *testing.T) {
|
||||
best := selectBest(nil, []*Arm{apiArm}, task)
|
||||
best := selectBest(nil, []*Arm{apiArm}, task, PreferAuto)
|
||||
if best == nil || best.ID != "mistral/mistral-large" {
|
||||
t.Errorf("want mistral/mistral-large (tier 2), got %v", best)
|
||||
}
|
||||
|
||||
@@ -43,7 +43,38 @@ func (d RoutingDecision) Rollback() {
|
||||
// - 1: CLI agent
|
||||
// - 2: local model (general purpose, no complexity ceiling)
|
||||
// - 3: API provider
|
||||
func armTier(arm *Arm, task Task) int {
|
||||
//
|
||||
// When prefer is PreferLocal, non-local non-CLI-agent arms (true cloud
|
||||
// API arms) are demoted by +2 tiers so any local or CLI-agent option
|
||||
// is preferred. When prefer is PreferCloud, IsLocal arms are demoted
|
||||
// by +2 tiers so cloud arms win the tier walk. The +2 shift is enough
|
||||
// to drop cloud below the locals (tier 3 → 5) and locals below cloud
|
||||
// (tier 2 → 4) without colliding with any normal tier value, keeping
|
||||
// the tier walk deterministic.
|
||||
//
|
||||
// The Strengths-promoted path in selectBest bypasses the tier walk
|
||||
// entirely, so prefer-policy never blocks a strongly-tagged arm from
|
||||
// winning the task it's tagged for. This is the intended interaction.
|
||||
func armTier(arm *Arm, task Task, prefer PreferPolicy) int {
|
||||
base := armBaseTier(arm, task)
|
||||
switch prefer {
|
||||
case PreferLocal:
|
||||
// Demote pure cloud arms. CLI-agent arms proxy to cloud but
|
||||
// remain "local" from a tooling perspective — leave them where
|
||||
// they are. Users who want to exclude them should use
|
||||
// `--provider X` or the existing exclude mechanisms.
|
||||
if !arm.IsLocal && !arm.IsCLIAgent {
|
||||
return base + 2
|
||||
}
|
||||
case PreferCloud:
|
||||
if arm.IsLocal {
|
||||
return base + 2
|
||||
}
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
func armBaseTier(arm *Arm, task Task) int {
|
||||
if arm.MaxComplexity > 0 && task.ComplexityScore <= arm.MaxComplexity {
|
||||
return 0
|
||||
}
|
||||
@@ -67,7 +98,7 @@ func armTier(arm *Arm, task Task) int {
|
||||
//
|
||||
// Step 2 (fallback): walk tiers low→high. Within a tier, highest-scoring
|
||||
// arm wins.
|
||||
func selectBest(qt *QualityTracker, arms []*Arm, task Task) *Arm {
|
||||
func selectBest(qt *QualityTracker, arms []*Arm, task Task, prefer PreferPolicy) *Arm {
|
||||
if len(arms) == 0 {
|
||||
return nil
|
||||
}
|
||||
@@ -79,29 +110,32 @@ func selectBest(qt *QualityTracker, arms []*Arm, task Task) *Arm {
|
||||
}
|
||||
}
|
||||
if len(promoted) > 0 {
|
||||
return bestScored(qt, promoted, task)
|
||||
return bestScored(qt, promoted, task, prefer)
|
||||
}
|
||||
|
||||
for tier := 0; tier <= 3; tier++ {
|
||||
// Walk tiers low→high. armTier returns up to 5 when prefer is set
|
||||
// (a dispreferred tier-3 cloud arm under PreferLocal lands at 5);
|
||||
// the loop bound has to cover that.
|
||||
for tier := 0; tier <= 5; tier++ {
|
||||
var inTier []*Arm
|
||||
for _, arm := range arms {
|
||||
if armTier(arm, task) == tier {
|
||||
if armTier(arm, task, prefer) == tier {
|
||||
inTier = append(inTier, arm)
|
||||
}
|
||||
}
|
||||
if len(inTier) > 0 {
|
||||
return bestScored(qt, inTier, task)
|
||||
return bestScored(qt, inTier, task, prefer)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// bestScored returns the highest-scoring arm within a set.
|
||||
func bestScored(qt *QualityTracker, arms []*Arm, task Task) *Arm {
|
||||
func bestScored(qt *QualityTracker, arms []*Arm, task Task, prefer PreferPolicy) *Arm {
|
||||
var best *Arm
|
||||
bestScore := math.Inf(-1)
|
||||
for _, arm := range arms {
|
||||
score := scoreArm(qt, arm, task)
|
||||
score := scoreArm(qt, arm, task) * policyMultiplier(arm, prefer)
|
||||
if score > bestScore {
|
||||
bestScore = score
|
||||
best = arm
|
||||
@@ -110,6 +144,34 @@ func bestScored(qt *QualityTracker, arms []*Arm, task Task) *Arm {
|
||||
return best
|
||||
}
|
||||
|
||||
// policyMultiplier returns the prefer-policy score multiplier for an
|
||||
// arm. Soft bias only — does not zero out the dispreferred set, so
|
||||
// when only cloud arms are feasible under PreferLocal a cloud arm can
|
||||
// still win. Calibrated against the typical scoreArm output range
|
||||
// (~0.5–2.0) so a 0.3 multiplier is roughly equivalent to "non-local
|
||||
// arm must be ~3x better than local to win."
|
||||
//
|
||||
// CLI-agent subprocess arms count as non-local because they proxy to
|
||||
// cloud — the prefer knob is about the privacy/cost axis, not the
|
||||
// tooling-locality axis. Users who want to pin subprocess specifically
|
||||
// should use --provider subprocess, which bypasses the policy.
|
||||
func policyMultiplier(arm *Arm, p PreferPolicy) float64 {
|
||||
switch p {
|
||||
case PreferLocal:
|
||||
if arm.IsLocal {
|
||||
return 1.0
|
||||
}
|
||||
return 0.3
|
||||
case PreferCloud:
|
||||
if arm.IsLocal {
|
||||
return 0.5
|
||||
}
|
||||
return 1.0
|
||||
default:
|
||||
return 1.0
|
||||
}
|
||||
}
|
||||
|
||||
// strengthScoreBonus is added to quality when an arm's Strengths list
|
||||
// matches the incoming task type. Tunable in one place.
|
||||
const strengthScoreBonus = 0.15
|
||||
|
||||
@@ -184,7 +184,7 @@ func TestSelectBest_StrengthPromotedArmBeatsCLIAgent(t *testing.T) {
|
||||
}
|
||||
|
||||
task := Task{Type: TaskSecurityReview, EstimatedTokens: 5000, RequiresTools: true, Priority: PriorityNormal}
|
||||
got := selectBest(nil, []*Arm{cliAgent, opus}, task)
|
||||
got := selectBest(nil, []*Arm{cliAgent, opus}, task, PreferAuto)
|
||||
if got == nil {
|
||||
t.Fatal("selectBest returned nil")
|
||||
}
|
||||
@@ -208,7 +208,7 @@ func TestSelectBest_EmptyStrengthsPreservesTierOrder(t *testing.T) {
|
||||
}
|
||||
|
||||
task := Task{Type: TaskSecurityReview, EstimatedTokens: 5000, RequiresTools: true, Priority: PriorityNormal}
|
||||
got := selectBest(nil, []*Arm{cliAgent, opus}, task)
|
||||
got := selectBest(nil, []*Arm{cliAgent, opus}, task, PreferAuto)
|
||||
if got.ID != cliAgent.ID {
|
||||
t.Errorf("without Strengths, CLI-agent tier-1 should win; got %s", got.ID)
|
||||
}
|
||||
@@ -339,7 +339,7 @@ func TestSelectBest_MultiplePromotedArmsBestQualityWins(t *testing.T) {
|
||||
}
|
||||
|
||||
task := Task{Type: TaskSecurityReview, EstimatedTokens: 5000, RequiresTools: true, Priority: PriorityNormal}
|
||||
got := selectBest(qt, []*Arm{armA, armB}, task)
|
||||
got := selectBest(qt, []*Arm{armA, armB}, task, PreferAuto)
|
||||
if got == nil {
|
||||
t.Fatal("selectBest returned nil")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user