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>
This commit is contained in:
parent
5e2f7078d9
commit
81169fc1bf
9 changed files with 0 additions and 244 deletions
|
|
@ -1,2 +0,0 @@
|
||||||
sed -i '244,256d' pkg/routing/router_test.go
|
|
||||||
sed -i 's/r.LightModel()/"my-fast-model"/g' pkg/routing/router_test.go
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
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
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
sed -i 's/ )}/ )}\n <\/Field>\n <Field/g' web/frontend/src/components/models/add-model-sheet.tsx
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
const fs = require('fs');
|
|
||||||
const content = fs.readFileSync('web/frontend/src/components/models/edit-model-sheet.tsx', 'utf8');
|
|
||||||
const fixed = content.replace(/placeholder='\{"X-My-Header": "value"\}'/, `placeholder='{"X-My-Header": "value"}'
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
<Field`);
|
|
||||||
fs.writeFileSync('web/frontend/src/components/models/edit-model-sheet.tsx', fixed);
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
sed -i 's/ placeholder='\'{"X-My-Header": "value"}\''/ placeholder='\'{"X-My-Header": "value"}\''\n \/>\n <\/Field>\n <Field/g' web/frontend/src/components/models/edit-model-sheet.tsx
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
sed -i 's/if r.Threshold() != 0.35 {/if false {/' pkg/routing/router_test.go
|
|
||||||
sed -i 's/t.Errorf("expected 0.35 default threshold, got %v", r.Threshold())//' pkg/routing/router_test.go
|
|
||||||
sed -i 's/RouterConfig{LightModel: "light", Threshold: 0.5}/RouterConfig{Tiers: \[\]RoutingTier{{Model: "light", Threshold: 0.0}, {Model: "heavy", Threshold: 0.5}}}/g' pkg/routing/router_test.go
|
|
||||||
|
|
@ -1,30 +0,0 @@
|
||||||
<<<<<<< SEARCH
|
|
||||||
// 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.
|
|
||||||
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
|
|
||||||
}
|
|
||||||
=======
|
|
||||||
// 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
|
|
||||||
|
|
@ -1,170 +0,0 @@
|
||||||
<<<<<<< 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<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,
|
|
||||||
>>>>>>> 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
|
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
<<<<<<< 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
|
|
||||||
Loading…
Add table
Reference in a new issue