Merge pull request #6 from TanLuong/feature/routing-tiers-8419239724563558261
Feature: Multi-tier Model Routing
This commit is contained in:
commit
39af2501fa
14 changed files with 394 additions and 104 deletions
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
|
||||
<AgentDefaultsSection form={form} onFieldChange={updateField} />
|
||||
|
||||
<RoutingSection
|
||||
enabled={form.routingEnabled}
|
||||
tiers={form.routingTiers}
|
||||
onEnabledChange={(v) => updateField("routingEnabled", v)}
|
||||
onTiersChange={(v) => updateField("routingTiers", v)}
|
||||
/>
|
||||
|
||||
<RuntimeSection form={form} onFieldChange={updateField} />
|
||||
|
||||
<ExecSection form={form} onFieldChange={updateField} />
|
||||
|
|
|
|||
34
web/frontend/src/components/config/control-item.tsx
Normal file
34
web/frontend/src/components/config/control-item.tsx
Normal file
|
|
@ -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 (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-row items-center justify-between rounded-lg border p-4 shadow-sm",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="space-y-0.5">
|
||||
<label className="text-sm leading-none font-medium peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
|
||||
{label}
|
||||
</label>
|
||||
{hint && <p className="text-muted-foreground text-sm">{hint}</p>}
|
||||
</div>
|
||||
<div>{control}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,5 +1,10 @@
|
|||
export type JsonRecord = Record<string, unknown>
|
||||
|
||||
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<string, unknown>
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
150
web/frontend/src/components/config/routing-section.tsx
Normal file
150
web/frontend/src/components/config/routing-section.tsx
Normal file
|
|
@ -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 (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-semibold">
|
||||
{t("pages.config.routing.title", "Model Routing")}
|
||||
</h2>
|
||||
<div className="bg-card rounded-lg border p-6 shadow-sm">
|
||||
<ControlItem
|
||||
label={t(
|
||||
"pages.config.routing.enabled",
|
||||
"Enable Intelligent Routing",
|
||||
)}
|
||||
hint={t(
|
||||
"pages.config.routing.enabled_hint",
|
||||
"Route messages to different models based on complexity score",
|
||||
)}
|
||||
control={
|
||||
<Switch checked={enabled} onCheckedChange={onEnabledChange} />
|
||||
}
|
||||
/>
|
||||
|
||||
{enabled && (
|
||||
<div className="mt-6 space-y-4 border-t pt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-sm font-medium">
|
||||
{t("pages.config.routing.tiers", "Routing Tiers")}
|
||||
</Label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleAddTier}
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{t("pages.config.routing.add_tier", "Add Tier")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{tiers.length === 0 && (
|
||||
<div className="text-muted-foreground py-4 text-center text-sm italic">
|
||||
{t(
|
||||
"pages.config.routing.no_tiers",
|
||||
"No routing tiers defined. Add a tier to configure routing.",
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{tiers.map((tier, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="bg-muted/20 flex items-end gap-3 rounded-md border p-3"
|
||||
>
|
||||
<div className="flex-1 space-y-1">
|
||||
<Label className="text-xs">
|
||||
{t("pages.config.routing.model", "Model Name")}
|
||||
</Label>
|
||||
<Input
|
||||
value={tier.model}
|
||||
onChange={(e) =>
|
||||
handleTierChange(index, "model", e.target.value)
|
||||
}
|
||||
placeholder="e.g. gpt-4o-mini"
|
||||
className="h-8"
|
||||
/>
|
||||
</div>
|
||||
<div className="w-24 space-y-1">
|
||||
<Label className="text-xs">
|
||||
{t("pages.config.routing.threshold", "Threshold")}
|
||||
</Label>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
max="1"
|
||||
value={tier.threshold}
|
||||
onChange={(e) =>
|
||||
handleTierChange(
|
||||
index,
|
||||
"threshold",
|
||||
parseFloat(e.target.value) || 0,
|
||||
)
|
||||
}
|
||||
className="h-8"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-destructive h-8 w-8"
|
||||
onClick={() => handleRemoveTier(index)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -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<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
(key: keyof AddForm) =>
|
||||
(e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
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 && (
|
||||
<p className="text-destructive text-xs">{fieldErrors.extraHeaders}</p>
|
||||
<p className="text-destructive text-xs">
|
||||
{fieldErrors.extraHeaders}
|
||||
</p>
|
||||
)}
|
||||
</Field>
|
||||
<Field
|
||||
label={t("models.field.extraBody")}
|
||||
hint={t("models.field.extraBodyHint")}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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<HTMLInputElement | HTMLTextAreaElement>) =>
|
||||
(key: keyof EditForm) =>
|
||||
(e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) =>
|
||||
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"}'
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
label={t("models.field.extraBody")}
|
||||
hint={t("models.field.extraBodyHint")}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -211,7 +211,7 @@
|
|||
"maxTokensField": "Max Tokens Field",
|
||||
"maxTokensFieldHint": "Override the request field name for max tokens, e.g. max_completion_tokens.",
|
||||
"extraHeaders": "Extra Headers",
|
||||
"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\"}",
|
||||
"extraBody": "Extra Body",
|
||||
"extraBodyHint": "Additional JSON fields to inject into the request body, e.g. {\"reasoning_split\": true}."
|
||||
},
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue