From 5e2f7078d9a509798a69e28745c3cc12df16db8d Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Wed, 25 Mar 2026 08:34:56 +0000 Subject: [PATCH] feat: implement multi-tier intelligent model routing This expands the intelligent routing feature (which previously only supported a binary light/heavy split) to support an arbitrary number of tiers, each with its own model and threshold score. - Update `RoutingConfig` to use a `Tiers` array. - Handle backward compatibility for `light_model` and `threshold`. - Update `Router.SelectModel` to iterate over sorted tiers and select the tier with the highest threshold that is <= the complexity score. - Update `AgentInstance` initialization to resolve candidates for all routing tiers instead of just the light model. - Update the frontend UI to display, add, edit, and remove routing tiers. Co-authored-by: TanLuong <28281768+TanLuong@users.noreply.github.com> --- delete_test_blocks.sh | 2 + fix_frontend.sh | 1 + fix_react.sh | 1 + fix_react2.js | 7 + fix_react2.sh | 1 + fix_tests.sh | 3 + patch_config.diff | 30 ++++ patch_form_model.diff | 170 ++++++++++++++++++ patch_router_test.diff | 29 +++ pkg/agent/instance.go | 68 +++++-- pkg/agent/loop.go | 28 +-- pkg/config/config.go | 17 +- pkg/routing/router.go | 59 +++--- pkg/routing/router_test.go | 45 ++--- .../channels/channel-forms/generic-form.tsx | 4 +- .../src/components/config/config-page.tsx | 12 ++ .../src/components/config/control-item.tsx | 34 ++++ .../src/components/config/form-model.ts | 33 ++++ .../src/components/config/routing-section.tsx | 150 ++++++++++++++++ .../src/components/models/add-model-sheet.tsx | 21 ++- .../components/models/edit-model-sheet.tsx | 22 ++- .../src/components/models/model-card.tsx | 3 +- web/frontend/src/i18n/locales/en.json | 2 +- 23 files changed, 638 insertions(+), 104 deletions(-) create mode 100644 delete_test_blocks.sh create mode 100644 fix_frontend.sh create mode 100644 fix_react.sh create mode 100644 fix_react2.js create mode 100644 fix_react2.sh create mode 100644 fix_tests.sh create mode 100644 patch_config.diff create mode 100644 patch_form_model.diff create mode 100644 patch_router_test.diff create mode 100644 web/frontend/src/components/config/control-item.tsx create mode 100644 web/frontend/src/components/config/routing-section.tsx diff --git a/delete_test_blocks.sh b/delete_test_blocks.sh new file mode 100644 index 000000000..1f642901f --- /dev/null +++ b/delete_test_blocks.sh @@ -0,0 +1,2 @@ +sed -i '244,256d' pkg/routing/router_test.go +sed -i 's/r.LightModel()/"my-fast-model"/g' pkg/routing/router_test.go diff --git a/fix_frontend.sh b/fix_frontend.sh new file mode 100644 index 000000000..39b52bb30 --- /dev/null +++ b/fix_frontend.sh @@ -0,0 +1 @@ +sed -i 's/"extraHeadersHint": "Custom HTTP headers in JSON format, e.g. {\\"X-My-Header\\": \\"value\\"}"/"extraHeadersHint": "Custom HTTP headers in JSON format, e.g. {\\"X-My-Header\\": \\"value\\"}",/' web/frontend/src/i18n/locales/en.json diff --git a/fix_react.sh b/fix_react.sh new file mode 100644 index 000000000..9bb3b5697 --- /dev/null +++ b/fix_react.sh @@ -0,0 +1 @@ +sed -i 's/ )}/ )}\n <\/Field>\n + + \n <\/Field>\n = threshold → primary model +} +======= +// RoutingTier defines a single tier for model routing. +type RoutingTier struct { + Model string `json:"model"` + Threshold float64 `json:"threshold"` +} + +// RoutingConfig controls the intelligent model routing feature. +// When enabled, each incoming message is scored against structural features +// (message length, code blocks, tool call history, conversation depth, attachments). +// The router selects the appropriate tier based on the computed score. +type RoutingConfig struct { + Enabled bool `json:"enabled"` + LightModel string `json:"light_model,omitempty"` // legacy: model_name from model_list to use for simple tasks + Threshold float64 `json:"threshold,omitempty"` // legacy: complexity score in [0,1]; score >= threshold → primary model + Tiers []RoutingTier `json:"tiers,omitempty"` // new: explicit tier definitions +} +>>>>>>> REPLACE diff --git a/patch_form_model.diff b/patch_form_model.diff new file mode 100644 index 000000000..2e72b517c --- /dev/null +++ b/patch_form_model.diff @@ -0,0 +1,170 @@ +<<<<<<< SEARCH +export interface CoreConfigForm { + workspace: string + restrictToWorkspace: boolean + toolFeedbackEnabled: boolean + toolFeedbackMaxArgsLength: string + execEnabled: boolean + allowRemote: boolean + enableDenyPatterns: boolean + customDenyPatternsText: string + customAllowPatternsText: string + execTimeoutSeconds: string + allowCommand: boolean + cronExecTimeoutMinutes: string + maxTokens: string + contextWindow: string + maxToolIterations: string + summarizeMessageThreshold: string + summarizeTokenPercent: string + dmScope: string + heartbeatEnabled: boolean + heartbeatInterval: string + devicesEnabled: boolean + monitorUSB: boolean +} +======= +export interface RoutingTier { + model: string + threshold: number +} + +export interface CoreConfigForm { + workspace: string + restrictToWorkspace: boolean + toolFeedbackEnabled: boolean + toolFeedbackMaxArgsLength: string + execEnabled: boolean + allowRemote: boolean + enableDenyPatterns: boolean + customDenyPatternsText: string + customAllowPatternsText: string + execTimeoutSeconds: string + allowCommand: boolean + cronExecTimeoutMinutes: string + maxTokens: string + contextWindow: string + maxToolIterations: string + summarizeMessageThreshold: string + summarizeTokenPercent: string + dmScope: string + heartbeatEnabled: boolean + heartbeatInterval: string + devicesEnabled: boolean + monitorUSB: boolean + routingEnabled: boolean + routingTiers: RoutingTier[] +} +>>>>>>> REPLACE +<<<<<<< SEARCH +export const EMPTY_FORM: CoreConfigForm = { + workspace: "", + restrictToWorkspace: true, + toolFeedbackEnabled: true, + toolFeedbackMaxArgsLength: "300", + execEnabled: true, + allowRemote: true, + enableDenyPatterns: true, + customDenyPatternsText: "", + customAllowPatternsText: "", + execTimeoutSeconds: "0", + allowCommand: true, + cronExecTimeoutMinutes: "5", + maxTokens: "32768", + contextWindow: "", + maxToolIterations: "50", + summarizeMessageThreshold: "20", + summarizeTokenPercent: "75", + dmScope: "per-channel-peer", + heartbeatEnabled: true, + heartbeatInterval: "30", + devicesEnabled: false, + monitorUSB: true, +} +======= +export const EMPTY_FORM: CoreConfigForm = { + workspace: "", + restrictToWorkspace: true, + toolFeedbackEnabled: true, + toolFeedbackMaxArgsLength: "300", + execEnabled: true, + allowRemote: true, + enableDenyPatterns: true, + customDenyPatternsText: "", + customAllowPatternsText: "", + execTimeoutSeconds: "0", + allowCommand: true, + cronExecTimeoutMinutes: "5", + maxTokens: "32768", + contextWindow: "", + maxToolIterations: "50", + summarizeMessageThreshold: "20", + summarizeTokenPercent: "75", + dmScope: "per-channel-peer", + heartbeatEnabled: true, + heartbeatInterval: "30", + devicesEnabled: false, + monitorUSB: true, + routingEnabled: false, + routingTiers: [], +} +>>>>>>> REPLACE +<<<<<<< SEARCH + const toolFeedback = asRecord(defaults.tool_feedback) + + return { + workspace: asString(defaults.workspace) || EMPTY_FORM.workspace, +======= + const toolFeedback = asRecord(defaults.tool_feedback) + const routing = asRecord(defaults.routing) + + // Backward compatibility for old light_model format + const parsedTiers: RoutingTier[] = [] + if (Array.isArray(routing.tiers)) { + for (const t of routing.tiers) { + if (t && typeof t === "object") { + const tier = t as Record + parsedTiers.push({ + model: asString(tier.model), + threshold: Number(tier.threshold) || 0, + }) + } + } + } else if (routing.light_model) { + parsedTiers.push({ + model: asString(routing.light_model), + threshold: Number(routing.threshold) || 0.35, + }) + } + + return { + workspace: asString(defaults.workspace) || EMPTY_FORM.workspace, +>>>>>>> REPLACE +<<<<<<< SEARCH + devicesEnabled: + devices.enabled === undefined + ? EMPTY_FORM.devicesEnabled + : asBool(devices.enabled), + monitorUSB: + devices.monitor_usb === undefined + ? EMPTY_FORM.monitorUSB + : asBool(devices.monitor_usb), + } +} +======= + devicesEnabled: + devices.enabled === undefined + ? EMPTY_FORM.devicesEnabled + : asBool(devices.enabled), + monitorUSB: + devices.monitor_usb === undefined + ? EMPTY_FORM.monitorUSB + : asBool(devices.monitor_usb), + routingEnabled: + routing.enabled === undefined + ? EMPTY_FORM.routingEnabled + : asBool(routing.enabled), + routingTiers: parsedTiers, + } +} +>>>>>>> REPLACE diff --git a/patch_router_test.diff b/patch_router_test.diff new file mode 100644 index 000000000..99c2b629f --- /dev/null +++ b/patch_router_test.diff @@ -0,0 +1,29 @@ +<<<<<<< SEARCH +func TestRouter_SelectModel_SimpleMessageUsesLight(t *testing.T) { + r := New(RouterConfig{LightModel: "gemini-flash"}) + msg := "hello, how are you?" + + model, usedLight, _ := r.SelectModel(msg, nil, "claude-sonnet-4-6") + + if !usedLight { + t.Errorf("expected to use light model for simple message") + } + if model != "gemini-flash" { + t.Errorf("expected light model 'gemini-flash', got %q", model) + } +} +======= +func TestRouter_SelectModel_SimpleMessageUsesLight(t *testing.T) { + r := New(RouterConfig{Tiers: []RoutingTier{{Model: "gemini-flash", Threshold: 0.0}, {Model: "claude-sonnet-4-6", Threshold: 0.35}}}) + msg := "hello, how are you?" + + model, usedLight, _ := r.SelectModel(msg, nil, "claude-sonnet-4-6") + + if !usedLight { + t.Errorf("expected to use light model for simple message") + } + if model != "gemini-flash" { + t.Errorf("expected light model 'gemini-flash', got %q", model) + } +} +>>>>>>> REPLACE diff --git a/pkg/agent/instance.go b/pkg/agent/instance.go index 34d401186..90e729da5 100644 --- a/pkg/agent/instance.go +++ b/pkg/agent/instance.go @@ -41,13 +41,13 @@ type AgentInstance struct { SkillsFilter []string Candidates []providers.FallbackCandidate - // Router is non-nil when model routing is configured and the light model - // was successfully resolved. It scores each incoming message and decides - // whether to route to LightCandidates or stay with Candidates. + // Router is non-nil when model routing is configured and the tiers + // were successfully resolved. It scores each incoming message and decides + // which model tier to use. Router *routing.Router - // LightCandidates holds the resolved provider candidates for the light model. + // TierCandidates holds the resolved provider candidates for each routing tier. // Pre-computed at agent creation to avoid repeated model_list lookups at runtime. - LightCandidates []providers.FallbackCandidate + TierCandidates map[string][]providers.FallbackCandidate } // NewAgentInstance creates an agent instance from config. @@ -165,21 +165,53 @@ func NewAgentInstance( // Resolve fallback candidates candidates := resolveModelCandidates(cfg, defaults.Provider, model, fallbacks) - // Model routing setup: pre-resolve light model candidates at creation time + // Model routing setup: pre-resolve tier model candidates at creation time // to avoid repeated model_list lookups on every incoming message. var router *routing.Router - var lightCandidates []providers.FallbackCandidate - if rc := defaults.Routing; rc != nil && rc.Enabled && rc.LightModel != "" { - resolved := resolveModelCandidates(cfg, defaults.Provider, rc.LightModel, nil) - if len(resolved) > 0 { - router = routing.New(routing.RouterConfig{ - LightModel: rc.LightModel, - Threshold: rc.Threshold, - }) - lightCandidates = resolved + var tierCandidates map[string][]providers.FallbackCandidate + if rc := defaults.Routing; rc != nil && rc.Enabled { + // Backward compatibility: handle old light_model/threshold config + var tiers []routing.RoutingTier + if len(rc.Tiers) == 0 && rc.LightModel != "" { + tiers = []routing.RoutingTier{ + {Model: rc.LightModel, Threshold: 0.0}, + } + if rc.Threshold > 0 { + tiers = append(tiers, routing.RoutingTier{Model: model, Threshold: rc.Threshold}) + } else { + tiers = append(tiers, routing.RoutingTier{Model: model, Threshold: 0.35}) + } } else { - logger.WarnCF("agent", "Routing light model not found; routing disabled", - map[string]any{"light_model": rc.LightModel, "agent_id": agentID}) + for _, t := range rc.Tiers { + tiers = append(tiers, routing.RoutingTier{Model: t.Model, Threshold: t.Threshold}) + } + } + + if len(tiers) > 0 { + tierCandidates = make(map[string][]providers.FallbackCandidate) + validTiers := []routing.RoutingTier{} + + for _, tier := range tiers { + if tier.Model == model { + // Don't need to resolve the primary model again + validTiers = append(validTiers, tier) + continue + } + resolved := resolveModelCandidates(cfg, defaults.Provider, tier.Model, nil) + if len(resolved) > 0 { + tierCandidates[tier.Model] = resolved + validTiers = append(validTiers, tier) + } else { + logger.WarnCF("agent", "Routing tier model not found; skipping tier", + map[string]any{"tier_model": tier.Model, "agent_id": agentID}) + } + } + + if len(validTiers) > 0 { + router = routing.New(routing.RouterConfig{ + Tiers: validTiers, + }) + } } } @@ -204,7 +236,7 @@ func NewAgentInstance( SkillsFilter: skillsFilter, Candidates: candidates, Router: router, - LightCandidates: lightCandidates, + TierCandidates: tierCandidates, } } diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index 995c5720b..a21595e57 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -2744,29 +2744,37 @@ func (al *AgentLoop) selectCandidates( userMsg string, history []providers.Message, ) (candidates []providers.FallbackCandidate, model string) { - if agent.Router == nil || len(agent.LightCandidates) == 0 { + if agent.Router == nil { return agent.Candidates, resolvedCandidateModel(agent.Candidates, agent.Model) } - _, usedLight, score := agent.Router.SelectModel(userMsg, history, agent.Model) - if !usedLight { + targetModelName, _, score := agent.Router.SelectModel(userMsg, history, agent.Model) + if targetModelName == agent.Model { logger.DebugCF("agent", "Model routing: primary model selected", map[string]any{ "agent_id": agent.ID, "score": score, - "threshold": agent.Router.Threshold(), }) return agent.Candidates, resolvedCandidateModel(agent.Candidates, agent.Model) } - logger.InfoCF("agent", "Model routing: light model selected", + if tierCands, ok := agent.TierCandidates[targetModelName]; ok && len(tierCands) > 0 { + logger.InfoCF("agent", "Model routing: tier model selected", + map[string]any{ + "agent_id": agent.ID, + "tier_model": targetModelName, + "score": score, + }) + return tierCands, resolvedCandidateModel(tierCands, targetModelName) + } + + logger.WarnCF("agent", "Model routing: tier model candidates not found, falling back to primary", map[string]any{ - "agent_id": agent.ID, - "light_model": agent.Router.LightModel(), - "score": score, - "threshold": agent.Router.Threshold(), + "agent_id": agent.ID, + "tier_model": targetModelName, + "score": score, }) - return agent.LightCandidates, resolvedCandidateModel(agent.LightCandidates, agent.Router.LightModel()) + return agent.Candidates, resolvedCandidateModel(agent.Candidates, agent.Model) } // maybeSummarize triggers summarization if the session history exceeds thresholds. diff --git a/pkg/config/config.go b/pkg/config/config.go index 3c49da1de..33e2fdb43 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -273,16 +273,21 @@ type SessionConfig struct { IdentityLinks map[string][]string `json:"identity_links,omitempty"` } +// RoutingTier defines a single tier for model routing. +type RoutingTier struct { + Model string `json:"model"` + Threshold float64 `json:"threshold"` +} + // RoutingConfig controls the intelligent model routing feature. // When enabled, each incoming message is scored against structural features // (message length, code blocks, tool call history, conversation depth, attachments). -// Messages scoring below Threshold are sent to LightModel; all others use the -// agent's primary model. This reduces cost and latency for simple tasks without -// requiring any keyword matching — all scoring is language-agnostic. +// The router selects the appropriate tier based on the computed score. type RoutingConfig struct { - Enabled bool `json:"enabled"` - LightModel string `json:"light_model"` // model_name from model_list to use for simple tasks - Threshold float64 `json:"threshold"` // complexity score in [0,1]; score >= threshold → primary model + Enabled bool `json:"enabled"` + LightModel string `json:"light_model,omitempty"` // legacy: model_name from model_list to use for simple tasks + Threshold float64 `json:"threshold,omitempty"` // legacy: complexity score in [0,1]; score >= threshold → primary model + Tiers []RoutingTier `json:"tiers,omitempty"` // new: explicit tier definitions } // SubTurnConfig configures the SubTurn execution system. diff --git a/pkg/routing/router.go b/pkg/routing/router.go index b1fa347e9..a5d489233 100644 --- a/pkg/routing/router.go +++ b/pkg/routing/router.go @@ -9,17 +9,17 @@ import ( // or an attachment) before the heavy model is chosen. const defaultThreshold = 0.35 +// RoutingTier defines a single tier for model routing. +type RoutingTier struct { + Model string + Threshold float64 +} + // RouterConfig holds the validated model routing settings. // It mirrors config.RoutingConfig but lives in pkg/routing to keep the // dependency graph simple: pkg/agent resolves config → routing, not the reverse. type RouterConfig struct { - // LightModel is the model_name (from model_list) used for simple tasks. - LightModel string - - // Threshold is the complexity score cutoff in [0, 1]. - // score >= Threshold → primary (heavy) model. - // score < Threshold → light model. - Threshold float64 + Tiers []RoutingTier } // Router selects the appropriate model tier for each incoming message. @@ -30,11 +30,7 @@ type Router struct { } // New creates a Router with the given config and the default RuleClassifier. -// If cfg.Threshold is zero or negative, defaultThreshold (0.35) is used. func New(cfg RouterConfig) *Router { - if cfg.Threshold <= 0 { - cfg.Threshold = defaultThreshold - } return &Router{ cfg: cfg, classifier: &RuleClassifier{}, @@ -44,20 +40,18 @@ func New(cfg RouterConfig) *Router { // newWithClassifier creates a Router with a custom Classifier. // Intended for unit tests that need to inject a deterministic scorer. func newWithClassifier(cfg RouterConfig, c Classifier) *Router { - if cfg.Threshold <= 0 { - cfg.Threshold = defaultThreshold - } return &Router{cfg: cfg, classifier: c} } // SelectModel returns the model to use for this conversation turn along with // the computed complexity score (for logging and debugging). // -// - If score < cfg.Threshold: returns (cfg.LightModel, true, score) -// - Otherwise: returns (primaryModel, false, score) +// The router selects the tier whose threshold is <= score. +// If multiple tiers match, it prefers the one with the highest threshold. +// If no tier matches, it returns the primary model. // // The caller is responsible for resolving the returned model name into -// provider candidates (see AgentInstance.LightCandidates). +// provider candidates. func (r *Router) SelectModel( msg string, history []providers.Message, @@ -65,18 +59,27 @@ func (r *Router) SelectModel( ) (model string, usedLight bool, score float64) { features := ExtractFeatures(msg, history) score = r.classifier.Score(features) - if score < r.cfg.Threshold { - return r.cfg.LightModel, true, score + + selectedModel := primaryModel + maxMatchedThreshold := -1.0 + matched := false + + // Find the highest threshold that is <= score + for _, tier := range r.cfg.Tiers { + if score >= tier.Threshold && tier.Threshold > maxMatchedThreshold { + selectedModel = tier.Model + maxMatchedThreshold = tier.Threshold + matched = true + } } - return primaryModel, false, score + + // usedLight is a bit of a legacy concept now, but we can set it to true if we didn't use primaryModel + usedLight = matched && selectedModel != primaryModel + + return selectedModel, usedLight, score } -// LightModel returns the configured light model name. -func (r *Router) LightModel() string { - return r.cfg.LightModel -} - -// Threshold returns the complexity threshold in use. -func (r *Router) Threshold() float64 { - return r.cfg.Threshold +// Tiers returns the configured routing tiers. +func (r *Router) Tiers() []RoutingTier { + return r.cfg.Tiers } diff --git a/pkg/routing/router_test.go b/pkg/routing/router_test.go index 2824d10ab..59d719608 100644 --- a/pkg/routing/router_test.go +++ b/pkg/routing/router_test.go @@ -241,22 +241,9 @@ func TestRuleClassifier_ScoreDoesNotExceedOne(t *testing.T) { // ── Router ─────────────────────────────────────────────────────────────────── -func TestRouter_DefaultThreshold(t *testing.T) { - r := New(RouterConfig{LightModel: "gemini-flash"}) - if r.Threshold() != defaultThreshold { - t.Errorf("default threshold: got %f, want %f", r.Threshold(), defaultThreshold) - } -} - -func TestRouter_NegativeThresholdFallsBackToDefault(t *testing.T) { - r := New(RouterConfig{LightModel: "gemini-flash", Threshold: -0.1}) - if r.Threshold() != defaultThreshold { - t.Errorf("negative threshold: got %f, want %f", r.Threshold(), defaultThreshold) - } -} func TestRouter_SelectModel_SimpleMessageUsesLight(t *testing.T) { - r := New(RouterConfig{LightModel: "gemini-flash", Threshold: 0.35}) + r := New(RouterConfig{Tiers: []RoutingTier{{Model: "gemini-flash", Threshold: 0.0}, {Model: "claude-sonnet-4-6", Threshold: 0.35}}}) msg := "hi" model, usedLight, _ := r.SelectModel(msg, nil, "claude-sonnet-4-6") if !usedLight { @@ -268,7 +255,7 @@ func TestRouter_SelectModel_SimpleMessageUsesLight(t *testing.T) { } func TestRouter_SelectModel_CodeBlockUsesPrimary(t *testing.T) { - r := New(RouterConfig{LightModel: "gemini-flash", Threshold: 0.35}) + r := New(RouterConfig{Tiers: []RoutingTier{{Model: "gemini-flash", Threshold: 0.0}, {Model: "claude-sonnet-4-6", Threshold: 0.35}}}) msg := "```go\nfmt.Println(\"hello\")\n```" model, usedLight, _ := r.SelectModel(msg, nil, "claude-sonnet-4-6") if usedLight { @@ -280,7 +267,7 @@ func TestRouter_SelectModel_CodeBlockUsesPrimary(t *testing.T) { } func TestRouter_SelectModel_AttachmentUsesPrimary(t *testing.T) { - r := New(RouterConfig{LightModel: "gemini-flash", Threshold: 0.35}) + r := New(RouterConfig{Tiers: []RoutingTier{{Model: "gemini-flash", Threshold: 0.0}, {Model: "claude-sonnet-4-6", Threshold: 0.35}}}) msg := "can you analyze this? data:image/png;base64,abc123" model, usedLight, _ := r.SelectModel(msg, nil, "claude-sonnet-4-6") if usedLight { @@ -292,7 +279,7 @@ func TestRouter_SelectModel_AttachmentUsesPrimary(t *testing.T) { } func TestRouter_SelectModel_LongMessageUsesPrimary(t *testing.T) { - r := New(RouterConfig{LightModel: "gemini-flash", Threshold: 0.35}) + r := New(RouterConfig{Tiers: []RoutingTier{{Model: "gemini-flash", Threshold: 0.0}, {Model: "claude-sonnet-4-6", Threshold: 0.35}}}) // >200 token estimate: 210 * 3 = 630 chars msg := strings.Repeat("word ", 210) model, usedLight, _ := r.SelectModel(msg, nil, "claude-sonnet-4-6") @@ -307,7 +294,7 @@ func TestRouter_SelectModel_LongMessageUsesPrimary(t *testing.T) { func TestRouter_SelectModel_DeepToolChainUsesLight(t *testing.T) { // Tool calls alone (0.25) don't cross the 0.35 threshold — acceptable behavior. // Routing is conservative: only promote to heavy when the signal is unambiguous. - r := New(RouterConfig{LightModel: "gemini-flash", Threshold: 0.35}) + r := New(RouterConfig{Tiers: []RoutingTier{{Model: "gemini-flash", Threshold: 0.0}, {Model: "claude-sonnet-4-6", Threshold: 0.35}}}) history := []providers.Message{ {Role: "assistant", ToolCalls: []providers.ToolCall{{Name: "read_file"}, {Name: "write_file"}}}, {Role: "assistant", ToolCalls: []providers.ToolCall{{Name: "exec"}, {Name: "search"}}}, @@ -321,7 +308,7 @@ func TestRouter_SelectModel_DeepToolChainUsesLight(t *testing.T) { func TestRouter_SelectModel_ToolChainPlusMediumUsesHeavy(t *testing.T) { // Tool calls (0.25) + medium message (0.15) = 0.40 >= 0.35 → heavy - r := New(RouterConfig{LightModel: "gemini-flash", Threshold: 0.35}) + r := New(RouterConfig{Tiers: []RoutingTier{{Model: "gemini-flash", Threshold: 0.0}, {Model: "claude-sonnet-4-6", Threshold: 0.35}}}) history := []providers.Message{ {Role: "assistant", ToolCalls: []providers.ToolCall{ {Name: "a"}, {Name: "b"}, {Name: "c"}, {Name: "d"}, @@ -337,7 +324,7 @@ func TestRouter_SelectModel_ToolChainPlusMediumUsesHeavy(t *testing.T) { func TestRouter_SelectModel_CustomThreshold(t *testing.T) { // Very low threshold: even a short message triggers heavy model - r := New(RouterConfig{LightModel: "gemini-flash", Threshold: 0.05}) + r := New(RouterConfig{Tiers: []RoutingTier{{Model: "gemini-flash", Threshold: 0.0}, {Model: "claude-sonnet-4-6", Threshold: 0.05}}}) msg := strings.Repeat("word ", 55) // medium message → 0.15 >= 0.05 _, usedLight, _ := r.SelectModel(msg, nil, "claude-sonnet-4-6") if usedLight { @@ -347,7 +334,7 @@ func TestRouter_SelectModel_CustomThreshold(t *testing.T) { func TestRouter_SelectModel_HighThreshold(t *testing.T) { // Very high threshold: even code blocks route to light - r := New(RouterConfig{LightModel: "gemini-flash", Threshold: 0.99}) + r := New(RouterConfig{Tiers: []RoutingTier{{Model: "gemini-flash", Threshold: 0.0}, {Model: "claude-sonnet-4-6", Threshold: 0.99}}}) msg := "```go\nfmt.Println()\n```" _, usedLight, _ := r.SelectModel(msg, nil, "claude-sonnet-4-6") if !usedLight { @@ -355,10 +342,10 @@ func TestRouter_SelectModel_HighThreshold(t *testing.T) { } } -func TestRouter_LightModel(t *testing.T) { - r := New(RouterConfig{LightModel: "my-fast-model", Threshold: 0.35}) - if r.LightModel() != "my-fast-model" { - t.Errorf("LightModel: got %q, want %q", r.LightModel(), "my-fast-model") +func TestRouter_Tiers(t *testing.T) { + r := New(RouterConfig{Tiers: []RoutingTier{{Model: "my-fast-model", Threshold: 0.0}, {Model: "heavy-model", Threshold: 0.35}}}) + if r.Tiers()[0].Model != "my-fast-model" { + t.Errorf("LightModel: got %q, want %q", "my-fast-model", "my-fast-model") } } @@ -370,7 +357,7 @@ func (f *fixedScoreClassifier) Score(_ Features) float64 { return f.score } func TestRouter_CustomClassifier_LowScore_SelectsLight(t *testing.T) { r := newWithClassifier( - RouterConfig{LightModel: "light", Threshold: 0.5}, + RouterConfig{Tiers: []RoutingTier{{Model: "light", Threshold: 0.0}, {Model: "heavy", Threshold: 0.5}}}, &fixedScoreClassifier{score: 0.2}, ) _, usedLight, _ := r.SelectModel("anything", nil, "heavy") @@ -381,7 +368,7 @@ func TestRouter_CustomClassifier_LowScore_SelectsLight(t *testing.T) { func TestRouter_CustomClassifier_HighScore_SelectsPrimary(t *testing.T) { r := newWithClassifier( - RouterConfig{LightModel: "light", Threshold: 0.5}, + RouterConfig{Tiers: []RoutingTier{{Model: "light", Threshold: 0.0}, {Model: "heavy", Threshold: 0.5}}}, &fixedScoreClassifier{score: 0.8}, ) _, usedLight, _ := r.SelectModel("anything", nil, "heavy") @@ -393,7 +380,7 @@ func TestRouter_CustomClassifier_HighScore_SelectsPrimary(t *testing.T) { func TestRouter_CustomClassifier_ExactThreshold_SelectsPrimary(t *testing.T) { // score == threshold → primary (uses >= comparison) r := newWithClassifier( - RouterConfig{LightModel: "light", Threshold: 0.5}, + RouterConfig{Tiers: []RoutingTier{{Model: "light", Threshold: 0.0}, {Model: "heavy", Threshold: 0.5}}}, &fixedScoreClassifier{score: 0.5}, ) _, usedLight, _ := r.SelectModel("anything", nil, "heavy") @@ -404,7 +391,7 @@ func TestRouter_CustomClassifier_ExactThreshold_SelectsPrimary(t *testing.T) { func TestRouter_SelectModel_ReturnsScore(t *testing.T) { r := newWithClassifier( - RouterConfig{LightModel: "light", Threshold: 0.5}, + RouterConfig{Tiers: []RoutingTier{{Model: "light", Threshold: 0.0}, {Model: "heavy", Threshold: 0.5}}}, &fixedScoreClassifier{score: 0.42}, ) _, _, score := r.SelectModel("anything", nil, "heavy") diff --git a/web/frontend/src/components/channels/channel-forms/generic-form.tsx b/web/frontend/src/components/channels/channel-forms/generic-form.tsx index 1a872542b..936802944 100644 --- a/web/frontend/src/components/channels/channel-forms/generic-form.tsx +++ b/web/frontend/src/components/channels/channel-forms/generic-form.tsx @@ -123,7 +123,9 @@ export function GenericForm({ bot_id: t("channels.form.desc.appId"), websocket_url: t("channels.form.desc.wsUrl"), dm_policy: t("channels.form.desc.genericField", { field: "DM policy" }), - group_policy: t("channels.form.desc.genericField", { field: "group policy" }), + group_policy: t("channels.form.desc.genericField", { + field: "group policy", + }), group_allow_from: t("channels.form.desc.allowFrom"), send_thinking_message: t("channels.form.desc.genericField", { field: "thinking message behavior", diff --git a/web/frontend/src/components/config/config-page.tsx b/web/frontend/src/components/config/config-page.tsx index 24a719d86..c34c48709 100644 --- a/web/frontend/src/components/config/config-page.tsx +++ b/web/frontend/src/components/config/config-page.tsx @@ -30,6 +30,7 @@ import { parseIntField, parseMultilineList, } from "@/components/config/form-model" +import { RoutingSection } from "@/components/config/routing-section" import { PageHeader } from "@/components/page-header" import { Button } from "@/components/ui/button" @@ -217,6 +218,10 @@ export function ConfigPage() { max_tool_iterations: maxToolIterations, summarize_message_threshold: summarizeMessageThreshold, summarize_token_percent: summarizeTokenPercent, + routing: { + enabled: form.routingEnabled, + tiers: form.routingTiers, + }, }, }, session: { @@ -322,6 +327,13 @@ export function ConfigPage() { + updateField("routingEnabled", v)} + onTiersChange={(v) => updateField("routingTiers", v)} + /> + diff --git a/web/frontend/src/components/config/control-item.tsx b/web/frontend/src/components/config/control-item.tsx new file mode 100644 index 000000000..b2c512eeb --- /dev/null +++ b/web/frontend/src/components/config/control-item.tsx @@ -0,0 +1,34 @@ +import { ReactNode } from "react" + +import { cn } from "@/lib/utils" + +interface ControlItemProps { + label: string + hint?: string + control: ReactNode + className?: string +} + +export function ControlItem({ + label, + hint, + control, + className, +}: ControlItemProps) { + return ( +
+
+ + {hint &&

{hint}

} +
+
{control}
+
+ ) +} diff --git a/web/frontend/src/components/config/form-model.ts b/web/frontend/src/components/config/form-model.ts index 10c5c71bb..a25238891 100644 --- a/web/frontend/src/components/config/form-model.ts +++ b/web/frontend/src/components/config/form-model.ts @@ -1,5 +1,10 @@ export type JsonRecord = Record +export interface RoutingTier { + model: string + threshold: number +} + export interface CoreConfigForm { workspace: string restrictToWorkspace: boolean @@ -23,6 +28,8 @@ export interface CoreConfigForm { heartbeatInterval: string devicesEnabled: boolean monitorUSB: boolean + routingEnabled: boolean + routingTiers: RoutingTier[] } export interface LauncherForm { @@ -85,6 +92,8 @@ export const EMPTY_FORM: CoreConfigForm = { heartbeatInterval: "30", devicesEnabled: false, monitorUSB: true, + routingEnabled: false, + routingTiers: [], } export const EMPTY_LAUNCHER_FORM: LauncherForm = { @@ -129,6 +138,25 @@ export function buildFormFromConfig(config: unknown): CoreConfigForm { const cron = asRecord(tools.cron) const exec = asRecord(tools.exec) const toolFeedback = asRecord(defaults.tool_feedback) + const routing = asRecord(defaults.routing) + + const parsedTiers: RoutingTier[] = [] + if (Array.isArray(routing.tiers)) { + for (const t of routing.tiers) { + if (t && typeof t === "object") { + const tier = t as Record + parsedTiers.push({ + model: asString(tier.model), + threshold: Number(tier.threshold) || 0, + }) + } + } + } else if (routing.light_model) { + parsedTiers.push({ + model: asString(routing.light_model), + threshold: Number(routing.threshold) || 0.35, + }) + } return { workspace: asString(defaults.workspace) || EMPTY_FORM.workspace, @@ -212,6 +240,11 @@ export function buildFormFromConfig(config: unknown): CoreConfigForm { devices.monitor_usb === undefined ? EMPTY_FORM.monitorUSB : asBool(devices.monitor_usb), + routingEnabled: + routing.enabled === undefined + ? EMPTY_FORM.routingEnabled + : asBool(routing.enabled), + routingTiers: parsedTiers, } } diff --git a/web/frontend/src/components/config/routing-section.tsx b/web/frontend/src/components/config/routing-section.tsx new file mode 100644 index 000000000..c46c66169 --- /dev/null +++ b/web/frontend/src/components/config/routing-section.tsx @@ -0,0 +1,150 @@ +import { Plus, Trash2 } from "lucide-react" +import { useTranslation } from "react-i18next" + +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Switch } from "@/components/ui/switch" + +import { ControlItem } from "./control-item" + +export interface RoutingTier { + model: string + threshold: number +} + +interface RoutingSectionProps { + enabled: boolean + tiers: RoutingTier[] + onEnabledChange: (enabled: boolean) => void + onTiersChange: (tiers: RoutingTier[]) => void +} + +export function RoutingSection({ + enabled, + tiers, + onEnabledChange, + onTiersChange, +}: RoutingSectionProps) { + const { t } = useTranslation() + + const handleAddTier = () => { + onTiersChange([...tiers, { model: "", threshold: 0 }]) + } + + const handleRemoveTier = (index: number) => { + onTiersChange(tiers.filter((_, i) => i !== index)) + } + + const handleTierChange = ( + index: number, + field: keyof RoutingTier, + value: string | number, + ) => { + const newTiers = [...tiers] + newTiers[index] = { ...newTiers[index], [field]: value } + onTiersChange(newTiers) + } + + return ( +
+

+ {t("pages.config.routing.title", "Model Routing")} +

+
+ + } + /> + + {enabled && ( +
+
+ + +
+ +
+ {tiers.length === 0 && ( +
+ {t( + "pages.config.routing.no_tiers", + "No routing tiers defined. Add a tier to configure routing.", + )} +
+ )} + {tiers.map((tier, index) => ( +
+
+ + + handleTierChange(index, "model", e.target.value) + } + placeholder="e.g. gpt-4o-mini" + className="h-8" + /> +
+
+ + + handleTierChange( + index, + "threshold", + parseFloat(e.target.value) || 0, + ) + } + className="h-8" + /> +
+ +
+ ))} +
+
+ )} +
+
+ ) +} diff --git a/web/frontend/src/components/models/add-model-sheet.tsx b/web/frontend/src/components/models/add-model-sheet.tsx index f5561dc7a..3812e5844 100644 --- a/web/frontend/src/components/models/add-model-sheet.tsx +++ b/web/frontend/src/components/models/add-model-sheet.tsx @@ -12,7 +12,6 @@ import { } from "@/components/shared-form" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" -import { Textarea } from "@/components/ui/textarea" import { Sheet, SheetContent, @@ -21,6 +20,7 @@ import { SheetHeader, SheetTitle, } from "@/components/ui/sheet" +import { Textarea } from "@/components/ui/textarea" interface AddForm { modelName: string @@ -103,7 +103,11 @@ export function AddModelSheet({ if (form.extraHeaders.trim()) { try { const parsed = JSON.parse(form.extraHeaders.trim()) - if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + if ( + typeof parsed !== "object" || + parsed === null || + Array.isArray(parsed) + ) { errors.extraHeaders = "Must be a valid JSON object" } } catch { @@ -115,7 +119,8 @@ export function AddModelSheet({ } const setField = - (key: keyof AddForm) => (e: React.ChangeEvent) => { + (key: keyof AddForm) => + (e: React.ChangeEvent) => { setForm((f) => ({ ...f, [key]: e.target.value })) if (fieldErrors[key]) { setFieldErrors((prev) => ({ ...prev, [key]: undefined })) @@ -144,7 +149,9 @@ export function AddModelSheet({ ? Number(form.requestTimeout) : undefined, thinking_level: form.thinkingLevel.trim() || undefined, - extra_headers: form.extraHeaders.trim() ? JSON.parse(form.extraHeaders.trim()) : undefined, + extra_headers: form.extraHeaders.trim() + ? JSON.parse(form.extraHeaders.trim()) + : undefined, extra_body: form.extraBody.trim() ? JSON.parse(form.extraBody.trim()) : undefined, @@ -336,8 +343,12 @@ export function AddModelSheet({ aria-invalid={!!fieldErrors.extraHeaders} /> {fieldErrors.extraHeaders && ( -

{fieldErrors.extraHeaders}

+

+ {fieldErrors.extraHeaders} +

)} +
+ diff --git a/web/frontend/src/components/models/edit-model-sheet.tsx b/web/frontend/src/components/models/edit-model-sheet.tsx index 5872d80a3..74720c123 100644 --- a/web/frontend/src/components/models/edit-model-sheet.tsx +++ b/web/frontend/src/components/models/edit-model-sheet.tsx @@ -12,7 +12,6 @@ import { } from "@/components/shared-form" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" -import { Textarea } from "@/components/ui/textarea" import { Sheet, SheetContent, @@ -21,6 +20,7 @@ import { SheetHeader, SheetTitle, } from "@/components/ui/sheet" +import { Textarea } from "@/components/ui/textarea" interface EditForm { apiKey: string @@ -84,7 +84,9 @@ export function EditModelSheet({ ? String(model.request_timeout) : "", thinkingLevel: model.thinking_level ?? "", - extraHeaders: model.extra_headers ? JSON.stringify(model.extra_headers) : "", + extraHeaders: model.extra_headers + ? JSON.stringify(model.extra_headers) + : "", extraBody: model.extra_body ? JSON.stringify(model.extra_body, null, 2) : "", @@ -95,7 +97,8 @@ export function EditModelSheet({ }, [model]) const setField = - (key: keyof EditForm) => (e: React.ChangeEvent) => + (key: keyof EditForm) => + (e: React.ChangeEvent) => setForm((f) => ({ ...f, [key]: e.target.value })) const handleSave = async () => { @@ -103,7 +106,11 @@ export function EditModelSheet({ if (form.extraHeaders.trim()) { try { const parsed = JSON.parse(form.extraHeaders.trim()) - if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + if ( + typeof parsed !== "object" || + parsed === null || + Array.isArray(parsed) + ) { setError("Extra headers must be a valid JSON object") return } @@ -130,7 +137,9 @@ export function EditModelSheet({ ? Number(form.requestTimeout) : undefined, thinking_level: form.thinkingLevel || undefined, - extra_headers: form.extraHeaders.trim() ? JSON.parse(form.extraHeaders.trim()) : undefined, + extra_headers: form.extraHeaders.trim() + ? JSON.parse(form.extraHeaders.trim()) + : undefined, extra_body: form.extraBody.trim() ? JSON.parse(form.extraBody.trim()) : {}, @@ -307,6 +316,9 @@ export function EditModelSheet({ value={form.extraHeaders} onChange={setField("extraHeaders")} placeholder='{"X-My-Header": "value"}' + /> + + diff --git a/web/frontend/src/components/models/model-card.tsx b/web/frontend/src/components/models/model-card.tsx index 319cb11a3..c554410a8 100644 --- a/web/frontend/src/components/models/model-card.tsx +++ b/web/frontend/src/components/models/model-card.tsx @@ -28,7 +28,8 @@ export function ModelCard({ }: ModelCardProps) { const { t } = useTranslation() const isOAuth = model.auth_method === "oauth" - const canSetDefault = model.configured && !model.is_default && !model.is_virtual + const canSetDefault = + model.configured && !model.is_default && !model.is_virtual return (